diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 671f480..0000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,160 +0,0 @@ -name: CD - -on: - workflow_dispatch: - inputs: - bump: - description: Stable semantic version increment - required: true - type: choice - options: - - patch - - minor - - major - -concurrency: - group: npm-release-main - cancel-in-progress: false - -jobs: - release: - name: Publish release - runs-on: ubuntu-latest - permissions: - contents: write - id-token: write - steps: - - name: Check out main - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - fetch-tags: true - - - name: Validate dispatch from current main - id: base - env: - DISPATCH_REF: ${{ github.ref }} - run: | - set -euo pipefail - - test "$DISPATCH_REF" = "refs/heads/main" - - git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' - - base_sha="$(git rev-parse HEAD)" - remote_main_sha="$(git rev-parse refs/remotes/origin/main)" - - test "$base_sha" = "$remote_main_sha" - - printf 'base_sha=%s\n' "$base_sha" >> "$GITHUB_OUTPUT" - - - name: Set up Node.js - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org/ - - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: 1.3.5 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Prepare release metadata - id: metadata - env: - BUMP: ${{ inputs.bump }} - run: | - set -euo pipefail - release_date="$(date -u +%F)" - git tag --list | bun scripts/release-metadata.ts \ - --package-json package.json \ - --changelog CHANGELOG.md \ - --bump "$BUMP" \ - --date "$release_date" - - - name: Run release checks - run: bun run release:check - - - name: Pack release - id: pack - run: | - set -euo pipefail - package_directory="$RUNNER_TEMP/opencode-vpet-package" - mkdir -p "$package_directory" - - tarball_name="$(npm pack --loglevel silent --ignore-scripts \ - --pack-destination "$package_directory")" - - test -f "$package_directory/$tarball_name" - - printf 'tarball_name=%s\n' "$tarball_name" >> "$GITHUB_OUTPUT" - - - name: Set up Git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Commit and tag release - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - run: | - set -euo pipefail - - git add package.json CHANGELOG.md - - git commit -m "chore(release): v$RELEASE_VERSION" - - git tag -a "v$RELEASE_VERSION" -m "v$RELEASE_VERSION" - - - name: Commit next version - env: - NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} - run: | - set -euo pipefail - - bun scripts/release-metadata.ts set-package-version package.json "$NEXT_DEV_VERSION" - - git add package.json - - git commit -m "chore: bump v$NEXT_DEV_VERSION" - - - name: Push release - env: - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - run: git push --atomic origin HEAD:refs/heads/main "refs/tags/v$RELEASE_VERSION" - - - name: Publish release (npm) - env: - TARBALL: ${{ runner.temp }}/opencode-vpet-package/${{ steps.pack.outputs.tarball_name }} - run: | - set -euo pipefail - - npm_version="$(npm --version)" - - NPM_VERSION="$npm_version" node --input-type=module <<'NODE' - const [major, minor, patch] = process.env.NPM_VERSION.split(".").map(Number); - if ( - major < 11 || - (major === 11 && (minor < 5 || (minor === 5 && patch < 1))) - ) { - throw new Error(`npm ${process.env.NPM_VERSION} is below required version 11.5.1`); - } - NODE - - npm publish "$TARBALL" \ - --access public --provenance --ignore-scripts - - - name: Publish release (GitHub) - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} - TARBALL: ${{ runner.temp }}/opencode-vpet-package/${{ steps.pack.outputs.tarball_name }} - run: | - gh release create "v$RELEASE_VERSION" \ - "$TARBALL" \ - --title "v$RELEASE_VERSION" \ - --generate-notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8457600..e25aaf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,7 @@ -name: CI +name: Validate monorepo on: pull_request: - push: - branches: - - main permissions: contents: read @@ -15,35 +12,38 @@ concurrency: jobs: validate: + name: Check, test and package runs-on: ubuntu-latest steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24 + node-version: 24.11.0 + cache: npm - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.5 - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci - - name: Format - run: bun run format:check + - name: Check formatting, lint and types + run: npm run check - - name: Lint - run: bun run lint + - name: Run unit tests + run: npm run test - - name: CI checks - run: bun run check + - name: Run persistence tests + run: npm run test:persistence - - name: Tests - run: bun test + - name: Inspect npm package + run: npm pack --workspace @jcendal/opencode-digital-pet --dry-run --json - - name: Prepack - run: bun run prepack - - - name: Pack - run: npm pack --dry-run --json + - name: Package Cursor VSIX + working-directory: packages/cursor-digital-pet + run: npm run package diff --git a/.github/workflows/release-after-merge.yml b/.github/workflows/release-after-merge.yml new file mode 100644 index 0000000..db089db --- /dev/null +++ b/.github/workflows/release-after-merge.yml @@ -0,0 +1,85 @@ +name: Release after PR merge +run-name: Release merged PR #${{ github.event.pull_request.number }} + +on: + pull_request_target: + types: + - closed + branches: + - main + +permissions: + contents: read + pull-requests: read + actions: write + +concurrency: + group: digital-pet-auto-release + cancel-in-progress: false + queue: max + +jobs: + plan: + name: Select packages and versions + if: github.repository == 'jcendal/digital-pet' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + release_opencode: ${{ steps.plan.outputs.release_opencode }} + opencode_bump: ${{ steps.plan.outputs.opencode_bump }} + release_cursor: ${{ steps.plan.outputs.release_cursor }} + cursor_bump: ${{ steps.plan.outputs.cursor_bump }} + steps: + - name: Check out merged main + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + cache: npm + + - name: Install release planner dependencies + run: npm ci --ignore-scripts + + - name: Plan releases from the merged PR + id: plan + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \ + --jq '.[].filename' > "$RUNNER_TEMP/merged-pr-files.txt" + git tag --list | node --experimental-strip-types scripts/plan-merged-release.ts \ + "$RUNNER_TEMP/merged-pr-files.txt" + + publish: + name: Publish selected packages + needs: plan + if: needs.plan.outputs.release_opencode == 'true' || needs.plan.outputs.release_cursor == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out release orchestrator + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Start and monitor product releases + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_OPENCODE: ${{ needs.plan.outputs.release_opencode }} + OPENCODE_BUMP: ${{ needs.plan.outputs.opencode_bump }} + RELEASE_CURSOR: ${{ needs.plan.outputs.release_cursor }} + CURSOR_BUMP: ${{ needs.plan.outputs.cursor_bump }} + RELEASE_NOTE: ${{ github.event.pull_request.title }} + run: node --experimental-strip-types scripts/dispatch-merged-releases.ts diff --git a/.github/workflows/release-cursor.yml b/.github/workflows/release-cursor.yml new file mode 100644 index 0000000..91529a3 --- /dev/null +++ b/.github/workflows/release-cursor.yml @@ -0,0 +1,256 @@ +name: Release Cursor Digital Pet + +on: + workflow_dispatch: + inputs: + bump: + description: Version increment for a new release (ignored when retry_tag is set) + required: false + type: choice + options: + - patch + - minor + - major + retry_tag: + description: Existing Cursor release tag to retry (for example cursor-digital-pet-v0.2.0) + required: false + type: string + retry_target: + description: Destination to retry when retry_tag is set + required: false + type: choice + options: + - github + - openvsx + bootstrap_openvsx: + description: Use the first-release Open VSX PAT when retrying its tag + required: false + type: boolean + default: false + release_note: + description: Merged pull request title to use when Unreleased has no notes + required: false + type: string + +concurrency: + group: digital-pet-release-cursor + cancel-in-progress: false + queue: max + +jobs: + version: + name: Version Cursor Digital Pet + if: github.repository == 'jcendal/digital-pet' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + release_version: ${{ steps.result.outputs.release_version }} + release_tag: ${{ steps.result.outputs.release_tag }} + retry: ${{ steps.mode.outputs.retry }} + bootstrap_openvsx: ${{ steps.mode.outputs.bootstrap_openvsx }} + steps: + - name: Check out release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Validate new release or retry + id: mode + env: + DISPATCH_REF: ${{ github.ref }} + BUMP: ${{ inputs.bump }} + RETRY_TAG: ${{ inputs.retry_tag }} + RETRY_TARGET: ${{ inputs.retry_target }} + BOOTSTRAP_INPUT: ${{ inputs.bootstrap_openvsx }} + run: | + set -euo pipefail + test "$DISPATCH_REF" = "refs/heads/main" + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if [[ -n "$RETRY_TAG" ]]; then + [[ "$RETRY_TAG" =~ ^cursor-digital-pet-v[0-9]+\.[0-9]+\.[0-9]+$ ]] + case "$RETRY_TARGET" in github|openvsx) ;; *) exit 1 ;; esac + git show-ref --verify --quiet "refs/tags/$RETRY_TAG" + git merge-base --is-ancestor "$RETRY_TAG^{commit}" refs/remotes/origin/main + git checkout --detach "$RETRY_TAG" + release_version="${RETRY_TAG#cursor-digital-pet-v}" + test "$(node -p 'require("./packages/cursor-digital-pet/package.json").version')" = "$release_version" + printf 'retry=true\nrelease_version=%s\n' "$release_version" >> "$GITHUB_OUTPUT" + bootstrap_openvsx="$BOOTSTRAP_INPUT" + else + [[ "$BUMP" =~ ^(patch|minor|major)$ ]] + test "$(git rev-parse HEAD)" = "$(git rev-parse refs/remotes/origin/main)" + printf 'retry=false\n' >> "$GITHUB_OUTPUT" + if [ -z "$(git tag --list 'cursor-digital-pet-v*')" ]; then + bootstrap_openvsx=true + else + bootstrap_openvsx=false + fi + fi + printf 'bootstrap_openvsx=%s\n' "$bootstrap_openvsx" >> "$GITHUB_OUTPUT" + + - name: Verify first-release Open VSX credential + if: steps.mode.outputs.bootstrap_openvsx == 'true' && (steps.mode.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + if [ -z "$OVSX_PAT" ]; then + echo 'Configure the temporary OVSX_PAT repository secret for the first Open VSX release.' >&2 + exit 1 + fi + + - name: Set up Node.js + if: steps.mode.outputs.retry != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Set up Bun + if: steps.mode.outputs.retry != 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + if: steps.mode.outputs.retry != 'true' + run: npm ci + + - name: Prepare release metadata + if: steps.mode.outputs.retry != 'true' + id: metadata + env: + BUMP: ${{ inputs.bump }} + RELEASE_NOTE: ${{ inputs.release_note }} + run: | + set -euo pipefail + release_date="$(date -u +%F)" + git tag --list | node --experimental-strip-types scripts/release-metadata.ts \ + --package-json packages/cursor-digital-pet/package.json \ + --changelog packages/cursor-digital-pet/CHANGELOG.md \ + --tag-prefix cursor-digital-pet-v \ + --bump "$BUMP" \ + --date "$release_date" \ + --release-note "$RELEASE_NOTE" + + - name: Update lockfile + if: steps.mode.outputs.retry != 'true' + run: npm install --package-lock-only --ignore-scripts + + - name: Run release checks + if: steps.mode.outputs.retry != 'true' + run: | + npm run check --workspace cursor-digital-pet + npm run test --workspace cursor-digital-pet + + - name: Set up Git + if: steps.mode.outputs.retry != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and tag release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + git add packages/cursor-digital-pet/package.json packages/cursor-digital-pet/CHANGELOG.md package-lock.json + git commit -m "chore(cursor release): v$RELEASE_VERSION" + git tag -a "cursor-digital-pet-v$RELEASE_VERSION" -m "Cursor Digital Pet v$RELEASE_VERSION" + + - name: Commit next version + if: steps.mode.outputs.retry != 'true' + env: + NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} + run: | + set -euo pipefail + node --experimental-strip-types scripts/release-metadata.ts set-package-version packages/cursor-digital-pet/package.json "$NEXT_DEV_VERSION" + npm install --package-lock-only --ignore-scripts + git add packages/cursor-digital-pet/package.json package-lock.json + git commit -m "chore(cursor): bump v$NEXT_DEV_VERSION" + + - name: Push release + if: steps.mode.outputs.retry != 'true' + run: node --experimental-strip-types scripts/push-release.ts "cursor-digital-pet-v${{ steps.metadata.outputs.release_version }}" + + - name: Export release coordinates + id: result + env: + RETRY: ${{ steps.mode.outputs.retry }} + RETRY_VERSION: ${{ steps.mode.outputs.release_version }} + NEW_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + if [ "$RETRY" = true ]; then + version="$RETRY_VERSION" + else + version="$NEW_VERSION" + fi + test -n "$version" + printf 'release_version=%s\nrelease_tag=cursor-digital-pet-v%s\n' "$version" "$version" >> "$GITHUB_OUTPUT" + + publish: + name: Publish Cursor Digital Pet + needs: version + if: github.repository == 'jcendal/digital-pet' && needs.version.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Check out release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.version.outputs.release_tag }} + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Build cursor-digital-pet + run: npm run build --workspace cursor-digital-pet + + - name: Pack VSIX + id: vsix + run: | + set -euo pipefail + vsix_path="$RUNNER_TEMP/cursor-digital-pet.vsix" + cd packages/cursor-digital-pet + npm run package -- -o "$vsix_path" + printf 'vsix_path=%s\n' "$vsix_path" >> "$GITHUB_OUTPUT" + + - name: Publish release (GitHub) + if: needs.version.outputs.retry != 'true' || inputs.retry_target == 'github' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.version.outputs.release_version }} + run: | + node --experimental-strip-types scripts/release-metadata.ts release-notes packages/cursor-digital-pet/CHANGELOG.md "$RELEASE_VERSION" > "$RUNNER_TEMP/cursor-release-notes.md" + if gh release view "cursor-digital-pet-v$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "cursor-digital-pet-v$RELEASE_VERSION" \ + "${{ steps.vsix.outputs.vsix_path }}" --clobber + else + gh release create "cursor-digital-pet-v$RELEASE_VERSION" \ + "${{ steps.vsix.outputs.vsix_path }}" \ + --title "Cursor Digital Pet v$RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/cursor-release-notes.md" + fi + + - name: Publish release (Open VSX via OIDC) + if: needs.version.outputs.bootstrap_openvsx != 'true' && (needs.version.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + run: npx ovsx publish "${{ steps.vsix.outputs.vsix_path }}" --trusted-publishing + + - name: Publish first release (Open VSX via PAT) + if: needs.version.outputs.bootstrap_openvsx == 'true' && (needs.version.outputs.retry != 'true' || inputs.retry_target == 'openvsx') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx ovsx publish "${{ steps.vsix.outputs.vsix_path }}" diff --git a/.github/workflows/release-opencode.yml b/.github/workflows/release-opencode.yml new file mode 100644 index 0000000..76b128a --- /dev/null +++ b/.github/workflows/release-opencode.yml @@ -0,0 +1,239 @@ +name: Release OpenCode Digital Pet + +on: + workflow_dispatch: + inputs: + bump: + description: Version increment for a new release (ignored when retry_tag is set) + required: false + type: choice + options: + - patch + - minor + - major + retry_tag: + description: Existing OpenCode release tag to retry (for example opencode-digital-pet-v0.2.0) + required: false + type: string + retry_target: + description: Registry to retry when retry_tag is set + required: false + type: choice + options: + - npm + - github + release_note: + description: Merged pull request title to use when Unreleased has no notes + required: false + type: string + +concurrency: + group: digital-pet-release-opencode + cancel-in-progress: false + queue: max + +jobs: + version: + name: Version OpenCode Digital Pet + if: github.repository == 'jcendal/digital-pet' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + release_version: ${{ steps.result.outputs.release_version }} + release_tag: ${{ steps.result.outputs.release_tag }} + retry: ${{ steps.mode.outputs.retry }} + steps: + - name: Check out release source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + fetch-depth: 0 + fetch-tags: true + + - name: Validate new release or retry + id: mode + env: + DISPATCH_REF: ${{ github.ref }} + BUMP: ${{ inputs.bump }} + RETRY_TAG: ${{ inputs.retry_tag }} + RETRY_TARGET: ${{ inputs.retry_target }} + run: | + set -euo pipefail + test "$DISPATCH_REF" = "refs/heads/main" + git fetch --no-tags origin '+refs/heads/main:refs/remotes/origin/main' + if [[ -n "$RETRY_TAG" ]]; then + [[ "$RETRY_TAG" =~ ^opencode-digital-pet-v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$RETRY_TARGET" = "npm" || test "$RETRY_TARGET" = "github" + git show-ref --verify --quiet "refs/tags/$RETRY_TAG" + git merge-base --is-ancestor "$RETRY_TAG^{commit}" refs/remotes/origin/main + git checkout --detach "$RETRY_TAG" + release_version="${RETRY_TAG#opencode-digital-pet-v}" + test "$(node -p 'require("./packages/opencode-digital-pet/package.json").version')" = "$release_version" + printf 'retry=true\nrelease_version=%s\n' "$release_version" >> "$GITHUB_OUTPUT" + else + [[ "$BUMP" =~ ^(patch|minor|major)$ ]] + test "$(git rev-parse HEAD)" = "$(git rev-parse refs/remotes/origin/main)" + printf 'retry=false\n' >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node.js + if: steps.mode.outputs.retry != 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Set up Bun + if: steps.mode.outputs.retry != 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.5 + + - name: Install dependencies + if: steps.mode.outputs.retry != 'true' + run: npm ci + + - name: Prepare release metadata + if: steps.mode.outputs.retry != 'true' + id: metadata + env: + BUMP: ${{ inputs.bump }} + RELEASE_NOTE: ${{ inputs.release_note }} + run: | + set -euo pipefail + release_date="$(date -u +%F)" + git tag --list | node --experimental-strip-types scripts/release-metadata.ts \ + --package-json packages/opencode-digital-pet/package.json \ + --changelog packages/opencode-digital-pet/CHANGELOG.md \ + --tag-prefix opencode-digital-pet-v \ + --legacy-tag-prefix v \ + --bump "$BUMP" \ + --date "$release_date" \ + --release-note "$RELEASE_NOTE" + + - name: Update lockfile + if: steps.mode.outputs.retry != 'true' + run: npm install --package-lock-only --ignore-scripts + + - name: Run release checks + if: steps.mode.outputs.retry != 'true' + run: npm run release:check + + - name: Set up Git + if: steps.mode.outputs.retry != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Commit and tag release + if: steps.mode.outputs.retry != 'true' + env: + RELEASE_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + git add packages/opencode-digital-pet/package.json packages/opencode-digital-pet/CHANGELOG.md package-lock.json + git commit -m "chore(opencode release): v$RELEASE_VERSION" + git tag -a "opencode-digital-pet-v$RELEASE_VERSION" -m "OpenCode Digital Pet v$RELEASE_VERSION" + + - name: Commit next version + if: steps.mode.outputs.retry != 'true' + env: + NEXT_DEV_VERSION: ${{ steps.metadata.outputs.next_dev_version }} + run: | + set -euo pipefail + node --experimental-strip-types scripts/release-metadata.ts set-package-version packages/opencode-digital-pet/package.json "$NEXT_DEV_VERSION" + npm install --package-lock-only --ignore-scripts + git add packages/opencode-digital-pet/package.json package-lock.json + git commit -m "chore(opencode): bump v$NEXT_DEV_VERSION" + + - name: Push release + if: steps.mode.outputs.retry != 'true' + run: node --experimental-strip-types scripts/push-release.ts "opencode-digital-pet-v${{ steps.metadata.outputs.release_version }}" + + - name: Export release coordinates + id: result + env: + RETRY: ${{ steps.mode.outputs.retry }} + RETRY_VERSION: ${{ steps.mode.outputs.release_version }} + NEW_VERSION: ${{ steps.metadata.outputs.release_version }} + run: | + set -euo pipefail + if [ "$RETRY" = true ]; then + version="$RETRY_VERSION" + else + version="$NEW_VERSION" + fi + test -n "$version" + printf 'release_version=%s\nrelease_tag=opencode-digital-pet-v%s\n' "$version" "$version" >> "$GITHUB_OUTPUT" + + publish: + name: Publish OpenCode Digital Pet + needs: version + if: github.repository == 'jcendal/digital-pet' && needs.version.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Check out release tag + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ needs.version.outputs.release_tag }} + fetch-depth: 1 + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.11.0 + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Pack release + id: pack + run: | + set -euo pipefail + package_directory="$RUNNER_TEMP/opencode-digital-pet-package" + mkdir -p "$package_directory" + tarball_name="$(npm pack --loglevel silent \ + --pack-destination "$package_directory" \ + --workspace @jcendal/opencode-digital-pet)" + test -f "$package_directory/$tarball_name" + printf 'tarball_name=%s\n' "$tarball_name" >> "$GITHUB_OUTPUT" + + - name: Publish release (npm) + if: needs.version.outputs.retry != 'true' || inputs.retry_target == 'npm' + env: + TARBALL: ${{ runner.temp }}/opencode-digital-pet-package/${{ steps.pack.outputs.tarball_name }} + run: | + set -euo pipefail + npm_version="$(npm --version)" + NPM_VERSION="$npm_version" node --input-type=module <<'NODE' + const [major, minor, patch] = process.env.NPM_VERSION.split(".").map(Number); + if ( + major < 11 || + (major === 11 && (minor < 5 || (minor === 5 && patch < 1))) + ) { + throw new Error(`npm ${process.env.NPM_VERSION} is below required version 11.5.1`); + } + NODE + npm publish "$TARBALL" --access public --provenance --ignore-scripts + + - name: Publish release (GitHub) + if: needs.version.outputs.retry != 'true' || inputs.retry_target == 'github' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.version.outputs.release_version }} + TARBALL: ${{ runner.temp }}/opencode-digital-pet-package/${{ steps.pack.outputs.tarball_name }} + run: | + node --experimental-strip-types scripts/release-metadata.ts release-notes packages/opencode-digital-pet/CHANGELOG.md "$RELEASE_VERSION" > "$RUNNER_TEMP/opencode-release-notes.md" + if gh release view "opencode-digital-pet-v$RELEASE_VERSION" >/dev/null 2>&1; then + gh release upload "opencode-digital-pet-v$RELEASE_VERSION" "$TARBALL" --clobber + else + gh release create "opencode-digital-pet-v$RELEASE_VERSION" \ + "$TARBALL" \ + --title "OpenCode Digital Pet v$RELEASE_VERSION" \ + --notes-file "$RUNNER_TEMP/opencode-release-notes.md" + fi diff --git a/.gitignore b/.gitignore index 4af8649..d7be5bb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build and test output dist/ +packages/opencode-digital-pet/build/ coverage/ .turbo/ *.tsbuildinfo @@ -15,6 +16,29 @@ Thumbs.db # Coding agents .omo/ +# Local configuration and credentials +.env* +!.env.example +.npmrc +.pypirc +.netrc +*.pem +*.key +*.p12 + +# Local user data and logs +.cursor/ +.opencode/ +*.db +*.db-* +*.sqlite +*.sqlite3 +*.jsonl +*.log + # Release workflow artifacts +release-artifacts/ *.tgz +*.vsix npm-pack.json +.package.json.publish.bak diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..2438910 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,6 @@ +# Digital Pet + +Maintainer: Jorge Cendal Prego (https://github.com/jcendal) +Original author and contributor: Sergio Bugallo Enjamio (https://github.com/sbugallo) + +The original Apache 2.0 copyright notice remains in LICENSE. diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d12c6..4d8e1ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,39 +1,6 @@ -# Changelog +# Changelogs -All notable changes to this project will be documented in this file. +The published products have independent release histories: -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.1.1] - 2026-08-30 - -### Fixed - -- Exported the OpenCode server plugin entrypoint so VPet commands and the sidebar load correctly. -- Updated `init` and `update` to delegate to OpenCode's native global plugin installer with the - installed exact package version, avoiding stale bare or `@latest` cache reuse. - -## [0.1.0] - 2026-08-30 - -### Added - -- Server and TUI plugins that add a Digimon virtual pet to the OpenCode sidebar. -- Progression from completed assistant-message token usage, including reconciliation of completed - usage from concurrent sessions. -- Toast notifications for spawning, progression controls, and Digimon evolutions. -- A 650-Digimon catalog spanning eight evolution stages, with evolution paths and Japanese and - English names. -- Partner generations that can be spawned, evolved, frozen, unfrozen, or set to a catalog ID. -- Sidebar progress and status display, including frozen and manually set partner states, with - animated walking, action, and sleep behavior. -- Dex and History dialogs for browsing discovered Digimon and current or retired partner - generations, with links to external encyclopedia entries. -- `/vpet-spawn`, `/vpet-freeze`, `/vpet-unfreeze`, `/vpet-set `, `/vpet-dex`, and - `/vpet-history` commands. -- SQLite persistence for partners, progression events, usage receipts, trainer totals, and control - state. -- Global settings for language, evolution thresholds, and notifications. -- `npx @sbugallo/opencode-vpet init` and `npx @sbugallo/opencode-vpet update` commands that register - server and TUI plugins while preserving unrelated JSON and JSONC configuration. +- [OpenCode Digital Pet](packages/opencode-digital-pet/CHANGELOG.md) +- [Cursor Digital Pet](packages/cursor-digital-pet/CHANGELOG.md) diff --git a/README.md b/README.md index 8632212..32d9d25 100644 --- a/README.md +++ b/README.md @@ -1,832 +1,73 @@
- OpenCode VPet logo -

opencode-vpet

-

- A Digimon virtual pet for OpenCode that evolves with usage. -

-

- About | - Evolution | - Quick start | - Commands | - Settings | - Storage | - Partner list | - License | - Attributions - -

+ Digital Pet logo +

Digital Pet

+

A Digimon virtual pet that evolves with AI token usage in Cursor and OpenCode, with a local web companion.

---- - -## About - -
- OpenCode VPet Overview -
- -`@sbugallo/opencode-vpet` is an OpenCode v1 plugin that adds a Digimon virtual pet to your OpenCode TUI -sidebar. Your Digimon evolves as you use OpenCode, and you can browse your discoveries and -partner history. - -The plugin support multiple concurrent sessions, so every token counts to evolve your partner. - -There are more than 600 Digimon to discover, and the plugin is designed to be extensible so that new Digimon can be added in future releases. - -## Evolution +Digital Pet has a Cursor extension, an OpenCode plugin, and a local web companion. They read the same SQLite database, so your partner, Dex discoveries, and generation history carry across the three views. Cursor and OpenCode record token usage; the web companion displays the shared progress. -Your active partner gains experience from the tokens reported for completed assistant -messages in OpenCode. This includes input, output, reasoning, and cache tokens. -Progress is shared across concurrent OpenCode sessions, so completed work in each -session contributes to the same partner. +## Choose your app -Each stage has a configurable experience threshold. When your partner reaches the -threshold for its current stage, it evolves and its experience gauge resets for the -next stage. Use `/vpet-freeze` to pause partner progression; usage while frozen is -not applied to your partner. - -The bundled evolution lines were extracted from the original V-Pets. When a Digimon -reaches an evolution threshold, the plugin randomly chooses one of the evolution -options in that Digimon's line. - -## Quick start - -Requirements: -- OpenCode `>=1.18.10` -- Node `>=20` to run `npx` -- Bun `>=1.3.5` only for local development - - -1. Install both plugins: +| App | What it does | Get started | +| --- | --- | --- | +| [Cursor Digital Pet](packages/cursor-digital-pet/README.md) | Shows your partner in the Explorer sidebar and tracks Cursor Agent usage through hooks, with an API fallback. | [Install from Open VSX](https://open-vsx.org/extension/jcendal/cursor-digital-pet) or use a VSIX from [GitHub Releases](https://github.com/jcendal/digital-pet/releases). Follow the [Cursor setup guide](packages/cursor-digital-pet/README.md#-usage) to install hooks and hatch a partner. | +| [OpenCode Digital Pet](packages/opencode-digital-pet/README.md) | Adds a TUI sidebar and `/digital-pet-*` commands; completed assistant messages provide experience. | Run `npx @jcendal/opencode-digital-pet init`, restart OpenCode, then run `/digital-pet-spawn`. See the [OpenCode guide](packages/opencode-digital-pet/README.md#quick-start). | +| [Web Digital Pet](packages/web-digital-pet/README.md) | Shows the animated partner, Digidex, and generation history in a phone-width browser layout. | Run `npm run web`, then open `http://localhost:4173`. | - ```sh - npx @sbugallo/opencode-vpet init - ``` +The [illustrative OpenCode preview](_images/digital-pet-overview.png) shows the partner, Dex, and history views. Each app's README describes its own commands, requirements, settings, and storage behavior. - The CLI delegates to `opencode plugin @sbugallo/opencode-vpet@ --global --force`. - It uses the package's installed exact version so OpenCode uses a version-specific cache key rather - than reusing a stale bare or `@latest` cache entry. `--force` replaces matching plugin - configuration registrations. +## Shared progress and migration -2. Restart OpenCode when the command finishes. +All three apps resolve the same `pet.db` location by default: -3. In OpenCode, run `/vpet-spawn` to hatch a partner. +| Platform | Database | +| --- | --- | +| macOS | `~/Library/Application Support/opencode-digital-pet/pet.db` | +| Linux / WSL | `~/.local/share/opencode-digital-pet/pet.db` | +| Windows | `%APPDATA%\opencode-digital-pet\pet.db` | -After that, use `/vpet-dex` to browse discoveries and `/vpet-history` to browse current and retired generations. +If the new database does not exist but an older `opencode-vpet/pet.db` does, both apps continue using that older database. OpenCode also continues reading an older `opencode-vpet.json` settings file until a new `opencode-digital-pet.json` is created. ---- +If you previously installed `@sbugallo/opencode-vpet` or unscoped `opencode-vpet`, remove its OpenCode registration to avoid loading both plugins. The [OpenCode guide](packages/opencode-digital-pet/README.md#update) has the update command; the [Cursor guide](packages/cursor-digital-pet/README.md#-storage) covers its database override. -## Commands +## Repository layout -| Command | Description | +| Workspace | Role | | --- | --- | -| `/vpet-spawn` | Spawns a new egg. | -| `/vpet-freeze` | Pauses partner progression. | -| `/vpet-unfreeze` | Resumes partner progression. | -| `/vpet-set ` | Overrides the partner with the specified catalog ID. | -| `/vpet-dex` | Opens the partner Dex. | -| `/vpet-history` | Opens the partner History. | +| [`packages/cursor-digital-pet`](packages/cursor-digital-pet) | Cursor extension, packaged as a VSIX | +| [`packages/opencode-digital-pet`](packages/opencode-digital-pet) | `@jcendal/opencode-digital-pet`, npm plugin and CLI | +| [`packages/digital-pet-fields`](packages/digital-pet-fields) | Fields, regions, habitat rosters, and scenery assets; private workspace | +| [`packages/digital-pet-core`](packages/digital-pet-core) | Shared catalog, evolution rules, use cases, and SQLite schema; private workspace | +| [`packages/digital-pet-animation`](packages/digital-pet-animation) | Shared animation logic; private workspace | +| [`packages/digital-pet-webviews`](packages/digital-pet-webviews) | Shared browser interface for Cursor and the web companion; private workspace | +| [`packages/web-digital-pet`](packages/web-digital-pet) | Local browser server and read-only SQLite adapter | -> [!NOTE] -> Using `/vpet-set` does not count for dex completion. +The root workspace is private. The shared packages are bundled into the products and are not published separately. Cursor and OpenCode have [separate changelogs](CHANGELOG.md) and release versions. See [monorepo documentation](docs/monorepo.md) for development tools and release details. -## Update +## Development -To update the plugin, run: +Use Node `24.11.0`, npm `11.6.1`, and Bun `1.3.5` (the versions declared by the workspace). npm installs dependencies and runs workspace scripts; Bun runs the tests and the OpenCode plugin at runtime. Builds use Node and esbuild. ```sh -npx @sbugallo/opencode-vpet update +npm ci +npm run check +npm run test +npm run test:persistence +npm run build ``` -Like `init`, `update` delegates to OpenCode's native global plugin installer with the installed exact -package version. If a legacy unscoped `opencode-vpet` registration is present, remove it manually. - -## Settings - -Settings are stored in `opencode-vpet.json` and control naming, notifications, and evolution -thresholds. +To create local distributables: -The location of the settings file depends on your operating system: - -- Linux, WSL, and macOS: `~/.config/opencode-vpet.json` -- Windows: `%APPDATA%\opencode-vpet.json`, or `%USERPROFILE%\AppData\Roaming\opencode-vpet.json` - when `APPDATA` isn't set. - - -Available settings are: - -| Setting | Description | -| --- | --- | -| `notifications` | Enables or disables notifications. | -| `language` | Sets the naming convention. Valid values are `jp` (Japanese, e.g., Omegamon, Child, Adult...) and `en` (English, e.g., Omnimon, Rookie, Champion...). | -| `stageThresholds` | Defines the experience thresholds for each evolution stage. | - -Default values are: - -```json -{ - "notifications": true, - "language": "jp", - "stageThresholds": { - "egg": 500000, - "babyI": 1000000, - "babyII": 2000000, - "child": 4000000, - "adult": 7500000, - "perfect": 12500000, - "ultimate": 20000000, - "superUltimate": 30000000 - } -} +```sh +npm run pack:opencode +npm run package --workspace cursor-digital-pet ``` -## Storage - -To persist your partner, history, and control state, VPet uses a SQLite database named `pet.db`. The location of the database depends on your operating system: - -- Linux, WSL: `~/.local/share/opencode-vpet/pet.db` -- macOS: `~/Library/Application Support/opencode-vpet/pet.db` -- Windows: `%APPDATA%\opencode-vpet\pet.db`, or `%USERPROFILE%\AppData\Roaming\opencode-vpet\pet.db` - when `APPDATA` isn't set. - ---- - -## Partner list - -| ID | Japanese name | English name | -| --- | --- | --- | -| 0-001 | Digitama | Digiegg | -| 1-001 | Algomon | Argomon | -| 1-002 | Botamon | Botamon | -| 1-003 | Chicomon | Chibomon | -| 1-004 | Choromon | Choromon | -| 1-005 | Cocomon | Conomon | -| 1-006 | Cotsucomon | Cotsucomon | -| 1-007 | Dodomon | Dodomon | -| 1-008 | Fufumon | Fufumon | -| 1-009 | Kiimon | Keemon | -| 1-010 | Ketomon | Ketomon | -| 1-011 | Kuramon | Kuramon | -| 1-012 | Mokumon | Mokumon | -| 1-013 | Nyokimon | Nyokimon | -| 1-014 | Bubbmon | Pabumon | -| 1-015 | Petitmon | Petitmon | -| 1-016 | Pitchmon | Pichimon | -| 1-017 | Poyomon | Poyomon | -| 1-018 | Punimon | Punimon | -| 1-019 | Pururumon | Pururumon | -| 1-020 | Puttimon | Puttimon | -| 1-021 | Sakumon | Sakumon | -| 1-022 | Tsubumon | Tsubumon | -| 1-023 | Yukimibotamon | YukimiBotamon | -| 1-024 | Yuramon | Yuramon | -| 1-025 | Zerimon | Zerimon | -| 1-026 | Zurumon | Zurumon | -| 1-027 | Chibickmon | Chibickmon | -| 1-028 | Dokimon | Dokimon | -| 1-029 | Jyarimon | Jyarimon | -| 1-030 | Relemon | Relemon | -| 1-031 | Sunamon | Sandmon | -| 2-001 | Algomon | Argomon | -| 2-002 | Babydmon | Bebydomon | -| 2-003 | Budmon | Budmon | -| 2-004 | Pukamon | Bukamon | -| 2-005 | PetiMeramon | DemiMeramon | -| 2-006 | Chibimon | DemiVeemon | -| 2-007 | Dorimon | Dorimon | -| 2-008 | Gummymon | Gummymon | -| 2-009 | Hopmon | Hopmon | -| 2-010 | Kakkinmon | Kakkinmon | -| 2-011 | Capromon | Kapurimon | -| 2-012 | Chocomon | Kokomon | -| 2-013 | Koromon | Koromon | -| 2-014 | Kyokyomon | Kyokyomon | -| 2-015 | Mochimon | Motimon | -| 2-016 | Nyaromon | Nyaromon | -| 2-017 | Pagumon | Pagumon | -| 2-018 | Poromon | Poromon | -| 2-019 | Sakuttomon | Sakuttomon | -| 2-020 | Tanemon | Tanemon | -| 2-021 | Tokomon | Tokomon | -| 2-022 | Tokomon X | Tokomon X | -| 2-023 | Tsumemon | Tsumemon | -| 2-024 | Tunomon | Tsunomon | -| 2-025 | Upamon | Upamon | -| 2-026 | Wanyamon | Wanyamon | -| 2-027 | Yarmon | Yaamon | -| 2-028 | Pyocomon | Yokomon | -| 2-029 | Bibimon | Bibimon | -| 2-030 | Gigimon | Gigimon | -| 2-031 | Goromon | Tumblemon | -| 2-032 | Hiyarimon | Hiyarimon | -| 2-033 | Minomon | Minomon | -| 2-034 | Moonmon | Moonmon | -| 2-035 | Pickmon | Pickmon | -| 2-036 | Pokomon | Viximon | -| 2-037 | Sunmon | Sunmon | -| 3-001 | Agumon | Agumon | -| 3-002 | Agumon (Black) X | Agumon (Black) X | -| 3-003 | Agumon Hakase | Agumon Expert | -| 3-004 | Agumon X | Agumon X | -| 3-005 | Algomon | Argomon | -| 3-006 | Armadimon | Armadillomon | -| 3-007 | Betamon | Betamon | -| 3-008 | Piyomon | Biyomon | -| 3-009 | Blucomon | Bulucomon | -| 3-010 | BushiAgumon | BushiAgumon | -| 3-011 | Candmon | Candlemon | -| 3-012 | Coronamon | Coronamon | -| 3-013 | Ganimon | Crabmon | -| 3-014 | PicoDevimon | DemiDevimon | -| 3-015 | DORUmon | Dorumon | -| 3-016 | Dracomon | Dracomon | -| 3-017 | Dracomon X | Dracomon X | -| 3-018 | Duskmon | Duskmon | -| 3-019 | Elecmon | Elecmon | -| 3-020 | Falcomon | Falcomon | -| 3-021 | Floramon | Floramon | -| 3-022 | Gabumon | Gabumon | -| 3-023 | Gabumon X | Gabumon X | -| 3-024 | Gaomon | Gaomon | -| 3-025 | Tailmon | Gatomon | -| 3-026 | Gazimon | Gazimon | -| 3-027 | Ghostmon | Ghostmon | -| 3-028 | Gizamon | Gizamon | -| 3-029 | Gomamon | Gomamon | -| 3-030 | Gomamon X | Gomamon X | -| 3-031 | Gottsumon | Gotsumon | -| 3-032 | Guilmon | Guilmon | -| 3-033 | Hagurumon | Hagurumon | -| 3-034 | Hawkmon | Hawkmon | -| 3-035 | Herissmon | Herissmon | -| 3-036 | Hackmon | Huckmon | -| 3-037 | Impmon | Impmon | -| 3-038 | Impmon X | Impmon X | -| 3-039 | Jazamon | Jazamon | -| 3-040 | Junkmon | Junkmon | -| 3-041 | Keramon X | Keramon X | -| 3-042 | Kokuwamon | Kokuwamon | -| 3-043 | Kokuwamon X | Kokuwamon X | -| 3-044 | Kunemon | Kunemon | -| 3-045 | Labramon | Labramon | -| 3-046 | Lalamon | Lalamon | -| 3-047 | Lopmon | Lopmon | -| 3-048 | Lopmon X | Lopmon X | -| 3-049 | Ludomon | Ludomon | -| 3-050 | Lunamon | Lunamon | -| 3-051 | Monodramon | Monodramon | -| 3-052 | Morphomon | Morphomon | -| 3-053 | Mushmon | Mushroomon | -| 3-054 | Otamamon | Otamamon | -| 3-055 | Otamamon X | Otamamon X | -| 3-056 | Palmon | Palmon | -| 3-057 | Palmon X | Palmon X | -| 3-058 | Patamon | Patamon | -| 3-059 | Phascomon | Phascomon | -| 3-060 | Pomumon | Pomumon | -| 3-061 | Renamon X | Renamon X | -| 3-062 | Ryudamon | Ryudamon | -| 3-063 | Plotmon | Salamon | -| 3-064 | Plotmon X | Salamon X | -| 3-065 | Sangomon | Sangomon | -| 3-066 | Shoutmon | Shoutmon | -| 3-067 | Sistermon Blanc | Sistermon Blanc | -| 3-068 | Sunarizamon | Sunarizamon | -| 3-069 | Swimmon | Swimmon | -| 3-070 | Shakomon | Syakomon | -| 3-071 | Shakomon X | Syakomon X | -| 3-072 | Bakumon | Tapirmon | -| 3-073 | Tentomon | Tentomon | -| 3-074 | Terriermon | Terriermon | -| 3-075 | Terriermon X | Terriermon X | -| 3-076 | Tinkermon | Tinkermon | -| 3-077 | ToyAgumon | ToyAgumon | -| 3-078 | V-mon | Veemon | -| 3-079 | Vorvomon | Vorvomon | -| 3-080 | Wormmon | Wormmon | -| 3-081 | Zubamon | Zubamon | -| 3-082 | Agumon (Black) | Agumon (Black) | -| 3-083 | Dracumon | Dracmon | -| 3-084 | Pulsemon | Pulsemon | -| 3-085 | Renamon | Renamon | -| 4-001 | Agnimon | Agunimon | -| 4-002 | Airdramon | Airdramon | -| 4-003 | Allomon X | Allomon X | -| 4-004 | Angemon | Angemon | -| 4-005 | Ankylomon | Ankylomon | -| 4-006 | Hanumon | Apemon | -| 4-007 | Aquilamon | Aquilamon | -| 4-008 | Algomon | Argomon | -| 4-009 | Arresterdramon | Arresterdramon | -| 4-010 | Baboongamon | Baboongamon | -| 4-011 | Bakemon | Bakemon | -| 4-012 | Baluchimon | Baluchimon | -| 4-013 | BaoHuckmon | BaoHuckmon | -| 4-014 | Birdramon | Birdramon | -| 4-015 | Centalmon | Centarumon | -| 4-016 | Clockmon | Clockmon | -| 4-017 | Coelamon | Coelamon | -| 4-018 | Coredramon (Blue) | Coredramon (Blue) | -| 4-019 | Coredramon (Green) | Coredramon (Green) | -| 4-020 | Cyclomon | Cyclonemon | -| 4-021 | Damemon | Damemon | -| 4-022 | Darcmon | Darcmon | -| 4-023 | DarkTyranomon | DarkTyrannomon | -| 4-024 | DarkTyranomon X | DarkTyrannomon X | -| 4-025 | Deltamon | Deltamon | -| 4-026 | Revolmon | Deputymon | -| 4-027 | Devidramon | Devidramon | -| 4-028 | Devimon | Devimon | -| 4-029 | Diatrymon | Diatrymon | -| 4-030 | Dobermon | Dobermon | -| 4-031 | Dokugumon | Dokugumon | -| 4-032 | Rukamon | Dolphmon | -| 4-033 | DORUgamon | Dorugamon | -| 4-034 | Drimogemon | Drimogemon | -| 4-035 | Duskmon | Duskmon | -| 4-036 | Ebidramon | Ebidramon | -| 4-037 | Eosmon | Eosmon | -| 4-038 | XV-mon | ExVeemon | -| 4-039 | Filmon | Filmon | -| 4-040 | Firamon | Firamon | -| 4-041 | FlareLizamon | Flarerizamon | -| 4-042 | Flymon | Flymon | -| 4-043 | Yukidarumon | Frigimon | -| 4-044 | Gaogamon | Gaogamon | -| 4-045 | Galgomon | Gargomon | -| 4-046 | Garurumon | Garurumon | -| 4-047 | Tailmon | Gatomon | -| 4-048 | Tailmon X | Gatomon X | -| 4-049 | Gekomon | Gekomon | -| 4-050 | GeoGreymon | GeoGreymon | -| 4-051 | Gesomon | Gesomon | -| 4-052 | Ginryumon | Ginryumon | -| 4-053 | Golemon | Golemon | -| 4-054 | Greymon | Greymon | -| 4-055 | Growmon | Growlmon | -| 4-056 | Growmon X | Growlmon X | -| 4-057 | Guardromon | Guardromon | -| 4-058 | Hookmon | Hookmon | -| 4-059 | Hudiemon | Hudiemon | -| 4-060 | Ikkakumon | Ikkakumon | -| 4-061 | Jazardmon | Jazardmon | -| 4-062 | Kabuterimon | Kabuterimon | -| 4-063 | Kiwimon | Kiwimon | -| 4-064 | Cockatrimon | Kokatorimon | -| 4-065 | Komondomon | Komondomon | -| 4-066 | Kuwagamon | Kuwagamon | -| 4-067 | Kuwagamon X | Kuwagamon X | -| 4-068 | Lavorvomon | Lavorvomon | -| 4-069 | Lekismon | Lekismon | -| 4-070 | Leomon | Leomon | -| 4-071 | Leomon X | Leomon X | -| 4-072 | Wolfmon | Lobomon | -| 4-073 | Machmon | Machmon | -| 4-074 | MadLeomon | MadLeomon | -| 4-075 | Manbomon | Manbomon | -| 4-076 | Mantaraymon X | Mantaraymon X | -| 4-077 | Meicoomon | Meicoomon | -| 4-078 | Mechanorimon | Mekanorimon | -| 4-079 | Meramon | Meramon | -| 4-080 | Meramon X | Meramon X | -| 4-081 | Mimicmon | Mimicmon | -| 4-082 | Minotaurmon | Minotarumon | -| 4-083 | Mojyamon | Mojyamon | -| 4-084 | Monochromon | Monochromon | -| 4-085 | Nanimon | Nanimon | -| 4-086 | Nefertimon X | Nefertimon X | -| 4-087 | Igamon | Ninjamon | -| 4-088 | Numemon | Numemon | -| 4-089 | Numemon X | Numemon X | -| 4-090 | Octmon | Octomon | -| 4-091 | Orgemon | Ogremon | -| 4-092 | Orgemon X | Ogremon X | -| 4-093 | Omekamon | Omekamon | -| 4-094 | Paledramon | Paledramon | -| 4-095 | Parasaurmon | Parasaurmon | -| 4-096 | Peckmon | Peckmon | -| 4-097 | Pegasmon X | Pegasusmon X | -| 4-098 | Petermon | Petermon | -| 4-099 | Porcupamon | Porcupamon | -| 4-100 | Pteranomon X | Pteramon X | -| 4-101 | Raremon | Raremon | -| 4-102 | RedVegimon | RedVegiemon | -| 4-103 | Reppamon | Reppamon | -| 4-104 | Rhinomon X | Rhinomon X | -| 4-105 | Gokimon | Roachmon | -| 4-106 | Sangloupmon | Sangloupmon | -| 4-107 | Seadramon | Seadramon | -| 4-108 | Seadramon X | Seadramon X | -| 4-109 | Siesamon X | Seasarmon X | -| 4-110 | Shadramon | Shadramon | -| 4-111 | Shellmon | Shellmon | -| 4-112 | KaratukiNumemon | ShellNumemon | -| 4-113 | Shoutmon X3 | Shoutmon X3 | -| 4-114 | Sistermon Ciel | Sistermon Ciel | -| 4-115 | Starmon | Starmon | -| 4-116 | Stingmon | Stingmon | -| 4-117 | Strikedramon | Strikedramon | -| 4-118 | Submarimon | Submarimon | -| 4-119 | Scumon | Sukamon | -| 4-120 | Sunflowmon | Sunflowmon | -| 4-121 | Tankmon | Tankmon | -| 4-122 | Targetmon | Targetmon | -| 4-123 | Thunderballmon | Thundermon | -| 4-124 | TiaLudomon | TiaLudomon | -| 4-125 | Tobiumon | Tobiumon | -| 4-126 | Tobucatmon | TobuCatmon | -| 4-127 | Togemon | Togemon | -| 4-128 | Togemon X | Togemon X | -| 4-129 | Tortamon | Tortomon | -| 4-130 | Troopmon | Troopmon | -| 4-131 | Turuiemon | Turuiemon | -| 4-132 | Tuskmon | Tuskmon | -| 4-133 | Tylomon | Tylomon | -| 4-134 | Tylomon X | Tylomon X | -| 4-135 | Tyranomon | Tyrannomon | -| 4-136 | Tyranomon X | Tyrannomon X | -| 4-137 | Unimon | Unimon | -| 4-138 | V-dramon | Veedramon | -| 4-139 | Vegimon | Vegiemon | -| 4-140 | Velgrmon | Velgrmon | -| 4-141 | Whamon | Whamon | -| 4-142 | Witchmon | Witchmon | -| 4-143 | Wizarmon | Wizardmon | -| 4-144 | Wizarmon X | Wizardmon X | -| 4-145 | Woodmon | Woodmon | -| 4-146 | Zubaeagermon | ZubaEagermon | -| 4-147 | Bulkmon | Bulkmon | -| 4-148 | Fugamon | Fugamon | -| 4-149 | Hyougamon | Hyogamon | -| 4-150 | Pidmon | Piddomon | -| 4-151 | Rhinomon | Rhinomon | -| 4-152 | Saberdramon | Saberdramon | -| 5-001 | AeroV-dramon | AeroVeedramon | -| 5-002 | Aldamon | Aldamon | -| 5-003 | Andromon | Andromon | -| 5-004 | Angewomon | Angewomon | -| 5-005 | Angewomon X | Angewomon X | -| 5-006 | Andiramon | Antylamon | -| 5-007 | Algomon | Argomon | -| 5-008 | Archnemon | Arukenimon | -| 5-009 | Astamon | Astamon | -| 5-010 | Asuramon | Asuramon | -| 5-011 | Baalmon | Baalmon | -| 5-012 | Beowolfmon | Beowolfmon | -| 5-013 | BigMamemon | BigMamemon | -| 5-014 | Blossomon | Blossomon | -| 5-015 | Valvemon | Bulbmon | -| 5-016 | Vritramon | BurningGreymon | -| 5-017 | CannonBeemon | CannonBeemon | -| 5-018 | Cerberumon | Cerberusmon | -| 5-019 | Cerberumon X | Cerberusmon X | -| 5-020 | Jyureimon | Cherrymon | -| 5-021 | Cho-Hakkaimon | Cho-Hakkaimon | -| 5-022 | Crescemon | Crescemon | -| 5-023 | Yatagaramon | Crowmon | -| 5-024 | CrysPaledramon | CrysPaledramon | -| 5-025 | Cyberdramon | Cyberdramon | -| 5-026 | Cyberdramon X | Cyberdramon X | -| 5-027 | DarkKnightmon | DarkKnightmon | -| 5-028 | Nanomon | Datamon | -| 5-029 | Delumon | Deramon | -| 5-030 | Digitamamon | Digitamamon | -| 5-031 | Dinobeemon | Dinobeemon | -| 5-032 | Hangyomon | Divermon | -| 5-033 | NeoDevimon | DoneDevimon | -| 5-034 | DORUguremon | DoruGreymon | -| 5-035 | Dagomon | Dragomon | -| 5-036 | Duramon | Duramon | -| 5-037 | Entmon | Entmon | -| 5-038 | Eosmon | Eosmon | -| 5-039 | Etemon | Etemon | -| 5-040 | Ex-Tyranomon | ExTyrannomon | -| 5-041 | Flaremon | Flaremon | -| 5-042 | Gerbemon | Garbagemon | -| 5-043 | Garudamon | Garudamon | -| 5-044 | Garudamon X | Garudamon X | -| 5-045 | Giromon | Giromon | -| 5-046 | Gogmamon | Gogmamon | -| 5-047 | Grademon | Grademon | -| 5-048 | Groundramon | Groundramon | -| 5-049 | Gusokumon | Gusokumon | -| 5-050 | Hippogriffomon | HippoGryphonmon | -| 5-051 | Hisyaryumon | Hisyaryumon | -| 5-052 | Jyagamon | Jagamon | -| 5-053 | KaiserLeomon | JagerLoweemon | -| 5-054 | Jazarichmon | Jazarichmon | -| 5-055 | Jewelbeemon | JewelBeemon | -| 5-056 | Garummon | KendoGarurumon | -| 5-057 | Chimairamon | Kimeramon | -| 5-058 | Knightmon | Knightmon | -| 5-059 | LadyDevimon | LadyDevimon | -| 5-060 | LadyDevimon X | LadyDevimon X | -| 5-061 | Lavogaritamon | Lavogaritamon | -| 5-062 | Lilamon | Lilamon | -| 5-063 | Lilimon | Lillymon | -| 5-064 | Lilimon X | Lillymon X | -| 5-065 | LoaderLeomon | LoaderLeomon | -| 5-066 | Locomon | Locomon | -| 5-067 | Lucemon: Falldown Mode | Lucemon: Chaos Mode | -| 5-068 | MachGaogamon | MachGaogamon | -| 5-069 | HolyAngemon | MagnaAngemon | -| 5-070 | Mamemon | Mamemon | -| 5-071 | Mamemon X | Mamemon X | -| 5-072 | Mametyramon | Mametyramon | -| 5-073 | Mammon | Mammothmon | -| 5-074 | Mammon X | Mammothmon X | -| 5-075 | Manticoremon | Manticoremon | -| 5-076 | MarinChimairamon | MarineChimairamon | -| 5-077 | MarinDevimon | MarineDevimon | -| 5-078 | Meicrackmon | Maycrackmon | -| 5-079 | Meicrackmon: Vicious Mode | Maycrackmon: Vicious Mode | -| 5-080 | Megadramon | Megadramon | -| 5-081 | AtlurKabuterimon | MegaKabuterimon (Red) | -| 5-082 | MegaSeadramon | MegaSeadramon | -| 5-083 | MegaSeadramon X | MegaSeadramon X | -| 5-084 | Mephismon X | Mephistomon X | -| 5-085 | Mermaimon | Mermaimon | -| 5-086 | MetalGreymon | MetalGreymon | -| 5-087 | MetalGreymon (Vaccine) | MetalGreymon (Vaccine) | -| 5-088 | MetalGreymon (Virus) | MetalGreymon (Virus) | -| 5-089 | MetalGreymon (Virus) X | MetalGreymon (Virus) X | -| 5-090 | MetalGreymon X | MetalGreymon X | -| 5-091 | MetalMamemon | MetalMamemon | -| 5-092 | MetalFantomon | MetalPhantomon | -| 5-093 | MetalTyranomon | MetalTyrannomon | -| 5-094 | MetalTyranomon X | MetalTyrannomon X | -| 5-095 | Monzaemon | Monzaemon | -| 5-096 | Monzaemon X | Monzaemon X | -| 5-097 | Mummymon | Mummymon | -| 5-098 | Vamdemon | Myotismon | -| 5-099 | Vamdemon X | Myotismon X | -| 5-100 | NeoDevimon | NeoDevimon | -| 5-101 | Okuwamon | Okuwamon | -| 5-102 | Okuwamon X | Okuwamon X | -| 5-103 | OmegaShoutmon X | OmniShoutmon X | -| 5-104 | Orochimon | Orochimon | -| 5-105 | Paildramon | Paildramon | -| 5-106 | Parrotmon | Parrotmon | -| 5-107 | Fantomon | Phantomon | -| 5-108 | Piranimon | Piranimon | -| 5-109 | Piccolomon | Piximon | -| 5-110 | Pumpmon | Pumpkinmon | -| 5-111 | RaijiLudomon | RaijiLudomon | -| 5-112 | Rapidmon | Rapidmon | -| 5-113 | Rebellimon | Rebellimon | -| 5-114 | Raihimon | Rhihimon | -| 5-115 | RizeGreymon | RizeGreymon | -| 5-116 | RizeGreymon X | RizeGreymon X | -| 5-117 | SaviorHackmon | SaviorHuckmon | -| 5-118 | Anomalocarimon | Scorpiomon | -| 5-119 | Anomalocarimon X | Scorpiomon X | -| 5-120 | Shakkoumon | Shakkoumon | -| 5-121 | TonosamaGekomon | ShogunGekomon | -| 5-122 | Shoutmon X5 | Shoutmon X5 | -| 5-123 | Silphymon | Silphymon | -| 5-124 | Sirenmon | Sirenmon | -| 5-125 | SkullBaluchimon | SkullBaluchimon | -| 5-126 | SkullGreymon | SkullGreymon | -| 5-127 | DeathMeramon | SkullMeramon | -| 5-128 | Stiffilmon | Stefilmon | -| 5-129 | Tekkamon | Tekkamon | -| 5-130 | Toropiamon | Toropiamon | -| 5-131 | Triceramon | Triceramon | -| 5-132 | Triceramon X | Triceramon X | -| 5-133 | Vademon | Vademon | -| 5-134 | Velgrmon | Velgrmon | -| 5-135 | MegaloGrowmon | WarGrowlmon | -| 5-136 | MegaloGrowmon X | WarGrowlmon X | -| 5-137 | WaruMonzaemon | WaruMonzaemon | -| 5-138 | WaruSeadramon | WaruSeadramon | -| 5-139 | WereGarurumon | WereGarurumon | -| 5-140 | WereGarurumon X | WereGarurumon X | -| 5-141 | Whamon | Whamon | -| 5-142 | Wingdramon | Wingdramon | -| 5-143 | Zudomon | Zudomon | -| 5-144 | Boutmon | Boutmon | -| 5-145 | DarkSuperStarmon | DarkSuperStarmon | -| 5-146 | DORUguremon | Doruguremon | -| 5-147 | Pandamon | Pandamon | -| 5-148 | Scorpiomon | SkullScorpiomon | -| 5-149 | SuperStarmon | SuperStarmon | -| 6-001 | Aegisdramon | Aegisdramon | -| 6-002 | Alphamon | Alphamon | -| 6-003 | AncientBeatmon | AncientBeetlemon | -| 6-004 | AncientGarurumon | AncientGarurumon | -| 6-005 | AncientGreymon | AncientGreymon | -| 6-006 | AncientIrismon | AncientKazemon | -| 6-007 | AncientMegatheriumon | AncientMegatheriummon | -| 6-008 | AncientMermaimon | AncientMermaimon | -| 6-009 | AncientSphinxmon | AncientSphinxmon | -| 6-010 | AncientTroiamon | AncientTroymon | -| 6-011 | AncientVolcamon | AncientVolcanomon | -| 6-012 | AncientWisemon | AncientWisemon | -| 6-013 | Anubimon | Anubismon | -| 6-014 | Apoclymon | Apocalymon | -| 6-015 | Apollomon | Apollomon | -| 6-016 | Algomon | Argomon | -| 6-017 | Armagemon | Armageddemon | -| 6-018 | Qinglongmon | Azulongmon | -| 6-019 | Bagramon | Bagramon | -| 6-020 | Baihumon | Baihumon | -| 6-021 | BanchoLeomon | BanchoLeomon | -| 6-022 | BanchoMamemon | BanchoMamemon | -| 6-023 | Barbamon | Barbamon | -| 6-024 | BeelStarmon X | BeelStarmon X | -| 6-025 | Beelzebumon | Beelzemon | -| 6-026 | Belphemon: Rage Mode | Belphemon: Rage Mode | -| 6-027 | BlackWarGreymon X | BlackWarGreymon X | -| 6-028 | BlitzGreymon | BlitzGreymon | -| 6-029 | Boltmon | Boltmon | -| 6-030 | Breakdramon | Breakdramon | -| 6-031 | BryweLudramon | BryweLudramon | -| 6-032 | Chaosdramon X | Chaosdramon X | -| 6-033 | Cherubimon (Virtue) | Cherubimon (Virtue) | -| 6-034 | Cherubimon (Virtue) X | Cherubimon (Virtue) X | -| 6-035 | Cherubimon (Vice) X | Cherubimon (Vice) X | -| 6-036 | Craniummon | Craniamon | -| 6-037 | Demon | Creepymon | -| 6-038 | CresGarurumon | CresGarurumon | -| 6-039 | Darkdramon | Darkdramon | -| 6-040 | DarkKnightmon X | DarkKnightmon X | -| 6-041 | Dianamon | Dianamon | -| 6-042 | Dinorexmon | Dinorexmon | -| 6-043 | Dinotigermon | Dinotigermon | -| 6-044 | Durandamon | Durandamon | -| 6-045 | Dynasmon | Dynasmon | -| 6-046 | Ebemon X | Ebemon X | -| 6-047 | Xuanwumon | Ebonwumon | -| 6-048 | KaiserGreymon | EmperorGreymon | -| 6-049 | Eosmon | Eosmon | -| 6-050 | Huanglongmon | Fanglongmon | -| 6-051 | Gaioumon | Gaiomon | -| 6-052 | Dukemon | Gallantmon | -| 6-053 | Dukemon X | Gallantmon X | -| 6-054 | Gankoomon | Gankoomon | -| 6-055 | Deathmon | Ghoulmon | -| 6-056 | GigaSeadramon | GigaSeadramon | -| 6-057 | Goddramon | Goldramon | -| 6-058 | Goddramon X | Goldramon X | -| 6-059 | GrandisKuwagamon | GrandisKuwagamon | -| 6-060 | GrandDracumon | GranDracmon | -| 6-061 | GranKuwagamon | GranKuwagamon | -| 6-062 | GrandLocomon | GroundLocomon | -| 6-063 | Griffomon | Gryphonmon | -| 6-064 | Gundramon | Gundramon | -| 6-065 | HeavyLeomon | HeavyLeomon | -| 6-066 | HerakleKabuterimon | HerculesKabuterimon | -| 6-067 | Hexeblaumon | Hexeblaumon | -| 6-068 | HiAndromon | HiAndromon | -| 6-069 | Jesmon | Jesmon | -| 6-070 | Justimon X | Justimon X | -| 6-071 | Justimon: Blitz Arm | Justimon: Blitz Arm | -| 6-072 | Sleipmon | Kentaurosmon | -| 6-073 | KingEtemon | KingEtemon | -| 6-074 | Duftmon | Leopardmon | -| 6-075 | Leviamon | Leviamon | -| 6-076 | Lilithmon | Lilithmon | -| 6-077 | LordKnightmon | LordKnightmon | -| 6-078 | Lotusmon | Lotosmon | -| 6-079 | Lucemon: Satan Mode | Lucemon: Satan Mode | -| 6-080 | Mugendramon | Machinedramon | -| 6-081 | Holydramon | Magnadramon | -| 6-082 | Holydramon X | Magnadramon X | -| 6-083 | MagnaGarurumon | MagnaGarurumon | -| 6-084 | Magnamon | Magnamon | -| 6-085 | BelialVamdemon | MaloMyotismon | -| 6-086 | MarinAngemon | MarineAngemon | -| 6-087 | SaintGalgomon | MegaGargomon | -| 6-088 | Megidramon | Megidramon | -| 6-089 | Megidramon X | Megidramon X | -| 6-090 | MetalEtemon | MetalEtemon | -| 6-091 | MetalGarurumon | MetalGarurumon | -| 6-092 | MetalGarurumon X | MetalGarurumon X | -| 6-093 | Metallicdramon | Metallicdramon | -| 6-094 | MetalPiranimon | MetalPiranimon | -| 6-095 | MetalSeadramon | MetalSeadramon | -| 6-096 | MirageGaogamon | MirageGaogamon | -| 6-097 | Murmukusmon | Murmukusmon | -| 6-098 | Nidhoggmon | Nidhoggmon | -| 6-099 | NoblePumpmon | NoblePumpkinmon | -| 6-100 | Ofanimon | Ophanimon | -| 6-101 | Ofanimon X | Ophanimon X | -| 6-102 | Ofanimon: Falldown Mode X | Ophanimon: Falldown Mode X | -| 6-103 | Ornismon | Ornismon | -| 6-104 | Ouryumon | Ouryumon | -| 6-105 | Hououmon | Phoenixmon | -| 6-106 | Hououmon X | Phoenixmon X | -| 6-107 | Piemon | Piedmon | -| 6-108 | PlatinumNumemon | PlatinumNumemon | -| 6-109 | Plesiomon | Plesiomon | -| 6-110 | Plesiomon X | Plesiomon X | -| 6-111 | PrinceMamemon X | PrinceMamemon X | -| 6-112 | Pukumon | Pukumon | -| 6-113 | Pinochimon | Puppetmon | -| 6-114 | Rafflesimon | Rafflesimon | -| 6-115 | Raguelmon | Raguelmon | -| 6-116 | Rapidmon X | Rapidmon X | -| 6-117 | Rasenmon | Rasenmon | -| 6-118 | Rasenmon: Fury Mode | Rasenmon: Fury Mode | -| 6-119 | Rasielmon | Rasielmon | -| 6-120 | Ravmon | Ravemon | -| 6-121 | Regalecusmon | Regalecusmon | -| 6-122 | Raihimon | Rhihimon | -| 6-123 | Rosemon | Rosemon | -| 6-124 | Rosemon X | Rosemon X | -| 6-125 | RustTyranomon | RustTyrannomon | -| 6-126 | SaberLeomon | SaberLeomon | -| 6-127 | Sakuyamon X | Sakuyamon X | -| 6-128 | Seraphimon | Seraphimon | -| 6-129 | ShineGreymon | ShineGreymon | -| 6-130 | SkullMammon | SkullMammothmon | -| 6-131 | SkullMammon X | SkullMammothmon X | -| 6-132 | SlashAngemon | SlashAngemon | -| 6-133 | Slayerdramon | Slayerdramon | -| 6-134 | Susanoomon | Susanomon | -| 6-135 | TigerVespamon | TigerVespamon | -| 6-136 | Titamon | Titamon | -| 6-137 | UlforceV-dramon | UlforceVeedramon | -| 6-138 | UltimateBrachiomon | UltimateBrachiomon | -| 6-139 | Valkyrimon | Valkyrimon | -| 6-140 | Valdurmon | Varodurumon | -| 6-141 | VenomVamdemon | VenomMyotismon | -| 6-142 | Volcanicdramon | Volcanicdramon | -| 6-143 | WarGreymon | WarGreymon | -| 6-144 | WarGreymon X | WarGreymon X | -| 6-145 | Zhuqiaomon | Zhuqiaomon | -| 6-146 | Vikemon | Vikemon | -| 6-147 | ShinMonzaemon | ShinMonzaemon | -| 6-148 | Kazuchimon | Kazuchimon | -| 6-149 | BanchoLilimon | BanchoLillymon | -| 6-150 | DORUgoramon | Dorugoramon | -| 7-001 | Alphamon: Ouryuken | Alphamon: Ouryuken | -| 7-002 | Armagemon | Armageddemon | -| 7-003 | Barbamon X | Barbamon X | -| 7-004 | Beelzebumon X | Beelzemon X | -| 7-005 | Beelzebumon: Blast Mode | Beelzemon: Blast Mode | -| 7-006 | Belphemon X | Belphemon X | -| 7-007 | Voltobautamon | Boltboutamon | -| 7-008 | Chaosmon | Chaosmon | -| 7-009 | Craniummon X | Craniamon X | -| 7-010 | Demon X | Creepymon X | -| 7-011 | DarknessBagramon | DarknessBagramon | -| 7-012 | Diablomon X | Diaboromon X | -| 7-013 | Dynasmon X | Dynasmon X | -| 7-014 | Examon | Examon | -| 7-015 | Examon X | Examon X | -| 7-016 | Dukemon X | Gallantmon X | -| 7-017 | Gankoomon X | Gankoomon X | -| 7-018 | GraceNovamon | GraceNovamon | -| 7-019 | Imperialdramon: Fighter Mode | Imperialdramon: Fighter Mode | -| 7-020 | Imperialdramon: Paladin Mode | Imperialdramon: Paladin Mode | -| 7-021 | Jesmon X | Jesmon X | -| 7-022 | Sleipmon X | Kentaurosmon X | -| 7-023 | Duftmon X | Leopardmon X | -| 7-024 | Leviamon X | Leviamon X | -| 7-025 | Lilithmon X | Lilithmon X | -| 7-026 | LordKnightmon X | LordKnightmon X | -| 7-027 | Lucemon X | Lucemon X | -| 7-028 | Magnamon X | Magnamon X | -| 7-029 | Mastemon | Mastemon | -| 7-030 | Minervamon X | Minervamon X | -| 7-031 | Ogudomon | Ogudomon | -| 7-032 | Omegamon | Omnimon | -| 7-033 | Omegamon Alter-S | Omnimon Alter-S | -| 7-034 | Omegamon X | Omnimon X | -| 7-035 | Ordinemon | Ordinemon | -| 7-036 | Rafflesimon | Rafflesimon | -| 7-037 | RagnaLordmon | RagnaLoardmon | -| 7-038 | Rasenmon | Rasenmon | -| 7-039 | RustTyranomon | RustTyrannomon | -| 7-040 | UlforceV-dramon X | UlforceVeedramon X | -| 7-041 | Agumon (Bond of Bravery) | Agumon (Bond of Bravery) | -| 7-042 | Gabumon (Bond of Friendship) | Gabumon (Bond of Friendship) | -| 7-043 | Jesmon GX | Jesmon GX | -| 7-044 | Ogudomon X | Ogudomon X | -| 7-045 | Chaosdramon | Chaosdramon | - -Check out the [Digimon Data List](src/data/digimon-data.ts) for more information about evolution -lines. - -## License - -This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. +Contributions and issues belong in the [repository](https://github.com/jcendal/digital-pet). The two product READMEs provide their user guides; [docs/monorepo.md](docs/monorepo.md) lists package-specific development commands. -## Attributions +## Maintainer and credits -The bundled sprites were derived from the following sources and were later edited and -processed for this project: +- [Jorge Cendal](https://github.com/jcendal), maintainer +- [Sergio Bugallo](https://github.com/sbugallo), original author and contributor -- [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) -- [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/) on the [With the Will forum](https://withthewill.net/members/tortoiseshel.4767/) +The project is licensed under [Apache 2.0](LICENSE). The bundled sprites were derived from [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) and [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/), then edited and processed for this project. diff --git a/_images/digital-pet-overview.png b/_images/digital-pet-overview.png new file mode 100644 index 0000000..6ba8318 Binary files /dev/null and b/_images/digital-pet-overview.png differ diff --git a/_images/vpet-overview.png b/_images/vpet-overview.png deleted file mode 100644 index 4d38303..0000000 Binary files a/_images/vpet-overview.png and /dev/null differ diff --git a/biome.json b/biome.json index 6d28508..5afb24b 100644 --- a/biome.json +++ b/biome.json @@ -2,15 +2,16 @@ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { "includes": [ - "src/**", - "tests/**", + "packages/**", "scripts/**", "package.json", "tsconfig*.json", - "!src/data/monster-frame-data.ts", + "!packages/**/dist/**", + "!packages/**/build", + "!packages/cursor-digital-pet/hook-bridge.js", + "!packages/digital-pet-core/src/data/monster-frame-data.ts", "!tests/**/*.jsonc", "!.omo/", - "!dist/", "!node_modules/", "!coverage/", "!**/generated/" @@ -44,7 +45,11 @@ }, "overrides": [ { - "includes": ["src/tui/sidebar-card.tsx", "src/tui/vpet-dex-dialog.tsx", "src/tui/vpet-history-dialog.tsx"], + "includes": [ + "packages/opencode-digital-pet/src/tui/sidebar-card.tsx", + "packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx", + "packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx" + ], "linter": { "rules": { "a11y": { diff --git a/bun.lock b/bun.lock deleted file mode 100644 index 740fed9..0000000 --- a/bun.lock +++ /dev/null @@ -1,349 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "opencode-vpet", - "dependencies": { - "solid-js": "^1.9.14", - }, - "devDependencies": { - "@biomejs/biome": "2.5.10", - "@opencode-ai/plugin": "^1.18.10", - "@opencode-ai/sdk": "^1.18.10", - "@opentui/core": "^0.4.5", - "@opentui/keymap": "^0.4.5", - "@opentui/solid": "^0.4.5", - "@types/bun": "latest", - "@types/semver": "^7.7.1", - "semver": "^7.7.4", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.18.10", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5", - }, - }, - }, - "packages": { - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], - - "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - - "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - - "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="], - - "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - - "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - - "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - - "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="], - - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.10", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.10", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-l4R1ulu5wtA0+cCinzdSPkEO8ZCdIgb/ZjltzstIWQqRqeH3H880OabNaeje/ZCkSoAT8c/o9oT3tdjyFP7o7g=="], - - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-K+glWbBp5pfGepZ/6EHvuXY3IpVG2qgsEqF4z9mOiUQUrs6KWN72Vdp15Qsl2TCDbemgHWXKQKgbX3b8PSK2hg=="], - - "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="], - - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="], - - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="], - - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="], - - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="], - - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="], - - "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="], - - "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], - - "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], - - "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], - - "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.11.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ=="], - - "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], - - "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - - "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.398", "", {}, "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], - - "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], - - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - - "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], - - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - - "glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - - "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - - "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], - - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - - "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - - "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], - - "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - - "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], - - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - - "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], - - "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - - "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], - - "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="], - - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], - - "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], - - "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], - - "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="], - - "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - - "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - - "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "solid-js": ["solid-js@1.9.14", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.4", "seroval-plugins": "~1.5.4" } }, "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ=="], - - "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - - "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], - - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - } -} diff --git a/bunfig.toml b/bunfig.toml index 0d8b3d7..08e479b 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,6 +1,3 @@ [jsx] runtime = "automatic" importSource = "@opentui/solid" - -[test] -preload = ["@opentui/solid/preload"] diff --git a/docs/cursor-digidex.md b/docs/cursor-digidex.md new file mode 100644 index 0000000..c465cba --- /dev/null +++ b/docs/cursor-digidex.md @@ -0,0 +1,39 @@ +# Cursor Digidex + +The `Cursor Digital Pet: Open Dex` command opens a collection browser and a selected record together. The full catalog remains visible even before the first partner is spawned. Discovery comes from persisted partner evolution events, so `Set Digimon` does not unlock entries. + +## Design requirements + +Derived from the [shared design conversation](https://chatgpt.com/share/6ac52823-b47c-83ee-aa5d-29f5fb05813c): + +- Show a browsable collection alongside the selected Digimon, with grid and list layouts. +- Use a visual language inspired by Digimon's LCD V-Pets and Pendulum devices. +- Use pixel lettering as well as pixel artwork. A monospace system font alone does not meet that requirement. +- Keep the design practical inside a Cursor extension webview. + +The public conversation exposes four image placeholders without the original image content. The user subsequently supplied a pixel reference with a brown casing, continuous olive LCD, index on the left and a sprite/stage record on the right. That reference defines the implemented visual direction. List view is the initial layout; evolution and archive information expand below the record. + +Additional context comes from the [official Pendulum 20th page](https://digimon.net/20th_pendulum/) and [Bandai's LCD toy history](https://toy.bandai.co.jp/assets/vb-digitalmonster/pdf/digimon_startguide.pdf). The brown device frame and monochrome olive LCD are a fan interface inspired by those devices, not an official canonical Digidex screen. + +## Interaction and data + +- Search registered English/Japanese romanized names or any catalog ID. +- Filter by stage and by registered/undiscovered status; browse with arrow keys, Home and End. +- Show collection completion, stage, first registration date and distinct partner generations. +- Display previous and next evolutions from the existing V-Pet catalog. Route buttons select the target and clear filters so its index entry stays visible. +- Redact undiscovered names, sprites, URLs and route details. Unknown route targets appear as `???` with their catalog ID. +- Display an explicit archive error when storage is unavailable. The completion count then reads `--` rather than reporting a false zero. +- Refresh on database changes, panel reveal and manual request. Reopening the command reveals the existing panel for that database. +- Persist layout, filters, selection and catalog scroll position through webview recreation. + +Species artwork uses the existing frame catalog. Terminal half-blocks are reconstructed into a 16×16 SVG pixel grid with crisp edges; no external images are requested. Silkscreen is bundled locally under the [SIL Open Font License](https://github.com/google/fonts/tree/main/ofl/silkscreen), including its license in the VSIX. Typography and shapes use square corners throughout. + +Only catalog IDs are accepted for opening references. The extension resolves the destination from a discovered record and restricts it to `https://digimon.net/`. Catalog strings render through `textContent`, embedded JSON is escaped, scripts use a nonce, and local resource access is limited to the media directory. + +The device fills the available webview height, including when the editor's terminal is open. The document has no outer vertical scroll: the catalog and record body scroll independently, while record navigation and the device footer stay visible. Short views reduce frame spacing and place the sprite beside the record name. On narrow panels, the catalog appears above the record, with both scroll regions bounded by the available height. Standard native search and select controls preserve keyboard and assistive technology support. + +Typography uses a shared scale: 20 px collection names, 28 px record names (24 px in short views), 18 px body/filter text, 16 px labels/actions and a 14 px minimum for supporting text. Secondary text is darkened for better LCD contrast. Narrow panels keep the same collection name size and stack the catalog and record. List discovery states use pixel check/dash marks inspired by the supplied reference, with the full status retained in each button's accessible name. + +## Verification + +The real webview HTML was rendered in a local browser with a development-only VS Code API shim and a sample archive. Visual checks covered the default desktop viewport, 965×650 and 965×450 editor views, and a 390×650 viewport. The viewport checks confirm that the document height equals the viewport height, the footer stays inside it, and expanding the archive grows only the record's scrollable content. Search, empty results, stage/discovery filters, list/grid switching, arrow navigation, route selection, state restoration and empty/unavailable archives were exercised. The [desktop screenshot](../packages/cursor-digital-pet/images/digidex-browser.jpg) uses sample discovery data and the existing Komondomon sprite. The extension host integration requires opening the packaged extension in Cursor. diff --git a/docs/cursor-history.md b/docs/cursor-history.md new file mode 100644 index 0000000..d7570b5 --- /dev/null +++ b/docs/cursor-history.md @@ -0,0 +1,19 @@ +# Cursor generation history + +`Cursor Digital Pet: Open History` remains an independent command and panel. It shares the Digidex's brown device frame, olive LCD, bundled Silkscreen font, pixel artwork, and viewport sizing. It has no internal tabs. + +Both views use the [shared panel UI](cursor-panel-ui.md). History supplies its own generation rows and timeline styles rather than importing Dex styles. + +The left column lists partner generations, newest first, with current/retired status. The right column shows the last recorded Digimon, its stage, creation/retirement dates, and a timeline of actual archive events. Consecutive sightings of the same species collapse into one step using its first timestamp; later returns to that species remain visible. Events sort chronologically. These are recorded journeys, rather than the possible evolution routes shown in the Dex. + +Search matches generation numbers and Digimon names/catalog IDs anywhere in the journey. Status filters isolate current or retired partners. Arrow keys, Home and End navigate generations. Selection, filters and catalog scroll persist when the webview is recreated. A button opens the last recorded species in the separate Digidex panel, clears its filters and selects that record. Missing catalog entries and generations without events disable this action. + +The device fills the available viewport; the generation list and record body scroll independently. Short layouts put the sprite beside the record name. Narrow views stack the generation list above the record. Empty history and unavailable storage have distinct messages; unavailable storage displays `--` rather than a misleading zero count. + +The panel refreshes on database changes, reveal and manual request, and reuses an existing panel for the same database. Database watchers and message handlers are disposed with the panel. Archive strings use `textContent`, embedded JSON escapes HTML delimiters, scripts use a nonce, and webview resources are limited to bundled media. Dex destinations are validated against the selected partner's final recorded catalog entry by the extension host. + +## Verification + +Model tests cover chronological ordering, repeated sightings, returns to previous species, retired generations, generations without events, missing catalog entries, storage errors and JSON escaping. Browser verification uses real generated webview HTML with a development-only VS Code API shim and example partner records. It covers search, empty results, status filtering, keyboard navigation, persisted selection, refresh and Dex messages, plus 965×650, 965×450 and 390×650 views. The document remains viewport height with the footer inside it. Empty, unavailable and no-event archives are also checked. + +The [History screenshot](../packages/cursor-digital-pet/images/history-browser.jpg) contains sample records. Extension host navigation still requires opening the packaged extension in Cursor. diff --git a/docs/cursor-panel-ui.md b/docs/cursor-panel-ui.md new file mode 100644 index 0000000..bd24cc3 --- /dev/null +++ b/docs/cursor-panel-ui.md @@ -0,0 +1,24 @@ +# Shared Cursor panel UI + +Dex and History remain independent panels with their own models, scripts and extension commands. Their presentation is built from `packages/cursor-digital-pet/src/webview/shared`: + +Each panel's domain files live in `webview/panels/dex/` or `webview/panels/history/`. Shared UI files remain in `webview/shared/`. + +- `theme.ts`: LCD/case colors, pixel typography and global text rules. +- `panel-styles.ts`: device frame, header/footer, controls, selection states, list/detail layout and scroll regions. Responsive rules keep the frame inside the available viewport. +- `panel-render.ts`: the HTML document, bundled font, CSP, safe JSON serialization and common layout. Each view supplies its heading, accessible labels, summary/toolbar HTML, styles and script. +- `webview-resources.ts`: the neutral resource contract for nonce, font URI and CSP source. +- `panel-script.ts`: frontend helpers for safe text nodes and pixel SVG sprites. +- `pixel-artwork.ts`: reconstruction of terminal half-block artwork into square SVG pixels. + +Views configure desktop proportions with `--workspace-columns` and `--toolbar-columns`, and narrow-view row proportions with `--workspace-rows`. The base supplies fallback proportions. Individual controls keep shared spacing and focus behavior. View styles own grid/list arrangement, discovery markers and evolution routes for Dex, and generation rows and timeline for History. + +## Adding a view + +Create its model, renderer, script and styles beside the existing panels. Call `buildPanelWebviewHtml` with the view's model and resources. Use the existing list/detail IDs and classes for the shared layout; set the layout variables when different proportions are needed. Provide accessible labels that describe the new view rather than inheriting catalog wording. + +HTML slots, CSS and scripts are trusted code authored by renderers. Escape any data inserted into an HTML slot; prefer rendering archive strings through the shared `element` helper's `textContent`. The document builder escapes labels and serializes model data safely. Scripts execute inside separate webviews, so sharing helpers does not share UI state between panels. + +## Verification + +Dex and History model/HTML tests exercise the shared serialization path. An additional shared renderer test covers hostile labels and archive data. Browser checks cover both views, Dex grid/list switching, History filtering and sprites, plus compact and narrow layouts. The refactor preserves the existing visual design and outer viewport sizing. diff --git a/docs/cursor-pet-display.md b/docs/cursor-pet-display.md new file mode 100644 index 0000000..f38a711 --- /dev/null +++ b/docs/cursor-pet-display.md @@ -0,0 +1,47 @@ +# Cursor partner display + +The Cursor Explorer sidebar uses the same LCD palette and pixel font as Dex and History. +A compact identity row sits above a full-width arena, leaving the partner free to walk +across the sidebar. Small Dex/History actions sit at the bottom right. Combat and +evolution expand the arena into the progress area. Both layouts keep the same +overall height so switching phases does not move the shortcuts. In battle, the +identity row shows the partner on the left and the opponent on the right. Animated +views retain the divider above the shortcuts. The SVG +viewport retains the reported arena column width as the partner moves, preserving +pixel size across walking positions. During battle, three outlined score boxes sit +above each fighter, filled for successful hits, and a larger caption sits below. +Shared battle callbacks supply score/caption metadata and frame HUD coordinates; +Cursor replaces those frame regions with its LCD HUD while OpenCode retains the +shared text frame. + +## Shared animation contract + +`digital-pet-animation/sessions/presentation-state.ts` defines semantic events: +`battle`, `evolving`, `evolved`, and `defeated`, including the participating catalog IDs. +Evolution sessions emit these events alongside the existing artwork callbacks. The +reveal event occurs before the target's reveal frames. Frame sequences, timings, +battle results, and persistence remain in the shared animation/domain packages. + +Cursor and OpenCode consume the same events. Cursor chooses the LCD layout and +converts Unicode block frames into SVG pixels, retaining shading and battle captions. +OpenCode retains its terminal layout and uses the phase in its existing status row. +Each frontend returns to `idle` when its presentation ends. + +## Cursor implementation + +- `sidebar-document.ts`: accessible HTML and resource CSP. +- `sidebar-styles.ts`: compact sidebar layout layered over the shared panel theme. +- `sidebar-script.ts`: SVG frame rendering, model/state updates, progress, and actions. +- `sidebar-orchestrator.ts`: maps shared phase IDs to the visible partner model. +- `provider.ts`: local font resource and whitelisted Dex/History commands. The ready + handshake replays state, model, and idle artwork after the webview script loads. + +## Verification + +The real HTML and shared frames were previewed at 350px and 240px widths. Rest, +combat, evolution, result, frozen, final-stage, and empty views were inspected. +At 350px, occupied partner views are approximately 308px high; at 240px, 304px. +A shorter host area can still scroll to preserve readable controls. + +The saved JPG previews in `packages/cursor-digital-pet/images/` use sample data. +The VSIX can be installed in Cursor to verify the final Explorer host integration. diff --git a/docs/monorepo.md b/docs/monorepo.md new file mode 100644 index 0000000..169f984 --- /dev/null +++ b/docs/monorepo.md @@ -0,0 +1,76 @@ +# Monorepo + +The root workspace is private and coordinates seven packages: + +| Package | Purpose | +| --- | --- | +| `packages/digital-pet-core` | Shared catalog, evolution rules, and SQLite schema | +| `packages/digital-pet-fields` | Reusable Fields, regions, locations, habitat rosters, and scenery assets | +| `packages/digital-pet-webviews` | Shared panels and browser presentation | +| `packages/web-digital-pet` | Web PWA, save selection, browser persistence, and device transfer | +| `packages/digital-pet-animation` | Shared animation and artwork logic | +| `packages/opencode-digital-pet` | OpenCode plugin published on npm | +| `packages/cursor-digital-pet` | Cursor extension published on Open VSX | + +Cursor and OpenCode use the same local `pet.db` by default. The shared packages are bundled into the products and are not published separately. + +## Region architecture + +Dependencies flow from the host toward reusable packages: + +```mermaid +flowchart LR + Web[web-digital-pet] --> Views[digital-pet-webviews] + Web --> Fields[digital-pet-fields] + Views --> Fields + Fields --> Core[digital-pet-core] + Views --> Animation[digital-pet-animation] + Animation --> Core + Views --> Core +``` + +Fields are species families; regions are destinations and can have multiple locations. The ten initial correspondences follow the game's selected mapping. Dragon Eye Lake is a second location under Digital Ocean. Habitat rosters are curated and can overlap. + +`digital-pet-fields` owns definitions, queries, validation, scenery assets, and the visit persistence port. `digital-pet-webviews/panels/world` owns the public habitat guide and LCD panel presentation. The web host owns the overlay controller, asset delivery, and storage adapter: browser visits live in the transferable IndexedDB save, while computer visits are a separate web preference. SQLite is read only. Cursor keeps its existing partner view; no region scene or frame is injected there. + +Exploring does not register species or change progression. The four additional Fields remain comments until supported. See [Digital Pet Fields](../packages/digital-pet-fields/README.md) for the asset filenames and catalog extension points. + +## Development + +Use Node `24.11.0`, npm `11.6.1`, and Bun `1.3.5`. From the repository root: + +```sh +npm ci +npm run check +npm run test +npm run test:persistence +npm run build +``` + +To create local distributables: + +```sh +npm run pack:opencode +npm run package --workspace cursor-digital-pet +``` + +The two products have independent versions and changelogs: + +| Product | Changelog | +| --- | --- | +| OpenCode | [`packages/opencode-digital-pet/CHANGELOG.md`](../packages/opencode-digital-pet/CHANGELOG.md) | +| Cursor | [`packages/cursor-digital-pet/CHANGELOG.md`](../packages/cursor-digital-pet/CHANGELOG.md) | + +Merging a pull request into `main` starts [the automatic release workflow](../.github/workflows/release-after-merge.yml). It publishes only products changed by the pull request; changes to core or animation publish both. The merge that adds this workflow does not run it. The next merge does, and that first automatic release publishes both products. Closing a pull request without merging it does not publish anything. + +By default, a `feat:` pull request makes a minor release and other changes make a patch release. Add exactly one `release:major`, `release:minor`, or `release:patch` label to choose a different bump. The first OpenCode release follows its existing `0.2.0-dev.0` candidate and becomes `0.2.0`. + +The release workflows update each product's package version and changelog, create its tag and GitHub Release, and publish OpenCode to npm or Cursor to Open VSX. Both product workflows start together. When one push moves `main`, the other rebases its release commits and retries. After publishing `0.2.0`, `main` moves to `0.2.1-dev.0`; the stable `0.2.0` metadata remains on the release tag. The individual workflows also allow a manual run. A failed publication can be re-run from its release tag without creating another version. + +## Development tools + +The development commands in each host preview feeding, activity, and evolution animations without waiting for live agent usage. + +For Cursor, run the extension in an Extension Development Host or enable the `digital-pet.devTools` setting. The command palette then offers **Cursor Digital Pet: Dev Tools**. + +For OpenCode, set `OPENCODE_DIGITAL_PET_DEV=1` before starting OpenCode. Its development commands appear under **Digital Pet Dev**. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..596d63b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8094 @@ +{ + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "license": "Apache-2.0", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "esbuild": "0.28.2", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "npm": ">=11.5.1" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.3.tgz", + "integrity": "sha512-zGQPtqvXPgSA8yfV2CkIQ1qirqk0p9AIVpC5uEkdXQYcKl07QHvyaGYRnZOk0AsQUmxNb4wfkcwY5di8Z5xa9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^6.0.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.24.0.tgz", + "integrity": "sha512-jS1stt81tslcVZi7fqVD+g6vr8w/IWqFl/vAB2dx/6EOroQDuCR4+z0o3C+d6a/GpLATvdoO+kwnDcYzsBXKQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.2.tgz", + "integrity": "sha512-HOBR0Gfo39TMrkxNG8k6cfjRkrRjXZc3JQs3ebMWQc9S4cihuknMYra3sgmzbZB752oQBqBN9+WF7c826p2vIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.10.tgz", + "integrity": "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.10", + "@biomejs/cli-darwin-x64": "2.5.10", + "@biomejs/cli-linux-arm64": "2.5.10", + "@biomejs/cli-linux-arm64-musl": "2.5.10", + "@biomejs/cli-linux-x64": "2.5.10", + "@biomejs/cli-linux-x64-musl": "2.5.10", + "@biomejs/cli-win32-arm64": "2.5.10", + "@biomejs/cli-win32-x64": "2.5.10" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.10.tgz", + "integrity": "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.10.tgz", + "integrity": "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.10.tgz", + "integrity": "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.10.tgz", + "integrity": "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.10.tgz", + "integrity": "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.10.tgz", + "integrity": "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.10.tgz", + "integrity": "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.10.tgz", + "integrity": "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jcendal/digital-pet-animation": { + "resolved": "packages/digital-pet-animation", + "link": true + }, + "node_modules/@jcendal/digital-pet-core": { + "resolved": "packages/digital-pet-core", + "link": true + }, + "node_modules/@jcendal/digital-pet-fields": { + "resolved": "packages/digital-pet-fields", + "link": true + }, + "node_modules/@jcendal/digital-pet-webviews": { + "resolved": "packages/digital-pet-webviews", + "link": true + }, + "node_modules/@jcendal/opencode-digital-pet": { + "resolved": "packages/opencode-digital-pet", + "link": true + }, + "node_modules/@jcendal/web-digital-pet": { + "resolved": "packages/web-digital-pet", + "link": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz", + "integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.8.tgz", + "integrity": "sha512-gOYKFwkojSdWrSmreCcGocRcAXSpRkNZYxv6nscmtddSWBKVGypdMbfLxu5qgmymdaCNApajuwZblYEfs7HswA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.8", + "@node-rs/crc32-android-arm64": "1.10.8", + "@node-rs/crc32-darwin-arm64": "1.10.8", + "@node-rs/crc32-darwin-x64": "1.10.8", + "@node-rs/crc32-freebsd-x64": "1.10.8", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.8", + "@node-rs/crc32-linux-arm64-gnu": "1.10.8", + "@node-rs/crc32-linux-arm64-musl": "1.10.8", + "@node-rs/crc32-linux-x64-gnu": "1.10.8", + "@node-rs/crc32-linux-x64-musl": "1.10.8", + "@node-rs/crc32-win32-arm64-msvc": "1.10.8", + "@node-rs/crc32-win32-ia32-msvc": "1.10.8", + "@node-rs/crc32-win32-x64-msvc": "1.10.8" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.8.tgz", + "integrity": "sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.8.tgz", + "integrity": "sha512-gvEOdU8tkSwK2McdlMeXDoyqtvJFPYShNopJtGaGMNoltN5EzFOsM/7XKF3rk9JLQ9GnZHQrCxur0Cksonow2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.8.tgz", + "integrity": "sha512-ylDspj/s3i+FCdTGoLZtOBsbR2PvAsI5taJDE5Pl7kDgeKX7IywdAYQtkh2orvTs0RqCjcnIBxrCDI/O/r+z1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.8.tgz", + "integrity": "sha512-DVTORLWomcx7F0CxQxlfBszAcMh8IkNGx/hgYHQ4iDnuf/uVjhs7l1hoVBghPymdFxP129+I/+b1WoQBbTJzaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.8.tgz", + "integrity": "sha512-ZzuW7RS9VFy7zD64YUFI2dK6tJrlAFfe1NBuNx/ouKZ6SK+NHW5F8hxZNrZCkv2XB7QyZk9wZ53oJ20PKylQTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.8.tgz", + "integrity": "sha512-5/2kW7V4KMV3o37ApvcnF4Pk11luzLnd38kQZowVCw5D/xGlCByEQOODfJHX9pv0C+4hp+8Dosul06RODjPEUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.8.tgz", + "integrity": "sha512-Vr1P3+WF+RsUVI8CNLiunl1vuzdpgTR1sf9PxgQFr3o//hh2KdqJeU4zZsFVTn+GbGZtqvRoba0wi8nKMzRLeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.8.tgz", + "integrity": "sha512-eS8YXANJLBqdOzf5jCNVEXiDI4wjXVqQybaJextJJINvHhnhhOXPar8402GMagewOxGxYsy4C69G8rBIzqL4cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.8.tgz", + "integrity": "sha512-DFd0eV47MAykz4PQDcBSlEvY3CtXqPt2gkBpvPngCpOUoIJgEk9tKtPjEgUwLatMkvsy4fEvAg7gckGickOyEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.8.tgz", + "integrity": "sha512-U7fP8FjDQuOb61YoT8EoWDIJ9x40ZrCzM6OcAH1wqYiu9Z94U14S+i8KwTC7YorotGtICdYZ/APn1eI8vNne5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.8.tgz", + "integrity": "sha512-VJezyT9OWnMPnArDR5ok0ARLemiL83LLNYh4MC++oHoLegqzyBllqNlO8fVp2PmczGkn+rfKkZbtG4w3MQ6BbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-ia32-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.8.tgz", + "integrity": "sha512-01HooQK7WiG07/vvp0XzRnqSC5cvXvQ1MsQwlH9wwMWZ9qqHRO1j08cSeSt7DM4eALNisfX83bjqqrHzSKI9Iw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-x64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.8.tgz", + "integrity": "sha512-4O6G7yme7s98LUGwiqW7q2QqYmWD2IRQvV+q7+S4vjkUWiXUMbP8QEPYP9cF2WEQSdXCkyDEqDW10zeUdniPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.34", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.34.tgz", + "integrity": "sha512-bC1EBL/QJ6LpC7liKZXr5JA+oa9qDAwTzpEgPxPsx1pbIUdhKOqg3/yoPEQgoa4YdO3hsgCWNFmbDE/DeOl/Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.34", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.34", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.34.tgz", + "integrity": "sha512-R9QWUpcngtdQ6FCixz7NC6n1WsSho6CEuRUzYHz/CzZcp3miSklDzXkm0oDwXVCnuEu+ANoeM0f6RDp51kOMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@opentui/core": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.4.5.tgz", + "integrity": "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bun-ffi-structs": "0.2.4", + "diff": "9.0.0", + "marked": "17.0.1", + "string-width": "7.2.0", + "strip-ansi": "7.1.2" + }, + "optionalDependencies": { + "@opentui/core-darwin-arm64": "0.4.5", + "@opentui/core-darwin-x64": "0.4.5", + "@opentui/core-linux-arm64": "0.4.5", + "@opentui/core-linux-arm64-musl": "0.4.5", + "@opentui/core-linux-x64": "0.4.5", + "@opentui/core-linux-x64-musl": "0.4.5", + "@opentui/core-win32-arm64": "0.4.5", + "@opentui/core-win32-x64": "0.4.5" + }, + "peerDependencies": { + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/@opentui/core-darwin-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.4.5.tgz", + "integrity": "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-darwin-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.4.5.tgz", + "integrity": "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-linux-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.4.5.tgz", + "integrity": "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-arm64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.4.5.tgz", + "integrity": "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.4.5.tgz", + "integrity": "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64-musl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.4.5.tgz", + "integrity": "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-win32-arm64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.4.5.tgz", + "integrity": "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core-win32-x64": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.4.5.tgz", + "integrity": "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/keymap": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/keymap/-/keymap-0.4.5.tgz", + "integrity": "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@opentui/core": "0.4.5" + }, + "peerDependencies": { + "@opentui/react": "0.4.5", + "@opentui/solid": "0.4.5", + "react": ">=19.2.0", + "solid-js": "1.9.12" + }, + "peerDependenciesMeta": { + "@opentui/react": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "react": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opentui/solid": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@opentui/solid/-/solid-0.4.5.tgz", + "integrity": "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "7.28.0", + "@babel/preset-typescript": "7.27.1", + "@opentui/core": "0.4.5", + "babel-plugin-module-resolver": "5.0.2", + "babel-preset-solid": "1.9.12", + "entities": "7.0.1", + "s-js": "^0.4.9" + }, + "peerDependencies": { + "solid-js": "1.9.12" + } + }, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-sarif-builder": "^3.2.0" + } + }, + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/ast-node-types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.8.0.tgz", + "integrity": "sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.8.0.tgz", + "integrity": "sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.8.0", + "@textlint/resolver": "15.8.0", + "@textlint/types": "15.8.0", + "debug": "^4.4.3", + "js-yaml": "^4.3.0", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=20.18.0" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@textlint/module-interop": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.8.0.tgz", + "integrity": "sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/resolver": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.8.0.tgz", + "integrity": "sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/types": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.8.0.tgz", + "integrity": "sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@textlint/ast-node-types": "15.8.0" + } + }, + "node_modules/@types/bun": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz", + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.14" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.90.0.tgz", + "integrity": "sha512-oT+ZJL7qHS9Z8bs0+WKf/kQ27qWYR3trsXpq46YDjFqBsMLG4ygGGjPaJ2tyrH0wJzjOEmDyg9PDJBBhWg9pkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.2.1.tgz", + "integrity": "sha512-AY9vBjwExakK1c0cI/3NN2Ey0EgiKLBye/fxl/ue+o4q6RZ7N+xzd1jAD6eI6eBeMVANi617+V2rxIAkDPco2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^11.0.0", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", + "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions": { + "version": "0.40.10", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.10.tgz", + "integrity": "sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.20.12" + } + }, + "node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/babel-plugin-module-resolver": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.2.tgz", + "integrity": "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-babel-config": "^2.1.1", + "glob": "^9.3.3", + "pkg-up": "^3.1.0", + "reselect": "^4.1.7", + "resolve": "^1.22.8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/glob": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", + "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "minimatch": "^8.0.2", + "minipass": "^4.2.4", + "path-scurry": "^1.6.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/babel-plugin-module-resolver/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/minipass": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.8.tgz", + "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/babel-plugin-module-resolver/node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/babel-preset-solid": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", + "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.6" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "solid-js": "^1.9.12" + }, + "peerDependenciesMeta": { + "solid-js": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bun-ffi-structs": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.2.4.tgz", + "integrity": "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-keychain": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cross-keychain/-/cross-keychain-1.1.0.tgz", + "integrity": "sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.6", + "meow": "^14.0.0" + }, + "bin": { + "cross-keychain": "dist/cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cursor-digital-pet": { + "resolved": "packages/cursor-digital-pet", + "link": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.445", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.445.tgz", + "integrity": "sha512-iX7o1nF+qNb3Bv5mLYgqfgF9Znh9e/8MKXdmbb8Gv2Q47OKIzTwEPD1oda1HrgEwYatTLJj0wQjHvIFt8Db53Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-babel-config": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz", + "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.3" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-it-type": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalthis": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.97.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.97.0.tgz", + "integrity": "sha512-6Pu8F0eXGZlyz0EepPJrWgWDcBI8rKt2Wj8exOJU1tjVbu/VyZy5ZaLYS5N+Jp/Y3IP3sIRKcIOElTu7uC6KKA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ovsx": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-1.2.0.tgz", + "integrity": "sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==", + "dev": true, + "license": "EPL-2.0", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@vscode/vsce": "^3.7.1", + "commander": "^6.2.1", + "cross-keychain": "^1.1.0", + "follow-redirects": "^1.16.0", + "is-ci": "^2.0.0", + "leven": "^3.1.0", + "semver": "^7.6.0", + "tmp": "^0.2.3", + "yauzl-promise": "^4.0.0" + }, + "bin": { + "ovsx": "bin/ovsx" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/ovsx/node_modules/@vscode/vsce/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ovsx/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ovsx/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ovsx/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/ovsx/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ovsx/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ovsx/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ovsx/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ovsx/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ovsx/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ovsx/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ovsx/node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-map": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz", + "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/peerjs": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.5.5.tgz", + "integrity": "sha512-viMUCPDL6CSfOu0ZqVcFqbWRXNHIbv2lPqNbrBIjbFYrflebOjItJ4hPfhjnuUCstqciHVu9vVJ7jFqqKi/EuQ==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^2.8.0", + "eventemitter3": "^4.0.7", + "peerjs-js-binarypack": "^2.1.0", + "webrtc-adapter": "^9.0.0" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, + "node_modules/peerjs-js-binarypack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-2.1.0.tgz", + "integrity": "sha512-YIwCC+pTzp3Bi8jPI9UFKO0t0SLo6xALnHkiNt/iUFmUUZG0fEEmEyFKvjsDKweiFitzHRyhuh6NvyJZ4nNxMg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/peer" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/s-js": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", + "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-invariant": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", + "integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/solid-js": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", + "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.0", + "seroval-plugins": "~1.5.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-hyperlinks/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yauzl-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", + "integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@node-rs/crc32": "^1.7.0", + "is-it-type": "^5.1.2", + "simple-invariant": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/cursor-digital-pet": { + "version": "0.5.1-dev.0", + "license": "Apache-2.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "@jcendal/digital-pet-webviews": "0.1.0-dev.0", + "sql.js": "^1.12.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/vscode": "1.90.0", + "@vscode/vsce": "3.2.1", + "ovsx": "1.2.0", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": ">=22.13.0", + "vscode": "^1.101.0" + } + }, + "packages/digital-pet-animation": { + "name": "@jcendal/digital-pet-animation", + "version": "0.2.0-dev.0", + "dependencies": { + "@jcendal/digital-pet-core": "0.2.0-dev.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0" + } + }, + "packages/digital-pet-core": { + "name": "@jcendal/digital-pet-core", + "version": "0.2.0-dev.0", + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0" + } + }, + "packages/digital-pet-fields": { + "name": "@jcendal/digital-pet-fields", + "version": "0.1.0-dev.0", + "dependencies": { + "@jcendal/digital-pet-core": "0.2.0-dev.0" + } + }, + "packages/digital-pet-webviews": { + "name": "@jcendal/digital-pet-webviews", + "version": "0.1.0-dev.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "@jcendal/digital-pet-fields": "0.1.0-dev.0" + } + }, + "packages/opencode-digital-pet": { + "name": "@jcendal/opencode-digital-pet", + "version": "0.3.1-dev.0", + "license": "Apache-2.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "solid-js": "1.9.12" + }, + "bin": { + "opencode-digital-pet": "dist/cli.js" + }, + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@opencode-ai/plugin": "^1.18.10", + "@opencode-ai/sdk": "^1.18.10", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "opencode": ">=1.18.10" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.10 <2", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5" + } + }, + "packages/web-digital-pet": { + "name": "@jcendal/web-digital-pet", + "version": "0.1.0-dev.0", + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "@jcendal/digital-pet-fields": "0.1.0-dev.0", + "@jcendal/digital-pet-webviews": "0.1.0-dev.0", + "peerjs": "^1.5.5" + }, + "engines": { + "node": ">=24.11.0" + } + } + } +} diff --git a/package.json b/package.json index 1d0e87b..d92d23a 100644 --- a/package.json +++ b/package.json @@ -1,99 +1,46 @@ { - "name": "@sbugallo/opencode-vpet", - "version": "0.2.0-dev.0", - "description": "An OpenCode virtual-pet plugin with /vpet-spawn, /vpet-freeze, /vpet-unfreeze, /vpet-set, /vpet-dex, and /vpet-history commands.", - "author": "Sergio Bugallo", - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "git+https://github.com/sbugallo/opencode-vpet.git" - }, - "homepage": "https://github.com/sbugallo/opencode-vpet#readme", - "bugs": { - "url": "https://github.com/sbugallo/opencode-vpet/issues" - }, - "keywords": [ - "opencode", - "plugin", - "virtual-pet", - "vpet", - "tui" + "name": "@jcendal/digital-pet-workspace", + "version": "0.0.0", + "type": "module", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" ], - "publishConfig": { - "access": "public" - }, - "oc-plugin": [ - "server", - "tui" + "license": "Apache-2.0", + "packageManager": "npm@11.6.1", + "workspaces": [ + "packages/*" ], - "packageManager": "bun@1.3.5", - "engines": { - "bun": ">=1.3.5", - "node": ">=20", - "opencode": ">=1.18.10" - }, - "main": "./dist/index.js", - "bin": { - "opencode-vpet": "./dist/cli.js" - }, - "dependencies": { - "solid-js": "^1.9.14" + "scripts": { + "test": "npm run test --workspace @jcendal/digital-pet-animation && npm run test --workspace @jcendal/opencode-digital-pet && npm run test --workspace cursor-digital-pet && npm run test --workspace @jcendal/web-digital-pet && npm run test --workspace @jcendal/digital-pet-fields", + "test:persistence": "npm run test:persistence --workspace @jcendal/opencode-digital-pet", + "format": "biome format --write packages scripts package.json biome.json tsconfig*.json", + "format:check": "biome format packages scripts package.json biome.json tsconfig*.json", + "lint": "biome lint packages scripts package.json biome.json tsconfig*.json", + "check": "biome ci packages scripts package.json biome.json tsconfig*.json && node scripts/check-workspace-dependencies.mjs && npm run typecheck:release-metadata && npm run typecheck --workspace @jcendal/digital-pet-core && npm run typecheck --workspace @jcendal/digital-pet-fields && npm run typecheck --workspace @jcendal/digital-pet-animation && npm run typecheck --workspace @jcendal/digital-pet-webviews && npm run check --workspace @jcendal/opencode-digital-pet && npm run check --workspace cursor-digital-pet && npm run check --workspace @jcendal/web-digital-pet", + "build": "npm run build --workspace @jcendal/opencode-digital-pet && npm run build --workspace cursor-digital-pet && npm run build --workspace @jcendal/web-digital-pet", + "web": "npm run build --workspace @jcendal/web-digital-pet && npm run start --workspace @jcendal/web-digital-pet", + "pack:opencode": "npm pack --workspace @jcendal/opencode-digital-pet", + "release:check": "npm run release:check --workspace @jcendal/opencode-digital-pet", + "release-metadata": "node --experimental-strip-types scripts/release-metadata.ts", + "typecheck:release-metadata": "tsc -p tsconfig.release-metadata.json --noEmit" }, "devDependencies": { "@biomejs/biome": "2.5.10", - "@opencode-ai/plugin": "^1.18.10", - "@opencode-ai/sdk": "^1.18.10", - "@opentui/core": "^0.4.5", - "@opentui/keymap": "^0.4.5", - "@opentui/solid": "^0.4.5", - "@types/bun": "latest", - "@types/semver": "^7.7.1", - "semver": "^7.7.4", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.18.10", - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5" - }, - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./server": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "default": "./dist/index.js" - }, - "./tui": { - "types": "./dist/tui.d.ts", - "import": "./dist/tui.js", - "default": "./dist/tui.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "test": "bun test", - "test:persistence": "bun test ./tests/persistence/sqlite-migrations.persistence.ts ./tests/persistence/sqlite-vpet-repository.persistence.ts", - "format": "biome format --write src tests scripts package.json biome.json tsconfig*.json", - "format:check": "biome format src tests scripts package.json biome.json tsconfig*.json", - "lint": "biome lint src tests scripts package.json biome.json tsconfig*.json", - "lint:fix": "biome lint --write src tests scripts package.json biome.json tsconfig*.json", - "check": "biome ci src tests scripts package.json biome.json tsconfig*.json && bun run typecheck && bun run typecheck:tests && bun run typecheck:release-metadata", - "typecheck": "tsc --noEmit", - "typecheck:tests": "tsc -p tsconfig.test.json --noEmit", - "typecheck:release-metadata": "tsc -p tsconfig.release-metadata.json --noEmit", - "build": "bun scripts/build.ts", - "prepack": "bun scripts/prepack.ts", - "release:check": "bun run check && bun test && bun run prepack && npm pack --dry-run --json" + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "esbuild": "0.28.2", + "semver": "7.7.4", + "typescript": "5.9.3" }, - "type": "module", - "types": "./dist/index.d.ts" + "engines": { + "node": "24.11.0", + "npm": ">=11.5.1", + "bun": "1.3.5" + }, + "volta": { + "node": "24.11.0", + "bun": "1.3.5" + } } diff --git a/packages/cursor-digital-pet/.vscodeignore b/packages/cursor-digital-pet/.vscodeignore new file mode 100644 index 0000000..633fd28 --- /dev/null +++ b/packages/cursor-digital-pet/.vscodeignore @@ -0,0 +1,16 @@ +** +!package.json +!README.md +!LICENSE +!CHANGELOG.md +!icon.png +!images/ +!images/*.png +!images/*.jpg +!hook-bridge.js +!dist/extension.js +!dist/sql-wasm.wasm +!media/ +!media/fonts/ +!media/fonts/Silkscreen-Regular.ttf +!media/fonts/OFL.txt diff --git a/packages/cursor-digital-pet/CHANGELOG.md b/packages/cursor-digital-pet/CHANGELOG.md new file mode 100644 index 0000000..6d0d262 --- /dev/null +++ b/packages/cursor-digital-pet/CHANGELOG.md @@ -0,0 +1,61 @@ +# Cursor Digital Pet changelog + +Changes to the Cursor extension are recorded here. OpenCode releases have their own changelog. + +## [Unreleased] + +## [0.5.0] - 2026-10-06 + +### Changed + +- Feat/cursor pet display + + +## [0.4.0] - 2026-10-06 + +### Changed + +- feat(cursor): add generation history panel and shared LCD webview UI + + +## [0.3.0] - 2026-10-06 + +### Changed + +- Feat/cursor digidex + + +## [0.2.4] - 2026-10-06 + +### Changed + +- chore(digital-pet-core): mark stages for a release probe + + +## [0.2.3] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.2] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.1] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.0] - 2026-10-06 + +### Added + +- Cursor extension with an Explorer sidebar, partner controls, and Agent usage tracking. +- Shared pet database support, so Cursor and OpenCode can show the same partner. diff --git a/packages/cursor-digital-pet/LICENSE b/packages/cursor-digital-pet/LICENSE new file mode 100644 index 0000000..3d6428f --- /dev/null +++ b/packages/cursor-digital-pet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sergio Bugallo Enjamio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cursor-digital-pet/README.md b/packages/cursor-digital-pet/README.md new file mode 100644 index 0000000..ff39ee6 --- /dev/null +++ b/packages/cursor-digital-pet/README.md @@ -0,0 +1,303 @@ +
+
+

Cursor Digital Pet logo

+

Cursor Digital Pet

+

Digimon virtual pet for Cursor that evolves as you use Agent — shares progress with OpenCode Digital Pet

+

+ Version + Downloads + Rating + Apache 2.0 license +

+

+ Features • Installation • Usage • + Commands • Settings • Storage • + How It Works • Authors +

+

+ Install from Open VSX +    + Download VSIX from GitHub Releases +

+
+
+ +--- + +
+
+Cursor Digital Pet sidebar showing a partner and its evolution progress +Cursor Digital Pet partner animation in the sidebar +Cursor Digital Pet battle scene in the sidebar +
+
+ +--- + +## 📖 About + +**Cursor Agent** turns every prompt into real work — but what if that effort also fed a partner +that grows with you? **Cursor Digital Pet** adds a Digimon virtual pet to the Explorer sidebar. Your +partner gains experience from Agent token usage and evolves through classic Digimon stages. + +It shares the same `pet.db` database as +[@jcendal/opencode-digital-pet](https://www.npmjs.com/package/@jcendal/opencode-digital-pet), so progress +carries over between Cursor and OpenCode. + +### 🎯 Key Highlights + +- 🐣 **Evolves with Agent usage** — Input, output, and cache tokens from Agent turns count +- 🔄 **Shared with OpenCode** — Same `pet.db`, Dex, and generation history across tools +- 🎮 **650 Digimon** — Evolution lines extracted from the original V-Pets +- 📊 **Sidebar + panels** — Live partner view, Dex browser, and generation history +- 🪝 **Reliable tracking** — Cursor hooks with API watermark fallback +- 🔒 **Privacy-first** — Local SQLite storage, no telemetry +- ⏸️ **Freeze anytime** — Pause progression without losing your partner + +--- + +## ✨ Features + +| Feature | Description | +| --- | --- | +| **Sidebar webview** | Compact pixel LCD partner view with evolution progress, shared combat/evolution animations, and Dex/History shortcuts | +| **Digidex panel** | Pixel LCD collection browser with grid/list layouts, search, stage filters, and evolution records | +| **History panel** | Browse current and retired generations, their recorded evolution journeys, and creation/retirement dates in a pixel LCD layout | +| **Token tracking** | Cursor hooks (`beforeSubmitPrompt` + `stop`) with API watermark fallback | +| **Shared database** | Compatible `pet.db` with the OpenCode Digital Pet plugin | +| **Partner commands** | Spawn, freeze, unfreeze, and set Digimon by catalog ID | +| **Hook management** | Install or uninstall hooks from the Command Palette | + +--- + +## 📦 Installation + +### Open VSX (Cursor) + +Open Quick Open (`Ctrl+P` / `Cmd+P`) and run: + +``` +ext install jcendal.cursor-digital-pet +``` + +Or search for **Cursor Digital Pet** in the Extensions sidebar, or install from +[Open VSX](https://open-vsx.org/extension/jcendal/cursor-digital-pet). + +### VSIX (manual) + +Download the `.vsix` from +[GitHub Releases](https://github.com/jcendal/digital-pet/releases) and run +**Extensions → Install from VSIX…**. + +--- + +## 🚀 Usage + +### First run — install hooks + +On first activation, the extension prompts you to register hooks in `~/.cursor/hooks.json`. +Hooks are required for reliable Agent token tracking. + +You can also run **Cursor Digital Pet: Install Hooks** from the Command Palette at any time. + +### Sidebar + +Open the **Digital Pet** view in the Explorer sidebar to see your active partner, evolution gauge, +and animated artwork. + +### Hatch a partner + +1. Open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) +2. Run **Cursor Digital Pet: Spawn Partner** + +Your partner starts as an egg and evolves as you complete Agent turns. + +### Evolution battles + +Evolution sequences can show your partner in a battle scene inside the sidebar. + +### Digidex + +Run **Cursor Digital Pet: Open Dex** to browse the full catalog alongside a selected record. +Switch between grid and list layouts, search by name or catalog ID, and filter by stage or discovery. +Registered Digimon show their pixel sprite, first registration, generation count and evolution routes. +Undiscovered entries stay hidden until you raise them. Arrow keys, Home and End navigate the catalog. +The panel refreshes when your shared database changes and remembers your filters and selection. + +![Digidex with brown V-Pet casing, green LCD, collection list and Komondomon record (sample archive)](images/digidex-browser.jpg) + +### Other access methods + +- **Command Palette** — all `Cursor Digital Pet:` commands +- **Explorer sidebar** — **Digital Pet** webview panel + +--- + +## 🎮 Commands + +| Command | Description | +| --- | --- | +| **Cursor Digital Pet: Spawn Partner** | Hatches a new egg | +| **Cursor Digital Pet: Freeze** | Pauses partner progression | +| **Cursor Digital Pet: Unfreeze** | Resumes partner progression | +| **Cursor Digital Pet: Set Digimon** | Overrides the partner with a catalog ID (e.g. `3-001`) | +| **Cursor Digital Pet: Open Dex** | Opens the partner Dex panel | +| **Cursor Digital Pet: Open History** | Opens the independent generation browser with search, status filters, and recorded evolution timelines | +| **Cursor Digital Pet: Install Hooks** | Registers hooks in `~/.cursor/hooks.json` | +| **Cursor Digital Pet: Uninstall Hooks** | Removes Digital Pet hooks from `~/.cursor/hooks.json` | + +> **Note:** Using **Set Digimon** does not count toward Dex completion. + +--- + +## 📝 Examples + +### Shared database with OpenCode + +By default, Cursor Digital Pet and OpenCode Digital Pet resolve the same `pet.db` path. +If you override the path in Cursor, point it to the database used by OpenCode: + +```json +{ + "digital-pet.databasePath": "/path/to/opencode-digital-pet/pet.db" +} +``` + +### Slower API watermark fallback + +If token deltas are not captured immediately after Agent turns, increase the settle delay: + +```json +{ + "digital-pet.usage.settleDelayMs": 8000 +} +``` + +--- + +## ⚙️ Settings + +Configure via **File → Preferences → Settings** and search for `digital-pet`. + +| Setting | Default | Description | +| --- | --- | --- | +| `databasePath` | _(empty)_ | Override path to `pet.db` (default: shared opencode-digital-pet location) | +| `usage.settleDelayMs` | `4000` | Initial delay before API watermark fallback (ms) | + +> Changing `databasePath` requires reloading the Cursor window. + +Stage labels and evolution thresholds use the built-in defaults from `@jcendal/digital-pet-core` +(Japanese naming by default). The OpenCode plugin can override these via +[`opencode-digital-pet.json`](https://github.com/jcendal/digital-pet/blob/main/packages/opencode-digital-pet/README.md#settings); Cursor Digital Pet does +not read that file yet. + +--- + +## 🔧 Requirements + +| Requirement | Details | +| --- | --- | +| **Cursor** | Token tracking requires Cursor (`state.vscdb` must be present) | +| **Extension host** | Node.js 22.13 or newer for native SQLite access to the shared `pet.db` | +| **Node.js** | Required on the system path to run `hook-bridge.js` from Cursor hooks | +| **Hooks** | `beforeSubmitPrompt` and `stop` entries in `~/.cursor/hooks.json` | + +The extension shows a warning if it cannot detect the Cursor runtime. VS Code alone is not +enough for token tracking, though the sidebar and panels still work with an existing `pet.db`. + +--- + +## 💾 Storage + +Partner state, Dex discoveries, and generation history are stored in a SQLite database named +`pet.db`. By default it uses the same location as OpenCode Digital Pet: + +| Platform | Default path | +| --- | --- | +| **macOS** | `~/Library/Application Support/opencode-digital-pet/pet.db` | +| **Linux / WSL** | `~/.local/share/opencode-digital-pet/pet.db` | +| **Windows** | `%APPDATA%\opencode-digital-pet\pet.db` | + +Override with `digital-pet.databasePath` to use a custom file. +If the new database does not exist but an older `opencode-vpet/pet.db` does, the +extension continues using the older file, as does the OpenCode plugin. + +--- + +## 🏗️ How It Works + +```text +Cursor hooks → hook-bridge.js → digital-pet-hook-events.jsonl + ↓ + extension usage pipeline → pet.db + ↓ + Sidebar · Dex · History +``` + +1. Cursor hooks fire on `beforeSubmitPrompt` and `stop` for each Agent turn +2. `hook-bridge.js` appends structured events to `~/.cursor/digital-pet-hook-events.jsonl` +3. The extension watches the event log and maps stop payloads to completed usage +4. If stop tokens are missing, it falls back to Cursor's usage API (watermark delta) +5. Usage is applied through `@jcendal/digital-pet-core` evolution logic into `pet.db` +6. The sidebar webview refreshes with updated partner state and artwork + +--- + +## 🔒 Privacy + +- ✅ No extension telemetry or analytics +- ✅ Partner data stays in your local `pet.db` +- ✅ Hook events are written only to `~/.cursor/digital-pet-hook-events.jsonl` +- ⚠️ API watermark fallback reads your Cursor access token from local `state.vscdb` and + queries `cursor.com/api/dashboard/get-filtered-usage-events` to compute token deltas +- ⚠️ When stop hooks include token counts, no network request is needed for that turn + +--- + +## 🐛 Known Issues + +| Issue | Workaround | +| --- | --- | +| Tokens not recorded after Agent turns | Run **Cursor Digital Pet: Install Hooks** and retry | +| Missing token data in stop hook | Increase `digital-pet.usage.settleDelayMs` (e.g. `8000`) | +| Running in VS Code instead of Cursor | Token tracking is unavailable; use Cursor for Agent usage | +| Database path change not applied | Reload the Cursor window after changing settings | + +--- + +## 🤝 Contributing + +Contributions, issues, and feature requests are welcome in the monorepo! + +1. Fork the [repository](https://github.com/jcendal/digital-pet) +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +Package path: [`packages/cursor-digital-pet`](https://github.com/jcendal/digital-pet/tree/main/packages/cursor-digital-pet) + +--- + +## 👤 Authors + +| Role | Name | +| --- | --- | +| **Maintainer** | [Jorge Cendal](https://github.com/jcendal) | +| **Original author and contributor** | [Sergio Bugallo](https://github.com/sbugallo) | + +--- + +## 📜 License + +[Apache-2.0](LICENSE) + +Part of the [Digital Pet](https://github.com/jcendal/digital-pet) monorepo. Digimon +sprites were derived from community sources — see the +[root README credits](https://github.com/jcendal/digital-pet#maintainer-and-credits). + +--- + +

Built with ❤️ for the developer community

+ +

⬆ Back to Top

diff --git a/packages/cursor-digital-pet/esbuild.mjs b/packages/cursor-digital-pet/esbuild.mjs new file mode 100644 index 0000000..3cc6f5d --- /dev/null +++ b/packages/cursor-digital-pet/esbuild.mjs @@ -0,0 +1,36 @@ +import { copyFile, mkdir, rm } from "node:fs/promises" +import { createRequire } from "node:module" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { build, context } from "esbuild" + +const packageRoot = dirname(fileURLToPath(import.meta.url)) +const distDirectory = resolve(packageRoot, "dist") +const require = createRequire(import.meta.url) +const wasmSource = require.resolve("sql.js/dist/sql-wasm.wasm") +await rm(distDirectory, { recursive: true, force: true }) +await mkdir(distDirectory, { recursive: true }) +await copyFile(wasmSource, resolve(distDirectory, "sql-wasm.wasm")) + +const options = { + entryPoints: [resolve(packageRoot, "src/extension.ts")], + outfile: resolve(distDirectory, "extension.js"), + bundle: true, + platform: "node", + target: "node22.13", + format: "cjs", + external: ["vscode", "bun:sqlite"], + define: { __DIGITAL_PET_BUNDLE__: "true", "import.meta.url": "undefined" }, + minify: true, + sourcemap: false, + logLevel: "info", +} + +if (process.argv.includes("--watch")) { + const buildContext = await context(options) + await buildContext.watch() + console.log("Watching cursor-digital-pet") +} else { + await build(options) +} diff --git a/packages/cursor-digital-pet/hook-bridge.js b/packages/cursor-digital-pet/hook-bridge.js new file mode 100644 index 0000000..d7fb6e7 --- /dev/null +++ b/packages/cursor-digital-pet/hook-bridge.js @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * Cursor hook bridge — reads hook JSON from stdin, appends to events file, responds {}. + * Fail-open: never blocks Cursor on errors. + */ +import { appendFileSync, mkdirSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { Readable } from "node:stream" + +const EVENTS_PATH = + process.env.DIGITAL_PET_HOOK_EVENTS_PATH ?? join(homedir(), ".cursor", "digital-pet-hook-events.jsonl") + +const readStdin = async () => { + const chunks = [] + for await (const chunk of Readable.toWeb(process.stdin)) { + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString("utf8") +} + +const main = async () => { + const raw = await readStdin() + if (raw.trim().length === 0) { + process.stdout.write("{}") + return + } + + let payload + try { + payload = JSON.parse(raw) + } catch { + process.stdout.write("{}") + return + } + + const hookEventName = + typeof payload.hook_event_name === "string" ? payload.hook_event_name : process.argv[2] ?? "unknown" + + const record = { + receivedAt: new Date().toISOString(), + hookEventName, + payload, + } + + try { + mkdirSync(dirname(EVENTS_PATH), { recursive: true }) + appendFileSync(EVENTS_PATH, `${JSON.stringify(record)}\n`, "utf8") + } catch (error) { + console.error("[digital-pet-hook-bridge]", error) + } + + process.stdout.write("{}") +} + +main().catch((error) => { + console.error("[digital-pet-hook-bridge]", error) + process.stdout.write("{}") +}) diff --git a/packages/cursor-digital-pet/icon.png b/packages/cursor-digital-pet/icon.png new file mode 100644 index 0000000..bd1aab4 Binary files /dev/null and b/packages/cursor-digital-pet/icon.png differ diff --git a/packages/cursor-digital-pet/images/digidex-browser.jpg b/packages/cursor-digital-pet/images/digidex-browser.jpg new file mode 100644 index 0000000..6b7d5f6 Binary files /dev/null and b/packages/cursor-digital-pet/images/digidex-browser.jpg differ diff --git a/packages/cursor-digital-pet/images/history-browser.jpg b/packages/cursor-digital-pet/images/history-browser.jpg new file mode 100644 index 0000000..3879f0f Binary files /dev/null and b/packages/cursor-digital-pet/images/history-browser.jpg differ diff --git a/packages/cursor-digital-pet/images/sidebar-animation.png b/packages/cursor-digital-pet/images/sidebar-animation.png new file mode 100644 index 0000000..b89bdb6 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-animation.png differ diff --git a/packages/cursor-digital-pet/images/sidebar-battle-lcd.jpg b/packages/cursor-digital-pet/images/sidebar-battle-lcd.jpg new file mode 100644 index 0000000..fe916f7 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-battle-lcd.jpg differ diff --git a/packages/cursor-digital-pet/images/sidebar-battle.png b/packages/cursor-digital-pet/images/sidebar-battle.png new file mode 100644 index 0000000..b99a4a7 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-battle.png differ diff --git a/packages/cursor-digital-pet/images/sidebar-evolution-lcd.jpg b/packages/cursor-digital-pet/images/sidebar-evolution-lcd.jpg new file mode 100644 index 0000000..6eea3ff Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-evolution-lcd.jpg differ diff --git a/packages/cursor-digital-pet/images/sidebar-evolved-lcd.jpg b/packages/cursor-digital-pet/images/sidebar-evolved-lcd.jpg new file mode 100644 index 0000000..b570952 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-evolved-lcd.jpg differ diff --git a/packages/cursor-digital-pet/images/sidebar-partner-lcd.jpg b/packages/cursor-digital-pet/images/sidebar-partner-lcd.jpg new file mode 100644 index 0000000..470dff6 Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-partner-lcd.jpg differ diff --git a/packages/cursor-digital-pet/images/sidebar-partner.png b/packages/cursor-digital-pet/images/sidebar-partner.png new file mode 100644 index 0000000..77c709c Binary files /dev/null and b/packages/cursor-digital-pet/images/sidebar-partner.png differ diff --git a/packages/cursor-digital-pet/media/fonts/OFL.txt b/packages/cursor-digital-pet/media/fonts/OFL.txt new file mode 100644 index 0000000..3dd7cfb --- /dev/null +++ b/packages/cursor-digital-pet/media/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/cursor-digital-pet/media/fonts/Silkscreen-Regular.ttf b/packages/cursor-digital-pet/media/fonts/Silkscreen-Regular.ttf new file mode 100644 index 0000000..8caff81 Binary files /dev/null and b/packages/cursor-digital-pet/media/fonts/Silkscreen-Regular.ttf differ diff --git a/packages/cursor-digital-pet/package.json b/packages/cursor-digital-pet/package.json new file mode 100644 index 0000000..547cfe7 --- /dev/null +++ b/packages/cursor-digital-pet/package.json @@ -0,0 +1,149 @@ +{ + "name": "cursor-digital-pet", + "displayName": "Cursor Digital Pet", + "description": "Digimon virtual pet for Cursor that evolves with Agent token usage.", + "version": "0.5.1-dev.0", + "publisher": "jcendal", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/jcendal/digital-pet.git", + "directory": "packages/cursor-digital-pet" + }, + "homepage": "https://github.com/jcendal/digital-pet#readme", + "bugs": { + "url": "https://github.com/jcendal/digital-pet/issues" + }, + "keywords": [ + "cursor", + "digital-pet", + "virtual-pet", + "digimon", + "agent", + "ai", + "sidebar", + "opencode" + ], + "icon": "icon.png", + "galleryBanner": { + "color": "#1a1a2e", + "theme": "dark" + }, + "engines": { + "vscode": "^1.101.0", + "node": ">=22.13.0", + "bun": "1.3.5" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onStartupFinished" + ], + "main": "./dist/extension.js", + "contributes": { + "views": { + "explorer": [ + { + "id": "cursorDigitalPet.sidebar", + "name": "Digital Pet", + "type": "webview" + } + ] + }, + "commands": [ + { + "command": "cursorDigitalPet.spawn", + "title": "Cursor Digital Pet: Spawn Partner" + }, + { + "command": "cursorDigitalPet.freeze", + "title": "Cursor Digital Pet: Freeze" + }, + { + "command": "cursorDigitalPet.unfreeze", + "title": "Cursor Digital Pet: Unfreeze" + }, + { + "command": "cursorDigitalPet.set", + "title": "Cursor Digital Pet: Set Digimon" + }, + { + "command": "cursorDigitalPet.dex", + "title": "Cursor Digital Pet: Open Dex" + }, + { + "command": "cursorDigitalPet.history", + "title": "Cursor Digital Pet: Open History" + }, + { + "command": "cursorDigitalPet.installHooks", + "title": "Cursor Digital Pet: Install Hooks" + }, + { + "command": "cursorDigitalPet.uninstallHooks", + "title": "Cursor Digital Pet: Uninstall Hooks" + }, + { + "command": "cursorDigitalPet.dev.showMenu", + "title": "Cursor Digital Pet: Dev Tools" + } + ], + "menus": { + "commandPalette": [ + { + "command": "cursorDigitalPet.dev.showMenu", + "when": "cursorDigitalPet.devToolsEnabled" + } + ] + }, + "configuration": { + "title": "Cursor Digital Pet", + "properties": { + "digital-pet.databasePath": { + "type": "string", + "default": "", + "description": "Override path to pet.db (default: shared opencode-digital-pet/pet.db)" + }, + "digital-pet.usage.settleDelayMs": { + "type": "number", + "default": 4000, + "description": "Initial delay before API watermark fallback (ms)" + }, + "digital-pet.devTools": { + "type": "boolean", + "default": false, + "scope": "window", + "description": "Enable development tools for previewing feed, evolution reveal, and evolution battle animations." + } + } + } + }, + "scripts": { + "build": "node esbuild.mjs", + "watch": "node esbuild.mjs --watch", + "check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + "test": "bun test", + "package": "npx @vscode/vsce package --no-dependencies --baseImagesUrl https://github.com/jcendal/digital-pet/raw/HEAD/packages/cursor-digital-pet", + "publish:openvsx": "npx ovsx publish *.vsix", + "vscode:prepublish": "npm run build" + }, + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "@jcendal/digital-pet-webviews": "0.1.0-dev.0", + "sql.js": "^1.12.0" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/vscode": "1.90.0", + "@vscode/vsce": "3.2.1", + "ovsx": "1.2.0", + "typescript": "5.9.3" + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts new file mode 100644 index 0000000..fc267cb --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-api-watermark.ts @@ -0,0 +1,142 @@ +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +import { CURSOR_USAGE_EVENTS_URL } from "../../shared/constants/cursor.ts" +import { sleep } from "../../shared/sleep.ts" +import { readCursorAccessToken } from "./cursor-auth.ts" +import type { UsageApiEvent, UsageApiResponse, UsageWatermark } from "./types.ts" + +export const fingerprintEvent = (event: UsageApiEvent | undefined): string => { + if (event === undefined) return "none" + const tokens = event.tokenUsage + return `${event.timestamp}:${event.model}:${tokens?.inputTokens ?? 0}:${tokens?.outputTokens ?? 0}` +} + +export const hasValidTokens = (event: UsageApiEvent): boolean => { + if (!event.isTokenBasedCall || event.tokenUsage === undefined) return false + const { inputTokens = 0, outputTokens = 0, cacheWriteTokens = 0, cacheReadTokens = 0 } = event.tokenUsage + return inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens > 0 +} + +export const sumTokenDelta = (events: readonly UsageApiEvent[]): number => + events.reduce((sum, event) => { + const tokens = event.tokenUsage + if (tokens === undefined) return sum + return ( + sum + + (tokens.inputTokens ?? 0) + + (tokens.outputTokens ?? 0) + + (tokens.cacheWriteTokens ?? 0) + + (tokens.cacheReadTokens ?? 0) + ) + }, 0) + +export const isNewEvent = (event: UsageApiEvent, watermark: UsageWatermark): boolean => { + const timestampMs = Number(event.timestamp) + if (timestampMs < watermark.timestampMs) return false + if (timestampMs === watermark.timestampMs && fingerprintEvent(event) === watermark.fingerprint) return false + return hasValidTokens(event) +} + +const fetchUsagePage = async ( + accessToken: string, + body: Record, +): Promise => { + const response = await fetch(CURSOR_USAGE_EVENTS_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Origin: "https://cursor.com", + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new Error(`Usage API failed: ${response.status}`) + } + + return (await response.json()) as UsageApiResponse +} + +export const captureWatermark = async (): Promise => { + const accessToken = await readCursorAccessToken() + if (accessToken === null) { + return { timestampMs: Date.now(), fingerprint: "none" } + } + + const response = await fetchUsagePage(accessToken, { page: 1, pageSize: 1 }) + const latest = response.usageEventsDisplay?.find(hasValidTokens) + if (latest === undefined) { + return { timestampMs: Date.now(), fingerprint: "none" } + } + + return { + timestampMs: Number(latest.timestamp), + fingerprint: fingerprintEvent(latest), + } +} + +export const fetchNewEventsSince = async ( + watermark: UsageWatermark, + endDateMs: number, +): Promise => { + const accessToken = await readCursorAccessToken() + if (accessToken === null) return [] + + const collected: UsageApiEvent[] = [] + let page = 1 + const pageSize = 100 + + while (true) { + const response = await fetchUsagePage(accessToken, { + startDate: String(watermark.timestampMs), + endDate: String(endDateMs), + page, + pageSize, + }) + + const events = response.usageEventsDisplay ?? [] + for (const event of events) { + if (isNewEvent(event, watermark)) collected.push(event) + } + + const total = response.totalUsageEventsCount ?? 0 + if (page * pageSize >= total || events.length === 0) break + page += 1 + } + + return collected.sort((a, b) => Number(a.timestamp) - Number(b.timestamp)) +} + +export type SettleOptions = { + readonly initialDelayMs: number + readonly retryDelaysMs: readonly number[] +} + +export const settleTurnDelta = async ( + watermark: UsageWatermark, + generationId: string, + options: SettleOptions, +): Promise => { + const delays = [options.initialDelayMs, ...options.retryDelaysMs] + + for (const delayMs of delays) { + await sleep(delayMs) + const endDateMs = Date.now() + const newEvents = await fetchNewEventsSince(watermark, endDateMs) + if (newEvents.length === 0) continue + + const tokenDelta = sumTokenDelta(newEvents) + if (tokenDelta <= 0) continue + + return { + receiptKey: `cursor-turn:${generationId}`, + eventId: `cursor-api-delta:${generationId}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), + } + } + + return null +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts new file mode 100644 index 0000000..eda88a8 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-auth.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs" +import type { Database } from "sql.js" + +import { CURSOR_ACCESS_TOKEN_KEY } from "../../shared/constants/cursor.ts" +import { getSqlRuntime } from "../sqlite/sqljs-config.ts" +import { resolveStateVscdbPath } from "./paths.ts" + +const queryItemTable = (database: Database, key: string): string | null => { + const statement = database.prepare("SELECT value FROM ItemTable WHERE key = ?") + statement.bind([key]) + if (!statement.step()) { + statement.free() + return null + } + const row = statement.getAsObject() as { value?: unknown } + statement.free() + const cell = row.value + if (cell === null || cell === undefined) return null + return typeof cell === "string" ? cell : String(cell) +} + +export const readCursorAccessToken = async (): Promise => { + const statePath = resolveStateVscdbPath() + try { + const SQL = await getSqlRuntime() + const buffer = readFileSync(statePath) + const database = new SQL.Database(buffer) + try { + return queryItemTable(database, CURSOR_ACCESS_TOKEN_KEY) + } finally { + database.close() + } + } catch { + return null + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts new file mode 100644 index 0000000..8380f78 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-stop-mapper.ts @@ -0,0 +1,26 @@ +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +import type { CursorHookPayload } from "./types.ts" + +export const hasStopTokens = (payload: CursorHookPayload): boolean => { + const input = payload.input_tokens ?? 0 + const output = payload.output_tokens ?? 0 + return input + output > 0 +} + +export const toCompletedUsageFromStop = (payload: CursorHookPayload, receivedAt: string): CompletedUsage | null => { + if (!hasStopTokens(payload)) return null + + const generationId = payload.generation_id ?? payload.conversation_id + if (generationId === undefined || generationId.length === 0) return null + + const tokenDelta = (payload.input_tokens ?? 0) + (payload.output_tokens ?? 0) + + return { + receiptKey: `cursor-turn:${generationId}`, + eventId: `cursor-stop:${generationId}`, + tokenDelta, + cost: null, + createdAt: receivedAt, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts b/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts new file mode 100644 index 0000000..b868d04 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/cursor-usage-event-source.ts @@ -0,0 +1,145 @@ +import { watch } from "node:fs" +import { open, stat, writeFile } from "node:fs/promises" + +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" + +import { captureWatermark, settleTurnDelta } from "./cursor-api-watermark.ts" +import { hasStopTokens, toCompletedUsageFromStop } from "./cursor-stop-mapper.ts" +import { resolveHookEventsPath } from "./paths.ts" +import type { HookEventRecord, UsageWatermark } from "./types.ts" + +type TurnState = { + watermark: UsageWatermark +} + +export type CursorUsageSettleOptions = { + readonly settleDelayMs: number +} + +export type CursorUsageEventSource = { + dispose(): void +} + +const toSettleOptions = (options: CursorUsageSettleOptions) => ({ + initialDelayMs: options.settleDelayMs, + retryDelaysMs: [Math.max(options.settleDelayMs + 2000, 6000), Math.max(options.settleDelayMs + 6000, 10000)], +}) + +export const createCursorUsageEventSource = ( + ledger: UsageLedger, + onApplied?: (result: UsageProcessingResult) => void, + options: CursorUsageSettleOptions & { readonly eventsPath?: string } = { settleDelayMs: 4000 }, +): CursorUsageEventSource => { + const eventsPath = options.eventsPath ?? resolveHookEventsPath() + const settleOptions = toSettleOptions(options) + const pendingWatermarks = new Map() + const processedReceipts = new Set() + let disposed = false + let watcher: ReturnType | undefined + + const applyUsage = async (usage: CompletedUsage, source: string): Promise => { + if (processedReceipts.has(usage.receiptKey)) return + processedReceipts.add(usage.receiptKey) + const result = recordUsage({ + ledger, + usage, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: Math.random, + thresholds: STAGE_GAUGE_THRESHOLDS, + }) + console.log(`[cursor-digital-pet] ${source}`, result) + if (result.kind === "applied") onApplied?.(result) + } + + const handleBeforeSubmit = async (record: HookEventRecord): Promise => { + const conversationId = record.payload.conversation_id + const watermark = await captureWatermark() + const key = conversationId ?? `turn:${record.receivedAt}` + pendingWatermarks.set(key, { watermark }) + } + + const handleStop = async (record: HookEventRecord): Promise => { + const conversationId = record.payload.conversation_id + const generationId = record.payload.generation_id ?? conversationId + if (generationId === undefined) return + + if (hasStopTokens(record.payload)) { + const usage = toCompletedUsageFromStop(record.payload, record.receivedAt) + if (usage !== null) { + await applyUsage(usage, "hook-stop") + pendingWatermarks.delete(conversationId ?? generationId) + return + } + } + + const key = conversationId ?? generationId + const state = pendingWatermarks.get(key) + pendingWatermarks.delete(key) + const usage = await settleTurnDelta( + state?.watermark ?? { timestampMs: 0, fingerprint: "none" }, + generationId, + settleOptions, + ) + if (usage !== null) { + await applyUsage(usage, "api-watermark") + } + } + + const processLine = async (line: string): Promise => { + const trimmed = line.trim() + if (trimmed.length === 0) return + let record: HookEventRecord + try { + record = JSON.parse(trimmed) as HookEventRecord + } catch { + return + } + const eventName = record.hookEventName ?? record.payload.hook_event_name + if (eventName === "beforeSubmitPrompt") await handleBeforeSubmit(record) + if (eventName === "stop") await handleStop(record) + } + + const startWatch = async (): Promise => { + let offset = 0 + try { + const fileStat = await stat(eventsPath) + offset = fileStat.size + } catch { + await writeFile(eventsPath, "", "utf8") + } + + watcher = watch(eventsPath, async () => { + if (disposed) return + try { + const fileStat = await stat(eventsPath) + if (fileStat.size <= offset) return + const handle = await open(eventsPath, "r") + const length = fileStat.size - offset + const buffer = Buffer.alloc(length) + await handle.read(buffer, 0, length, offset) + await handle.close() + offset = fileStat.size + for (const line of buffer.toString("utf8").split("\n")) { + await processLine(line) + } + } catch (error) { + console.error("[cursor-digital-pet] hook watch error", error) + } + }) + } + + void startWatch() + + return { + dispose() { + disposed = true + watcher?.close() + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts b/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts new file mode 100644 index 0000000..4900751 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/install-hooks.ts @@ -0,0 +1,62 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" + +import { DIGITAL_PET_HOOK_MARKER } from "../../shared/constants/cursor.ts" +import { resolveHooksJsonPath } from "./paths.ts" + +export type HooksJson = { + readonly version: number + readonly hooks: Record +} + +const parseHooksJson = (raw: string): HooksJson => { + const parsed = JSON.parse(raw) as HooksJson + if (typeof parsed.version !== "number" || typeof parsed.hooks !== "object") { + throw new Error("Invalid hooks.json shape") + } + return parsed +} + +const isDigitalPetHook = (entry: { readonly command: string }): boolean => + entry.command.includes(DIGITAL_PET_HOOK_MARKER) + +export const installDigitalPetHooks = (bridgePath: string): { readonly hooksPath: string } => { + const hooksPath = resolveHooksJsonPath() + const command = `node "${bridgePath}"` + + mkdirSync(dirname(hooksPath), { recursive: true }) + + let config: HooksJson + try { + config = parseHooksJson(readFileSync(hooksPath, "utf8")) + } catch { + config = { version: 1, hooks: {} } + } + + const ensureHook = (name: string): void => { + const existing = config.hooks[name] ?? [] + config.hooks[name] = [...existing.filter((entry) => !isDigitalPetHook(entry)), { command }] + } + + ensureHook("beforeSubmitPrompt") + ensureHook("stop") + + writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`, "utf8") + return { hooksPath } +} + +export const uninstallDigitalPetHooks = (): boolean => { + const hooksPath = resolveHooksJsonPath() + try { + const config = parseHooksJson(readFileSync(hooksPath, "utf8")) + const nextHooks: HooksJson["hooks"] = {} + for (const [name, entries] of Object.entries(config.hooks)) { + const filtered = entries.filter((entry) => !isDigitalPetHook(entry)) + if (filtered.length > 0) nextHooks[name] = filtered + } + writeFileSync(hooksPath, `${JSON.stringify({ ...config, hooks: nextHooks }, null, 2)}\n`, "utf8") + return true + } catch { + return false + } +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/paths.ts b/packages/cursor-digital-pet/src/adapters/cursor/paths.ts new file mode 100644 index 0000000..e4f0677 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/paths.ts @@ -0,0 +1,32 @@ +import { homedir } from "node:os" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +declare const __DIGITAL_PET_BUNDLE__: boolean + +export const resolveCursorUserDir = (): string => { + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "Cursor", "User") + } + if (process.platform === "win32") { + const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming") + return join(appData, "Cursor", "User") + } + return join(homedir(), ".config", "Cursor", "User") +} + +export const resolveStateVscdbPath = (): string => join(resolveCursorUserDir(), "globalStorage", "state.vscdb") + +export const resolveHooksJsonPath = (): string => join(homedir(), ".cursor", "hooks.json") + +export const resolveHookEventsPath = (): string => + process.env.DIGITAL_PET_HOOK_EVENTS_PATH ?? join(homedir(), ".cursor", "digital-pet-hook-events.jsonl") + +export const resolveHookBridgePath = (): string => { + const override = process.env.DIGITAL_PET_HOOK_BRIDGE_PATH + if (override !== undefined && override.length > 0) return override + if (typeof __DIGITAL_PET_BUNDLE__ !== "undefined" && __DIGITAL_PET_BUNDLE__) { + return join(__dirname, "..", "hook-bridge.js") + } + return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "hook-bridge.js") +} diff --git a/packages/cursor-digital-pet/src/adapters/cursor/types.ts b/packages/cursor-digital-pet/src/adapters/cursor/types.ts new file mode 100644 index 0000000..6a9f82a --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/cursor/types.ts @@ -0,0 +1,41 @@ +export type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" + +export type UsageWatermark = { + readonly timestampMs: number + readonly fingerprint: string + readonly conversationId?: string +} + +export type HookEventRecord = { + readonly receivedAt: string + readonly hookEventName: string + readonly payload: CursorHookPayload +} + +export type CursorHookPayload = { + readonly hook_event_name?: string + readonly conversation_id?: string + readonly generation_id?: string + readonly input_tokens?: number + readonly output_tokens?: number + readonly cache_read_tokens?: number + readonly cache_write_tokens?: number + readonly status?: string +} + +export type UsageApiEvent = { + readonly timestamp: string + readonly model: string + readonly isTokenBasedCall?: boolean + readonly tokenUsage?: { + readonly inputTokens?: number + readonly outputTokens?: number + readonly cacheWriteTokens?: number + readonly cacheReadTokens?: number + } +} + +export type UsageApiResponse = { + readonly totalUsageEventsCount?: number + readonly usageEventsDisplay?: readonly UsageApiEvent[] +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts b/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts new file mode 100644 index 0000000..c6134df --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/database-change-watcher.ts @@ -0,0 +1,76 @@ +import { mkdirSync } from "node:fs" +import { watch } from "node:fs" +import { basename, dirname } from "node:path" + +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type DatabaseChangeWatcherOptions = HostPathOptions & { + readonly databasePath?: string + readonly onChange: () => void + readonly debounceMs?: number +} + +export type DatabaseChangeWatcher = { + dispose(): void +} + +type DirectoryWatcher = { close(): void } +export type WatchDirectory = ( + directory: string, + listener: (eventType: string, filename: string | null) => void, +) => DirectoryWatcher + +const isPetDatabaseFilename = (filename: string | null | undefined, databaseFileName: string): boolean => { + if (filename === undefined || filename === null) return true + return filename === databaseFileName || filename === `${databaseFileName}-wal` +} + +export const createDatabaseChangeWatcher = ( + options: DatabaseChangeWatcherOptions, + watchDirectory: WatchDirectory = watch, +): DatabaseChangeWatcher => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + const databaseDirectory = dirname(databasePath) + const databaseFileName = basename(databasePath) + const debounceMs = options.debounceMs ?? 200 + + let disposed = false + let debounceTimer: ReturnType | undefined + let watcher: DirectoryWatcher | undefined + + const scheduleChange = (): void => { + if (disposed) return + if (debounceTimer !== undefined) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + debounceTimer = undefined + if (!disposed) options.onChange() + }, debounceMs) + } + + const handleWatchEvent = (filename: string | null | undefined): void => { + if (disposed) return + if (!isPetDatabaseFilename(filename, databaseFileName)) return + scheduleChange() + } + + mkdirSync(databaseDirectory, { recursive: true }) + watcher = watchDirectory(databaseDirectory, (eventType, filename) => { + if (eventType !== "change" && eventType !== "rename") return + handleWatchEvent(filename) + }) + + return { + dispose() { + disposed = true + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + watcher?.close() + watcher = undefined + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts b/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts new file mode 100644 index 0000000..7726aa8 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/errors.ts @@ -0,0 +1,3 @@ +export const isRecoverableSqliteReadError = (error: unknown): boolean => + error instanceof Error && + (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/options.ts b/packages/cursor-digital-pet/src/adapters/sqlite/options.ts new file mode 100644 index 0000000..a8a3b06 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/options.ts @@ -0,0 +1,11 @@ +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type SqliteDatabaseOptions = HostPathOptions & { + readonly databasePath?: string +} + +export const resolveDatabasePath = (options: SqliteDatabaseOptions = {}): string => + options.databasePath ?? resolveHostDatabasePath(options) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts new file mode 100644 index 0000000..98bf280 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts @@ -0,0 +1,98 @@ +import { existsSync } from "node:fs" + +import { + ARCHIVE_PARTNER_EVENTS_SELECT, + ARCHIVE_PARTNERS_SELECT, + type PersistedPartnerEventRow, + type PersistedPartnerRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" +import type { + DigitalPetArchiveEvent, + DigitalPetArchivePartner, + DigitalPetArchiveResult, +} from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +import { UNAVAILABLE_ARCHIVE_MESSAGE } from "../../shared/constants/sqlite.ts" +import { isRecoverableSqliteReadError } from "./errors.ts" +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { openReadonlySqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteDigitalPetArchiveReaderOptions = SqliteDatabaseOptions + +const toArchiveEvent = (row: PersistedPartnerEventRow): DigitalPetArchiveEvent => ({ + eventId: row.event_id, + currentNodeId: row.current_node_id, + createdAt: row.created_at, +}) + +const toArchivePartner = ( + row: PersistedPartnerRow, + events: readonly DigitalPetArchiveEvent[], +): DigitalPetArchivePartner => ({ + partnerId: row.partner_id, + generation: row.generation, + createdAt: row.created_at, + retiredAt: row.retired_at, + events, +}) + +const groupEventsByPartnerId = ( + rows: readonly PersistedPartnerEventRow[], +): ReadonlyMap => { + const eventsByPartnerId = new Map() + for (const row of rows) { + const events = eventsByPartnerId.get(row.partner_id) + if (events === undefined) { + eventsByPartnerId.set(row.partner_id, [toArchiveEvent(row)]) + continue + } + events.push(toArchiveEvent(row)) + } + return eventsByPartnerId +} + +export const readSqliteDigitalPetArchive = (executor: Pick): DigitalPetArchiveResult => { + const partners = executor.all(ARCHIVE_PARTNERS_SELECT) + if (partners.length === 0) return { kind: "empty" } + + const eventsByPartnerId = groupEventsByPartnerId( + executor.all(ARCHIVE_PARTNER_EVENTS_SELECT), + ) + return { + kind: "available", + partners: partners.map((partner) => toArchivePartner(partner, eventsByPartnerId.get(partner.partner_id) ?? [])), + } +} + +export const createSqliteDigitalPetArchiveReader = async ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + return { + getArchive(): DigitalPetArchiveResult { + return readArchiveAtPath(databasePath) + }, + } +} + +const readArchiveAtPath = (databasePath: string): DigitalPetArchiveResult => { + if (!existsSync(databasePath)) return { kind: "empty" } + + try { + const database = openReadonlySqliteDatabase(databasePath) + try { + return readSqliteDigitalPetArchive(database.executor) + } finally { + database.close() + } + } catch (error) { + if (isRecoverableSqliteReadError(error)) return { kind: "unavailable", message: UNAVAILABLE_ARCHIVE_MESSAGE } + throw error + } +} + +export const readArchive = async ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => readArchiveAtPath(resolveDatabasePath(options)) diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts new file mode 100644 index 0000000..023904c --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts @@ -0,0 +1,45 @@ +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { createSqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { readSidebarSnapshotFromExecutor } from "./sqlite-sidebar-snapshot-reader.ts" +import { openWritableSqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteDigitalPetRepositoryOptions = SqliteDatabaseOptions + +export { resolveHostDatabasePath } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export { resolveDatabasePath } from "./options.ts" +export type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export type { + PersistedPartnerEvent, + SqliteDigitalPetWriteStore, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" + +export type CursorSqliteDigitalPetRepository = SqliteDigitalPetWriteStore & + SidebarSnapshotReader & { + reloadFromDisk(): void + } + +export const createSqliteDigitalPetRepository = async ( + options: CreateSqliteDigitalPetRepositoryOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + const database = openWritableSqliteDatabase(databasePath) + + const writeStore = createSqliteDigitalPetWriteStore({ + databasePath, + executor: database.executor, + close: () => { + database.close() + }, + }) + + return { + ...writeStore, + getSidebarSnapshot: () => readSidebarSnapshotFromExecutor(database.executor), + reloadFromDisk: () => { + database.reloadFromDisk() + }, + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts new file mode 100644 index 0000000..f5eb53b --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-driver.ts @@ -0,0 +1,108 @@ +import { mkdirSync } from "node:fs" +import { dirname } from "node:path" + +import type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +type SqliteValue = string | number | null + +type Statement = { + run(...params: SqliteValue[]): unknown + get(...params: SqliteValue[]): unknown + all(...params: SqliteValue[]): unknown[] +} + +type Connection = { + exec(sql: string): void + prepare(sql: string): Statement + close(): void +} + +type BunDatabaseConstructor = new ( + path: string, + options: { readonly create: boolean; readonly readonly: boolean }, +) => { + run(sql: string): unknown + query(sql: string): Statement + close(): void +} + +export type SqliteDatabase = { + readonly executor: SqliteExecutor + reloadFromDisk(): void + close(): void +} + +const toSqliteValue = (value: QueryValue): SqliteValue => (typeof value === "boolean" ? Number(value) : value) + +const openConnection = (databasePath: string, readonly: boolean): Connection => { + if ("bun" in process.versions) { + const { Database } = require("bun:sqlite") as { Database: BunDatabaseConstructor } + const database = new Database(databasePath, { create: !readonly, readonly }) + return { + exec: (sql) => { + database.run(sql) + }, + prepare: (sql) => database.query(sql), + close: () => database.close(), + } + } + + const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite") + const database = new DatabaseSync(databasePath, { readOnly: readonly }) + return { + exec: (sql) => database.exec(sql), + prepare: (sql) => database.prepare(sql), + close: () => database.close(), + } +} + +const createExecutor = (database: Connection): SqliteExecutor => ({ + run(sql, params = []) { + database.prepare(sql).run(...params.map(toSqliteValue)) + }, + get>(sql: string, params: readonly QueryValue[] = []) { + return (database.prepare(sql).get(...params.map(toSqliteValue)) as TRow | undefined) ?? null + }, + all>(sql: string, params: readonly QueryValue[] = []) { + return database.prepare(sql).all(...params.map(toSqliteValue)) as TRow[] + }, + transaction(operation) { + database.exec("BEGIN IMMEDIATE") + try { + const result = operation() + database.exec("COMMIT") + return result + } catch (error) { + try { + database.exec("ROLLBACK") + } catch { + // Preserve the original failure if SQLite already closed the transaction. + } + throw error + } + }, +}) + +export const openReadonlySqliteDatabase = (databasePath: string): SqliteDatabase => { + const database = openConnection(databasePath, true) + database.exec("PRAGMA busy_timeout = 5000") + return { + executor: createExecutor(database), + reloadFromDisk() {}, + close: () => database.close(), + } +} + +export const openWritableSqliteDatabase = (databasePath: string): SqliteDatabase => { + mkdirSync(dirname(databasePath), { recursive: true }) + const database = openConnection(databasePath, false) + database.exec("PRAGMA busy_timeout = 5000") + database.exec("PRAGMA journal_mode = WAL") + database.exec("PRAGMA foreign_keys = ON") + return { + executor: createExecutor(database), + // A file-backed SQLite connection sees committed writes from other processes. + reloadFromDisk() {}, + close: () => database.close(), + } +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts new file mode 100644 index 0000000..d88c31a --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts @@ -0,0 +1,89 @@ +import { existsSync } from "node:fs" + +import { + ACTIVE_PARTNER_SELECT, + toPartner, + type PersistedPartnerRow, + type TrainerStateRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" + +import { CONTROL_STATE_SELECT, TRAINER_STATE_SELECT } from "../../shared/constants/sqlite.ts" +import { isRecoverableSqliteReadError } from "./errors.ts" +import { resolveDatabasePath, type SqliteDatabaseOptions } from "./options.ts" +import { openReadonlySqliteDatabase } from "./sqlite-driver.ts" + +export type CreateSqliteSidebarSnapshotReaderOptions = SqliteDatabaseOptions + +type ControlStateRow = { + readonly frozen: number + readonly cheat_node_id: string | null +} + +export const readSidebarSnapshotFromExecutor = (executor: Pick): SidebarSnapshot | null => { + const trainer = executor.get(TRAINER_STATE_SELECT) + const control = executor.get(CONTROL_STATE_SELECT) + if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { + return { + currentNodeId: control.cheat_node_id, + gauge: 0, + isTerminal: true, + frozen: false, + isSetOverride: true, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) + if (partnerRow === null) return null + + const partner = toPartner(partnerRow) + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + frozen: control?.frozen === 1, + isSetOverride: false, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } +} + +const readSidebarSnapshotAtPath = (databasePath: string): SidebarSnapshot | null => { + if (!existsSync(databasePath)) return null + + const database = openReadonlySqliteDatabase(databasePath) + try { + return readSidebarSnapshotFromExecutor(database.executor) + } catch (error) { + if (isRecoverableSqliteReadError(error)) return null + throw error + } finally { + database.close() + } +} + +export const createSqliteSidebarSnapshotReader = async ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + + return { + getSidebarSnapshot(): SidebarSnapshot | null { + return readSidebarSnapshotAtPath(databasePath) + }, + } +} + +export const readSidebarSnapshot = async ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): Promise => { + return readSidebarSnapshotAtPath(resolveDatabasePath(options)) +} diff --git a/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts b/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts new file mode 100644 index 0000000..20f7741 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/sqlite/sqljs-config.ts @@ -0,0 +1,29 @@ +import { createRequire } from "node:module" +import initSqlJs, { type SqlJsStatic } from "sql.js" + +declare const __DIGITAL_PET_BUNDLE__: boolean + +const require = createRequire( + typeof __DIGITAL_PET_BUNDLE__ !== "undefined" && __DIGITAL_PET_BUNDLE__ ? __filename : import.meta.url, +) + +let sqlRuntime: SqlJsStatic | undefined +let wasmPath: string | undefined + +export const configureSqlJsWasmPath = (path: string): void => { + wasmPath = path +} + +const resolveWasmPath = (): string => { + if (wasmPath !== undefined) return wasmPath + return require.resolve("sql.js/dist/sql-wasm.wasm") +} + +export const getSqlRuntime = async (): Promise => { + if (sqlRuntime === undefined) { + sqlRuntime = await initSqlJs({ + locateFile: () => resolveWasmPath(), + }) + } + return sqlRuntime +} diff --git a/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts b/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts new file mode 100644 index 0000000..240304e --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/animation-sink.ts @@ -0,0 +1,22 @@ +import type { BattleFrameHud } from "@jcendal/digital-pet-animation/sequences/evolution-battle-artwork.ts" +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" +import type { SidebarWebviewPayload } from "../../webview/sidebar/webview-messages.ts" +import type { WebviewMessenger } from "./webview-messenger.ts" + +export type AnimationSink = { + postState?(state: PresentationState): Promise + postArtwork(artwork: string, hud?: BattleFrameHud): Promise + postModel(payload: SidebarWebviewPayload): Promise +} + +export const createAnimationSink = (messenger: WebviewMessenger): AnimationSink => ({ + async postState(state: PresentationState): Promise { + await messenger.post({ type: "presentation-state", state }) + }, + async postArtwork(artwork: string, hud?: BattleFrameHud): Promise { + await messenger.post({ type: "animation-frame", artwork, ...(hud === undefined ? {} : { hud }) }) + }, + async postModel(payload: SidebarWebviewPayload): Promise { + await messenger.post(payload) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts b/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts new file mode 100644 index 0000000..8ad53bf --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/notification-port.ts @@ -0,0 +1,11 @@ +import * as vscode from "vscode" + +export type NotificationPort = { + showInformation(message: string): void +} + +export const createVsCodeNotificationPort = (): NotificationPort => ({ + showInformation(message: string): void { + void vscode.window.showInformationMessage(message) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts b/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts new file mode 100644 index 0000000..d4ba074 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/scheduler.ts @@ -0,0 +1,10 @@ +export type IntervalScheduler = { + start(callback: () => void, intervalMs: number): () => void +} + +export const createIntervalScheduler = (): IntervalScheduler => ({ + start(callback: () => void, intervalMs: number): () => void { + const handle = setInterval(callback, intervalMs) + return () => clearInterval(handle) + }, +}) diff --git a/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts b/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts new file mode 100644 index 0000000..b669826 --- /dev/null +++ b/packages/cursor-digital-pet/src/adapters/vscode/webview-messenger.ts @@ -0,0 +1,13 @@ +import type * as vscode from "vscode" + +import type { ExtensionToWebviewMessage } from "../../webview/sidebar/webview-messages.ts" + +export type WebviewMessenger = { + post(message: ExtensionToWebviewMessage): Promise +} + +export const createWebviewMessenger = (webview: vscode.Webview): WebviewMessenger => ({ + async post(message: ExtensionToWebviewMessage): Promise { + await webview.postMessage(message) + }, +}) diff --git a/packages/cursor-digital-pet/src/application/usage-pipeline.ts b/packages/cursor-digital-pet/src/application/usage-pipeline.ts new file mode 100644 index 0000000..b801c79 --- /dev/null +++ b/packages/cursor-digital-pet/src/application/usage-pipeline.ts @@ -0,0 +1,18 @@ +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" + +import { + createCursorUsageEventSource, + type CursorUsageEventSource, +} from "../adapters/cursor/cursor-usage-event-source.ts" + +export type UsagePipeline = CursorUsageEventSource + +export const createUsagePipeline = ( + ledger: UsageLedger, + onApplied: (result: UsageProcessingResult) => void, + options: { readonly settleDelayMs: number }, +): UsagePipeline => + createCursorUsageEventSource(ledger, onApplied, { + settleDelayMs: options.settleDelayMs, + }) diff --git a/packages/cursor-digital-pet/src/bootstrap/container.ts b/packages/cursor-digital-pet/src/bootstrap/container.ts new file mode 100644 index 0000000..1b3148a --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/container.ts @@ -0,0 +1,57 @@ +import { join } from "node:path" +import type * as vscode from "vscode" + +import { createDatabaseChangeWatcher } from "../adapters/sqlite/database-change-watcher.ts" +import { + createSqliteDigitalPetRepository, + type CursorSqliteDigitalPetRepository, +} from "../adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { configureSqlJsWasmPath } from "../adapters/sqlite/sqljs-config.ts" +import { + getDigitalPetExtensionSettings, + toDatabaseOptions, + type DigitalPetExtensionSettings, +} from "../config/extension-settings.ts" +import { DigitalPetSidebarProvider } from "../webview/digital-pet-sidebar-provider.ts" + +export type DigitalPetContainer = { + readonly repository: CursorSqliteDigitalPetRepository + readonly sidebarProvider: DigitalPetSidebarProvider + readonly readSettings: () => DigitalPetExtensionSettings + readonly databaseOptions: () => ReturnType + readonly refreshSidebar: () => void +} + +export const createDigitalPetContainer = async (context: vscode.ExtensionContext): Promise => { + configureSqlJsWasmPath(join(context.extensionPath, "dist", "sql-wasm.wasm")) + + const readSettings = () => getDigitalPetExtensionSettings() + const databaseOptions = () => toDatabaseOptions(readSettings()) + + const repository = await createSqliteDigitalPetRepository(databaseOptions()) + context.subscriptions.push({ dispose: () => repository.close() }) + + const sidebarProvider = new DigitalPetSidebarProvider(context.extensionUri, repository, repository) + const refreshSidebar = (): void => { + void sidebarProvider.refresh() + } + + const databaseWatcher = createDatabaseChangeWatcher({ + ...databaseOptions(), + onChange: () => { + repository.reloadFromDisk() + if (sidebarProvider.isPresentationInProgress() !== true) { + refreshSidebar() + } + }, + }) + context.subscriptions.push({ dispose: () => databaseWatcher.dispose() }) + + return { + repository, + sidebarProvider, + readSettings, + databaseOptions, + refreshSidebar, + } +} diff --git a/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts b/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts new file mode 100644 index 0000000..3147ce6 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/ensure-hooks.ts @@ -0,0 +1,21 @@ +import { join } from "node:path" +import * as vscode from "vscode" + +import { installDigitalPetHooks } from "../adapters/cursor/install-hooks.ts" + +export const ensureHooksInstalled = async (context: vscode.ExtensionContext): Promise => { + const key = "digital-pet.hooksInstalled" + if (context.globalState.get(key) === true) return + + const choice = await vscode.window.showInformationMessage( + "Cursor Digital Pet needs to register hooks in ~/.cursor/hooks.json to track Agent usage.", + "Install hooks", + "Not now", + ) + if (choice !== "Install hooks") return + + const bridgePath = join(context.extensionPath, "hook-bridge.js") + const result = installDigitalPetHooks(bridgePath) + await context.globalState.update(key, true) + vscode.window.showInformationMessage(`Digital Pet hooks installed at ${result.hooksPath}`) +} diff --git a/packages/cursor-digital-pet/src/bootstrap/register-commands.ts b/packages/cursor-digital-pet/src/bootstrap/register-commands.ts new file mode 100644 index 0000000..fce4426 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/register-commands.ts @@ -0,0 +1,62 @@ +import { join } from "node:path" +import * as vscode from "vscode" + +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" + +import { installDigitalPetHooks, uninstallDigitalPetHooks } from "../adapters/cursor/install-hooks.ts" +import { openDexPanel } from "../webview/panels/dex/dex-panel.ts" +import { openHistoryPanel } from "../webview/panels/history/history-panel.ts" +import type { DigitalPetContainer } from "./container.ts" + +export const registerCommands = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + const { repository, databaseOptions, refreshSidebar } = container + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("digital-pet")) return + vscode.window.showInformationMessage( + "Digital Pet settings changed. Reload the window to apply database path changes.", + ) + }), + vscode.commands.registerCommand("cursorDigitalPet.spawn", async () => { + spawnPartner(repository, new Date().toISOString()) + vscode.window.showInformationMessage("Digital Pet partner spawned.") + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.freeze", async () => { + const result = freezeDigitalPet(repository) + vscode.window.showInformationMessage( + result.kind === "frozen" ? "Digital Pet frozen." : "Digital Pet already frozen.", + ) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.unfreeze", async () => { + const result = unfreezeDigitalPet(repository) + vscode.window.showInformationMessage( + result.kind === "unfrozen" ? "Digital Pet unfrozen." : "Digital Pet already active.", + ) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.set", async () => { + const nodeId = await vscode.window.showInputBox({ prompt: "Digimon node ID (e.g. 0-001)" }) + if (nodeId === undefined || nodeId.length === 0) return + const result = setDigitalPetCheatNode(repository, nodeId) + vscode.window.showInformationMessage(`Set result: ${result.kind}`) + refreshSidebar() + }), + vscode.commands.registerCommand("cursorDigitalPet.dex", () => openDexPanel(context, databaseOptions())), + vscode.commands.registerCommand("cursorDigitalPet.history", () => openHistoryPanel(context, databaseOptions())), + vscode.commands.registerCommand("cursorDigitalPet.installHooks", async () => { + const bridgePath = join(context.extensionPath, "hook-bridge.js") + installDigitalPetHooks(bridgePath) + await context.globalState.update("digital-pet.hooksInstalled", true) + }), + vscode.commands.registerCommand("cursorDigitalPet.uninstallHooks", () => { + uninstallDigitalPetHooks() + void context.globalState.update("digital-pet.hooksInstalled", false) + }), + ) +} diff --git a/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts b/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts new file mode 100644 index 0000000..5eee1a8 --- /dev/null +++ b/packages/cursor-digital-pet/src/bootstrap/register-sidebar.ts @@ -0,0 +1,13 @@ +import * as vscode from "vscode" + +import { DigitalPetSidebarProvider } from "../webview/digital-pet-sidebar-provider.ts" +import type { DigitalPetContainer } from "./container.ts" + +export const registerSidebar = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(DigitalPetSidebarProvider.viewType, container.sidebarProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + { dispose: () => container.sidebarProvider.dispose() }, + ) +} diff --git a/packages/cursor-digital-pet/src/config/extension-settings.ts b/packages/cursor-digital-pet/src/config/extension-settings.ts new file mode 100644 index 0000000..b21a073 --- /dev/null +++ b/packages/cursor-digital-pet/src/config/extension-settings.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode" + +import type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" + +export type DigitalPetExtensionSettings = { + readonly databasePath?: string + readonly settleDelayMs: number +} + +export const getDigitalPetExtensionSettings = (): DigitalPetExtensionSettings => { + const configuration = vscode.workspace.getConfiguration("digital-pet") + const databasePath = configuration.get("databasePath", "").trim() + const settings: DigitalPetExtensionSettings = { + settleDelayMs: configuration.get("usage.settleDelayMs", 4000), + } + if (databasePath.length > 0) { + return { ...settings, databasePath } + } + return settings +} + +export const toDatabaseOptions = ( + settings: DigitalPetExtensionSettings, +): HostPathOptions & { readonly databasePath?: string } => + settings.databasePath === undefined ? {} : { databasePath: settings.databasePath } + +export const DEV_TOOLS_CONTEXT = "cursorDigitalPet.devToolsEnabled" + +export const isDevToolsEnabled = (extensionMode: vscode.ExtensionMode): boolean => { + if (process.env.CURSOR_DIGITAL_PET_DEV === "1") return true + if (extensionMode === vscode.ExtensionMode.Development) return true + return vscode.workspace.getConfiguration("digital-pet").get("devTools", false) +} + +export const syncDevToolsContext = async (extensionMode: vscode.ExtensionMode): Promise => { + await vscode.commands.executeCommand("setContext", DEV_TOOLS_CONTEXT, isDevToolsEnabled(extensionMode)) +} diff --git a/packages/cursor-digital-pet/src/dev/catalog.ts b/packages/cursor-digital-pet/src/dev/catalog.ts new file mode 100644 index 0000000..5fd052f --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/catalog.ts @@ -0,0 +1,12 @@ +export const DEV_SHOW_MENU_COMMAND = "cursorDigitalPet.dev.showMenu" + +/** Keep in sync with `menus.commandPalette` in package.json */ +export const DEV_TOOLS_COMMAND_PALETTE_WHEN = "cursorDigitalPet.devToolsEnabled" + +export const CURSOR_DEV_ACTIONS = [ + { id: "feed", label: "Dev Feed" }, + { id: "evolution_reveal", label: "Dev Evolution Reveal" }, + { id: "evolution_battle", label: "Dev Evolution Battle" }, +] as const + +export type CursorDevActionId = (typeof CURSOR_DEV_ACTIONS)[number]["id"] diff --git a/packages/cursor-digital-pet/src/dev/fixtures.ts b/packages/cursor-digital-pet/src/dev/fixtures.ts new file mode 100644 index 0000000..5eadda9 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/fixtures.ts @@ -0,0 +1,6 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" + +export const DEV_EVOLUTION: UsageEvolutionTransition = { + fromNodeId: "3-001", + toNodeId: "4-017", +} diff --git a/packages/cursor-digital-pet/src/dev/register-commands.ts b/packages/cursor-digital-pet/src/dev/register-commands.ts new file mode 100644 index 0000000..4f7f8f1 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/register-commands.ts @@ -0,0 +1,57 @@ +import * as vscode from "vscode" + +import type { DigitalPetContainer } from "../bootstrap/container.ts" +import { CURSOR_DEV_ACTIONS, type CursorDevActionId, DEV_SHOW_MENU_COMMAND } from "./catalog.ts" +import { DEV_EVOLUTION } from "./fixtures.ts" +import { setupBattlePending } from "./setup-scenario.ts" + +const warnBusy = (): void => { + void vscode.window.showWarningMessage("Digital Pet dev: presentation already in progress.") +} + +export const registerDevCommands = (context: vscode.ExtensionContext, container: DigitalPetContainer): void => { + const { sidebarProvider, repository } = container + + const handlers: Record void | Promise> = { + feed: () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + sidebarProvider.playFeedAnimation() + }, + evolution_reveal: async () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + sidebarProvider.queueEvolutionReveal(DEV_EVOLUTION) + await sidebarProvider.refresh() + }, + evolution_battle: async () => { + if (sidebarProvider.isPresentationInProgress()) { + warnBusy() + return + } + try { + setupBattlePending(repository) + await sidebarProvider.refresh() + } catch (error) { + void vscode.window.showErrorMessage( + `Digital Pet dev: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }, + } + + context.subscriptions.push( + vscode.commands.registerCommand(DEV_SHOW_MENU_COMMAND, async () => { + const picked = await vscode.window.showQuickPick( + CURSOR_DEV_ACTIONS.map((action) => ({ label: action.label, actionId: action.id })), + { title: "Digital Pet Dev Tools" }, + ) + if (picked === undefined) return + void handlers[picked.actionId]() + }), + ) +} diff --git a/packages/cursor-digital-pet/src/dev/setup-scenario.ts b/packages/cursor-digital-pet/src/dev/setup-scenario.ts new file mode 100644 index 0000000..292a2b1 --- /dev/null +++ b/packages/cursor-digital-pet/src/dev/setup-scenario.ts @@ -0,0 +1,84 @@ +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +export type DevScenarioRepository = SqliteDigitalPetWriteStore & UsageLedger & SidebarSnapshotReader + +const usageReceipt = (receiptKey: string, tokenDelta = 1): Parameters[0]["usage"] => ({ + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), +}) + +const applyTokenUsage = (ledger: UsageLedger, receiptKey: string, tokenDelta = 1): ReturnType => + recordUsage({ + usage: usageReceipt(receiptKey, tokenDelta), + ledger, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) + +const hasPendingBattle = (snapshot: SidebarSnapshot): boolean => + snapshot.pendingEvolutionTargetId !== null && snapshot.battleOpponentNodeId !== null + +const assertPartnerCanBattle = (snapshot: SidebarSnapshot): void => { + const current = DIGIMON_CATALOG.byId.get(snapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${snapshot.currentNodeId} is missing from the catalog.`) + } + if (snapshot.frozen) { + throw new Error("Digital Pet dev: partner is frozen. Unfreeze before opening an evolution battle.") + } + if (snapshot.isSetOverride) { + throw new Error("Digital Pet dev: clear the digimon override before opening an evolution battle.") + } + if (snapshot.isTerminal || current.nextEvolutions.length === 0) { + throw new Error("Digital Pet dev: partner cannot evolve further, so no evolution battle can be opened.") + } +} + +export const ensureActivePartner = (repository: DevScenarioRepository): void => { + if (repository.getActivePartner() !== null) return + spawnPartner(repository, new Date().toISOString()) +} + +export const setupBattlePending = (repository: DevScenarioRepository): void => { + ensureActivePartner(repository) + const initialSnapshot = repository.getSidebarSnapshot() + if (initialSnapshot === null) throw new Error("Digital Pet dev: could not read the active partner snapshot.") + + if (hasPendingBattle(initialSnapshot)) return + + assertPartnerCanBattle(initialSnapshot) + + const receiptSuffix = Date.now().toString(36) + const current = DIGIMON_CATALOG.byId.get(initialSnapshot.currentNodeId) + if (current === undefined) + throw new Error(`Digital Pet dev: partner node ${initialSnapshot.currentNodeId} is missing from the catalog.`) + + if (current.stage === 0) applyTokenUsage(repository, `dev-hatch-${receiptSuffix}`, 1) + + const afterHatch = repository.getSidebarSnapshot() + if (afterHatch !== null && !hasPendingBattle(afterHatch)) + applyTokenUsage(repository, `dev-battle-${receiptSuffix}`, 1) + + const pending = repository.getSidebarSnapshot() + if (pending === null || !hasPendingBattle(pending)) + throw new Error("Digital Pet dev: could not open a pending evolution battle for the current partner.") +} diff --git a/packages/cursor-digital-pet/src/extension.ts b/packages/cursor-digital-pet/src/extension.ts new file mode 100644 index 0000000..fc2998e --- /dev/null +++ b/packages/cursor-digital-pet/src/extension.ts @@ -0,0 +1,58 @@ +import { existsSync } from "node:fs" +import * as vscode from "vscode" + +import { resolveStateVscdbPath } from "./adapters/cursor/paths.ts" +import { createUsagePipeline } from "./application/usage-pipeline.ts" +import { isDevToolsEnabled, syncDevToolsContext } from "./config/extension-settings.ts" +import { createDigitalPetContainer } from "./bootstrap/container.ts" +import { ensureHooksInstalled } from "./bootstrap/ensure-hooks.ts" +import { registerCommands } from "./bootstrap/register-commands.ts" +import { registerSidebar } from "./bootstrap/register-sidebar.ts" + +const isCursorRuntime = (): boolean => existsSync(resolveStateVscdbPath()) + +export async function activate(context: vscode.ExtensionContext): Promise { + if (!isCursorRuntime()) { + vscode.window.showWarningMessage("Cursor Digital Pet: token tracking requires Cursor (state.vscdb not found).") + } + + const container = await createDigitalPetContainer(context) + registerSidebar(context, container) + registerCommands(context, container) + + const usageSource = createUsagePipeline( + container.repository, + (result) => { + if (result.kind === "applied") { + container.sidebarProvider.playFeedAnimation() + if (result.evolution !== undefined) { + container.sidebarProvider.queueEvolutionReveal(result.evolution) + } + } + container.refreshSidebar() + }, + { settleDelayMs: container.readSettings().settleDelayMs }, + ) + context.subscriptions.push(usageSource) + + const poll = setInterval(container.refreshSidebar, 30_000) + context.subscriptions.push({ dispose: () => clearInterval(poll) }) + + await ensureHooksInstalled(context) + container.refreshSidebar() + + await syncDevToolsContext(context.extensionMode) + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("digital-pet.devTools")) return + void syncDevToolsContext(context.extensionMode) + }), + ) + + if (isDevToolsEnabled(context.extensionMode)) { + const { registerDevCommands } = await import("./dev/register-commands.ts") + registerDevCommands(context, container) + } +} + +export function deactivate(): void {} diff --git a/packages/cursor-digital-pet/src/shared/assert-never.ts b/packages/cursor-digital-pet/src/shared/assert-never.ts new file mode 100644 index 0000000..1e92484 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/assert-never.ts @@ -0,0 +1,3 @@ +export const assertNever = (value: never): never => { + throw new Error(`Unexpected variant: ${JSON.stringify(value)}`) +} diff --git a/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts b/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts new file mode 100644 index 0000000..1800f64 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/async-refresh-queue.ts @@ -0,0 +1,27 @@ +export type AsyncRefreshQueue = { + run(task: () => Promise): Promise +} + +export const createAsyncRefreshQueue = (): AsyncRefreshQueue => { + let inFlight = false + let pending = false + + const run = async (task: () => Promise): Promise => { + if (inFlight) { + pending = true + return + } + + inFlight = true + try { + do { + pending = false + await task() + } while (pending) + } finally { + inFlight = false + } + } + + return { run } +} diff --git a/packages/cursor-digital-pet/src/shared/constants/cursor.ts b/packages/cursor-digital-pet/src/shared/constants/cursor.ts new file mode 100644 index 0000000..0702bd9 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/cursor.ts @@ -0,0 +1,3 @@ +export const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken" +export const CURSOR_USAGE_EVENTS_URL = "https://cursor.com/api/dashboard/get-filtered-usage-events" +export const DIGITAL_PET_HOOK_MARKER = "hook-bridge.js" diff --git a/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts b/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts new file mode 100644 index 0000000..dd785b9 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/presentation-timing.ts @@ -0,0 +1,2 @@ +/** Sidebar monster animation tick interval (host-specific). */ +export const SIDEBAR_VISUAL_INTERVAL_MS = 500 diff --git a/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts b/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts new file mode 100644 index 0000000..9cf1b6e --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/sidebar-ui.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/sidebar-ui.ts" diff --git a/packages/cursor-digital-pet/src/shared/constants/sqlite.ts b/packages/cursor-digital-pet/src/shared/constants/sqlite.ts new file mode 100644 index 0000000..61ffa31 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/constants/sqlite.ts @@ -0,0 +1,3 @@ +export const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" +export const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" +export const UNAVAILABLE_ARCHIVE_MESSAGE = "Digital Pet archive is unavailable." diff --git a/packages/cursor-digital-pet/src/shared/escape-html.ts b/packages/cursor-digital-pet/src/shared/escape-html.ts new file mode 100644 index 0000000..a3c91a4 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/escape-html.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/escape-html.ts" diff --git a/packages/cursor-digital-pet/src/shared/random.ts b/packages/cursor-digital-pet/src/shared/random.ts new file mode 100644 index 0000000..16dee68 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/random.ts @@ -0,0 +1,6 @@ +/** Returns value in [0, 1) safe for index selection. */ +export const normalizedRandom = (random: () => number): number => { + const sample = random() + if (!Number.isFinite(sample) || sample <= 0) return 0 + return sample >= 1 ? 1 - Number.EPSILON : sample +} diff --git a/packages/cursor-digital-pet/src/shared/sleep.ts b/packages/cursor-digital-pet/src/shared/sleep.ts new file mode 100644 index 0000000..3df1811 --- /dev/null +++ b/packages/cursor-digital-pet/src/shared/sleep.ts @@ -0,0 +1,4 @@ +export const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) diff --git a/packages/cursor-digital-pet/src/types/sql.js.d.ts b/packages/cursor-digital-pet/src/types/sql.js.d.ts new file mode 100644 index 0000000..01dd3d5 --- /dev/null +++ b/packages/cursor-digital-pet/src/types/sql.js.d.ts @@ -0,0 +1,31 @@ +declare module "sql.js" { + export type SqlJsStatic = { + Database: new (data?: ArrayLike | Buffer | null) => Database + } + + export type Database = { + run(sql: string, params?: readonly (string | number | boolean | null)[]): void + prepare(sql: string): Statement + exec(sql: string, params?: readonly (string | number | boolean | null)[]): QueryExecResult[] + export(): Uint8Array + close(): void + } + + export type QueryExecResult = { + columns: string[] + values: unknown[][] + } + + export type Statement = { + bind(values?: readonly (string | number | boolean | null)[]): boolean + step(): boolean + getAsObject(): Record + free(): void + } + + export type InitSqlJsConfig = { + readonly locateFile?: (file: string) => string + } + + export default function initSqlJs(config?: InitSqlJsConfig): Promise +} diff --git a/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts b/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts new file mode 100644 index 0000000..454807c --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/digital-pet-sidebar-provider.ts @@ -0,0 +1 @@ +export { DigitalPetSidebarProvider, type DigitalPetSidebarProviderOptions } from "./sidebar/provider.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/dex/dex-model.ts b/packages/cursor-digital-pet/src/webview/panels/dex/dex-model.ts new file mode 100644 index 0000000..70941cd --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex/dex-model.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/dex/dex-model.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/dex/dex-panel.ts b/packages/cursor-digital-pet/src/webview/panels/dex/dex-panel.ts new file mode 100644 index 0000000..958bbc6 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex/dex-panel.ts @@ -0,0 +1,91 @@ +import { randomBytes } from "node:crypto" +import * as vscode from "vscode" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { createDatabaseChangeWatcher } from "../../../adapters/sqlite/database-change-watcher.ts" +import { readArchive } from "../../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import type { CreateSqliteDigitalPetArchiveReaderOptions } from "../../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { resolveDatabasePath } from "../../../adapters/sqlite/options.ts" +import { createAsyncRefreshQueue } from "../../../shared/async-refresh-queue.ts" +import { buildDexPanelModel } from "./dex-model.ts" +import { buildDexWebviewHtml } from "./dex-render.ts" + +const panels = new Map Promise }>() + +export const openDexPanel = async ( + context: vscode.ExtensionContext, + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, + selectedId?: string, +): Promise => { + const databasePath = resolveDatabasePath(options) + const existing = panels.get(databasePath) + if (existing !== undefined) { + existing.panel.reveal(vscode.ViewColumn.One) + await existing.refresh() + if (selectedId !== undefined) await existing.panel.webview.postMessage({ type: "dex-select", id: selectedId }) + return + } + const archive = await readArchive(options) + // Another invocation may have opened the panel while the archive was being read. + const opened = panels.get(databasePath) + if (opened !== undefined) { + opened.panel.reveal(vscode.ViewColumn.One) + await opened.refresh() + if (selectedId !== undefined) await opened.panel.webview.postMessage({ type: "dex-select", id: selectedId }) + return + } + let model = buildDexPanelModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + const mediaRoot = vscode.Uri.joinPath(context.extensionUri, "media") + const panel = vscode.window.createWebviewPanel("cursorDigitalPetDex", "Digital Pet Digidex", vscode.ViewColumn.One, { + enableScripts: true, + localResourceRoots: [mediaRoot], + }) + let disposed = false + let pendingSelection = selectedId + const queue = createAsyncRefreshQueue() + const refresh = (): Promise => + queue.run(async () => { + if (disposed) return + model = buildDexPanelModel(await readArchive(options), DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + if (!disposed) await panel.webview.postMessage({ type: "dex-model", model }) + if (!disposed && pendingSelection !== undefined) { + await panel.webview.postMessage({ type: "dex-select", id: pendingSelection }) + pendingSelection = undefined + } + }) + const requestRefresh = (): void => { + void refresh().catch((error: unknown) => { + if (!disposed) void vscode.window.showWarningMessage(`Digital Pet Dex could not refresh: ${String(error)}`) + }) + } + const watcher = createDatabaseChangeWatcher({ ...options, databasePath, onChange: requestRefresh }) + const messages = panel.webview.onDidReceiveMessage((message: unknown) => { + if (typeof message !== "object" || message === null || !("type" in message)) return + if (message.type === "dex-ready" || message.type === "dex-refresh") { + requestRefresh() + } else if (message.type === "dex-reference" && "id" in message && typeof message.id === "string") { + const entry = model.entries.find((candidate) => candidate.id === message.id && candidate.discovered) + // Resolve the URL from the trusted catalog, never from a webview supplied URL. + if (entry?.url.startsWith("https://digimon.net/")) void vscode.env.openExternal(vscode.Uri.parse(entry.url)) + } + }) + const visibility = panel.onDidChangeViewState((event) => { + if (event.webviewPanel.visible) requestRefresh() + }) + const disposal = panel.onDidDispose(() => { + disposed = true + panels.delete(databasePath) + watcher.dispose() + messages.dispose() + visibility.dispose() + disposal.dispose() + }) + panels.set(databasePath, { panel, refresh }) + panel.webview.html = buildDexWebviewHtml(model, { + nonce: randomBytes(16).toString("hex"), + fontUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "fonts", "Silkscreen-Regular.ttf")).toString(), + cspSource: panel.webview.cspSource, + }) + context.subscriptions.push(panel) +} diff --git a/packages/cursor-digital-pet/src/webview/panels/dex/dex-render.ts b/packages/cursor-digital-pet/src/webview/panels/dex/dex-render.ts new file mode 100644 index 0000000..a653759 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex/dex-render.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/dex/dex-render.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/dex/dex-script.ts b/packages/cursor-digital-pet/src/webview/panels/dex/dex-script.ts new file mode 100644 index 0000000..c5e9518 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex/dex-script.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/dex/dex-script.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/dex/dex-styles.ts b/packages/cursor-digital-pet/src/webview/panels/dex/dex-styles.ts new file mode 100644 index 0000000..d9e5b22 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/dex/dex-styles.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/dex/dex-styles.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/history/history-model.ts b/packages/cursor-digital-pet/src/webview/panels/history/history-model.ts new file mode 100644 index 0000000..b328c22 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history/history-model.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/history/history-model.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/history/history-panel.ts b/packages/cursor-digital-pet/src/webview/panels/history/history-panel.ts new file mode 100644 index 0000000..bde2dba --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history/history-panel.ts @@ -0,0 +1,99 @@ +import { randomBytes } from "node:crypto" +import * as vscode from "vscode" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { createDatabaseChangeWatcher } from "../../../adapters/sqlite/database-change-watcher.ts" +import { readArchive } from "../../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import type { CreateSqliteDigitalPetArchiveReaderOptions } from "../../../adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { resolveDatabasePath } from "../../../adapters/sqlite/options.ts" +import { createAsyncRefreshQueue } from "../../../shared/async-refresh-queue.ts" +import { buildHistoryPanelModel } from "./history-model.ts" +import { buildHistoryWebviewHtml } from "./history-render.ts" + +import { openDexPanel } from "../dex/dex-panel.ts" + +const panels = new Map Promise }>() + +export const openHistoryPanel = async ( + context: vscode.ExtensionContext, + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): Promise => { + const databasePath = resolveDatabasePath(options) + const existing = panels.get(databasePath) + if (existing !== undefined) { + existing.panel.reveal(vscode.ViewColumn.One) + await existing.refresh() + return + } + const archive = await readArchive(options) + // Another invocation may have opened the panel while the archive was being read. + const opened = panels.get(databasePath) + if (opened !== undefined) { + opened.panel.reveal(vscode.ViewColumn.One) + return opened.refresh() + } + let model = buildHistoryPanelModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + const mediaRoot = vscode.Uri.joinPath(context.extensionUri, "media") + const panel = vscode.window.createWebviewPanel( + "cursorDigitalPetHistory", + "Digital Pet History", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [mediaRoot], + }, + ) + let disposed = false + const queue = createAsyncRefreshQueue() + const refresh = (): Promise => + queue.run(async () => { + if (disposed) return + model = buildHistoryPanelModel(await readArchive(options), DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + if (!disposed) await panel.webview.postMessage({ type: "history-model", model }) + }) + const requestRefresh = (): void => { + void refresh().catch((error: unknown) => { + if (!disposed) void vscode.window.showWarningMessage(`Digital Pet History could not refresh: ${String(error)}`) + }) + } + const watcher = createDatabaseChangeWatcher({ ...options, databasePath, onChange: requestRefresh }) + const messages = panel.webview.onDidReceiveMessage((message: unknown) => { + if (typeof message !== "object" || message === null || !("type" in message)) return + if (message.type === "history-ready" || message.type === "history-refresh") { + requestRefresh() + } else if ( + message.type === "history-dex" && + "id" in message && + typeof message.id === "string" && + "partnerId" in message && + typeof message.partnerId === "string" + ) { + const generation = model.generations.find((candidate) => candidate.partnerId === message.partnerId) + const step = generation?.steps.at(-1) + if (step?.catalogued && step.id === message.id) { + void openDexPanel(context, options, step.id).catch((error: unknown) => { + void vscode.window.showWarningMessage(`Digital Pet Dex could not open: ${String(error)}`) + }) + } + } + }) + const visibility = panel.onDidChangeViewState((event) => { + if (event.webviewPanel.visible) requestRefresh() + }) + const disposal = panel.onDidDispose(() => { + disposed = true + panels.delete(databasePath) + watcher.dispose() + messages.dispose() + visibility.dispose() + disposal.dispose() + }) + panels.set(databasePath, { panel, refresh }) + panel.webview.html = buildHistoryWebviewHtml(model, { + nonce: randomBytes(16).toString("hex"), + fontUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "fonts", "Silkscreen-Regular.ttf")).toString(), + cspSource: panel.webview.cspSource, + }) + context.subscriptions.push(panel) +} diff --git a/packages/cursor-digital-pet/src/webview/panels/history/history-render.ts b/packages/cursor-digital-pet/src/webview/panels/history/history-render.ts new file mode 100644 index 0000000..46b2eaa --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history/history-render.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/history/history-render.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/history/history-script.ts b/packages/cursor-digital-pet/src/webview/panels/history/history-script.ts new file mode 100644 index 0000000..14a0817 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history/history-script.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/history/history-script.ts" diff --git a/packages/cursor-digital-pet/src/webview/panels/history/history-styles.ts b/packages/cursor-digital-pet/src/webview/panels/history/history-styles.ts new file mode 100644 index 0000000..0ef2282 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/panels/history/history-styles.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/panels/history/history-styles.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/panel-render.ts b/packages/cursor-digital-pet/src/webview/shared/panel-render.ts new file mode 100644 index 0000000..4e6e16e --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/panel-render.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/panel-render.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/panel-script.ts b/packages/cursor-digital-pet/src/webview/shared/panel-script.ts new file mode 100644 index 0000000..9147fbb --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/panel-script.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/panel-script.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/panel-styles.ts b/packages/cursor-digital-pet/src/webview/shared/panel-styles.ts new file mode 100644 index 0000000..504198b --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/panel-styles.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/panel-styles.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/pixel-artwork.ts b/packages/cursor-digital-pet/src/webview/shared/pixel-artwork.ts new file mode 100644 index 0000000..270e945 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/pixel-artwork.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/pixel-artwork.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/theme.ts b/packages/cursor-digital-pet/src/webview/shared/theme.ts new file mode 100644 index 0000000..32b0b8f --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/theme.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/theme.ts" diff --git a/packages/cursor-digital-pet/src/webview/shared/webview-resources.ts b/packages/cursor-digital-pet/src/webview/shared/webview-resources.ts new file mode 100644 index 0000000..e384376 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/shared/webview-resources.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/shared/webview-resources.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/provider.ts b/packages/cursor-digital-pet/src/webview/sidebar/provider.ts new file mode 100644 index 0000000..e703f7e --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/provider.ts @@ -0,0 +1,168 @@ +import * as vscode from "vscode" + +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" + +import { createAnimationSink, type AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import { createVsCodeNotificationPort, type NotificationPort } from "../../adapters/vscode/notification-port.ts" +import { createIntervalScheduler, type IntervalScheduler } from "../../adapters/vscode/scheduler.ts" +import { createWebviewMessenger } from "../../adapters/vscode/webview-messenger.ts" +import { createSidebarAnimationHost, type SidebarAnimationHost } from "./sidebar-animation-host.ts" +import { createSidebarOrchestrator, type SidebarOrchestrator } from "./sidebar-orchestrator.ts" +import { buildSidebarWebviewHtml } from "./sidebar-render.ts" +import { parseWebviewInboundMessage } from "./webview-messages.ts" + +export type DigitalPetSidebarProviderOptions = { + readonly notification?: NotificationPort + readonly scheduler?: IntervalScheduler + readonly random?: () => number + readonly nowMs?: () => number +} + +const createDeferredAnimationSink = (): { readonly sink: AnimationSink; setSink(next: AnimationSink): void } => { + let current: AnimationSink = { + postArtwork: async () => {}, + postModel: async () => {}, + } + return { + sink: { + postState: async (state) => current.postState?.(state), + postArtwork: async (artwork, hud) => current.postArtwork(artwork, hud), + postModel: async (payload) => current.postModel(payload), + }, + setSink(next: AnimationSink): void { + current = next + }, + } +} + +export class DigitalPetSidebarProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "cursorDigitalPet.sidebar" + + private view?: vscode.WebviewView + private readonly notification: NotificationPort + private readonly scheduler: IntervalScheduler + private readonly animationHost: SidebarAnimationHost + private readonly orchestrator: SidebarOrchestrator + private readonly deferredSink: ReturnType + + constructor( + private readonly extensionUri: vscode.Uri, + snapshotReader: SidebarSnapshotReader, + battleRepository: EvolutionBattleRepository, + options: DigitalPetSidebarProviderOptions = {}, + ) { + this.notification = options.notification ?? createVsCodeNotificationPort() + this.scheduler = options.scheduler ?? createIntervalScheduler() + const random = options.random ?? Math.random + const nowMs = options.nowMs ?? (() => performance.now()) + this.deferredSink = createDeferredAnimationSink() + + this.animationHost = createSidebarAnimationHost({ + frameCatalog: MONSTER_FRAME_CATALOG, + sink: this.deferredSink.sink, + scheduler: this.scheduler, + random, + nowMs, + isVisible: () => this.view?.visible === true, + }) + + this.orchestrator = createSidebarOrchestrator({ + snapshotReader, + battleRepository, + animationHost: this.animationHost, + notification: this.notification, + random, + onPresentationEnd: () => { + if (this.view?.visible === true) this.animationHost.start() + }, + }) + this.orchestrator.setSink(this.deferredSink.sink) + } + + isPresentationInProgress(): boolean { + return this.orchestrator.isPresentationInProgress() + } + + queueEvolutionReveal(evolution: Parameters[0]): void { + this.orchestrator.queueEvolutionReveal(evolution) + } + + playFeedAnimation(): void { + if (this.orchestrator.isPresentationInProgress()) return + this.animationHost.playFeedAnimation() + } + + async refresh(): Promise { + await this.orchestrator.refresh() + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.view = webviewView + const sink = createAnimationSink(createWebviewMessenger(webviewView.webview)) + this.deferredSink.setSink(sink) + this.orchestrator.setSink(sink) + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "media")], + } + + webviewView.webview.html = buildSidebarWebviewHtml(String(Date.now()), { + fontUri: webviewView.webview + .asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "media", "fonts", "Silkscreen-Regular.ttf")) + .toString(), + cspSource: webviewView.webview.cspSource, + }) + + webviewView.webview.onDidReceiveMessage((message: unknown) => { + const inbound = parseWebviewInboundMessage(message) + if (inbound === null) return + if (inbound.type === "sidebar-ready") { + this.orchestrator.setSink(sink) + const payload = this.orchestrator.getCachedPayload() + if (payload !== undefined) void sink.postModel(payload) + this.animationHost.clearArtwork() + void this.animationHost.postCurrentFrame() + return + } + if (inbound.type === "open-panel") { + void vscode.commands.executeCommand( + inbound.panel === "dex" ? "cursorDigitalPet.dex" : "cursorDigitalPet.history", + ) + return + } + if (inbound.type === "open-url") { + void vscode.env.openExternal(vscode.Uri.parse(inbound.url)) + return + } + this.animationHost.setArtworkWidth(inbound.width) + void this.animationHost.postCurrentFrame() + }) + + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) { + this.animationHost.start() + void this.refresh() + } else { + this.animationHost.stop() + } + }) + + const cachedPayload = this.orchestrator.getCachedPayload() + if (cachedPayload !== undefined) { + void sink.postModel(cachedPayload) + } + + if (webviewView.visible) { + this.animationHost.start() + void this.refresh() + } + } + + dispose(): void { + this.animationHost.stop() + delete this.view + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts new file mode 100644 index 0000000..4dbe0dc --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-animation-host.ts @@ -0,0 +1,98 @@ +import type { MonsterFrameCatalog } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { + MonsterAnimationController, + type MonsterAnimationIdentity, + type MonsterAnimationOutput, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtwork } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" + +import type { AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import type { IntervalScheduler } from "../../adapters/vscode/scheduler.ts" +import { SIDEBAR_VISUAL_INTERVAL_MS } from "../../shared/constants/presentation-timing.ts" +import { DEFAULT_ARTWORK_WIDTH, MIN_ARTWORK_WIDTH } from "../../shared/constants/sidebar-ui.ts" + +export type SidebarAnimationHost = { + setArtworkWidth(width: number): void + syncPartner(partner: MonsterAnimationIdentity | undefined): void + start(): void + stop(): void + clearArtwork(): void + postCurrentFrame(animation?: MonsterAnimationOutput): Promise + playFeedAnimation(): void + isPresentationBlocked(): boolean + setPresentationBlocked(blocked: boolean): void + getArtworkWidth(): number +} + +export type CreateSidebarAnimationHostOptions = { + readonly frameCatalog: MonsterFrameCatalog + readonly sink: AnimationSink + readonly scheduler: IntervalScheduler + readonly random?: () => number + readonly nowMs?: () => number + readonly isVisible?: () => boolean +} + +export const createSidebarAnimationHost = ({ + frameCatalog, + sink, + scheduler, + random = Math.random, + nowMs = () => performance.now(), + isVisible = () => true, +}: CreateSidebarAnimationHostOptions): SidebarAnimationHost => { + const animation = new MonsterAnimationController(frameCatalog, random, nowMs) + let artworkWidth = DEFAULT_ARTWORK_WIDTH + let cachedArtwork = "" + let presentationBlocked = false + let stopInterval: (() => void) | undefined + + const postCurrentFrame = async (nextAnimation?: MonsterAnimationOutput): Promise => { + if (presentationBlocked) return + const output = nextAnimation ?? animation.output() + const artwork = renderPositionedArtwork(output, artworkWidth) + if (artwork === cachedArtwork) return + cachedArtwork = artwork + await sink.postArtwork(artwork) + } + + return { + setArtworkWidth(width: number): void { + artworkWidth = Math.max(MIN_ARTWORK_WIDTH, Math.floor(width)) + animation.dispatch({ kind: "viewport_resized", width: artworkWidth }) + }, + syncPartner(partner: MonsterAnimationIdentity | undefined): void { + animation.dispatch({ kind: "partner_changed", partner }) + }, + start(): void { + if (stopInterval !== undefined) return + stopInterval = scheduler.start(() => { + if (!isVisible() || presentationBlocked) return + const nextAnimation = animation.dispatch({ kind: "tick" }) + void postCurrentFrame(nextAnimation) + }, SIDEBAR_VISUAL_INTERVAL_MS) + }, + stop(): void { + stopInterval?.() + stopInterval = undefined + }, + clearArtwork(): void { + cachedArtwork = "" + }, + postCurrentFrame, + playFeedAnimation(): void { + if (presentationBlocked) return + const nextAnimation = animation.dispatch({ kind: "feed" }) + void postCurrentFrame(nextAnimation) + }, + isPresentationBlocked(): boolean { + return presentationBlocked + }, + setPresentationBlocked(blocked: boolean): void { + presentationBlocked = blocked + }, + getArtworkWidth(): number { + return artworkWidth + }, + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-document.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-document.ts new file mode 100644 index 0000000..5f3b7db --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-document.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/sidebar-document.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts new file mode 100644 index 0000000..ad481f3 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-orchestrator.ts @@ -0,0 +1,167 @@ +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { runEvolutionBattleSession } from "@jcendal/digital-pet-animation/sessions/evolution-battle-session.ts" +import { runEvolutionRevealSession } from "@jcendal/digital-pet-animation/sessions/evolution-reveal-session.ts" + +import type { AnimationSink } from "../../adapters/vscode/animation-sink.ts" +import type { NotificationPort } from "../../adapters/vscode/notification-port.ts" +import { createAsyncRefreshQueue } from "../../shared/async-refresh-queue.ts" +import type { SidebarAnimationHost } from "./sidebar-animation-host.ts" +import { buildSidebarPresentation } from "./sidebar-presenter.ts" +import type { SidebarWebviewPayload } from "./webview-messages.ts" + +export type SidebarOrchestrator = { + refresh(): Promise + queueEvolutionReveal(evolution: UsageEvolutionTransition): void + isPresentationInProgress(): boolean + getCachedPayload(): SidebarWebviewPayload | undefined + setSink(sink: AnimationSink): void +} + +export type CreateSidebarOrchestratorOptions = { + readonly snapshotReader: SidebarSnapshotReader + readonly battleRepository: EvolutionBattleRepository + readonly animationHost: SidebarAnimationHost + readonly notification: NotificationPort + readonly random?: () => number + readonly onPresentationEnd?: () => void +} + +export const createSidebarOrchestrator = ({ + snapshotReader, + battleRepository, + animationHost, + notification, + random = Math.random, + onPresentationEnd, +}: CreateSidebarOrchestratorOptions): SidebarOrchestrator => { + const refreshQueue = createAsyncRefreshQueue() + let sink: AnimationSink | undefined + let cachedPayload: SidebarWebviewPayload | undefined + let pendingEvolutionReveal: UsageEvolutionTransition | undefined + let presentationInProgress = false + let currentState: PresentationState = { phase: "idle" } + const publishState = async (state: PresentationState): Promise => { + currentState = state + if (state.phase !== "idle") { + const snapshot = snapshotReader.getSidebarSnapshot() + if (snapshot !== null && sink !== undefined) { + const currentNodeId = state.phase === "evolved" ? state.toNodeId : state.fromNodeId + cachedPayload = buildSidebarPresentation({ ...snapshot, currentNodeId }).payload + if (state.phase === "battle" && cachedPayload.kind === "partner") { + cachedPayload = { + ...cachedPayload, + opponentName: DIGIMON_CATALOG.byId.get(state.opponentNodeId)?.nameEn ?? "", + } + } + await sink.postModel(cachedPayload) + } + } + await sink?.postState?.(state) + } + + const publishSidebarModel = async (): Promise => { + if (sink === undefined) return + const presentation = buildSidebarPresentation(snapshotReader.getSidebarSnapshot()) + cachedPayload = presentation.payload + await sink.postModel(presentation.payload) + if (presentationInProgress) return + animationHost.syncPartner(presentation.partner) + await animationHost.postCurrentFrame() + } + + const resolvePendingBattleIfNeeded = async (): Promise => { + if (presentationInProgress) return + const snapshot = snapshotReader.getSidebarSnapshot() + if (snapshot === null) return + if (snapshot.pendingEvolutionTargetId === null || snapshot.battleOpponentNodeId === null) return + + presentationInProgress = true + animationHost.setPresentationBlocked(true) + animationHost.stop() + animationHost.clearArtwork() + + try { + await runEvolutionBattleSession(snapshot, animationHost.getArtworkWidth(), { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: battleRepository, + random, + onState: publishState, + onArtwork: async (artwork, hud) => { + if (sink !== undefined) await sink.postArtwork(artwork, hud) + }, + onResolved: async (result) => { + if (result.kind === "won") { + pendingEvolutionReveal = undefined + notification.showInformation("Victory! Your partner evolved!") + } else if (result.kind === "lost") { + notification.showInformation("Defeat! You lost all tokens for this stage.") + } + }, + }) + } finally { + presentationInProgress = false + await publishState({ phase: "idle" }) + animationHost.setPresentationBlocked(false) + onPresentationEnd?.() + } + } + + const playPendingEvolutionReveal = async (): Promise => { + const evolution = pendingEvolutionReveal + if (evolution === undefined || presentationInProgress) return + + pendingEvolutionReveal = undefined + presentationInProgress = true + animationHost.setPresentationBlocked(true) + animationHost.stop() + animationHost.clearArtwork() + + try { + const revealed = await runEvolutionRevealSession(evolution, animationHost.getArtworkWidth(), { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onState: publishState, + onArtwork: async (artwork) => { + if (sink !== undefined) await sink.postArtwork(artwork) + }, + }) + if (revealed) { + notification.showInformation("Your partner evolved!") + } + } finally { + presentationInProgress = false + await publishState({ phase: "idle" }) + animationHost.setPresentationBlocked(false) + onPresentationEnd?.() + } + } + + return { + async refresh(): Promise { + await refreshQueue.run(async () => { + await resolvePendingBattleIfNeeded() + await playPendingEvolutionReveal() + await publishSidebarModel() + }) + }, + queueEvolutionReveal(evolution: UsageEvolutionTransition): void { + pendingEvolutionReveal = evolution + }, + isPresentationInProgress(): boolean { + return presentationInProgress + }, + getCachedPayload(): SidebarWebviewPayload | undefined { + return cachedPayload + }, + setSink(nextSink: AnimationSink): void { + sink = nextSink + void sink.postState?.(currentState) + }, + } +} diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts new file mode 100644 index 0000000..d835cdf --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-presenter.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/sidebar-presenter.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts new file mode 100644 index 0000000..b40ef61 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-render.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/sidebar-render.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-script.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-script.ts new file mode 100644 index 0000000..7215376 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-script.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/sidebar-script.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/sidebar-styles.ts b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-styles.ts new file mode 100644 index 0000000..a7b68b9 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/sidebar-styles.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/sidebar-styles.ts" diff --git a/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts b/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts new file mode 100644 index 0000000..21fb209 --- /dev/null +++ b/packages/cursor-digital-pet/src/webview/sidebar/webview-messages.ts @@ -0,0 +1 @@ +export * from "@jcendal/digital-pet-webviews/sidebar/webview-messages.ts" diff --git a/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts b/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts new file mode 100644 index 0000000..debebcb --- /dev/null +++ b/packages/cursor-digital-pet/tests/architecture-boundary-helpers.ts @@ -0,0 +1,73 @@ +import { readdir, readFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +export const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") + +export const FORBIDDEN_PRESENTATION_IMPORTS = [ + "vscode", + "/adapters/", + "/bootstrap/", + "node:fs", + "node:path", + "sql.js", + "/webview/sidebar/", +] as const + +export const FORBIDDEN_SHARED_IMPORTS = ["vscode", "/adapters/", "/webview/", "/bootstrap/"] as const + +export const FORBIDDEN_SQLITE_ADAPTER_IMPORTS = ["vscode"] as const + +export const FORBIDDEN_VSCODE_ADAPTER_IMPORTS = ["/adapters/cursor/", "/adapters/sqlite/", "/bootstrap/"] as const + +export const FORBIDDEN_CURSOR_ADAPTER_IMPORTS = ["vscode", "/webview/"] as const + +export const FORBIDDEN_SIDEBAR_IMPORTS = ["vscode"] as const + +export const SIDEBAR_VSCODE_EXCEPTION = "webview/sidebar/provider.ts" + +const matchesForbiddenImport = (specifier: string, forbidden: string): boolean => { + if (forbidden.startsWith("/")) return specifier.includes(forbidden) + return specifier === forbidden +} + +export const findForbiddenImports = ( + sourcePath: string, + source: string, + forbiddenImports: readonly string[], +): readonly string[] => { + const importSpecifiers = source.matchAll(/\bfrom\s+["']([^"']+)["']|\bimport\s+["']([^"']+)["']/g) + const violations: string[] = [] + for (const match of importSpecifiers) { + const specifier = match[1] ?? match[2] + if (specifier !== undefined && forbiddenImports.some((forbidden) => matchesForbiddenImport(specifier, forbidden))) + violations.push(`${sourcePath}: ${specifier}`) + } + return violations +} + +export const scanForbiddenImports = async ( + directory: string, + forbiddenImports: readonly string[], + ignorePaths: readonly string[] = [], +): Promise => { + const entries = await readdir(directory, { withFileTypes: true }) + const violations: string[] = [] + for (const entry of entries) { + const entryPath = join(directory, entry.name) + if (ignorePaths.some((ignored) => entryPath.endsWith(ignored))) continue + if (entry.isDirectory()) violations.push(...(await scanForbiddenImports(entryPath, forbiddenImports, ignorePaths))) + else if (entry.isFile() && entry.name.endsWith(".ts")) + violations.push(...findForbiddenImports(entryPath, await readFile(entryPath, "utf8"), forbiddenImports)) + } + return violations.sort() +} + +export const scanPresentationImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_PRESENTATION_IMPORTS) + +export const scanSharedImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_SHARED_IMPORTS) + +export const scanSidebarImports = (directory: string): Promise => + scanForbiddenImports(directory, FORBIDDEN_SIDEBAR_IMPORTS, [SIDEBAR_VSCODE_EXCEPTION]) diff --git a/packages/cursor-digital-pet/tests/architecture-boundary.test.ts b/packages/cursor-digital-pet/tests/architecture-boundary.test.ts new file mode 100644 index 0000000..0140e1a --- /dev/null +++ b/packages/cursor-digital-pet/tests/architecture-boundary.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { + FORBIDDEN_PRESENTATION_IMPORTS, + FORBIDDEN_SHARED_IMPORTS, + findForbiddenImports, + scanForbiddenImports, + scanPresentationImports, + scanSharedImports, +} from "./architecture-boundary-helpers.ts" + +describe("cursor-digital-pet architecture boundary fixtures", () => { + test("Given presentation source importing vscode When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanPresentationImports(directory)).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given presentation source importing sqlite adapters When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import { openReadonlySqliteDatabase } from "../../adapters/sqlite/sqlite-driver.ts"\n') + expect(await scanPresentationImports(directory)).toEqual([`${path}: ../../adapters/sqlite/sqlite-driver.ts`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given shared source importing vscode When scanned Then it is rejected", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-shared-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanSharedImports(directory)).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given malformed source When scanned Then valid imports are still found", () => + expect( + findForbiddenImports("malformed.ts", 'import { broken\nimport "vscode"\n', FORBIDDEN_SHARED_IMPORTS), + ).toEqual(["malformed.ts: vscode"])) + + test("Given sqlite adapter fixtures importing vscode When scanned Then violations are reported", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-sqlite-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import * as vscode from "vscode"\n') + expect(await scanForbiddenImports(directory, ["vscode"])).toEqual([`${path}: vscode`]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + + test("Given presentation fixtures importing sidebar modules When scanned Then violations are reported", async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-digital-pet-presentation-sidebar-")) + const path = join(directory, "forbidden.ts") + try { + await writeFile(path, 'import { buildSidebarPresentation } from "../webview/sidebar/sidebar-presenter.ts"\n') + expect(await scanForbiddenImports(directory, FORBIDDEN_PRESENTATION_IMPORTS)).toEqual([ + `${path}: ../webview/sidebar/sidebar-presenter.ts`, + ]) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/catalog-evolution.test.ts b/packages/cursor-digital-pet/tests/catalog-evolution.test.ts new file mode 100644 index 0000000..170e3eb --- /dev/null +++ b/packages/cursor-digital-pet/tests/catalog-evolution.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" + +import type { DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { + applyTokenProgress, + resolveEvolutionBattle, + STAGE_GAUGE_THRESHOLDS, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" + +const noBattle = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +const controlledCurrent: DigimonNode = { + id: "controlled-current", + nameEn: "Controlled Current", + nameJp: "Controlled Current", + nextEvolutions: ["controlled-target"], + sprite: "controlled-current.png", + stage: 0, + url: "https://example.test/controlled-current", +} + +const controlledTarget: DigimonNode = { + id: "controlled-target", + nameEn: "Controlled Target", + nameJp: "Controlled Target", + nextEvolutions: [], + sprite: "controlled-target.png", + stage: 1, + url: "https://example.test/controlled-target", +} + +const controlledCatalogNodes = [controlledCurrent, controlledTarget] +const controlledLookup = new Map(controlledCatalogNodes.map((node) => [node.id, node])) + +describe("catalog and evolution", () => { + test("Given a Digitama threshold crossing When token progress applies Then it evolves immediately without a battle", () => { + const evolved = applyTokenProgress( + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0, + controlledLookup, + controlledCatalogNodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(evolved).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + }) + + test("Given progress below threshold When token progress applies Then it only accumulates gauge", () => { + const thresholds: StageThresholds = Object.freeze({ + ...STAGE_GAUGE_THRESHOLDS, + 0: 2, + }) + + expect( + applyTokenProgress( + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, + 1, + () => { + throw new Error("selector must not run") + }, + controlledLookup, + controlledCatalogNodes, + thresholds, + ), + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) + }) + + test("Given a lost evolution battle When resolving Then gauge resets for the current stage", () => { + const andromon = DIGIMON_CATALOG.byId.get("5-003") + if (andromon === undefined) throw new Error("Expected Andromon in catalog") + + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0.5, + DIGIMON_CATALOG.byId, + DIGIMON_CATALOG.nodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(pending.pendingEvolutionTargetId).not.toBeNull() + expect(resolveEvolutionBattle(pending, false, DIGIMON_CATALOG.byId)).toEqual({ + current: andromon, + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts b/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts new file mode 100644 index 0000000..dc78dcf --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-api-watermark.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" + +import { + fingerprintEvent, + hasValidTokens, + isNewEvent, + sumTokenDelta, +} from "../src/adapters/cursor/cursor-api-watermark.ts" +import type { UsageApiEvent, UsageWatermark } from "../src/adapters/cursor/types.ts" + +describe("cursor-api-watermark", () => { + const event: UsageApiEvent = { + timestamp: "1000", + model: "composer-2", + isTokenBasedCall: true, + tokenUsage: { + inputTokens: 10, + outputTokens: 5, + cacheWriteTokens: 2, + cacheReadTokens: 3, + }, + } + + test("sums token fields", () => { + expect(sumTokenDelta([event])).toBe(20) + }) + + test("filters new events by watermark", () => { + const watermark: UsageWatermark = { + timestampMs: 1000, + fingerprint: fingerprintEvent(event), + } + expect(isNewEvent(event, watermark)).toBe(false) + expect(isNewEvent({ ...event, timestamp: "1001" }, watermark)).toBe(true) + }) + + test("validates token based events", () => { + expect(hasValidTokens(event)).toBe(true) + expect(hasValidTokens({ timestamp: "1", model: "x" })).toBe(false) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts b/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts new file mode 100644 index 0000000..6366f58 --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-stop-mapper.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" + +import { hasStopTokens, toCompletedUsageFromStop } from "../src/adapters/cursor/cursor-stop-mapper.ts" + +describe("cursor-stop-mapper", () => { + test("detects tokens in stop payload", () => { + expect(hasStopTokens({ input_tokens: 100, output_tokens: 50 })).toBe(true) + expect(hasStopTokens({})).toBe(false) + }) + + test("maps stop payload to CompletedUsage", () => { + const usage = toCompletedUsageFromStop( + { + generation_id: "gen-1", + input_tokens: 1000, + output_tokens: 200, + }, + "2026-09-09T00:00:00.000Z", + ) + + expect(usage).toEqual({ + receiptKey: "cursor-turn:gen-1", + eventId: "cursor-stop:gen-1", + tokenDelta: 1200, + cost: null, + createdAt: "2026-09-09T00:00:00.000Z", + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts b/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts new file mode 100644 index 0000000..26724de --- /dev/null +++ b/packages/cursor-digital-pet/tests/cursor-usage-event-source.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" + +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { Partner } from "@jcendal/digital-pet-core/domain/partner.ts" + +import type { HookEventRecord } from "../src/adapters/cursor/types.ts" + +mock.module("../src/adapters/cursor/cursor-api-watermark.ts", () => ({ + captureWatermark: async () => ({ timestampMs: 0, fingerprint: "test-watermark" }), + settleTurnDelta: async () => ({ + receiptKey: "cursor-api:gen-api", + eventId: "cursor-api:gen-api", + tokenDelta: 12, + cost: null, + createdAt: "2026-09-09T00:00:01.000Z", + }), +})) + +import { createCursorUsageEventSource } from "../src/adapters/cursor/cursor-usage-event-source.ts" + +const wait = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +const activePartner: Partner = { + partnerId: "partner-1", + generation: 1, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + createdAt: "2026-09-09T00:00:00.000Z", + retiredAt: null, +} + +const createLedger = (): UsageLedger & { readonly receiptKeys: string[] } => { + const receiptKeys: string[] = [] + return { + receiptKeys, + applyUsageReceipt: (metadata, evolve) => { + receiptKeys.push(metadata.receiptKey) + evolve(activePartner) + return { kind: "applied" } + }, + } +} + +const appendHookEvent = async (eventsPath: string, record: HookEventRecord): Promise => { + await appendFile(eventsPath, `${JSON.stringify(record)}\n`, "utf8") +} + +const createHookEventsFixture = async (): Promise<{ tempRoot: string; eventsPath: string }> => { + const tempRoot = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-hook-events-")) + const eventsPath = join(tempRoot, "events.jsonl") + await writeFile(eventsPath, "", "utf8") + return { tempRoot, eventsPath } +} + +describe("cursor usage event source", () => { + let tempRoot: string | undefined + let source: ReturnType | undefined + + afterEach(async () => { + source?.dispose() + source = undefined + if (tempRoot !== undefined) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } + }) + + test("Given a stop hook with token fields When the events file changes Then usage is applied once", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + const applied: UsageProcessingResult[] = [] + source = createCursorUsageEventSource( + ledger, + (result) => { + applied.push(result) + }, + { eventsPath, settleDelayMs: 50 }, + ) + + await wait(50) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-stop", + input_tokens: 100, + output_tokens: 50, + }, + }) + + await wait(150) + expect(ledger.receiptKeys).toEqual(["cursor-turn:gen-stop"]) + expect(applied.some((result) => result.kind === "applied")).toBe(true) + }) + + test("Given duplicate stop receipts When events are replayed Then usage is applied only once", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + + const stopEvent: HookEventRecord = { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-dup", + input_tokens: 10, + output_tokens: 5, + }, + } + + await wait(50) + await appendHookEvent(eventsPath, stopEvent) + await wait(150) + await appendHookEvent(eventsPath, stopEvent) + await wait(150) + + expect(ledger.receiptKeys).toEqual(["cursor-turn:gen-dup"]) + }) + + test("Given beforeSubmit and a tokenless stop When the events file changes Then usage falls back to the watermark settle path", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + + await wait(50) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "beforeSubmitPrompt", + payload: { + hook_event_name: "beforeSubmitPrompt", + conversation_id: "conv-1", + }, + }) + await wait(150) + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:01.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + conversation_id: "conv-1", + generation_id: "gen-api", + }, + }) + + await wait(250) + expect(ledger.receiptKeys).toEqual(["cursor-api:gen-api"]) + }) + + test("Given a disposed source When new hook events arrive Then usage is not applied", async () => { + const fixture = await createHookEventsFixture() + tempRoot = fixture.tempRoot + const { eventsPath } = fixture + + const ledger = createLedger() + source = createCursorUsageEventSource(ledger, undefined, { eventsPath, settleDelayMs: 50 }) + await wait(50) + source.dispose() + + await appendHookEvent(eventsPath, { + receivedAt: "2026-09-09T00:00:00.000Z", + hookEventName: "stop", + payload: { + hook_event_name: "stop", + generation_id: "gen-disposed", + input_tokens: 1, + output_tokens: 1, + }, + }) + + await wait(150) + expect(ledger.receiptKeys).toEqual([]) + }) +}) diff --git a/packages/cursor-digital-pet/tests/database-change-watcher.test.ts b/packages/cursor-digital-pet/tests/database-change-watcher.test.ts new file mode 100644 index 0000000..f6bffce --- /dev/null +++ b/packages/cursor-digital-pet/tests/database-change-watcher.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" + +import { createDatabaseChangeWatcher, type WatchDirectory } from "../src/adapters/sqlite/database-change-watcher.ts" + +const wait = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +const waitForChangeCount = async (count: () => number, expected: number): Promise => { + const deadline = Date.now() + 1_000 + while (count() < expected && Date.now() < deadline) await wait(10) + expect(count()).toBe(expected) +} + +const createFakeWatchDirectory = () => { + let listener: ((eventType: string, filename: string | null) => void) | undefined + let directory: string | undefined + let closed = false + + const watchDirectory: WatchDirectory = (path, onEvent) => { + directory = path + listener = onEvent + return { + close: () => { + closed = true + }, + } + } + + return { + watchDirectory, + watchedDirectory: () => directory, + isClosed: () => closed, + emit: (eventType: string, filename: string | null) => { + if (!closed) listener?.(eventType, filename) + }, + } +} + +describe("database change watcher", () => { + let tempRoot: string | undefined + let watcher: ReturnType | undefined + + afterEach(async () => { + watcher?.dispose() + watcher = undefined + if (tempRoot !== undefined) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = undefined + } + }) + + const startWatcher = async (debounceMs: number) => { + tempRoot = await mkdtemp(join(tmpdir(), "cursor-digital-pet-watch-")) + const databasePath = join(tempRoot, "opencode-digital-pet", "pet.db") + const fake = createFakeWatchDirectory() + let changeCount = 0 + + watcher = createDatabaseChangeWatcher( + { + databasePath, + debounceMs, + onChange: () => { + changeCount += 1 + }, + }, + fake.watchDirectory, + ) + + return { fake, databasePath, count: () => changeCount } + } + + test("Given a pet.db event When watching the data directory Then each separate change fires once", async () => { + const { fake, databasePath, count } = await startWatcher(40) + expect(fake.watchedDirectory()).toBe(dirname(databasePath)) + + fake.emit("rename", "pet.db") + await waitForChangeCount(count, 1) + + fake.emit("change", "pet.db") + await waitForChangeCount(count, 2) + }) + + test("Given rapid pet.db events When watching Then onChange coalesces into one debounced callback", async () => { + const { fake, count } = await startWatcher(60) + + fake.emit("change", "pet.db") + fake.emit("change", "pet.db") + fake.emit("rename", "pet.db") + await waitForChangeCount(count, 1) + await wait(100) + + expect(count()).toBe(1) + }) + + test("Given another file in the data directory When it changes Then onChange is not called", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("change", "notes.txt") + await wait(100) + + expect(count()).toBe(0) + }) + + test("Given no filename in a watch event When it changes Then the database is refreshed", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("rename", null) + await waitForChangeCount(count, 1) + }) + + test("Given a SQLite WAL write When watching Then the shared database is refreshed", async () => { + const { fake, count } = await startWatcher(40) + + fake.emit("change", "pet.db-wal") + await waitForChangeCount(count, 1) + }) + + test("Given a disposed watcher When pet.db changes Then onChange is not called", async () => { + const { fake, count } = await startWatcher(40) + + watcher?.dispose() + watcher = undefined + fake.emit("change", "pet.db") + await wait(100) + + expect(fake.isClosed()).toBe(true) + expect(count()).toBe(0) + }) +}) diff --git a/packages/cursor-digital-pet/tests/dev/register-commands.test.ts b/packages/cursor-digital-pet/tests/dev/register-commands.test.ts new file mode 100644 index 0000000..ebae10b --- /dev/null +++ b/packages/cursor-digital-pet/tests/dev/register-commands.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, test } from "bun:test" + +import { CURSOR_DEV_ACTIONS, DEV_SHOW_MENU_COMMAND, DEV_TOOLS_COMMAND_PALETTE_WHEN } from "../../src/dev/catalog.ts" + +describe("cursor-digital-pet dev register-commands", () => { + test("Given the dev catalog When inspected Then it exposes three dev tool actions and one palette command", () => { + expect(DEV_SHOW_MENU_COMMAND).toBe("cursorDigitalPet.dev.showMenu") + expect(DEV_TOOLS_COMMAND_PALETTE_WHEN).toBe("cursorDigitalPet.devToolsEnabled") + expect(CURSOR_DEV_ACTIONS.map((action) => action.id)).toEqual(["feed", "evolution_reveal", "evolution_battle"]) + }) +}) diff --git a/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts b/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts new file mode 100644 index 0000000..4e94693 --- /dev/null +++ b/packages/cursor-digital-pet/tests/dev/setup-scenario.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { setupBattlePending } from "../../src/dev/setup-scenario.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +const applyDevUsage = ( + repository: Awaited>, + receiptKey: string, +): void => { + recordUsage({ + usage: { + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta: 1, + cost: null, + createdAt: new Date().toISOString(), + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) +} + +const advancePartnerToStage = ( + repository: Awaited>, + targetStage: number, +): void => { + for (let step = 0; step < 12; step += 1) { + const partner = repository.getActivePartner() + const current = partner === null ? undefined : DIGIMON_CATALOG.byId.get(partner.currentNodeId) + if (current !== undefined && current.stage >= targetStage) return + + applyDevUsage(repository, `dev-progress-${step}`) + const pending = repository.getActivePartner() + if (pending?.pendingEvolutionTargetId !== null && pending?.battleOpponentNodeId !== null) { + resolveEvolutionBattleForPartner(repository, true, DIGIMON_CATALOG.byId, `dev-battle-${step}-${Date.now()}`) + } + } +} + +describe("cursor-digital-pet dev setup-scenario", () => { + test("Given a perfect-stage partner When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-dev-perfect-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + spawnPartner(repository, new Date().toISOString()) + advancePartnerToStage(repository, 4) + + const current = DIGIMON_CATALOG.byId.get(repository.getActivePartner()?.currentNodeId ?? "") + expect(current?.stage).toBeGreaterThanOrEqual(4) + + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("Given an empty database When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-cursor-digital-pet-dev-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/cursor-digital-pet/tests/dex-model.test.ts b/packages/cursor-digital-pet/tests/dex-model.test.ts new file mode 100644 index 0000000..32843e9 --- /dev/null +++ b/packages/cursor-digital-pet/tests/dex-model.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" + +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" + +import { artworkToPixelPath } from "../src/webview/shared/pixel-artwork.ts" +import { buildDexPanelModel } from "../src/webview/panels/dex/dex-model.ts" +import { buildDexWebviewHtml } from "../src/webview/panels/dex/dex-render.ts" + +const archive: DigitalPetArchiveResult = { + kind: "available", + partners: [ + { + partnerId: "first", + generation: 1, + createdAt: "2026-09-01T00:00:00Z", + retiredAt: "2026-09-04T00:00:00Z", + events: [ + { eventId: "a", currentNodeId: "3-001", createdAt: "2026-09-03T00:00:00Z" }, + { eventId: "b", currentNodeId: "3-001", createdAt: "2026-09-03T01:00:00Z" }, + ], + }, + { + partnerId: "second", + generation: 2, + createdAt: "2026-09-04T00:00:00Z", + retiredAt: null, + events: [{ eventId: "c", currentNodeId: "3-001", createdAt: "2026-09-05T00:00:00Z" }], + }, + ], +} + +describe("Cursor Digidex records", () => { + test("empty and unavailable archives still show every catalog slot without revealing species", () => { + for (const result of [{ kind: "empty" }, { kind: "unavailable", message: "Cannot read archive" }] as const) { + const model = buildDexPanelModel(result, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + expect(model.status).toBe(result.kind) + expect(model.entries).toHaveLength(DIGIMON_CATALOG.nodes.length) + expect(model.discovered).toBe(0) + for (const entry of model.entries) { + expect(entry.name).toBe("Unknown Digimon") + expect(entry.alternateName).toBe("") + expect(entry.artwork).toBe("") + expect(entry.url).toBe("") + expect(entry.nextIds).toEqual([]) + expect(entry.previousIds).toEqual([]) + } + } + }) + + test("registration counts distinct generations and keeps the earliest sighting", () => { + const model = buildDexPanelModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + const agumon = model.entries.find((entry) => entry.id === "3-001") + expect(model.discovered).toBe(1) + expect(agumon?.name).toBe("Agumon") + expect(agumon?.generations).toBe(2) + expect(agumon?.firstSeen).toBe("2026-09-03T00:00:00Z") + expect(agumon?.artwork).not.toBe("") + expect(agumon?.nextIds).toEqual(DIGIMON_CATALOG.byId.get("3-001")?.nextEvolutions) + expect(agumon?.previousIds).toContain("2-013") + expect(model.entries.find((entry) => entry.id === agumon?.nextIds[0])?.name).toBe("Unknown Digimon") + }) + + test("half blocks are reconstructed into separate square pixels", () => { + expect(artworkToPixelPath("▀▄█ ")).toBe("M0 0h1v1h-1zM1 1h1v1h-1zM2 0h1v1h-1zM2 1h1v1h-1z") + expect(artworkToPixelPath(" \n▄")).toBe("M0 3h1v1h-1z") + }) + + test("embedded model data cannot terminate the JSON script or execute catalog markup", () => { + const model = buildDexPanelModel(archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + const malicious = "" + const html = buildDexWebviewHtml( + { ...model, message: malicious }, + { + nonce: "testnonce", + fontUri: "https://webview.test/font.ttf", + cspSource: "https://webview.test", + }, + ) + expect(html).not.toContain(malicious) + expect(html).toContain("script-src 'nonce-testnonce'") + const data = html.match(/`)).toBe("<script>"&"</script>") + }) +}) diff --git a/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts b/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts new file mode 100644 index 0000000..be134f4 --- /dev/null +++ b/packages/cursor-digital-pet/tests/get-sidebar-card-inputs.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test" + +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" + +const agumon: DigimonNode = { + id: "3-001", + nameEn: "Agumon", + nameJp: "Agumon", + nextEvolutions: [], + sprite: "agumon", + stage: 3, + url: "https://example.test/agumon", +} + +const egg: DigimonNode = { + id: "0-001", + nameEn: "Digitama", + nameJp: "Digitama", + nextEvolutions: [], + sprite: "egg", + stage: 0, + url: "https://example.test/egg", +} + +const catalog: DigimonCatalog = { + nodes: [agumon, egg], + byId: new Map([ + [agumon.id, agumon], + [egg.id, egg], + ]), +} + +const snapshotFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +describe("get sidebar card inputs", () => { + test("Given a reader without a snapshot When querying sidebar card inputs Then it returns no partner", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => null, + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ kind: "no_partner" }) + }) + + test("Given a reader with a catalogued partner snapshot When querying sidebar card inputs Then it preserves partner card fields", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => ({ + currentNodeId: agumon.id, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + ...snapshotFields, + }), + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ + kind: "partner", + node: agumon, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + evolutionBattlePending: false, + }) + }) + + test("Given a pending evolution battle When querying sidebar card inputs Then evolutionBattlePending is true", () => { + const reader: SidebarSnapshotReader = { + getSidebarSnapshot: () => ({ + currentNodeId: agumon.id, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + pendingEvolutionTargetId: "4-017", + battleOpponentNodeId: "3-051", + }), + } + + expect(getSidebarCardInputs(reader, catalog)).toEqual({ + kind: "partner", + node: agumon, + gauge: 25_000, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 40_000, + evolutionBattlePending: true, + }) + }) +}) diff --git a/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts b/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts new file mode 100644 index 0000000..e3f24bb --- /dev/null +++ b/packages/cursor-digital-pet/tests/hexagonal-boundary.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { join } from "node:path" + +import { + FORBIDDEN_CURSOR_ADAPTER_IMPORTS, + FORBIDDEN_SQLITE_ADAPTER_IMPORTS, + FORBIDDEN_VSCODE_ADAPTER_IMPORTS, + PROJECT_ROOT, + findForbiddenImports, + scanForbiddenImports, + scanSharedImports, + scanSidebarImports, +} from "./architecture-boundary-helpers.ts" + +describe("cursor-digital-pet hexagonal dependency direction", () => { + test("Given the shared source tree When scanned Then it has no forbidden dependencies", async () => + expect(await scanSharedImports(join(PROJECT_ROOT, "src", "shared"))).toEqual([])) + + test("Given the sqlite adapter tree When scanned Then it has no vscode dependency", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "sqlite"), FORBIDDEN_SQLITE_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the vscode adapter tree When scanned Then it points inward", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "vscode"), FORBIDDEN_VSCODE_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the cursor adapter tree When scanned Then it has no webview or vscode dependency", async () => + expect( + await scanForbiddenImports(join(PROJECT_ROOT, "src", "adapters", "cursor"), FORBIDDEN_CURSOR_ADAPTER_IMPORTS), + ).toEqual([])) + + test("Given the sidebar source tree When scanned Then only provider imports vscode", async () => + expect(await scanSidebarImports(join(PROJECT_ROOT, "src", "webview", "sidebar"))).toEqual([])) + + test("Given sidebar presenter When scanned Then it remains free of adapter imports", async () => { + const path = join(PROJECT_ROOT, "src", "webview", "sidebar", "sidebar-presenter.ts") + expect(findForbiddenImports(path, await readFile(path, "utf8"), ["/adapters/", "vscode"])).toEqual([]) + }) + + test("Given extension composition When scanned Then it wires the usage pipeline", async () => { + const path = join(PROJECT_ROOT, "src", "extension.ts") + const source = await readFile(path, "utf8") + expect(source.includes("application/usage-pipeline")).toBe(true) + }) +}) diff --git a/packages/cursor-digital-pet/tests/history-model.test.ts b/packages/cursor-digital-pet/tests/history-model.test.ts new file mode 100644 index 0000000..ff6c458 --- /dev/null +++ b/packages/cursor-digital-pet/tests/history-model.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildHistoryPanelModel } from "../src/webview/panels/history/history-model.ts" +import { buildHistoryWebviewHtml } from "../src/webview/panels/history/history-render.ts" + +describe("Cursor history records", () => { + test("orders generations and events, merges repeated sightings but preserves returns to a species", () => { + const model = buildHistoryPanelModel( + { + kind: "available", + partners: [ + { + partnerId: "old", + generation: 1, + createdAt: "2026-09-01T00:00:00Z", + retiredAt: "2026-10-01T00:00:00Z", + events: [], + }, + { + partnerId: "current", + generation: 2, + createdAt: "2026-10-01T00:00:00Z", + retiredAt: null, + events: [ + { eventId: "d", currentNodeId: "3-001", createdAt: "2026-10-01T04:00:00Z" }, + { eventId: "b", currentNodeId: "3-001", createdAt: "2026-10-01T02:00:00Z" }, + { eventId: "a", currentNodeId: "3-001", createdAt: "2026-10-01T01:00:00Z" }, + { eventId: "c", currentNodeId: "2-013", createdAt: "2026-10-01T03:00:00Z" }, + ], + }, + ], + }, + DIGIMON_CATALOG, + DEFAULT_DIGITAL_PET_SETTINGS, + ) + expect(model.generations.map((g) => g.partnerId)).toEqual(["current", "old"]) + expect(model.generations[0]?.steps.map((s) => s.id)).toEqual(["3-001", "2-013", "3-001"]) + expect(model.generations[0]?.steps[0]?.createdAt).toBe("2026-10-01T01:00:00Z") + expect(model.generations[0]?.steps[0]?.name).toBe("Agumon") + expect(model.generations[0]?.steps[0]?.artwork).not.toBe("") + expect(model.generations[1]?.steps).toEqual([]) + expect(model.generations[1]?.retiredAt).toBe("2026-10-01T00:00:00Z") + }) + + test("preserves missing catalog IDs without inventing a species or allowing a Dex destination", () => { + const model = buildHistoryPanelModel( + { + kind: "available", + partners: [ + { + partnerId: "a", + generation: 1, + createdAt: "2026-10-01", + retiredAt: null, + events: [{ eventId: "a", currentNodeId: "removed-node", createdAt: "2026-10-01" }], + }, + ], + }, + DIGIMON_CATALOG, + DEFAULT_DIGITAL_PET_SETTINGS, + ) + expect(model.generations[0]?.steps[0]).toMatchObject({ + id: "removed-node", + name: "removed-node", + artwork: "", + catalogued: false, + }) + }) + + test("keeps unavailable storage distinct from an empty history and escapes embedded archive data", () => { + const message = "&" + const model = buildHistoryPanelModel( + { kind: "unavailable", message }, + DIGIMON_CATALOG, + DEFAULT_DIGITAL_PET_SETTINGS, + ) + expect(model.status).toBe("unavailable") + expect(model.generations).toEqual([]) + expect(buildHistoryPanelModel({ kind: "empty" }, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS).status).toBe( + "empty", + ) + const html = buildHistoryWebviewHtml(model, { + nonce: "testnonce", + fontUri: "font.ttf", + cspSource: "https://webview.test", + }) + expect(html).not.toContain(message) + expect(html).toContain("script-src 'nonce-testnonce'") + expect(JSON.parse(html.match(/&' + const html = buildPanelWebviewHtml( + { message: unsafe }, + { nonce: "testnonce", fontUri: "font.ttf", cspSource: "https://webview.test" }, + { + title: unsafe, + heading: unsafe, + viewClass: "test", + dataId: "test-data", + styles: "", + script: "", + layout: { + summaryHtml: "", + toolbarHtml: '', + listLabel: unsafe, + listHeading: unsafe, + itemsLabel: unsafe, + detailLabel: unsafe, + keyboardHint: unsafe, + }, + }, + ) + expect(html).not.toContain(unsafe) + expect(html).not.toContain("FILTERS') + expect(html).toContain('
') + const data = html.match(/ + +` +} diff --git a/packages/digital-pet-webviews/src/shared/panel-script.ts b/packages/digital-pet-webviews/src/shared/panel-script.ts new file mode 100644 index 0000000..da58ab3 --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/panel-script.ts @@ -0,0 +1,32 @@ +// Runs inside each isolated webview. Strings enter the DOM through textContent. +export const PANEL_SCRIPT_HELPERS = /* javascript */ ` + const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + const UNKNOWN = 'M5 3h6v1H5zM4 4h2v2H4zM10 4h2v3h-2zM8 7h3v1H8zM7 8h2v3H7zM7 13h2v2H7z'; + const sprite = (entry, className) => { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); + svg.setAttribute('aria-hidden', 'true'); + if (className) svg.setAttribute('class', className); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', entry.artwork || UNKNOWN); + path.setAttribute('fill', 'currentColor'); + svg.append(path); + return svg; + }; + entries.addEventListener('click', event => { + if (!(event.target instanceof Element) || !event.target.closest('.entry')) return; + const filters = document.getElementById('filters'); + if (filters) filters.open = false; + if (!window.digitalPetBridge) return; + requestAnimationFrame(() => { + const screen = document.querySelector('.screen'); + const heading = document.querySelector('.catalog-header'); + if (screen && heading) screen.scrollTop += heading.getBoundingClientRect().top - screen.getBoundingClientRect().top - 16; + }); + }); +` diff --git a/packages/digital-pet-webviews/src/shared/panel-styles.ts b/packages/digital-pet-webviews/src/shared/panel-styles.ts new file mode 100644 index 0000000..97ca2eb --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/panel-styles.ts @@ -0,0 +1,113 @@ +export const PANEL_STYLES = /* css */ ` + button, input, select { font: inherit; color: inherit; border-radius: 0; text-transform: uppercase; } + button { cursor: pointer; } + button:disabled { cursor: default; opacity: .4; } + :focus-visible { outline: 2px solid var(--ink); outline-offset: -4px; } + .device { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; max-width: 1120px; margin: auto; padding: 12px; background: var(--case); border: 4px solid var(--case-edge); + box-shadow: 4px 0 #342b21, -4px 0 #342b21, 0 4px #342b21, 0 -4px #342b21, inset 0 0 0 3px #342b21; + position: relative; } + .device::before { content: ''; position: absolute; inset: 4px; pointer-events: none; border: 2px solid #342b21; + background: repeating-linear-gradient(90deg, transparent 0 66px, #342b21 66px 68px, transparent 68px 70px) top / 100% 8px no-repeat, + repeating-linear-gradient(90deg, transparent 0 66px, #342b21 66px 68px, transparent 68px 70px) bottom / 100% 8px no-repeat; } + .masthead { display: flex; justify-content: space-between; align-items: center; gap: 20px; color: var(--case-ink); padding: 0 6px 10px; } + .brand, h1 { margin: 0; font-size: 24px; line-height: 1.5; font-weight: 400; } + .screen { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--lcd); border: 4px solid #303725; padding: 12px; + box-shadow: 0 0 0 3px #3f3225, inset 0 0 0 4px var(--lcd-edge); } + .completion { flex-shrink: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } + .completion > div:first-child { display: flex; align-items: center; gap: 12px; flex: 1; } + .completion .micro { font-size: var(--type-body); color: var(--ink); } + .completion > div:first-child > p { margin-right: auto; } + .completion strong { font-size: 24px; font-weight: 400; } + .toolbar { flex-shrink: 0; display: grid; grid-template-columns: var(--toolbar-columns, minmax(0,1fr) auto); gap: 12px; align-items: end; margin-bottom: 20px; } + .panel-filters { flex-shrink: 0; margin-bottom: 20px; } + .panel-filters summary { min-height: 42px; display: flex; align-items: center; justify-content: space-between; + padding: 8px 10px; border: 2px solid var(--muted); cursor: pointer; font-size: var(--type-label); list-style: none; } + .panel-filters summary::-webkit-details-marker { display: none; } + .panel-filters summary::after { content: '+'; font-family: monospace; font-size: 20px; line-height: 1; } + .panel-filters[open] summary::after { content: '−'; } + .panel-filters .toolbar { margin-top: 10px; margin-bottom: 0; } + .field { display: grid; gap: 6px; min-width: 0; } + .search-field { grid-column: 1 / -1; } + .search-field > .micro { display: none; } + input, select { min-height: 48px; background: transparent; border: 2px solid var(--muted); padding: 7px 10px; } + input { width: 100%; } + input::placeholder { color: var(--muted); opacity: 1; } + select { width: 100%; font-size: var(--type-body); } + select option { background: var(--lcd); } + .utility { min-height: 48px; padding: 7px 10px; background: transparent; border: 0; font-size: var(--type-label); } + .utility { border: 2px solid var(--muted); } + .utility:hover { background: var(--ink); color: var(--lcd); } + .workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: var(--workspace-columns, minmax(0,1fr) minmax(0,1fr)); gap: 16px; align-items: stretch; } + .catalog { min-width: 0; min-height: 0; display: flex; flex-direction: column; } + .catalog-header { padding: 0 4px 9px; display: flex; justify-content: space-between; gap: 12px; border-bottom: 2px solid var(--muted); } + .entries { gap: 2px; + flex: 1; min-height: 0; overflow-y: auto; padding: 4px 6px 4px 0; + scrollbar-color: var(--muted) var(--line); scrollbar-width: thin; } + .entry { border: 0; border-bottom: 2px dotted var(--line); color: var(--ink); background: transparent; + min-width: 0; padding: 10px 6px; position: relative; } + .entry:hover { background: #a4b488; } + .entry[aria-pressed=true] { background: var(--ink); color: var(--lcd); } + .entry:focus-visible { outline: 2px dotted var(--lcd); outline-offset: -4px; z-index: 1; } + .no-results { padding: 36px 16px; color: var(--muted); text-align: center; line-height: 2; } + .detail { border-left: 4px solid var(--line); padding-left: 16px; display: flex; flex-direction: column; min-height: 0; min-width: 0; } + .detail-heading { flex-shrink: 0; display: flex; justify-content: space-between; gap: 12px; padding: 0 0 10px; border-bottom: 2px solid var(--muted); } + .detail-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-top: 12px; scrollbar-width: thin; scrollbar-color: var(--muted) var(--line); } + .lcd-frame { padding: 0; } + .lcd { height: 200px; color: var(--ink); display: grid; place-items: center; position: relative; } + .lcd svg { width: 128px; height: 128px; } + .detail-name { padding: 14px 0; border-bottom: 2px dotted var(--line); } + .detail-name .micro { margin-top: 4px; } + .lcd { height: 200px; } + .facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px 0; border-bottom: 2px dotted var(--line); } + .fact { display: grid; gap: 4px; font-size: var(--type-body); } + .hint { color: var(--muted); font-size: var(--type-small); line-height: 1.8; } + .detail-body > .hint { margin: 12px 0; } + .detail-nav { flex-shrink: 0; display: flex; justify-content: space-between; gap: 8px; border-top: 2px solid var(--muted); padding-top: 10px; margin-top: 14px; } + .detail-nav button { background: transparent; border: 0; padding: 4px 0; font-size: var(--type-label); } + .keyboard-hint { flex-shrink: 0; margin-top: 12px; color: var(--muted); font-size: var(--type-small); } + .footer { padding: 14px 6px 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; + font-size: var(--type-body); color: var(--case-ink); } + .archive-status { font-size: var(--type-small); margin-left: auto; } + .case-dots { font-family: monospace; font-size: 28px; line-height: 16px; letter-spacing: 2px; } + .notice { flex-shrink: 0; max-height: 20%; overflow-y: auto; padding: 10px; margin: 0 0 12px; border: 2px dashed var(--muted); font-size: var(--type-body); } + @media (max-width: 820px) { .toolbar { grid-template-columns: minmax(0,1fr) minmax(0,1fr); } } + @media (max-width: 820px) { body { padding: 10px; } + .workspace { grid-template-columns: 1fr; grid-template-rows: var(--workspace-rows, minmax(0,1fr) minmax(0,1.6fr)); } + .detail { position: static; border-left: 0; border-top: 4px solid var(--line); padding: 16px 0 0; } + .entries { max-height: none; } } + @media (max-width: 600px) { body { padding: 10px; } + .device { padding: 10px; } + .screen { padding: 10px; } + .brand, h1 { font-size: var(--type-body); } + .masthead { gap: 12px; flex-wrap: wrap; } + .completion > div:first-child { flex-wrap: wrap; gap: 6px; } + .completion > div:first-child > p { width: 100%; } + .archive-status, .keyboard-hint { display: none; } + .footer { font-size: var(--type-small); } + .toolbar { gap: 10px; max-height: 35dvh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--muted) var(--line); } + .utility { padding: 7px 8px; } + .detail-heading { flex-wrap: wrap; } + .catalog-header { flex-wrap: wrap; } + .detail-nav { flex-wrap: wrap; } } + @media (max-height: 750px) { body { padding: 8px; } + .device { padding: 10px; } + .screen { padding: 10px; } + .brand, h1 { font-size: 20px; line-height: 1.2; } + .masthead { padding-bottom: 8px; } + .completion { gap: 6px; margin-bottom: 8px; } + .completion strong { font-size: 20px; } + .toolbar { gap: 8px; margin-bottom: 10px; } + .panel-filters { margin-bottom: 10px; } + .field { gap: 3px; } + input, select, .utility { min-height: 40px; } + .footer { padding-top: 8px; font-size: var(--type-label); } + .keyboard-hint { display: none; } + .detail-body { padding-top: 8px; } + .detail-name h2 { font-size: 24px; } + .facts { clear: both; padding: 8px 0; } + .detail-nav { padding-top: 6px; margin-top: 8px; } + .catalog-header, .detail-heading { padding-bottom: 6px; } } + @media (max-width: 820px) and (max-height: 750px) { .toolbar { max-height: 35dvh; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--muted) var(--line); } + .detail { padding-top: 8px; } } + @media (forced-colors: active) { .entry[aria-pressed=true] { outline: 2px solid Highlight; } } +` diff --git a/packages/digital-pet-webviews/src/shared/pixel-artwork.ts b/packages/digital-pet-webviews/src/shared/pixel-artwork.ts new file mode 100644 index 0000000..daed99a --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/pixel-artwork.ts @@ -0,0 +1,31 @@ +const artworkPixels = (artwork: string): readonly { x: number; y: number }[] => { + const pixels: { x: number; y: number }[] = [] + for (const [row, line] of artwork.split("\n").entries()) { + for (const [column, cell] of Array.from(line).entries()) { + if (cell === "█" || cell === "▀") pixels.push({ x: column, y: row * 2 }) + if (cell === "█" || cell === "▄") pixels.push({ x: column, y: row * 2 + 1 }) + } + } + return pixels +} + +const pixelPath = (pixels: readonly { x: number; y: number }[]): string => + pixels.map(({ x, y }) => `M${x} ${y}h1v1h-1z`).join("") + +/** Expand terminal half-block artwork back into the original square LCD pixels. */ +export const artworkToPixelPath = (artwork: string): string => pixelPath(artworkPixels(artwork)) + +/** Center the visible pixels while keeping a consistent scale between species. */ +export const artworkToCenteredPixelArt = (artwork: string): { path: string; viewBox: string } => { + const pixels = artworkPixels(artwork) + if (!pixels.length) return { path: "", viewBox: "0 0 32 32" } + const left = Math.min(...pixels.map((pixel) => pixel.x)) + const top = Math.min(...pixels.map((pixel) => pixel.y)) + const right = Math.max(...pixels.map((pixel) => pixel.x)) + 1 + const bottom = Math.max(...pixels.map((pixel) => pixel.y)) + 1 + const size = Math.max(32, right - left, bottom - top) + return { + path: pixelPath(pixels), + viewBox: `${(left + right - size) / 2} ${(top + bottom - size) / 2} ${size} ${size}`, + } +} diff --git a/packages/digital-pet-webviews/src/shared/sidebar-ui.ts b/packages/digital-pet-webviews/src/shared/sidebar-ui.ts new file mode 100644 index 0000000..157dcbd --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/sidebar-ui.ts @@ -0,0 +1,6 @@ +export const MIN_ARTWORK_WIDTH = 16 +export const DEFAULT_ARTWORK_WIDTH = 32 + +export const NEXT_CHECK_PREFIX = "Next check: " +export const NEXT_CHECK_BAR_WIDTH = 20 +export const SIDEBAR_URL_LABEL = "Encyclopedia entry" diff --git a/packages/digital-pet-webviews/src/shared/theme.ts b/packages/digital-pet-webviews/src/shared/theme.ts new file mode 100644 index 0000000..f2155a2 --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/theme.ts @@ -0,0 +1,15 @@ +export const PANEL_THEME = /* css */ ` + * { box-sizing: border-box; } + :root { color-scheme: light; --case: #594130; --case-edge: #9a7750; --case-ink: #d6c5a3; + --lcd: #bac79c; --lcd-edge: #89966f; --ink: #26351e; --muted: #435334; --line: #8fa276; + --type-small: 14px; --type-label: 16px; --type-body: 18px; --type-name: 20px; --type-title: 28px; } + html { height: 100%; overflow: hidden; } + body { height: 100%; height: 100dvh; overflow: hidden; margin: 0; background: #10120e; color: var(--ink); font-family: 'Digital Pet Pixel', monospace; + font-size: var(--type-body); line-height: 1.4; padding: 12px; } + .micro { font-size: var(--type-label); color: var(--muted); } + h2, h3, p { margin: 0; } + h2 { font-size: var(--type-title); font-weight: 400; overflow-wrap: anywhere; text-transform: uppercase; } + h3 { font-size: var(--type-body); font-weight: 400; } + svg { shape-rendering: crispEdges; } + [hidden] { display: none !important; } +` diff --git a/packages/digital-pet-webviews/src/shared/webview-resources.ts b/packages/digital-pet-webviews/src/shared/webview-resources.ts new file mode 100644 index 0000000..6ee0b81 --- /dev/null +++ b/packages/digital-pet-webviews/src/shared/webview-resources.ts @@ -0,0 +1,5 @@ +export type PanelWebviewResources = { + readonly nonce: string + readonly fontUri: string + readonly cspSource: string +} diff --git a/packages/digital-pet-webviews/src/sidebar/sidebar-document.ts b/packages/digital-pet-webviews/src/sidebar/sidebar-document.ts new file mode 100644 index 0000000..681f1d8 --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/sidebar-document.ts @@ -0,0 +1,22 @@ +import { escapeHtml } from "../shared/escape-html.ts" +import { PANEL_STYLES } from "../shared/panel-styles.ts" +import { PANEL_THEME } from "../shared/theme.ts" +import { SIDEBAR_SCRIPT } from "./sidebar-script.ts" +import { SIDEBAR_STYLES } from "./sidebar-styles.ts" + +export const buildSidebarWebviewHtml = ( + nonce: string, + resources?: { readonly fontUri: string; readonly cspSource: string; readonly webShell?: boolean }, +): string => ` +Digital Pet Partner + + +${resources?.webShell ? '

DIGITAL MONSTER

PARTNER

' : ""}<${resources?.webShell ? "section" : "main"} class="pet-module" aria-label="Digital Pet"> +
LOADINGVPET
+
+

+
NEXT CHECK
+

+ +
+${resources?.webShell ? '
VPET · DATA ARCHIVELOCAL ARCHIVE
' : ""}` diff --git a/packages/digital-pet-webviews/src/sidebar/sidebar-presenter.ts b/packages/digital-pet-webviews/src/sidebar/sidebar-presenter.ts new file mode 100644 index 0000000..ab4718d --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/sidebar-presenter.ts @@ -0,0 +1,26 @@ +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" + +import type { MonsterAnimationIdentity } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { toSidebarWebviewPayload, type SidebarWebviewPayload } from "./sidebar-render.ts" + +export type SidebarPresentation = { + readonly payload: SidebarWebviewPayload + readonly partner: MonsterAnimationIdentity | undefined +} + +export const buildSidebarPresentation = ( + snapshot: SidebarSnapshot | null, + settings: ResolvedDigitalPetSettings = DEFAULT_DIGITAL_PET_SETTINGS, +): SidebarPresentation => { + const reader = { getSidebarSnapshot: () => snapshot } + const inputs = getSidebarCardInputs(reader, DIGIMON_CATALOG) + const model = buildSidebarCardModel(inputs, settings) + const payload = toSidebarWebviewPayload(model) + const partner = model.kind === "partner" ? { sprite: model.sprite, isDigitama: model.stageNumber === 0 } : undefined + return { payload, partner } +} diff --git a/packages/digital-pet-webviews/src/sidebar/sidebar-render.ts b/packages/digital-pet-webviews/src/sidebar/sidebar-render.ts new file mode 100644 index 0000000..536564a --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/sidebar-render.ts @@ -0,0 +1,57 @@ +import type { SidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" + +import { + DEFAULT_ARTWORK_WIDTH, + MIN_ARTWORK_WIDTH, + NEXT_CHECK_BAR_WIDTH, + NEXT_CHECK_PREFIX, + SIDEBAR_URL_LABEL, +} from "../shared/sidebar-ui.ts" +import type { SidebarWebviewPayload } from "./webview-messages.ts" + +export type { AnimationFramePayload, SidebarWebviewPayload } from "./webview-messages.ts" + +export const pixelWidthToArtworkColumns = (pixelWidth: number, charWidthPx: number): number => { + if (!Number.isFinite(pixelWidth) || pixelWidth <= 0) return DEFAULT_ARTWORK_WIDTH + if (!Number.isFinite(charWidthPx) || charWidthPx <= 0) return DEFAULT_ARTWORK_WIDTH + return Math.max(MIN_ARTWORK_WIDTH, Math.floor(pixelWidth / charWidthPx)) +} + +const formatCount = (value: number): string => value.toLocaleString("en-US") + +export const buildNextCheckLine = (model: SidebarCardModel): string => { + if (model.kind === "no_partner") return "" + if (model.isTerminal && !model.isSetOverride) return `${NEXT_CHECK_PREFIX}None` + if (model.evolutionBattlePending) return `${NEXT_CHECK_PREFIX}Evolution battle!` + + const progress = model.isTerminal ? 1 : Math.min(Math.max(model.gauge / model.threshold, 0), 1) + const filled = Math.floor(progress * NEXT_CHECK_BAR_WIDTH) + return `${NEXT_CHECK_PREFIX}[${"█".repeat(filled)}${"░".repeat(NEXT_CHECK_BAR_WIDTH - filled)}]` +} + +export const buildGaugeLine = (model: SidebarCardModel): string => { + if (model.kind !== "partner") return "" + return model.isTerminal ? "-/-" : `${formatCount(model.gauge)}/${formatCount(model.threshold)}` +} + +export const toSidebarWebviewPayload = (model: SidebarCardModel): SidebarWebviewPayload => { + if (model.kind === "no_partner") { + return { type: "sidebar-model", kind: "no_partner", messageLine: model.messageLine } + } + + return { + type: "sidebar-model", + kind: "partner", + progress: model.isTerminal ? 1 : model.threshold > 0 ? Math.min(Math.max(model.gauge / model.threshold, 0), 1) : 0, + terminal: model.isTerminal && !model.isSetOverride, + name: model.isSetOverride ? `${model.name} (set)` : model.name, + stage: model.frozen ? `${model.stage} (frozen)` : model.stage, + nextCheck: buildNextCheckLine(model), + gauge: buildGaugeLine(model), + url: model.url, + urlLabel: SIDEBAR_URL_LABEL, + frozen: model.frozen, + } +} + +export { buildSidebarWebviewHtml } from "./sidebar-document.ts" diff --git a/packages/digital-pet-webviews/src/sidebar/sidebar-script.ts b/packages/digital-pet-webviews/src/sidebar/sidebar-script.ts new file mode 100644 index 0000000..31b4936 --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/sidebar-script.ts @@ -0,0 +1,92 @@ +export const SIDEBAR_SCRIPT = /* javascript */ ` + const vscode = window.digitalPetBridge || acquireVsCodeApi(); + let model = null; + let state = {phase:'idle'}; + let artwork = ''; + let artworkColumns = 32; + let battleHud = null; + const byId = id => document.getElementById(id); + const root = document.querySelector('.pet-module'); + const renderBattleHud = () => { + const visible = battleHud !== null; + document.querySelector('.arena').classList.toggle('with-battle-hud',visible); + byId('battle-scores').hidden = !visible; + byId('battle-caption').hidden = !visible || !battleHud.caption; + byId('battle-caption').textContent = battleHud?.caption || ''; + if (!visible) return; + document.querySelector('.arena').setAttribute('aria-label','Battle: partner ' + battleHud.playerHits + ' / ' + battleHud.hitsToWin + ' hits; opponent ' + battleHud.opponentHits + ' / ' + battleHud.hitsToWin + ' hits' + (battleHud.caption ? '; ' + battleHud.caption : '')); + for (const [id,hits] of [['player-score',battleHud.playerHits],['opponent-score',battleHud.opponentHits]]) { + const score = byId(id); score.replaceChildren(); + score.setAttribute('aria-label',(id === 'player-score' ? 'Partner' : 'Opponent') + ': ' + hits + ' / ' + battleHud.hitsToWin + ' hits'); + for (let index = 0; index < battleHud.hitsToWin; index++) { + const pip = document.createElement('span'); pip.className = index < hits ? 'score-pip filled' : 'score-pip'; pip.setAttribute('aria-hidden','true'); score.append(pip); + } + } + }; + const renderArtwork = () => { + const svg = byId('artwork'); svg.replaceChildren(); + const lines = artwork.split('\\n'); + const width = Math.max(artworkColumns,...lines.map(line => Array.from(line).length)); + svg.setAttribute('viewBox', '0 0 ' + width + ' ' + Math.max(16,lines.length * 2)); + const append = (tag, attrs) => { const node = document.createElementNS('http://www.w3.org/2000/svg',tag); for (const [key,value] of Object.entries(attrs)) node.setAttribute(key,String(value)); svg.append(node); return node; }; + for (const [row,line] of lines.entries()) for (const [column,cell] of Array.from(line).entries()) { + if (battleHud && (row === battleHud.scoreRow || row === battleHud.captionRow) && column >= battleHud.gapStartColumn && column < battleHud.gapStartColumn + battleHud.gapColumns) continue; + if ('█▀▄░▒▓'.includes(cell)) { + const top = cell !== '▄'; const bottom = cell !== '▀'; + const opacity = cell === '░' ? .25 : cell === '▒' ? .5 : cell === '▓' ? .75 : 1; + if (top) append('rect',{x:column,y:row*2,width:1,height:1,fill:'currentColor',opacity}); + if (bottom) append('rect',{x:column,y:row*2+1,width:1,height:1,fill:'currentColor',opacity}); + } else if (cell.trim()) { + // Battle captions (HIT!, WIN!, MISS) share frames with the pixel sprites. + const text = append('text',{x:column+.5,y:row*2+1.6,'text-anchor':'middle','font-family':'monospace','font-size':1.6,fill:'currentColor'}); text.textContent = cell; + } + } + }; + const render = () => { + const active = model?.kind === 'partner'; + root.classList.toggle('no-partner',!active); + root.classList.toggle('animating',state.phase !== 'idle'); + root.classList.toggle('battling',state.phase === 'battle'); + byId('empty').hidden = active; + byId('empty').textContent = model?.messageLine || 'Spawn a partner to begin.'; + byId('phase').textContent = !active ? 'NO PARTNER' : state.phase === 'idle' ? (model.frozen ? 'FROZEN' : 'ACTIVE') : state.phase.toUpperCase(); + if (!active) return; + byId('name').textContent = model.name; + byId('name').title = model.name; + byId('stage').textContent = state.phase === 'battle' ? (model.opponentName || '') : state.phase === 'evolving' ? 'TRANSFORMING...' : state.phase === 'defeated' ? 'DEFEAT · ' + model.stage : model.stage; + byId('stage').title = byId('stage').textContent; + const percent = Math.round(model.progress * 100); + byId('progress-label').textContent = model.terminal ? 'FINAL STAGE' : 'NEXT CHECK'; + byId('percent').textContent = model.terminal ? '' : percent + '%'; + byId('meter').hidden = model.terminal; + byId('meter').setAttribute('aria-valuenow',String(percent)); + byId('meter-fill').style.width = percent + '%'; + const abbreviated = model.gauge.split('/').map(value => { + const count = Number(value.replaceAll(',', '')); + return Number.isFinite(count) ? new Intl.NumberFormat('en', {notation:'compact',maximumFractionDigits:1}).format(count) : value; + }).join(' / '); + byId('gauge').textContent = model.terminal ? 'No further evolution check.' : abbreviated; + byId('gauge').title = model.gauge; + document.querySelector('.arena').setAttribute('aria-label', (state.phase === 'idle' ? 'Partner' : state.phase) + ' animation: ' + model.name); + }; + const reportWidth = () => { + const arena = document.querySelector('.arena'); + const width = arena.clientWidth; + if (width > 0) { + const pixelSize = parseFloat(getComputedStyle(arena).getPropertyValue('--artwork-pixel-size')) || 6; + const columns = Math.max(state.phase === 'idle' ? 16 : 36,Math.floor(width / pixelSize)); + if (columns !== artworkColumns) { artworkColumns = columns; renderArtwork(); } + vscode.postMessage({type:'artwork-width',width:columns}); + } + }; + for (const button of document.querySelectorAll('[data-panel]')) button.addEventListener('click', () => vscode.postMessage({type:'open-panel',panel:button.dataset.panel})); + window.addEventListener('message', event => { + const message = event.data; + if (message?.type === 'sidebar-model') { model = message; render(); reportWidth(); } + if (message?.type === 'presentation-state') { state = message.state; if (state.phase !== 'battle') { battleHud = null; renderBattleHud(); } render(); reportWidth(); } + if (message?.type === 'animation-frame' && typeof message.artwork === 'string') { artwork = message.artwork; battleHud = message.hud || null; renderBattleHud(); renderArtwork(); } + }); + new ResizeObserver(reportWidth).observe(root); + render(); reportWidth(); + vscode.postMessage({type:"sidebar-ready"}); +` diff --git a/packages/digital-pet-webviews/src/sidebar/sidebar-styles.ts b/packages/digital-pet-webviews/src/sidebar/sidebar-styles.ts new file mode 100644 index 0000000..e5daf20 --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/sidebar-styles.ts @@ -0,0 +1,38 @@ +export const SIDEBAR_STYLES = /* css */ ` + html { height: auto; overflow: auto; } + body { height: auto; padding: 10px; background: transparent; overflow: auto; } + .pet-module { padding: 10px; border: 2px solid var(--case-edge); background: var(--lcd); box-shadow: 0 0 0 2px var(--case); } + .pet-header { display: flex; justify-content: space-between; gap: 8px; border-bottom: 2px solid var(--muted); padding-bottom: 6px; font-size: 14px; } + .pet-header .micro { font-size: 14px; } + #phase::before { content: ''; display: inline-block; width: 8px; height: 8px; background: var(--ink); margin-right: 6px; } + #content { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "name" "art" "progress"; grid-template-rows: 26px 144px 64px; } + .arena { position: relative; grid-area: art; min-width: 0; height: 144px; display: grid; place-items: center; overflow: hidden; } + .arena svg { width: 100%; height: 100%; } + .arena.with-battle-hud svg { height: calc(100% - 56px); margin-top: 24px; margin-bottom: 32px; } + #battle-scores { position: absolute; top: 8px; left: 2px; right: 2px; display: flex; justify-content: space-between; } + #player-score, #opponent-score { display: flex; gap: 6px; } + .score-pip { width: 16px; height: 16px; border: 2px solid var(--ink); } + .score-pip.filled { background: var(--ink); } + #battle-caption { position: absolute; bottom: 6px; left: 0; right: 0; text-align: center; font-size: 18px; line-height: 1.2; } + .identity { grid-area: name; min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; } + #name { min-width: 0; font-size: 14px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + #stage { min-width: 0; max-width: 50%; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .battling #name, .battling #stage { flex: 1; max-width: 50%; font-size: 14px; } + .battling #stage { text-align: right; } + .progress { grid-area: progress; height: 64px; padding: 4px 0; border-top: 2px solid var(--muted); } + .progress-heading { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; } + #meter { height: 12px; margin: 4px 0; border: 1px solid var(--muted); padding: 2px; } + #meter-fill { display: block; height: 100%; background: repeating-linear-gradient(90deg, var(--ink) 0 8px, transparent 8px 10px); width: 0; } + #gauge { font-size: 12px; } + .pet-actions { display: flex; justify-content: flex-end; gap: 6px; } + .pet-actions button { min-height: 22px; padding: 2px 6px; color: var(--ink); font: inherit; font-size: 12px; line-height: 1.2; border: 1px solid var(--muted); border-radius: 0; background: transparent; cursor: pointer; } + .pet-actions button:hover { color: var(--lcd); background: var(--ink); } + :focus-visible { outline: 2px solid var(--ink); outline-offset: -3px; } + .animating #content { grid-template-areas: "name" "art"; grid-template-rows: 26px 200px; } + .animating .arena { height: 200px; } + .animating .progress { display: none; } + .animating .pet-actions { border-top: 2px solid var(--muted); padding-top: 6px; } + #empty { padding: 20px 0; font-size: 14px; } + .no-partner #content { display: none; } + @media (max-width: 260px) { body { padding: 6px; } .pet-module { padding: 8px; } } +` diff --git a/packages/digital-pet-webviews/src/sidebar/webview-messages.ts b/packages/digital-pet-webviews/src/sidebar/webview-messages.ts new file mode 100644 index 0000000..cad8fde --- /dev/null +++ b/packages/digital-pet-webviews/src/sidebar/webview-messages.ts @@ -0,0 +1,55 @@ +import type { BattleFrameHud } from "@jcendal/digital-pet-animation/sequences/evolution-battle-artwork.ts" +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" +export type SidebarWebviewPayload = + | { readonly type: "sidebar-model"; readonly kind: "no_partner"; readonly messageLine: string } + | { + readonly type: "sidebar-model" + readonly kind: "partner" + readonly progress: number + readonly terminal: boolean + readonly name: string + readonly opponentName?: string + readonly stage: string + readonly nextCheck: string + readonly gauge: string + readonly url: string + readonly urlLabel: string + readonly frozen: boolean + } + +export type AnimationFramePayload = { + readonly hud?: BattleFrameHud + readonly type: "animation-frame" + readonly artwork: string +} + +export type ExtensionToWebviewMessage = + | SidebarWebviewPayload + | AnimationFramePayload + | { readonly type: "presentation-state"; readonly state: PresentationState } + +export type WebviewToExtensionMessage = + | { readonly type: "sidebar-ready" } + | { readonly type: "open-panel"; readonly panel: "dex" | "history" } + | { readonly type: "open-url"; readonly url: string } + | { readonly type: "artwork-width"; readonly width: number } + +export const parseWebviewInboundMessage = (raw: unknown): WebviewToExtensionMessage | null => { + if (raw === null || typeof raw !== "object") return null + const message = raw as { + readonly type?: unknown + readonly panel?: unknown + readonly url?: unknown + readonly width?: unknown + } + if (message.type === "sidebar-ready") return { type: "sidebar-ready" } + if (message.type === "open-panel" && (message.panel === "dex" || message.panel === "history")) + return { type: "open-panel", panel: message.panel } + if (message.type === "open-url" && typeof message.url === "string" && message.url.length > 0) { + return { type: "open-url", url: message.url } + } + if (message.type === "artwork-width" && typeof message.width === "number" && Number.isFinite(message.width)) { + return { type: "artwork-width", width: message.width } + } + return null +} diff --git a/packages/digital-pet-webviews/tsconfig.json b/packages/digital-pet-webviews/tsconfig.json new file mode 100644 index 0000000..ae646cf --- /dev/null +++ b/packages/digital-pet-webviews/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["node"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/opencode-digital-pet/CHANGELOG.md b/packages/opencode-digital-pet/CHANGELOG.md new file mode 100644 index 0000000..1f153cb --- /dev/null +++ b/packages/opencode-digital-pet/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.3.0] - 2026-10-06 + +### Changed + +- Feat/cursor pet display + + +## [0.2.3] - 2026-10-06 + +### Changed + +- chore(digital-pet-core): mark stages for a release probe + + +## [0.2.2] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.1] - 2026-10-06 + +### Changed + +- fix(cursor-digital-pet): point README images at the package directory + + +## [0.2.0] - 2026-10-06 + +### Changed + +- Renamed the product and packages to Digital Pet under the `jcendal` namespace. +- Kept existing `opencode-vpet` settings and database paths readable so current partners remain available. +- Updated release metadata for the `jcendal/digital-pet` repository and credited Sergio Bugallo as the original author. + +## [0.1.1] - 2026-08-30 + +### Fixed + +- Exported the OpenCode server plugin entrypoint so VPet commands and the sidebar load correctly. +- Updated `init` and `update` to delegate to OpenCode's native global plugin installer with the + installed exact package version, avoiding stale bare or `@latest` cache reuse. + +## [0.1.0] - 2026-08-30 + +### Added + +- Server and TUI plugins that add a Digimon virtual pet to the OpenCode sidebar. +- Progression from completed assistant-message token usage, including reconciliation of completed + usage from concurrent sessions. +- Toast notifications for spawning, progression controls, and Digimon evolutions. +- A 650-Digimon catalog spanning eight evolution stages, with evolution paths and Japanese and + English names. +- Partner generations that can be spawned, evolved, frozen, unfrozen, or set to a catalog ID. +- Sidebar progress and status display, including frozen and manually set partner states, with + animated walking, action, and sleep behavior. +- Dex and History dialogs for browsing discovered Digimon and current or retired partner + generations, with links to external encyclopedia entries. +- `/vpet-spawn`, `/vpet-freeze`, `/vpet-unfreeze`, `/vpet-set `, `/vpet-dex`, and + `/vpet-history` commands. +- SQLite persistence for partners, progression events, usage receipts, trainer totals, and control + state. +- Global settings for language, evolution thresholds, and notifications. +- `npx @sbugallo/opencode-vpet init` and `npx @sbugallo/opencode-vpet update` commands that register + server and TUI plugins while preserving unrelated JSON and JSONC configuration. diff --git a/packages/opencode-digital-pet/LICENSE b/packages/opencode-digital-pet/LICENSE new file mode 100644 index 0000000..3d6428f --- /dev/null +++ b/packages/opencode-digital-pet/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Sergio Bugallo Enjamio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/opencode-digital-pet/README.md b/packages/opencode-digital-pet/README.md new file mode 100644 index 0000000..3d69938 --- /dev/null +++ b/packages/opencode-digital-pet/README.md @@ -0,0 +1,843 @@ +
+ OpenCode Digital Pet logo +

OpenCode Digital Pet

+

+ A Digimon virtual pet for OpenCode that evolves with usage. +

+

+ About | + Evolution | + Quick start | + Commands | + Settings | + Storage | + Partner list | + License | + Attributions + +

+
+ +--- + +## About + +
+ Illustrative OpenCode Digital Pet overview +
+ +`@jcendal/opencode-digital-pet` is an OpenCode v1 plugin that adds a Digimon virtual pet to your OpenCode TUI +sidebar. Your Digimon evolves as you use OpenCode, and you can browse your discoveries and +partner history. + +The plugin supports multiple concurrent sessions, so every token counts toward your partner's evolution. + +There are more than 600 Digimon to discover, and the plugin is designed to be extensible so that new Digimon can be added in future releases. + +## Evolution + +Your active partner gains experience from the tokens reported for completed assistant +messages in OpenCode. This includes input, output, reasoning, and cache tokens. +Progress is shared across concurrent OpenCode sessions, so completed work in each +session contributes to the same partner. + +Each stage has a configurable experience threshold. When your partner reaches the +threshold for its current stage, it evolves and its experience gauge resets for the +next stage. Use `/digital-pet-freeze` to pause partner progression; usage while frozen is +not applied to your partner. + +The bundled evolution lines were extracted from the original V-Pets. When a Digimon +reaches an evolution threshold, the plugin randomly chooses one of the evolution +options in that Digimon's line. + +## Quick start + +Requirements: + +- OpenCode `>=1.18.10` +- Node `24.11.0` to run the installation CLI with `npx` +- Bun `1.3.5` for local development and tests; OpenCode runs the plugin with Bun + + +1. Install the OpenCode plugin: + + ```sh + npx @jcendal/opencode-digital-pet init + ``` + + The CLI delegates to `opencode plugin @jcendal/opencode-digital-pet@ --global --force`. + It uses the package's installed exact version so OpenCode uses a version-specific cache key rather + than reusing a stale bare or `@latest` cache entry. `--force` replaces matching plugin + configuration registrations. + +2. Restart OpenCode when the command finishes. + +3. In OpenCode, run `/digital-pet-spawn` to hatch a partner. + +After that, use `/digital-pet-dex` to browse discoveries and `/digital-pet-history` to browse current and retired generations. + +--- + +## Commands + +| Command | Description | +| --- | --- | +| `/digital-pet-spawn` | Spawns a new egg. | +| `/digital-pet-freeze` | Pauses partner progression. | +| `/digital-pet-unfreeze` | Resumes partner progression. | +| `/digital-pet-set ` | Overrides the partner with the specified catalog ID. | +| `/digital-pet-dex` | Opens the partner Dex. | +| `/digital-pet-history` | Opens the partner History. | + +> [!NOTE] +> Using `/digital-pet-set` does not count for dex completion. + +## Update + +To update the plugin, run: + +```sh +npx @jcendal/opencode-digital-pet update +``` + +Like `init`, `update` delegates to OpenCode's native global plugin installer with the installed exact +package version. Remove any old `@sbugallo/opencode-vpet` or unscoped `opencode-vpet` +registration from your OpenCode configuration to avoid loading both plugins. + +## Settings + +Settings are stored in `opencode-digital-pet.json` and control naming, notifications, and evolution +thresholds. + +The location of the settings file depends on your operating system: + +- Linux, WSL, and macOS: `~/.config/opencode-digital-pet.json` +- Windows: `%APPDATA%\opencode-digital-pet.json`, or `%USERPROFILE%\AppData\Roaming\opencode-digital-pet.json` + when `APPDATA` isn't set. + +An existing `opencode-vpet.json` remains in use until you create the new file. + +Available settings are: + +| Setting | Description | +| --- | --- | +| `notifications` | Enables or disables notifications. | +| `language` | Sets the naming convention. Valid values are `jp` (Japanese, e.g., Omegamon, Child, Adult...) and `en` (English, e.g., Omnimon, Rookie, Champion...). | +| `stageThresholds` | Defines the experience thresholds for each evolution stage. | + +Default values are: + +```json +{ + "notifications": true, + "language": "jp", + "stageThresholds": { + "egg": 5000000, + "babyI": 10000000, + "babyII": 20000000, + "child": 40000000, + "adult": 75000000, + "perfect": 125000000, + "ultimate": 200000000, + "superUltimate": 300000000 + } +} +``` + +## Storage + +To persist your partner, history, and control state, Digital Pet uses a SQLite database named `pet.db`. The location of the database depends on your operating system: + +- Linux, WSL: `~/.local/share/opencode-digital-pet/pet.db` +- macOS: `~/Library/Application Support/opencode-digital-pet/pet.db` +- Windows: `%APPDATA%\opencode-digital-pet\pet.db`, or `%USERPROFILE%\AppData\Roaming\opencode-digital-pet\pet.db` + when `APPDATA` isn't set. + +If an existing `opencode-vpet/pet.db` is present and the new database does not exist, +Digital Pet continues using it. Internal SQLite table names are retained for compatibility. + +--- + +## Partner list + +| ID | Japanese name | English name | +| --- | --- | --- | +| 0-001 | Digitama | Digiegg | +| 1-001 | Algomon | Argomon | +| 1-002 | Botamon | Botamon | +| 1-003 | Chicomon | Chibomon | +| 1-004 | Choromon | Choromon | +| 1-005 | Cocomon | Conomon | +| 1-006 | Cotsucomon | Cotsucomon | +| 1-007 | Dodomon | Dodomon | +| 1-008 | Fufumon | Fufumon | +| 1-009 | Kiimon | Keemon | +| 1-010 | Ketomon | Ketomon | +| 1-011 | Kuramon | Kuramon | +| 1-012 | Mokumon | Mokumon | +| 1-013 | Nyokimon | Nyokimon | +| 1-014 | Bubbmon | Pabumon | +| 1-015 | Petitmon | Petitmon | +| 1-016 | Pitchmon | Pichimon | +| 1-017 | Poyomon | Poyomon | +| 1-018 | Punimon | Punimon | +| 1-019 | Pururumon | Pururumon | +| 1-020 | Puttimon | Puttimon | +| 1-021 | Sakumon | Sakumon | +| 1-022 | Tsubumon | Tsubumon | +| 1-023 | Yukimibotamon | YukimiBotamon | +| 1-024 | Yuramon | Yuramon | +| 1-025 | Zerimon | Zerimon | +| 1-026 | Zurumon | Zurumon | +| 1-027 | Chibickmon | Chibickmon | +| 1-028 | Dokimon | Dokimon | +| 1-029 | Jyarimon | Jyarimon | +| 1-030 | Relemon | Relemon | +| 1-031 | Sunamon | Sandmon | +| 2-001 | Algomon | Argomon | +| 2-002 | Babydmon | Bebydomon | +| 2-003 | Budmon | Budmon | +| 2-004 | Pukamon | Bukamon | +| 2-005 | PetiMeramon | DemiMeramon | +| 2-006 | Chibimon | DemiVeemon | +| 2-007 | Dorimon | Dorimon | +| 2-008 | Gummymon | Gummymon | +| 2-009 | Hopmon | Hopmon | +| 2-010 | Kakkinmon | Kakkinmon | +| 2-011 | Capromon | Kapurimon | +| 2-012 | Chocomon | Kokomon | +| 2-013 | Koromon | Koromon | +| 2-014 | Kyokyomon | Kyokyomon | +| 2-015 | Mochimon | Motimon | +| 2-016 | Nyaromon | Nyaromon | +| 2-017 | Pagumon | Pagumon | +| 2-018 | Poromon | Poromon | +| 2-019 | Sakuttomon | Sakuttomon | +| 2-020 | Tanemon | Tanemon | +| 2-021 | Tokomon | Tokomon | +| 2-022 | Tokomon X | Tokomon X | +| 2-023 | Tsumemon | Tsumemon | +| 2-024 | Tunomon | Tsunomon | +| 2-025 | Upamon | Upamon | +| 2-026 | Wanyamon | Wanyamon | +| 2-027 | Yarmon | Yaamon | +| 2-028 | Pyocomon | Yokomon | +| 2-029 | Bibimon | Bibimon | +| 2-030 | Gigimon | Gigimon | +| 2-031 | Goromon | Tumblemon | +| 2-032 | Hiyarimon | Hiyarimon | +| 2-033 | Minomon | Minomon | +| 2-034 | Moonmon | Moonmon | +| 2-035 | Pickmon | Pickmon | +| 2-036 | Pokomon | Viximon | +| 2-037 | Sunmon | Sunmon | +| 3-001 | Agumon | Agumon | +| 3-002 | Agumon (Black) X | Agumon (Black) X | +| 3-003 | Agumon Hakase | Agumon Expert | +| 3-004 | Agumon X | Agumon X | +| 3-005 | Algomon | Argomon | +| 3-006 | Armadimon | Armadillomon | +| 3-007 | Betamon | Betamon | +| 3-008 | Piyomon | Biyomon | +| 3-009 | Blucomon | Bulucomon | +| 3-010 | BushiAgumon | BushiAgumon | +| 3-011 | Candmon | Candlemon | +| 3-012 | Coronamon | Coronamon | +| 3-013 | Ganimon | Crabmon | +| 3-014 | PicoDevimon | DemiDevimon | +| 3-015 | DORUmon | Dorumon | +| 3-016 | Dracomon | Dracomon | +| 3-017 | Dracomon X | Dracomon X | +| 3-018 | Duskmon | Duskmon | +| 3-019 | Elecmon | Elecmon | +| 3-020 | Falcomon | Falcomon | +| 3-021 | Floramon | Floramon | +| 3-022 | Gabumon | Gabumon | +| 3-023 | Gabumon X | Gabumon X | +| 3-024 | Gaomon | Gaomon | +| 3-025 | Tailmon | Gatomon | +| 3-026 | Gazimon | Gazimon | +| 3-027 | Ghostmon | Ghostmon | +| 3-028 | Gizamon | Gizamon | +| 3-029 | Gomamon | Gomamon | +| 3-030 | Gomamon X | Gomamon X | +| 3-031 | Gottsumon | Gotsumon | +| 3-032 | Guilmon | Guilmon | +| 3-033 | Hagurumon | Hagurumon | +| 3-034 | Hawkmon | Hawkmon | +| 3-035 | Herissmon | Herissmon | +| 3-036 | Hackmon | Huckmon | +| 3-037 | Impmon | Impmon | +| 3-038 | Impmon X | Impmon X | +| 3-039 | Jazamon | Jazamon | +| 3-040 | Junkmon | Junkmon | +| 3-041 | Keramon X | Keramon X | +| 3-042 | Kokuwamon | Kokuwamon | +| 3-043 | Kokuwamon X | Kokuwamon X | +| 3-044 | Kunemon | Kunemon | +| 3-045 | Labramon | Labramon | +| 3-046 | Lalamon | Lalamon | +| 3-047 | Lopmon | Lopmon | +| 3-048 | Lopmon X | Lopmon X | +| 3-049 | Ludomon | Ludomon | +| 3-050 | Lunamon | Lunamon | +| 3-051 | Monodramon | Monodramon | +| 3-052 | Morphomon | Morphomon | +| 3-053 | Mushmon | Mushroomon | +| 3-054 | Otamamon | Otamamon | +| 3-055 | Otamamon X | Otamamon X | +| 3-056 | Palmon | Palmon | +| 3-057 | Palmon X | Palmon X | +| 3-058 | Patamon | Patamon | +| 3-059 | Phascomon | Phascomon | +| 3-060 | Pomumon | Pomumon | +| 3-061 | Renamon X | Renamon X | +| 3-062 | Ryudamon | Ryudamon | +| 3-063 | Plotmon | Salamon | +| 3-064 | Plotmon X | Salamon X | +| 3-065 | Sangomon | Sangomon | +| 3-066 | Shoutmon | Shoutmon | +| 3-067 | Sistermon Blanc | Sistermon Blanc | +| 3-068 | Sunarizamon | Sunarizamon | +| 3-069 | Swimmon | Swimmon | +| 3-070 | Shakomon | Syakomon | +| 3-071 | Shakomon X | Syakomon X | +| 3-072 | Bakumon | Tapirmon | +| 3-073 | Tentomon | Tentomon | +| 3-074 | Terriermon | Terriermon | +| 3-075 | Terriermon X | Terriermon X | +| 3-076 | Tinkermon | Tinkermon | +| 3-077 | ToyAgumon | ToyAgumon | +| 3-078 | V-mon | Veemon | +| 3-079 | Vorvomon | Vorvomon | +| 3-080 | Wormmon | Wormmon | +| 3-081 | Zubamon | Zubamon | +| 3-082 | Agumon (Black) | Agumon (Black) | +| 3-083 | Dracumon | Dracmon | +| 3-084 | Pulsemon | Pulsemon | +| 3-085 | Renamon | Renamon | +| 4-001 | Agnimon | Agunimon | +| 4-002 | Airdramon | Airdramon | +| 4-003 | Allomon X | Allomon X | +| 4-004 | Angemon | Angemon | +| 4-005 | Ankylomon | Ankylomon | +| 4-006 | Hanumon | Apemon | +| 4-007 | Aquilamon | Aquilamon | +| 4-008 | Algomon | Argomon | +| 4-009 | Arresterdramon | Arresterdramon | +| 4-010 | Baboongamon | Baboongamon | +| 4-011 | Bakemon | Bakemon | +| 4-012 | Baluchimon | Baluchimon | +| 4-013 | BaoHuckmon | BaoHuckmon | +| 4-014 | Birdramon | Birdramon | +| 4-015 | Centalmon | Centarumon | +| 4-016 | Clockmon | Clockmon | +| 4-017 | Coelamon | Coelamon | +| 4-018 | Coredramon (Blue) | Coredramon (Blue) | +| 4-019 | Coredramon (Green) | Coredramon (Green) | +| 4-020 | Cyclomon | Cyclonemon | +| 4-021 | Damemon | Damemon | +| 4-022 | Darcmon | Darcmon | +| 4-023 | DarkTyranomon | DarkTyrannomon | +| 4-024 | DarkTyranomon X | DarkTyrannomon X | +| 4-025 | Deltamon | Deltamon | +| 4-026 | Revolmon | Deputymon | +| 4-027 | Devidramon | Devidramon | +| 4-028 | Devimon | Devimon | +| 4-029 | Diatrymon | Diatrymon | +| 4-030 | Dobermon | Dobermon | +| 4-031 | Dokugumon | Dokugumon | +| 4-032 | Rukamon | Dolphmon | +| 4-033 | DORUgamon | Dorugamon | +| 4-034 | Drimogemon | Drimogemon | +| 4-035 | Duskmon | Duskmon | +| 4-036 | Ebidramon | Ebidramon | +| 4-037 | Eosmon | Eosmon | +| 4-038 | XV-mon | ExVeemon | +| 4-039 | Filmon | Filmon | +| 4-040 | Firamon | Firamon | +| 4-041 | FlareLizamon | Flarerizamon | +| 4-042 | Flymon | Flymon | +| 4-043 | Yukidarumon | Frigimon | +| 4-044 | Gaogamon | Gaogamon | +| 4-045 | Galgomon | Gargomon | +| 4-046 | Garurumon | Garurumon | +| 4-047 | Tailmon | Gatomon | +| 4-048 | Tailmon X | Gatomon X | +| 4-049 | Gekomon | Gekomon | +| 4-050 | GeoGreymon | GeoGreymon | +| 4-051 | Gesomon | Gesomon | +| 4-052 | Ginryumon | Ginryumon | +| 4-053 | Golemon | Golemon | +| 4-054 | Greymon | Greymon | +| 4-055 | Growmon | Growlmon | +| 4-056 | Growmon X | Growlmon X | +| 4-057 | Guardromon | Guardromon | +| 4-058 | Hookmon | Hookmon | +| 4-059 | Hudiemon | Hudiemon | +| 4-060 | Ikkakumon | Ikkakumon | +| 4-061 | Jazardmon | Jazardmon | +| 4-062 | Kabuterimon | Kabuterimon | +| 4-063 | Kiwimon | Kiwimon | +| 4-064 | Cockatrimon | Kokatorimon | +| 4-065 | Komondomon | Komondomon | +| 4-066 | Kuwagamon | Kuwagamon | +| 4-067 | Kuwagamon X | Kuwagamon X | +| 4-068 | Lavorvomon | Lavorvomon | +| 4-069 | Lekismon | Lekismon | +| 4-070 | Leomon | Leomon | +| 4-071 | Leomon X | Leomon X | +| 4-072 | Wolfmon | Lobomon | +| 4-073 | Machmon | Machmon | +| 4-074 | MadLeomon | MadLeomon | +| 4-075 | Manbomon | Manbomon | +| 4-076 | Mantaraymon X | Mantaraymon X | +| 4-077 | Meicoomon | Meicoomon | +| 4-078 | Mechanorimon | Mekanorimon | +| 4-079 | Meramon | Meramon | +| 4-080 | Meramon X | Meramon X | +| 4-081 | Mimicmon | Mimicmon | +| 4-082 | Minotaurmon | Minotarumon | +| 4-083 | Mojyamon | Mojyamon | +| 4-084 | Monochromon | Monochromon | +| 4-085 | Nanimon | Nanimon | +| 4-086 | Nefertimon X | Nefertimon X | +| 4-087 | Igamon | Ninjamon | +| 4-088 | Numemon | Numemon | +| 4-089 | Numemon X | Numemon X | +| 4-090 | Octmon | Octomon | +| 4-091 | Orgemon | Ogremon | +| 4-092 | Orgemon X | Ogremon X | +| 4-093 | Omekamon | Omekamon | +| 4-094 | Paledramon | Paledramon | +| 4-095 | Parasaurmon | Parasaurmon | +| 4-096 | Peckmon | Peckmon | +| 4-097 | Pegasmon X | Pegasusmon X | +| 4-098 | Petermon | Petermon | +| 4-099 | Porcupamon | Porcupamon | +| 4-100 | Pteranomon X | Pteramon X | +| 4-101 | Raremon | Raremon | +| 4-102 | RedVegimon | RedVegiemon | +| 4-103 | Reppamon | Reppamon | +| 4-104 | Rhinomon X | Rhinomon X | +| 4-105 | Gokimon | Roachmon | +| 4-106 | Sangloupmon | Sangloupmon | +| 4-107 | Seadramon | Seadramon | +| 4-108 | Seadramon X | Seadramon X | +| 4-109 | Siesamon X | Seasarmon X | +| 4-110 | Shadramon | Shadramon | +| 4-111 | Shellmon | Shellmon | +| 4-112 | KaratukiNumemon | ShellNumemon | +| 4-113 | Shoutmon X3 | Shoutmon X3 | +| 4-114 | Sistermon Ciel | Sistermon Ciel | +| 4-115 | Starmon | Starmon | +| 4-116 | Stingmon | Stingmon | +| 4-117 | Strikedramon | Strikedramon | +| 4-118 | Submarimon | Submarimon | +| 4-119 | Scumon | Sukamon | +| 4-120 | Sunflowmon | Sunflowmon | +| 4-121 | Tankmon | Tankmon | +| 4-122 | Targetmon | Targetmon | +| 4-123 | Thunderballmon | Thundermon | +| 4-124 | TiaLudomon | TiaLudomon | +| 4-125 | Tobiumon | Tobiumon | +| 4-126 | Tobucatmon | TobuCatmon | +| 4-127 | Togemon | Togemon | +| 4-128 | Togemon X | Togemon X | +| 4-129 | Tortamon | Tortomon | +| 4-130 | Troopmon | Troopmon | +| 4-131 | Turuiemon | Turuiemon | +| 4-132 | Tuskmon | Tuskmon | +| 4-133 | Tylomon | Tylomon | +| 4-134 | Tylomon X | Tylomon X | +| 4-135 | Tyranomon | Tyrannomon | +| 4-136 | Tyranomon X | Tyrannomon X | +| 4-137 | Unimon | Unimon | +| 4-138 | V-dramon | Veedramon | +| 4-139 | Vegimon | Vegiemon | +| 4-140 | Velgrmon | Velgrmon | +| 4-141 | Whamon | Whamon | +| 4-142 | Witchmon | Witchmon | +| 4-143 | Wizarmon | Wizardmon | +| 4-144 | Wizarmon X | Wizardmon X | +| 4-145 | Woodmon | Woodmon | +| 4-146 | Zubaeagermon | ZubaEagermon | +| 4-147 | Bulkmon | Bulkmon | +| 4-148 | Fugamon | Fugamon | +| 4-149 | Hyougamon | Hyogamon | +| 4-150 | Pidmon | Piddomon | +| 4-151 | Rhinomon | Rhinomon | +| 4-152 | Saberdramon | Saberdramon | +| 5-001 | AeroV-dramon | AeroVeedramon | +| 5-002 | Aldamon | Aldamon | +| 5-003 | Andromon | Andromon | +| 5-004 | Angewomon | Angewomon | +| 5-005 | Angewomon X | Angewomon X | +| 5-006 | Andiramon | Antylamon | +| 5-007 | Algomon | Argomon | +| 5-008 | Archnemon | Arukenimon | +| 5-009 | Astamon | Astamon | +| 5-010 | Asuramon | Asuramon | +| 5-011 | Baalmon | Baalmon | +| 5-012 | Beowolfmon | Beowolfmon | +| 5-013 | BigMamemon | BigMamemon | +| 5-014 | Blossomon | Blossomon | +| 5-015 | Valvemon | Bulbmon | +| 5-016 | Vritramon | BurningGreymon | +| 5-017 | CannonBeemon | CannonBeemon | +| 5-018 | Cerberumon | Cerberusmon | +| 5-019 | Cerberumon X | Cerberusmon X | +| 5-020 | Jyureimon | Cherrymon | +| 5-021 | Cho-Hakkaimon | Cho-Hakkaimon | +| 5-022 | Crescemon | Crescemon | +| 5-023 | Yatagaramon | Crowmon | +| 5-024 | CrysPaledramon | CrysPaledramon | +| 5-025 | Cyberdramon | Cyberdramon | +| 5-026 | Cyberdramon X | Cyberdramon X | +| 5-027 | DarkKnightmon | DarkKnightmon | +| 5-028 | Nanomon | Datamon | +| 5-029 | Delumon | Deramon | +| 5-030 | Digitamamon | Digitamamon | +| 5-031 | Dinobeemon | Dinobeemon | +| 5-032 | Hangyomon | Divermon | +| 5-033 | NeoDevimon | DoneDevimon | +| 5-034 | DORUguremon | DoruGreymon | +| 5-035 | Dagomon | Dragomon | +| 5-036 | Duramon | Duramon | +| 5-037 | Entmon | Entmon | +| 5-038 | Eosmon | Eosmon | +| 5-039 | Etemon | Etemon | +| 5-040 | Ex-Tyranomon | ExTyrannomon | +| 5-041 | Flaremon | Flaremon | +| 5-042 | Gerbemon | Garbagemon | +| 5-043 | Garudamon | Garudamon | +| 5-044 | Garudamon X | Garudamon X | +| 5-045 | Giromon | Giromon | +| 5-046 | Gogmamon | Gogmamon | +| 5-047 | Grademon | Grademon | +| 5-048 | Groundramon | Groundramon | +| 5-049 | Gusokumon | Gusokumon | +| 5-050 | Hippogriffomon | HippoGryphonmon | +| 5-051 | Hisyaryumon | Hisyaryumon | +| 5-052 | Jyagamon | Jagamon | +| 5-053 | KaiserLeomon | JagerLoweemon | +| 5-054 | Jazarichmon | Jazarichmon | +| 5-055 | Jewelbeemon | JewelBeemon | +| 5-056 | Garummon | KendoGarurumon | +| 5-057 | Chimairamon | Kimeramon | +| 5-058 | Knightmon | Knightmon | +| 5-059 | LadyDevimon | LadyDevimon | +| 5-060 | LadyDevimon X | LadyDevimon X | +| 5-061 | Lavogaritamon | Lavogaritamon | +| 5-062 | Lilamon | Lilamon | +| 5-063 | Lilimon | Lillymon | +| 5-064 | Lilimon X | Lillymon X | +| 5-065 | LoaderLeomon | LoaderLeomon | +| 5-066 | Locomon | Locomon | +| 5-067 | Lucemon: Falldown Mode | Lucemon: Chaos Mode | +| 5-068 | MachGaogamon | MachGaogamon | +| 5-069 | HolyAngemon | MagnaAngemon | +| 5-070 | Mamemon | Mamemon | +| 5-071 | Mamemon X | Mamemon X | +| 5-072 | Mametyramon | Mametyramon | +| 5-073 | Mammon | Mammothmon | +| 5-074 | Mammon X | Mammothmon X | +| 5-075 | Manticoremon | Manticoremon | +| 5-076 | MarinChimairamon | MarineChimairamon | +| 5-077 | MarinDevimon | MarineDevimon | +| 5-078 | Meicrackmon | Maycrackmon | +| 5-079 | Meicrackmon: Vicious Mode | Maycrackmon: Vicious Mode | +| 5-080 | Megadramon | Megadramon | +| 5-081 | AtlurKabuterimon | MegaKabuterimon (Red) | +| 5-082 | MegaSeadramon | MegaSeadramon | +| 5-083 | MegaSeadramon X | MegaSeadramon X | +| 5-084 | Mephismon X | Mephistomon X | +| 5-085 | Mermaimon | Mermaimon | +| 5-086 | MetalGreymon | MetalGreymon | +| 5-087 | MetalGreymon (Vaccine) | MetalGreymon (Vaccine) | +| 5-088 | MetalGreymon (Virus) | MetalGreymon (Virus) | +| 5-089 | MetalGreymon (Virus) X | MetalGreymon (Virus) X | +| 5-090 | MetalGreymon X | MetalGreymon X | +| 5-091 | MetalMamemon | MetalMamemon | +| 5-092 | MetalFantomon | MetalPhantomon | +| 5-093 | MetalTyranomon | MetalTyrannomon | +| 5-094 | MetalTyranomon X | MetalTyrannomon X | +| 5-095 | Monzaemon | Monzaemon | +| 5-096 | Monzaemon X | Monzaemon X | +| 5-097 | Mummymon | Mummymon | +| 5-098 | Vamdemon | Myotismon | +| 5-099 | Vamdemon X | Myotismon X | +| 5-100 | NeoDevimon | NeoDevimon | +| 5-101 | Okuwamon | Okuwamon | +| 5-102 | Okuwamon X | Okuwamon X | +| 5-103 | OmegaShoutmon X | OmniShoutmon X | +| 5-104 | Orochimon | Orochimon | +| 5-105 | Paildramon | Paildramon | +| 5-106 | Parrotmon | Parrotmon | +| 5-107 | Fantomon | Phantomon | +| 5-108 | Piranimon | Piranimon | +| 5-109 | Piccolomon | Piximon | +| 5-110 | Pumpmon | Pumpkinmon | +| 5-111 | RaijiLudomon | RaijiLudomon | +| 5-112 | Rapidmon | Rapidmon | +| 5-113 | Rebellimon | Rebellimon | +| 5-114 | Raihimon | Rhihimon | +| 5-115 | RizeGreymon | RizeGreymon | +| 5-116 | RizeGreymon X | RizeGreymon X | +| 5-117 | SaviorHackmon | SaviorHuckmon | +| 5-118 | Anomalocarimon | Scorpiomon | +| 5-119 | Anomalocarimon X | Scorpiomon X | +| 5-120 | Shakkoumon | Shakkoumon | +| 5-121 | TonosamaGekomon | ShogunGekomon | +| 5-122 | Shoutmon X5 | Shoutmon X5 | +| 5-123 | Silphymon | Silphymon | +| 5-124 | Sirenmon | Sirenmon | +| 5-125 | SkullBaluchimon | SkullBaluchimon | +| 5-126 | SkullGreymon | SkullGreymon | +| 5-127 | DeathMeramon | SkullMeramon | +| 5-128 | Stiffilmon | Stefilmon | +| 5-129 | Tekkamon | Tekkamon | +| 5-130 | Toropiamon | Toropiamon | +| 5-131 | Triceramon | Triceramon | +| 5-132 | Triceramon X | Triceramon X | +| 5-133 | Vademon | Vademon | +| 5-134 | Velgrmon | Velgrmon | +| 5-135 | MegaloGrowmon | WarGrowlmon | +| 5-136 | MegaloGrowmon X | WarGrowlmon X | +| 5-137 | WaruMonzaemon | WaruMonzaemon | +| 5-138 | WaruSeadramon | WaruSeadramon | +| 5-139 | WereGarurumon | WereGarurumon | +| 5-140 | WereGarurumon X | WereGarurumon X | +| 5-141 | Whamon | Whamon | +| 5-142 | Wingdramon | Wingdramon | +| 5-143 | Zudomon | Zudomon | +| 5-144 | Boutmon | Boutmon | +| 5-145 | DarkSuperStarmon | DarkSuperStarmon | +| 5-146 | DORUguremon | Doruguremon | +| 5-147 | Pandamon | Pandamon | +| 5-148 | Scorpiomon | SkullScorpiomon | +| 5-149 | SuperStarmon | SuperStarmon | +| 6-001 | Aegisdramon | Aegisdramon | +| 6-002 | Alphamon | Alphamon | +| 6-003 | AncientBeatmon | AncientBeetlemon | +| 6-004 | AncientGarurumon | AncientGarurumon | +| 6-005 | AncientGreymon | AncientGreymon | +| 6-006 | AncientIrismon | AncientKazemon | +| 6-007 | AncientMegatheriumon | AncientMegatheriummon | +| 6-008 | AncientMermaimon | AncientMermaimon | +| 6-009 | AncientSphinxmon | AncientSphinxmon | +| 6-010 | AncientTroiamon | AncientTroymon | +| 6-011 | AncientVolcamon | AncientVolcanomon | +| 6-012 | AncientWisemon | AncientWisemon | +| 6-013 | Anubimon | Anubismon | +| 6-014 | Apoclymon | Apocalymon | +| 6-015 | Apollomon | Apollomon | +| 6-016 | Algomon | Argomon | +| 6-017 | Armagemon | Armageddemon | +| 6-018 | Qinglongmon | Azulongmon | +| 6-019 | Bagramon | Bagramon | +| 6-020 | Baihumon | Baihumon | +| 6-021 | BanchoLeomon | BanchoLeomon | +| 6-022 | BanchoMamemon | BanchoMamemon | +| 6-023 | Barbamon | Barbamon | +| 6-024 | BeelStarmon X | BeelStarmon X | +| 6-025 | Beelzebumon | Beelzemon | +| 6-026 | Belphemon: Rage Mode | Belphemon: Rage Mode | +| 6-027 | BlackWarGreymon X | BlackWarGreymon X | +| 6-028 | BlitzGreymon | BlitzGreymon | +| 6-029 | Boltmon | Boltmon | +| 6-030 | Breakdramon | Breakdramon | +| 6-031 | BryweLudramon | BryweLudramon | +| 6-032 | Chaosdramon X | Chaosdramon X | +| 6-033 | Cherubimon (Virtue) | Cherubimon (Virtue) | +| 6-034 | Cherubimon (Virtue) X | Cherubimon (Virtue) X | +| 6-035 | Cherubimon (Vice) X | Cherubimon (Vice) X | +| 6-036 | Craniummon | Craniamon | +| 6-037 | Demon | Creepymon | +| 6-038 | CresGarurumon | CresGarurumon | +| 6-039 | Darkdramon | Darkdramon | +| 6-040 | DarkKnightmon X | DarkKnightmon X | +| 6-041 | Dianamon | Dianamon | +| 6-042 | Dinorexmon | Dinorexmon | +| 6-043 | Dinotigermon | Dinotigermon | +| 6-044 | Durandamon | Durandamon | +| 6-045 | Dynasmon | Dynasmon | +| 6-046 | Ebemon X | Ebemon X | +| 6-047 | Xuanwumon | Ebonwumon | +| 6-048 | KaiserGreymon | EmperorGreymon | +| 6-049 | Eosmon | Eosmon | +| 6-050 | Huanglongmon | Fanglongmon | +| 6-051 | Gaioumon | Gaiomon | +| 6-052 | Dukemon | Gallantmon | +| 6-053 | Dukemon X | Gallantmon X | +| 6-054 | Gankoomon | Gankoomon | +| 6-055 | Deathmon | Ghoulmon | +| 6-056 | GigaSeadramon | GigaSeadramon | +| 6-057 | Goddramon | Goldramon | +| 6-058 | Goddramon X | Goldramon X | +| 6-059 | GrandisKuwagamon | GrandisKuwagamon | +| 6-060 | GrandDracumon | GranDracmon | +| 6-061 | GranKuwagamon | GranKuwagamon | +| 6-062 | GrandLocomon | GroundLocomon | +| 6-063 | Griffomon | Gryphonmon | +| 6-064 | Gundramon | Gundramon | +| 6-065 | HeavyLeomon | HeavyLeomon | +| 6-066 | HerakleKabuterimon | HerculesKabuterimon | +| 6-067 | Hexeblaumon | Hexeblaumon | +| 6-068 | HiAndromon | HiAndromon | +| 6-069 | Jesmon | Jesmon | +| 6-070 | Justimon X | Justimon X | +| 6-071 | Justimon: Blitz Arm | Justimon: Blitz Arm | +| 6-072 | Sleipmon | Kentaurosmon | +| 6-073 | KingEtemon | KingEtemon | +| 6-074 | Duftmon | Leopardmon | +| 6-075 | Leviamon | Leviamon | +| 6-076 | Lilithmon | Lilithmon | +| 6-077 | LordKnightmon | LordKnightmon | +| 6-078 | Lotusmon | Lotosmon | +| 6-079 | Lucemon: Satan Mode | Lucemon: Satan Mode | +| 6-080 | Mugendramon | Machinedramon | +| 6-081 | Holydramon | Magnadramon | +| 6-082 | Holydramon X | Magnadramon X | +| 6-083 | MagnaGarurumon | MagnaGarurumon | +| 6-084 | Magnamon | Magnamon | +| 6-085 | BelialVamdemon | MaloMyotismon | +| 6-086 | MarinAngemon | MarineAngemon | +| 6-087 | SaintGalgomon | MegaGargomon | +| 6-088 | Megidramon | Megidramon | +| 6-089 | Megidramon X | Megidramon X | +| 6-090 | MetalEtemon | MetalEtemon | +| 6-091 | MetalGarurumon | MetalGarurumon | +| 6-092 | MetalGarurumon X | MetalGarurumon X | +| 6-093 | Metallicdramon | Metallicdramon | +| 6-094 | MetalPiranimon | MetalPiranimon | +| 6-095 | MetalSeadramon | MetalSeadramon | +| 6-096 | MirageGaogamon | MirageGaogamon | +| 6-097 | Murmukusmon | Murmukusmon | +| 6-098 | Nidhoggmon | Nidhoggmon | +| 6-099 | NoblePumpmon | NoblePumpkinmon | +| 6-100 | Ofanimon | Ophanimon | +| 6-101 | Ofanimon X | Ophanimon X | +| 6-102 | Ofanimon: Falldown Mode X | Ophanimon: Falldown Mode X | +| 6-103 | Ornismon | Ornismon | +| 6-104 | Ouryumon | Ouryumon | +| 6-105 | Hououmon | Phoenixmon | +| 6-106 | Hououmon X | Phoenixmon X | +| 6-107 | Piemon | Piedmon | +| 6-108 | PlatinumNumemon | PlatinumNumemon | +| 6-109 | Plesiomon | Plesiomon | +| 6-110 | Plesiomon X | Plesiomon X | +| 6-111 | PrinceMamemon X | PrinceMamemon X | +| 6-112 | Pukumon | Pukumon | +| 6-113 | Pinochimon | Puppetmon | +| 6-114 | Rafflesimon | Rafflesimon | +| 6-115 | Raguelmon | Raguelmon | +| 6-116 | Rapidmon X | Rapidmon X | +| 6-117 | Rasenmon | Rasenmon | +| 6-118 | Rasenmon: Fury Mode | Rasenmon: Fury Mode | +| 6-119 | Rasielmon | Rasielmon | +| 6-120 | Ravmon | Ravemon | +| 6-121 | Regalecusmon | Regalecusmon | +| 6-122 | Raihimon | Rhihimon | +| 6-123 | Rosemon | Rosemon | +| 6-124 | Rosemon X | Rosemon X | +| 6-125 | RustTyranomon | RustTyrannomon | +| 6-126 | SaberLeomon | SaberLeomon | +| 6-127 | Sakuyamon X | Sakuyamon X | +| 6-128 | Seraphimon | Seraphimon | +| 6-129 | ShineGreymon | ShineGreymon | +| 6-130 | SkullMammon | SkullMammothmon | +| 6-131 | SkullMammon X | SkullMammothmon X | +| 6-132 | SlashAngemon | SlashAngemon | +| 6-133 | Slayerdramon | Slayerdramon | +| 6-134 | Susanoomon | Susanomon | +| 6-135 | TigerVespamon | TigerVespamon | +| 6-136 | Titamon | Titamon | +| 6-137 | UlforceV-dramon | UlforceVeedramon | +| 6-138 | UltimateBrachiomon | UltimateBrachiomon | +| 6-139 | Valkyrimon | Valkyrimon | +| 6-140 | Valdurmon | Varodurumon | +| 6-141 | VenomVamdemon | VenomMyotismon | +| 6-142 | Volcanicdramon | Volcanicdramon | +| 6-143 | WarGreymon | WarGreymon | +| 6-144 | WarGreymon X | WarGreymon X | +| 6-145 | Zhuqiaomon | Zhuqiaomon | +| 6-146 | Vikemon | Vikemon | +| 6-147 | ShinMonzaemon | ShinMonzaemon | +| 6-148 | Kazuchimon | Kazuchimon | +| 6-149 | BanchoLilimon | BanchoLillymon | +| 6-150 | DORUgoramon | Dorugoramon | +| 7-001 | Alphamon: Ouryuken | Alphamon: Ouryuken | +| 7-002 | Armagemon | Armageddemon | +| 7-003 | Barbamon X | Barbamon X | +| 7-004 | Beelzebumon X | Beelzemon X | +| 7-005 | Beelzebumon: Blast Mode | Beelzemon: Blast Mode | +| 7-006 | Belphemon X | Belphemon X | +| 7-007 | Voltobautamon | Boltboutamon | +| 7-008 | Chaosmon | Chaosmon | +| 7-009 | Craniummon X | Craniamon X | +| 7-010 | Demon X | Creepymon X | +| 7-011 | DarknessBagramon | DarknessBagramon | +| 7-012 | Diablomon X | Diaboromon X | +| 7-013 | Dynasmon X | Dynasmon X | +| 7-014 | Examon | Examon | +| 7-015 | Examon X | Examon X | +| 7-016 | Dukemon X | Gallantmon X | +| 7-017 | Gankoomon X | Gankoomon X | +| 7-018 | GraceNovamon | GraceNovamon | +| 7-019 | Imperialdramon: Fighter Mode | Imperialdramon: Fighter Mode | +| 7-020 | Imperialdramon: Paladin Mode | Imperialdramon: Paladin Mode | +| 7-021 | Jesmon X | Jesmon X | +| 7-022 | Sleipmon X | Kentaurosmon X | +| 7-023 | Duftmon X | Leopardmon X | +| 7-024 | Leviamon X | Leviamon X | +| 7-025 | Lilithmon X | Lilithmon X | +| 7-026 | LordKnightmon X | LordKnightmon X | +| 7-027 | Lucemon X | Lucemon X | +| 7-028 | Magnamon X | Magnamon X | +| 7-029 | Mastemon | Mastemon | +| 7-030 | Minervamon X | Minervamon X | +| 7-031 | Ogudomon | Ogudomon | +| 7-032 | Omegamon | Omnimon | +| 7-033 | Omegamon Alter-S | Omnimon Alter-S | +| 7-034 | Omegamon X | Omnimon X | +| 7-035 | Ordinemon | Ordinemon | +| 7-036 | Rafflesimon | Rafflesimon | +| 7-037 | RagnaLordmon | RagnaLoardmon | +| 7-038 | Rasenmon | Rasenmon | +| 7-039 | RustTyranomon | RustTyrannomon | +| 7-040 | UlforceV-dramon X | UlforceVeedramon X | +| 7-041 | Agumon (Bond of Bravery) | Agumon (Bond of Bravery) | +| 7-042 | Gabumon (Bond of Friendship) | Gabumon (Bond of Friendship) | +| 7-043 | Jesmon GX | Jesmon GX | +| 7-044 | Ogudomon X | Ogudomon X | +| 7-045 | Chaosdramon | Chaosdramon | + +Check out the [Digimon Data List](https://github.com/jcendal/digital-pet/blob/main/packages/digital-pet-core/src/data/digimon-data.ts) for more information about evolution +lines. + +## Maintainer and contributors + +- [Jorge Cendal](https://github.com/jcendal), maintainer +- [Sergio Bugallo](https://github.com/sbugallo), original author and contributor + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. + +## Attributions + +The bundled sprites were derived from the following sources and were later edited and +processed for this project: + +- [sundeth/omnipet](https://github.com/sundeth/omnipet/tree/main) +- [Tortoiseshel's Full Color Digimon Dot Sprites post](https://withthewill.net/threads/full-color-digimon-dot-sprites.25843/) on the [With the Will forum](https://withthewill.net/members/tortoiseshel.4767/) diff --git a/packages/opencode-digital-pet/bunfig.toml b/packages/opencode-digital-pet/bunfig.toml new file mode 100644 index 0000000..215183d --- /dev/null +++ b/packages/opencode-digital-pet/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["@opentui/solid/preload"] diff --git a/packages/opencode-digital-pet/esbuild.mjs b/packages/opencode-digital-pet/esbuild.mjs new file mode 100644 index 0000000..abf6913 --- /dev/null +++ b/packages/opencode-digital-pet/esbuild.mjs @@ -0,0 +1,107 @@ +import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" +import { dirname, join, relative, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { spawnSync } from "node:child_process" + +import { build } from "esbuild" + +const packageRoot = dirname(fileURLToPath(import.meta.url)) +const distRoot = resolve(packageRoot, "dist") +const require = createRequire(import.meta.url) +const tscPath = require.resolve("typescript/bin/tsc") + +const stageSources = async (packageName) => { + const source = resolve(packageRoot, `../${packageName}/src`) + const target = resolve(packageRoot, `build/${packageName}-src`) + await rm(target, { recursive: true, force: true }) + await cp(source, target, { recursive: true }) +} + +const runTsc = (config) => { + const result = spawnSync(process.execPath, [tscPath, "-p", config], { + cwd: packageRoot, + stdio: "inherit", + }) + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`Type declarations failed: ${config}`) +} + +const declarationFiles = async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await declarationFiles(path))) + else if (entry.name.endsWith(".d.ts")) files.push(path) + } + return files +} + +const toJs = (specifier) => specifier.replace(/\.tsx?$/, ".js") + +const rewriteDeclarations = async () => { + for (const file of await declarationFiles(distRoot)) { + const source = await readFile(file, "utf8") + let rewritten = source.replace( + /(["'])(@jcendal\/digital-pet-(core|animation)\/([^"']+))(["'])/g, + (_match, opening, _specifier, packageName, subpath, closing) => { + const target = join(distRoot, `digital-pet-${packageName}`, toJs(subpath)) + const path = relative(dirname(file), target).replaceAll("\\", "/") + return `${opening}${path.startsWith(".") ? path : `./${path}`}${closing}` + }, + ) + rewritten = rewritten.replace( + /(["'])(\.\.?\/[^"']+\.tsx?)(["'])/g, + (_match, opening, path, closing) => `${opening}${toJs(path)}${closing}`, + ) + if (rewritten !== source) await writeFile(file, rewritten) + } +} + +await stageSources("digital-pet-core") +await stageSources("digital-pet-animation") +await rm(distRoot, { recursive: true, force: true }) +await mkdir(distRoot, { recursive: true }) + +await build({ + entryPoints: [ + resolve(packageRoot, "src/index.ts"), + resolve(packageRoot, "src/tui.tsx"), + resolve(packageRoot, "src/dev/attach-dev-tools.ts"), + ], + outbase: resolve(packageRoot, "src"), + outdir: distRoot, + bundle: true, + platform: "node", + target: "node24", + format: "esm", + splitting: false, + sourcemap: false, + external: [ + "bun:*", + "@opencode-ai/plugin", + "@opencode-ai/sdk", + "@opentui/core", + "@opentui/keymap", + "@opentui/solid", + "solid-js", + ], + logLevel: "info", +}) + +await build({ + entryPoints: [resolve(packageRoot, "src/cli.ts")], + outfile: resolve(distRoot, "cli.js"), + bundle: true, + platform: "node", + target: "node24", + format: "esm", + sourcemap: false, + logLevel: "info", +}) + +runTsc("tsconfig.build.json") +runTsc("tsconfig.digital-pet-core-declarations.json") +runTsc("tsconfig.digital-pet-animation-declarations.json") +await rewriteDeclarations() diff --git a/packages/opencode-digital-pet/package.json b/packages/opencode-digital-pet/package.json new file mode 100644 index 0000000..8826a2f --- /dev/null +++ b/packages/opencode-digital-pet/package.json @@ -0,0 +1,108 @@ +{ + "name": "@jcendal/opencode-digital-pet", + "version": "0.3.1-dev.0", + "description": "An OpenCode virtual-pet plugin with /digital-pet-spawn, /digital-pet-freeze, /digital-pet-unfreeze, /digital-pet-set, /digital-pet-dex, and /digital-pet-history commands.", + "author": "Jorge Cendal", + "contributors": [ + "Sergio Bugallo" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/jcendal/digital-pet.git", + "directory": "packages/opencode-digital-pet" + }, + "homepage": "https://github.com/jcendal/digital-pet#readme", + "bugs": { + "url": "https://github.com/jcendal/digital-pet/issues" + }, + "keywords": [ + "opencode", + "plugin", + "virtual-pet", + "digital-pet", + "tui" + ], + "publishConfig": { + "access": "public" + }, + "oc-plugin": [ + "server", + "tui" + ], + "packageManager": "npm@11.6.1", + "engines": { + "bun": "1.3.5", + "node": "24.11.0", + "opencode": ">=1.18.10" + }, + "main": "./dist/index.js", + "bin": { + "opencode-digital-pet": "./dist/cli.js" + }, + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "solid-js": "1.9.12" + }, + "devDependencies": { + "@biomejs/biome": "2.5.10", + "@opencode-ai/plugin": "^1.18.10", + "@opencode-ai/sdk": "^1.18.10", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5", + "@types/bun": "1.3.14", + "@types/node": "24.10.1", + "@types/semver": "7.7.1", + "semver": "7.7.4", + "typescript": "5.9.3" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.18.10 <2", + "@opentui/core": "^0.4.5", + "@opentui/keymap": "^0.4.5", + "@opentui/solid": "^0.4.5" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js", + "default": "./dist/tui.js" + } + }, + "files": [ + "dist", + "!dist/**/*.map", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "scripts": { + "test": "bun test", + "test:persistence": "bun test ./tests/persistence/sqlite-migrations.persistence.ts ./tests/persistence/sqlite-digital-pet-repository.persistence.ts ./tests/persistence/sqlite-digital-pet-control.persistence.ts ./tests/persistence/sqlite-usage-event-toast.persistence.test.ts", + "format": "biome format --write src tests scripts package.json", + "format:check": "biome format src tests scripts package.json", + "lint": "biome lint src tests scripts package.json", + "check": "biome ci src tests scripts package.json && npm run typecheck && npm run typecheck:scripts && npm run typecheck:tests", + "typecheck": "tsc --noEmit", + "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit", + "typecheck:tests": "tsc -p tsconfig.test.json --noEmit", + "build": "node esbuild.mjs", + "prepack": "node --experimental-strip-types scripts/prepack.ts", + "postpack": "node --experimental-strip-types scripts/postpack.ts", + "release:check": "npm run check && npm run test && npm run test:persistence && npm pack --dry-run --json" + }, + "type": "module", + "types": "./dist/index.d.ts" +} diff --git a/packages/opencode-digital-pet/scripts/postpack.ts b/packages/opencode-digital-pet/scripts/postpack.ts new file mode 100644 index 0000000..d401f30 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/postpack.ts @@ -0,0 +1,14 @@ +import { readFile, rm, stat } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { restorePackageJson } from "./strip-internal-deps.ts" + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const backupPath = resolve(packageRoot, ".package.json.publish.bak") + +if ((await stat(backupPath).catch(() => undefined)) !== undefined) { + const originalSource = await readFile(backupPath, "utf8") + await restorePackageJson(packageRoot, originalSource) + await rm(backupPath, { force: true }) +} diff --git a/packages/opencode-digital-pet/scripts/prepack.ts b/packages/opencode-digital-pet/scripts/prepack.ts new file mode 100644 index 0000000..cd2cfb2 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/prepack.ts @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process" +import { readFile, rm, stat, writeFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { restorePackageJson, stripInternalDependencies } from "./strip-internal-deps.ts" + +type JsonObject = { readonly [key: string]: unknown } + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +const collectPaths = (value: unknown, paths: Set): void => { + if (typeof value === "string") { + paths.add(value) + return + } + if (!isJsonObject(value)) return + for (const nestedValue of Object.values(value)) collectPaths(nestedValue, paths) +} + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const backupPath = resolve(packageRoot, ".package.json.publish.bak") +const exists = async (path: string): Promise => (await stat(path).catch(() => undefined)) !== undefined + +if (await exists(backupPath)) { + await restorePackageJson(packageRoot, await readFile(backupPath, "utf8")) + await rm(backupPath, { force: true }) +} + +const originalSource = await stripInternalDependencies(packageRoot) +await writeFile(backupPath, originalSource) + +try { + const build = spawnSync(process.execPath, ["esbuild.mjs"], { cwd: packageRoot, stdio: "inherit" }) + if (build.error) throw build.error + if (build.status !== 0) throw new Error("OpenCode build failed during prepack.") + + const packageJson: unknown = JSON.parse(await readFile(resolve(packageRoot, "package.json"), "utf8")) + if (!isJsonObject(packageJson)) throw new Error("package.json must contain an object.") + + const publishedPaths = new Set() + collectPaths(packageJson.main, publishedPaths) + collectPaths(packageJson.types, publishedPaths) + collectPaths(packageJson.exports, publishedPaths) + collectPaths(packageJson.bin, publishedPaths) + + for (const publishedPath of publishedPaths) { + const artifactPath = resolve(packageRoot, publishedPath) + const artifact = await stat(artifactPath).catch(() => undefined) + if (artifact === undefined || !artifact.isFile() || artifact.size === 0) { + throw new Error(`Published artifact is missing or empty: ${publishedPath}`) + } + } + + const binPaths = new Set() + collectPaths(packageJson.bin, binPaths) + const cliPath = binPaths.values().next().value + if (typeof cliPath !== "string") throw new Error("Package metadata does not define a bin path.") + + const cliResult = spawnSync(process.execPath, [resolve(packageRoot, cliPath), "--help"], { + cwd: packageRoot, + encoding: "utf8", + }) + if (cliResult.error) throw cliResult.error + if (cliResult.status !== 0) { + throw new Error(`Node CLI help failed: ${cliPath}\n${cliResult.stderr}`) + } +} catch (error) { + await restorePackageJson(packageRoot, originalSource) + await rm(backupPath, { force: true }) + throw error +} diff --git a/packages/opencode-digital-pet/scripts/strip-internal-deps.ts b/packages/opencode-digital-pet/scripts/strip-internal-deps.ts new file mode 100644 index 0000000..481b461 --- /dev/null +++ b/packages/opencode-digital-pet/scripts/strip-internal-deps.ts @@ -0,0 +1,37 @@ +import { readFile, writeFile } from "node:fs/promises" +import { resolve } from "node:path" + +type JsonObject = { readonly [key: string]: unknown } + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value) + +const INTERNAL_PACKAGES = new Set(["@jcendal/digital-pet-core", "@jcendal/digital-pet-animation"]) + +const withoutInternalDependencies = (dependencies: unknown): JsonObject | undefined => { + if (!isJsonObject(dependencies)) return undefined + const filtered = Object.fromEntries(Object.entries(dependencies).filter(([name]) => !INTERNAL_PACKAGES.has(name))) + return Object.keys(filtered).length > 0 ? filtered : undefined +} + +export const stripInternalDependencies = async (packageRoot: string): Promise => { + const packageJsonPath = resolve(packageRoot, "package.json") + const originalSource = await readFile(packageJsonPath, "utf8") + const packageMetadata = JSON.parse(originalSource) as JsonObject + if (!isJsonObject(packageMetadata)) throw new Error("package.json must contain an object.") + + const dependencies = withoutInternalDependencies(packageMetadata["dependencies"]) + const publishMetadata = { ...packageMetadata } as Record + if (dependencies === undefined) { + delete publishMetadata["dependencies"] + } else { + publishMetadata["dependencies"] = dependencies + } + + await writeFile(packageJsonPath, `${JSON.stringify(publishMetadata, null, 2)}\n`) + return originalSource +} + +export const restorePackageJson = async (packageRoot: string, originalSource: string): Promise => { + await writeFile(resolve(packageRoot, "package.json"), originalSource) +} diff --git a/src/adapters/opencode/create-server-hooks.ts b/packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts similarity index 56% rename from src/adapters/opencode/create-server-hooks.ts rename to packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts index ea95956..94f49b8 100644 --- a/src/adapters/opencode/create-server-hooks.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/create-server-hooks.ts @@ -1,46 +1,53 @@ import type { Hooks } from "@opencode-ai/plugin" -import { runVpetFreezeCommand } from "../../commands/vpet-freeze.ts" -import { runVpetSpawnCommand } from "../../commands/vpet-spawn.ts" -import { runVpetSetCommand } from "../../commands/vpet-set.ts" -import { runVpetUnfreezeCommand } from "../../commands/vpet-unfreeze.ts" -import type { VpetCommandEvent } from "../../commands/vpet-command-result.ts" -import { loadDigimonCatalog } from "../../data/catalog.ts" -import type { VpetLanguage } from "../../config/types.ts" -import type { DigimonCatalog } from "../../data/catalog.ts" -import type { PartnerLifecycle } from "../../application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../../application/ports/usage-ledger.ts" -import type { VpetControl } from "../../application/ports/vpet-control.ts" -import type { UsageProcessingResult } from "../../application/models/usage.ts" -import { reconcileUsage } from "../../application/use-cases/reconcile-usage.ts" -import { recordUsage } from "../../application/use-cases/record-usage.ts" -import { STAGE_GAUGE_THRESHOLDS, type EvolutionSelector, type StageThresholds } from "../../domain/evolution.ts" +import { runDigitalPetFreezeCommand } from "../../commands/digital-pet-freeze.ts" +import { runDigitalPetSpawnCommand } from "../../commands/digital-pet-spawn.ts" +import { runDigitalPetSetCommand } from "../../commands/digital-pet-set.ts" +import { runDigitalPetUnfreezeCommand } from "../../commands/digital-pet-unfreeze.ts" +import type { DigitalPetCommandEvent } from "../../commands/digital-pet-command-result.ts" +import { loadDigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigitalPetLanguage } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import type { UsageProcessingResult } from "@jcendal/digital-pet-core/application/models/usage.ts" +import { reconcileUsage } from "@jcendal/digital-pet-core/application/use-cases/reconcile-usage.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { + STAGE_GAUGE_THRESHOLDS, + type EvolutionSelector, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" import type { SessionMessagesFetcher } from "./session-messages.ts" import { toCompletedUsageFromEvent, toCompletedUsageFromMessage } from "./usage-event-mapper.ts" -import { formatVpetToast, type VpetToastEvent, type VpetToastNotifier } from "./vpet-toast.ts" +import { formatDigitalPetToast, type DigitalPetToastEvent, type DigitalPetToastNotifier } from "./digital-pet-toast.ts" export type ServerResource = { close(): Promise } export type ServerHookDependencies = { - readonly repository: PartnerLifecycle & UsageLedger & VpetControl + readonly repository: PartnerLifecycle & UsageLedger & DigitalPetControl readonly resource: ServerResource readonly evolutionSelector?: EvolutionSelector readonly evolutionThresholds?: StageThresholds readonly fetchMessages?: SessionMessagesFetcher - readonly language?: VpetLanguage + readonly language?: DigitalPetLanguage readonly notificationsEnabled?: boolean readonly loadCatalog?: () => Promise - readonly notify?: VpetToastNotifier + readonly notify?: DigitalPetToastNotifier } -const noOpNotifier: VpetToastNotifier = async () => {} +const noOpNotifier: DigitalPetToastNotifier = async () => {} const EMPTY_CATALOG: DigimonCatalog = Object.freeze({ nodes: Object.freeze([]), byId: new Map() }) -const toToastEvent = (result: UsageProcessingResult): VpetToastEvent | undefined => { +const toToastEvent = (result: UsageProcessingResult): DigitalPetToastEvent | undefined => { switch (result.kind) { case "applied": + if (result.evolutionBattlePending !== undefined) { + return { kind: "evolution_battle", opponentNodeId: result.evolutionBattlePending.opponentNodeId } + } return result.evolution === undefined ? undefined : { kind: "evolution", ...result.evolution } case "duplicate": case "no_active_partner": @@ -52,7 +59,7 @@ const toToastEvent = (result: UsageProcessingResult): VpetToastEvent | undefined } } -const toCommandToastEvent = (event: VpetCommandEvent): VpetToastEvent => { +const toCommandToastEvent = (event: DigitalPetCommandEvent): DigitalPetToastEvent => { switch (event.kind) { case "spawned": return { kind: "spawn", nodeId: event.nodeId, generation: event.generation } @@ -65,23 +72,25 @@ const toCommandToastEvent = (event: VpetCommandEvent): VpetToastEvent => { } } -const requiresCatalog = (event: VpetToastEvent): boolean => { +const requiresCatalog = (event: DigitalPetToastEvent): boolean => { switch (event.kind) { case "spawn": case "set": case "evolution": + case "evolution_battle": return true case "freeze": case "unfreeze": + case "defeat": return false } } export const createCommandConfig = () => ({ - "vpet-spawn": { template: "Spawn a new virtual pet." }, - "vpet-freeze": { template: "Freeze virtual pet progression." }, - "vpet-unfreeze": { template: "Unfreeze virtual pet progression." }, - "vpet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS" }, + "digital-pet-spawn": { template: "Spawn a new virtual pet." }, + "digital-pet-freeze": { template: "Freeze virtual pet progression." }, + "digital-pet-unfreeze": { template: "Unfreeze virtual pet progression." }, + "digital-pet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS" }, }) export const createServerHooks = ({ @@ -97,12 +106,25 @@ export const createServerHooks = ({ }: ServerHookDependencies): Hooks => { let closePromise: Promise | undefined - const notifyEvent = async (event: VpetToastEvent, catalog?: DigimonCatalog): Promise => { + const notifyUsageResult = async (result: UsageProcessingResult, catalog: DigimonCatalog): Promise => { + if (result.kind !== "applied") return + + if (result.evolutionBattlePending !== undefined) { + const battleToast = toToastEvent(result) + if (battleToast !== undefined) await notifyEvent(battleToast, catalog) + return + } + + const toastEvent = toToastEvent(result) + if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + } + + const notifyEvent = async (event: DigitalPetToastEvent, catalog?: DigimonCatalog): Promise => { if (!notificationsEnabled) return await Promise.resolve() .then(async () => { const resolvedCatalog = catalog ?? (requiresCatalog(event) ? await loadCatalog() : EMPTY_CATALOG) - const payload = formatVpetToast(event, language, resolvedCatalog) + const payload = formatDigitalPetToast(event, language, resolvedCatalog) if (payload !== undefined) await notify(payload) }) .then( @@ -119,11 +141,11 @@ export const createServerHooks = ({ } }, async "command.execute.before"(input, output) { - const messageID = `vpet-${input.command}-${input.sessionID}-${crypto.randomUUID()}` + const messageID = `digital-pet-${input.command}-${input.sessionID}-${crypto.randomUUID()}` switch (input.command) { - case "vpet-spawn": + case "digital-pet-spawn": { - const result = await runVpetSpawnCommand(repository, { + const result = await runDigitalPetSpawnCommand(repository, { sessionID: input.sessionID, messageID, createdAt: new Date().toISOString(), @@ -132,25 +154,25 @@ export const createServerHooks = ({ if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-freeze": + case "digital-pet-freeze": { - const result = await runVpetFreezeCommand(repository, { sessionID: input.sessionID, messageID }) + const result = await runDigitalPetFreezeCommand(repository, { sessionID: input.sessionID, messageID }) output.parts.push(...result.parts) if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-unfreeze": + case "digital-pet-unfreeze": { - const result = await runVpetUnfreezeCommand(repository, { sessionID: input.sessionID, messageID }) + const result = await runDigitalPetUnfreezeCommand(repository, { sessionID: input.sessionID, messageID }) output.parts.push(...result.parts) if (result.event !== undefined) await notifyEvent(toCommandToastEvent(result.event)) } return - case "vpet-set": + case "digital-pet-set": { let catalog: DigimonCatalog | undefined const loadCommandCatalog = async (): Promise => (catalog ??= await loadCatalog()) - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( repository, { sessionID: input.sessionID, messageID, arguments: input.arguments }, loadCommandCatalog, @@ -174,11 +196,11 @@ export const createServerHooks = ({ usage, ledger: repository, digimonById: catalog.byId, + catalogNodes: catalog.nodes, selector: evolutionSelector, thresholds: evolutionThresholds, }) - const toastEvent = toToastEvent(result) - if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + await notifyUsageResult(result, catalog) return } case "session.idle": { @@ -193,12 +215,12 @@ export const createServerHooks = ({ usages, ledger: repository, digimonById: catalog.byId, + catalogNodes: catalog.nodes, selector: evolutionSelector, thresholds: evolutionThresholds, }) for (const result of results) { - const toastEvent = toToastEvent(result) - if (toastEvent !== undefined) await notifyEvent(toastEvent, catalog) + await notifyUsageResult(result, catalog) } return } diff --git a/src/adapters/opencode/vpet-toast.ts b/packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts similarity index 50% rename from src/adapters/opencode/vpet-toast.ts rename to packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts index beda2a6..10ad33f 100644 --- a/src/adapters/opencode/vpet-toast.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/digital-pet-toast.ts @@ -1,29 +1,31 @@ -import type { VpetLanguage } from "../../config/types.ts" -import type { DigimonCatalog, DigimonNode } from "../../data/catalog.ts" +import type { DigitalPetLanguage } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" -export type VpetToastPayload = Readonly<{ +export type DigitalPetToastPayload = Readonly<{ readonly title: string readonly message: string readonly variant: "info" | "success" readonly duration: number }> -export type VpetToastEvent = +export type DigitalPetToastEvent = | Readonly<{ readonly kind: "spawn"; readonly nodeId: string; readonly generation: number }> | Readonly<{ readonly kind: "freeze" }> | Readonly<{ readonly kind: "unfreeze" }> | Readonly<{ readonly kind: "set"; readonly nodeId: string }> | Readonly<{ readonly kind: "evolution"; readonly fromNodeId: string; readonly toNodeId: string }> + | Readonly<{ readonly kind: "evolution_battle"; readonly opponentNodeId: string }> + | Readonly<{ readonly kind: "defeat" }> -export type VpetToastNotifier = (payload: VpetToastPayload) => Promise +export type DigitalPetToastNotifier = (payload: DigitalPetToastPayload) => Promise -export type VpetToastTransport = (payload: VpetToastPayload) => boolean | Promise +export type DigitalPetToastTransport = (payload: DigitalPetToastPayload) => boolean | Promise const assertNever = (value: never): never => { - throw new Error(`Unhandled VPet toast event: ${JSON.stringify(value)}`) + throw new Error(`Unhandled Digital Pet toast event: ${JSON.stringify(value)}`) } -const selectName = (node: DigimonNode, language: VpetLanguage): string => { +const selectName = (node: DigimonNode, language: DigitalPetLanguage): string => { switch (language) { case "en": return node.nameEn @@ -34,39 +36,39 @@ const selectName = (node: DigimonNode, language: VpetLanguage): string => { } } -const namedPayload = (nodeId: string, language: VpetLanguage, catalog: DigimonCatalog): string | undefined => { +const namedPayload = (nodeId: string, language: DigitalPetLanguage, catalog: DigimonCatalog): string | undefined => { const node = catalog.byId.get(nodeId) return node === undefined ? undefined : selectName(node, language) } -export const formatVpetToast = ( - event: VpetToastEvent, - language: VpetLanguage, +export const formatDigitalPetToast = ( + event: DigitalPetToastEvent, + language: DigitalPetLanguage, catalog: DigimonCatalog, -): VpetToastPayload | undefined => { +): DigitalPetToastPayload | undefined => { switch (event.kind) { case "spawn": { const name = namedPayload(event.nodeId, language, catalog) return name === undefined ? undefined : { - title: "VPet", + title: "Digital Pet", message: `Spawned ${name} (Generation ${event.generation}).`, variant: "success", duration: 5_000, } } case "freeze": - return { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 } + return { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 } case "unfreeze": - return { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 } + return { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 } case "set": { const name = namedPayload(event.nodeId, language, catalog) return name === undefined ? undefined : { - title: "VPet", - message: `VPet set to ${name} (${event.nodeId}).`, + title: "Digital Pet", + message: `Digital Pet set to ${name} (${event.nodeId}).`, variant: "info", duration: 3_000, } @@ -83,12 +85,32 @@ export const formatVpetToast = ( duration: 5_000, } } + case "evolution_battle": { + const opponentName = namedPayload(event.opponentNodeId, language, catalog) + return opponentName === undefined + ? undefined + : { + title: "Evolution battle", + message: `Battle against ${opponentName}! Win to evolve.`, + variant: "info", + duration: 5_000, + } + } + case "defeat": + return { + title: "Defeat", + message: "Defeat! You lost all tokens for this stage.", + variant: "info", + duration: 5_000, + } default: return assertNever(event) } } -export const createBestEffortVpetToastNotifier = (transport: VpetToastTransport): VpetToastNotifier => { +export const createBestEffortDigitalPetToastNotifier = ( + transport: DigitalPetToastTransport, +): DigitalPetToastNotifier => { return async (payload) => await Promise.resolve() .then(() => transport(payload)) diff --git a/src/adapters/opencode/session-messages.ts b/packages/opencode-digital-pet/src/adapters/opencode/session-messages.ts similarity index 100% rename from src/adapters/opencode/session-messages.ts rename to packages/opencode-digital-pet/src/adapters/opencode/session-messages.ts diff --git a/src/adapters/opencode/usage-event-mapper.ts b/packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts similarity index 91% rename from src/adapters/opencode/usage-event-mapper.ts rename to packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts index fc90b92..a8f4445 100644 --- a/src/adapters/opencode/usage-event-mapper.ts +++ b/packages/opencode-digital-pet/src/adapters/opencode/usage-event-mapper.ts @@ -1,6 +1,6 @@ import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk" -import type { CompletedUsage } from "../../application/use-cases/record-usage.ts" +import type { CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" const isCompletedAssistantMessage = (message: Message): message is AssistantMessage => { return message.role === "assistant" && "time" in message && message.time.completed !== undefined diff --git a/src/adapters/sqlite/bun-sqlite-driver.ts b/packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts similarity index 77% rename from src/adapters/sqlite/bun-sqlite-driver.ts rename to packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts index 094bc24..f97d463 100644 --- a/src/adapters/sqlite/bun-sqlite-driver.ts +++ b/packages/opencode-digital-pet/src/adapters/sqlite/bun-sqlite-driver.ts @@ -2,14 +2,9 @@ import { mkdirSync } from "node:fs" import { dirname } from "node:path" import { Database } from "bun:sqlite" -export type QueryValue = string | number | boolean | null +import type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" -export type SqliteExecutor = { - run: (sql: string, params?: readonly QueryValue[]) => void - get: >(sql: string, params?: readonly QueryValue[]) => TRow | null - all: >(sql: string, params?: readonly QueryValue[]) => readonly TRow[] - transaction: (operation: () => TReturn) => TReturn -} +export type { QueryValue, SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" export const createExecutor = (database: Database): SqliteExecutor => ({ run(sql, params = []) { diff --git a/src/adapters/sqlite/sqlite-vpet-archive-reader.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts similarity index 59% rename from src/adapters/sqlite/sqlite-vpet-archive-reader.ts rename to packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts index d64076b..40e18a2 100644 --- a/src/adapters/sqlite/sqlite-vpet-archive-reader.ts +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts @@ -1,20 +1,27 @@ import { existsSync } from "node:fs" import { SQLiteError } from "bun:sqlite" -import type { VpetArchiveEvent, VpetArchivePartner, VpetArchiveResult } from "../../application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../../application/ports/vpet-archive.ts" -import { resolveHostDatabasePath, type HostPathOptions } from "./app-data-path.ts" +import type { + DigitalPetArchiveEvent, + DigitalPetArchivePartner, + DigitalPetArchiveResult, +} from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" import { createExecutor, openReadonlyDatabase, type SqliteExecutor } from "./bun-sqlite-driver.ts" import { ARCHIVE_PARTNER_EVENTS_SELECT, ARCHIVE_PARTNERS_SELECT, type PersistedPartnerEventRow, type PersistedPartnerRow, -} from "./sqlite-vpet-schema.ts" +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" -export type CreateSqliteVpetArchiveReaderOptions = HostPathOptions & { readonly databasePath?: string } +export type CreateSqliteDigitalPetArchiveReaderOptions = HostPathOptions & { readonly databasePath?: string } -const UNAVAILABLE_ARCHIVE_MESSAGE = "VPet archive is unavailable." +const UNAVAILABLE_ARCHIVE_MESSAGE = "Digital Pet archive is unavailable." export const isRecoverableSqliteReadError = (error: unknown): error is SQLiteError => { return ( @@ -24,13 +31,16 @@ export const isRecoverableSqliteReadError = (error: unknown): error is SQLiteErr ) } -const toArchiveEvent = (row: PersistedPartnerEventRow): VpetArchiveEvent => ({ +const toArchiveEvent = (row: PersistedPartnerEventRow): DigitalPetArchiveEvent => ({ eventId: row.event_id, currentNodeId: row.current_node_id, createdAt: row.created_at, }) -const toArchivePartner = (row: PersistedPartnerRow, events: readonly VpetArchiveEvent[]): VpetArchivePartner => ({ +const toArchivePartner = ( + row: PersistedPartnerRow, + events: readonly DigitalPetArchiveEvent[], +): DigitalPetArchivePartner => ({ partnerId: row.partner_id, generation: row.generation, createdAt: row.created_at, @@ -40,8 +50,8 @@ const toArchivePartner = (row: PersistedPartnerRow, events: readonly VpetArchive const groupEventsByPartnerId = ( rows: readonly PersistedPartnerEventRow[], -): ReadonlyMap => { - const eventsByPartnerId = new Map() +): ReadonlyMap => { + const eventsByPartnerId = new Map() for (const row of rows) { const events = eventsByPartnerId.get(row.partner_id) if (events === undefined) { @@ -53,7 +63,10 @@ const groupEventsByPartnerId = ( return eventsByPartnerId } -export const readSqliteVpetArchive = (executor: Pick, close: () => void): VpetArchiveResult => { +export const readSqliteDigitalPetArchive = ( + executor: Pick, + close: () => void, +): DigitalPetArchiveResult => { try { const partners = executor.all(ARCHIVE_PARTNERS_SELECT) if (partners.length === 0) return { kind: "empty" } @@ -73,18 +86,18 @@ export const readSqliteVpetArchive = (executor: Pick, clo } } -export const createSqliteVpetArchiveReader = ( - options: CreateSqliteVpetArchiveReaderOptions = {}, -): VpetArchiveReader => { +export const createSqliteDigitalPetArchiveReader = ( + options: CreateSqliteDigitalPetArchiveReaderOptions = {}, +): DigitalPetArchiveReader => { const databasePath = options.databasePath ?? resolveHostDatabasePath(options) return { - getArchive(): VpetArchiveResult { + getArchive(): DigitalPetArchiveResult { if (!existsSync(databasePath)) return { kind: "empty" } try { const database = openReadonlyDatabase(databasePath) - return readSqliteVpetArchive(createExecutor(database), () => database.close()) + return readSqliteDigitalPetArchive(createExecutor(database), () => database.close()) } catch (error) { if (isRecoverableSqliteReadError(error)) return { kind: "unavailable", message: UNAVAILABLE_ARCHIVE_MESSAGE } throw error diff --git a/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts new file mode 100644 index 0000000..102092e --- /dev/null +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-digital-pet-write-store.ts @@ -0,0 +1,42 @@ +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +import { createSqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +import { createExecutor, openWritableDatabase } from "./bun-sqlite-driver.ts" +import { readSidebarSnapshotFromExecutor } from "./sqlite-sidebar-snapshot-reader.ts" + +export type CreateSqliteDigitalPetRepositoryOptions = HostPathOptions & { readonly databasePath?: string } + +export { resolveHostDatabasePath } +export type { HostPathOptions } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +export type { + PersistedPartnerEvent, + SqliteDigitalPetWriteStore, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" + +export type OpencodeSqliteDigitalPetRepository = SqliteDigitalPetWriteStore & SidebarSnapshotReader + +export const createSqliteDigitalPetRepository = async ( + options: CreateSqliteDigitalPetRepositoryOptions = {}, +): Promise => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + const database = openWritableDatabase(databasePath) + const executor = createExecutor(database) + + const writeStore = createSqliteDigitalPetWriteStore({ + databasePath, + executor, + close: () => { + database.close() + }, + }) + + return { + ...writeStore, + getSidebarSnapshot: () => readSidebarSnapshotFromExecutor(executor), + } +} diff --git a/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts new file mode 100644 index 0000000..4d33498 --- /dev/null +++ b/packages/opencode-digital-pet/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts @@ -0,0 +1,93 @@ +import { existsSync } from "node:fs" + +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { + resolveHostDatabasePath, + type HostPathOptions, +} from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +import type { SqliteExecutor } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" +import { + ACTIVE_PARTNER_SELECT, + toPartner, + type PersistedPartnerRow, + type TrainerStateRow, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" + +import { createExecutor, openReadonlyDatabase } from "./bun-sqlite-driver.ts" + +export type CreateSqliteSidebarSnapshotReaderOptions = HostPathOptions & { readonly databasePath?: string } + +const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" +const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" + +type ControlStateRow = { + readonly frozen: number + readonly cheat_node_id: string | null +} + +const isRecoverableReadError = (error: unknown): boolean => { + return ( + error instanceof Error && + (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) + ) +} + +export const readSidebarSnapshotFromExecutor = (executor: Pick): SidebarSnapshot | null => { + const trainer = executor.get(TRAINER_STATE_SELECT) + const control = executor.get(CONTROL_STATE_SELECT) + if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { + return { + currentNodeId: control.cheat_node_id, + gauge: 0, + isTerminal: true, + frozen: false, + isSetOverride: true, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + + const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) + if (partnerRow === null) return null + + const partner = toPartner(partnerRow) + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + frozen: control?.frozen === 1, + isSetOverride: false, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } +} + +export const createSqliteSidebarSnapshotReader = ( + options: CreateSqliteSidebarSnapshotReaderOptions = {}, +): SidebarSnapshotReader => { + const databasePath = options.databasePath ?? resolveHostDatabasePath(options) + + return { + getSidebarSnapshot(): SidebarSnapshot | null { + if (!existsSync(databasePath)) return null + + try { + const database = openReadonlyDatabase(databasePath) + try { + const executor = createExecutor(database) + return readSidebarSnapshotFromExecutor(executor) + } finally { + database.close() + } + } catch (error) { + if (isRecoverableReadError(error)) return null + throw error + } + }, + } +} diff --git a/src/cli.ts b/packages/opencode-digital-pet/src/cli.ts similarity index 90% rename from src/cli.ts rename to packages/opencode-digital-pet/src/cli.ts index 0f1d6ac..5bc6a46 100644 --- a/src/cli.ts +++ b/packages/opencode-digital-pet/src/cli.ts @@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises" import { installGlobalPlugin } from "./cli/install.ts" -const HELP = `Usage: opencode-vpet [--dry-run] +const HELP = `Usage: opencode-digital-pet [--dry-run] Commands: init Install the current package version through OpenCode's global plugin installer. @@ -21,7 +21,7 @@ type ParsedArguments = | Readonly<{ readonly kind: "command"; readonly command: Command; readonly dryRun: boolean }> | Readonly<{ readonly kind: "invalid"; readonly message: string }> -type PackageManifest = Readonly<{ readonly name: "@sbugallo/opencode-vpet"; readonly version: string }> +type PackageManifest = Readonly<{ readonly name: "@jcendal/opencode-digital-pet"; readonly version: string }> const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ @@ -30,13 +30,13 @@ const isPackageManifest = (value: unknown): value is PackageManifest => { if (typeof value !== "object" || value === null || Array.isArray(value)) return false if (!("name" in value) || !("version" in value)) return false const { name, version } = value - return name === "@sbugallo/opencode-vpet" && typeof version === "string" && EXACT_SEMVER.test(version) + return name === "@jcendal/opencode-digital-pet" && typeof version === "string" && EXACT_SEMVER.test(version) } const readPackageManifest = async (): Promise => { const source = await readFile(new URL("../package.json", import.meta.url), "utf8") const manifest: unknown = JSON.parse(source) - if (!isPackageManifest(manifest)) throw new Error("Invalid opencode-vpet package manifest.") + if (!isPackageManifest(manifest)) throw new Error("Invalid opencode-digital-pet package manifest.") return manifest } diff --git a/src/cli/install.ts b/packages/opencode-digital-pet/src/cli/install.ts similarity index 100% rename from src/cli/install.ts rename to packages/opencode-digital-pet/src/cli/install.ts diff --git a/src/commands/vpet-command-result.ts b/packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts similarity index 70% rename from src/commands/vpet-command-result.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts index f831c2a..d6fa9b8 100644 --- a/src/commands/vpet-command-result.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-command-result.ts @@ -1,12 +1,12 @@ import type { Part } from "@opencode-ai/sdk" -export type VpetCommandEvent = +export type DigitalPetCommandEvent = | { readonly kind: "spawned"; readonly nodeId: string; readonly generation: number } | { readonly kind: "frozen" } | { readonly kind: "unfrozen" } | { readonly kind: "set"; readonly nodeId: string } -export type VpetCommandResult = { +export type DigitalPetCommandResult = { readonly parts: readonly Part[] - readonly event?: VpetCommandEvent + readonly event?: DigitalPetCommandEvent } diff --git a/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts b/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts new file mode 100644 index 0000000..d54db8d --- /dev/null +++ b/packages/opencode-digital-pet/src/commands/digital-pet-freeze.ts @@ -0,0 +1,35 @@ +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" + +export type DigitalPetFreezeContext = { + readonly sessionID: string + readonly messageID: string +} + +export const runDigitalPetFreezeCommand = async ( + control: Pick, + context: DigitalPetFreezeContext, +): Promise => { + const outcome = freezeDigitalPet(control) + const parts = [ + { + id: context.messageID, + sessionID: context.sessionID, + messageID: context.messageID, + type: "text" as const, + text: "Digital Pet frozen.", + }, + ] + + switch (outcome.kind) { + case "frozen": + return { parts, event: { kind: "frozen" } } + case "already_frozen": + return { parts } + default: { + const unexpectedOutcome: never = outcome + return unexpectedOutcome + } + } +} diff --git a/src/commands/vpet-set.ts b/packages/opencode-digital-pet/src/commands/digital-pet-set.ts similarity index 51% rename from src/commands/vpet-set.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-set.ts index 3eb33c2..e4f0906 100644 --- a/src/commands/vpet-set.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-set.ts @@ -1,11 +1,11 @@ import type { Part } from "@opencode-ai/sdk" -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { setVpetCheatNode } from "../application/use-cases/set-vpet-cheat-node.ts" -import type { DigimonCatalog } from "../data/catalog.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" -export type VpetSetContext = { +export type DigitalPetSetContext = { readonly sessionID: string readonly messageID: string readonly arguments: string @@ -13,7 +13,7 @@ export type VpetSetContext = { export type DigimonCatalogLoader = () => Promise -const textPart = (context: VpetSetContext, text: string): Part => ({ +const textPart = (context: DigitalPetSetContext, text: string): Part => ({ id: context.messageID, sessionID: context.sessionID, messageID: context.messageID, @@ -21,23 +21,23 @@ const textPart = (context: VpetSetContext, text: string): Part => ({ text, }) -export const runVpetSetCommand = async ( - control: Pick, - context: VpetSetContext, +export const runDigitalPetSetCommand = async ( + control: Pick, + context: DigitalPetSetContext, loadCatalog: DigimonCatalogLoader, -): Promise => { - if (context.arguments.includes("\n")) return { parts: [textPart(context, "Usage: /vpet-set .")] } +): Promise => { + if (context.arguments.includes("\n")) return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const tokens = context.arguments.trim().split(/\s+/) if (context.arguments.trim() === "" || tokens.length !== 1) - return { parts: [textPart(context, "Usage: /vpet-set .")] } + return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const requestedId = tokens[0] - if (requestedId === undefined) return { parts: [textPart(context, "Usage: /vpet-set .")] } + if (requestedId === undefined) return { parts: [textPart(context, "Usage: /digital-pet-set .")] } const node = (await loadCatalog()).byId.get(requestedId) if (node === undefined) return { parts: [textPart(context, `Unknown Digimon ID: ${requestedId}.`)] } - const outcome = setVpetCheatNode(control, node.id) - const parts = [textPart(context, `VPet set to ${node.nameEn} (${node.id}).`)] + const outcome = setDigitalPetCheatNode(control, node.id) + const parts = [textPart(context, `Digital Pet set to ${node.nameEn} (${node.id}).`)] switch (outcome.kind) { case "set": diff --git a/src/commands/vpet-spawn.ts b/packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts similarity index 53% rename from src/commands/vpet-spawn.ts rename to packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts index 51fa9a5..7b0f36f 100644 --- a/src/commands/vpet-spawn.ts +++ b/packages/opencode-digital-pet/src/commands/digital-pet-spawn.ts @@ -1,17 +1,17 @@ -import type { PartnerLifecycle } from "../application/ports/partner-lifecycle.ts" -import { spawnPartner } from "../application/use-cases/spawn-partner.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" -export type VpetSpawnContext = { +export type DigitalPetSpawnContext = { readonly sessionID: string readonly messageID: string readonly createdAt: string } -export const runVpetSpawnCommand = async ( +export const runDigitalPetSpawnCommand = async ( lifecycle: PartnerLifecycle, - context: VpetSpawnContext, -): Promise => { + context: DigitalPetSpawnContext, +): Promise => { const partner = spawnPartner(lifecycle, context.createdAt) return { diff --git a/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts b/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts new file mode 100644 index 0000000..8cf9c15 --- /dev/null +++ b/packages/opencode-digital-pet/src/commands/digital-pet-unfreeze.ts @@ -0,0 +1,35 @@ +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" +import type { DigitalPetCommandResult } from "./digital-pet-command-result.ts" + +export type DigitalPetUnfreezeContext = { + readonly sessionID: string + readonly messageID: string +} + +export const runDigitalPetUnfreezeCommand = async ( + control: Pick, + context: DigitalPetUnfreezeContext, +): Promise => { + const outcome = unfreezeDigitalPet(control) + const parts = [ + { + id: context.messageID, + sessionID: context.sessionID, + messageID: context.messageID, + type: "text" as const, + text: "Digital Pet unfrozen.", + }, + ] + + switch (outcome.kind) { + case "unfrozen": + return { parts, event: { kind: "unfrozen" } } + case "already_unfrozen": + return { parts } + default: { + const unexpectedOutcome: never = outcome + return unexpectedOutcome + } + } +} diff --git a/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts b/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts new file mode 100644 index 0000000..fee0fdb --- /dev/null +++ b/packages/opencode-digital-pet/src/config/global-digital-pet-settings.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { homedir } from "node:os" +import { join } from "node:path" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" + +type PathApi = Readonly<{ + join: (...paths: readonly string[]) => string +}> + +export type GlobalDigitalPetConfigOptions = Readonly<{ + readonly env?: NodeJS.ProcessEnv + readonly home?: string + readonly pathApi?: PathApi + readonly platform?: NodeJS.Platform +}> + +export const resolveGlobalDigitalPetConfigPath = (options: GlobalDigitalPetConfigOptions = {}): string => { + const pathApi = options.pathApi ?? { join } + const env = options.env ?? process.env + const home = options.home ?? homedir() + + const configDirectory = + (options.platform ?? process.platform) === "win32" + ? pathApi.join(env["APPDATA"] ?? pathApi.join(home, "AppData", "Roaming")) + : pathApi.join(home, ".config") + const currentPath = pathApi.join(configDirectory, "opencode-digital-pet.json") + const legacyPath = pathApi.join(configDirectory, "opencode-vpet.json") + if (!existsSync(currentPath) && existsSync(legacyPath)) return legacyPath + return currentPath +} + +export const loadGlobalDigitalPetSettings = async ( + options: GlobalDigitalPetConfigOptions = {}, +): Promise => { + try { + return normalizeDigitalPetSettings(JSON.parse(await readFile(resolveGlobalDigitalPetConfigPath(options), "utf8"))) + } catch { + return DEFAULT_DIGITAL_PET_SETTINGS + } +} + +export const loadGlobalDigitalPetSettingsSync = ( + options: GlobalDigitalPetConfigOptions = {}, +): ResolvedDigitalPetSettings => { + try { + return normalizeDigitalPetSettings(JSON.parse(readFileSync(resolveGlobalDigitalPetConfigPath(options), "utf8"))) + } catch { + return DEFAULT_DIGITAL_PET_SETTINGS + } +} diff --git a/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts b/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts new file mode 100644 index 0000000..3f6d805 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/attach-dev-tools.ts @@ -0,0 +1,64 @@ +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" + +import type { TuiCompositionApi } from "../tui.tsx" +import { buildPartnerInputs } from "./build-partner-inputs.ts" +import { DEV_EVOLUTION } from "./fixtures.ts" +import { registerDevCommandLayer } from "./register-dev-command-layer.tsx" +import { setupBattlePending, type DevScenarioRepository } from "./setup-scenario.ts" + +export type DevToolsRuntime = { + readonly runFeed: () => void + readonly runActivity: () => void + readonly runEvolutionReveal: () => Promise + readonly runEvolutionBattle: () => Promise +} + +export type AttachDevToolsDependencies = { + readonly api: { readonly keymap: NonNullable } + readonly isDisposed: () => boolean + readonly isBusy: () => boolean + readonly feed: () => void + readonly activity: () => void + readonly tryResolveEvolutionBattle: () => Promise + readonly tryPlayEvolutionReveal: (nextInputs: SidebarCardInputs) => Promise + readonly setLastPresentedNodeId: (nodeId: string) => void + readonly battleRepository?: EvolutionBattleRepository +} + +export const attachDevTools = (deps: AttachDevToolsDependencies): (() => void) => { + if (deps.api.keymap === undefined) return () => undefined + + const runtime: DevToolsRuntime = { + runFeed: () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.feed() + }, + runActivity: () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.activity() + }, + runEvolutionReveal: async () => { + if (deps.isDisposed() || deps.isBusy()) return + deps.setLastPresentedNodeId(DEV_EVOLUTION.fromNodeId) + const played = await deps.tryPlayEvolutionReveal(buildPartnerInputs(DIGIMON_CATALOG, DEV_EVOLUTION.toNodeId)) + if (!played) throw new Error("Evolution reveal did not play. Ensure the sidebar is visible.") + }, + runEvolutionBattle: async () => { + if (deps.isDisposed() || deps.isBusy()) return + if (deps.battleRepository === undefined) throw new Error("Battle repository is unavailable.") + setupBattlePending(deps.battleRepository as DevScenarioRepository) + const battled = await deps.tryResolveEvolutionBattle() + if (!battled) throw new Error("Evolution battle did not start. Ensure a partner is active.") + }, + } + + const disposeLayer = registerDevCommandLayer({ + keymap: deps.api.keymap, + runtime, + isDisposed: deps.isDisposed, + }) + + return disposeLayer +} diff --git a/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts b/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts new file mode 100644 index 0000000..5616902 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/build-partner-inputs.ts @@ -0,0 +1,18 @@ +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" + +export const buildPartnerInputs = (catalog: DigimonCatalog, nodeId: string): SidebarCardInputs => { + const node = catalog.byId.get(nodeId) + if (node === undefined) throw new Error(`Unknown Digimon node: ${nodeId}`) + + return { + kind: "partner", + node, + gauge: 42, + isTerminal: false, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 100, + evolutionBattlePending: false, + } +} diff --git a/packages/opencode-digital-pet/src/dev/catalog.ts b/packages/opencode-digital-pet/src/dev/catalog.ts new file mode 100644 index 0000000..3955d0b --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/catalog.ts @@ -0,0 +1,9 @@ +export const OPENCODE_DEV_ACTIONS = [ + { id: "feed", title: "Digital Pet Dev Feed", slashName: "digital-pet-dev-feed" }, + { id: "activity", title: "Digital Pet Dev Activity", slashName: "digital-pet-dev-activity" }, + { id: "evolution_reveal", title: "Digital Pet Dev Evolution Reveal", slashName: "digital-pet-dev-evolution-reveal" }, + { id: "evolution_battle", title: "Digital Pet Dev Evolution Battle", slashName: "digital-pet-dev-evolution-battle" }, + { id: "defeat", title: "Digital Pet Dev Defeat (via battle)", slashName: "digital-pet-dev-defeat" }, +] as const + +export type OpencodeDevActionId = (typeof OPENCODE_DEV_ACTIONS)[number]["id"] diff --git a/packages/opencode-digital-pet/src/dev/fixtures.ts b/packages/opencode-digital-pet/src/dev/fixtures.ts new file mode 100644 index 0000000..5eadda9 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/fixtures.ts @@ -0,0 +1,6 @@ +import type { UsageEvolutionTransition } from "@jcendal/digital-pet-core/application/models/usage.ts" + +export const DEV_EVOLUTION: UsageEvolutionTransition = { + fromNodeId: "3-001", + toNodeId: "4-017", +} diff --git a/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx b/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx new file mode 100644 index 0000000..ebe6f3e --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/register-dev-command-layer.tsx @@ -0,0 +1,42 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiKeymap } from "@opencode-ai/plugin/tui" + +import { OPENCODE_DEV_ACTIONS, type OpencodeDevActionId } from "./catalog.ts" +import type { DevToolsRuntime } from "./attach-dev-tools.ts" + +export type DevCommandLayerDependencies = { + readonly keymap: Pick + readonly runtime: DevToolsRuntime + readonly isDisposed: () => boolean +} + +export const registerDevCommandLayer = ({ + keymap, + runtime, + isDisposed, +}: DevCommandLayerDependencies): ReturnType => { + const handlers: Record void | Promise> = { + feed: () => runtime.runFeed(), + activity: () => runtime.runActivity(), + evolution_reveal: () => runtime.runEvolutionReveal(), + evolution_battle: () => runtime.runEvolutionBattle(), + defeat: () => runtime.runEvolutionBattle(), + } + + return keymap.registerLayer({ + name: "opencode-digital-pet-dev.layer", + namespace: "opencode-digital-pet-dev", + commands: OPENCODE_DEV_ACTIONS.map((action) => ({ + name: action.id, + title: action.title, + description: action.title, + category: "Digital Pet Dev", + namespace: "palette", + slashName: action.slashName, + run: () => { + if (isDisposed()) return + void handlers[action.id]() + }, + })), + }) +} diff --git a/packages/opencode-digital-pet/src/dev/setup-scenario.ts b/packages/opencode-digital-pet/src/dev/setup-scenario.ts new file mode 100644 index 0000000..2092329 --- /dev/null +++ b/packages/opencode-digital-pet/src/dev/setup-scenario.ts @@ -0,0 +1,90 @@ +import type { + SidebarSnapshot, + SidebarSnapshotReader, +} from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { SqliteDigitalPetWriteStore } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +export type DevScenarioRepository = SqliteDigitalPetWriteStore & UsageLedger & SidebarSnapshotReader + +const usageReceipt = (receiptKey: string, tokenDelta = 1): Parameters[0]["usage"] => ({ + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta, + cost: null, + createdAt: new Date().toISOString(), +}) + +const applyTokenUsage = (ledger: UsageLedger, receiptKey: string, tokenDelta = 1): ReturnType => + recordUsage({ + usage: usageReceipt(receiptKey, tokenDelta), + ledger, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) + +const hasPendingBattle = (snapshot: SidebarSnapshot): boolean => + snapshot.pendingEvolutionTargetId !== null && snapshot.battleOpponentNodeId !== null + +const assertPartnerCanBattle = (snapshot: SidebarSnapshot): void => { + const current = DIGIMON_CATALOG.byId.get(snapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${snapshot.currentNodeId} is missing from the catalog.`) + } + if (snapshot.frozen) { + throw new Error("Digital Pet dev: partner is frozen. Unfreeze before opening an evolution battle.") + } + if (snapshot.isSetOverride) { + throw new Error("Digital Pet dev: clear the digimon override before opening an evolution battle.") + } + if (snapshot.isTerminal || current.nextEvolutions.length === 0) { + throw new Error("Digital Pet dev: partner cannot evolve further, so no evolution battle can be opened.") + } +} + +export const ensureActivePartner = (repository: DevScenarioRepository): void => { + if (repository.getActivePartner() !== null) return + spawnPartner(repository, new Date().toISOString()) +} + +export const setupBattlePending = (repository: DevScenarioRepository): void => { + ensureActivePartner(repository) + const initialSnapshot = repository.getSidebarSnapshot() + if (initialSnapshot === null) { + throw new Error("Digital Pet dev: could not read the active partner snapshot.") + } + if (hasPendingBattle(initialSnapshot)) return + + assertPartnerCanBattle(initialSnapshot) + + const receiptSuffix = Date.now().toString(36) + const current = DIGIMON_CATALOG.byId.get(initialSnapshot.currentNodeId) + if (current === undefined) { + throw new Error(`Digital Pet dev: partner node ${initialSnapshot.currentNodeId} is missing from the catalog.`) + } + + if (current.stage === 0) { + applyTokenUsage(repository, `dev-hatch-${receiptSuffix}`, 1) + } + + const afterHatch = repository.getSidebarSnapshot() + if (afterHatch !== null && !hasPendingBattle(afterHatch)) { + applyTokenUsage(repository, `dev-battle-${receiptSuffix}`, 1) + } + + const pending = repository.getSidebarSnapshot() + if (pending === null || !hasPendingBattle(pending)) { + throw new Error("Digital Pet dev: could not open a pending evolution battle for the current partner.") + } +} diff --git a/src/index.ts b/packages/opencode-digital-pet/src/index.ts similarity index 63% rename from src/index.ts rename to packages/opencode-digital-pet/src/index.ts index e14b948..e7ffaed 100644 --- a/src/index.ts +++ b/packages/opencode-digital-pet/src/index.ts @@ -2,21 +2,21 @@ import type { Plugin, PluginOptions } from "@opencode-ai/plugin" import { createServerHooks } from "./adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "./adapters/opencode/session-messages.ts" -import { createBestEffortVpetToastNotifier } from "./adapters/opencode/vpet-toast.ts" -import { createSqliteVpetRepository } from "./adapters/sqlite/sqlite-vpet-write-store.ts" -import { loadGlobalVpetSettings } from "./config/global-vpet-settings.ts" -import type { ResolvedVpetSettings } from "./config/types.ts" -import type { StageThresholds } from "./domain/evolution.ts" +import { createBestEffortDigitalPetToastNotifier } from "./adapters/opencode/digital-pet-toast.ts" +import { createSqliteDigitalPetRepository } from "./adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { loadGlobalDigitalPetSettings } from "./config/global-digital-pet-settings.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" export { createCommandConfig } from "./adapters/opencode/create-server-hooks.ts" -export { DIGIMON_DATA } from "./data/digimon-data.js" -export type { DigimonId, DigimonRecord, DigimonStage } from "./data/digimon-data.js" +export { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" +export type { DigimonId, DigimonRecord, DigimonStage } from "@jcendal/digital-pet-core/data/digimon-data.ts" const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { const value = options?.[name] return typeof value === "string" ? value : undefined } -const toEvolutionThresholds = (settings: ResolvedVpetSettings): StageThresholds => +const toEvolutionThresholds = (settings: ResolvedDigitalPetSettings): StageThresholds => Object.freeze({ 0: settings.stageThresholds.egg, 1: settings.stageThresholds.babyI, @@ -31,8 +31,8 @@ const toEvolutionThresholds = (settings: ResolvedVpetSettings): StageThresholds export const plugin: Plugin = async (input, options) => { const appDataRoot = getStringOption(options, "appDataRoot") const databasePath = getStringOption(options, "databasePath") - const settings = await loadGlobalVpetSettings() - const repository = await createSqliteVpetRepository({ + const settings = await loadGlobalDigitalPetSettings() + const repository = await createSqliteDigitalPetRepository({ ...(appDataRoot === undefined ? {} : { appDataRoot }), ...(databasePath === undefined ? {} : { databasePath }), }) @@ -43,14 +43,14 @@ export const plugin: Plugin = async (input, options) => { fetchMessages: createSessionMessagesFetcher(input.client), language: settings.language, notificationsEnabled: settings.notifications, - notify: createBestEffortVpetToastNotifier( + notify: createBestEffortDigitalPetToastNotifier( async (payload) => (await input.client.tui.showToast({ body: payload })).data ?? false, ), }) } const entryPlugin = { - id: "opencode-vpet", + id: "opencode-digital-pet", server: plugin, } as const diff --git a/packages/opencode-digital-pet/src/tui.tsx b/packages/opencode-digital-pet/src/tui.tsx new file mode 100644 index 0000000..e5e107a --- /dev/null +++ b/packages/opencode-digital-pet/src/tui.tsx @@ -0,0 +1,449 @@ +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" +/** @jsxImportSource @opentui/solid */ +import type { PluginOptions } from "@opencode-ai/plugin" +import type { EventMessagePartUpdated, EventMessageUpdated, EventSessionStatus } from "@opencode-ai/sdk/v2" +import type { TuiPlugin, TuiDialogProps, TuiDialogStack, TuiTheme, TuiKeymap } from "@opencode-ai/plugin/tui" +import type { JSX } from "@opentui/solid" +import { createSignal } from "solid-js" + +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import type { EvolutionBattleRepository } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" +import { + MonsterAnimationController, + type MonsterAnimationOutput, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { runEvolutionBattleSession } from "@jcendal/digital-pet-animation/sessions/evolution-battle-session.ts" +import { runEvolutionRevealSession } from "@jcendal/digital-pet-animation/sessions/evolution-reveal-session.ts" + +import { + formatDigitalPetToast, + type DigitalPetToastEvent, + type DigitalPetToastNotifier, +} from "./adapters/opencode/digital-pet-toast.ts" +import { createSqliteSidebarSnapshotReader } from "./adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" +import { createSqliteDigitalPetArchiveReader } from "./adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { createSqliteDigitalPetRepository } from "./adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { loadGlobalDigitalPetSettings } from "./config/global-digital-pet-settings.ts" +import { createSidebarPollLoop } from "./tui/sidebar-poll-loop.ts" +import { DigitalPetSidebarCard } from "./tui/sidebar-card.tsx" +import { registerDigitalPetCommandLayer } from "./tui/digital-pet-command-layer.tsx" + +const VISUAL_INTERVAL_MS = 500 +const DEFAULT_BATTLE_ARTWORK_WIDTH = 80 + +const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { + const value = options?.[name] + return typeof value === "string" ? value : undefined +} + +const readerOptions = (options: PluginOptions | undefined): Parameters[0] => { + const appDataRoot = getStringOption(options, "appDataRoot") + const databasePath = getStringOption(options, "databasePath") + return { + ...(appDataRoot === undefined ? {} : { appDataRoot }), + ...(databasePath === undefined ? {} : { databasePath }), + } +} + +export type TuiCompositionApi = { + readonly event: { readonly on: (...subscription: ActivitySubscription) => () => void } + readonly renderer: { readonly requestRender: () => void } + readonly lifecycle: { readonly onDispose: (callback: () => void) => () => void } + readonly slots: { + readonly register: (plugin: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => string + } + readonly keymap?: { readonly registerLayer: TuiKeymap["registerLayer"] } + readonly ui?: { + readonly dialog: Pick + readonly Dialog: (props: TuiDialogProps) => JSX.Element + } + readonly theme?: TuiTheme +} + +type ActivitySubscription = + | readonly [type: "message.updated", handler: (event: EventMessageUpdated) => void] + | readonly [type: "message.part.updated", handler: (event: EventMessagePartUpdated) => void] + | readonly [type: "session.status", handler: (event: EventSessionStatus) => void] + +type TuiSchedulingOptions = { + readonly scheduleVisualInterval?: (callback: () => void, intervalMs: number) => () => void + readonly schedulePollTimeout?: (callback: () => void, intervalMs: number) => () => void + readonly nowMs?: () => number + readonly random?: () => number + readonly onAnimation?: (output: MonsterAnimationOutput) => void + readonly archiveReader?: DigitalPetArchiveReader + readonly battleRepository?: EvolutionBattleRepository + readonly getSnapshot?: () => SidebarSnapshot | null + readonly notify?: DigitalPetToastNotifier + readonly closeBattleRepository?: () => Promise +} + +const sameAnimation = (left: MonsterAnimationOutput, right: MonsterAnimationOutput): boolean => + left.kind === right.kind && + left.result.kind === right.result.kind && + (left.result.kind !== "frame" || right.result.kind !== "frame" || left.result.frame === right.result.frame) && + (left.result.kind !== "unavailable" || + right.result.kind !== "unavailable" || + left.result.sprite === right.result.sprite) && + left.offset === right.offset && + left.facing === right.facing + +export const createTui = + ( + loadInputs: () => SidebarCardInputs | Promise, + settings: ResolvedDigitalPetSettings, + scheduling: TuiSchedulingOptions = {}, + ) => + async (api: TuiCompositionApi): Promise => { + const [inputs, setInputs] = createSignal({ kind: "no_partner" }) + const [animation, setAnimation] = createSignal({ + kind: "blank", + result: { kind: "blank" }, + offset: 0, + facing: "left", + }) + const [presentationState, setPresentationState] = createSignal({ phase: "idle" }) + const [customArtwork, setCustomArtwork] = createSignal() + const controller = new MonsterAnimationController( + MONSTER_FRAME_CATALOG, + scheduling.random ?? Math.random, + scheduling.nowMs, + ) + let disposed = false + let battleInProgress = false + let lastPresentedNodeId: string | undefined + let artworkWidth = DEFAULT_BATTLE_ARTWORK_WIDTH + const notifyEvent = async (event: DigitalPetToastEvent): Promise => { + if (scheduling.notify === undefined) return + const payload = formatDigitalPetToast(event, settings.language, DIGIMON_CATALOG) + if (payload !== undefined) await scheduling.notify(payload) + } + const tryResolveEvolutionBattle = async (): Promise => { + if ( + disposed || + battleInProgress || + scheduling.battleRepository === undefined || + scheduling.getSnapshot === undefined + ) { + return false + } + const snapshot = scheduling.getSnapshot() + if (snapshot === null) return false + if (snapshot.pendingEvolutionTargetId === null || snapshot.battleOpponentNodeId === null) return false + + battleInProgress = true + try { + const battled = await runEvolutionBattleSession(snapshot, artworkWidth, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + repository: scheduling.battleRepository, + ...(scheduling.random === undefined ? {} : { random: scheduling.random }), + onState: async (state) => { + setPresentationState(state) + if (state.phase === "evolved") { + const current = inputs() + const node = DIGIMON_CATALOG.byId.get(state.toNodeId) + if (current.kind === "partner" && node !== undefined) setInputs({ ...current, node }) + } + api.renderer.requestRender() + }, + onArtwork: async (artwork) => { + setCustomArtwork(artwork) + api.renderer.requestRender() + }, + onResolved: async (result) => { + if (result.kind === "won") { + await notifyEvent({ kind: "evolution", ...result.evolution }) + } else if (result.kind === "lost") { + await notifyEvent({ kind: "defeat" }) + } + }, + }) + if (battled) { + setCustomArtwork(undefined) + const nextInputs = await Promise.resolve().then(() => loadInputs()) + if (nextInputs.kind === "partner") lastPresentedNodeId = nextInputs.node.id + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + } + return battled + } finally { + battleInProgress = false + setPresentationState({ phase: "idle" }) + setCustomArtwork(undefined) + } + } + const tryPlayEvolutionReveal = async (nextInputs: SidebarCardInputs): Promise => { + if (disposed || battleInProgress) return false + if (nextInputs.kind !== "partner" || nextInputs.evolutionBattlePending) return false + if (lastPresentedNodeId === undefined) { + lastPresentedNodeId = nextInputs.node.id + return false + } + if (lastPresentedNodeId === nextInputs.node.id) return false + + const evolution = { fromNodeId: lastPresentedNodeId, toNodeId: nextInputs.node.id } + battleInProgress = true + try { + const revealed = await runEvolutionRevealSession(evolution, artworkWidth, { + frameCatalog: MONSTER_FRAME_CATALOG, + digimonCatalog: DIGIMON_CATALOG, + onState: async (state) => { + setPresentationState(state) + if (state.phase === "evolved") { + const current = inputs() + const node = DIGIMON_CATALOG.byId.get(state.toNodeId) + if (current.kind === "partner" && node !== undefined) setInputs({ ...current, node }) + } + api.renderer.requestRender() + }, + onArtwork: async (artwork) => { + setCustomArtwork(artwork) + api.renderer.requestRender() + }, + }) + if (!revealed) return false + lastPresentedNodeId = nextInputs.node.id + setCustomArtwork(undefined) + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + return true + } finally { + battleInProgress = false + setPresentationState({ phase: "idle" }) + setCustomArtwork(undefined) + } + } + const publish = (nextAnimation: MonsterAnimationOutput): boolean => { + if (sameAnimation(animation(), nextAnimation)) return false + setAnimation(nextAnimation) + return true + } + const syncAnimation = (nextInputs: SidebarCardInputs): void => { + const cardModel = buildSidebarCardModel(nextInputs, settings) + publish( + controller.dispatch({ + kind: "partner_changed", + partner: + cardModel.kind === "partner" + ? { sprite: cardModel.sprite, isDigitama: cardModel.stageNumber === 0 } + : undefined, + }), + ) + } + const poller = createSidebarPollLoop({ + intervalMs: VISUAL_INTERVAL_MS, + load: async () => loadInputs(), + apply: (nextInputs) => { + void (async () => { + if (await tryResolveEvolutionBattle()) { + if (disposed) return + poller.refresh() + return + } + if (await tryPlayEvolutionReveal(nextInputs)) { + if (disposed) return + poller.refresh() + return + } + if (disposed || battleInProgress) return + if (nextInputs.kind === "partner") lastPresentedNodeId = nextInputs.node.id + setInputs(nextInputs) + syncAnimation(nextInputs) + api.renderer.requestRender() + })() + }, + schedule: (callback, intervalMs) => + ( + scheduling.schedulePollTimeout ?? + ((scheduledCallback, delay) => { + const handle = setTimeout(scheduledCallback, delay) + return () => clearTimeout(handle) + }) + )(callback, intervalMs), + clear: (stop) => stop(), + }) + const publishAnimation = (nextAnimation: MonsterAnimationOutput): void => { + if (!publish(nextAnimation)) return + scheduling.onAnimation?.(nextAnimation) + api.renderer.requestRender() + } + const stopVisualInterval = ( + scheduling.scheduleVisualInterval ?? + ((callback, intervalMs) => { + const handle = setInterval(callback, intervalMs) + return () => clearInterval(handle) + }) + )(() => { + if (disposed || battleInProgress) return + publishAnimation(controller.dispatch({ kind: "tick" })) + }, VISUAL_INTERVAL_MS) + + let sessionWasBusy = false + const activity = (): void => { + if (disposed) return + poller.refresh() + publishAnimation(controller.dispatch({ kind: "activity" })) + } + const feed = (): void => { + if (disposed || battleInProgress) return + publishAnimation(controller.dispatch({ kind: "feed" })) + } + const unsubscribes = [ + api.event.on("message.updated", activity), + api.event.on("message.part.updated", activity), + api.event.on("session.status", (event: EventSessionStatus) => { + switch (event.properties.status.type) { + case "busy": + case "retry": + sessionWasBusy = true + activity() + return + case "idle": + if (sessionWasBusy) { + sessionWasBusy = false + feed() + } + return + default: { + const unexpectedStatus: never = event.properties.status + return unexpectedStatus + } + } + }), + ] + + poller.start() + + let disposeDevTools = (): void => undefined + if (process.env["OPENCODE_DIGITAL_PET_DEV"] === "1" && api.keymap !== undefined) { + const devAttachPath = `./dev/${"attach-dev-tools.js"}` + const { attachDevTools } = await import(devAttachPath) + disposeDevTools = attachDevTools({ + api: { keymap: api.keymap }, + isDisposed: () => disposed, + isBusy: () => battleInProgress, + feed, + activity, + tryResolveEvolutionBattle, + tryPlayEvolutionReveal, + setLastPresentedNodeId: (nodeId: string) => { + lastPresentedNodeId = nodeId + }, + ...(scheduling.battleRepository === undefined ? {} : { battleRepository: scheduling.battleRepository }), + }) + } + + const disposeLayer = + api.keymap === undefined || api.ui === undefined || api.theme === undefined + ? () => undefined + : scheduling.archiveReader === undefined + ? api.keymap.registerLayer({ + name: "opencode-digital-pet.layer", + namespace: "opencode-digital-pet", + commands: [], + }) + : registerDigitalPetCommandLayer({ + api: { keymap: api.keymap, ui: api.ui, theme: api.theme }, + reader: scheduling.archiveReader, + catalog: DIGIMON_CATALOG, + settings, + isDisposed: () => disposed, + }) + + api.lifecycle.onDispose(() => { + if (disposed) return + disposed = true + disposeDevTools() + disposeLayer() + poller.dispose() + stopVisualInterval() + for (const unsubscribe of unsubscribes) unsubscribe() + void scheduling.closeBattleRepository?.() + }) + + api.slots.register({ + slots: { + sidebar_content() { + return ( + buildSidebarCardModel(inputs(), settings)} + animation={animation} + customArtwork={customArtwork} + presentationState={presentationState} + onArtworkWidthChange={(width) => { + if (disposed) return + artworkWidth = width + if (publish(controller.dispatch({ kind: "viewport_resized", width }))) api.renderer.requestRender() + }} + /> + ) + }, + }, + }) + } + +export const tui: TuiPlugin = async (api, options) => { + const databaseOptions = readerOptions(options) + const reader = createSqliteSidebarSnapshotReader(databaseOptions) + const battleRepository = await createSqliteDigitalPetRepository(databaseOptions) + const archiveReader = createSqliteDigitalPetArchiveReader(databaseOptions) + const settings = await loadGlobalDigitalPetSettings() + await createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), settings, { + archiveReader, + battleRepository, + getSnapshot: () => reader.getSidebarSnapshot(), + ...("client" in api && api.client !== undefined && "tui" in api.client + ? { + notify: async (payload): Promise => { + void api.client.tui.showToast({ + title: payload.title, + message: payload.message, + variant: payload.variant, + duration: payload.duration, + }) + }, + } + : {}), + closeBattleRepository: () => battleRepository.close(), + })({ + event: { + on: (...subscription) => { + switch (subscription[0]) { + case "message.updated": + return api.event.on(subscription[0], subscription[1]) + case "message.part.updated": + return api.event.on(subscription[0], subscription[1]) + case "session.status": + return api.event.on(subscription[0], subscription[1]) + } + }, + }, + renderer: api.renderer, + lifecycle: api.lifecycle, + slots: api.slots, + keymap: { registerLayer: api.keymap.registerLayer.bind(api.keymap) }, + ui: { + dialog: { + replace: api.ui.dialog.replace.bind(api.ui.dialog), + clear: api.ui.dialog.clear.bind(api.ui.dialog), + setSize: api.ui.dialog.setSize.bind(api.ui.dialog), + }, + Dialog: api.ui.Dialog, + }, + theme: api.theme, + }) +} + +const plugin = { id: "opencode-digital-pet", tui } as const + +export default plugin diff --git a/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx new file mode 100644 index 0000000..4ec5e87 --- /dev/null +++ b/packages/opencode-digital-pet/src/tui/digital-pet-command-layer.tsx @@ -0,0 +1,109 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiDialogStack, TuiKeymap, TuiTheme } from "@opencode-ai/plugin/tui" + +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" +import { DigitalPetDexDialog } from "./digital-pet-dex-dialog.tsx" +import { DigitalPetHistoryDialog } from "./digital-pet-history-dialog.tsx" + +export type DigitalPetCommandLayerApi = { + readonly keymap: Pick + readonly ui: { + readonly dialog: Pick + } + readonly theme: TuiTheme +} + +export type DigitalPetCommandLayerDependencies = { + readonly api: DigitalPetCommandLayerApi + readonly reader: DigitalPetArchiveReader + readonly catalog: DigimonCatalog + readonly settings: ResolvedDigitalPetSettings + readonly isDisposed: () => boolean +} + +const unavailableArchive = (): DigitalPetArchiveResult => ({ + kind: "unavailable", + message: "Digital Pet archive is unavailable.", +}) + +const readArchive = (reader: DigitalPetArchiveReader): DigitalPetArchiveResult => { + try { + return reader.getArchive() + } catch { + return unavailableArchive() + } +} + +export const registerDigitalPetCommandLayer = ({ + api, + reader, + catalog, + settings, + isDisposed, +}: DigitalPetCommandLayerDependencies): ReturnType => { + let isDialogOpen = false + const closeDialog = (): void => { + if (isDisposed() || !isDialogOpen) return + isDialogOpen = false + api.ui.dialog.clear() + } + + const openDex = (): void => { + if (isDisposed()) return + const model = buildDexViewModel(readArchive(reader), catalog, settings) + isDialogOpen = true + api.ui.dialog.replace( + () => , + closeDialog, + ) + api.ui.dialog.setSize("medium") + } + const openHistory = (): void => { + if (isDisposed()) return + const model = buildHistoryViewModel(readArchive(reader), catalog, settings) + isDialogOpen = true + api.ui.dialog.replace( + () => , + closeDialog, + ) + api.ui.dialog.setSize("medium") + } + + const disposeLayer = api.keymap.registerLayer({ + name: "opencode-digital-pet.layer", + namespace: "opencode-digital-pet", + commands: [ + { + name: "digital-pet.dex", + title: "Digital Pet Dex", + description: "Browse the Digital Pet discovery archive.", + category: "Digital Pet", + namespace: "palette", + slashName: "digital-pet-dex", + run: openDex, + }, + { + name: "digital-pet.history", + title: "Digital Pet History", + description: "Browse Digital Pet generation history.", + category: "Digital Pet", + namespace: "palette", + slashName: "digital-pet-history", + run: openHistory, + }, + ], + }) + + return () => { + if (isDialogOpen) { + api.ui.dialog.clear() + isDialogOpen = false + } + disposeLayer() + } +} diff --git a/src/tui/vpet-dex-dialog.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx similarity index 90% rename from src/tui/vpet-dex-dialog.tsx rename to packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx index 614ce09..f68450d 100644 --- a/src/tui/vpet-dex-dialog.tsx +++ b/packages/opencode-digital-pet/src/tui/digital-pet-dex-dialog.tsx @@ -3,21 +3,21 @@ import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@ import { For, createMemo } from "solid-js" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import type { TuiTheme } from "@opencode-ai/plugin/tui" -import type { DexRow, DexViewModel } from "./dex-view-model.ts" +import type { DexRow, DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" -export type VpetDexDialogTheme = { +export type DigitalPetDexDialogTheme = { readonly current: Pick } -export type VpetDexDialogProps = { - readonly theme: VpetDexDialogTheme +export type DigitalPetDexDialogProps = { + readonly theme: DigitalPetDexDialogTheme readonly model: DexViewModel readonly onClose: () => void } const BOLD = createTextAttributes({ bold: true }) -export function VpetDexDialog(props: VpetDexDialogProps) { +export function DigitalPetDexDialog(props: DigitalPetDexDialogProps) { const dimensions = useTerminalDimensions() const height = Math.max(10, Math.floor(dimensions().height / 2) - 1) let scrollbox: ScrollBoxRenderable | undefined @@ -38,14 +38,14 @@ export function VpetDexDialog(props: VpetDexDialogProps) { return ( - VPet Dex + Digital Pet Dex {props.model.kind === "empty" && No data available} {props.model.kind === "unavailable" && {props.model.message}} {props.model.kind === "available" && ( { scrollbox = renderable }} @@ -88,7 +88,7 @@ const arrowDelta = (name: string): number | undefined => { function DexList(props: { readonly model: Extract - readonly theme: VpetDexDialogTheme + readonly theme: DigitalPetDexDialogTheme }) { const groups = createMemo(() => { const byStage = new Map() diff --git a/src/tui/vpet-history-dialog.tsx b/packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx similarity index 89% rename from src/tui/vpet-history-dialog.tsx rename to packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx index 2f17bea..14b48c8 100644 --- a/src/tui/vpet-history-dialog.tsx +++ b/packages/opencode-digital-pet/src/tui/digital-pet-history-dialog.tsx @@ -3,21 +3,21 @@ import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@ import { For } from "solid-js" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import type { TuiTheme } from "@opencode-ai/plugin/tui" -import type { HistoryViewModel } from "./history-view-model.ts" +import type { HistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" -export type VpetHistoryDialogTheme = { +export type DigitalPetHistoryDialogTheme = { readonly current: Pick } -export type VpetHistoryDialogProps = { - readonly theme: VpetHistoryDialogTheme +export type DigitalPetHistoryDialogProps = { + readonly theme: DigitalPetHistoryDialogTheme readonly model: HistoryViewModel readonly onClose: () => void } const BOLD = createTextAttributes({ bold: true }) -export function VpetHistoryDialog(props: VpetHistoryDialogProps) { +export function DigitalPetHistoryDialog(props: DigitalPetHistoryDialogProps) { const dimensions = useTerminalDimensions() const height = Math.max(10, Math.floor(dimensions().height / 2) - 1) let scrollbox: ScrollBoxRenderable | undefined @@ -38,14 +38,14 @@ export function VpetHistoryDialog(props: VpetHistoryDialogProps) { return ( - VPet History + Digital Pet History {props.model.kind === "empty" && No data available} {props.model.kind === "unavailable" && {props.model.message}} {props.model.kind === "available" && ( { scrollbox = renderable }} diff --git a/src/tui/open-in-browser.ts b/packages/opencode-digital-pet/src/tui/open-in-browser.ts similarity index 100% rename from src/tui/open-in-browser.ts rename to packages/opencode-digital-pet/src/tui/open-in-browser.ts diff --git a/src/tui/sidebar-card.tsx b/packages/opencode-digital-pet/src/tui/sidebar-card.tsx similarity index 68% rename from src/tui/sidebar-card.tsx rename to packages/opencode-digital-pet/src/tui/sidebar-card.tsx index 6a92926..e0f3406 100644 --- a/src/tui/sidebar-card.tsx +++ b/packages/opencode-digital-pet/src/tui/sidebar-card.tsx @@ -1,13 +1,19 @@ +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" /** @jsxImportSource @opentui/solid */ import { createTextAttributes, type BoxRenderable, type TextRenderable } from "@opentui/core" import { createEffect, type Accessor } from "solid-js" + +import type { SidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" +import { MONSTER_FRAME_ROWS } from "@jcendal/digital-pet-animation/constants/monster-artwork.ts" +import type { + MonsterAnimationOutput, + MonsterAnimationResult, +} from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtworkRows } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" + import { openInBrowser } from "./open-in-browser.ts" -import { mirrorMonsterFrame } from "./monster-artwork-mirror.ts" -import type { MonsterAnimationOutput, MonsterAnimationResult } from "./monster-animation.ts" -import type { SidebarCardModel } from "./sidebar-view-model.ts" -const ARTWORK_ROWS = 8 -const ARTWORK_COLUMNS = 16 +const ARTWORK_ROWS = MONSTER_FRAME_ROWS const NEXT_CHECK_PREFIX = "Next check: " const URL_LABEL = "Encyclopedia entry" const LINK_ATTRIBUTES = createTextAttributes({ underline: true }) @@ -15,43 +21,15 @@ const LINK_COLOR = "#5f87ff" const formatCount = (value: number): string => value.toLocaleString("en-US") -const positionedOutput = (animation: MonsterAnimationOutput | MonsterAnimationResult): MonsterAnimationOutput => { - if ("result" in animation) return animation - switch (animation.kind) { - case "blank": - return { kind: "blank", result: animation, offset: 0, facing: "left" } - case "frame": - return { kind: "walking", result: animation, offset: 0, facing: "left" } - case "unavailable": - return { kind: "unavailable", result: animation, offset: 0, facing: "left" } - } -} - -const artworkRows = (animation: MonsterAnimationOutput | MonsterAnimationResult, width: number): readonly string[] => { - const output = positionedOutput(animation) - switch (output.result.kind) { - case "blank": - return Array.from({ length: ARTWORK_ROWS }, () => "") - case "frame": { - const mirrored = output.facing === "right" ? mirrorMonsterFrame(output.result.frame) : undefined - if (mirrored?.kind === "invalid") return Array.from({ length: ARTWORK_ROWS }, () => "") - const content = mirrored?.frame.content ?? output.result.frame.content - const free = Math.max(width - ARTWORK_COLUMNS, 0) - const left = Math.max(0, Math.min(free, Math.floor(free / 2) + output.offset)) - return content.split("\n").map((row) => `${" ".repeat(left)}${row}`) - } - case "unavailable": { - const key = output.result.sprite === "" ? "(empty)" : output.result.sprite - const content = `Artwork unavailable: ${key}` - return Array.from({ length: ARTWORK_ROWS }, (_, index) => - index === 3 ? `${" ".repeat(Math.max(Math.floor((width - content.length) / 2), 0))}${content}` : "", - ) - } - } +const customArtworkRows = (artwork: string): readonly string[] => { + const rows = artwork.split("\n") + if (rows.length >= ARTWORK_ROWS) return rows.slice(0, ARTWORK_ROWS) + return [...rows, ...Array.from({ length: ARTWORK_ROWS - rows.length }, () => "")] } const buildNextCheckLine = (model: SidebarCardModel, width: number): string => { if (model.kind === "no_partner") return "" + if (model.evolutionBattlePending) return `${NEXT_CHECK_PREFIX}Evolution battle!` if (model.isTerminal && !model.isSetOverride) return `${NEXT_CHECK_PREFIX}None` const barWidth = Math.max(width - NEXT_CHECK_PREFIX.length - 2, 0) @@ -60,9 +38,11 @@ const buildNextCheckLine = (model: SidebarCardModel, width: number): string => { return `${NEXT_CHECK_PREFIX}[${"█".repeat(filled)}${"░".repeat(barWidth - filled)}]` } -export const VpetSidebarCard = (props: { +export const DigitalPetSidebarCard = (props: { readonly model: Accessor readonly animation: Accessor + readonly presentationState?: Accessor + readonly customArtwork?: Accessor readonly onArtworkWidthChange?: (width: number) => void readonly onUrlClick?: (url: string) => void }) => { @@ -90,7 +70,13 @@ export const VpetSidebarCard = (props: { const renderCard = (): void => { const model = props.model() - for (const [index, content] of artworkRows(props.animation(), Math.floor(artworkWidth?.width ?? 0)).entries()) { + const width = Math.floor(artworkWidth?.width ?? 0) + const battleArtwork = props.customArtwork?.() + const rows = + battleArtwork === undefined + ? renderPositionedArtworkRows(props.animation(), width) + : customArtworkRows(battleArtwork) + for (const [index, content] of rows.entries()) { updateText(artwork[index], content) } @@ -102,13 +88,18 @@ export const VpetSidebarCard = (props: { updateText(gauge, "") updateText(url, "") return - case "partner": + case "partner": { updateText(name, model.isSetOverride ? `${model.name} (set)` : model.name) updateText(stage, model.frozen ? `${model.stage} (frozen)` : model.stage) - updateText(nextCheck, buildNextCheckLine(model, nextCheckWidth?.width ?? 0)) + const phase = props.presentationState?.().phase ?? "idle" + updateText( + nextCheck, + phase === "idle" ? buildNextCheckLine(model, nextCheckWidth?.width ?? 0) : phase.toUpperCase(), + ) updateText(gauge, model.isTerminal ? "-/-" : `${formatCount(model.gauge)}/${formatCount(model.threshold)}`) updateText(url, URL_LABEL) return + } } } diff --git a/src/tui/sidebar-poll-loop.ts b/packages/opencode-digital-pet/src/tui/sidebar-poll-loop.ts similarity index 100% rename from src/tui/sidebar-poll-loop.ts rename to packages/opencode-digital-pet/src/tui/sidebar-poll-loop.ts diff --git a/tests/architecture-boundary-helpers.ts b/packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts similarity index 92% rename from tests/architecture-boundary-helpers.ts rename to packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts index 64de7e7..410bc55 100644 --- a/tests/architecture-boundary-helpers.ts +++ b/packages/opencode-digital-pet/tests/architecture-boundary-helpers.ts @@ -25,6 +25,7 @@ export const FORBIDDEN_APPLICATION_IMPORTS = [ "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "/adapters/opencode/", "/tui/", "/commands/", @@ -32,16 +33,17 @@ export const FORBIDDEN_APPLICATION_IMPORTS = [ "@opentui/", "solid-js", "bun:sqlite", - "sqlite-vpet-schema", + "sqlite-digital-pet-schema", "bun-sqlite-driver", ] as const export const FORBIDDEN_TUI_PRESENTATION_IMPORTS = [ - "/config/global-vpet-settings", + "/config/global-digital-pet-settings", "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "bun:sqlite", - "sqlite-vpet-schema", + "sqlite-digital-pet-schema", "bun-sqlite-driver", "sqlite-migrations", ] as const @@ -51,16 +53,17 @@ export const FORBIDDEN_TUI_ANIMATION_IMPORTS = [ "/persistence/", "/runtime/", "/adapters/sqlite/", + "@jcendal/digital-pet-core/adapters/", "bun:sqlite", ] as const export const FORBIDDEN_ADAPTER_IMPORTS = ["/tui/", "../tui.tsx", "../index.ts", "/persistence/", "/runtime/"] as const export const FORBIDDEN_LEGACY_IMPORTS = ["/persistence/", "/runtime/", "/tui/sidebar-state.ts"] as const export const FORBIDDEN_SERVER_HOOK_IMPORTS = ["/adapters/sqlite/", "/application/ports/resource-lifecycle"] as const -export const TUI_PRIVATE_ANIMATION_MODULES = [ - "monster-action-policy.ts", - "monster-animation.ts", - "monster-artwork-mirror.ts", - "monster-walking-policy.ts", +export const DIGITAL_PET_ANIMATION_IDLE_MODULES = [ + "idle/monster-action-policy.ts", + "idle/monster-animation.ts", + "idle/monster-artwork-mirror.ts", + "idle/monster-walking-policy.ts", ] as const const CORE_PARTNER_FIELDS = [ diff --git a/tests/architecture-boundary.test.ts b/packages/opencode-digital-pet/tests/architecture-boundary.test.ts similarity index 75% rename from tests/architecture-boundary.test.ts rename to packages/opencode-digital-pet/tests/architecture-boundary.test.ts index 94d14b8..cef1efc 100644 --- a/tests/architecture-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/architecture-boundary.test.ts @@ -17,7 +17,7 @@ const assertRemoved = async (path: string): Promise => expect(await Bun.fi describe("application architecture boundary", () => { test("Given an application source importing a SQLite adapter When scanned Then its path and forbidden import are reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { await writeFile(path, 'import { openWritableDatabase } from "../adapters/sqlite/bun-sqlite-driver.ts"\n') @@ -28,18 +28,23 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given an application source importing a SQLite write-store type When scanned Then its type-only import is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { - await writeFile(path, 'import type { SqliteVpetWriteStore } from "../adapters/sqlite/sqlite-vpet-types.ts"\n') - expect(await scanApplicationImports(directory)).toEqual([`${path}: ../adapters/sqlite/sqlite-vpet-types.ts`]) + await writeFile( + path, + 'import type { SqliteDigitalPetWriteStore } from "../adapters/sqlite/sqlite-digital-pet-types.ts"\n', + ) + expect(await scanApplicationImports(directory)).toEqual([ + `${path}: ../adapters/sqlite/sqlite-digital-pet-types.ts`, + ]) } finally { await rm(directory, { recursive: true, force: true }) } await assertRemoved(path) }) test("Given application fixtures importing filesystem, adapters, and frameworks When scanned Then each forbidden boundary import is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-application-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-application-architecture-")) const fixtures = [ ["filesystem.ts", 'import { readFile } from "node:fs"\n'], ["framework.ts", 'import type { Plugin } from "@opencode-ai/plugin"\n'], @@ -58,7 +63,7 @@ describe("application architecture boundary", () => { await assertRemoved(directory) }) test("Given a fixture redeclaring Partner core state When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-partner-contract-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-partner-contract-")) const path = join(directory, "duplicate.ts") try { await writeFile( @@ -72,7 +77,7 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given an application port declaring technical resource shutdown When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-lifecycle-port-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-lifecycle-port-")) const path = join(directory, "resource-lifecycle.ts") try { await writeFile(path, "export type ResourceLifecycle = { close: () => Promise }\n") @@ -83,11 +88,13 @@ describe("application architecture boundary", () => { await assertRemoved(path) }) test("Given a TUI presentation source importing a SQLite schema When scanned Then it is rejected", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-architecture-")) const path = join(directory, "forbidden.ts") try { - await writeFile(path, 'import { ACTIVE_PARTNER_SELECT } from "../adapters/sqlite/sqlite-vpet-schema.ts"\n') - expect(await scanTuiPresentationImports(directory)).toEqual([`${path}: ../adapters/sqlite/sqlite-vpet-schema.ts`]) + await writeFile(path, 'import { ACTIVE_PARTNER_SELECT } from "../adapters/sqlite/sqlite-digital-pet-schema.ts"\n') + expect(await scanTuiPresentationImports(directory)).toEqual([ + `${path}: ../adapters/sqlite/sqlite-digital-pet-schema.ts`, + ]) } finally { await rm(directory, { recursive: true, force: true }) } @@ -98,9 +105,17 @@ describe("application architecture boundary", () => { findForbiddenImports("malformed.ts", 'import { broken\nimport "bun:sqlite"\n', FORBIDDEN_APPLICATION_IMPORTS), ).toEqual(["malformed.ts: bun:sqlite"])) test("Given the application source tree When scanned Then it contains no forbidden imports", async () => - expect(await scanApplicationImports(join(PROJECT_ROOT, "src", "application"))).toEqual([])) + expect(await scanApplicationImports(join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application"))).toEqual( + [], + )) test("Given the application models When scanned Then they do not redeclare Partner core state", async () => - expect(await scanDuplicateCorePartnerDeclarations(join(PROJECT_ROOT, "src", "application", "models"))).toEqual([])) + expect( + await scanDuplicateCorePartnerDeclarations( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "models"), + ), + ).toEqual([])) test("Given the application port tree When scanned Then it has no technical lifecycle contract", async () => - expect(await scanTechnicalLifecyclePorts(join(PROJECT_ROOT, "src", "application", "ports"))).toEqual([])) + expect( + await scanTechnicalLifecyclePorts(join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "ports")), + ).toEqual([])) }) diff --git a/tests/catalog-evolution.test.ts b/packages/opencode-digital-pet/tests/catalog-evolution.test.ts similarity index 67% rename from tests/catalog-evolution.test.ts rename to packages/opencode-digital-pet/tests/catalog-evolution.test.ts index af386a9..e07c8cf 100644 --- a/tests/catalog-evolution.test.ts +++ b/packages/opencode-digital-pet/tests/catalog-evolution.test.ts @@ -1,11 +1,21 @@ import { describe, expect, test } from "bun:test" -import { loadDigimonCatalog, parseDigimonCatalog, type DigimonNode } from "../src/data/catalog.ts" -import { DIGIMON_DATA } from "../src/data/digimon-data.ts" -import type { DigimonNode as DomainDigimonNode } from "../src/domain/digimon-node.ts" -import { applyTokenProgress, STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import { DIGIMON_STAGES, isDigimonStage } from "../src/domain/stage.ts" +import { loadDigimonCatalog, parseDigimonCatalog, type DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" +import type { DigimonNode as DomainDigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { + applyTokenProgress, + resolveEvolutionBattle, + STAGE_GAUGE_THRESHOLDS, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import { DIGIMON_STAGES, isDigimonStage } from "@jcendal/digital-pet-core/domain/stage.ts" + +const noBattle = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const const controlledCurrent: DigimonNode = { id: "controlled-current", @@ -27,6 +37,19 @@ const controlledTarget: DigimonNode = { url: "https://example.test/controlled-target", } +const controlledOpponent: DigimonNode = { + id: "controlled-opponent", + nameEn: "Controlled Opponent", + nameJp: "Controlled Opponent", + nextEvolutions: [], + sprite: "controlled-opponent.png", + stage: 0, + url: "https://example.test/controlled-opponent", +} + +const controlledCatalogNodes = [controlledCurrent, controlledTarget, controlledOpponent] +const controlledLookup = new Map(controlledCatalogNodes.map((node) => [node.id, node])) + describe("catalog and evolution", () => { test("Given domain contracts When catalog data is parsed Then normalized nodes use the domain stage and node shapes", () => { const node: DomainDigimonNode = controlledCurrent @@ -36,6 +59,8 @@ describe("catalog and evolution", () => { currentNodeId: node.id, gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, } @@ -43,12 +68,14 @@ describe("catalog and evolution", () => { currentNodeId: node.id, gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } const thresholds: StageThresholds = STAGE_GAUGE_THRESHOLDS expect(DIGIMON_STAGES.every(isDigimonStage)).toBeTrue() expect(partner.currentNodeId).toBe(progression.currentNodeId) - expect(thresholds[node.stage]).toBe(500_000) + expect(thresholds[node.stage]).toBe(5_000_000) }) test("Given a raw catalog row with a reference URL When parsing Then the normalized node preserves the URL and evolution references are immutable", () => { @@ -118,81 +145,103 @@ describe("catalog and evolution", () => { expect(() => parseDigimonCatalog([root])).toThrow(/next_evolutions/i) }) - test("Given a threshold crossing on a same-stage edge When token progress applies Then it resets after exactly one selected evolution", async () => { + test("Given a threshold crossing on a same-stage edge When token progress applies Then it opens a pending evolution battle", async () => { const catalog = await loadDigimonCatalog() const andromon = catalog.byId.get("5-003") if (andromon === undefined) throw new Error("Expected Andromon in catalog") - const evolved = applyTokenProgress( - { current: andromon, gauge: 12_499_999, isTerminal: false }, + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, 1, () => 0.5, catalog.byId, + catalog.nodes, STAGE_GAUGE_THRESHOLDS, ) + expect(pending.current.id).toBe("5-003") + expect(pending.pendingEvolutionTargetId).toBe("5-033") + expect(pending.battleOpponentNodeId).not.toBe("5-003") + expect(pending.gauge).toBe(STAGE_GAUGE_THRESHOLDS[5]) + + const evolved = resolveEvolutionBattle(pending, true, catalog.byId) expect(evolved.current.id).toBe("5-033") expect(evolved.gauge).toBe(0) + expect(evolved.pendingEvolutionTargetId).toBeNull() }) - test("Given a controlled immutable lookup When token progress reaches a threshold Then it selects the lookup target and marks terminal state", () => { - const lookup = new Map([[controlledTarget.id, controlledTarget]]) - + test("Given a Digitama threshold crossing When token progress applies Then it evolves immediately without a battle", () => { const evolved = applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 0, - lookup, + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ) - expect(evolved).toEqual({ current: controlledTarget, gauge: 0, isTerminal: true }) + expect(evolved).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) test("Given a controlled lookup missing the selected target When token progress reaches a threshold Then it retains the existing catalog error", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 0, - new Map(), + new Map([[controlledCurrent.id, controlledCurrent]]), + [controlledCurrent], STAGE_GAUGE_THRESHOLDS, ), ).toThrow("Evolution target controlled-target is missing from the catalog") }) - test("Given a custom child threshold When token progress reaches it Then evolution crosses at the supplied policy boundary", () => { + test("Given a custom Digitama threshold When token progress reaches it Then evolution crosses at the supplied policy boundary", () => { const thresholds: StageThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, }) - const evolved = applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, - 1, - () => 0, - new Map([[controlledTarget.id, controlledTarget]]), - thresholds, - ) - - expect(evolved).toEqual({ current: controlledTarget, gauge: 0, isTerminal: true }) + expect( + applyTokenProgress( + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, + 1, + () => 0, + controlledLookup, + controlledCatalogNodes, + thresholds, + ), + ).toEqual({ + current: controlledTarget, + gauge: 0, + isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) test("Given an invalid selector When token progress reaches a threshold Then the existing selector error is retained", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 499_999, isTerminal: false }, + { current: controlledCurrent, gauge: 4_999_999, isTerminal: false, ...noBattle }, 1, () => 1, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ), ).toThrow("Evolution selector must return a finite number in [0, 1), received 1") }) test("Given a terminal partner When token progress applies Then it preserves the existing terminal state", () => { - const state = { current: controlledTarget, gauge: 0, isTerminal: true } as const + const state = { current: controlledTarget, gauge: 0, isTerminal: true, ...noBattle } as const expect( applyTokenProgress( @@ -201,7 +250,8 @@ describe("catalog and evolution", () => { () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, STAGE_GAUGE_THRESHOLDS, ), ).toBe(state) @@ -215,24 +265,26 @@ describe("catalog and evolution", () => { expect( applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, thresholds, ), - ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false }) + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) }) test("Given an incomplete untyped threshold policy When token progress applies Then it rejects the policy before evolution", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, {}, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -244,10 +296,11 @@ describe("catalog and evolution", () => { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, thresholds, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -263,10 +316,11 @@ describe("catalog and evolution", () => { for (const thresholds of policies) { expect(() => applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => 0, - new Map([[controlledTarget.id, controlledTarget]]), + controlledLookup, + controlledCatalogNodes, thresholds, ), ).toThrow("Evolution thresholds must be a complete frozen policy of positive finite numbers") @@ -279,15 +333,40 @@ describe("catalog and evolution", () => { expect(Reflect.set(thresholds, 0, 1)).toBeFalse() expect( applyTokenProgress( - { current: controlledCurrent, gauge: 0, isTerminal: false }, + { current: controlledCurrent, gauge: 0, isTerminal: false, ...noBattle }, 1, () => { throw new Error("selector must not run") }, - new Map(), + controlledLookup, + controlledCatalogNodes, thresholds, ), - ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false }) + ).toEqual({ current: controlledCurrent, gauge: 1, isTerminal: false, ...noBattle }) + }) + + test("Given a lost evolution battle When resolving Then gauge resets for the current stage", async () => { + const catalog = await loadDigimonCatalog() + const andromon = catalog.byId.get("5-003") + + if (andromon === undefined) throw new Error("Expected Andromon in catalog") + + const pending = applyTokenProgress( + { current: andromon, gauge: 124_999_999, isTerminal: false, ...noBattle }, + 1, + () => 0.5, + catalog.byId, + catalog.nodes, + STAGE_GAUGE_THRESHOLDS, + ) + + expect(resolveEvolutionBattle(pending, false, catalog.byId)).toEqual({ + current: andromon, + gauge: 0, + isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }) }) }) diff --git a/tests/cli-config.test.ts b/packages/opencode-digital-pet/tests/cli-config.test.ts similarity index 93% rename from tests/cli-config.test.ts rename to packages/opencode-digital-pet/tests/cli-config.test.ts index 3232207..a0ecfcb 100644 --- a/tests/cli-config.test.ts +++ b/packages/opencode-digital-pet/tests/cli-config.test.ts @@ -12,7 +12,7 @@ type FakeOpenCode = Readonly<{ readonly callsPath: string }> -const createRoot = async (): Promise => mkdtemp(join(tmpdir(), "opencode-vpet-cli-")) +const createRoot = async (): Promise => mkdtemp(join(tmpdir(), "opencode-digital-pet-cli-")) const createFakeOpenCode = async (root: string): Promise => { const binDirectory = join(root, "bin") @@ -41,7 +41,7 @@ const runCli = (arguments_: readonly string[], root: string, fake: FakeOpenCode, OPENCODE_CONFIG_DIR: join(root, "opencode"), OPENCODE_FAKE_CALLS: fake.callsPath, OPENCODE_FAKE_EXIT: String(exitCode), - PATH: `${fake.binDirectory}:${process.env.PATH ?? ""}`, + PATH: `${fake.binDirectory}:${process.env["PATH"] ?? ""}`, }, stdout: "pipe", stderr: "pipe", @@ -62,7 +62,7 @@ describe("OpenCode CLI installer", () => { try { const fake = await createFakeOpenCode(root) const manifest = JSON.parse(await Bun.file(new URL("../package.json", import.meta.url)).text()) - const expected = ["plugin", `@sbugallo/opencode-vpet@${manifest.version}`, "--global", "--force"] + const expected = ["plugin", `@jcendal/opencode-digital-pet@${manifest.version}`, "--global", "--force"] const init = runCli(["init"], root, fake) const update = runCli(["update"], root, fake) @@ -86,7 +86,7 @@ describe("OpenCode CLI installer", () => { const initDryRun = runCli(["init", "--dry-run"], root, fake) const updateDryRun = runCli(["update", "--dry-run"], root, fake) const manifest = JSON.parse(await Bun.file(new URL("../package.json", import.meta.url)).text()) - const plannedCommand = `opencode plugin @sbugallo/opencode-vpet@${manifest.version} --global --force\n` + const plannedCommand = `opencode plugin @jcendal/opencode-digital-pet@${manifest.version} --global --force\n` expect(help.exitCode, new TextDecoder().decode(help.stderr)).toBe(0) expect(invalid.exitCode).toBe(2) diff --git a/tests/config-loader.test.ts b/packages/opencode-digital-pet/tests/config-loader.test.ts similarity index 63% rename from tests/config-loader.test.ts rename to packages/opencode-digital-pet/tests/config-loader.test.ts index c28ff6d..6aad668 100644 --- a/tests/config-loader.test.ts +++ b/packages/opencode-digital-pet/tests/config-loader.test.ts @@ -4,21 +4,21 @@ import { tmpdir } from "node:os" import { join, posix, win32 } from "node:path" import { - loadGlobalVpetSettings, - loadGlobalVpetSettingsSync, - resolveGlobalVpetConfigPath, -} from "../src/config/global-vpet-settings.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" + loadGlobalDigitalPetSettings, + loadGlobalDigitalPetSettingsSync, + resolveGlobalDigitalPetConfigPath, +} from "../src/config/global-digital-pet-settings.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-config-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-config-")) tempRoots.push(root) return root } -const resolveConfigPath = (root: string): string => join(root, ".config", "opencode-vpet.json") +const resolveConfigPath = (root: string): string => join(root, ".config", "opencode-digital-pet.json") const createConfigDirectory = async (root: string): Promise => { const directory = join(root, ".config") @@ -30,16 +30,27 @@ afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe("global VPet configuration paths", () => { +describe("global Digital Pet configuration paths", () => { + test("existing legacy configuration remains readable until a new configuration is created", async () => { + const root = await createTempRoot() + const directory = await createConfigDirectory(root) + const legacyPath = join(directory, "opencode-vpet.json") + await writeFile(legacyPath, JSON.stringify({ language: "jp" })) + expect(resolveGlobalDigitalPetConfigPath({ home: root, platform: "linux" })).toBe(legacyPath) + expect(loadGlobalDigitalPetSettingsSync({ home: root, platform: "linux" }).language).toBe("jp") + await writeFile(resolveConfigPath(root), JSON.stringify({ language: "en" })) + expect(resolveGlobalDigitalPetConfigPath({ home: root, platform: "linux" })).toBe(resolveConfigPath(root)) + }) + test.each([ - ["Linux", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-vpet.json"], - ["macOS", "darwin", "/Users/na me/雪", "/Users/na me/雪/.config/opencode-vpet.json"], - ["WSL", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-vpet.json"], + ["Linux", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-digital-pet.json"], + ["macOS", "darwin", "/Users/na me/雪", "/Users/na me/雪/.config/opencode-digital-pet.json"], + ["WSL", "linux", "/home/na me/雪", "/home/na me/雪/.config/opencode-digital-pet.json"], ] as const)( - "Given %s with OpenCode and XDG variables When resolving with path.posix Then it uses the independent VPet path", + "Given %s with OpenCode and XDG variables When resolving with path.posix Then it uses the independent Digital Pet path", (_name, platform, home, expected) => { expect( - resolveGlobalVpetConfigPath({ + resolveGlobalDigitalPetConfigPath({ env: { APPDATA: "/app-data/雪", OPENCODE_CONFIG_DIR: "/open-code-config/雪", @@ -58,39 +69,39 @@ describe("global VPet configuration paths", () => { "APPDATA", { APPDATA: "C:\\App Data\\雪", OPENCODE_CONFIG_DIR: "C:\\OpenCode\\雪", XDG_CONFIG_HOME: "C:\\XDG\\雪" }, "C:\\Users\\ignored", - "C:\\App Data\\雪\\opencode-vpet.json", + "C:\\App Data\\雪\\opencode-digital-pet.json", ], [ "home fallback", { OPENCODE_CONFIG_DIR: "C:\\OpenCode\\雪", XDG_CONFIG_HOME: "C:\\XDG\\雪" }, "C:\\Users\\na me\\雪", - "C:\\Users\\na me\\雪\\AppData\\Roaming\\opencode-vpet.json", + "C:\\Users\\na me\\雪\\AppData\\Roaming\\opencode-digital-pet.json", ], ] as const)( - "Given native Windows with %s When resolving with path.win32 Then it uses the independent VPet path", + "Given native Windows with %s When resolving with path.win32 Then it uses the independent Digital Pet path", (_name, env, home, expected) => { - expect(resolveGlobalVpetConfigPath({ env, home, pathApi: win32, platform: "win32" })).toBe(expected) + expect(resolveGlobalDigitalPetConfigPath({ env, home, pathApi: win32, platform: "win32" })).toBe(expected) }, ) test.each([ - ["missing", async (_root: string) => undefined], + ["missing", async (_root: string): Promise => undefined], [ "unreadable directory", - async (root: string) => { + async (root: string): Promise => { await mkdir(resolveConfigPath(root), { recursive: true }) }, ], [ "malformed", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), "{") }, ], [ "scalar root", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), '"jp"') }, @@ -102,18 +113,18 @@ describe("global VPet configuration paths", () => { await createPath(root) const entriesBefore = await readdir(root) - const settings = loadGlobalVpetSettingsSync({ + const settings = loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix, }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) expect(await readdir(root)).toEqual(entriesBefore) }, ) }) -describe("global VPet configuration loading", () => { +describe("global Digital Pet configuration loading", () => { test("Given a valid file with mixed settings When loading asynchronously Then valid siblings survive in a frozen snapshot", async () => { const root = await createTempRoot() await createConfigDirectory(root) @@ -122,16 +133,16 @@ describe("global VPet configuration loading", () => { JSON.stringify({ language: "en", notifications: false, stageThresholds: { child: 777, egg: 0 } }), ) - const settings = await loadGlobalVpetSettings({ + const settings = await loadGlobalDigitalPetSettings({ home: root, pathApi: posix, }) expect(settings).toEqual({ - ...DEFAULT_VPET_SETTINGS, + ...DEFAULT_DIGITAL_PET_SETTINGS, language: "en", notifications: false, - stageThresholds: { ...DEFAULT_VPET_SETTINGS.stageThresholds, child: 777 }, + stageThresholds: { ...DEFAULT_DIGITAL_PET_SETTINGS.stageThresholds, child: 777 }, }) expect(Object.isFrozen(settings)).toBeTrue() expect(Object.isFrozen(settings.stageThresholds)).toBeTrue() @@ -142,7 +153,7 @@ describe("global VPet configuration loading", () => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), JSON.stringify({ stageThresholds: { adult: 999 } })) - const settings = loadGlobalVpetSettingsSync({ home: root, pathApi: posix }) + const settings = loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix }) expect(settings.stageThresholds.adult).toBe(999) expect(Object.isFrozen(settings)).toBeTrue() @@ -161,29 +172,29 @@ describe("global VPet configuration loading", () => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), JSON.stringify(config)) - expect((await loadGlobalVpetSettings({ home: root, pathApi: posix })).notifications).toBeTrue() - expect(loadGlobalVpetSettingsSync({ home: root, pathApi: posix }).notifications).toBeTrue() + expect((await loadGlobalDigitalPetSettings({ home: root, pathApi: posix })).notifications).toBeTrue() + expect(loadGlobalDigitalPetSettingsSync({ home: root, pathApi: posix }).notifications).toBeTrue() }, ) test.each([ - ["missing", async (_root: string) => undefined], + ["missing", async (_root: string): Promise => undefined], [ "unreadable directory", - async (root: string) => { + async (root: string): Promise => { await mkdir(resolveConfigPath(root), { recursive: true }) }, ], [ "malformed", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), "{") }, ], [ "scalar root", - async (root: string) => { + async (root: string): Promise => { await createConfigDirectory(root) await writeFile(resolveConfigPath(root), '"jp"') }, @@ -195,12 +206,12 @@ describe("global VPet configuration loading", () => { await createPath(root) const entriesBefore = await readdir(root) - const settings = await loadGlobalVpetSettings({ + const settings = await loadGlobalDigitalPetSettings({ home: root, pathApi: posix, }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) expect(await readdir(root)).toEqual(entriesBefore) }, ) diff --git a/tests/config-settings.test.ts b/packages/opencode-digital-pet/tests/config-settings.test.ts similarity index 79% rename from tests/config-settings.test.ts rename to packages/opencode-digital-pet/tests/config-settings.test.ts index 1a78108..4e8b4ae 100644 --- a/tests/config-settings.test.ts +++ b/packages/opencode-digital-pet/tests/config-settings.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test" -import { normalizeVpetSettings } from "../src/config/normalize.ts" -import { STAGE_THRESHOLD_KEYS, VPET_LANGUAGES, type ResolvedVpetSettings } from "../src/config/types.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import { + STAGE_THRESHOLD_KEYS, + DIGITAL_PET_LANGUAGES, + type ResolvedDigitalPetSettings, +} from "@jcendal/digital-pet-core/config/types.ts" const expectedDefaults = { language: "jp", @@ -29,18 +33,18 @@ const expectedDefaults = { }, }, stageThresholds: { - egg: 500_000, - babyI: 1_000_000, - babyII: 2_000_000, - child: 4_000_000, - adult: 7_500_000, - perfect: 12_500_000, - ultimate: 20_000_000, - superUltimate: 30_000_000, + egg: 5_000_000, + babyI: 10_000_000, + babyII: 20_000_000, + child: 40_000_000, + adult: 75_000_000, + perfect: 125_000_000, + ultimate: 200_000_000, + superUltimate: 300_000_000, }, -} as const satisfies ResolvedVpetSettings +} as const satisfies ResolvedDigitalPetSettings -const happyCases: readonly [string, unknown, ResolvedVpetSettings][] = [ +const happyCases: readonly [string, unknown, ResolvedDigitalPetSettings][] = [ ["empty object", {}, expectedDefaults], [ "complete English config", @@ -84,7 +88,7 @@ const happyCases: readonly [string, unknown, ResolvedVpetSettings][] = [ ], ] -const fallbackCases: readonly [string, unknown, ResolvedVpetSettings][] = [ +const fallbackCases: readonly [string, unknown, ResolvedDigitalPetSettings][] = [ ["null root", null, expectedDefaults], ["array root", [], expectedDefaults], ["scalar root", "jp", expectedDefaults], @@ -113,19 +117,19 @@ const fallbackCases: readonly [string, unknown, ResolvedVpetSettings][] = [ ["wrong threshold container", { language: true, stageThresholds: [11, 22] }, expectedDefaults], ] -describe("vpet settings normalization", () => { +describe("digital-pet settings normalization", () => { test.each(happyCases)( "Given %s When normalizing Then it returns the exact resolved settings", (_name, input, expected) => { - expect(normalizeVpetSettings(input)).toEqual(expected) + expect(normalizeDigitalPetSettings(input)).toEqual(expected) }, ) test.each(fallbackCases)( "Given %s When normalizing Then it independently falls back without throwing", (_name, input, expected) => { - expect(() => normalizeVpetSettings(input)).not.toThrow() - expect(normalizeVpetSettings(input)).toEqual(expected) + expect(() => normalizeDigitalPetSettings(input)).not.toThrow() + expect(normalizeDigitalPetSettings(input)).toEqual(expected) }, ) @@ -148,12 +152,12 @@ describe("vpet settings normalization", () => { ] as const)( "Given %s notifications When normalizing Then only literal booleans survive", (_name, input, expected) => { - expect(normalizeVpetSettings(input).notifications).toBe(expected) + expect(normalizeDigitalPetSettings(input).notifications).toBe(expected) }, ) test("Given invalid notifications with valid language and threshold siblings When normalizing Then sibling values survive", () => { - const settings = normalizeVpetSettings({ + const settings = normalizeDigitalPetSettings({ language: "en", notifications: "false", stageThresholds: { child: 777 }, @@ -165,7 +169,7 @@ describe("vpet settings normalization", () => { }) test("Given resolved settings When mutation is attempted Then every resolved layer is frozen", () => { - const settings = normalizeVpetSettings({}) + const settings = normalizeDigitalPetSettings({}) expect(Object.isFrozen(settings)).toBeTrue() expect(Object.isFrozen(settings.stageThresholds)).toBeTrue() @@ -182,7 +186,7 @@ describe("vpet settings normalization", () => { throw new Error("root") }, }), - 4_000_000, + 40_000_000, ], [ "language getter", @@ -191,7 +195,7 @@ describe("vpet settings normalization", () => { throw new Error("language") }, }), - 4_000_000, + 40_000_000, ], [ "stageThresholds getter", @@ -200,7 +204,7 @@ describe("vpet settings normalization", () => { throw new Error("thresholds") }, }), - 4_000_000, + 40_000_000, ], [ "threshold getter with valid sibling", @@ -228,7 +232,7 @@ describe("vpet settings normalization", () => { }, }, ), - 4_000_000, + 40_000_000, ], [ "threshold Proxy get trap with valid sibling", @@ -252,17 +256,17 @@ describe("vpet settings normalization", () => { ])( "Given a throwing %s When normalizing Then it falls back without losing valid siblings", (_name, input, expectedChild) => { - expect(() => normalizeVpetSettings(input)).not.toThrow() - expect(normalizeVpetSettings(input).stageThresholds.child).toBe(expectedChild) + expect(() => normalizeDigitalPetSettings(input)).not.toThrow() + expect(normalizeDigitalPetSettings(input).stageThresholds.child).toBe(expectedChild) }, ) test("Given public config collections When push mutation is attempted Then both exports remain frozen", () => { - expect(Object.isFrozen(VPET_LANGUAGES)).toBeTrue() + expect(Object.isFrozen(DIGITAL_PET_LANGUAGES)).toBeTrue() expect(Object.isFrozen(STAGE_THRESHOLD_KEYS)).toBeTrue() - expect(() => Reflect.apply(Array.prototype.push, VPET_LANGUAGES, ["en"])).toThrow() + expect(() => Reflect.apply(Array.prototype.push, DIGITAL_PET_LANGUAGES, ["en"])).toThrow() expect(() => Reflect.apply(Array.prototype.push, STAGE_THRESHOLD_KEYS, ["egg"])).toThrow() - expect(VPET_LANGUAGES).toEqual(["en", "jp"]) + expect(DIGITAL_PET_LANGUAGES).toEqual(["en", "jp"]) expect(STAGE_THRESHOLD_KEYS).toEqual([ "egg", "babyI", diff --git a/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts b/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts new file mode 100644 index 0000000..ab4ec6e --- /dev/null +++ b/packages/opencode-digital-pet/tests/dev/attach-dev-tools.test.ts @@ -0,0 +1,112 @@ +import { beforeAll, describe, expect, test } from "bun:test" +import { existsSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { join } from "node:path" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { createTui } from "../../src/tui.tsx" +import { OPENCODE_DEV_ACTIONS } from "../../src/dev/catalog.ts" +import { partnerInputs, settle, TuiCompositionHarness } from "../tui-composition-fixture.ts" + +const PACKAGE_ROOT = fileURLToPath(new URL("../../", import.meta.url)) +const DIST_DEV_TOOLS = join(PACKAGE_ROOT, "dist/dev/attach-dev-tools.js") +const DIST_TUI = new URL("../../dist/tui.js", import.meta.url).href + +const DEV_COMMAND_IDS = OPENCODE_DEV_ACTIONS.map((action) => action.id) + +const withDevEnv = async (run: () => Promise): Promise => { + const previous = process.env["OPENCODE_DIGITAL_PET_DEV"] + process.env["OPENCODE_DIGITAL_PET_DEV"] = "1" + try { + await run() + } finally { + if (previous === undefined) delete process.env["OPENCODE_DIGITAL_PET_DEV"] + else process.env["OPENCODE_DIGITAL_PET_DEV"] = previous + } +} + +const withoutDevEnv = async (run: () => Promise): Promise => { + const previous = process.env["OPENCODE_DIGITAL_PET_DEV"] + delete process.env["OPENCODE_DIGITAL_PET_DEV"] + try { + await run() + } finally { + if (previous === undefined) delete process.env["OPENCODE_DIGITAL_PET_DEV"] + else process.env["OPENCODE_DIGITAL_PET_DEV"] = previous + } +} + +const expectDevLayer = (harness: TuiCompositionHarness): void => { + const devLayer = harness.layers.find((layer) => layer["namespace"] === "opencode-digital-pet-dev") + expect(devLayer).toBeDefined() + expect(devLayer?.commands?.map((command) => command["name"])).toEqual(DEV_COMMAND_IDS) +} + +describe("opencode-digital-pet dev attach-dev-tools", () => { + test("Given the dev catalog When inspected Then it exposes five palette commands", () => { + expect(OPENCODE_DEV_ACTIONS.map((action) => action.id)).toEqual([ + "feed", + "activity", + "evolution_reveal", + "evolution_battle", + "defeat", + ]) + }) + + test("Given OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer registers", async () => { + await withDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expectDevLayer(harness) + await harness.dispose?.() + }) + }) + + test("Given OPENCODE_DIGITAL_PET_DEV When the feed command runs Then it publishes a feed animation", async () => { + await withDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.start(() => partnerInputs("agumon")) + const observedBefore = harness.observed.length + harness.invokeCommand("feed") + harness.scheduler.tickVisual() + expect(harness.observed.length).toBeGreaterThan(observedBefore) + await harness.dispose?.() + }) + }) + + test("Given no OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer is omitted", async () => { + await withoutDevEnv(async () => { + const harness = new TuiCompositionHarness() + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expect(harness.layers.some((layer) => layer["namespace"] === "opencode-digital-pet-dev")).toBe(false) + await harness.dispose?.() + }) + }) +}) + +describe("opencode-digital-pet dev attach-dev-tools dist", () => { + beforeAll(() => { + if (!existsSync(DIST_DEV_TOOLS)) { + const build = Bun.spawnSync(["bun", "run", "build"], { cwd: PACKAGE_ROOT }) + if (build.exitCode !== 0) { + throw new Error(new TextDecoder().decode(build.stderr)) + } + } + }, 60_000) + + test("Given built dist When the dev attach module is imported Then attachDevTools is exported", async () => { + const module = await import(new URL("../../dist/dev/attach-dev-tools.js", import.meta.url).href) + expect(typeof module.attachDevTools).toBe("function") + }) + + test("Given built dist and OPENCODE_DIGITAL_PET_DEV When createTui composes Then the dev command layer registers", async () => { + await withDevEnv(async () => { + const { createTui: createBuiltTui } = await import(DIST_TUI) + const harness = new TuiCompositionHarness() + await harness.startWith(createBuiltTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS)) + expectDevLayer(harness) + await harness.dispose?.() + await settle() + }) + }) +}) diff --git a/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts b/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts new file mode 100644 index 0000000..4ea158a --- /dev/null +++ b/packages/opencode-digital-pet/tests/dev/setup-scenario.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" + +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { recordUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { resolveEvolutionBattleForPartner } from "@jcendal/digital-pet-core/application/use-cases/resolve-evolution-battle.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" +import type { StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { DIGIMON_STAGES } from "@jcendal/digital-pet-core/domain/stage.ts" + +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { setupBattlePending } from "../../src/dev/setup-scenario.ts" + +const DEV_INSTANT_BATTLE_THRESHOLDS: StageThresholds = Object.freeze( + Object.fromEntries(DIGIMON_STAGES.map((stage) => [stage, 1])) as StageThresholds, +) + +const applyDevUsage = ( + repository: Awaited>, + receiptKey: string, +): void => { + recordUsage({ + usage: { + receiptKey, + eventId: `dev:${receiptKey}`, + tokenDelta: 1, + cost: null, + createdAt: new Date().toISOString(), + }, + ledger: repository, + digimonById: DIGIMON_CATALOG.byId, + catalogNodes: DIGIMON_CATALOG.nodes, + selector: () => 0, + thresholds: DEV_INSTANT_BATTLE_THRESHOLDS, + }) +} + +const advancePartnerToStage = ( + repository: Awaited>, + targetStage: number, +): void => { + for (let step = 0; step < 12; step += 1) { + const partner = repository.getActivePartner() + const current = partner === null ? undefined : DIGIMON_CATALOG.byId.get(partner.currentNodeId) + if (current !== undefined && current.stage >= targetStage) return + + applyDevUsage(repository, `dev-progress-${step}`) + const pending = repository.getActivePartner() + if (pending?.pendingEvolutionTargetId !== null && pending?.battleOpponentNodeId !== null) { + resolveEvolutionBattleForPartner(repository, true, DIGIMON_CATALOG.byId, `dev-battle-${step}-${Date.now()}`) + } + } +} + +describe("opencode-digital-pet dev setup-scenario", () => { + test("Given a perfect-stage partner When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-opencode-digital-pet-dev-perfect-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + spawnPartner(repository, new Date().toISOString()) + advancePartnerToStage(repository, 4) + + const current = DIGIMON_CATALOG.byId.get(repository.getActivePartner()?.currentNodeId ?? "") + expect(current?.stage).toBeGreaterThanOrEqual(4) + + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + test("Given an empty database When battle setup runs Then a pending evolution battle opens", async () => { + const root = await mkdtemp(join(process.cwd(), ".tmp-opencode-digital-pet-dev-")) + try { + const repository = await createSqliteDigitalPetRepository({ appDataRoot: root }) + try { + setupBattlePending(repository) + const snapshot = repository.getSidebarSnapshot() + expect(snapshot?.pendingEvolutionTargetId).not.toBeNull() + expect(snapshot?.battleOpponentNodeId).not.toBeNull() + } finally { + repository.close() + } + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/dex-view-model.test.ts b/packages/opencode-digital-pet/tests/dex-view-model.test.ts similarity index 85% rename from tests/dex-view-model.test.ts rename to packages/opencode-digital-pet/tests/dex-view-model.test.ts index a6e5a94..a7aaae6 100644 --- a/tests/dex-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/dex-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { DIGIMON_CATALOG } from "../src/data/catalog.ts" -import { buildDexViewModel } from "../src/tui/dex-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" const catalog = { nodes: [ @@ -62,7 +62,7 @@ describe("Dex view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -77,14 +77,14 @@ describe("Dex view model", () => { }) test("Given an empty or unavailable archive When building the Dex Then it preserves the explicit result state", () => { - expect(buildDexViewModel({ kind: "empty" }, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ kind: "empty" }) + expect(buildDexViewModel({ kind: "empty" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "empty" }) expect( - buildDexViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_VPET_SETTINGS), + buildDexViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS), ).toEqual({ kind: "unavailable", message: "Archive unavailable" }) }) test("Given the production catalog When building the Dex Then it deterministically projects all 650 catalog IDs once", () => { - const model = buildDexViewModel({ kind: "available", partners: [] }, DIGIMON_CATALOG, DEFAULT_VPET_SETTINGS) + const model = buildDexViewModel({ kind: "available", partners: [] }, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "available") throw new Error("Expected available Dex model") diff --git a/tests/digimon-data.test.ts b/packages/opencode-digital-pet/tests/digimon-data.test.ts similarity index 96% rename from tests/digimon-data.test.ts rename to packages/opencode-digital-pet/tests/digimon-data.test.ts index 4fe215e..cbeeea8 100644 --- a/tests/digimon-data.test.ts +++ b/packages/opencode-digital-pet/tests/digimon-data.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { DIGIMON_DATA } from "../src/data/digimon-data" +import { DIGIMON_DATA } from "@jcendal/digital-pet-core/data/digimon-data.ts" const RAW_RECORD_KEYS = ["id", "name_en", "name_jp", "next_evolutions", "sprite", "stage", "url"] diff --git a/tests/vpet-command-layer.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx similarity index 71% rename from tests/vpet-command-layer.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx index a14e5de..fc27b95 100644 --- a/tests/vpet-command-layer.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-command-layer.test.tsx @@ -2,30 +2,30 @@ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" import { createTui } from "../src/tui.tsx" import { partnerInputs, settle, TuiCompositionHarness } from "./tui-composition-fixture.ts" -describe("VPet command dialog ownership", () => { +describe("Digital Pet command dialog ownership", () => { test.each([ - ["Dex", "vpet-dex"], - ["history", "vpet-history"], + ["Dex", "digital-pet-dex"], + ["history", "digital-pet-history"], ])( "Given the %s command When Escape and the host close callback run Then one host modal is replaced and cleared once", async (_label, command) => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => ({ kind: "empty" }), } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) harness.invokeCommand(command) expect(harness.dialogStack.replaceCount).toBe(1) expect(harness.dialogStack.sizes).toEqual(["medium"]) const render = harness.dialogStack.renders[0] - if (render === undefined) throw new Error("Expected VPet dialog body") + if (render === undefined) throw new Error("Expected Digital Pet dialog body") const setup = await testRender(render, { width: 60, height: 20 }) await setup.flush() expect(harness.dialogComponentCalls).toBe(0) diff --git a/tests/vpet-control.test.ts b/packages/opencode-digital-pet/tests/digital-pet-control.test.ts similarity index 52% rename from tests/vpet-control.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-control.test.ts index fecf9e5..49a7f97 100644 --- a/tests/vpet-control.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-control.test.ts @@ -1,21 +1,21 @@ import { describe, expect, test } from "bun:test" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" -import { freezeVpet } from "../src/application/use-cases/freeze-vpet.ts" -import { setVpetCheatNode } from "../src/application/use-cases/set-vpet-cheat-node.ts" -import { unfreezeVpet } from "../src/application/use-cases/unfreeze-vpet.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { freezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/freeze-digital-pet.ts" +import { setDigitalPetCheatNode } from "@jcendal/digital-pet-core/application/use-cases/set-digital-pet-cheat-node.ts" +import { unfreezeDigitalPet } from "@jcendal/digital-pet-core/application/use-cases/unfreeze-digital-pet.ts" type ControlState = { frozen: boolean cheatNodeId: string | null } -type FakeVpetControl = VpetControl & { +type FakeDigitalPetControl = DigitalPetControl & { readonly calls: string[] readonly state: ControlState } -const createVpetControl = (initialState: ControlState): FakeVpetControl => { +const createDigitalPetControl = (initialState: ControlState): FakeDigitalPetControl => { const calls: string[] = [] const state = { ...initialState } @@ -43,12 +43,12 @@ const createVpetControl = (initialState: ControlState): FakeVpetControl => { } } -describe("vpet control application use cases", () => { - test("Given an unfrozen VPet When freeze runs twice Then the first transition applies and the second is idempotent", () => { - const control = createVpetControl({ frozen: false, cheatNodeId: null }) +describe("digital-pet control application use cases", () => { + test("Given an unfrozen Digital Pet When freeze runs twice Then the first transition applies and the second is idempotent", () => { + const control = createDigitalPetControl({ frozen: false, cheatNodeId: null }) - const first = freezeVpet(control) - const second = freezeVpet(control) + const first = freezeDigitalPet(control) + const second = freezeDigitalPet(control) expect(first).toEqual({ kind: "frozen" }) expect(second).toEqual({ kind: "already_frozen" }) @@ -56,11 +56,11 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: true, cheatNodeId: null }) }) - test("Given a frozen VPet When unfreeze runs twice Then the first transition applies and the second is idempotent", () => { - const control = createVpetControl({ frozen: true, cheatNodeId: "3-001" }) + test("Given a frozen Digital Pet When unfreeze runs twice Then the first transition applies and the second is idempotent", () => { + const control = createDigitalPetControl({ frozen: true, cheatNodeId: "3-001" }) - const first = unfreezeVpet(control) - const second = unfreezeVpet(control) + const first = unfreezeDigitalPet(control) + const second = unfreezeDigitalPet(control) expect(first).toEqual({ kind: "unfrozen" }) expect(second).toEqual({ kind: "already_unfrozen" }) @@ -68,11 +68,11 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: false, cheatNodeId: "3-001" }) }) - test("Given a frozen VPet and a catalog-validated node ID When set runs Then it preserves freeze and returns typed set outcomes", () => { - const control = createVpetControl({ frozen: true, cheatNodeId: "2-001" }) + test("Given a frozen Digital Pet and a catalog-validated node ID When set runs Then it preserves freeze and returns typed set outcomes", () => { + const control = createDigitalPetControl({ frozen: true, cheatNodeId: "2-001" }) - const first = setVpetCheatNode(control, "3-001") - const second = setVpetCheatNode(control, "3-001") + const first = setDigitalPetCheatNode(control, "3-001") + const second = setDigitalPetCheatNode(control, "3-001") expect(first).toEqual({ kind: "set", cheatNodeId: "3-001" }) expect(second).toEqual({ kind: "already_set", cheatNodeId: "3-001" }) @@ -80,10 +80,10 @@ describe("vpet control application use cases", () => { expect(control.state).toEqual({ frozen: true, cheatNodeId: "3-001" }) }) - test("Given a VPet control that throws during freeze When freeze runs Then the error propagates and no other transition is called", () => { + test("Given a Digital Pet control that throws during freeze When freeze runs Then the error propagates and no other transition is called", () => { const failure = new Error("freeze failed") const calls: string[] = [] - const control: VpetControl = { + const control: DigitalPetControl = { freeze: () => { calls.push("freeze") throw failure @@ -98,7 +98,7 @@ describe("vpet control application use cases", () => { }, } - expect(() => freezeVpet(control)).toThrow(failure) + expect(() => freezeDigitalPet(control)).toThrow(failure) expect(calls).toEqual(["freeze"]) }) }) diff --git a/tests/vpet-dex-dialog-fixture.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx similarity index 80% rename from tests/vpet-dex-dialog-fixture.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx index f3aa0e4..26a370d 100644 --- a/tests/vpet-dex-dialog-fixture.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-fixture.tsx @@ -1,11 +1,11 @@ /** @jsxImportSource @opentui/solid */ import { mock } from "bun:test" -import type { VpetDexDialogTheme } from "../src/tui/vpet-dex-dialog.tsx" +import type { DigitalPetDexDialogTheme } from "../src/tui/digital-pet-dex-dialog.tsx" import type { TuiDialogProps } from "@opencode-ai/plugin/tui" import { parseColor } from "@opentui/core" import type { JSX } from "solid-js" -export const createMockTheme = (): VpetDexDialogTheme => ({ +export const createMockTheme = (): DigitalPetDexDialogTheme => ({ current: { success: parseColor("#00ff00"), textMuted: parseColor("#888888"), diff --git a/tests/vpet-dex-dialog-hardening.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx similarity index 55% rename from tests/vpet-dex-dialog-hardening.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx index 362f43c..884ea14 100644 --- a/tests/vpet-dex-dialog-hardening.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog-hardening.test.tsx @@ -1,29 +1,29 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test, mock } from "bun:test" import { testRender } from "@opentui/solid" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import type { DexViewModel } from "../src/tui/dex-view-model.ts" -import { createMockTheme } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import type { DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { createMockTheme } from "./digital-pet-dex-dialog-fixture.tsx" -describe("VpetDexDialog hardening", () => { +describe("DigitalPetDexDialog hardening", () => { test("renders all undiscovered correctly", async () => { const onClose = mock() const model: DexViewModel = { kind: "available", rows: [ - { id: "vpet-001", stage: "Baby", discovered: false, name: "------" }, - { id: "vpet-002", stage: "Child", discovered: false, name: "------" }, + { id: "digital-pet-001", stage: "Baby", discovered: false, name: "------" }, + { id: "digital-pet-002", stage: "Child", discovered: false, name: "------" }, ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("vpet-001") + expect(frame).toContain("digital-pet-001") expect(frame).not.toContain("Agumon") setup.renderer.destroy() @@ -34,12 +34,12 @@ describe("VpetDexDialog hardening", () => { const model: DexViewModel = { kind: "available", rows: [ - { id: "vpet-special-001", stage: "Mega", discovered: true, name: "Omegamon / WarGreymon" }, - { id: "vpet-extremely-long-unknown-id", stage: "Super", discovered: false, name: "------" }, + { id: "digital-pet-special-001", stage: "Mega", discovered: true, name: "Omegamon / WarGreymon" }, + { id: "digital-pet-extremely-long-unknown-id", stage: "Super", discovered: false, name: "------" }, ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 50, height: 24, }) @@ -54,7 +54,7 @@ describe("VpetDexDialog hardening", () => { test("scrolls the entire unfiltered catalogue", async () => { const onClose = mock() const rows = Array.from({ length: 50 }).map((_, i) => ({ - id: `vpet-${i.toString().padStart(3, "0")}`, + id: `digital-pet-${i.toString().padStart(3, "0")}`, stage: "Baby", discovered: true, name: `Mon ${i}`, @@ -65,15 +65,15 @@ describe("VpetDexDialog hardening", () => { rows, } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() let frame = setup.captureCharFrame() - expect(frame).toContain("vpet-000") - expect(frame).not.toContain("vpet-049") + expect(frame).toContain("digital-pet-000") + expect(frame).not.toContain("digital-pet-049") for (let i = 0; i < 30; i++) { setup.mockInput.pressArrow("down") @@ -81,7 +81,7 @@ describe("VpetDexDialog hardening", () => { await setup.flush() frame = setup.captureCharFrame() - expect(frame).not.toContain("vpet-000") + expect(frame).not.toContain("digital-pet-000") setup.renderer.destroy() }) diff --git a/tests/vpet-dex-dialog.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx similarity index 74% rename from tests/vpet-dex-dialog.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx index c44285a..0901479 100644 --- a/tests/vpet-dex-dialog.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dex-dialog.test.tsx @@ -2,16 +2,16 @@ import { describe, expect, test, mock } from "bun:test" import { ScrollBoxRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import type { DexViewModel } from "../src/tui/dex-view-model.ts" -import { createMockTheme, createTrackedDialogApi } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import type { DexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { createMockTheme, createTrackedDialogApi } from "./digital-pet-dex-dialog-fixture.tsx" -describe("VpetDexDialog", () => { +describe("DigitalPetDexDialog", () => { test("renders empty state without invoking a nested Dialog", async () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const { theme, Dialog } = createTrackedDialogApi() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -26,7 +26,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "unavailable", message: "Cannot load" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -41,20 +41,20 @@ describe("VpetDexDialog", () => { const model: DexViewModel = { kind: "available", rows: Array.from({ length: 50 }, (_, index) => ({ - id: `vpet-${index.toString().padStart(3, "0")}`, + id: `digital-pet-${index.toString().padStart(3, "0")}`, stage: "Baby", discovered: true, name: `Mon ${index}`, })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-dex-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-dex-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native Dex scrollbox") expect(renderable.primaryAxis).toBe("row") @@ -79,7 +79,7 @@ describe("VpetDexDialog", () => { expect(renderable.scrollTop).toBe(0) const frame = setup.captureCharFrame() - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("↑↓: 1 item · ←→: 10 items · Esc: Close") expect(frame).not.toContain("Search") expect(frame).not.toMatch(/[╭╮╰╯]/) @@ -97,10 +97,10 @@ describe("VpetDexDialog", () => { }) const model: DexViewModel = { kind: "available", - rows: [{ id: "vpet-001", stage: "Baby", discovered: true, name: "Botamon" }], + rows: [{ id: "digital-pet-001", stage: "Baby", discovered: true, name: "Botamon" }], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -117,17 +117,17 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "available", - rows: [{ id: "vpet-001", stage: "Baby", discovered: true, name: "Very Long Name Indeed" }], + rows: [{ id: "digital-pet-001", stage: "Baby", discovered: true, name: "Very Long Name Indeed" }], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 25, height: 24, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("vpet-001") + expect(frame).toContain("digital-pet-001") expect(frame).toContain("Very") expect(frame).toContain("Long") setup.renderer.destroy() @@ -137,14 +137,14 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) await setup.flush() const frame = setup.captureCharFrame() - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("Esc: Close") expect(frame).not.toContain("Search") expect(frame).not.toMatch(/[╭╮╰╯]/) @@ -155,7 +155,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 60, height: 20, }) @@ -163,10 +163,10 @@ describe("VpetDexDialog", () => { const frame = setup.captureCharFrame() const lines = frame.split("\n") - expect(frame).toContain("VPet Dex") + expect(frame).toContain("Digital Pet Dex") expect(frame).toContain("No data available") expect(frame).toContain("Esc: Close") - expect(lines.findIndex((line) => line.includes("VPet Dex"))).toBeLessThan( + expect(lines.findIndex((line) => line.includes("Digital Pet Dex"))).toBeLessThan( lines.findIndex((line) => line.includes("No data available")), ) expect(lines.findIndex((line) => line.includes("No data available"))).toBeLessThan( @@ -179,7 +179,7 @@ describe("VpetDexDialog", () => { const onClose = mock() const model: DexViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) diff --git a/tests/vpet-dialog-layout.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx similarity index 76% rename from tests/vpet-dialog-layout.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx index 242c2f3..8f5e4be 100644 --- a/tests/vpet-dialog-layout.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-dialog-layout.test.tsx @@ -3,9 +3,9 @@ import { describe, expect, mock, test } from "bun:test" import { testRender, useTerminalDimensions } from "@opentui/solid" import type { BoxRenderable } from "@opentui/core" import type { JSX } from "solid-js" -import { VpetDexDialog } from "../src/tui/vpet-dex-dialog.tsx" -import { VpetHistoryDialog } from "../src/tui/vpet-history-dialog.tsx" -import { createMockTheme } from "./vpet-dex-dialog-fixture.tsx" +import { DigitalPetDexDialog } from "../src/tui/digital-pet-dex-dialog.tsx" +import { DigitalPetHistoryDialog } from "../src/tui/digital-pet-history-dialog.tsx" +import { createMockTheme } from "./digital-pet-dex-dialog-fixture.tsx" type DialogLayoutCase = { readonly name: string @@ -15,15 +15,15 @@ type DialogLayoutCase = { const layoutCases: DialogLayoutCase[] = [ { name: "Dex", - render: () => , + render: () => , }, { name: "history", - render: () => , + render: () => , }, ] -describe("VPet dialog layout", () => { +describe("Digital Pet dialog layout", () => { test.each(layoutCases)( "Given the OpenCode host Dialog When it renders a %s replacement body Then the host-owned surface is centered in the viewport", async ({ render }) => { diff --git a/tests/vpet-freeze.test.ts b/packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts similarity index 69% rename from tests/vpet-freeze.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts index 9ef02eb..7b3d5e7 100644 --- a/tests/vpet-freeze.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-freeze.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetFreezeCommand } from "../src/commands/vpet-freeze.ts" +import { runDigitalPetFreezeCommand } from "../src/commands/digital-pet-freeze.ts" -describe("vpet freeze command", () => { - test("Given a VPet control When freeze runs repeatedly Then it returns the exact success text for both transitions", async () => { +describe("digital-pet freeze command", () => { + test("Given a Digital Pet control When freeze runs repeatedly Then it returns the exact success text for both transitions", async () => { let freezeCalls = 0 const control = { freeze: () => { @@ -14,15 +14,19 @@ describe("vpet freeze command", () => { }, } - const first = await runVpetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-1" }) - const second = await runVpetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-2" }) + const first = await runDigitalPetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-1" }) + const second = await runDigitalPetFreezeCommand(control, { sessionID: "session-1", messageID: "freeze-2" }) expect(first).toEqual({ - parts: [{ id: "freeze-1", sessionID: "session-1", messageID: "freeze-1", type: "text", text: "VPet frozen." }], + parts: [ + { id: "freeze-1", sessionID: "session-1", messageID: "freeze-1", type: "text", text: "Digital Pet frozen." }, + ], event: { kind: "frozen" }, }) expect(second).toEqual({ - parts: [{ id: "freeze-2", sessionID: "session-1", messageID: "freeze-2", type: "text", text: "VPet frozen." }], + parts: [ + { id: "freeze-2", sessionID: "session-1", messageID: "freeze-2", type: "text", text: "Digital Pet frozen." }, + ], }) expect(freezeCalls).toBe(2) }) @@ -44,6 +48,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -52,15 +58,15 @@ describe("vpet freeze command", () => { const otherOutput = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-freeze", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, freezeOutput, ) await hooks["command.execute.before"]?.( - { command: "vpet-freeze ", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-freeze ", sessionID: "session-1", arguments: "" }, otherOutput, ) - expect(freezeOutput.parts).toEqual([expect.objectContaining({ type: "text", text: "VPet frozen." })]) + expect(freezeOutput.parts).toEqual([expect.objectContaining({ type: "text", text: "Digital Pet frozen." })]) expect(otherOutput.parts).toEqual([]) expect(freezeCalls).toBe(1) }) @@ -78,6 +84,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -85,8 +93,14 @@ describe("vpet freeze command", () => { const first = { parts: [] as Part[] } const second = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, first) - await hooks["command.execute.before"]?.({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, second) + await hooks["command.execute.before"]?.( + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, + first, + ) + await hooks["command.execute.before"]?.( + { command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, + second, + ) expect(first.parts[0]?.id).not.toBe(second.parts[0]?.id) }) @@ -108,6 +122,8 @@ describe("vpet freeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -115,9 +131,9 @@ describe("vpet freeze command", () => { const handler = hooks["command.execute.before"] if (handler === undefined) throw new Error("Expected command handler") - await expect(handler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output)).rejects.toBe( - failure, - ) + await expect( + handler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output), + ).rejects.toBe(failure) expect(output.parts).toEqual([]) }) }) diff --git a/tests/vpet-history-dialog.test.tsx b/packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx similarity index 80% rename from tests/vpet-history-dialog.test.tsx rename to packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx index 32cc94c..d9249b3 100644 --- a/tests/vpet-history-dialog.test.tsx +++ b/packages/opencode-digital-pet/tests/digital-pet-history-dialog.test.tsx @@ -2,12 +2,12 @@ import { describe, expect, test, mock } from "bun:test" import { ScrollBoxRenderable } from "@opentui/core" import { testRender } from "@opentui/solid" -import { VpetHistoryDialog, type VpetHistoryDialogTheme } from "../src/tui/vpet-history-dialog.tsx" -import type { HistoryViewModel } from "../src/tui/history-view-model.ts" +import { DigitalPetHistoryDialog, type DigitalPetHistoryDialogTheme } from "../src/tui/digital-pet-history-dialog.tsx" +import type { HistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" import { parseColor } from "@opentui/core" -import { createTrackedDialogApi } from "./vpet-dex-dialog-fixture.tsx" +import { createTrackedDialogApi } from "./digital-pet-dex-dialog-fixture.tsx" -const createMockTheme = (): VpetHistoryDialogTheme => { +const createMockTheme = (): DigitalPetHistoryDialogTheme => { return { current: { primary: parseColor("#0000ff"), @@ -15,12 +15,12 @@ const createMockTheme = (): VpetHistoryDialogTheme => { } } -describe("VpetHistoryDialog", () => { +describe("DigitalPetHistoryDialog", () => { test("renders empty state without invoking a nested Dialog", async () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const { theme, Dialog } = createTrackedDialogApi() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -41,7 +41,7 @@ describe("VpetHistoryDialog", () => { ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -71,7 +71,7 @@ describe("VpetHistoryDialog", () => { ], } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 30, height: 24, }) @@ -99,7 +99,7 @@ describe("VpetHistoryDialog", () => { }) const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 24, }) @@ -127,7 +127,7 @@ describe("VpetHistoryDialog", () => { generations, } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) @@ -161,13 +161,13 @@ describe("VpetHistoryDialog", () => { })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-history-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-history-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native History scrollbox") const frame = setup.captureCharFrame() @@ -177,7 +177,7 @@ describe("VpetHistoryDialog", () => { expect(renderable.verticalScrollBar.y).toBe(renderable.viewport.y) expect(renderable.verticalScrollBar.height).toBe(renderable.viewport.height) expect(footerRow).toBeGreaterThan(renderable.y + renderable.height - 1) - expect(frame).toContain("VPet History") + expect(frame).toContain("Digital Pet History") expect(frame).toContain("Esc: Close") expect(frame).not.toMatch(/[╭╮╰╯]/) setup.renderer.destroy() @@ -196,13 +196,13 @@ describe("VpetHistoryDialog", () => { })), } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 80, height: 10, }) await setup.flush() - const renderable = setup.renderer.root.findDescendantById("vpet-history-scrollbox") + const renderable = setup.renderer.root.findDescendantById("digital-pet-history-scrollbox") if (!(renderable instanceof ScrollBoxRenderable)) throw new Error("Expected native History scrollbox") setup.mockInput.pressArrow("down") @@ -229,7 +229,7 @@ describe("VpetHistoryDialog", () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 60, height: 20, }) @@ -237,10 +237,10 @@ describe("VpetHistoryDialog", () => { const frame = setup.captureCharFrame() const lines = frame.split("\n") - expect(frame).toContain("VPet History") + expect(frame).toContain("Digital Pet History") expect(frame).toContain("No data available") expect(frame).toContain("Esc: Close") - expect(lines.findIndex((line) => line.includes("VPet History"))).toBeLessThan( + expect(lines.findIndex((line) => line.includes("Digital Pet History"))).toBeLessThan( lines.findIndex((line) => line.includes("No data available")), ) expect(lines.findIndex((line) => line.includes("No data available"))).toBeLessThan( @@ -253,7 +253,7 @@ describe("VpetHistoryDialog", () => { const onClose = mock() const model: HistoryViewModel = { kind: "empty" } const theme = createMockTheme() - const setup = await testRender(() => , { + const setup = await testRender(() => , { width: 32, height: 12, }) diff --git a/tests/vpet-set.test.ts b/packages/opencode-digital-pet/tests/digital-pet-set.test.ts similarity index 80% rename from tests/vpet-set.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-set.test.ts index 7e6ef82..d940622 100644 --- a/tests/vpet-set.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-set.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" -import type { DigimonCatalog } from "../src/data/catalog.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetSetCommand } from "../src/commands/vpet-set.ts" +import { runDigitalPetSetCommand } from "../src/commands/digital-pet-set.ts" const nodes = [ { @@ -31,7 +31,7 @@ const catalog: DigimonCatalog = { byId: new Map(nodes.map((node) => [node.id, node])), } -describe("vpet set command", () => { +describe("digital-pet set command", () => { test("Given whitespace-padded egg and terminal IDs When set runs Then it trims and returns exact catalog-backed messages", async () => { const calls: string[] = [] const control = { @@ -42,12 +42,12 @@ describe("vpet set command", () => { } const loadCatalog = async () => catalog - const egg = await runVpetSetCommand( + const egg = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "set-egg", arguments: " 0-001 " }, loadCatalog, ) - const terminal = await runVpetSetCommand( + const terminal = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "set-terminal", arguments: "\t7-001\t" }, loadCatalog, @@ -60,7 +60,7 @@ describe("vpet set command", () => { sessionID: "session-1", messageID: "set-egg", type: "text", - text: "VPet set to Digitama (0-001).", + text: "Digital Pet set to Digitama (0-001).", }, ], event: { kind: "set", nodeId: "0-001" }, @@ -72,7 +72,7 @@ describe("vpet set command", () => { sessionID: "session-1", messageID: "set-terminal", type: "text", - text: "VPet set to Susanoomon (7-001).", + text: "Digital Pet set to Susanoomon (7-001).", }, ], event: { kind: "set", nodeId: "7-001" }, @@ -91,7 +91,7 @@ describe("vpet set command", () => { }, } - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "invalid", arguments: arguments_ }, async () => catalog, @@ -99,7 +99,13 @@ describe("vpet set command", () => { expect(result).toEqual({ parts: [ - { id: "invalid", sessionID: "session-1", messageID: "invalid", type: "text", text: "Usage: /vpet-set ." }, + { + id: "invalid", + sessionID: "session-1", + messageID: "invalid", + type: "text", + text: "Usage: /digital-pet-set .", + }, ], }) expect(writes).toBe(0) @@ -108,7 +114,7 @@ describe("vpet set command", () => { test("Given an unknown ID When set runs Then it appends the exact error and makes zero writes", async () => { let writes = 0 - const result = await runVpetSetCommand( + const result = await runDigitalPetSetCommand( { setCheatNode: () => { writes += 1 @@ -142,20 +148,20 @@ describe("vpet set command", () => { }, } - const same = await runVpetSetCommand( + const same = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "same", arguments: "0-001" }, async () => catalog, ) - const replacement = await runVpetSetCommand( + const replacement = await runDigitalPetSetCommand( control, { sessionID: "session-1", messageID: "replacement", arguments: "7-001" }, async () => catalog, ) - expect(same).toEqual({ parts: [expect.objectContaining({ text: "VPet set to Digitama (0-001)." })] }) + expect(same).toEqual({ parts: [expect.objectContaining({ text: "Digital Pet set to Digitama (0-001)." })] }) expect(replacement).toEqual({ - parts: [expect.objectContaining({ text: "VPet set to Susanoomon (7-001)." })], + parts: [expect.objectContaining({ text: "Digital Pet set to Susanoomon (7-001)." })], event: { kind: "set", nodeId: "7-001" }, }) expect(calls).toEqual(["0-001", "7-001"]) @@ -164,7 +170,7 @@ describe("vpet set command", () => { test("Given catalog or control failures When set runs Then the failure propagates without a part", async () => { const catalogFailure = new Error("catalog failed") await expect( - runVpetSetCommand( + runDigitalPetSetCommand( { setCheatNode: () => ({ kind: "set" as const, cheatNodeId: "0-001" }) }, { sessionID: "session-1", messageID: "catalog-failure", arguments: "0-001" }, async () => { @@ -175,7 +181,7 @@ describe("vpet set command", () => { const controlFailure = new Error("set failed") await expect( - runVpetSetCommand( + runDigitalPetSetCommand( { setCheatNode: () => { throw controlFailure @@ -187,7 +193,7 @@ describe("vpet set command", () => { ).rejects.toBe(controlFailure) }) - test("Given a complete repository When the hook dispatches vpet-set Then it routes valid raw input once", async () => { + test("Given a complete repository When the hook dispatches digital-pet-set Then it routes valid raw input once", async () => { const calls: string[] = [] const repository = { freeze: () => ({ kind: "frozen" as const }), @@ -204,6 +210,8 @@ describe("vpet set command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } @@ -211,11 +219,11 @@ describe("vpet set command", () => { const output = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-set", sessionID: "session-1", arguments: " 0-001 " }, + { command: "digital-pet-set", sessionID: "session-1", arguments: " 0-001 " }, output, ) - expect(output.parts).toEqual([expect.objectContaining({ text: "VPet set to Digiegg (0-001)." })]) + expect(output.parts).toEqual([expect.objectContaining({ text: "Digital Pet set to Digiegg (0-001)." })]) expect(calls).toEqual(["0-001"]) }) }) diff --git a/tests/vpet-spawn.test.ts b/packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts similarity index 72% rename from tests/vpet-spawn.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts index 1b7fb63..652db7f 100644 --- a/tests/vpet-spawn.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-spawn.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" -import { spawnPartner } from "../src/application/use-cases/spawn-partner.ts" +import { spawnPartner } from "@jcendal/digital-pet-core/application/use-cases/spawn-partner.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetSpawnCommand } from "../src/commands/vpet-spawn.ts" +import { runDigitalPetSpawnCommand } from "../src/commands/digital-pet-spawn.ts" -describe("vpet spawn command", () => { +describe("digital-pet spawn command", () => { test("Given a partner lifecycle When the application spawns a partner Then it uses the fixed initial state and returns the generation", () => { const calls: unknown[] = [] const lifecycle = { @@ -19,6 +19,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } }, } @@ -36,7 +38,7 @@ describe("vpet spawn command", () => { expect(result).toEqual({ generation: 2 }) }) - test("Given the vpet spawn command When it runs Then it atomically creates the egg generation and returns a text part", async () => { + test("Given the digital-pet spawn command When it runs Then it atomically creates the egg generation and returns a text part", async () => { const spawned = { partnerId: "partner-2", generation: 2, @@ -45,6 +47,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } const calls: unknown[] = [] const repository = { @@ -54,9 +58,9 @@ describe("vpet spawn command", () => { }, } - const result = await runVpetSpawnCommand(repository, { + const result = await runDigitalPetSpawnCommand(repository, { sessionID: "session-1", - messageID: "vpet-spawn-session-1", + messageID: "digital-pet-spawn-session-1", createdAt: spawned.createdAt, }) @@ -64,9 +68,9 @@ describe("vpet spawn command", () => { expect(result).toEqual({ parts: [ { - id: "vpet-spawn-session-1", + id: "digital-pet-spawn-session-1", sessionID: "session-1", - messageID: "vpet-spawn-session-1", + messageID: "digital-pet-spawn-session-1", type: "text", text: "Spawned Generation 2.", }, @@ -75,7 +79,7 @@ describe("vpet spawn command", () => { }) }) - test("Given a repository stub When OpenCode dispatches the raw command name Then the hook appends spawn parts only for vpet-spawn", async () => { + test("Given a repository stub When OpenCode dispatches the raw command name Then the hook appends spawn parts only for digital-pet-spawn", async () => { let spawnCalls = 0 const repository = { spawnPartner: () => { @@ -88,6 +92,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, } }, applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), @@ -100,7 +106,7 @@ describe("vpet spawn command", () => { const unrelatedOutput = { parts: [] as Part[] } await hooks["command.execute.before"]?.( - { command: "vpet-spawn", sessionID: "session-1", arguments: "" }, + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, spawnOutput, ) await hooks["command.execute.before"]?.( @@ -110,9 +116,9 @@ describe("vpet spawn command", () => { expect(spawnOutput.parts).toEqual([ expect.objectContaining({ - id: expect.stringMatching(/^vpet-vpet-spawn-session-1-/), + id: expect.stringMatching(/^digital-pet-digital-pet-spawn-session-1-/), sessionID: "session-1", - messageID: expect.stringMatching(/^vpet-vpet-spawn-session-1-/), + messageID: expect.stringMatching(/^digital-pet-digital-pet-spawn-session-1-/), type: "text", text: "Spawned Generation 2.", }), @@ -121,7 +127,7 @@ describe("vpet spawn command", () => { expect(spawnCalls).toBe(1) }) - test("Given a lifecycle that throws When OpenCode dispatches vpet-spawn Then the error propagates and no success part is appended", () => { + test("Given a lifecycle that throws When OpenCode dispatches digital-pet-spawn Then the error propagates and no success part is appended", () => { const failure = new Error("spawn failed") const repository = { spawnPartner: () => { @@ -135,9 +141,11 @@ describe("vpet spawn command", () => { const hooks = createServerHooks({ repository, resource: { close: async () => {} } }) const output = { parts: [] as Part[] } const handler = hooks["command.execute.before"] - if (handler === undefined) throw new Error("vpet-spawn command handler is not registered") + if (handler === undefined) throw new Error("digital-pet-spawn command handler is not registered") - return Promise.resolve(handler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, output)).then( + return Promise.resolve( + handler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, output), + ).then( () => { throw new Error("expected spawn lifecycle failure") }, @@ -158,6 +166,8 @@ describe("vpet spawn command", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), freeze: () => ({ kind: "frozen" as const }), unfreeze: () => ({ kind: "unfrozen" as const }), @@ -168,8 +178,14 @@ describe("vpet spawn command", () => { const first = { parts: [] as Part[] } const second = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, first) - await hooks["command.execute.before"]?.({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, second) + await hooks["command.execute.before"]?.( + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, + first, + ) + await hooks["command.execute.before"]?.( + { command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, + second, + ) expect(first.parts[0]?.id).not.toBe(second.parts[0]?.id) }) diff --git a/tests/vpet-toast.test.ts b/packages/opencode-digital-pet/tests/digital-pet-toast.test.ts similarity index 62% rename from tests/vpet-toast.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-toast.test.ts index 69d630a..6776feb 100644 --- a/tests/vpet-toast.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-toast.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import { - createBestEffortVpetToastNotifier, - formatVpetToast, - type VpetToastPayload, -} from "../src/adapters/opencode/vpet-toast.ts" -import type { DigimonCatalog } from "../src/data/catalog.ts" + createBestEffortDigitalPetToastNotifier, + formatDigitalPetToast, + type DigitalPetToastPayload, +} from "../src/adapters/opencode/digital-pet-toast.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" const nodes = [ { @@ -50,35 +50,56 @@ const unsuccessfulTransports: readonly (readonly [string, () => boolean | Promis ["rejected Promise", () => Promise.reject(new Error("toast transport failed"))], ] -describe("VPet toast presentation", () => { +describe("Digital Pet toast presentation", () => { test.each([ [ "spawn", spawn, - { title: "VPet", message: "Spawned Digitama (Generation 2).", variant: "success", duration: 5_000 }, + { title: "Digital Pet", message: "Spawned Digitama (Generation 2).", variant: "success", duration: 5_000 }, + ], + [ + "freeze", + freeze, + { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, ], - ["freeze", freeze, { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }], [ "unfreeze", unfreeze, - { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + ], + [ + "set", + set, + { title: "Digital Pet", message: "Digital Pet set to Koromon (1-001).", variant: "info", duration: 3_000 }, ], - ["set", set, { title: "VPet", message: "VPet set to Koromon (1-001).", variant: "info", duration: 3_000 }], [ "evolution", evolution, { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, ], + [ + "defeat", + { kind: "defeat" }, + { + title: "Defeat", + message: "Defeat! You lost all tokens for this stage.", + variant: "info", + duration: 5_000, + }, + ], ] as const)( "Given an English catalog and a %s event When formatting Then it returns the approved payload", (_name, event, expected) => { - expect(formatVpetToast(event, "en", catalog)).toEqual(expected) + expect(formatDigitalPetToast(event, "en", catalog)).toEqual(expected) }, ) test.each([ - [spawn, { title: "VPet", message: "Spawned DigiTama (Generation 2).", variant: "success", duration: 5_000 }], - [set, { title: "VPet", message: "VPet set to Koromon JP (1-001).", variant: "info", duration: 3_000 }], + [spawn, { title: "Digital Pet", message: "Spawned DigiTama (Generation 2).", variant: "success", duration: 5_000 }], + [ + set, + { title: "Digital Pet", message: "Digital Pet set to Koromon JP (1-001).", variant: "info", duration: 3_000 }, + ], [ evolution, { title: "Digi-evolution", message: "DigiTama evolved into Koromon JP!", variant: "success", duration: 5_000 }, @@ -86,7 +107,7 @@ describe("VPet toast presentation", () => { ] as const)( "Given a Japanese catalog-name setting When formatting a name-bearing event Then it selects nameJp", (event, expected) => { - expect(formatVpetToast(event, "jp", catalog)).toEqual(expected) + expect(formatDigitalPetToast(event, "jp", catalog)).toEqual(expected) }, ) @@ -97,17 +118,17 @@ describe("VPet toast presentation", () => { ] as const)( "Given a missing catalog ID When formatting a name-bearing event Then it produces no payload", (event) => { - expect(formatVpetToast(event, "en", catalog)).toBeUndefined() + expect(formatDigitalPetToast(event, "en", catalog)).toBeUndefined() }, ) test("Given a true-returning toast transport When the notifier receives a payload Then it calls the transport once", async () => { - const calls: VpetToastPayload[] = [] - const notifier = createBestEffortVpetToastNotifier(async (payload) => { + const calls: DigitalPetToastPayload[] = [] + const notifier = createBestEffortDigitalPetToastNotifier(async (payload) => { calls.push(payload) return true }) - const payload = formatVpetToast(spawn, "en", catalog) + const payload = formatDigitalPetToast(spawn, "en", catalog) if (payload === undefined) throw new Error("Expected spawn toast payload") await notifier(payload) @@ -119,11 +140,11 @@ describe("VPet toast presentation", () => { "Given a transport that returns %s When the notifier receives a payload Then it resolves after one attempted call without retry", async (_outcome, deliver) => { let callCount = 0 - const notifier = createBestEffortVpetToastNotifier(() => { + const notifier = createBestEffortDigitalPetToastNotifier(() => { callCount += 1 return deliver() }) - const payload = formatVpetToast(freeze, "en", catalog) + const payload = formatDigitalPetToast(freeze, "en", catalog) if (payload === undefined) throw new Error("Expected freeze toast payload") await notifier(payload) diff --git a/tests/vpet-unfreeze.test.ts b/packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts similarity index 58% rename from tests/vpet-unfreeze.test.ts rename to packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts index 3fe5692..ca943ee 100644 --- a/tests/vpet-unfreeze.test.ts +++ b/packages/opencode-digital-pet/tests/digital-pet-unfreeze.test.ts @@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test" import type { Part } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import { runVpetUnfreezeCommand } from "../src/commands/vpet-unfreeze.ts" +import { runDigitalPetUnfreezeCommand } from "../src/commands/digital-pet-unfreeze.ts" -describe("vpet unfreeze command", () => { - test("Given a VPet control When unfreeze runs repeatedly Then it returns the exact success text for both transitions", async () => { +describe("digital-pet unfreeze command", () => { + test("Given a Digital Pet control When unfreeze runs repeatedly Then it returns the exact success text for both transitions", async () => { let unfreezeCalls = 0 const control = { unfreeze: () => { @@ -14,24 +14,36 @@ describe("vpet unfreeze command", () => { }, } - const first = await runVpetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-1" }) - const second = await runVpetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-2" }) + const first = await runDigitalPetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-1" }) + const second = await runDigitalPetUnfreezeCommand(control, { sessionID: "session-1", messageID: "unfreeze-2" }) expect(first).toEqual({ parts: [ - { id: "unfreeze-1", sessionID: "session-1", messageID: "unfreeze-1", type: "text", text: "VPet unfrozen." }, + { + id: "unfreeze-1", + sessionID: "session-1", + messageID: "unfreeze-1", + type: "text", + text: "Digital Pet unfrozen.", + }, ], event: { kind: "unfrozen" }, }) expect(second).toEqual({ parts: [ - { id: "unfreeze-2", sessionID: "session-1", messageID: "unfreeze-2", type: "text", text: "VPet unfrozen." }, + { + id: "unfreeze-2", + sessionID: "session-1", + messageID: "unfreeze-2", + type: "text", + text: "Digital Pet unfrozen.", + }, ], }) expect(unfreezeCalls).toBe(2) }) - test("Given a complete repository When the hook dispatches vpet-unfreeze Then it routes once", async () => { + test("Given a complete repository When the hook dispatches digital-pet-unfreeze Then it routes once", async () => { let unfreezeCalls = 0 const repository = { freeze: () => ({ kind: "frozen" as const }), @@ -48,15 +60,20 @@ describe("vpet unfreeze command", () => { isTerminal: false, createdAt: "2026-08-22T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt: () => ({ kind: "no_active_partner" as const }), } const hooks = createServerHooks({ repository, resource: { close: async () => {} } }) const output = { parts: [] as Part[] } - await hooks["command.execute.before"]?.({ command: "vpet-unfreeze", sessionID: "session-1", arguments: "" }, output) + await hooks["command.execute.before"]?.( + { command: "digital-pet-unfreeze", sessionID: "session-1", arguments: "" }, + output, + ) - expect(output.parts).toEqual([expect.objectContaining({ type: "text", text: "VPet unfrozen." })]) + expect(output.parts).toEqual([expect.objectContaining({ type: "text", text: "Digital Pet unfrozen." })]) expect(unfreezeCalls).toBe(1) }) @@ -64,7 +81,7 @@ describe("vpet unfreeze command", () => { const failure = new Error("unfreeze failed") await expect( - runVpetUnfreezeCommand( + runDigitalPetUnfreezeCommand( { unfreeze: () => { throw failure diff --git a/packages/opencode-digital-pet/tests/dispatch-merged-releases.test.ts b/packages/opencode-digital-pet/tests/dispatch-merged-releases.test.ts new file mode 100644 index 0000000..39633cf --- /dev/null +++ b/packages/opencode-digital-pet/tests/dispatch-merged-releases.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" + +import { dispatchSelectedReleases, type DispatchOptions } from "../../../scripts/dispatch-merged-releases.ts" + +const options: DispatchOptions = { + repository: "jcendal/digital-pet", + token: "token", + releaseNote: "feat: sidebar", + opencode: { release: true, bump: "minor" }, + cursor: { release: true, bump: "patch" }, +} + +const workflowName = (url: string, method: string | undefined): string => { + if (method === "POST") return url.split("/").at(-2) ?? "" + return url.endsWith("/11") ? "release-opencode.yml" : "release-cursor.yml" +} + +describe("dispatch merged releases", () => { + test("Given both products When dispatching Then both runs start before either is checked", async () => { + const calls: string[] = [] + const gets = new Map() + const request = async (url: string, init?: RequestInit): Promise => { + const workflow = workflowName(url, init?.method) + if (init?.method === "POST") { + const body = JSON.parse(String(init.body)) as { return_run_details?: boolean } + calls.push(`POST ${workflow}`) + expect(body.return_run_details).toBe(true) + return Response.json({ + workflow_run_id: workflow.includes("opencode") ? 11 : 22, + html_url: "https://github.com/run", + }) + } + const seen = (gets.get(workflow) ?? 0) + 1 + gets.set(workflow, seen) + if (workflow.includes("cursor") && seen === 1) return new Response("missing", { status: 404 }) + return Response.json({ status: "completed", conclusion: "success", html_url: "https://github.com/run" }) + } + + await dispatchSelectedReleases( + options, + request as typeof fetch, + async () => {}, + () => {}, + ) + + expect(calls.slice(0, 2)).toEqual(["POST release-opencode.yml", "POST release-cursor.yml"]) + }) + + test("Given an OpenCode failure When both runs start Then Cursor still finishes and the failure is reported", async () => { + const posts: string[] = [] + const request = async (url: string, init?: RequestInit): Promise => { + const workflow = workflowName(url, init?.method) + if (init?.method === "POST") { + posts.push(workflow) + return Response.json({ + workflow_run_id: workflow.includes("opencode") ? 11 : 22, + html_url: "https://github.com/run", + }) + } + const conclusion = workflow.includes("opencode") ? "failure" : "success" + return Response.json({ status: "completed", conclusion, html_url: "https://github.com/run" }) + } + + await expect( + dispatchSelectedReleases( + options, + request as typeof fetch, + async () => {}, + () => {}, + ), + ).rejects.toThrow("release-opencode.yml finished with failure") + expect(posts).toEqual(["release-opencode.yml", "release-cursor.yml"]) + }) +}) diff --git a/tests/get-sidebar-card-inputs.test.ts b/packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts similarity index 86% rename from tests/get-sidebar-card-inputs.test.ts rename to packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts index 44b6693..6700bad 100644 --- a/tests/get-sidebar-card-inputs.test.ts +++ b/packages/opencode-digital-pet/tests/get-sidebar-card-inputs.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import type { DigimonCatalog, DigimonNode } from "../src/data/catalog.ts" -import type { SidebarSnapshotReader } from "../src/application/ports/sidebar-snapshot.ts" -import { getSidebarCardInputs } from "../src/application/use-cases/get-sidebar-card-inputs.ts" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { SidebarSnapshotReader } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" const agumon: DigimonNode = { id: "3-001", @@ -32,6 +32,11 @@ const catalog: DigimonCatalog = { ]), } +const snapshotFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + describe("get sidebar card inputs", () => { test("Given a reader without a snapshot When querying sidebar card inputs Then it returns no partner", () => { const reader: SidebarSnapshotReader = { @@ -50,6 +55,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -61,6 +67,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -73,6 +80,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -88,6 +96,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -103,6 +112,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -114,6 +124,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -126,6 +137,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -137,6 +149,7 @@ describe("get sidebar card inputs", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) @@ -149,6 +162,7 @@ describe("get sidebar card inputs", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 40_000, + ...snapshotFields, }), } @@ -160,6 +174,7 @@ describe("get sidebar card inputs", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 40_000, + evolutionBattlePending: false, }) }) }) diff --git a/tests/hexagonal-boundary.test.ts b/packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts similarity index 68% rename from tests/hexagonal-boundary.test.ts rename to packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts index cac04e1..1ec5d2d 100644 --- a/tests/hexagonal-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/hexagonal-boundary.test.ts @@ -10,7 +10,7 @@ import { FORBIDDEN_SERVER_HOOK_IMPORTS, FORBIDDEN_TUI_ANIMATION_IMPORTS, PROJECT_ROOT, - TUI_PRIVATE_ANIMATION_MODULES, + DIGITAL_PET_ANIMATION_IDLE_MODULES, findForbiddenImports, scanForbiddenImports, scanTuiPresentationImports, @@ -18,19 +18,19 @@ import { describe("hexagonal dependency direction", () => { test("Given domain fixtures importing data, config, application, and frameworks When scanned Then each exact violation is reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-domain-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-domain-architecture-")) const fixtures = [ - ["data.ts", 'import type { DigimonCatalog } from "../data/catalog.ts"\n'], - ["config.ts", 'import type { ResolvedVpetSettings } from "../config/types.ts"\n'], - ["application.ts", 'import type { Partner } from "../application/models/partner.ts"\n'], + ["data.ts", 'import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts"\n'], + ["config.ts", 'import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts"\n'], + ["application.ts", 'import type { Partner } from "@jcendal/digital-pet-core/application/models/partner.ts"\n'], ["framework.ts", 'import type { Plugin } from "@opencode-ai/plugin"\n'], ] as const try { await Promise.all(fixtures.map(async ([name, source]) => writeFile(join(directory, name), source))) expect(await scanForbiddenImports(directory, FORBIDDEN_DOMAIN_IMPORTS)).toEqual([ - `${join(directory, "application.ts")}: ../application/models/partner.ts`, - `${join(directory, "config.ts")}: ../config/types.ts`, - `${join(directory, "data.ts")}: ../data/catalog.ts`, + `${join(directory, "application.ts")}: @jcendal/digital-pet-core/application/models/partner.ts`, + `${join(directory, "config.ts")}: @jcendal/digital-pet-core/config/types.ts`, + `${join(directory, "data.ts")}: @jcendal/digital-pet-core/data/catalog.ts`, `${join(directory, "framework.ts")}: @opencode-ai/plugin`, ]) } finally { @@ -39,11 +39,17 @@ describe("hexagonal dependency direction", () => { expect(await Bun.file(directory).exists()).toBeFalse() }) test("Given the domain source tree When scanned Then it has no forbidden dependencies", async () => - expect(await scanForbiddenImports(join(PROJECT_ROOT, "src", "domain"), FORBIDDEN_DOMAIN_IMPORTS)).toEqual([])) + expect( + await scanForbiddenImports( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "domain"), + FORBIDDEN_DOMAIN_IMPORTS, + ), + ).toEqual([])) test("Given the inner TUI source tree When scanned Then it has no persistence or SQLite dependencies", async () => expect(await scanTuiPresentationImports(join(PROJECT_ROOT, "src", "tui"))).toEqual([])) - test("Given animation policy and mirror modules When scanned Then they remain TUI-private without domain, application, or SQLite dependencies", async () => { - const paths = TUI_PRIVATE_ANIMATION_MODULES.map((module) => join(PROJECT_ROOT, "src", "tui", module)) + test("Given digital-pet-animation idle modules When scanned Then they remain presentation-only without application or SQLite dependencies", async () => { + const animationRoot = join(PROJECT_ROOT, "..", "digital-pet-animation", "src") + const paths = DIGITAL_PET_ANIMATION_IDLE_MODULES.map((module) => join(animationRoot, module)) expect(await Promise.all(paths.map((path) => Bun.file(path).exists()))).toEqual([true, true, true, true]) expect( await Promise.all( @@ -54,15 +60,15 @@ describe("hexagonal dependency direction", () => { ).toEqual([[], [], [], []]) }) test("Given inner TUI fixtures importing raw config and SQLite When scanned Then both violations are reported", async () => { - const directory = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-architecture-")) + const directory = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-architecture-")) const fixtures = [ - ["config.ts", 'import { loadGlobalVpetSettings } from "../config/global-vpet-settings.ts"\n'], + ["config.ts", 'import { loadGlobalDigitalPetSettings } from "../config/global-digital-pet-settings.ts"\n'], ["sqlite.ts", 'import { Database } from "bun:sqlite"\n'], ] as const try { await Promise.all(fixtures.map(async ([name, source]) => writeFile(join(directory, name), source))) expect(await scanTuiPresentationImports(directory)).toEqual([ - `${join(directory, "config.ts")}: ../config/global-vpet-settings.ts`, + `${join(directory, "config.ts")}: ../config/global-digital-pet-settings.ts`, `${join(directory, "sqlite.ts")}: bun:sqlite`, ]) } finally { @@ -84,7 +90,9 @@ describe("hexagonal dependency direction", () => { test("Given the server hook composition When scanned Then it has no SQLite or application lifecycle port dependency", async () => { const hook = join(PROJECT_ROOT, "src", "adapters", "opencode", "create-server-hooks.ts") expect( - await Bun.file(join(PROJECT_ROOT, "src", "application", "ports", "resource-lifecycle.ts")).exists(), + await Bun.file( + join(PROJECT_ROOT, "..", "digital-pet-core", "src", "application", "ports", "resource-lifecycle.ts"), + ).exists(), ).toBeFalse() expect(findForbiddenImports(hook, await readFile(hook, "utf8"), FORBIDDEN_SERVER_HOOK_IMPORTS)).toEqual([]) }) diff --git a/tests/history-view-model.test.ts b/packages/opencode-digital-pet/tests/history-view-model.test.ts similarity index 86% rename from tests/history-view-model.test.ts rename to packages/opencode-digital-pet/tests/history-view-model.test.ts index c6cb208..657d18f 100644 --- a/tests/history-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/history-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import type { DigimonNode } from "../src/domain/digimon-node.ts" -import { buildHistoryViewModel } from "../src/tui/history-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" const eggNode: DigimonNode = { id: "0-001", @@ -59,7 +59,7 @@ describe("history view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -93,7 +93,7 @@ describe("history view model", () => { ], }, catalog, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ @@ -111,9 +111,13 @@ describe("history view model", () => { }) test("Given an empty or unavailable archive When building history Then it preserves the explicit result state", () => { - expect(buildHistoryViewModel({ kind: "empty" }, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ kind: "empty" }) + expect(buildHistoryViewModel({ kind: "empty" }, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "empty" }) expect( - buildHistoryViewModel({ kind: "unavailable", message: "Archive unavailable" }, catalog, DEFAULT_VPET_SETTINGS), + buildHistoryViewModel( + { kind: "unavailable", message: "Archive unavailable" }, + catalog, + DEFAULT_DIGITAL_PET_SETTINGS, + ), ).toEqual({ kind: "unavailable", message: "Archive unavailable" }) }) }) diff --git a/tests/monster-frame-catalog.test.ts b/packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts similarity index 97% rename from tests/monster-frame-catalog.test.ts rename to packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts index 3236e47..7d3780f 100644 --- a/tests/monster-frame-catalog.test.ts +++ b/packages/opencode-digital-pet/tests/monster-frame-catalog.test.ts @@ -6,7 +6,7 @@ import { MonsterFrameCatalog, STANDARD_ANIMATION_SEQUENCE, type MonsterFrameName, -} from "../src/data/monster-frame-catalog.ts" +} from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false diff --git a/tests/monster-frame-data.test.ts b/packages/opencode-digital-pet/tests/monster-frame-data.test.ts similarity index 94% rename from tests/monster-frame-data.test.ts rename to packages/opencode-digital-pet/tests/monster-frame-data.test.ts index 526e534..4dfa973 100644 --- a/tests/monster-frame-data.test.ts +++ b/packages/opencode-digital-pet/tests/monster-frame-data.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" -import { MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" -import { MONSTER_FRAME_DATA } from "../src/data/monster-frame-data.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { MONSTER_FRAME_DATA } from "@jcendal/digital-pet-core/data/monster-frame-data.ts" const AGUMON_WALK_1 = " ▄▄▄▄▄ \n ▄█ ▄▄ ▀▄ \n ▄▀▀ ▄██ ▀▄ \n ▀█▀▀ █ \n ▀██▀▀ ▄ █ \n █▄█ █▄▄ █ \n ▄▄▀▀▄▄▄▄▀ █ \n █▄█▄▄█ █▄█▄█▄█" diff --git a/tests/package-fixture.ts b/packages/opencode-digital-pet/tests/package-fixture.ts similarity index 80% rename from tests/package-fixture.ts rename to packages/opencode-digital-pet/tests/package-fixture.ts index 8209e7b..b508f7e 100644 --- a/tests/package-fixture.ts +++ b/packages/opencode-digital-pet/tests/package-fixture.ts @@ -3,9 +3,12 @@ import { tmpdir } from "node:os" import { basename, join } from "node:path" import { fileURLToPath } from "node:url" +import { stripInternalDependencies } from "../scripts/strip-internal-deps.ts" + const PROJECT_ROOT = fileURLToPath(new URL("../", import.meta.url)) +const MONOREPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url)) const REQUIRED_ARTIFACTS = ["index.js", "index.d.ts", "tui.js", "tui.d.ts", "cli.js"] as const -const EXCLUDED_COPY_DIRECTORIES = new Set([".git", ".omo", "dist", "node_modules"]) +const EXCLUDED_COPY_DIRECTORIES = new Set([".git", ".omo", "node_modules", "build", "dist"]) export type PackageFixture = { readonly root: string @@ -32,18 +35,28 @@ const runCommand = (command: Command): string => { } const copyPackage = async (root: string): Promise => { - const packageDirectory = join(root, "package") + const packagesDirectory = join(root, "packages") + const packageDirectory = join(packagesDirectory, "opencode-digital-pet") await cp(PROJECT_ROOT, packageDirectory, { recursive: true, filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), }) - await symlink(join(PROJECT_ROOT, "node_modules"), join(packageDirectory, "node_modules"), "dir") + await cp(join(PROJECT_ROOT, "../digital-pet-core"), join(packagesDirectory, "digital-pet-core"), { + recursive: true, + filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), + }) + await cp(join(PROJECT_ROOT, "../digital-pet-animation"), join(packagesDirectory, "digital-pet-animation"), { + recursive: true, + filter: (source) => !EXCLUDED_COPY_DIRECTORIES.has(basename(source)), + }) + await symlink(join(MONOREPO_ROOT, "node_modules"), join(packageDirectory, "node_modules"), "dir") return packageDirectory } const packPackage = async ( packageDirectory: string, ): Promise<{ readonly archivePath: string; readonly archiveMembers: readonly string[] }> => { + await stripInternalDependencies(packageDirectory) const archiveDirectory = join(packageDirectory, "archive") await mkdir(archiveDirectory) runCommand({ args: ["bun", "pm", "pack", "--destination", archiveDirectory], cwd: packageDirectory }) @@ -68,7 +81,7 @@ const linkConsumerDependencies = async (consumerDirectory: string): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-package-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-package-")) try { const packageDirectory = await copyPackage(root) const packed = await packPackage(packageDirectory) @@ -114,8 +127,8 @@ export const verifyPackedConsumer = async (fixture: PackageFixture): Promise { })({ renderer: { requestRender: () => undefined }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, - slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-vpet" } }, + slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-digital-pet" } }, }) try { tick() @@ -154,8 +167,8 @@ const render = async (inputs) => { } } -if (entryPlugin.id !== "opencode-vpet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") -if (tuiPlugin.id !== "opencode-vpet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") +if (entryPlugin.id !== "opencode-digital-pet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") +if (tuiPlugin.id !== "opencode-digital-pet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") const [known, unavailable, empty] = await Promise.all([render(partner("agumon")), render(partner("unknown-sprite")), render(partner(""))]) if (!known.includes("▄▀▀▀▀▀▄")) throw new Error("Known artwork fallback failed") if (!unavailable.includes("Artwork unavailable: unknown-sprite")) throw new Error("Unknown artwork fallback failed") diff --git a/packages/opencode-digital-pet/tests/partner-fixtures.ts b/packages/opencode-digital-pet/tests/partner-fixtures.ts new file mode 100644 index 0000000..7e5e050 --- /dev/null +++ b/packages/opencode-digital-pet/tests/partner-fixtures.ts @@ -0,0 +1,30 @@ +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" + +export const idlePartnerBattleFields = { + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, +} as const + +export const testPartner = (overrides: Partial = {}): Partner => ({ + partnerId: "partner-1", + generation: 1, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + createdAt: "2026-07-31T00:00:00.000Z", + retiredAt: null, + ...idlePartnerBattleFields, + ...overrides, +}) + +export const partnerProgression = ( + partner: Partner, + overrides: Partial = {}, +): PartnerProgression => ({ + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + ...overrides, +}) diff --git a/tests/persistence/sqlite-vpet-archive-history.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts similarity index 79% rename from tests/persistence/sqlite-vpet-archive-history.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts index 67dbf8e..6c08c22 100644 --- a/tests/persistence/sqlite-vpet-archive-history.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-archive-history.persistence.ts @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import { Database } from "bun:sqlite" -import { createSqliteVpetArchiveReader } from "../../src/adapters/sqlite/sqlite-vpet-archive-reader.ts" +import { createSqliteDigitalPetArchiveReader } from "../../src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts" import { openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" -import { runVpetSetCommand } from "../../src/commands/vpet-set.ts" -import { DEFAULT_VPET_SETTINGS } from "../../src/config/defaults.ts" -import type { ResolvedVpetSettings } from "../../src/config/types.ts" -import type { DigimonCatalog } from "../../src/data/catalog.ts" -import type { DigimonNode } from "../../src/domain/digimon-node.ts" -import { buildDexViewModel } from "../../src/tui/dex-view-model.ts" -import { buildHistoryViewModel } from "../../src/tui/history-view-model.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { runDigitalPetSetCommand } from "../../src/commands/digital-pet-set.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { ResolvedDigitalPetSettings } from "@jcendal/digital-pet-core/config/types.ts" +import type { DigimonCatalog } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/domain/digimon-node.ts" +import { buildDexViewModel } from "@jcendal/digital-pet-core/view-models/dex-view-model.ts" +import { buildHistoryViewModel } from "@jcendal/digital-pet-core/view-models/history-view-model.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -20,7 +20,7 @@ import { spawn, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" const catalogNodes = [ { @@ -67,9 +67,9 @@ const catalog: DigimonCatalog = { } const englishSettings = { - ...DEFAULT_VPET_SETTINGS, + ...DEFAULT_DIGITAL_PET_SETTINGS, language: "en", -} as const satisfies ResolvedVpetSettings +} as const satisfies ResolvedDigitalPetSettings test("Given a set-only catalog ID without canonical events When projecting the Dex Then its English and Japanese names remain redacted", () => { const archive = { @@ -85,7 +85,7 @@ test("Given a set-only catalog ID without canonical events When projecting the D ], } as const const englishDex = buildDexViewModel(archive, catalog, englishSettings) - const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_VPET_SETTINGS) + const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS) if (englishDex.kind !== "available" || japaneseDex.kind !== "available") throw new Error("Expected available Dex models") @@ -95,7 +95,7 @@ test("Given a set-only catalog ID without canonical events When projecting the D expect(japaneseDex.rows.map((row) => row.name)).not.toContain("Set-only Japanese") }) -describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet archive history persistence", () => { let tempRoot: TempTestRoot beforeEach(async () => { @@ -106,7 +106,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () }) test("Given canonical generations and a set-only control When the archive reopens Then discovery and history expose only canonical event state", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) let repositoryClosed = false try { @@ -115,7 +115,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () expect( repository.applyUsageReceipt( usageReceipt("receipt-same-snapshot", "event-c-same-snapshot", 100), - (partner) => ({ currentNodeId: "1-001", gauge: partner.gauge + 100, isTerminal: false }), + (partner) => ({ + currentNodeId: "1-001", + gauge: partner.gauge + 100, + isTerminal: false, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + }), ), ).toEqual({ kind: "applied" }) expect( @@ -123,11 +129,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () currentNodeId: "2-001", gauge: partner.gauge + 100, isTerminal: false, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })), ).toEqual({ kind: "applied" }) const secondPartner = spawn(repository, "2026-07-30T12:10:00.000Z") - const setResult = await runVpetSetCommand( + const setResult = await runDigitalPetSetCommand( repository, { sessionID: "session-history", messageID: "set-history", arguments: "7-001" }, async () => catalog, @@ -158,7 +166,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () const closeSpy = spyOn(Database.prototype, "close") try { - const archive = createSqliteVpetArchiveReader({ databasePath: repository.databasePath }).getArchive() + const archive = createSqliteDigitalPetArchiveReader({ databasePath: repository.databasePath }).getArchive() expect(closeSpy).toHaveBeenCalledTimes(1) if (archive.kind !== "available") throw new Error("Expected populated archive after canonical writes") @@ -172,7 +180,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () ) const englishDex = buildDexViewModel(archive, catalog, englishSettings) - const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_VPET_SETTINGS) + const japaneseDex = buildDexViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS) if (englishDex.kind !== "available" || japaneseDex.kind !== "available") throw new Error("Expected available Dex models") expect(englishDex.rows.find((row) => row.id === "7-001")).toEqual({ @@ -190,7 +198,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet archive history persistence", () expect(englishDex.rows.map((row) => row.name)).not.toContain("Set-only English") expect(japaneseDex.rows.map((row) => row.name)).not.toContain("Set-only Japanese") - expect(buildHistoryViewModel(archive, catalog, DEFAULT_VPET_SETTINGS)).toEqual({ + expect(buildHistoryViewModel(archive, catalog, DEFAULT_DIGITAL_PET_SETTINGS)).toEqual({ kind: "available", generations: [ { diff --git a/tests/persistence/sqlite-vpet-control.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts similarity index 86% rename from tests/persistence/sqlite-vpet-control.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts index 5a788a9..3932a4a 100644 --- a/tests/persistence/sqlite-vpet-control.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-control.persistence.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import { join } from "node:path" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" type TempTestRoot = { @@ -24,7 +24,7 @@ type SpawnControlCase = { const SPAWNED_AT = "2026-07-30T12:00:00.000Z" -const spawn = (repository: Awaited>, createdAt = SPAWNED_AT) => +const spawn = (repository: Awaited>, createdAt = SPAWNED_AT) => repository.spawnPartner({ currentNodeId: "0-001", gauge: 0, @@ -39,7 +39,7 @@ const getControlState = (databasePath: string): ControlState => { const controlState = database .query("SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1") .get() - if (controlState === null) throw new Error("Expected singleton VPet control state") + if (controlState === null) throw new Error("Expected singleton Digital Pet control state") return controlState } finally { database.close() @@ -53,11 +53,11 @@ const spawnControlCases: SpawnControlCase[] = [ { name: "frozen and cheat", freeze: true, cheatNodeId: "6-001" }, ] -describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet control persistence", () => { let tempRoot: TempTestRoot beforeEach(async () => { - const root = await mkdtemp(join(process.cwd(), ".tmp-vpet-control-")) + const root = await mkdtemp(join(process.cwd(), ".tmp-digital-pet-control-")) const appDataRoot = join(root, "app-data") await mkdir(appDataRoot, { recursive: true }) tempRoot = { root, appDataRoot } @@ -68,7 +68,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { }) test("Given SQLite control state When freeze and unfreeze repeat Then each single-row transition is idempotent and unfreeze preserves cheat", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(repository.freeze()).toEqual({ kind: "frozen" }) @@ -83,7 +83,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { }) test("Given no active partner When cheat is set to the same and different IDs Then only the cheat control changes with typed idempotent outcomes", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(repository.setCheatNode("2-001")).toEqual({ kind: "set", cheatNodeId: "2-001" }) @@ -98,7 +98,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { test("Given coexisting frozen and cheat controls When the repository reopens Then both values persist and set preserves freeze", async () => { const databasePath = join(tempRoot.root, "control-reopen.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(repository.freeze()).toEqual({ kind: "frozen" }) @@ -107,7 +107,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { await repository.close() } - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(getControlState(databasePath)).toEqual({ frozen: 1, cheat_node_id: "3-001" }) @@ -121,7 +121,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { test.each(spawnControlCases)( "Given $name controls When a replacement spawns Then it resets controls and creates the next egg atomically", async ({ freeze, cheatNodeId }) => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) @@ -143,7 +143,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet control persistence", () => { ) test("Given frozen and cheat controls When the egg insert fails after spawn reset Then the immediate transaction restores controls and the active partner", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) diff --git a/tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts similarity index 84% rename from tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts index 1a5423f..126478c 100644 --- a/tests/persistence/sqlite-vpet-repository.control-ledger.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.control-ledger.persistence.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { join } from "node:path" import { openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -13,9 +13,9 @@ import { spawn, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" -describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository control ledger", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -25,7 +25,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given a cheat and frozen control state When a receipt arrives Then cheat takes precedence and only its control receipt is written", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(repository) const database = openWritableDatabase(repository.databasePath) @@ -48,14 +48,14 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => test("Given a control-ledger receipt When a repository reopens and replays it Then both ledgers deduplicate it without changing trainer totals", async () => { const databasePath = join(tempRoot.root, "control-reopen.db") - const firstRepository = await createSqliteVpetRepository({ databasePath }) + const firstRepository = await createSqliteDigitalPetRepository({ databasePath }) try { setReceiptMode(databasePath, "frozen") expect(applyReceipt(firstRepository, "receipt-reopen", "usage-reopen", 100)).toEqual({ kind: "applied" }) } finally { await firstRepository.close() } - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { expect(applyReceipt(reopenedRepository, "receipt-reopen", "usage-reopen-replay", 999)).toEqual({ kind: "duplicate", @@ -69,8 +69,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given two repositories sharing a control-ledger receipt When both replay it Then exactly one control transaction changes trainer totals", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { setReceiptMode(firstRepository.databasePath, "frozen") expect(applyReceipt(firstRepository, "receipt-control-replay", "usage-control-replay", 100)).toEqual({ @@ -90,7 +90,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given a receipt first written to the canonical ledger When control changes before replay Then the control ledger cannot apply it again", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(repository) expect(applyReceipt(repository, "receipt-cross-ledger", "usage-canonical", 100)).toEqual({ kind: "applied" }) @@ -107,7 +107,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository control ledger", () => }) test("Given frozen mode When the receipt insert fails Then the immediate transaction rolls back the trainer total and control receipt", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { setReceiptMode(repository.databasePath, "frozen") const database = openWritableDatabase(repository.databasePath) diff --git a/tests/persistence/sqlite-vpet-repository.fixtures.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts similarity index 82% rename from tests/persistence/sqlite-vpet-repository.fixtures.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts index 5a89ed1..910b78c 100644 --- a/tests/persistence/sqlite-vpet-repository.fixtures.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.fixtures.ts @@ -2,17 +2,17 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import { join } from "node:path" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import type { UsageReceiptMetadata } from "../../src/application/models/usage.ts" -import type { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" export type TempTestRoot = { readonly root: string; readonly appDataRoot: string } -export type Repository = Awaited> +export type Repository = Awaited> export type ReceiptMode = "normal" | "frozen" | "cheat" const SPAWNED_AT = "2026-07-30T12:00:00.000Z" export const createTempTestRoot = async (): Promise => { - const root = await mkdtemp(join(process.cwd(), ".tmp-vpet-persistence-")) + const root = await mkdtemp(join(process.cwd(), ".tmp-digital-pet-persistence-")) const appDataRoot = join(root, "app-data") await mkdir(appDataRoot, { recursive: true }) return { root, appDataRoot } @@ -43,6 +43,8 @@ export const applyReceipt = (repository: Repository, receiptKey: string, eventId currentNodeId: partner.currentNodeId, gauge: partner.gauge + tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })) export const setReceiptMode = (databasePath: string, mode: ReceiptMode): void => { diff --git a/tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts similarity index 76% rename from tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts index 4dbee42..1d7bd7c 100644 --- a/tests/persistence/sqlite-vpet-repository.lifecycle.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.lifecycle.persistence.ts @@ -1,13 +1,14 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import { mkdir } from "node:fs/promises" import { join } from "node:path" import { Database } from "bun:sqlite" import { openReadonlyDatabase, openWritableDatabase } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import type { Partner } from "../../src/domain/partner.ts" +import type { Partner } from "@jcendal/digital-pet-core/domain/partner.ts" import { - createSqliteVpetRepository, + createSqliteDigitalPetRepository, resolveHostDatabasePath, -} from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +} from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -17,9 +18,9 @@ import { spawnInput, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" -describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository lifecycle", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -49,19 +50,34 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given a temp app-data root When the repository initializes Then it creates the migrated host-wide database path", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { - expect(repository.databasePath).toBe(join(tempRoot.appDataRoot, "opencode-vpet", "pet.db")) + expect(repository.databasePath).toBe(join(tempRoot.appDataRoot, "opencode-digital-pet", "pet.db")) expect(resolveHostDatabasePath({ appDataRoot: tempRoot.appDataRoot })).toBe(repository.databasePath) expect(await Bun.file(repository.databasePath).exists()).toBe(true) - expect(repository.getAppliedMigrations()).toEqual([1, 2, 3]) + expect(repository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) + } finally { + await repository.close() + } + }) + + test("an existing legacy database is opened without creating a second pet", async () => { + const legacyDirectory = join(tempRoot.appDataRoot, "opencode-vpet") + await mkdir(legacyDirectory, { recursive: true }) + const legacyPath = join(legacyDirectory, "pet.db") + const legacyDatabase = new Database(legacyPath) + legacyDatabase.close() + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + try { + expect(repository.databasePath).toBe(legacyPath) + expect(repository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) } finally { await repository.close() } }) test("Given application spawn and receipt contracts When SQLite persists them Then reads and transaction callbacks use canonical domain Partner state", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const spawned: Partner = repository.spawnPartner(spawnInput()) const callbackPartners: Partner[] = [] @@ -69,7 +85,13 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { usageReceipt("receipt-domain-contract", "usage-domain-contract", 100), (partner) => { callbackPartners.push(partner) - return { currentNodeId: partner.currentNodeId, gauge: partner.gauge + 100, isTerminal: partner.isTerminal } + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge + 100, + isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } }, ) expect(outcome).toEqual({ kind: "applied" }) @@ -82,7 +104,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { test("Given a populated SQLite database When the repository reopens it Then migrations preserve its partner, receipt, event, and trainer state", async () => { const databasePath = join(tempRoot.root, "existing.db") - const firstRepository = await createSqliteVpetRepository({ databasePath }) + const firstRepository = await createSqliteDigitalPetRepository({ databasePath }) const partner = spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-existing", "usage-existing", 100)).toEqual({ kind: "applied" }) const expectedPartner = firstRepository.getActivePartner() @@ -90,9 +112,9 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { const expectedReceipts = firstRepository.listUsageReceipts() const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { - expect(reopenedRepository.getAppliedMigrations()).toEqual([1, 2, 3]) + expect(reopenedRepository.getAppliedMigrations()).toEqual([1, 2, 3, 4]) expect(reopenedRepository.getActivePartner()).toEqual(expectedPartner) expect(reopenedRepository.listPartnerEvents(partner.partnerId)).toEqual(expectedEvents) expect(reopenedRepository.listUsageReceipts()).toEqual(expectedReceipts) @@ -103,8 +125,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing a database When distinct usage receipts arrive Then both receipts and their token totals survive", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-1", "usage-1", 100)).toEqual({ kind: "applied" }) @@ -122,8 +144,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing a database When they apply the same receipt Then exactly one transaction changes the state", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) expect(applyReceipt(firstRepository, "receipt-1", "usage-1", 100)).toEqual({ kind: "applied" }) @@ -138,7 +160,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given an active generation When a replacement is spawned Then retirement, events, and the next unique generation commit atomically", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const firstPartner = spawn(repository) const secondPartner = spawn(repository, "2026-07-30T12:10:00.000Z") @@ -160,8 +182,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository lifecycle", () => { }) test("Given two repositories sharing one active partner When both spawn replacements Then generations remain unique and exactly one partner is active", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const secondRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const secondRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { spawn(firstRepository) const replacements = await Promise.all([ diff --git a/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts new file mode 100644 index 0000000..a54e2f7 --- /dev/null +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.persistence.ts @@ -0,0 +1,4 @@ +import "./sqlite-digital-pet-repository.lifecycle.persistence.ts" +import "./sqlite-digital-pet-repository.receipts.persistence.ts" +import "./sqlite-digital-pet-repository.control-ledger.persistence.ts" +import "./sqlite-digital-pet-archive-history.persistence.ts" diff --git a/tests/persistence/sqlite-vpet-repository.receipts.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts similarity index 89% rename from tests/persistence/sqlite-vpet-repository.receipts.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts index b4021c8..38093be 100644 --- a/tests/persistence/sqlite-vpet-repository.receipts.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-digital-pet-repository.receipts.persistence.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" import { applyReceipt, @@ -12,7 +12,7 @@ import { type ReceiptMode, type TempTestRoot, usageReceipt, -} from "./sqlite-vpet-repository.fixtures.ts" +} from "./sqlite-digital-pet-repository.fixtures.ts" type ReceiptMatrixCase = { readonly mode: ReceiptMode @@ -148,7 +148,7 @@ const receiptMatrixCases: ReceiptMatrixCase[] = [ }, ] -describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { +describe.if(isBunSqliteAvailable)("sqlite digital-pet repository receipts", () => { let tempRoot: TempTestRoot beforeEach(async () => { tempRoot = await createTempTestRoot() @@ -169,7 +169,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { expectedTrainerTotal, expectedEvolutionCalls, }) => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) let evolutionCalls = 0 try { if (hasActivePartner) spawn(repository) @@ -181,6 +181,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge + receipt.tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, })), ).toEqual({ kind: "applied" }) const outcome = repository.applyUsageReceipt(receipt, (partner) => { @@ -189,6 +191,8 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge + receipt.tokenDelta, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, } }) expect(outcome).toEqual({ kind: expectedOutcome }) @@ -203,7 +207,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { ) test("Given no active partner When a receipt arrives Then no receipt or trainer state is written", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { expect(applyReceipt(repository, "receipt-1", "usage-1", 100)).toEqual({ kind: "no_active_partner" }) expect(repository.getTrainerState()).toEqual({ totalTokens: 0 }) @@ -214,7 +218,7 @@ describe.if(isBunSqliteAvailable)("sqlite vpet repository receipts", () => { }) test("Given an active partner When the evolution callback throws Then the partner, trainer, events, and receipts remain unchanged", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) try { const partner = spawn(repository) const baselinePartner = repository.getActivePartner() diff --git a/tests/persistence/sqlite-migrations.persistence.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts similarity index 67% rename from tests/persistence/sqlite-migrations.persistence.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts index 75b6b56..d6457b7 100644 --- a/tests/persistence/sqlite-migrations.persistence.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-migrations.persistence.ts @@ -5,11 +5,10 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { createExecutor } from "../../src/adapters/sqlite/bun-sqlite-driver.ts" -import { runMigrations } from "../../src/adapters/sqlite/sqlite-migrations.ts" +import { runMigrations } from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-migrations.ts" +import type { QueryValue } from "@jcendal/digital-pet-core/ports/sqlite-executor.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" -type QueryValue = string | number | null - type MigrationExecutor = { readonly statements: string[] transactionCount: number @@ -42,7 +41,7 @@ const createMigrationExecutor = (): MigrationExecutor => ({ }) const withDatabase = async (operation: (database: Database) => void): Promise => { - const root = await mkdtemp(join(tmpdir(), "vpet-migrations-")) + const root = await mkdtemp(join(tmpdir(), "digital-pet-migrations-")) const database = new Database(join(root, "pet.db")) try { @@ -57,25 +56,38 @@ const createV2Database = (database: Database): void => { runMigrations(createExecutor(database)) database.run("DROP TABLE vpet_control_receipts") database.run("DROP TABLE vpet_control_state") - database.run("DELETE FROM schema_migrations WHERE version = 3") + database.run("ALTER TABLE partners DROP COLUMN pending_evolution_target_id") + database.run("ALTER TABLE partners DROP COLUMN battle_opponent_node_id") + database.run("DELETE FROM schema_migrations WHERE version IN (3, 4)") } +const createV3Database = (database: Database): void => { + runMigrations(createExecutor(database)) + database.run("ALTER TABLE partners DROP COLUMN pending_evolution_target_id") + database.run("ALTER TABLE partners DROP COLUMN battle_opponent_node_id") + database.run("DELETE FROM schema_migrations WHERE version = 4") +} + +const ALL_MIGRATIONS = [1, 2, 3, 4] as const + +const partnerLegacyColumns = "partner_id, generation, current_node_id, gauge, is_terminal, created_at, retired_at" + describe("sqlite migrations", () => { test("Given an unmigrated database When migrations run Then inspection and all migration writes share one immediate transaction", () => { const executor = createMigrationExecutor() - expect(runMigrations(executor)).toEqual([1, 2, 3]) + expect(runMigrations(executor)).toEqual(ALL_MIGRATIONS) expect(executor.transactionCount).toBe(1) expect(executor.statements.filter((statement) => statement.includes("INSERT INTO schema_migrations"))).toHaveLength( - 3, + ALL_MIGRATIONS.length, ) }) test.if(isBunSqliteAvailable)( - "Given a fresh database When migration 3 runs Then it creates one default singleton control state and a constrained disabled-mode receipt ledger", + "Given a fresh database When migrations run Then it creates one default singleton control state and a constrained disabled-mode receipt ledger", async () => { await withDatabase((database) => { - expect(runMigrations(createExecutor(database))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) expect(database.query("SELECT control_id, frozen, cheat_node_id FROM vpet_control_state").all()).toEqual([ { control_id: 1, frozen: 0, cheat_node_id: null }, ]) @@ -110,11 +122,16 @@ describe("sqlite migrations", () => { database.run( "INSERT INTO usage_receipts (receipt_key, partner_id, event_id, token_delta, cost, created_at) VALUES ('receipt-v2', 'partner-v2', 'event-v2', 7, 1.5, '2026-08-22T00:00:00.000Z')", ) - const legacyTables = ["trainer_state", "partners", "partner_events", "usage_receipts"] + const legacyTables = ["trainer_state", "partner_events", "usage_receipts"] const legacyRows = legacyTables.map((table) => database.query(`SELECT * FROM ${table}`).all()) + const legacyPartners = database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all() - expect(runMigrations(createExecutor(database))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) expect(legacyTables.map((table) => database.query(`SELECT * FROM ${table}`).all())).toEqual(legacyRows) + expect(database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all()).toEqual(legacyPartners) + expect( + database.query("SELECT pending_evolution_target_id, battle_opponent_node_id FROM partners").all(), + ).toEqual([{ pending_evolution_target_id: null, battle_opponent_node_id: null }]) }) }, ) @@ -152,15 +169,15 @@ describe("sqlite migrations", () => { test.if(isBunSqliteAvailable)( "Given a migrated database When migration 3 reruns or two writers open it Then it retains exactly one default control row", async () => { - const root = await mkdtemp(join(tmpdir(), "vpet-migrations-")) + const root = await mkdtemp(join(tmpdir(), "digital-pet-migrations-")) const databasePath = join(root, "pet.db") const first = new Database(databasePath) const second = new Database(databasePath) try { - expect(runMigrations(createExecutor(first))).toEqual([1, 2, 3]) - expect(runMigrations(createExecutor(first))).toEqual([1, 2, 3]) - expect(runMigrations(createExecutor(second))).toEqual([1, 2, 3]) + expect(runMigrations(createExecutor(first))).toEqual(ALL_MIGRATIONS) + expect(runMigrations(createExecutor(first))).toEqual(ALL_MIGRATIONS) + expect(runMigrations(createExecutor(second))).toEqual(ALL_MIGRATIONS) expect(first.query("SELECT control_id, frozen, cheat_node_id FROM vpet_control_state").all()).toEqual([ { control_id: 1, frozen: 0, cheat_node_id: null }, ]) @@ -171,4 +188,23 @@ describe("sqlite migrations", () => { } }, ) + + test.if(isBunSqliteAvailable)( + "Given a migrated version 3 database When migration 4 runs Then it adds nullable evolution battle columns to partners", + async () => { + await withDatabase((database) => { + createV3Database(database) + database.run( + "INSERT INTO partners (partner_id, generation, current_node_id, gauge, is_terminal, created_at, retired_at) VALUES ('partner-v3', 1, '0-001', 7, 0, '2026-08-22T00:00:00.000Z', NULL)", + ) + const legacyPartner = database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all() + + expect(runMigrations(createExecutor(database))).toEqual(ALL_MIGRATIONS) + expect(database.query(`SELECT ${partnerLegacyColumns} FROM partners`).all()).toEqual(legacyPartner) + expect( + database.query("SELECT pending_evolution_target_id, battle_opponent_node_id FROM partners").all(), + ).toEqual([{ pending_evolution_target_id: null, battle_opponent_node_id: null }]) + }) + }, + ) }) diff --git a/tests/persistence/sqlite-usage-event-toast.persistence.test.ts b/packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts similarity index 75% rename from tests/persistence/sqlite-usage-event-toast.persistence.test.ts rename to packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts index efaa6be..33d982b 100644 --- a/tests/persistence/sqlite-usage-event-toast.persistence.test.ts +++ b/packages/opencode-digital-pet/tests/persistence/sqlite-usage-event-toast.persistence.test.ts @@ -2,12 +2,17 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { AssistantMessage, Event } from "@opencode-ai/sdk" import { createServerHooks } from "../../src/adapters/opencode/create-server-hooks.ts" -import type { VpetToastPayload } from "../../src/adapters/opencode/vpet-toast.ts" -import { DIGIMON_CATALOG } from "../../src/data/catalog.ts" -import { STAGE_GAUGE_THRESHOLDS } from "../../src/domain/evolution.ts" -import { createSqliteVpetRepository } from "../../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import type { DigitalPetToastPayload } from "../../src/adapters/opencode/digital-pet-toast.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" +import { createSqliteDigitalPetRepository } from "../../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "../sqlite-capability.ts" -import { createTempTestRoot, removeTempTestRoot, spawn, type TempTestRoot } from "./sqlite-vpet-repository.fixtures.ts" +import { + createTempTestRoot, + removeTempTestRoot, + spawn, + type TempTestRoot, +} from "./sqlite-digital-pet-repository.fixtures.ts" const completedMessage = (id: string): AssistantMessage => ({ id, @@ -29,10 +34,10 @@ const idleEvent = { type: "session.idle", properties: { sessionID: "session-toas const evolutionThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1 }) const createHooks = ( - repository: Awaited>, - delivered: VpetToastPayload[], + repository: Awaited>, + delivered: DigitalPetToastPayload[], messages: readonly AssistantMessage[] = [], - notify: (payload: VpetToastPayload) => Promise = async (payload) => { + notify: (payload: DigitalPetToastPayload) => Promise = async (payload) => { delivered.push(payload) }, ) => @@ -64,8 +69,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a persisted evolution receipt When direct ingestion handles it Then exactly one evolution toast is delivered", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -74,7 +79,13 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => expect(delivered).toEqual([ expect.objectContaining({ title: "Digi-evolution", variant: "success", duration: 5_000 }), ]) - expect(repository.getActivePartner()).toEqual(expect.objectContaining({ currentNodeId: "1-001", gauge: 0 })) + expect(repository.getActivePartner()).toEqual( + expect.objectContaining({ + currentNodeId: "1-001", + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual(["message:direct-only"]) expect(repository.getTrainerState()).toEqual({ totalTokens: 1 }) } finally { @@ -83,15 +94,21 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an idle-only evolution When reconciliation receives it Then one toast and durable receipt are recorded", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) await dispatch(createHooks(repository, delivered, [completedMessage("idle-only")]), idleEvent) expect(delivered).toHaveLength(1) - expect(repository.getActivePartner()).toEqual(expect.objectContaining({ currentNodeId: "1-001", gauge: 0 })) + expect(repository.getActivePartner()).toEqual( + expect.objectContaining({ + currentNodeId: "1-001", + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual(["message:idle-only"]) expect(repository.getTrainerState()).toEqual({ totalTokens: 1 }) } finally { @@ -100,8 +117,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a direct receipt replayed by idle reconciliation When both hooks run Then SQLite suppresses the duplicate toast and state change", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -121,8 +138,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an idle receipt replayed by direct ingestion When both hooks run Then SQLite suppresses the duplicate toast and state change", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -142,8 +159,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given an evolved receipt before repository reopen When idle replays it through a new hook Then persisted duplicate state prevents another toast", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] spawn(firstRepository) await dispatch(createHooks(firstRepository, delivered), directEvent("reopen-replay")) @@ -153,7 +170,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { await dispatch(createHooks(reopenedRepository, delivered, [completedMessage("reopen-replay")]), idleEvent) @@ -167,8 +184,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given a notifier rejection after an evolution commits When the receipt replays after reopen Then the durable duplicate prevents retry and a second toast", async () => { - const firstRepository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const firstRepository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] let attempts = 0 spawn(firstRepository) @@ -185,7 +202,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => const expectedTrainer = firstRepository.getTrainerState() await firstRepository.close() - const reopenedRepository = await createSqliteVpetRepository({ databasePath }) + const reopenedRepository = await createSqliteDigitalPetRepository({ databasePath }) try { await dispatch(createHooks(reopenedRepository, delivered, [completedMessage("notifier-failure")]), idleEvent) @@ -200,8 +217,8 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => }) test("Given two new completed messages and one replay in idle order When reconciliation processes them Then it emits only new evolutions in message order", async () => { - const repository = await createSqliteVpetRepository({ appDataRoot: tempRoot.appDataRoot }) - const delivered: VpetToastPayload[] = [] + const repository = await createSqliteDigitalPetRepository({ appDataRoot: tempRoot.appDataRoot }) + const delivered: DigitalPetToastPayload[] = [] try { spawn(repository) @@ -214,10 +231,7 @@ describe.if(isBunSqliteAvailable)("sqlite usage event toast persistence", () => idleEvent, ) - expect(delivered.map((payload) => payload.message)).toEqual([ - "Digiegg evolved into Argomon!", - "Argomon evolved into Argomon!", - ]) + expect(delivered.map((payload) => payload.title)).toEqual(["Digi-evolution", "Evolution battle"]) expect(repository.listUsageReceipts().map((receipt) => receipt.receiptKey)).toEqual([ "message:ordered-first", "message:ordered-second", diff --git a/tests/port-boundary.test.ts b/packages/opencode-digital-pet/tests/port-boundary.test.ts similarity index 78% rename from tests/port-boundary.test.ts rename to packages/opencode-digital-pet/tests/port-boundary.test.ts index 09b23d6..830982f 100644 --- a/tests/port-boundary.test.ts +++ b/packages/opencode-digital-pet/tests/port-boundary.test.ts @@ -4,11 +4,11 @@ import type { JSX } from "@opentui/solid" import { chmod, mkdir, readFile, writeFile } from "node:fs/promises" import { join } from "node:path" -import { MonsterFrame, MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" +import { MonsterFrame, MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" import entryPlugin, { createCommandConfig, plugin } from "../src/index.ts" import tuiPlugin from "../src/tui.tsx" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" -import { mirrorMonsterFrame } from "../src/tui/monster-artwork-mirror.ts" +import { mirrorMonsterFrame } from "@jcendal/digital-pet-animation/idle/monster-artwork-mirror.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" import { createPackageFixture, fixtureEntryUrl, @@ -141,7 +141,7 @@ const renderBuiltSidebar = async ( slots: { register: (registered: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => { slot = registered.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -190,19 +190,19 @@ describe("lightweight package boundary", () => { { timeout: 15_000 }, ) - test("Given the core entrypoint When OpenCode configures it Then it registers all VPet commands", async () => { - expect(entryPlugin).toEqual({ id: "opencode-vpet", server: plugin }) + test("Given the core entrypoint When OpenCode configures it Then it registers all Digital Pet commands", async () => { + expect(entryPlugin).toEqual({ id: "opencode-digital-pet", server: plugin }) expect(createCommandConfig()).toEqual({ - "vpet-spawn": { + "digital-pet-spawn": { template: "Spawn a new virtual pet.", }, - "vpet-freeze": { + "digital-pet-freeze": { template: "Freeze virtual pet progression.", }, - "vpet-unfreeze": { + "digital-pet-unfreeze": { template: "Unfreeze virtual pet progression.", }, - "vpet-set": { + "digital-pet-set": { template: "Set the virtual pet to a Digimon ID: $ARGUMENTS", }, }) @@ -213,7 +213,7 @@ describe("lightweight package boundary", () => { const builtModule = await import(fixtureEntryUrl(fixture, "index.js")) const builtEntry = builtModule.default - expect(builtEntry.id).toBe("opencode-vpet") + expect(builtEntry.id).toBe("opencode-digital-pet") expect(builtEntry.server).toBeFunction() expect(builtModule.createCommandConfig()).toEqual(createCommandConfig()) }) @@ -233,7 +233,8 @@ describe("lightweight package boundary", () => { Math.random = originalRandom } - expect(digitama.frame).toContain(" ▄▄▀▀▀▀▄▄") + expect(digitama.frame).toContain("Digitama") + expect(digitama.outputs.some((output) => output.kind === "digitama" && output.result.kind === "frame")).toBe(true) expect(digitama.subscriptions).toEqual(["message.updated", "message.part.updated", "session.status"]) expect(digitama.unsubscribeCalls).toBe(3) const rightFacingFrame = firstRightFacingAnimation(walking) @@ -262,6 +263,7 @@ describe("lightweight package boundary", () => { ]), ) expect(fixture.archiveMembers.some((member) => /\.(?:txt|png|npy)$/.test(member))).toBe(false) + expect(fixture.archiveMembers.some((member) => member.endsWith(".map"))).toBe(false) expect( fixture.archiveMembers.some( (member) => member.startsWith("package/src/") || member.startsWith("package/assets/"), @@ -272,15 +274,15 @@ describe("lightweight package boundary", () => { await Bun.write( consumerScript, ` -import entryPlugin, { createCommandConfig } from "opencode-vpet" -import serverPlugin from "opencode-vpet/server" +import entryPlugin, { createCommandConfig } from "opencode-digital-pet" +import serverPlugin from "opencode-digital-pet/server" import { testRender } from "@opentui/solid" const settings = { language: "jp", stageLabels: { en: { egg: "DigiEgg", babyI: "Baby I", babyII: "Baby II", child: "Child", adult: "Adult", perfect: "Perfect", ultimate: "Ultimate", superUltimate: "SuperUltimate" }, jp: { egg: "Digitama", babyI: "Baby I", babyII: "Baby II", child: "Child", adult: "Adult", perfect: "Perfect", ultimate: "Ultimate", superUltimate: "SuperUltimate" } }, stageThresholds: { egg: 1000, babyI: 5000, babyII: 25000, child: 100000, adult: 500000, perfect: 2500000, ultimate: 5000000, superUltimate: 10000000 } } const originalRandom = Math.random try { Math.random = () => 0 -const { default: tuiPlugin, createTui } = await import("opencode-vpet/tui") +const { default: tuiPlugin, createTui } = await import("opencode-digital-pet/tui") let registeredLayer; let dialogOpened = false; const partner = (sprite, stage) => ({ kind: "partner", node: { id: "3-001", nameEn: "Agumon", nameJp: "Agumon", nextEvolutions: [], sprite, stage, url: "https://example.test/partner" }, gauge: 42, isTerminal: false, trainerTotalTokens: 100 }) const render = async (loadInputs, ticks, refreshAfterMount) => { let tick; let poll @@ -289,18 +291,18 @@ const render = async (loadInputs, ticks, refreshAfterMount) => { let renderer const subscriptions = [] const outputs = [] - const fakeArchiveReader = { async *readVpetArchiveEvents() { yield { type: "vpet_spawned", timestamp: Date.now() } } }; await createTui(loadInputs, settings, { scheduleVisualInterval: (callback) => { tick = callback; return () => undefined }, schedulePollTimeout: (callback) => { poll = callback; return () => undefined }, onAnimation: (output) => outputs.push(output), archiveReader: fakeArchiveReader })({ event: { on: (type) => { subscriptions.push(type); return () => undefined } }, renderer: { requestRender: () => renderer?.requestRender() }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-vpet" } }, keymap: { registerLayer: (layer) => { registeredLayer = layer; return () => undefined } }, ui: { dialog: { replace: (el) => { dialogOpened = true; return undefined }, clear: () => undefined, setSize: () => undefined }, Dialog: (props) => props.children }, theme: { spacing: { 1: 1 }, colors: { fg: "white" } } }) - try { const setup = await testRender(slot, { width: 81, height: 24 }); renderer = setup.renderer; await setup.flush(); setup.renderer.resize(80, 24); await setup.flush(); await Promise.resolve(); await Promise.resolve(); refreshAfterMount?.(); poll?.(); await Promise.resolve(); await Promise.resolve(); for (let index = 0; index < ticks; index += 1) tick(); await setup.renderOnce(); const frame = setup.captureCharFrame(); setup.renderer.destroy(); if (registeredLayer) { const dexCmd = registeredLayer.commands.find(c => c.name === "vpet.dex"); if (dexCmd) dexCmd.run(); } return { frame, subscriptions, outputs } } finally { dispose() } + const fakeArchiveReader = { async *readDigitalPetArchiveEvents() { yield { type: "digital_pet_spawned", timestamp: Date.now() } } }; await createTui(loadInputs, settings, { scheduleVisualInterval: (callback) => { tick = callback; return () => undefined }, schedulePollTimeout: (callback) => { poll = callback; return () => undefined }, onAnimation: (output) => outputs.push(output), archiveReader: fakeArchiveReader })({ event: { on: (type) => { subscriptions.push(type); return () => undefined } }, renderer: { requestRender: () => renderer?.requestRender() }, lifecycle: { onDispose: (callback) => { dispose = callback; return () => undefined } }, slots: { register: (registered) => { slot = registered.slots.sidebar_content; return "opencode-digital-pet" } }, keymap: { registerLayer: (layer) => { registeredLayer = layer; return () => undefined } }, ui: { dialog: { replace: (el) => { dialogOpened = true; return undefined }, clear: () => undefined, setSize: () => undefined }, Dialog: (props) => props.children }, theme: { spacing: { 1: 1 }, colors: { fg: "white" } } }) + try { const setup = await testRender(slot, { width: 81, height: 24 }); renderer = setup.renderer; await setup.flush(); setup.renderer.resize(80, 24); await setup.flush(); await Promise.resolve(); await Promise.resolve(); refreshAfterMount?.(); poll?.(); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 50)); for (let index = 0; index < ticks; index += 1) tick(); await setup.renderOnce(); const frame = setup.captureCharFrame(); setup.renderer.destroy(); if (registeredLayer) { const dexCmd = registeredLayer.commands.find(c => c.name === "digital-pet.dex"); if (dexCmd) dexCmd.run(); } return { frame, subscriptions, outputs } } finally { dispose() } } -if (entryPlugin.id !== "opencode-vpet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") -if (serverPlugin.id !== "opencode-vpet" || typeof serverPlugin.server !== "function") throw new Error("Server export failed") -if (tuiPlugin.id !== "opencode-vpet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") -const privateTuiRejected = !(await import("opencode-vpet/tui/monster-animation").then(() => true, () => false)) -const privateCommandRejected = !(await import("opencode-vpet/commands/vpet-freeze").then(() => true, () => false)) +if (entryPlugin.id !== "opencode-digital-pet" || typeof entryPlugin.server !== "function") throw new Error("Core export failed") +if (serverPlugin.id !== "opencode-digital-pet" || typeof serverPlugin.server !== "function") throw new Error("Server export failed") +if (tuiPlugin.id !== "opencode-digital-pet" || typeof tuiPlugin.tui !== "function") throw new Error("TUI export failed") +const privateTuiRejected = !(await import("opencode-digital-pet/tui/monster-animation").then(() => true, () => false)) +const privateCommandRejected = !(await import("opencode-digital-pet/commands/digital-pet-freeze").then(() => true, () => false)) const commandConfigs = Object.keys(createCommandConfig()).sort() if (!privateTuiRejected) throw new Error("Private TUI module exported") if (!privateCommandRejected) throw new Error("Private command module exported") -if (commandConfigs.join(",") !== "vpet-freeze,vpet-set,vpet-spawn,vpet-unfreeze") throw new Error("Packed command configuration missing") +if (commandConfigs.join(",") !== "digital-pet-freeze,digital-pet-set,digital-pet-spawn,digital-pet-unfreeze") throw new Error("Packed command configuration missing") const walking = await render(() => partner("agumon", 3), 58) if (!walking.outputs.some((output) => output.kind === "walking" && output.facing === "right" && output.offset === -32)) throw new Error("Right-facing animation transition failed") if (walking.subscriptions.join(",") !== "message.updated,message.part.updated,session.status") throw new Error("Activity subscriptions missing") @@ -322,26 +324,28 @@ console.log(JSON.stringify({ commandConfigs, privateCommandRejected, privateTuiR expect(new TextDecoder().decode(result.stderr)).toBe("") expect(new TextDecoder().decode(result.stdout)).toContain('"facing":"right"') expect(new TextDecoder().decode(result.stdout)).toContain( - '"commandConfigs":["vpet-freeze","vpet-set","vpet-spawn","vpet-unfreeze"]', + '"commandConfigs":["digital-pet-freeze","digital-pet-set","digital-pet-spawn","digital-pet-unfreeze"]', ) expect(new TextDecoder().decode(result.stdout)).toContain('"privateCommandRejected":true') expect(new TextDecoder().decode(result.stdout)).toContain('"privateTuiRejected":true') expect(new TextDecoder().decode(result.stdout)).toContain('"retainedMovement":true') expect(new TextDecoder().decode(result.stdout)).toContain('"retainedAction":true') - expect(new TextDecoder().decode(result.stdout)).toContain('"layerCommands":["vpet.dex","vpet.history"]') + expect(new TextDecoder().decode(result.stdout)).toContain( + '"layerCommands":["digital-pet.dex","digital-pet.history"]', + ) expect(new TextDecoder().decode(result.stdout)).toContain('"dialogOpened":true') }) test("Given the packed npm bin and a fake OpenCode executable When its CLI commands run Then only init and update execute the native exact-version installer", async () => { const fixture = getFixture() - const cliPath = join(fixture.consumerDirectory, "node_modules", "opencode-vpet", "dist", "cli.js") + const cliPath = join(fixture.consumerDirectory, "node_modules", "opencode-digital-pet", "dist", "cli.js") const fakeBinDirectory = join(fixture.root, "fake-bin") const callsPath = join(fixture.root, "opencode-calls.jsonl") const executablePath = join(fakeBinDirectory, "opencode") const manifest = JSON.parse( - await Bun.file(join(fixture.consumerDirectory, "node_modules", "opencode-vpet", "package.json")).text(), + await Bun.file(join(fixture.consumerDirectory, "node_modules", "opencode-digital-pet", "package.json")).text(), ) - const expected = ["plugin", `@sbugallo/opencode-vpet@${manifest.version}`, "--global", "--force"] + const expected = ["plugin", `@jcendal/opencode-digital-pet@${manifest.version}`, "--global", "--force"] await mkdir(fakeBinDirectory) await writeFile( executablePath, @@ -359,7 +363,7 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) OPENCODE_CONFIG_DIR: join(fixture.root, "opencode"), OPENCODE_FAKE_CALLS: callsPath, OPENCODE_FAKE_EXIT: String(exitCode), - PATH: `${fakeBinDirectory}:${process.env.PATH ?? ""}`, + PATH: `${fakeBinDirectory}:${process.env["PATH"] ?? ""}`, }) const runCli = (arguments_: readonly string[], exitCode = 0) => Bun.spawnSync([process.execPath, cliPath, ...arguments_], { @@ -374,7 +378,7 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) const updateDryRunResult = runCli(["update", "--dry-run"]) expect(helpResult.exitCode, new TextDecoder().decode(helpResult.stderr)).toBe(0) - expect(new TextDecoder().decode(helpResult.stdout)).toContain("Usage: opencode-vpet") + expect(new TextDecoder().decode(helpResult.stdout)).toContain("Usage: opencode-digital-pet") expect(initDryRunResult.exitCode, new TextDecoder().decode(initDryRunResult.stderr)).toBe(0) expect(updateDryRunResult.exitCode, new TextDecoder().decode(updateDryRunResult.stderr)).toBe(0) expect(new TextDecoder().decode(initDryRunResult.stdout)).toBe(`opencode ${expected.join(" ")}\n`) @@ -398,16 +402,16 @@ process.exit(Number(process.env.OPENCODE_FAKE_EXIT ?? "0")) expect(await Bun.file(join(fixture.root, "opencode", "tui.json")).exists()).toBe(false) }) - test("Given published package metadata When a registry reads it Then it describes all four VPet commands", async () => { + test("Given published package metadata When a registry reads it Then it describes all four Digital Pet commands", async () => { const manifest = await Bun.file(new URL("../package.json", import.meta.url)).text() expect(manifest).toContain( - '"description": "An OpenCode virtual-pet plugin with /vpet-spawn, /vpet-freeze, /vpet-unfreeze, /vpet-set, /vpet-dex, and /vpet-history commands."', + '"description": "An OpenCode virtual-pet plugin with /digital-pet-spawn, /digital-pet-freeze, /digital-pet-unfreeze, /digital-pet-set, /digital-pet-dex, and /digital-pet-history commands."', ) }) test("Given the TUI entrypoint When OpenCode loads it Then it is the compact plugin module", () => { - expect(tuiPlugin).toMatchObject({ id: "opencode-vpet" }) + expect(tuiPlugin).toMatchObject({ id: "opencode-digital-pet" }) expect(tuiPlugin.tui).toBeFunction() }) }) diff --git a/packages/opencode-digital-pet/tests/push-release.test.ts b/packages/opencode-digital-pet/tests/push-release.test.ts new file mode 100644 index 0000000..784e727 --- /dev/null +++ b/packages/opencode-digital-pet/tests/push-release.test.ts @@ -0,0 +1,81 @@ +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, test } from "bun:test" + +import { pushRelease } from "../../../scripts/push-release.ts" + +const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", +} + +const git = (cwd: string, args: readonly string[]): void => { + execFileSync("git", args, { cwd, env: gitEnv, stdio: "pipe" }) +} + +const commitRelease = (cwd: string, fileName: string, tag: string): void => { + writeFileSync(join(cwd, fileName), `${fileName}\n`) + writeFileSync(join(cwd, "package-lock.json"), `${fileName}-release\n`) + git(cwd, ["add", fileName, "package-lock.json"]) + git(cwd, ["commit", "-m", `release ${fileName}`]) + git(cwd, ["tag", "-a", tag, "-m", tag]) + writeFileSync(join(cwd, fileName), `${fileName}-dev\n`) + git(cwd, ["add", fileName]) + git(cwd, ["commit", "-m", `dev ${fileName}`]) +} + +describe("push release when main moved", () => { + let root: string | undefined + + afterEach(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + }) + + test("Given another product already pushed When this release pushes Then both changes land on main", () => { + root = mkdtempSync(join(tmpdir(), "push-release-")) + const origin = join(root, "origin.git") + const seed = join(root, "seed") + const first = join(root, "first") + const second = join(root, "second") + git(root, ["init", "--bare", "-b", "main", origin]) + git(root, ["init", "-b", "main", seed]) + writeFileSync(join(seed, "package.json"), '{"name":"root","version":"0.0.0","private":true}\n') + writeFileSync(join(seed, "package-lock.json"), "base\n") + git(seed, ["add", "package.json", "package-lock.json"]) + git(seed, ["commit", "-m", "init"]) + git(seed, ["remote", "add", "origin", origin]) + git(seed, ["push", "origin", "main"]) + git(root, ["clone", origin, first]) + git(root, ["clone", origin, second]) + for (const cwd of [first, second]) { + git(cwd, ["config", "user.name", "Test"]) + git(cwd, ["config", "user.email", "test@example.com"]) + } + commitRelease(first, "opencode.txt", "opencode-v1.0.0") + commitRelease(second, "cursor.txt", "cursor-v1.0.0") + + const previous = process.cwd() + try { + process.chdir(first) + pushRelease("opencode-v1.0.0") + process.chdir(second) + pushRelease("cursor-v1.0.0") + } finally { + process.chdir(previous) + } + + const check = join(root, "check") + git(root, ["clone", origin, check]) + expect(execFileSync("git", ["tag", "--list"], { cwd: check, encoding: "utf8" })).toContain("opencode-v1.0.0") + expect(execFileSync("git", ["tag", "--list"], { cwd: check, encoding: "utf8" })).toContain("cursor-v1.0.0") + expect(execFileSync("git", ["show", "HEAD:opencode.txt"], { cwd: check, encoding: "utf8" })).toBe( + "opencode.txt-dev\n", + ) + expect(execFileSync("git", ["show", "HEAD:cursor.txt"], { cwd: check, encoding: "utf8" })).toBe("cursor.txt-dev\n") + }) +}) diff --git a/tests/record-usage.test.ts b/packages/opencode-digital-pet/tests/record-usage.test.ts similarity index 85% rename from tests/record-usage.test.ts rename to packages/opencode-digital-pet/tests/record-usage.test.ts index 432b4c3..cc03872 100644 --- a/tests/record-usage.test.ts +++ b/packages/opencode-digital-pet/tests/record-usage.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test" -import type { DigimonNode } from "../src/data/catalog.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import type { UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import { reconcileUsage } from "../src/application/use-cases/reconcile-usage.ts" -import { recordUsage, type CompletedUsage } from "../src/application/use-cases/record-usage.ts" -import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" +import type { DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import { reconcileUsage } from "@jcendal/digital-pet-core/application/use-cases/reconcile-usage.ts" +import { recordUsage, type CompletedUsage } from "@jcendal/digital-pet-core/application/use-cases/record-usage.ts" +import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" const currentNode: DigimonNode = { id: "current", @@ -28,12 +28,16 @@ const targetNode: DigimonNode = { url: "https://example.test/target", } +const catalogNodes = [currentNode, targetNode] + const activePartner: Partner = { partnerId: "partner-1", generation: 1, currentNodeId: currentNode.id, - gauge: 499_999, + gauge: 4_999_999, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, } @@ -69,7 +73,7 @@ const createLedger = (outcome: "applied" | "duplicate" | "no_active_partner", pa } describe("record usage application use case", () => { - test("Given completed usage and an active partner When recording it Then the ledger applies the transaction-scoped evolution", () => { + test("Given completed usage and an active Digitama When recording it Then the ledger evolves immediately", () => { const ledger = createLedger("applied") const outcome = recordUsage({ @@ -79,6 +83,7 @@ describe("record usage application use case", () => { [currentNode.id, currentNode], [targetNode.id, targetNode], ]), + catalogNodes, selector: () => 0, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -94,6 +99,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -111,6 +118,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -121,6 +129,8 @@ describe("record usage application use case", () => { currentNodeId: currentNode.id, gauge: 1, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -135,6 +145,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -152,6 +163,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -178,6 +190,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -201,6 +214,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -210,6 +224,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -233,6 +249,7 @@ describe("record usage application use case", () => { selector: () => { throw new Error("selector must not run") }, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }) @@ -254,6 +271,7 @@ describe("record usage application use case", () => { ledger, digimonById: new Map(), selector: () => 0, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }), ).toThrow("Persisted partner node missing is missing from the catalog") @@ -278,12 +296,13 @@ describe("record usage application use case", () => { [targetNode.id, targetNode], ]), selector: () => 0, + catalogNodes, thresholds: STAGE_GAUGE_THRESHOLDS, }), ).toThrow(writeFailure) }) - test("Given a custom child threshold When recording usage Then the ledger evolves at the supplied threshold", () => { + test("Given a custom Digitama threshold When recording usage Then the ledger evolves immediately", () => { const thresholds: StageThresholds = Object.freeze({ ...STAGE_GAUGE_THRESHOLDS, 0: 1, @@ -297,6 +316,7 @@ describe("record usage application use case", () => { [currentNode.id, currentNode], [targetNode.id, targetNode], ]), + catalogNodes, selector: () => 0, thresholds, }) @@ -311,6 +331,8 @@ describe("record usage application use case", () => { currentNodeId: targetNode.id, gauge: 0, isTerminal: true, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) diff --git a/tests/release-metadata.test.ts b/packages/opencode-digital-pet/tests/release-metadata.test.ts similarity index 51% rename from tests/release-metadata.test.ts rename to packages/opencode-digital-pet/tests/release-metadata.test.ts index a8e6b13..dd44e95 100644 --- a/tests/release-metadata.test.ts +++ b/packages/opencode-digital-pet/tests/release-metadata.test.ts @@ -6,13 +6,14 @@ import { join, resolve } from "node:path" import { calculateNextDevelopmentVersion, calculateReleaseVersion, + extractReleaseNotes, insertReleaseIntoChangelog, -} from "../scripts/release-metadata.ts" +} from "../../../scripts/release-metadata.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-release-metadata-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-release-metadata-")) tempRoots.push(root) return root } @@ -31,6 +32,16 @@ describe("release metadata", () => { expect(releaseVersion).toBe("1.11.0") }) + test("Given product tags When calculating a release Then unrelated products and legacy tags do not change its version", () => { + expect( + calculateReleaseVersion( + ["v9.0.0", "opencode-digital-pet-v8.0.0", "cursor-digital-pet-v0.4.1"], + "patch", + "cursor-digital-pet-v", + ), + ).toBe("0.4.2") + }) + test.each([ ["patch", "0.0.1"], ["minor", "0.1.0"], @@ -43,8 +54,8 @@ describe("release metadata", () => { ) test.each([ - ["1.4.3", "1.5.0-dev.0"], - ["2.0.0", "2.1.0-dev.0"], + ["1.4.3", "1.4.4-dev.0"], + ["2.0.0", "2.0.1-dev.0"], ] as const)( "Given a stable release %s When calculating its next development version Then it is %s", (releaseVersion, expectedVersion) => { @@ -60,6 +71,59 @@ describe("release metadata", () => { ) }) + test("Given no unreleased notes When a merged PR releases Then its title becomes the release note", () => { + const changelog = "# Changelog\n\n## [Unreleased]\n" + const released = insertReleaseIntoChangelog(changelog, "0.2.1", "2026-10-06", "fix: keep Cursor in sync") + + expect(extractReleaseNotes(released, "0.2.1")).toBe("### Changed\n\n- fix: keep Cursor in sync\n") + }) + + test("Given an automatic patch release When the changelog is empty Then the CLI records the PR title", async () => { + const root = await createTempRoot() + const packageJsonPath = join(root, "package.json") + const changelogPath = join(root, "CHANGELOG.md") + await Promise.all([ + writeFile(packageJsonPath, '{"name":"fixture","version":"0.2.1-dev.0"}\n'), + writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-10-06\n\n- First release\n"), + ]) + + const result = Bun.spawnSync( + [ + "bun", + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), + "--package-json", + packageJsonPath, + "--changelog", + changelogPath, + "--tag-prefix", + "opencode-digital-pet-v", + "--bump", + "patch", + "--date", + "2026-10-07", + "--release-note", + "fix: refresh the shared database", + ], + { stdin: new Blob(["opencode-digital-pet-v0.2.0\n"]) }, + ) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + nextDevVersion: "0.2.2-dev.0", + releaseVersion: "0.2.1", + }) + expect(extractReleaseNotes(await readFile(changelogPath, "utf8"), "0.2.1")).toBe( + "### Changed\n\n- fix: refresh the shared database\n", + ) + }) + + test("Given multiple releases When extracting notes Then only the requested product version is returned", () => { + const changelog = + "# Changelog\n\n## [Unreleased]\n\n## [0.2.0] - 2026-10-06\n\n### Added\n\n- Cursor sidebar\n\n## [0.1.0] - 2026-08-30\n\n- Old notes\n" + expect(extractReleaseNotes(changelog, "0.2.0")).toBe("### Added\n\n- Cursor sidebar\n") + expect(() => extractReleaseNotes(changelog, "0.3.0")).toThrow() + }) + test.each([ ["# Changelog\n", "1.2.3", "2026-08-30"], ["## [Unreleased]\n\n## [Unreleased]\n", "1.2.3", "2026-08-30"], @@ -84,14 +148,14 @@ describe("release metadata", () => { const changelogPath = join(root, "CHANGELOG.md") const outputPath = join(root, "github-output") await Promise.all([ - writeFile(packageJsonPath, '{\n "name": "fixture",\n "version": "1.5.0-dev.0"\n}\n'), + writeFile(packageJsonPath, '{\n "name": "fixture",\n "version": "1.4.6-dev.0"\n}\n'), writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n- Fixture\n"), ]) const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -109,12 +173,12 @@ describe("release metadata", () => { expect(result.exitCode).toBe(0) expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ - nextDevVersion: "1.6.0-dev.0", + nextDevVersion: "1.5.1-dev.0", releaseVersion: "1.5.0", }) expect(await readFile(packageJsonPath, "utf8")).toContain('"version": "1.5.0"') expect(await readFile(changelogPath, "utf8")).toContain("## [1.5.0] - 2026-08-30") - expect(await readFile(outputPath, "utf8")).toBe("release_version=1.5.0\nnext_dev_version=1.6.0-dev.0\n") + expect(await readFile(outputPath, "utf8")).toBe("release_version=1.5.0\nnext_dev_version=1.5.1-dev.0\n") }) test("Given a latest stable tag and its expected development source When the helper CLI prepares a minor release Then it releases from that development source", async () => { @@ -122,14 +186,14 @@ describe("release metadata", () => { const packageJsonPath = join(root, "package.json") const changelogPath = join(root, "CHANGELOG.md") await Promise.all([ - writeFile(packageJsonPath, '{"name":"fixture","version":"1.5.0-dev.0"}\n'), + writeFile(packageJsonPath, '{"name":"fixture","version":"1.4.4-dev.0"}\n'), writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n"), ]) const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -144,7 +208,7 @@ describe("release metadata", () => { expect(result.exitCode).toBe(0) expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ - nextDevVersion: "1.6.0-dev.0", + nextDevVersion: "1.5.1-dev.0", releaseVersion: "1.5.0", }) }) @@ -161,7 +225,7 @@ describe("release metadata", () => { const result = Bun.spawnSync( [ "bun", - resolve(import.meta.dir, "../scripts/release-metadata.ts"), + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), "--package-json", packageJsonPath, "--changelog", @@ -177,4 +241,79 @@ describe("release metadata", () => { expect(result.exitCode).not.toBe(0) expect(await readFile(packageJsonPath, "utf8")).toBe('{"name":"fixture","version":"1.5.1-dev.0"}\n') }) + + test.each([ + ["opencode-digital-pet-v", "v", "minor", "v0.1.1\ncursor-digital-pet-v9.0.0\n"], + ["cursor-digital-pet-v", undefined, "patch", "v0.1.1\nopencode-digital-pet-v9.0.0\n"], + ] as const)( + "Given %s has no product tags When preparing its first release Then it does not take another product's version", + async (tagPrefix, legacyPrefix, bump, tags) => { + const root = await createTempRoot() + const packageJsonPath = join(root, "package.json") + const changelogPath = join(root, "CHANGELOG.md") + await Promise.all([ + writeFile(packageJsonPath, '{"name":"fixture","version":"0.2.0-dev.0"}\n'), + writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- Feature\n"), + ]) + + const args = [ + "bun", + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), + "--package-json", + packageJsonPath, + "--changelog", + changelogPath, + "--tag-prefix", + tagPrefix, + "--bump", + bump, + "--date", + "2026-08-30", + ] + if (legacyPrefix !== undefined) args.push("--legacy-tag-prefix", legacyPrefix) + const result = Bun.spawnSync(args, { + stdin: new Blob([tags]), + }) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + nextDevVersion: "0.2.1-dev.0", + releaseVersion: "0.2.0", + }) + }, + ) + + test("Given an OpenCode legacy candidate When the first product release uses another bump Then it rejects it", async () => { + const root = await createTempRoot() + const packageJsonPath = join(root, "package.json") + const changelogPath = join(root, "CHANGELOG.md") + const manifest = '{"name":"fixture","version":"0.2.0-dev.0"}\n' + await Promise.all([ + writeFile(packageJsonPath, manifest), + writeFile(changelogPath, "# Changelog\n\n## [Unreleased]\n\n- Feature\n"), + ]) + + const result = Bun.spawnSync( + [ + "bun", + resolve(import.meta.dir, "../../../scripts/release-metadata.ts"), + "--package-json", + packageJsonPath, + "--changelog", + changelogPath, + "--tag-prefix", + "opencode-digital-pet-v", + "--legacy-tag-prefix", + "v", + "--bump", + "patch", + "--date", + "2026-08-30", + ], + { stdin: new Blob(["v0.1.1\n"]) }, + ) + + expect(result.exitCode).not.toBe(0) + expect(await readFile(packageJsonPath, "utf8")).toBe(manifest) + }) }) diff --git a/packages/opencode-digital-pet/tests/release-plan.test.ts b/packages/opencode-digital-pet/tests/release-plan.test.ts new file mode 100644 index 0000000..e0284f1 --- /dev/null +++ b/packages/opencode-digital-pet/tests/release-plan.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" + +import { planMergedRelease } from "../../../scripts/plan-merged-release.ts" + +const productTags = ["v0.1.1", "opencode-digital-pet-v0.2.0", "cursor-digital-pet-v0.2.0"] + +describe("merged pull request release planning", () => { + test("Given no product releases When the release workflow PR merges Then both 0.2.0 candidates are released", () => { + expect( + planMergedRelease({ + changedFiles: [".github/workflows/release-after-merge.yml"], + tags: ["v0.1.0", "v0.1.1"], + title: "fix: automate releases", + labels: [], + opencodeVersion: "0.2.0-dev.0", + alreadyReleased: { opencode: false, cursor: false }, + }), + ).toEqual({ + opencode: { release: true, bump: "minor" }, + cursor: { release: true, bump: "patch" }, + }) + }) + + test("Given existing product releases When only Cursor changes Then OpenCode is not republished", () => { + expect( + planMergedRelease({ + changedFiles: ["packages/cursor-digital-pet/src/extension.ts"], + tags: productTags, + title: "feat: improve the sidebar", + labels: [], + opencodeVersion: "0.2.1-dev.0", + alreadyReleased: { opencode: false, cursor: false }, + }), + ).toEqual({ + opencode: { release: false, bump: "minor" }, + cursor: { release: true, bump: "minor" }, + }) + }) + + test("Given a shared package change When the PR has a release label Then both packages use that bump", () => { + expect( + planMergedRelease({ + changedFiles: ["packages/digital-pet-core/src/schema.ts"], + tags: productTags, + title: "refactor: change the database schema", + labels: ["release:major"], + opencodeVersion: "0.2.1-dev.0", + alreadyReleased: { opencode: false, cursor: false }, + }), + ).toEqual({ + opencode: { release: true, bump: "major" }, + cursor: { release: true, bump: "major" }, + }) + }) + + test("Given a merge already contained in each product tag When a queued run starts Then it publishes nothing", () => { + const plan = planMergedRelease({ + changedFiles: ["packages/opencode-digital-pet/src/index.ts", "packages/cursor-digital-pet/src/extension.ts"], + tags: productTags, + title: "fix: update both hosts", + labels: [], + opencodeVersion: "0.2.1-dev.0", + alreadyReleased: { opencode: true, cursor: true }, + }) + + expect(plan.opencode.release).toBe(false) + expect(plan.cursor.release).toBe(false) + }) + + test("Given a documentation-only PR after the first releases When it merges Then it publishes nothing", () => { + const plan = planMergedRelease({ + changedFiles: ["docs/monorepo.md"], + tags: productTags, + title: "docs: clarify releases", + labels: [], + opencodeVersion: "0.2.1-dev.0", + alreadyReleased: { opencode: false, cursor: false }, + }) + + expect(plan.opencode.release).toBe(false) + expect(plan.cursor.release).toBe(false) + }) + + test("Given conflicting release labels When planning Then it rejects the ambiguous version bump", () => { + expect(() => + planMergedRelease({ + changedFiles: ["packages/opencode-digital-pet/src/index.ts"], + tags: productTags, + title: "fix: update the plugin", + labels: ["release:minor", "release:major"], + opencodeVersion: "0.2.1-dev.0", + alreadyReleased: { opencode: false, cursor: false }, + }), + ).toThrow("only one") + }) +}) diff --git a/tests/server-hooks-lifecycle.test.ts b/packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts similarity index 73% rename from tests/server-hooks-lifecycle.test.ts rename to packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts index 2ec20a5..08e0935 100644 --- a/tests/server-hooks-lifecycle.test.ts +++ b/packages/opencode-digital-pet/tests/server-hooks-lifecycle.test.ts @@ -1,26 +1,24 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk" +import type { DigimonCatalog, DigimonNode } from "@jcendal/digital-pet-core/data/catalog.ts" +import { STAGE_GAUGE_THRESHOLDS } from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" + import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import type { VpetToastPayload } from "../src/adapters/opencode/vpet-toast.ts" -import type { DigimonCatalog, DigimonNode } from "../src/data/catalog.ts" -import { STAGE_GAUGE_THRESHOLDS } from "../src/domain/evolution.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import type { DigitalPetToastPayload } from "../src/adapters/opencode/digital-pet-toast.ts" +import { testPartner } from "./partner-fixtures.ts" const createRepository = (outcome: ApplyUsageReceiptOutcome, onReceipt?: () => void) => ({ - spawnPartner: () => ({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }), + spawnPartner: () => testPartner(), applyUsageReceipt: (_receipt: UsageReceiptMetadata) => { onReceipt?.() return outcome @@ -28,7 +26,7 @@ const createRepository = (outcome: ApplyUsageReceiptOutcome, onReceipt?: () => v freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - }) satisfies PartnerLifecycle & UsageLedger & VpetControl + }) satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const toastNodes = [ { @@ -57,36 +55,9 @@ const createToastRepository = () => { let frozen = false let cheatNodeId: string | undefined return { - spawnPartner: () => ({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }), - applyUsageReceipt: ( - _receipt: UsageReceiptMetadata, - evolve: (partner: { - readonly partnerId: string - readonly generation: number - readonly currentNodeId: string - readonly gauge: number - readonly isTerminal: boolean - readonly createdAt: string - readonly retiredAt: string | null - }) => { readonly currentNodeId: string; readonly gauge: number; readonly isTerminal: boolean }, - ) => { - evolve({ - partnerId: "partner-1", - generation: 1, - currentNodeId: "0-001", - gauge: 0, - isTerminal: false, - createdAt: "2026-07-31T00:00:00.000Z", - retiredAt: null, - }) + spawnPartner: () => testPartner(), + applyUsageReceipt: (_receipt: UsageReceiptMetadata, evolve: (partner: Partner) => PartnerProgression) => { + evolve(testPartner()) return { kind: "applied" as const } }, freeze: () => { @@ -104,7 +75,7 @@ const createToastRepository = () => { cheatNodeId = nodeId return { kind: "set" as const, cheatNodeId: nodeId } }, - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl } const completedAssistantEvent = { @@ -185,7 +156,7 @@ describe("server hook lifecycle", () => { }) test("Given default-enabled command transitions When each command commits Then it appends its legacy parts before exactly one formatted toast", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createToastRepository(), resource: { close: async () => {} }, @@ -197,27 +168,27 @@ describe("server hook lifecycle", () => { }) const commandHandler = hooks["command.execute.before"] if (commandHandler === undefined) throw new Error("Expected command hook") - const commands = ["vpet-spawn", "vpet-freeze", "vpet-unfreeze", "vpet-set"] as const + const commands = ["digital-pet-spawn", "digital-pet-freeze", "digital-pet-unfreeze", "digital-pet-set"] as const for (const command of commands) { const output = { parts: [] } await commandHandler( - { command, sessionID: "session-1", arguments: command === "vpet-set" ? "1-001" : "" }, + { command, sessionID: "session-1", arguments: command === "digital-pet-set" ? "1-001" : "" }, output, ) expect(output.parts).toHaveLength(1) } expect(delivered).toEqual([ - { title: "VPet", message: "Spawned Digitama (Generation 1).", variant: "success", duration: 5_000 }, - { title: "VPet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, - { title: "VPet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, - { title: "VPet", message: "VPet set to Koromon (1-001).", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Spawned Digitama (Generation 1).", variant: "success", duration: 5_000 }, + { title: "Digital Pet", message: "Digimon progression frozen.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digimon progression resumed.", variant: "info", duration: 3_000 }, + { title: "Digital Pet", message: "Digital Pet set to Koromon (1-001).", variant: "info", duration: 3_000 }, ]) }) test("Given committed direct and ordered idle evolutions When their receipts apply Then each evolution produces one formatted toast in processing order", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createToastRepository(), resource: { close: async () => {} }, @@ -240,9 +211,24 @@ describe("server hook lifecycle", () => { await eventHandler({ event: { type: "session.idle", properties: { sessionID: "session-1" } } satisfies Event }) expect(delivered).toEqual([ - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, - { title: "Digi-evolution", message: "Digitama evolved into Koromon!", variant: "success", duration: 5_000 }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, + { + title: "Digi-evolution", + message: "Digitama evolved into Koromon!", + variant: "success", + duration: 5_000, + }, ]) }) @@ -264,14 +250,14 @@ describe("server hook lifecycle", () => { const commandHandler = hooks["command.execute.before"] if (commandHandler === undefined) throw new Error("Expected command hook") - await commandHandler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) + await commandHandler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) expect(catalogLoads).toBe(0) expect(notifications).toBe(0) }) test("Given idempotent, invalid, duplicate, and missing-name paths When hooks complete Then they remain silent while preserving their committed result", async () => { - const delivered: VpetToastPayload[] = [] + const delivered: DigitalPetToastPayload[] = [] const hooks = createServerHooks({ repository: createRepository({ kind: "duplicate" }), resource: { close: async () => {} }, @@ -286,8 +272,8 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined || eventHandler === undefined) throw new Error("Expected command and event hooks") const output = { parts: [] } - await commandHandler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output) - await commandHandler({ command: "vpet-set", sessionID: "session-1", arguments: "missing" }, output) + await commandHandler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-set", sessionID: "session-1", arguments: "missing" }, output) await eventHandler({ event: completedAssistantEvent }) expect(output.parts).toHaveLength(2) @@ -314,7 +300,7 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined) throw new Error("Expected command hook") const output = { parts: [] } - await commandHandler({ command: "vpet-freeze", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-freeze", sessionID: "session-1", arguments: "" }, output) expect(output.parts).toHaveLength(1) }, @@ -332,7 +318,7 @@ describe("server hook lifecycle", () => { if (commandHandler === undefined) throw new Error("Expected command hook") const output = { parts: [] } - await commandHandler({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, output) + await commandHandler({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, output) expect(output.parts).toHaveLength(1) }) diff --git a/tests/server-thresholds.test.ts b/packages/opencode-digital-pet/tests/server-thresholds.test.ts similarity index 69% rename from tests/server-thresholds.test.ts rename to packages/opencode-digital-pet/tests/server-thresholds.test.ts index cf1db3a..efe4f9e 100644 --- a/tests/server-thresholds.test.ts +++ b/packages/opencode-digital-pet/tests/server-thresholds.test.ts @@ -6,20 +6,23 @@ import { join, posix } from "node:path" import { createServerHooks, type ServerHookDependencies } from "../src/adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "../src/adapters/opencode/session-messages.ts" -import type { UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" -import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "../src/domain/evolution.ts" -import type { Partner, PartnerProgression } from "../src/domain/partner.ts" -import { loadGlobalVpetSettings, resolveGlobalVpetConfigPath } from "../src/config/global-vpet-settings.ts" +import type { UsageReceiptMetadata } from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" +import { STAGE_GAUGE_THRESHOLDS, type StageThresholds } from "@jcendal/digital-pet-core/domain/evolution.ts" +import type { Partner, PartnerProgression } from "@jcendal/digital-pet-core/domain/partner.ts" +import { + loadGlobalDigitalPetSettings, + resolveGlobalDigitalPetConfigPath, +} from "../src/config/global-digital-pet-settings.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-server-thresholds-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-server-thresholds-")) tempRoots.push(root) return root } @@ -42,7 +45,9 @@ const completedAssistantMessage = (id: string): AssistantMessage => ({ tokens: { input: 1, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) -type TestRepository = PartnerLifecycle & UsageLedger & VpetControl & { readonly progressions: PartnerProgression[] } +type TestRepository = PartnerLifecycle & + UsageLedger & + DigitalPetControl & { readonly progressions: PartnerProgression[] } const createRepository = (partner: Partner): TestRepository => { const progressions: PartnerProgression[] = [] @@ -93,12 +98,12 @@ const hooks = await plugin({ experimental_workspace: { register: () => {} }, serverUrl: new URL("http://localhost"), $: Bun.$, -}, { appDataRoot: process.env["VPET_APP_DATA_ROOT"] }) +}, { appDataRoot: process.env["DIGITAL_PET_APP_DATA_ROOT"] }) const command = hooks["command.execute.before"] if (command === undefined) throw new Error("Expected spawn command hook") -await command({ command: "vpet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) -if (process.env["VPET_UPDATED_THRESHOLD"] !== undefined) { - await writeFile(join(process.env["HOME"] ?? "", ".config", "opencode-vpet.json"), JSON.stringify({ stageThresholds: { egg: Number(process.env["VPET_UPDATED_THRESHOLD"]) } })) +await command({ command: "digital-pet-spawn", sessionID: "session-1", arguments: "" }, { parts: [] }) +if (process.env["DIGITAL_PET_UPDATED_THRESHOLD"] !== undefined) { + await writeFile(join(process.env["HOME"] ?? "", ".config", "opencode-digital-pet.json"), JSON.stringify({ stageThresholds: { egg: Number(process.env["DIGITAL_PET_UPDATED_THRESHOLD"]) } })) } const handler = hooks.event if (handler === undefined) throw new Error("Expected usage event hook") @@ -125,8 +130,8 @@ await hooks.dispose?.()`, env: { ...process.env, HOME: configHome, - VPET_APP_DATA_ROOT: appDataRoot, - ...(updatedThreshold === undefined ? {} : { VPET_UPDATED_THRESHOLD: String(updatedThreshold) }), + DIGITAL_PET_APP_DATA_ROOT: appDataRoot, + ...(updatedThreshold === undefined ? {} : { DIGITAL_PET_UPDATED_THRESHOLD: String(updatedThreshold) }), }, stderr: "pipe", }, @@ -141,12 +146,12 @@ describe("server threshold policy", () => { const injectedHome = "/temporary/injected-home" expect( - resolveGlobalVpetConfigPath({ + resolveGlobalDigitalPetConfigPath({ env: { HOME: injectedHome }, pathApi: posix, platform: "linux", }), - ).toBe(posix.join(homedir(), ".config", "opencode-vpet.json")) + ).toBe(posix.join(homedir(), ".config", "opencode-digital-pet.json")) }) test("Given a controlled child threshold When direct and idle usage arrive Then both paths evolve from the same supplied policy", async () => { @@ -156,6 +161,8 @@ describe("server threshold policy", () => { currentNodeId: "3-001", gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-08-21T00:00:00.000Z", retiredAt: null, } @@ -184,8 +191,15 @@ describe("server threshold policy", () => { await dispatch(idleHooks, { type: "session.idle", properties: { sessionID: "session-1" } } satisfies Event) expect(directRepository.progressions).toEqual(idleRepository.progressions) - expect(directRepository.progressions).toEqual([expect.objectContaining({ gauge: 0, isTerminal: false })]) - expect(directRepository.progressions[0]?.currentNodeId).not.toBe(partner.currentNodeId) + expect(directRepository.progressions).toEqual([ + expect.objectContaining({ + currentNodeId: partner.currentNodeId, + gauge: 1, + isTerminal: false, + pendingEvolutionTargetId: expect.any(String), + battleOpponentNodeId: expect.any(String), + }), + ]) }) test("Given default thresholds When direct usage arrives below the child threshold Then the existing timing is retained", async () => { @@ -195,6 +209,8 @@ describe("server threshold policy", () => { currentNodeId: "3-001", gauge: 0, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, createdAt: "2026-08-21T00:00:00.000Z", retiredAt: null, } @@ -211,6 +227,8 @@ describe("server threshold policy", () => { currentNodeId: partner.currentNodeId, gauge: 1, isTerminal: false, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }, ]) }) @@ -218,10 +236,10 @@ describe("server threshold policy", () => { test("Given a threshold file changed after hook construction When loading it once Then the original snapshot remains active", async () => { const root = await createTempRoot() const configDirectory = join(root, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ stageThresholds: { child: 2 } })) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) await writeFile(configPath, JSON.stringify({ stageThresholds: { child: 1 } })) expect(settings.stageThresholds.child).toBe(2) @@ -234,14 +252,26 @@ describe("server threshold policy", () => { const appDataRoot = await createTempRoot() const configDirectory = join(configRoot, ".config") await mkdir(configDirectory) - await writeFile(join(configDirectory, "opencode-vpet.json"), JSON.stringify({ stageThresholds: { egg: 1 } })) + await writeFile( + join(configDirectory, "opencode-digital-pet.json"), + JSON.stringify({ stageThresholds: { egg: 1 } }), + ) await runPluginWithConfigHome(configRoot, appDataRoot) - const repository = await createSqliteVpetRepository({ - databasePath: join(appDataRoot, "opencode-vpet", "pet.db"), + const repository = await createSqliteDigitalPetRepository({ + databasePath: join(appDataRoot, "opencode-digital-pet", "pet.db"), }) try { - expect(repository.getActivePartner()?.gauge).toBe(0) + const partner = repository.getActivePartner() + expect(partner).not.toBeNull() + expect(partner).toEqual( + expect.objectContaining({ + gauge: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) + expect(partner?.currentNodeId).toMatch(/^1-/) } finally { await repository.close() } @@ -254,16 +284,25 @@ describe("server threshold policy", () => { const configRoot = await createTempRoot() const appDataRoot = await createTempRoot() const configDirectory = join(configRoot, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ stageThresholds: { egg: 1 } })) await runPluginWithConfigHome(configRoot, appDataRoot, 2) - const repository = await createSqliteVpetRepository({ - databasePath: join(appDataRoot, "opencode-vpet", "pet.db"), + const repository = await createSqliteDigitalPetRepository({ + databasePath: join(appDataRoot, "opencode-digital-pet", "pet.db"), }) try { - expect(repository.getActivePartner()?.gauge).toBe(0) + const partner = repository.getActivePartner() + expect(partner).not.toBeNull() + expect(partner).toEqual( + expect.objectContaining({ + gauge: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + }), + ) + expect(partner?.currentNodeId).toMatch(/^1-/) } finally { await repository.close() } diff --git a/tests/sidebar-card.test.tsx b/packages/opencode-digital-pet/tests/sidebar-card.test.tsx similarity index 91% rename from tests/sidebar-card.test.tsx rename to packages/opencode-digital-pet/tests/sidebar-card.test.tsx index a6a48a5..724ee0a 100644 --- a/tests/sidebar-card.test.tsx +++ b/packages/opencode-digital-pet/tests/sidebar-card.test.tsx @@ -3,10 +3,11 @@ import { describe, expect, test } from "bun:test" import { createMockMouse } from "@opentui/core/testing" import { testRender } from "@opentui/solid" -import { MONSTER_FRAME_CATALOG, type MonsterFrame } from "../src/data/monster-frame-catalog.ts" -import { mirrorMonsterFrame } from "../src/tui/monster-artwork-mirror.ts" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" -import { VpetSidebarCard } from "../src/tui/sidebar-card.tsx" +import { MONSTER_FRAME_CATALOG, type MonsterFrame } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { mirrorMonsterFrame } from "@jcendal/digital-pet-animation/idle/monster-artwork-mirror.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" + +import { DigitalPetSidebarCard } from "../src/tui/sidebar-card.tsx" const agumonFrame = (): MonsterFrame => { const frame = MONSTER_FRAME_CATALOG.get("agumon", "angry") @@ -31,6 +32,7 @@ const partnerModel = () => ({ isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, }) const frameOutput = (offset: number, facing: "left" | "right" = "left"): MonsterAnimationOutput => ({ @@ -40,11 +42,11 @@ const frameOutput = (offset: number, facing: "left" | "right" = "left"): Monster facing, }) -describe("VPet sidebar frame", () => { +describe("Digital Pet sidebar frame", () => { test("Given a persisted Agumon and animation frame When rendering at sidebar width Then it centers exact artwork above five status rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), })} @@ -84,7 +86,7 @@ describe("VPet sidebar frame", () => { [60, 22, 44], ] as const) { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -105,7 +107,7 @@ describe("VPet sidebar frame", () => { [60, 22, 44], ] as const) { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -122,7 +124,7 @@ describe("VPet sidebar frame", () => { const widths: number[] = [] const setup = await testRender( () => ( - widths.push(width)} @@ -152,7 +154,7 @@ describe("VPet sidebar frame", () => { "Given a %i-column artwork viewport When rendering a right-facing frame Then it pins at origin without disturbing status rows", async (width) => { const setup = await testRender( - () => , + () => , { width, height: 24 }, ) @@ -168,7 +170,7 @@ describe("VPet sidebar frame", () => { test("Given a normal stage at a tight sidebar width When rendering Then it retains the five existing status rows", async () => { const setup = await testRender( - () => , + () => , { width: 16, height: 24 }, ) @@ -186,7 +188,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen stage at a tight sidebar width When rendering Then it truncates on the existing stage row", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), stage: "Child", @@ -215,7 +217,7 @@ describe("VPet sidebar frame", () => { test("Given localized terminal card models When rendering Then it displays exact configured name-stage pairs and placeholders", async () => { const japaneseSetup = await testRender( () => ( - ({ kind: "partner", name: "Tailmon", @@ -228,6 +230,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -245,7 +248,7 @@ describe("VPet sidebar frame", () => { const englishSetup = await testRender( () => ( - ({ kind: "partner", name: "Gatomon", @@ -258,6 +261,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -277,7 +281,7 @@ describe("VPet sidebar frame", () => { test("Given a terminal partner When rendering Then it shows terminal placeholders instead of check values", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -290,6 +294,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -312,7 +317,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen canonical Digitama When rendering Then it appends frozen to the existing stage row without changing status rows", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Digitama", @@ -325,6 +330,7 @@ describe("VPet sidebar frame", () => { isTerminal: false, frozen: true, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -348,7 +354,7 @@ describe("VPet sidebar frame", () => { test("Given a terminal cheat projection When rendering Then it suppresses frozen and never labels cheat", async () => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -361,6 +367,7 @@ describe("VPet sidebar frame", () => { isTerminal: true, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation(frameOutput(0))} /> @@ -386,7 +393,7 @@ describe("VPet sidebar frame", () => { test("Given a valid set-override card When rendering Then it appends set to the existing name row without touching stage or status rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), gauge: 0, @@ -417,7 +424,7 @@ describe("VPet sidebar frame", () => { test("Given a frozen set-override card When rendering Then set and frozen markers stay on their separate name and stage rows", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), frozen: true, @@ -443,7 +450,7 @@ describe("VPet sidebar frame", () => { test("Given a set-override name at a tight sidebar width When rendering Then it truncates on the existing name row without wrapping", async () => { const setup = await testRender( () => ( - ({ ...partnerModel(), name: "SuperUltimate", @@ -470,7 +477,7 @@ describe("VPet sidebar frame", () => { test("Given no active partner and blank animation When rendering Then the eight-row canvas is blank and its message remains below it", async () => { const setup = await testRender( () => ( - ({ kind: "no_partner", messageLine: "No active partner" })} animation={animation({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" })} /> @@ -498,7 +505,7 @@ describe("VPet sidebar frame", () => { async (_caseName, sprite, message) => { const setup = await testRender( () => ( - ({ kind: "partner", name: "Agumon", @@ -511,6 +518,7 @@ describe("VPet sidebar frame", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, })} animation={animation({ kind: "unavailable", @@ -541,7 +549,7 @@ describe("VPet sidebar frame", () => { const openedUrls: string[] = [] const setup = await testRender( () => ( - openedUrls.push(url)} @@ -564,7 +572,7 @@ describe("VPet sidebar frame", () => { const openedUrls: string[] = [] const setup = await testRender( () => ( - ({ kind: "no_partner", messageLine: "No active partner" })} animation={animation({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" })} onUrlClick={(url) => openedUrls.push(url)} diff --git a/tests/sidebar-poll-loop.test.ts b/packages/opencode-digital-pet/tests/sidebar-poll-loop.test.ts similarity index 100% rename from tests/sidebar-poll-loop.test.ts rename to packages/opencode-digital-pet/tests/sidebar-poll-loop.test.ts diff --git a/packages/opencode-digital-pet/tests/sidebar-presentation.test.ts b/packages/opencode-digital-pet/tests/sidebar-presentation.test.ts new file mode 100644 index 0000000..dd9dcf3 --- /dev/null +++ b/packages/opencode-digital-pet/tests/sidebar-presentation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" + +import type { SidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { + buildBattleScoreRow, + defaultBattleScene, + renderEvolutionBattleArtwork, +} from "@jcendal/digital-pet-animation/sequences/evolution-battle-artwork.ts" +import type { PresentationState } from "@jcendal/digital-pet-animation/sessions/presentation-state.ts" +import { DigitalPetSidebarCard } from "../src/tui/sidebar-card.tsx" + +const partner: SidebarCardModel = { + kind: "partner", + name: "Agumon", + sprite: "agumon", + stage: "Child", + stageNumber: 3, + url: "https://example.test/agumon", + gauge: 50, + threshold: 100, + isTerminal: false, + frozen: false, + isSetOverride: false, + evolutionBattlePending: false, +} + +const battleFrame = renderEvolutionBattleArtwork( + MONSTER_FRAME_CATALOG, + "agumon", + "gabumon", + defaultBattleScene({ playerHits: 1, lastResult: "hit" }), + 80, +) + +describe("OpenCode sidebar presentation", () => { + test("shared phases retain terminal score text and return to the normal gauge after presentation", async () => { + const [phase, setPhase] = createSignal({ phase: "idle" }) + const [artwork, setArtwork] = createSignal() + const [model, setModel] = createSignal(partner) + const setup = await testRender( + () => + DigitalPetSidebarCard({ + model, + presentationState: phase, + customArtwork: artwork, + animation: () => ({ kind: "blank" }), + }), + { width: 80, height: 24 }, + ) + try { + await setup.flush() + const idle = setup.captureCharFrame() + expect(idle).toContain("Next check: [") + expect(idle).toContain("50/100") + expect(idle).toContain("Encyclopedia entry") + + setPhase({ phase: "battle", fromNodeId: "3-001", opponentNodeId: "3-051" }) + setArtwork(battleFrame) + await setup.renderOnce() + const battle = setup.captureCharFrame() + expect(battle).toContain("BATTLE") + expect(battle).toContain(buildBattleScoreRow(1, 0)) + expect(battle).toContain("HIT!") + for (const row of battleFrame.split("\n")) expect(battle).toContain(row.trimEnd()) + + for (const state of [ + { phase: "evolving", fromNodeId: "3-001", toNodeId: "4-017" }, + { phase: "evolved", fromNodeId: "3-001", toNodeId: "4-017" }, + { phase: "defeated", fromNodeId: "3-001" }, + ] as const) { + setPhase(state) + if (state.phase === "evolved") + setModel({ ...partner, name: "Greymon", sprite: "greymon", stage: "Adult", stageNumber: 4 }) + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain(state.phase.toUpperCase()) + if (state.phase === "evolved") expect(setup.captureCharFrame()).toContain("Greymon") + } + setPhase({ phase: "idle" }) + setArtwork(undefined) + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain("Next check: [") + expect(setup.captureCharFrame()).not.toContain("HIT!") + expect(setup.captureCharFrame()).not.toContain("DEFEATED") + } finally { + setup.renderer.destroy() + } + }) + + test("legacy consumers without a phase accessor keep their normal terminal layout", async () => { + const setup = await testRender( + () => + DigitalPetSidebarCard({ + model: () => partner, + animation: () => ({ kind: "blank" }), + }), + { width: 80, height: 24 }, + ) + try { + await setup.flush() + const frame = setup.captureCharFrame() + expect(frame).toContain("Agumon") + expect(frame).toContain("Child") + expect(frame).toContain("Next check: [") + expect(frame).toContain("50/100") + } finally { + setup.renderer.destroy() + } + }) +}) diff --git a/tests/sidebar-state.test.ts b/packages/opencode-digital-pet/tests/sidebar-state.test.ts similarity index 87% rename from tests/sidebar-state.test.ts rename to packages/opencode-digital-pet/tests/sidebar-state.test.ts index 7729c1c..b9689ce 100644 --- a/tests/sidebar-state.test.ts +++ b/packages/opencode-digital-pet/tests/sidebar-state.test.ts @@ -5,13 +5,13 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { createSqliteSidebarSnapshotReader } from "../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-sidebar-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-sidebar-")) tempRoots.push(root) return root } @@ -28,7 +28,7 @@ describe("SQLite sidebar snapshot reader", () => { expect(createSqliteSidebarSnapshotReader({ databasePath }).getSidebarSnapshot()).toBeNull() }) - test("Given a valid SQLite database without VPet tables When reading the sidebar snapshot Then the no-table failure returns null", async () => { + test("Given a valid SQLite database without Digital Pet tables When reading the sidebar snapshot Then the no-table failure returns null", async () => { const root = await createTempRoot() const databasePath = join(root, "empty.db") const result = Bun.spawnSync(["sqlite3", databasePath, "VACUUM"]) @@ -46,11 +46,11 @@ describe("SQLite sidebar snapshot reader", () => { }) test.if(isBunSqliteAvailable)( - "Given a populated VPet database When reading the sidebar snapshot Then it maps the active partner and trainer state", + "Given a populated Digital Pet database When reading the sidebar snapshot Then it maps the active partner and trainer state", async () => { const root = await createTempRoot() const databasePath = join(root, "pet.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const closeSpy = spyOn(Database.prototype, "close") try { @@ -72,6 +72,8 @@ describe("SQLite sidebar snapshot reader", () => { currentNodeId: partner.currentNodeId, gauge: partner.gauge, isTerminal: partner.isTerminal, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, }), ) @@ -82,6 +84,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 100, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) expect(closeSpy).toHaveBeenCalledTimes(1) } finally { @@ -96,7 +100,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "control-normal.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -116,6 +120,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: true, isSetOverride: false, trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) } finally { await repository.close() @@ -128,7 +134,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "control-cheat.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -149,6 +155,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const withoutPartner = new Database(databasePath) @@ -162,6 +170,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) } finally { await repository.close() @@ -174,7 +184,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "cheat-only-read.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -202,6 +212,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 900, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const afterDatabase = new Database(databasePath) @@ -228,7 +240,7 @@ describe("SQLite sidebar snapshot reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "stale-cheat.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { repository.spawnPartner({ @@ -248,6 +260,8 @@ describe("SQLite sidebar snapshot reader", () => { frozen: false, isSetOverride: true, trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }) const stored = new Database(databasePath) expect(stored.query("SELECT cheat_node_id FROM vpet_control_state WHERE control_id = 1").get()).toEqual({ diff --git a/tests/sidebar-view-model.test.ts b/packages/opencode-digital-pet/tests/sidebar-view-model.test.ts similarity index 81% rename from tests/sidebar-view-model.test.ts rename to packages/opencode-digital-pet/tests/sidebar-view-model.test.ts index bd34682..ae7d374 100644 --- a/tests/sidebar-view-model.test.ts +++ b/packages/opencode-digital-pet/tests/sidebar-view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { normalizeVpetSettings } from "../src/config/normalize.ts" -import { buildSidebarCardModel } from "../src/tui/sidebar-view-model.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { normalizeDigitalPetSettings } from "@jcendal/digital-pet-core/config/normalize.ts" +import { buildSidebarCardModel } from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts" describe("static sidebar model", () => { const partnerInputs = { @@ -21,14 +21,16 @@ describe("static sidebar model", () => { frozen: false, isSetOverride: false, trainerTotalTokens: 25_000, + evolutionBattlePending: false, } as const test("Given default Japanese settings When building the sidebar model Then it renders catalog Japanese names and familiar labels", () => { - const model = buildSidebarCardModel(partnerInputs, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel(partnerInputs, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") expect(Object.keys(model).sort()).toEqual([ + "evolutionBattlePending", "frozen", "gauge", "isSetOverride", @@ -46,7 +48,7 @@ describe("static sidebar model", () => { expect(model.stageNumber).toBe(4) expect(model.url).toBe("https://example.test/tailmon") expect(model.gauge).toBe(25_000) - expect(model.threshold).toBe(7_500_000) + expect(model.threshold).toBe(75_000_000) expect(model.isTerminal).toBe(false) expect(model.frozen).toBe(false) }) @@ -60,7 +62,7 @@ describe("static sidebar model", () => { ...partnerInputs, node: { ...partnerInputs.node, sprite }, }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -74,7 +76,7 @@ describe("static sidebar model", () => { ...partnerInputs, node: { ...partnerInputs.node, stage: 0, sprite: "not-an-egg" }, }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -85,7 +87,7 @@ describe("static sidebar model", () => { }) test("Given a frozen canonical partner When building the sidebar model Then it preserves the frozen display state", () => { - const model = buildSidebarCardModel({ ...partnerInputs, frozen: true }, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel({ ...partnerInputs, frozen: true }, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -94,7 +96,7 @@ describe("static sidebar model", () => { }) test("Given a set-override cheat partner When building the sidebar model Then it preserves the set display state", () => { - const model = buildSidebarCardModel({ ...partnerInputs, isSetOverride: true }, DEFAULT_VPET_SETTINGS) + const model = buildSidebarCardModel({ ...partnerInputs, isSetOverride: true }, DEFAULT_DIGITAL_PET_SETTINGS) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -103,7 +105,7 @@ describe("static sidebar model", () => { }) test("Given English settings with a custom threshold When building the sidebar model Then one language selects both English display fields", () => { - const settings = normalizeVpetSettings({ language: "en", stageThresholds: { adult: 777 } }) + const settings = normalizeDigitalPetSettings({ language: "en", stageThresholds: { adult: 777 } }) const model = buildSidebarCardModel(partnerInputs, settings) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") @@ -114,14 +116,14 @@ describe("static sidebar model", () => { }) test("Given invalid settings and a terminal partner When building the sidebar model Then it uses the resolved default presentation values", () => { - const settings = normalizeVpetSettings({ language: "fr", stageThresholds: { adult: 0 } }) + const settings = normalizeDigitalPetSettings({ language: "fr", stageThresholds: { adult: 0 } }) const model = buildSidebarCardModel({ ...partnerInputs, isTerminal: true }, settings) if (model.kind !== "partner") throw new Error("Expected partner sidebar model") expect(model.name).toBe("Tailmon") expect(model.stage).toBe("Adult") - expect(model.threshold).toBe(7_500_000) + expect(model.threshold).toBe(75_000_000) expect(model.isTerminal).toBe(true) }) @@ -130,7 +132,7 @@ describe("static sidebar model", () => { { kind: "no_partner", }, - DEFAULT_VPET_SETTINGS, + DEFAULT_DIGITAL_PET_SETTINGS, ) expect(model).toEqual({ kind: "no_partner", messageLine: "No active partner" }) diff --git a/tests/sqlite-capability.ts b/packages/opencode-digital-pet/tests/sqlite-capability.ts similarity index 100% rename from tests/sqlite-capability.ts rename to packages/opencode-digital-pet/tests/sqlite-capability.ts diff --git a/tests/sqlite-vpet-archive-reader.test.ts b/packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts similarity index 83% rename from tests/sqlite-vpet-archive-reader.test.ts rename to packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts index fb28de1..ae01341 100644 --- a/tests/sqlite-vpet-archive-reader.test.ts +++ b/packages/opencode-digital-pet/tests/sqlite-digital-pet-archive-reader.test.ts @@ -5,17 +5,17 @@ import { join } from "node:path" import { Database } from "bun:sqlite" import { - createSqliteVpetArchiveReader, + createSqliteDigitalPetArchiveReader, isRecoverableSqliteReadError, - readSqliteVpetArchive, -} from "../src/adapters/sqlite/sqlite-vpet-archive-reader.ts" -import { createSqliteVpetRepository } from "../src/adapters/sqlite/sqlite-vpet-write-store.ts" + readSqliteDigitalPetArchive, +} from "../src/adapters/sqlite/sqlite-digital-pet-archive-reader.ts" +import { createSqliteDigitalPetRepository } from "../src/adapters/sqlite/sqlite-digital-pet-write-store.ts" import { isBunSqliteAvailable } from "./sqlite-capability.ts" const tempRoots: string[] = [] const createTempRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-archive-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-archive-")) tempRoots.push(root) return root } @@ -24,13 +24,13 @@ afterEach(async () => { await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -describe("SQLite VPet archive reader", () => { +describe("SQLite Digital Pet archive reader", () => { test("Given a generic error mentioning sqlite and database When reading an archive Then it rethrows after closing", () => { const error = new Error("sqlite database reader interrupted") let closeCount = 0 expect(() => - readSqliteVpetArchive( + readSqliteDigitalPetArchive( { all: (): never => { throw error @@ -59,7 +59,7 @@ describe("SQLite VPet archive reader", () => { const root = await createTempRoot() const databasePath = join(root, "missing", "pet.db") - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) }) test.if(isBunSqliteAvailable)( @@ -69,9 +69,9 @@ describe("SQLite VPet archive reader", () => { const databasePath = join(root, "invalid.db") await Bun.write(databasePath, "not a sqlite database") - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "unavailable", - message: "VPet archive is unavailable.", + message: "Digital Pet archive is unavailable.", }) }, ) @@ -84,9 +84,9 @@ describe("SQLite VPet archive reader", () => { const result = Bun.spawnSync(["sqlite3", databasePath, "VACUUM"]) expect(result.exitCode).toBe(0) - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "unavailable", - message: "VPet archive is unavailable.", + message: "Digital Pet archive is unavailable.", }) }, ) @@ -96,10 +96,10 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "empty-archive.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) try { - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "empty" }) } finally { await repository.close() } @@ -111,7 +111,7 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "pet.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const database = new Database(databasePath) try { @@ -139,7 +139,7 @@ describe("SQLite VPet archive reader", () => { const closeSpy = spyOn(Database.prototype, "close") try { - expect(createSqliteVpetArchiveReader({ databasePath }).getArchive()).toEqual({ + expect(createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toEqual({ kind: "available", partners: [ { @@ -181,14 +181,14 @@ describe("SQLite VPet archive reader", () => { async () => { const root = await createTempRoot() const databasePath = join(root, "interrupted-read.db") - const repository = await createSqliteVpetRepository({ databasePath }) + const repository = await createSqliteDigitalPetRepository({ databasePath }) const querySpy = spyOn(Database.prototype, "query").mockImplementation((): never => { throw new Error("sqlite database reader interrupted") }) const closeSpy = spyOn(Database.prototype, "close") try { - expect(() => createSqliteVpetArchiveReader({ databasePath }).getArchive()).toThrow( + expect(() => createSqliteDigitalPetArchiveReader({ databasePath }).getArchive()).toThrow( "sqlite database reader interrupted", ) expect(closeSpy).toHaveBeenCalledTimes(1) diff --git a/packages/opencode-digital-pet/tests/tsconfig.json b/packages/opencode-digital-pet/tests/tsconfig.json new file mode 100644 index 0000000..f34f24f --- /dev/null +++ b/packages/opencode-digital-pet/tests/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.test.json", + "include": ["./**/*"], + "exclude": ["../node_modules", "../dist"] +} diff --git a/tests/tui-composition-fixture.ts b/packages/opencode-digital-pet/tests/tui-composition-fixture.ts similarity index 94% rename from tests/tui-composition-fixture.ts rename to packages/opencode-digital-pet/tests/tui-composition-fixture.ts index 6dc9e7a..fcfc0ac 100644 --- a/tests/tui-composition-fixture.ts +++ b/packages/opencode-digital-pet/tests/tui-composition-fixture.ts @@ -7,10 +7,11 @@ import type { import type { TuiDialogProps, TuiTheme } from "@opencode-ai/plugin/tui" import type { JSX } from "@opentui/solid" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import type { DigimonStage } from "../src/domain/stage.ts" -import type { MonsterAnimationOutput } from "../src/tui/monster-animation.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { DigimonStage } from "@jcendal/digital-pet-core/domain/stage.ts" +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" + import { createTui, type TuiCompositionApi } from "../src/tui.tsx" type TuiCompositionKeymap = NonNullable @@ -55,6 +56,7 @@ export const partnerInputs = (sprite: string, options: PartnerInputOptions = {}) frozen: false, isSetOverride: options.isSetOverride ?? false, trainerTotalTokens: 100, + evolutionBattlePending: false, }) export const settle = async (): Promise => { @@ -232,7 +234,7 @@ export class TuiCompositionHarness { for (const layer of this.layers) { if (!this.#activeLayers.has(layer) || layer.commands === undefined) continue for (const cmd of layer.commands) { - if (cmd.name === name || cmd.slashName === name) { + if (cmd["name"] === name || cmd["slashName"] === name) { if (cmd.run) { type RunFn = (context?: typeof ctx) => void const run = cmd.run as RunFn @@ -253,7 +255,7 @@ export class TuiCompositionHarness { options: { readonly random?: () => number } = {}, ): Promise { await this.startWith( - createTui(loadInputs, DEFAULT_VPET_SETTINGS, { + createTui(loadInputs, DEFAULT_DIGITAL_PET_SETTINGS, { scheduleVisualInterval: this.scheduler.scheduleVisualInterval, schedulePollTimeout: this.scheduler.schedulePollTimeout, nowMs: this.scheduler.nowMs, @@ -287,7 +289,7 @@ export class TuiCompositionHarness { slots: { register: (plugin: Parameters[0]) => { this.slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, keymap: this.keymap, diff --git a/tests/tui-composition.test.ts b/packages/opencode-digital-pet/tests/tui-composition.test.ts similarity index 90% rename from tests/tui-composition.test.ts rename to packages/opencode-digital-pet/tests/tui-composition.test.ts index 9bbf027..d141ea3 100644 --- a/tests/tui-composition.test.ts +++ b/packages/opencode-digital-pet/tests/tui-composition.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import type { VpetArchiveResult } from "../src/application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { MONSTER_FRAME_CATALOG } from "../src/data/monster-frame-catalog.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" import { createTui } from "../src/tui.tsx" import { messagePartUpdated, @@ -49,7 +49,7 @@ const sleepAtThreshold = (harness: TuiCompositionHarness, now: number): void => expect(lastKind(harness)).toBe("sleeping") } -const archiveWithAgumon: VpetArchiveResult = { +const archiveWithAgumon: DigitalPetArchiveResult = { kind: "available", partners: [ { @@ -71,34 +71,34 @@ const archiveWithAgumon: VpetArchiveResult = { describe("TUI composition", () => { test("Given an archive reader When the Dex and history commands run Then they register palette metadata, reread, replace one dialog, clear on close, and become inert after disposal", async () => { let reads = 0 - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { reads += 1 return archiveWithAgumon }, } const harness = new TuiCompositionHarness() - const compose = createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader }) + const compose = createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader }) await harness.startWith(compose) const layer = harness.layers[0] const commands = layer?.commands - if (commands === undefined) throw new Error("Expected VPet command layer") + if (commands === undefined) throw new Error("Expected Digital Pet command layer") expect( commands.map((command) => ({ - name: command.name, - slashName: command.slashName, - namespace: command.namespace, - category: command.category, + name: command["name"], + slashName: command["slashName"], + namespace: command["namespace"], + category: command["category"], })), ).toEqual([ - { name: "vpet.dex", slashName: "vpet-dex", namespace: "palette", category: "VPet" }, - { name: "vpet.history", slashName: "vpet-history", namespace: "palette", category: "VPet" }, + { name: "digital-pet.dex", slashName: "digital-pet-dex", namespace: "palette", category: "Digital Pet" }, + { name: "digital-pet.history", slashName: "digital-pet-history", namespace: "palette", category: "Digital Pet" }, ]) - harness.invokeCommand("vpet-dex") - harness.invokeCommand("vpet.history") + harness.invokeCommand("digital-pet-dex") + harness.invokeCommand("digital-pet.history") expect(reads).toBe(2) expect(harness.dialogStack.replaceCount).toBe(2) @@ -109,26 +109,26 @@ describe("TUI composition", () => { close() expect(harness.dialogStack.clearCount).toBe(1) - harness.invokeCommand("vpet-history") + harness.invokeCommand("digital-pet-history") expect(reads).toBe(3) expect(harness.dialogStack.replaceCount).toBe(3) await harness.dispose?.() - harness.invokeCommand("vpet.dex") + harness.invokeCommand("digital-pet.dex") expect(reads).toBe(3) expect(harness.dialogStack.replaceCount).toBe(3) }) test("Given an archive reader that throws When either archive command runs Then it opens one safe unavailable dialog instead of rejecting", async () => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { throw new Error("reader interrupted") }, } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) - harness.invokeCommand("vpet.dex") + harness.invokeCommand("digital-pet.dex") expect(harness.dialogStack.replaceCount).toBe(1) expect(harness.dialogStack.closeCallbacks).toHaveLength(1) await harness.dispose?.() @@ -140,8 +140,8 @@ describe("TUI composition", () => { expect(harness.layers).toHaveLength(1) const layer = harness.layers[0] - expect(layer?.name).toBe("opencode-vpet.layer") - expect(layer?.namespace).toBe("opencode-vpet") + expect(layer?.["name"]).toBe("opencode-digital-pet.layer") + expect(layer?.["namespace"]).toBe("opencode-digital-pet") expect(layer?.commands).toEqual([]) let testCommandRun = 0 @@ -281,7 +281,7 @@ describe("TUI composition", () => { await harness.dispose?.() }) - test("Given project-wide activity events When parent and child sessions wake a sleeping partner Then all accepted events are equivalent and idle is inert", async () => { + test("Given project-wide activity events When parent and child sessions wake a sleeping partner Then all accepted events are equivalent and idle plays feed", async () => { const harness = new TuiCompositionHarness() await harness.start(() => partnerInputs("agumon")) const accepted = [ @@ -304,8 +304,8 @@ describe("TUI composition", () => { harness.eventBus.emit(sessionStatus("parent", { type: "idle" })) - expect(lastKind(harness)).toBe("sleeping") - expect(harness.renderRequests).toBe(rendersBeforeIdle) + expect(lastKind(harness)).toBe("action") + expect(harness.renderRequests).toBe(rendersBeforeIdle + 1) expect(harness.eventBus.subscriptions).toEqual(["message.updated", "message.part.updated", "session.status"]) await harness.dispose?.() }) @@ -390,6 +390,7 @@ describe("TUI composition", () => { expect(loadCalls).toBe(2) resolveRefresh?.() await settle() + await settle() await setup.renderOnce() expect(loadCalls).toBe(3) @@ -440,16 +441,16 @@ describe("TUI composition", () => { test("Given an open dialog When the harness is disposed Then it handles it without errors", async () => { let _reads = 0 - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => { _reads += 1 return archiveWithAgumon }, } const harness = new TuiCompositionHarness() - await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_VPET_SETTINGS, { archiveReader })) + await harness.startWith(createTui(() => partnerInputs("agumon"), DEFAULT_DIGITAL_PET_SETTINGS, { archiveReader })) - harness.invokeCommand("vpet-dex") + harness.invokeCommand("digital-pet-dex") expect(harness.dialogStack.renders).toHaveLength(1) await harness.dispose?.() diff --git a/tests/tui-settings-composition.test.ts b/packages/opencode-digital-pet/tests/tui-settings-composition.test.ts similarity index 80% rename from tests/tui-settings-composition.test.ts rename to packages/opencode-digital-pet/tests/tui-settings-composition.test.ts index 1c1c2f5..4740c3c 100644 --- a/tests/tui-settings-composition.test.ts +++ b/packages/opencode-digital-pet/tests/tui-settings-composition.test.ts @@ -7,12 +7,12 @@ import { testRender } from "@opentui/solid" import type { JSX } from "@opentui/solid" import { createSqliteSidebarSnapshotReader } from "../src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import type { SidebarCardInputs } from "../src/application/models/sidebar-card-inputs.ts" -import type { VpetArchiveReader } from "../src/application/ports/vpet-archive.ts" -import { getSidebarCardInputs } from "../src/application/use-cases/get-sidebar-card-inputs.ts" -import { DEFAULT_VPET_SETTINGS } from "../src/config/defaults.ts" -import { loadGlobalVpetSettings } from "../src/config/global-vpet-settings.ts" -import { DIGIMON_CATALOG } from "../src/data/catalog.ts" +import type { SidebarCardInputs } from "@jcendal/digital-pet-core/application/models/sidebar-card-inputs.ts" +import type { DigitalPetArchiveReader } from "@jcendal/digital-pet-core/application/ports/digital-pet-archive.ts" +import { getSidebarCardInputs } from "@jcendal/digital-pet-core/application/use-cases/get-sidebar-card-inputs.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { loadGlobalDigitalPetSettings } from "../src/config/global-digital-pet-settings.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" import { createTui } from "../src/tui.tsx" import { TuiCompositionHarness } from "./tui-composition-fixture.ts" @@ -24,7 +24,7 @@ const event = { on: () => () => undefined } describe("TUI settings composition", () => { test("Given English startup settings When the archive command opens after configuration changes Then its history retains startup localization", async () => { - const archiveReader: VpetArchiveReader = { + const archiveReader: DigitalPetArchiveReader = { getArchive: () => ({ kind: "available", partners: [ @@ -38,19 +38,19 @@ describe("TUI settings composition", () => { ], }), } - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-history-settings-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-history-settings-")) const configDirectory = join(root, ".config") - const configPath = join(configDirectory, "opencode-vpet.json") + const configPath = join(configDirectory, "opencode-digital-pet.json") const harness = new TuiCompositionHarness() try { await mkdir(configDirectory) await writeFile(configPath, JSON.stringify({ language: "en" })) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) await harness.startWith(createTui(() => ({ kind: "no_partner" }), settings, { archiveReader })) await writeFile(configPath, JSON.stringify({ language: "jp" })) - harness.invokeCommand("vpet-history") + harness.invokeCommand("digital-pet-history") const dialog = harness.dialogStack.renders[0] if (dialog === undefined) throw new Error("Expected history dialog render") const setup = await testRender(() => dialog(), { width: 80, height: 24 }) @@ -82,13 +82,14 @@ describe("TUI settings composition", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, trainerTotalTokens: 100, } let slot: (() => unknown) | undefined let dispose: (() => void | Promise) | undefined let renderRequests = 0 - const tui = createTui(() => inputs, DEFAULT_VPET_SETTINGS) + const tui = createTui(() => inputs, DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -106,7 +107,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -119,8 +120,8 @@ describe("TUI settings composition", () => { }) test("Given an English startup config When its file changes after initialization Then every poll retains the original sidebar projection", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-config-")) - const configPath = join(root, ".config", "opencode-vpet.json") + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-config-")) + const configPath = join(root, ".config", "opencode-digital-pet.json") const inputs: SidebarCardInputs = { kind: "partner", node: { @@ -136,6 +137,7 @@ describe("TUI settings composition", () => { isTerminal: false, frozen: false, isSetOverride: false, + evolutionBattlePending: false, trainerTotalTokens: 777, } let slot: (() => JSX.Element) | undefined @@ -148,7 +150,7 @@ describe("TUI settings composition", () => { configPath, JSON.stringify({ language: "en", notifications: false, stageThresholds: { adult: 777 } }), ) - const settings = await loadGlobalVpetSettings({ home: root }) + const settings = await loadGlobalDigitalPetSettings({ home: root }) expect(settings.notifications).toBeFalse() const tui = createTui(() => { loadCalls += 1 @@ -167,7 +169,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -194,25 +196,25 @@ describe("TUI settings composition", () => { }) test.each([ - ["missing", async (_configPath: string) => undefined], + ["missing", async (_configPath: string): Promise => undefined], [ "invalid", - async (configPath: string) => + async (configPath: string): Promise => writeFile(configPath, JSON.stringify({ language: "fr", stageThresholds: { adult: 0 } })), ], ] as const)( "Given a %s startup config When the TUI composes its snapshot Then it renders JP defaults", async (_name, prepareConfig) => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-defaults-")) - const configPath = join(root, ".config", "opencode-vpet.json") + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-defaults-")) + const configPath = join(root, ".config", "opencode-digital-pet.json") let dispose: (() => void | Promise) | undefined let slot: (() => JSX.Element) | undefined try { await mkdir(join(root, ".config")) await prepareConfig(configPath) - const settings = await loadGlobalVpetSettings({ home: root }) - expect(settings).toEqual(DEFAULT_VPET_SETTINGS) + const settings = await loadGlobalDigitalPetSettings({ home: root }) + expect(settings).toEqual(DEFAULT_DIGITAL_PET_SETTINGS) const tui = createTui( () => ({ kind: "partner", @@ -225,11 +227,12 @@ describe("TUI settings composition", () => { stage: 4, url: "https://example.test/tailmon", }, - gauge: 7_500_000, + gauge: 75_000_000, isTerminal: false, frozen: false, isSetOverride: false, - trainerTotalTokens: 7_500_000, + evolutionBattlePending: false, + trainerTotalTokens: 75_000_000, }), settings, ) @@ -246,7 +249,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -259,7 +262,7 @@ describe("TUI settings composition", () => { expect(frame).toContain("Tailmon") expect(frame).toContain("Adult") - expect(frame).toContain("7,500,000/7,500,000") + expect(frame).toContain("75,000,000/75,000,000") expect(frame).not.toContain("Gatomon") expect(frame).not.toContain("Champion") setup.renderer.destroy() @@ -271,7 +274,7 @@ describe("TUI settings composition", () => { ) test("Given a missing database at startup When the independently composed reader and query load Then the TUI renders no partner without creating it", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-vpet-tui-")) + const root = await mkdtemp(join(tmpdir(), "opencode-digital-pet-tui-")) const databasePath = join(root, "missing", "pet.db") let slot: (() => unknown) | undefined let dispose: (() => void | Promise) | undefined @@ -279,7 +282,7 @@ describe("TUI settings composition", () => { try { const reader = createSqliteSidebarSnapshotReader({ databasePath }) - const tui = createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), DEFAULT_VPET_SETTINGS) + const tui = createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -297,7 +300,7 @@ describe("TUI settings composition", () => { slots: { register: (plugin) => { slot = plugin.slots.sidebar_content - return "opencode-vpet" + return "opencode-digital-pet" }, }, }) @@ -322,7 +325,7 @@ describe("TUI settings composition", () => { resolveQuery = resolve }) return { kind: "no_partner" } - }, DEFAULT_VPET_SETTINGS) + }, DEFAULT_DIGITAL_PET_SETTINGS) await tui({ event, @@ -337,7 +340,7 @@ describe("TUI settings composition", () => { return () => undefined }, }, - slots: { register: () => "opencode-vpet" }, + slots: { register: () => "opencode-digital-pet" }, }) await dispose?.() resolveQuery?.() diff --git a/tests/usage-event-ingestion.test.ts b/packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts similarity index 91% rename from tests/usage-event-ingestion.test.ts rename to packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts index 0016870..458290b 100644 --- a/tests/usage-event-ingestion.test.ts +++ b/packages/opencode-digital-pet/tests/usage-event-ingestion.test.ts @@ -4,10 +4,13 @@ import type { AssistantMessage, Event, Message, UserMessage } from "@opencode-ai import { toCompletedUsageFromEvent, toCompletedUsageFromMessage } from "../src/adapters/opencode/usage-event-mapper.ts" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" import { createSessionMessagesFetcher } from "../src/adapters/opencode/session-messages.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" const completedAssistantEvent = { type: "message.updated", @@ -143,6 +146,8 @@ describe("usage event ingestion", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedReceipts.push(receipt) @@ -151,7 +156,7 @@ describe("usage event ingestion", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const hooks = createServerHooks({ repository, resource: { close: async () => {} }, evolutionSelector: () => 0 }) const handleEvent = hooks.event @@ -173,6 +178,8 @@ describe("usage event ingestion", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedReceipts.push(receipt) @@ -181,7 +188,7 @@ describe("usage event ingestion", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const fetchMessages = createSessionMessagesFetcher({ session: { async messages({ path }) { diff --git a/tests/usage-event-processing-time.test.ts b/packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts similarity index 82% rename from tests/usage-event-processing-time.test.ts rename to packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts index 4f90a33..eb738a3 100644 --- a/tests/usage-event-processing-time.test.ts +++ b/packages/opencode-digital-pet/tests/usage-event-processing-time.test.ts @@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test" import type { Event, Message } from "@opencode-ai/sdk" import { createServerHooks } from "../src/adapters/opencode/create-server-hooks.ts" -import type { ApplyUsageReceiptOutcome, UsageReceiptMetadata } from "../src/application/models/usage.ts" -import type { PartnerLifecycle } from "../src/application/ports/partner-lifecycle.ts" -import type { UsageLedger } from "../src/application/ports/usage-ledger.ts" -import type { VpetControl } from "../src/application/ports/vpet-control.ts" +import type { + ApplyUsageReceiptOutcome, + UsageReceiptMetadata, +} from "@jcendal/digital-pet-core/application/models/usage.ts" +import type { PartnerLifecycle } from "@jcendal/digital-pet-core/application/ports/partner-lifecycle.ts" +import type { UsageLedger } from "@jcendal/digital-pet-core/application/ports/usage-ledger.ts" +import type { DigitalPetControl } from "@jcendal/digital-pet-core/application/ports/digital-pet-control.ts" const completedAssistantEvent = { type: "message.updated", @@ -44,6 +47,8 @@ describe("usage event processing time", () => { isTerminal: false, createdAt: "2026-07-31T00:00:00.000Z", retiredAt: null, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, }), applyUsageReceipt(receipt: UsageReceiptMetadata): ApplyUsageReceiptOutcome { appliedModes.push({ receiptKey: receipt.receiptKey, mode: processingMode }) @@ -52,7 +57,7 @@ describe("usage event processing time", () => { freeze: () => ({ kind: "already_frozen" as const }), unfreeze: () => ({ kind: "already_unfrozen" as const }), setCheatNode: (cheatNodeId: string) => ({ kind: "already_set" as const, cheatNodeId }), - } satisfies PartnerLifecycle & UsageLedger & VpetControl + } satisfies PartnerLifecycle & UsageLedger & DigitalPetControl const fetchMessages = async (): Promise => [ completedAssistantMessage("message-idle-processing-time", 300), ] diff --git a/tsconfig.build.json b/packages/opencode-digital-pet/tsconfig.build.json similarity index 65% rename from tsconfig.build.json rename to packages/opencode-digital-pet/tsconfig.build.json index 2d0bd49..7e13ec8 100644 --- a/tsconfig.build.json +++ b/packages/opencode-digital-pet/tsconfig.build.json @@ -2,9 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, - "declarationMap": true, + "declarationMap": false, "emitDeclarationOnly": true, "noEmit": false }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/dev"] } diff --git a/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json b/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json new file mode 100644 index 0000000..ea030ef --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.digital-pet-animation-declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "../digital-pet-animation/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist/digital-pet-animation", + "rootDir": "build/digital-pet-animation-src" + }, + "include": ["build/digital-pet-animation-src"] +} diff --git a/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json b/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json new file mode 100644 index 0000000..dd97a3d --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.digital-pet-core-declarations.json @@ -0,0 +1,12 @@ +{ + "extends": "../digital-pet-core/tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist/digital-pet-core", + "rootDir": "build/digital-pet-core-src" + }, + "include": ["build/digital-pet-core-src"] +} diff --git a/packages/opencode-digital-pet/tsconfig.json b/packages/opencode-digital-pet/tsconfig.json new file mode 100644 index 0000000..cad9cc8 --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["bun", "node"], + "jsx": "react-jsx", + "jsxImportSource": "@opentui/solid", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/opencode-digital-pet/tsconfig.scripts.json b/packages/opencode-digital-pet/tsconfig.scripts.json new file mode 100644 index 0000000..b36d54c --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.scripts.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "target": "ESNext", + "lib": ["ESNext"], + "types": ["bun", "node"], + "noEmit": true, + "rootDir": "scripts", + "skipLibCheck": true + }, + "include": ["scripts/**/*.ts"] +} diff --git a/packages/opencode-digital-pet/tsconfig.test.json b/packages/opencode-digital-pet/tsconfig.test.json new file mode 100644 index 0000000..083a2bc --- /dev/null +++ b/packages/opencode-digital-pet/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/solid" + }, + "include": ["src", "tests"], + "exclude": [ + "node_modules", + "dist", + "tests/release-metadata.test.ts", + "tests/release-plan.test.ts", + "tests/dispatch-merged-releases.test.ts", + "tests/push-release.test.ts" + ] +} diff --git a/packages/web-digital-pet/README.md b/packages/web-digital-pet/README.md new file mode 100644 index 0000000..6849f74 --- /dev/null +++ b/packages/web-digital-pet/README.md @@ -0,0 +1,289 @@ +
+
+

+ Web Digital Pet logo +

+

Web Digital Pet

+

+ Digimon companion in the browser — shared SQLite archive with Cursor and OpenCode, or a standalone browser save with PWA install and device transfer +

+

+ Apache 2.0 license + Node 24.11.0 or newer +

+

+ About • + Features • + Quick start • + Storage • + PWA • + Transfer • + Configuration • + Privacy • + Authors +

+

+ Run locally with npm run web +    + Cursor Digital Pet +    + OpenCode Digital Pet +

+
+
+ +--- + +
+
+

Web Digital Pet with Greymon, green LCD, bottom navigation, and Digital World lake background

+

+Digidex with Algomon selected, pixel artwork, and registered catalog entries +Generation history with the current Greymon partner and previous generations +

+
+
+ +

Partner, Digidex, and History above use an example computer save. A new browser save starts with a Digitama.

+ +--- + +## 📖 About + +**Web Digital Pet** is the browser companion in the [Digital Pet](https://github.com/jcendal/digital-pet) monorepo. It shows your animated partner, the Digidex, and generation history in a phone-width layout (up to 430 × 880 px), centered over an original Digital World background on larger screens. Bottom navigation keeps Partner, Dex, and History mounted so switching views does not reload the page. + +When a shared `pet.db` is present on the host machine, the web app **reads** the same archive as +[@jcendal/cursor-digital-pet](https://open-vsx.org/extension/jcendal/cursor-digital-pet) and +[@jcendal/opencode-digital-pet](https://www.npmjs.com/package/@jcendal/opencode-digital-pet). When that file is absent, the app runs in **browser mode**: progress lives in IndexedDB, experience ticks on a five-minute schedule, and you can install the app as a PWA or move saves between devices from **OPTIONS → PAIR DEVICES**. You can also choose a browser companion even when a computer save is available. + +### 🎯 Key Highlights + +- 🐣 **Two storage modes** — Shared local `pet.db` or an independent browser save +- 📱 **PWA-ready** — Manifest, icons, maskable assets, and a service worker for offline UI +- 🎮 **650 Digimon** — Same catalog, evolution art, and panels as the desktop integrations +- 📊 **Partner · Dex · History** — Shared webview UI from `digital-pet-webviews` +- ⚙️ **Options panel** — Choose your save, set evolution experience, and start a new egg +- 🌍 **Explore regions** — Ten destinations, a habitat guide, and eleven LCD landscapes +- 🔄 **Manual device sync** — Pair once, then bring the other browser's save with **SYNC** +- 🔒 **Privacy-first** — No accounts, no cloud backup; saves stay on your device or local disk +- 📖 **Read-only SQLite** — Cursor and OpenCode advance the shared archive; the web app does not write `pet.db` + +--- + +## ✨ Features + +| Feature | Description | +| --- | --- | +| **Partner view** | Animated Digimon with evolution progress toward the next stage | +| **Digidex** | Catalog browser with discoveries, filters (collapsed by default), and per-entry detail | +| **Regions** | Explore ten classic Field destinations, visit Dragon Eye Lake, and change your companion’s LCD landscape | +| **History** | Current and retired generations with recorded evolution journeys | +| **SQLite mode** | Live view of the shared `pet.db` while Cursor or OpenCode records usage | +| **Browser mode** | Digitama hatch, timed experience, automatic evolution, IndexedDB persistence | +| **PWA install** | Add to home screen or desktop with standalone window and cached shell | +| **Options** | Fourth navigation button opens the save selector, growth settings, new egg, and device pairing | +| **Evolution experience** | Low = 10% of the original requirement, Normal = 50%, High = 100% | +| **New egg** | Start a fresh browser companion while keeping the previous generation in History | +| **Device pairing and Sync** | Approve the first exchange, remember the device, then request later transfers with one button and a rollback backup | + +--- + +## 🚀 Quick start + +### Requirements + +| Requirement | Details | +| --- | --- | +| **Node.js** | `24.11.0` or newer | +| **npm** | Workspace install from the monorepo root | +| **Bun** | `1.3.5` for package tests only | + +From the repository root: + +```sh +npm ci +npm run web +``` + +Open [http://localhost:4173](http://localhost:4173). The command builds this package and starts the local server on `127.0.0.1`. SQLite access uses Node's built-in SQLite support; a separate SQLite installation is not required. + +> **Note:** A phone cannot reach your computer through its own `localhost`. Access from another device needs HTTPS via a tunnel or proxy you control. This server is for local development, not public hosting. + +### Try browser mode locally + +Open **OPTIONS** and select **THIS BROWSER**. This keeps the computer save and browser save separate. To test a host without any computer save, point the server at a database path that does not exist: + +```sh +DIGITAL_PET_DATABASE_PATH=/path/to/nonexistent/pet.db npm run web +``` + +--- + +## 🌍 Explore the Digital World + +Open **OPTIONS → YOUR WORLD → EXPLORE REGIONS**, or select the place name above your partner's viewer. Browse ten destinations, inspect their inhabitants, and choose **TRAVEL HERE**. Digital Ocean also includes **Dragon Eye Lake**, the starting location with the existing background illustration. + +Each place has its own LCD pixel landscape. While your companion walks, the landscape drifts slowly in the same direction; it pauses during other actions. Reflected copies keep the edges continuous. **OPTIONS → YOUR WORLD → MOVING LANDSCAPE** turns the effect on or off for this device. It follows the device’s reduced motion preference until you choose a setting. The guide shows public species names and artwork, with a registration marker and Dex shortcut for species already in your save. Counts reflect your history; visiting does not register species or change evolution rules. All 650 catalog records have at least one habitat, combining reference Fields with explicit thematic choices where references are missing. Habitats can overlap between regions. The guide shows **five cards initially**; use the arrow below them to reveal the remaining inhabitants. The arrow disappears when expanded. See the [habitat catalog notes](../digital-pet-fields/README.md) for sources and classification details. + +
+ LCD region selector with pixel landscape cards and registration counts + Region selector fitted to a 375 px phone screen + Habitat Guide showing five Digimon and an arrow to reveal 93 more inhabitants + Animated partner with its current location and pixel landscape +
+ +Travel is available for both saves. The browser destination travels with its save during Pair / Sync and remains when starting a new egg. The computer destination is a separate web preference, without changing SQLite. The four future Fields are documented but inactive. + +Only Dragon Eye Lake currently has a large page illustration. Other places use atmosphere colors until their images are supplied. Add the named PNG files in [`digital-pet-fields/assets/backgrounds`](../digital-pet-fields/assets/backgrounds) and restart the server; see the [Fields package guide](../digital-pet-fields/README.md#add-background-images). All eleven LCD scenes are cached for offline use; supplied photos are cached after they are viewed. + +--- + +## 💾 Storage + +Open **OPTIONS → YOUR SAVE** to choose **COMPUTER SAVE** (the companion raised with Cursor or OpenCode) or **THIS BROWSER** (an independent companion saved in this browser). The default is the computer save when available, otherwise the browser save. Switching remembers your choice and preserves both saves; it does not copy or merge them. + +| | Local SQLite archive | Browser save | +| --- | --- | --- | +| **When** | Choose COMPUTER SAVE while the configured `pet.db` exists | Choose THIS BROWSER, or no computer save is available | +| **Partner** | Shared with Cursor and OpenCode on that computer | Separate partner, starting with a Digitama | +| **Experience** | Token usage from the integrations | One twenty-fourth of the original stage threshold per completed five-minute interval; evolution requirements are adjustable | +| **Persistence** | Existing database (read-only from the web app) | IndexedDB for this browser origin | +| **Offline** | Live progress needs the local server | UI and save after the service worker cache | +| **Growth / new egg** | Managed in Cursor or OpenCode | Configurable in OPTIONS; retired companions stay in History | +| **Pair / Sync** | Unavailable | On request between paired, open browser sessions | + +Default `pet.db` location (same as the other apps): + +| Platform | Default path | +| --- | --- | +| **macOS** | `~/Library/Application Support/opencode-digital-pet/pet.db` | +| **Linux / WSL** | `~/.local/share/opencode-digital-pet/pet.db` | +| **Windows** | `%APPDATA%\opencode-digital-pet\pet.db` | + +Override with `DIGITAL_PET_DATABASE_PATH`. In SQLite mode, partner progression and history are updated only by Cursor or OpenCode. + +In browser mode, elapsed five-minute intervals are applied when you reopen the app; evolution is automatic. The save includes partner progress, discoveries, history, and the selected evolution experience and location. The stable device code and paired device are stored separately, so importing a save keeps this device's identity. It is tied to this site's origin — another browser, host, or port has separate storage. Clearing site data removes the save and its local backup. + +--- + +## 📱 Install as a PWA + +1. Open the app and wait for the first load to finish. +2. Use the browser **Install app** or **Add to Home Screen** action when offered. +3. Launch Digital Pet from the installed icon. + +The package ships a web app manifest, favicons, standard and maskable icons, and a service worker that caches the shell for offline use. A browser save can keep progressing offline; **Pair / Sync** needs a network connection, and SQLite mode needs the local server. Installation away from `localhost` requires HTTPS. + +--- + +## 🔗 Transfer a browser save + +Keep the app **open and online** on both devices, with **OPTIONS → THIS BROWSER** selected. Computer saves are not transferred. + +
+ Device transfer dialog with this device's code and a field to request another device's save +
+ +1. On device **B**, open **OPTIONS → PAIR DEVICES** and copy its device code. +2. On device **A**, open **OPTIONS → PAIR DEVICES**, enter B's code, and select **REQUEST**. +3. On **B**, choose **SEND SAVE & PAIR** to approve or **DECLINE** to cancel. +4. On **A**, review the preview. Choose **REPLACE SAVE & PAIR** to import or **KEEP MINE** to keep the current save. +5. After importing, **A** can use **RESTORE PREVIOUS SAVE** to recover the backed-up copy. + +After the first accepted transfer, both devices remember each other. In **OPTIONS → CONNECT DEVICES**, press **SYNC** on A to request B's current save again, without entering its code or repeating approval. B must remain open with **THIS BROWSER** selected. **FORGET DEVICE** removes the remembered pairing on that device. + +Each transfer replaces the receiving device's browser save and keeps a backup. **B** keeps its save, **A** keeps its device code, and progress can diverge afterward. **SYNC always brings the other device's save here**; it does not merge progress, pick the newest save, or run continuously. Switching Partner, Dex, or History does not cancel an active request. + +[PeerJS Cloud](https://peerjs.com/server/cloud) provides free signaling; WebRTC carries the save peer-to-peer. The public service has no TURN relay, so some networks may block the connection. Incoming requests appear only while the app is open — there are no background push notifications. + +--- + +## ⚙️ Configuration + +### In-app options + +
+ Options panel with computer or browser save choice and evolution experience slider + Paired browser companion options with new egg and manual Sync +
+ +**OPTIONS** opens a scrollable overlay from any section. Browser companion options are enabled when **THIS BROWSER** is selected: + +| Evolution experience | Requirement | Effect | +| --- | --- | --- | +| **LOW** | 10% of HIGH | Faster evolution | +| **NORMAL** | 50% of HIGH | Half the original requirement | +| **HIGH** | 100% | Original progression (default) | + +The amount earned every five minutes stays the same. Changes apply to future evolution checks and travel with transferred saves. **START A NEW EGG** asks for confirmation, moves the current companion to History, and keeps the experience setting, location, and paired device. + +### Server settings + +| Setting | Default | Description | +| --- | --- | --- | +| `PORT` | `4173` | Local web server port (`1`–`65535`) | +| `DIGITAL_PET_DATABASE_PATH` | Shared Digital Pet app-data `pet.db` | SQLite file to read; a missing file enables browser mode | + +Example — custom archive and port: + +```sh +DIGITAL_PET_DATABASE_PATH=/path/to/pet.db PORT=4174 npm run web +``` + +### Development commands + +Run from the repository root. Tests require Bun. + +```sh +npm run build --workspace @jcendal/web-digital-pet +npm run check --workspace @jcendal/web-digital-pet +npm run test --workspace @jcendal/web-digital-pet +``` + +This package adds the local server, SQLite read adapter, IndexedDB save, timed browser progression, and device transfer. Rendering and panels come from `digital-pet-core`, `digital-pet-webviews`, and `digital-pet-animation`. World definitions and assets come from `digital-pet-fields`. See the [Cursor extension](../cursor-digital-pet/README.md) and [OpenCode plugin](../opencode-digital-pet/README.md) guides for desktop setup. + +--- + +## 🔒 Privacy + +- ✅ No analytics or telemetry in the web package +- ✅ SQLite mode keeps partner data in your local `pet.db` only +- ✅ Browser saves stay in IndexedDB on your device; no account or cloud sync +- ⚠️ **Pair / Sync** uses PeerJS Cloud for signaling and exchanges save data directly between browsers you approve +- ⚠️ Clearing site data or uninstalling the PWA removes browser saves unless you transferred them elsewhere + +--- + +## 🤝 Contributing + +Contributions and issues belong in the [monorepo](https://github.com/jcendal/digital-pet). + +Package path: [`packages/web-digital-pet`](https://github.com/jcendal/digital-pet/tree/main/packages/web-digital-pet) + +--- + +## 👤 Authors + +| Role | Name | +| --- | --- | +| **Maintainer** | [Jorge Cendal](https://github.com/jcendal) | +| **Original author and contributor** | [Sergio Bugallo](https://github.com/sbugallo) | + +--- + +## 📜 License + +[Apache-2.0](../../LICENSE) + +Part of the [Digital Pet](https://github.com/jcendal/digital-pet) monorepo. Digimon sprites were derived from community sources — see the [root README credits](https://github.com/jcendal/digital-pet#maintainer-and-credits). + +--- + +

Built with ❤️ for the developer community

+ +

⬆ Back to Top

diff --git a/packages/web-digital-pet/assets/favicon.ico b/packages/web-digital-pet/assets/favicon.ico new file mode 100644 index 0000000..bb43c48 Binary files /dev/null and b/packages/web-digital-pet/assets/favicon.ico differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-16.png b/packages/web-digital-pet/assets/icons/digital-pet-16.png new file mode 100644 index 0000000..39f9f1e Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-16.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-180.png b/packages/web-digital-pet/assets/icons/digital-pet-180.png new file mode 100644 index 0000000..c44ba25 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-180.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-192.png b/packages/web-digital-pet/assets/icons/digital-pet-192.png new file mode 100644 index 0000000..8eb15e0 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-192.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-32.png b/packages/web-digital-pet/assets/icons/digital-pet-32.png new file mode 100644 index 0000000..17ef478 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-32.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-512.png b/packages/web-digital-pet/assets/icons/digital-pet-512.png new file mode 100644 index 0000000..7d0da87 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-512.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-maskable-192.png b/packages/web-digital-pet/assets/icons/digital-pet-maskable-192.png new file mode 100644 index 0000000..6b5ac80 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-maskable-192.png differ diff --git a/packages/web-digital-pet/assets/icons/digital-pet-maskable-512.png b/packages/web-digital-pet/assets/icons/digital-pet-maskable-512.png new file mode 100644 index 0000000..e2db497 Binary files /dev/null and b/packages/web-digital-pet/assets/icons/digital-pet-maskable-512.png differ diff --git a/packages/web-digital-pet/assets/manifest.webmanifest b/packages/web-digital-pet/assets/manifest.webmanifest new file mode 100644 index 0000000..1c788c4 --- /dev/null +++ b/packages/web-digital-pet/assets/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "id": "/", + "name": "Digital Pet", + "short_name": "Digital Pet", + "description": "Your Digital Pet partner, Digidex, and generation history.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#594130", + "theme_color": "#594130", + "icons": [ + { "src": "/icons/digital-pet-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/digital-pet-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/digital-pet-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/digital-pet-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/packages/web-digital-pet/assets/service-worker.js b/packages/web-digital-pet/assets/service-worker.js new file mode 100644 index 0000000..41e0d06 --- /dev/null +++ b/packages/web-digital-pet/assets/service-worker.js @@ -0,0 +1,80 @@ +const CACHE_NAME = "web-digital-pet-v13" +const APP_FILES = [ + "/", + "/dex", + "/history", + "/view/sidebar", + "/view/dex", + "/view/history", + "/browser-local.js", + "/browser-pairing.js", + "/browser-options.js", + "/browser-world.js", + "/browser-scenery.js", + "/regions/dragon-eye-lake/background.png", + "/regions/gear-savannah/scene.svg", + "/regions/digital-ocean/scene.svg", + "/regions/dragon-eye-lake/scene.svg", + "/regions/wasteland/scene.svg", + "/regions/digital-forest/scene.svg", + "/regions/digital-city/scene.svg", + "/regions/village-of-beginnings/scene.svg", + "/regions/ancient-dino-region/scene.svg", + "/regions/tropical-jungle/scene.svg", + "/regions/vamdemon-castle/scene.svg", + "/regions/upside-down-pyramid/scene.svg", + "/manifest.webmanifest", + "/favicon.ico", + "/fonts/Silkscreen-Regular.ttf", + "/images/digital-world-lake-background.png", + "/icons/digital-pet-16.png", + "/icons/digital-pet-32.png", + "/icons/digital-pet-180.png", + "/icons/digital-pet-192.png", + "/icons/digital-pet-512.png", + "/icons/digital-pet-maskable-192.png", + "/icons/digital-pet-maskable-512.png", +] + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(APP_FILES)) + .then(() => self.skipWaiting()), + ) +}) + +self.addEventListener("activate", (event) => { + event.waitUntil( + Promise.all([ + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), + self.clients.claim(), + ]), + ) +}) + +self.addEventListener("fetch", (event) => { + const request = event.request + if (request.method !== "GET") return + const url = new URL(request.url) + if (url.origin !== self.location.origin || url.pathname === "/service-worker.js" || url.pathname.startsWith("/api/")) + return + event.respondWith( + (async () => { + try { + const response = await fetch(request) + if (response.ok) { + const cache = await caches.open(CACHE_NAME) + await cache.put(request, response.clone()) + } + return response + } catch { + const cache = await caches.open(CACHE_NAME) + return (await cache.match(request, { ignoreSearch: request.mode === "navigate" })) || Response.error() + } + })(), + ) +}) diff --git a/packages/web-digital-pet/esbuild.mjs b/packages/web-digital-pet/esbuild.mjs new file mode 100644 index 0000000..4fcff29 --- /dev/null +++ b/packages/web-digital-pet/esbuild.mjs @@ -0,0 +1,79 @@ +import { mkdir } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import { build } from "esbuild" + +const root = dirname(fileURLToPath(import.meta.url)) +await mkdir(resolve(root, "dist"), { recursive: true }) +await build({ + entryPoints: [resolve(root, "src/server.ts")], + outfile: resolve(root, "dist/server.js"), + bundle: true, + platform: "node", + target: "node24", + format: "esm", + packages: "bundle", + sourcemap: false, + logLevel: "info", +}) +await build({ + entryPoints: [resolve(root, "src/browser-local.ts")], + outfile: resolve(root, "dist/browser-local.js"), + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + charset: "utf8", + minify: true, + sourcemap: false, + logLevel: "info", +}) +await build({ + entryPoints: [resolve(root, "src/browser-pairing.ts")], + outfile: resolve(root, "dist/browser-pairing.js"), + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + charset: "utf8", + minify: true, + sourcemap: false, + logLevel: "info", +}) +await build({ + entryPoints: [resolve(root, "src/browser-options.ts")], + outfile: resolve(root, "dist/browser-options.js"), + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + charset: "utf8", + minify: true, + sourcemap: false, + logLevel: "info", +}) + +await build({ + entryPoints: [resolve(root, "src/browser-world.ts")], + outfile: resolve(root, "dist/browser-world.js"), + external: ["/browser-local.js"], + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + charset: "utf8", + minify: true, + logLevel: "info", +}) + +await build({ + entryPoints: [resolve(root, "src/browser-scenery.ts")], + outfile: resolve(root, "dist/browser-scenery.js"), + bundle: true, + platform: "browser", + target: "es2022", + format: "esm", + minify: true, + logLevel: "info", +}) diff --git a/packages/web-digital-pet/images/device-transfer.jpg b/packages/web-digital-pet/images/device-transfer.jpg new file mode 100644 index 0000000..4f49b6d Binary files /dev/null and b/packages/web-digital-pet/images/device-transfer.jpg differ diff --git a/packages/web-digital-pet/images/digidex.jpg b/packages/web-digital-pet/images/digidex.jpg new file mode 100644 index 0000000..da2085b Binary files /dev/null and b/packages/web-digital-pet/images/digidex.jpg differ diff --git a/packages/web-digital-pet/images/habitat-guide-centered.jpg b/packages/web-digital-pet/images/habitat-guide-centered.jpg new file mode 100644 index 0000000..e7a1d4c Binary files /dev/null and b/packages/web-digital-pet/images/habitat-guide-centered.jpg differ diff --git a/packages/web-digital-pet/images/habitat-guide-mobile.jpg b/packages/web-digital-pet/images/habitat-guide-mobile.jpg new file mode 100644 index 0000000..ca5aa71 Binary files /dev/null and b/packages/web-digital-pet/images/habitat-guide-mobile.jpg differ diff --git a/packages/web-digital-pet/images/history.jpg b/packages/web-digital-pet/images/history.jpg new file mode 100644 index 0000000..8090bf2 Binary files /dev/null and b/packages/web-digital-pet/images/history.jpg differ diff --git a/packages/web-digital-pet/images/manual-sync.jpg b/packages/web-digital-pet/images/manual-sync.jpg new file mode 100644 index 0000000..4cee917 Binary files /dev/null and b/packages/web-digital-pet/images/manual-sync.jpg differ diff --git a/packages/web-digital-pet/images/options.jpg b/packages/web-digital-pet/images/options.jpg new file mode 100644 index 0000000..df2ac7d Binary files /dev/null and b/packages/web-digital-pet/images/options.jpg differ diff --git a/packages/web-digital-pet/images/partner-desktop.jpg b/packages/web-digital-pet/images/partner-desktop.jpg new file mode 100644 index 0000000..05c8681 Binary files /dev/null and b/packages/web-digital-pet/images/partner-desktop.jpg differ diff --git a/packages/web-digital-pet/images/partner-region.jpg b/packages/web-digital-pet/images/partner-region.jpg new file mode 100644 index 0000000..224084f Binary files /dev/null and b/packages/web-digital-pet/images/partner-region.jpg differ diff --git a/packages/web-digital-pet/images/regions-mobile.jpg b/packages/web-digital-pet/images/regions-mobile.jpg new file mode 100644 index 0000000..a89b715 Binary files /dev/null and b/packages/web-digital-pet/images/regions-mobile.jpg differ diff --git a/packages/web-digital-pet/images/regions.jpg b/packages/web-digital-pet/images/regions.jpg new file mode 100644 index 0000000..67e3810 Binary files /dev/null and b/packages/web-digital-pet/images/regions.jpg differ diff --git a/packages/web-digital-pet/package.json b/packages/web-digital-pet/package.json new file mode 100644 index 0000000..f2aa729 --- /dev/null +++ b/packages/web-digital-pet/package.json @@ -0,0 +1,22 @@ +{ + "name": "@jcendal/web-digital-pet", + "version": "0.1.0-dev.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24.11.0" + }, + "scripts": { + "build": "node esbuild.mjs", + "start": "node dist/server.js", + "check": "tsc --noEmit -p tsconfig.json", + "test": "bun test tests" + }, + "dependencies": { + "@jcendal/digital-pet-animation": "0.2.0-dev.0", + "@jcendal/digital-pet-core": "0.2.0-dev.0", + "@jcendal/digital-pet-webviews": "0.1.0-dev.0", + "peerjs": "^1.5.5", + "@jcendal/digital-pet-fields": "0.1.0-dev.0" + } +} diff --git a/packages/web-digital-pet/src/browser-local.ts b/packages/web-digital-pet/src/browser-local.ts new file mode 100644 index 0000000..2e1b7b8 --- /dev/null +++ b/packages/web-digital-pet/src/browser-local.ts @@ -0,0 +1,96 @@ +import { buildWorldPanelModel } from "@jcendal/digital-pet-webviews/panels/world/world-model.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" +import { MonsterAnimationController } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtwork } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" +import { sceneMotionFor } from "./scene-motion.ts" +import { buildDexPanelModel } from "@jcendal/digital-pet-webviews/panels/dex/dex-model.ts" +import { buildHistoryPanelModel } from "@jcendal/digital-pet-webviews/panels/history/history-model.ts" +import { buildSidebarPresentation } from "@jcendal/digital-pet-webviews/sidebar/sidebar-presenter.ts" + +import { readLocalState } from "./browser-store.ts" +import { experienceMultiplier, type LocalPetState } from "./local-progress.ts" + +const animation = new MonsterAnimationController(MONSTER_FRAME_CATALOG) +let currentPartner = "" + +export const isKnownNode = (id: string): boolean => DIGIMON_CATALOG.byId.has(id) + +const archiveFor = (state: LocalPetState): DigitalPetArchiveResult => ({ + kind: "available", + partners: [ + ...(state.retiredPartners ?? []).map((partner, index) => ({ + ...partner, + generation: index + 1, + events: partner.events.map((event, eventIndex) => ({ eventId: `${index}:${eventIndex}`, ...event })), + })), + { + partnerId: state.partnerId, + generation: (state.retiredPartners?.length ?? 0) + 1, + createdAt: state.createdAt, + retiredAt: null, + events: state.events.map((event, index) => ({ eventId: String(index), ...event })), + }, + ], +}) + +const settingsFor = (state: LocalPetState) => ({ + ...DEFAULT_DIGITAL_PET_SETTINGS, + stageThresholds: Object.fromEntries( + Object.entries(DEFAULT_DIGITAL_PET_SETTINGS.stageThresholds).map(([stage, threshold]) => [ + stage, + threshold * experienceMultiplier(state.experienceLevel), + ]), + ) as typeof DEFAULT_DIGITAL_PET_SETTINGS.stageThresholds, +}) + +export const sidebar = async (requestedWidth: number) => { + const state = await readLocalState() + const snapshot: SidebarSnapshot = { + currentNodeId: state.currentNodeId, + gauge: state.gauge, + isTerminal: state.isTerminal, + frozen: false, + isSetOverride: false, + trainerTotalTokens: 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + const presentation = buildSidebarPresentation(snapshot, settingsFor(state)) + const identity = presentation.partner + const partnerKey = identity ? `${identity.sprite}:${identity.isDigitama}` : "" + if (partnerKey !== currentPartner) { + currentPartner = partnerKey + animation.dispatch({ kind: "partner_changed", partner: identity }) + } + const width = Math.max(16, Math.min(120, Math.floor(requestedWidth) || 40)) + animation.dispatch({ kind: "viewport_resized", width }) + const frame = animation.dispatch({ kind: "tick" }) + return { + model: presentation.payload, + frame: { + type: "animation-frame", + artwork: renderPositionedArtwork(frame, width), + motion: sceneMotionFor(frame, partnerKey, width), + }, + } +} + +export const dex = async () => { + const state = await readLocalState() + const model = buildDexPanelModel(archiveFor(state), DIGIMON_CATALOG, settingsFor(state)) + return { ...model, message: "Discoveries are saved in this browser." } +} + +export const history = async () => { + const state = await readLocalState() + return buildHistoryPanelModel(archiveFor(state), DIGIMON_CATALOG, settingsFor(state)) +} + +export const world = async (regionId: string) => { + const state = await readLocalState() + return buildWorldPanelModel(regionId, archiveFor(state), DIGIMON_CATALOG, settingsFor(state)) +} diff --git a/packages/web-digital-pet/src/browser-options.ts b/packages/web-digital-pet/src/browser-options.ts new file mode 100644 index 0000000..6142b2e --- /dev/null +++ b/packages/web-digital-pet/src/browser-options.ts @@ -0,0 +1,175 @@ +import { readLocalState, setExperienceLevel, startNewPartner } from "./browser-store.ts" +import { ACTIVE_SOURCE_KEY, SOURCE_PREFERENCE_KEY, refreshBrowserViews, resolveSaveSource } from "./browser-source.ts" +import type { ExperienceLevel } from "./local-progress.ts" +import type { BrowserPairingControls } from "./browser-pairing.ts" +import { LANDSCAPE_MOTION_KEY, landscapeMotionEnabled } from "./scene-motion.ts" + +const byId = (id: string): T => document.getElementById(id) as T +const levels: readonly ExperienceLevel[] = ["low", "normal", "high"] +const descriptions: Record = { + low: "A faster journey. One tenth of the original experience.", + normal: "A steady journey. Half the original experience.", + high: "The original pace. Take your time to evolve.", +} + +export const initBrowserOptions = async (): Promise => { + const dialog = byId("options-dialog") + const landscape = byId("landscape-motion") + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)") + const describeLandscape = () => { + const on = landscapeMotionEnabled(localStorage.getItem(LANDSCAPE_MOTION_KEY), reducedMotion.matches) + landscape.setAttribute("aria-pressed", String(on)) + byId("landscape-motion-state").textContent = on ? "ON" : "OFF" + } + landscape.addEventListener("click", () => { + const on = landscape.getAttribute("aria-pressed") !== "true" + localStorage.setItem(LANDSCAPE_MOTION_KEY, on ? "on" : "off") + describeLandscape() + }) + window.addEventListener("storage", (event) => { + if (event.key === LANDSCAPE_MOTION_KEY || event.key === null) describeLandscape() + }) + reducedMotion.addEventListener("change", describeLandscape) + describeLandscape() + const computer = byId("source-computer") + const browser = byId("source-browser") + const fieldset = byId("browser-options") + const slider = byId("experience-level") + const description = byId("experience-description") + const status = byId("options-status") + const confirmation = byId("new-partner-confirm") + let pairing: BrowserPairingControls | undefined + let available: boolean | null = null + let source = resolveSaveSource( + localStorage.getItem(SOURCE_PREFERENCE_KEY), + null, + localStorage.getItem(ACTIVE_SOURCE_KEY), + ) + + byId("options-button").addEventListener("click", () => { + byId("options-status").textContent = "" + dialog.showModal() + dialog.querySelector(".dialog-body")?.scrollTo(0, 0) + }) + try { + const response = await fetch("/api/mode", { cache: "no-store" }) + if (response.ok) { + const mode = (await response.json()) as { mode: string } + available = mode.mode === "sqlite" + } + } catch { + /* The browser save remains available offline. */ + } + source = resolveSaveSource( + localStorage.getItem(SOURCE_PREFERENCE_KEY), + available, + localStorage.getItem(ACTIVE_SOURCE_KEY), + ) + computer.disabled = available !== true + if (available === false && localStorage.getItem(SOURCE_PREFERENCE_KEY) === "sqlite") + localStorage.setItem(SOURCE_PREFERENCE_KEY, "browser") + byId("computer-availability").textContent = + available === true ? "AVAILABLE" : available === false ? "UNAVAILABLE" : "OFFLINE" + + const describe = (level: ExperienceLevel): void => { + description.textContent = descriptions[level] + slider.setAttribute("aria-valuetext", level === "low" ? "Low" : level === "normal" ? "Normal" : "High") + const index = levels.indexOf(level) + byId("experience-amount").textContent = level === "low" ? "10%" : level === "normal" ? "50%" : "100%" + byId("experience-control").style.setProperty("--range-fill", `${index * 50}%`) + for (const button of Array.from(dialog.querySelectorAll("[data-experience-index]"))) + button.setAttribute("aria-pressed", String(Number(button.dataset.experienceIndex) === index)) + } + const refreshSettings = async (): Promise => { + if (source !== "browser") return + const level = (await readLocalState()).experienceLevel ?? "high" + slider.value = String(levels.indexOf(level)) + describe(level) + } + const updateSource = async (): Promise => { + localStorage.setItem(ACTIVE_SOURCE_KEY, source) + computer.checked = source === "sqlite" + browser.checked = source === "browser" + fieldset.disabled = source !== "browser" + byId("browser-options-hint").hidden = source === "browser" + confirmation.hidden = true + pairing?.setBrowserEnabled(source === "browser") + await refreshSettings() + refreshBrowserViews() + } + describe("high") + await updateSource() + window.addEventListener("storage", (event) => { + if (event.key !== SOURCE_PREFERENCE_KEY) return + source = resolveSaveSource(event.newValue, available, localStorage.getItem(ACTIVE_SOURCE_KEY)) + void updateSource() + }) + for (const radio of [computer, browser]) + radio.addEventListener("change", () => { + source = radio.value === "browser" ? "browser" : "sqlite" + localStorage.setItem(SOURCE_PREFERENCE_KEY, source) + void updateSource() + .then(() => { + status.textContent = "" + }) + .catch((error: unknown) => { + status.textContent = String(error) + }) + }) + slider.addEventListener("input", () => describe(levels[Number(slider.value)] ?? "high")) + for (const button of Array.from(dialog.querySelectorAll("[data-experience-index]"))) + button.addEventListener("click", () => { + slider.value = button.dataset.experienceIndex ?? "2" + slider.dispatchEvent(new Event("input")) + slider.dispatchEvent(new Event("change")) + }) + slider.addEventListener("change", async () => { + if (source !== "browser") return + if (pairing?.busy) { + status.textContent = "Finish the current device transfer first." + await refreshSettings() + return + } + try { + await setExperienceLevel(levels[Number(slider.value)] ?? "high") + refreshBrowserViews() + status.textContent = "Experience requirement saved." + } catch (error) { + status.textContent = String(error) + } + }) + byId("new-partner").addEventListener("click", () => { + confirmation.hidden = false + byId("new-partner-cancel").focus() + }) + byId("new-partner-cancel").addEventListener("click", () => { + confirmation.hidden = true + byId("new-partner").focus() + }) + byId("new-partner-accept").addEventListener("click", async () => { + if (source !== "browser") return + if (pairing?.busy) { + status.textContent = "Finish the current device transfer first." + return + } + try { + await startNewPartner() + confirmation.hidden = true + refreshBrowserViews() + status.textContent = "A new egg is ready. Your previous companion is in History." + } catch (error) { + status.textContent = String(error) + } + }) + window.addEventListener("digital-pet:save-updated", () => { + void refreshSettings() + }) + try { + const pairingPath = "/browser-pairing.js" + const module = (await import(pairingPath)) as { initBrowserPairing: () => Promise } + pairing = await module.initBrowserPairing() + pairing.setBrowserEnabled(source === "browser") + } catch (error) { + byId("pair-summary").textContent = `Device pairing unavailable: ${String(error)}` + } +} diff --git a/packages/web-digital-pet/src/browser-pairing.ts b/packages/web-digital-pet/src/browser-pairing.ts new file mode 100644 index 0000000..2e7d5af --- /dev/null +++ b/packages/web-digital-pet/src/browser-pairing.ts @@ -0,0 +1,382 @@ +import type { DataConnection } from "peerjs" + +import { + getDeviceCode, + getPairedDevice, + hasPreviousSave, + readLocalState, + replaceLocalState, + restorePreviousSave, + setPairedDevice, +} from "./browser-store.ts" +import { browserSaveSelected, refreshBrowserViews } from "./browser-source.ts" +import { + BrowserTransfer, + CODE_LENGTH, + displayDeviceCode, + normalizeDeviceCode, + validDeviceCode, +} from "./browser-transfer.ts" +import { parsePetTransfer, TRANSFER_VERSION, type PetTransfer } from "./transfer-protocol.ts" + +type PairMessage = + | { type: "request"; version: 1; sync: boolean } + | { type: "snapshot"; transfer: PetTransfer } + | { type: "result"; accepted: boolean } + | { type: "decline" } + +export type BrowserPairingControls = { + readonly busy: boolean + setBrowserEnabled: (enabled: boolean) => void +} + +const element = (id: string): T => { + const found = document.getElementById(id) + if (!found) throw new Error(`Missing pairing control: ${id}`) + return found as T +} +const remoteCode = (connection: DataConnection): string => connection.peer.replace(/^web-digital-pet-/, "") + +export const initBrowserPairing = async (): Promise => { + const button = element("pair-button") + const sync = element("pair-sync") + const forget = element("pair-forget") + const paired = element("paired-device") + const linked = element("pair-linked") + const summary = element("pair-summary") + const dialog = element("pair-dialog") + const ownCode = element("pair-own-code") + const copy = element("pair-copy") + const input = element("pair-target") + const request = element("pair-request") + const status = element("pair-status") + const incoming = element("pair-incoming") + const incomingCode = element("pair-incoming-code") + const send = element("pair-send") + const decline = element("pair-decline") + const preview = element("pair-preview") + const previewText = element("pair-preview-text") + const importSave = element("pair-import") + const rejectSave = element("pair-reject") + const restore = element("pair-restore") + + const deviceCode = await getDeviceCode() + let pairedCode = await getPairedDevice() + ownCode.textContent = displayDeviceCode(deviceCode) + input.maxLength = CODE_LENGTH + 3 + if (pairedCode) input.value = displayDeviceCode(pairedCode) + restore.hidden = !(await hasPreviousSave()) + + let connection: DataConnection | null = null + let direction: "send" | "receive" | null = null + let pendingTransfer: PetTransfer | null = null + let timer: number | undefined + let ready = false + let enabled = browserSaveSelected() + let finishing = false + let automatic = false + let approved = false + let receiving = false + const ownTitle = document.title + + function note(message: string): void { + status.textContent = message + summary.textContent = message + } + function updateControls(): void { + paired.hidden = pairedCode === null + forget.hidden = pairedCode === null + element("pair-button-label").textContent = pairedCode ? "PAIR ANOTHER DEVICE" : "PAIR DEVICES" + linked.textContent = pairedCode ? displayDeviceCode(pairedCode) : "" + request.disabled = !ready || !enabled || connection !== null + sync.disabled = !ready || !enabled || connection !== null || pairedCode === null + forget.disabled = connection !== null + button.disabled = !enabled + } + function openDialog(): void { + if (!dialog.open) dialog.showModal() + } + function resetConnection(): void { + window.clearTimeout(timer) + const previous = connection + connection = null + previous?.close() + direction = null + pendingTransfer = null + receiving = false + incoming.hidden = true + preview.hidden = true + updateControls() + document.title = ownTitle + } + function finish(message: PairMessage, noteText: string): void { + if (!connection) return + finishing = true + connection.send(message) + incoming.hidden = true + preview.hidden = true + note(noteText) + window.clearTimeout(timer) + timer = window.setTimeout(() => { + resetConnection() + finishing = false + }, 750) + } + function requireBrowser(): void { + if (!enabled || !browserSaveSelected()) + throw new Error("Choose THIS BROWSER on both devices to transfer a companion.") + } + async function remember(remote: DataConnection): Promise { + const code = remoteCode(remote) + if (!validDeviceCode(code)) throw new Error("The other device has an invalid code") + await setPairedDevice(code) + pairedCode = code + updateControls() + } + + const transfer = new BrowserTransfer(deviceCode, { + ready: () => { + ready = true + updateControls() + note("Ready to connect your devices.") + }, + incoming: (remote) => { + if (connection || !enabled || !validDeviceCode(remoteCode(remote))) { + remote.close() + return + } + bind(remote, "send", false) + }, + disconnected: () => { + ready = false + updateControls() + note("Device connection lost. Reconnecting…") + }, + error: (message) => { + ready = transfer.connected + if (connection) resetConnection() + updateControls() + note(`Could not connect: ${message}. Check that the other app is open with THIS BROWSER selected.`) + }, + }) + + function bind(remote: DataConnection, role: "send" | "receive", syncRequest: boolean): void { + connection = remote + direction = role + finishing = false + automatic = syncRequest + approved = false + receiving = false + updateControls() + note(role === "receive" ? "Contacting the other device…" : "Incoming connection…") + timer = window.setTimeout(() => { + if (connection === remote) { + resetConnection() + note("Request timed out. Keep both apps open with THIS BROWSER selected.") + } + }, 120_000) + remote.on("open", () => { + if (connection !== remote) return + if (role === "receive") { + remote.send({ type: "request", version: TRANSFER_VERSION, sync: syncRequest } satisfies PairMessage) + note( + syncRequest + ? "Requesting the latest save from your paired device…" + : "Waiting for the other device to approve pairing…", + ) + } + }) + remote.on("data", (data) => { + void receive(remote, data).catch((error: unknown) => { + if (remote !== connection) return + resetConnection() + note(error instanceof Error ? error.message : "The transfer could not be completed") + }) + }) + remote.on("close", () => { + if (connection === remote) { + resetConnection() + if (!finishing) note("The other device disconnected. Try again with both apps open.") + finishing = false + } + }) + remote.on("error", (error) => { + if (connection === remote) { + resetConnection() + note(`Connection failed: ${error.message}`) + } + }) + } + + async function sendSnapshot(remote: DataConnection): Promise { + requireBrowser() + if (remote !== connection || direction !== "send" || approved) return + approved = true + const state = await readLocalState() + if (remote !== connection) return + requireBrowser() + remote.send({ + type: "snapshot", + transfer: parsePetTransfer({ version: TRANSFER_VERSION, state }), + } satisfies PairMessage) + incoming.hidden = true + note("Save sent. Waiting for the other device to accept it…") + } + + async function importSnapshot(remote: DataConnection, snapshot: PetTransfer): Promise { + requireBrowser() + if (remote !== connection || direction !== "receive" || receiving) return + receiving = true + importSave.disabled = true + try { + await replaceLocalState(snapshot.state) + await remember(remote) + finish( + { type: "result", accepted: true }, + automatic + ? "Synced. Your companion is up to date with the other device." + : "Paired. The other device's companion is now saved here.", + ) + restore.hidden = !(await hasPreviousSave()) + refreshBrowserViews() + } finally { + receiving = false + importSave.disabled = false + } + } + + async function receive(remote: DataConnection, data: unknown): Promise { + if (remote !== connection || finishing || typeof data !== "object" || data === null || !("type" in data)) return + const message = data as { type: string; [key: string]: unknown } + if (direction === "send" && message.type === "request" && message.version === TRANSFER_VERSION && !approved) { + requireBrowser() + pairedCode = await getPairedDevice() + if (remote !== connection) return + if (message.sync === true && pairedCode === remoteCode(remote)) { + automatic = true + await sendSnapshot(remote) + } else { + incomingCode.textContent = displayDeviceCode(remoteCode(remote)) + incoming.hidden = false + document.title = "PAIRING REQUEST · Digital Pet" + note("Another device wants to receive your companion and pair with you.") + openDialog() + } + return + } + if (direction === "receive" && message.type === "snapshot" && !pendingTransfer && !receiving) { + const parsed = parsePetTransfer(message.transfer) + const catalogPath = "/browser-local.js" + const catalog = (await import(catalogPath)) as { isKnownNode: (id: string) => boolean } + const events = [ + ...parsed.state.events, + ...(parsed.state.retiredPartners ?? []).flatMap((partner) => partner.events), + ] + if ( + !catalog.isKnownNode(parsed.state.currentNodeId) || + events.some((event) => !catalog.isKnownNode(event.currentNodeId)) + ) + throw new Error("The received save contains an unknown Digimon") + if (remote !== connection) return + requireBrowser() + pendingTransfer = parsed + if (automatic && pairedCode === remoteCode(remote)) { + await importSnapshot(remote, parsed) + } else { + previewText.textContent = `Companion ${parsed.state.currentNodeId} · ${(parsed.state.retiredPartners?.length ?? 0) + 1} generations. Your current save will be kept as a backup. After pairing, either device can use SYNC to bring the other's save.` + preview.hidden = false + note("Save received. Choose whether to replace your save and remember this device.") + openDialog() + } + return + } + if (direction === "send" && approved && message.type === "result" && typeof message.accepted === "boolean") { + if (message.accepted) await remember(remote) + resetConnection() + note( + message.accepted + ? "Save accepted. Your devices are paired for manual sync." + : "The other device kept its companion.", + ) + return + } + if (message.type === "decline") { + resetConnection() + note("The request was declined.") + } + } + + async function requestFrom(target: string, syncRequest: boolean): Promise { + if (!validDeviceCode(target)) return note("Enter the full 16-character device code.") + if (target === deviceCode) return note("Enter a different device's code.") + if (connection) return note("Finish the current transfer first.") + requireBrowser() + bind(transfer.connect(target), "receive", syncRequest) + } + const showError = (error: unknown): void => + note(error instanceof Error ? error.message : "Could not complete the request") + + button.addEventListener("click", openDialog) + dialog.addEventListener("close", () => { + if (connection && !finishing) finish({ type: "decline" }, "Transfer cancelled.") + }) + copy.addEventListener("click", () => { + void navigator.clipboard.writeText(deviceCode).then( + () => note("Code copied."), + () => note("Could not copy the code."), + ) + }) + request.addEventListener("click", () => { + void requestFrom(normalizeDeviceCode(input.value), false).catch(showError) + }) + sync.addEventListener("click", () => { + if (pairedCode) void requestFrom(pairedCode, true).catch(showError) + }) + forget.addEventListener("click", async () => { + if (connection) return + await setPairedDevice(null) + pairedCode = null + updateControls() + note("Device forgotten. Pair again to use manual sync.") + }) + send.addEventListener("click", () => { + if (!connection) return + send.disabled = true + void sendSnapshot(connection) + .catch(showError) + .finally(() => { + send.disabled = false + }) + }) + decline.addEventListener("click", () => finish({ type: "decline" }, "Pairing declined.")) + importSave.addEventListener("click", () => { + if (connection && pendingTransfer) void importSnapshot(connection, pendingTransfer).catch(showError) + }) + rejectSave.addEventListener("click", () => + finish({ type: "result", accepted: false }, "Your current companion was kept."), + ) + restore.addEventListener("click", async () => { + if (connection) return note("Finish the current transfer first.") + requireBrowser() + if (!window.confirm("Restore the previous save on this device? The current save will become the backup.")) return + if (await restorePreviousSave()) { + refreshBrowserViews() + note("Previous save restored.") + } + }) + window.addEventListener("pagehide", () => transfer.stop()) + updateControls() + return { + get busy() { + return connection !== null + }, + setBrowserEnabled(value) { + enabled = value + if (!value) { + if (connection) finish({ type: "decline" }, "Choose THIS BROWSER to use device pairing.") + if (dialog.open) dialog.close() + } + updateControls() + }, + } +} diff --git a/packages/web-digital-pet/src/browser-scenery.ts b/packages/web-digital-pet/src/browser-scenery.ts new file mode 100644 index 0000000..6e895aa --- /dev/null +++ b/packages/web-digital-pet/src/browser-scenery.ts @@ -0,0 +1,88 @@ +import { + advanceLandscape, + landscapeDirection, + landscapeMotionEnabled, + LANDSCAPE_MOTION_KEY, + type SceneMotion, +} from "./scene-motion.ts" + +export const initPartnerScenery = () => { + const arena = document.querySelector(".arena")! + const scenery = document.createElement("div") + scenery.className = "world-scenery" + scenery.setAttribute("aria-hidden", "true") + const track = document.createElement("div") + track.className = "world-scenery-track" + const tiles = Array.from({ length: 4 }, () => { + const tile = document.createElement("img") + tile.alt = "" + tile.draggable = false + track.append(tile) + return tile + }) + scenery.append(track) + arena.prepend(scenery) + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)") + let preference = localStorage.getItem(LANDSCAPE_MOTION_KEY) + const enabled = () => landscapeMotionEnabled(preference, reducedMotion.matches) + window.addEventListener("storage", (event) => { + if (event.key === LANDSCAPE_MOTION_KEY || event.key === null) + preference = localStorage.getItem(LANDSCAPE_MOTION_KEY) + }) + let previous: SceneMotion | undefined + let direction: -1 | 0 | 1 = 0 + let offset = 0 + let width = arena.clientWidth + let requested: number | undefined + let lastTime = 0 + let lastMotionAt = 0 + let locationId = "" + + const paint = () => { + track.style.transform = `translateX(${offset}px)` + } + new ResizeObserver(() => { + const nextWidth = arena.clientWidth + if (width > 0 && nextWidth > 0) offset *= nextWidth / width + width = nextWidth + paint() + }).observe(arena) + + const visible = () => !document.hidden && window.frameElement?.getAttribute("aria-hidden") !== "true" + const animate = (now: number) => { + requested = undefined + if (!enabled() || !visible() || !direction || now - lastMotionAt > 1300) return + offset = advanceLandscape(offset, direction, now - lastTime, width) + lastTime = now + paint() + requested = requestAnimationFrame(animate) + } + const setLocation = (next: string) => { + if (locationId === next) return + locationId = next + offset = 0 + previous = undefined + direction = 0 + for (const tile of tiles) tile.src = `/regions/${encodeURIComponent(next)}/scene.svg` + paint() + } + setLocation(arena.dataset.locationId ?? "dragon-eye-lake") + + return { + setLocation, + update(motion: SceneMotion | undefined) { + if (!motion) { + previous = undefined + direction = 0 + return + } + direction = landscapeDirection(previous, motion) + previous = motion + lastMotionAt = performance.now() + if (direction && requested === undefined && enabled() && visible()) { + lastTime = lastMotionAt + requested = requestAnimationFrame(animate) + } + }, + } +} diff --git a/packages/web-digital-pet/src/browser-source.ts b/packages/web-digital-pet/src/browser-source.ts new file mode 100644 index 0000000..d78d331 --- /dev/null +++ b/packages/web-digital-pet/src/browser-source.ts @@ -0,0 +1,26 @@ +export const SOURCE_PREFERENCE_KEY = "digital-pet:preferred-source" +export const ACTIVE_SOURCE_KEY = "digital-pet:source" +export type SaveSource = "browser" | "sqlite" + +export const resolveSaveSource = ( + preferred: string | null, + computerAvailable: boolean | null, + previous: string | null, +): SaveSource => { + if (preferred === "browser") return "browser" + if (computerAvailable !== null) return computerAvailable ? "sqlite" : "browser" + if (preferred === "sqlite") return "sqlite" + return previous === "sqlite" ? "sqlite" : "browser" +} + +export const isBrowserSaveSelected = (preferred: string | null, active: string | null): boolean => + preferred === "browser" || (preferred !== "sqlite" && active === "browser") + +export const browserSaveSelected = (): boolean => + isBrowserSaveSelected(localStorage.getItem(SOURCE_PREFERENCE_KEY), localStorage.getItem(ACTIVE_SOURCE_KEY)) + +export const refreshBrowserViews = (): void => { + for (const frame of Array.from(document.querySelectorAll(".web-view"))) + frame.contentWindow?.postMessage({ type: "browser-save-updated" }, location.origin) + window.dispatchEvent(new Event("digital-pet:save-updated")) +} diff --git a/packages/web-digital-pet/src/browser-store.ts b/packages/web-digital-pet/src/browser-store.ts new file mode 100644 index 0000000..f9a4451 --- /dev/null +++ b/packages/web-digital-pet/src/browser-store.ts @@ -0,0 +1,183 @@ +import { isWorldVisit } from "@jcendal/digital-pet-fields/application/world.ts" +import type { WorldVisit } from "@jcendal/digital-pet-fields/domain/world.ts" +import { advanceLocalPet, beginNewPartner, type ExperienceLevel, type LocalPetState } from "./local-progress.ts" + +const DB_NAME = "web-digital-pet" +const STORE_NAME = "pet" +const STATE_KEY = "current" +const DEVICE_KEY = "device-code" +const BACKUP_KEY = "previous-save" +const PAIRED_KEY = "paired-device" +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + +const openDatabase = (): Promise => + new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1) + request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME) + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + request.onblocked = () => reject(new Error("Browser pet storage is blocked")) + }) + +const completed = (transaction: IDBTransaction): Promise => + new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(transaction.error ?? new Error("Browser pet storage was interrupted")) + }) + +const resultOf = (request: IDBRequest): Promise => + new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + +const initialState = (now: number): LocalPetState => { + const createdAt = new Date(now).toISOString() + return { + partnerId: crypto.randomUUID(), + createdAt, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + lastTickAt: now, + events: [{ currentNodeId: "0-001", createdAt }], + } +} + +export const readLocalState = async (): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + const stored = (await resultOf(store.get(STATE_KEY))) as LocalPetState | undefined + const now = Date.now() + const state = stored ? advanceLocalPet(stored, now) : initialState(now) + if (state !== stored) store.put(state, STATE_KEY) + await done + return state + } finally { + database.close() + } +} + +const newDeviceCode = (): string => { + const random = crypto.getRandomValues(new Uint8Array(16)) + return Array.from(random, (value) => CODE_ALPHABET[value % CODE_ALPHABET.length]).join("") +} + +export const getDeviceCode = async (): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + const stored = (await resultOf(store.get(DEVICE_KEY))) as string | undefined + const code = stored ?? newDeviceCode() + if (!stored) store.put(code, DEVICE_KEY) + await done + return code + } finally { + database.close() + } +} + +export const hasPreviousSave = async (): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readonly") + const previous = await resultOf(transaction.objectStore(STORE_NAME).get(BACKUP_KEY)) + return previous !== undefined + } finally { + database.close() + } +} + +export const replaceLocalState = async (incoming: LocalPetState): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + const previous = (await resultOf(store.get(STATE_KEY))) as LocalPetState | undefined + if (previous) store.put(previous, BACKUP_KEY) + store.put(incoming, STATE_KEY) + await done + } finally { + database.close() + } +} + +export const restorePreviousSave = async (): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + const previous = (await resultOf(store.get(BACKUP_KEY))) as LocalPetState | undefined + if (!previous) { + await done + return false + } + const current = (await resultOf(store.get(STATE_KEY))) as LocalPetState | undefined + store.put(previous, STATE_KEY) + if (current) store.put(current, BACKUP_KEY) + await done + return true + } finally { + database.close() + } +} + +const changeLocalState = async (change: (state: LocalPetState) => LocalPetState, backup = false): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + const stored = (await resultOf(store.get(STATE_KEY))) as LocalPetState | undefined + const state = advanceLocalPet(stored ?? initialState(Date.now()), Date.now()) + if (backup) store.put(state, BACKUP_KEY) + store.put(change(state), STATE_KEY) + await done + } finally { + database.close() + } +} + +export const setExperienceLevel = (level: ExperienceLevel): Promise => + changeLocalState((state) => ({ ...state, experienceLevel: level })) + +export const startNewPartner = (): Promise => + changeLocalState((state) => beginNewPartner(state, crypto.randomUUID(), Date.now()), true) + +export const getPairedDevice = async (): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readonly") + const value = await resultOf(transaction.objectStore(STORE_NAME).get(PAIRED_KEY)) + return typeof value === "string" ? value : null + } finally { + database.close() + } +} + +export const setPairedDevice = async (code: string | null): Promise => { + const database = await openDatabase() + try { + const transaction = database.transaction(STORE_NAME, "readwrite") + const done = completed(transaction) + const store = transaction.objectStore(STORE_NAME) + if (code === null) store.delete(PAIRED_KEY) + else store.put(code, PAIRED_KEY) + await done + } finally { + database.close() + } +} + +export const setWorldVisit = (worldVisit: WorldVisit): Promise => { + if (!isWorldVisit(worldVisit)) throw new Error("Unknown destination") + return changeLocalState((state) => ({ ...state, worldVisit })) +} diff --git a/packages/web-digital-pet/src/browser-transfer.ts b/packages/web-digital-pet/src/browser-transfer.ts new file mode 100644 index 0000000..a8d22b0 --- /dev/null +++ b/packages/web-digital-pet/src/browser-transfer.ts @@ -0,0 +1,47 @@ +import Peer, { type DataConnection } from "peerjs" + +const PEER_PREFIX = "web-digital-pet-" +export const CODE_LENGTH = 16 + +export const normalizeDeviceCode = (value: string): string => value.toUpperCase().replace(/[\s-]/g, "") +export const validDeviceCode = (value: string): boolean => /^[A-HJ-NP-Z2-9]{16}$/.test(value) +export const displayDeviceCode = (value: string): string => value.match(/.{1,4}/g)?.join("-") ?? value + +export type TransferListeners = { + ready: () => void + incoming: (connection: DataConnection) => void + disconnected: () => void + error: (message: string) => void +} + +export class BrowserTransfer { + private readonly peer: Peer + private stopped = false + + constructor(code: string, listeners: TransferListeners) { + this.peer = new Peer(`${PEER_PREFIX}${code}`) + this.peer.on("open", listeners.ready) + this.peer.on("connection", listeners.incoming) + this.peer.on("disconnected", () => { + listeners.disconnected() + window.setTimeout(() => { + if (!this.stopped && this.peer.disconnected) this.peer.reconnect() + }, 2500) + }) + this.peer.on("error", (error) => listeners.error(error.message)) + } + + connect(code: string): DataConnection { + if (!this.peer.open) throw new Error("The pairing service is not connected yet") + return this.peer.connect(`${PEER_PREFIX}${code}`, { serialization: "json", reliable: true }) + } + + get connected(): boolean { + return this.peer.open + } + + stop(): void { + this.stopped = true + this.peer.destroy() + } +} diff --git a/packages/web-digital-pet/src/browser-world.ts b/packages/web-digital-pet/src/browser-world.ts new file mode 100644 index 0000000..0f6544e --- /dev/null +++ b/packages/web-digital-pet/src/browser-world.ts @@ -0,0 +1,158 @@ +import { DEFAULT_WORLD_VISIT, getLocation, getRegion } from "@jcendal/digital-pet-fields/application/world.ts" +import type { WorldVisit } from "@jcendal/digital-pet-fields/domain/world.ts" +import { renderWorldOverview, renderWorldRegion } from "@jcendal/digital-pet-webviews/panels/world/world-render.ts" +import type { WorldPanelModel } from "@jcendal/digital-pet-webviews/panels/world/world-model.ts" +import { ACTIVE_SOURCE_KEY, SOURCE_PREFERENCE_KEY, resolveSaveSource, type SaveSource } from "./browser-source.ts" +import { COMPUTER_WORLD_KEY, worldStoreFor } from "./world-store.ts" + +export const initBrowserWorld = async (): Promise => { + const dialog = document.querySelector("#world-dialog")! + const content = document.querySelector("#world-content")! + const status = document.querySelector("#world-status")! + const options = document.querySelector("#options-dialog")! + const photos = new Set(JSON.parse(dialog.dataset.photos ?? "[]")) + const frames = Array.from(document.querySelectorAll(".web-view")) + let visit: WorldVisit = DEFAULT_WORLD_VISIT + let source: SaveSource = "browser" + let selectedRegion: string | null = null + let revision = 0 + let model: WorldPanelModel = { registeredIds: [], residents: [] } + + const applyVisit = () => { + const place = getLocation(visit.locationId) + const photo = photos.has(place.id) + ? `linear-gradient(#0b171a44, #0b171a55), url('/regions/${place.id}/background.png') center / cover no-repeat` + : `linear-gradient(160deg, ${place.atmosphere[0]}, ${place.atmosphere[1]})` + document.documentElement.style.background = photo + document.querySelector("#world-current")!.textContent = place.name + for (const frame of frames) + frame.contentWindow?.postMessage( + { type: "digital-pet:world-changed", locationId: place.id, name: place.name }, + location.origin, + ) + } + for (const frame of frames) frame.addEventListener("load", applyVisit) + + const loadModel = async (regionId: string, selectedSource: SaveSource): Promise => { + if (selectedSource === "browser") return (await import("/browser-local.js" as string)).world(regionId) + const response = await fetch(`/api/world?region=${encodeURIComponent(regionId)}`, { cache: "no-store" }) + if (!response.ok) throw new Error("Could not load your world. Please try again.") + const data = await response.json() + if (data.mode === "browser") throw new Error("Computer save is unavailable. Choose THIS BROWSER in Options.") + return data + } + const render = () => { + content.innerHTML = selectedRegion + ? renderWorldRegion(getRegion(selectedRegion), model, visit) + : renderWorldOverview(new Set(model.registeredIds), visit) + dialog.querySelector(".dialog-body")!.scrollTop = 0 + } + const refresh = async () => { + const token = ++revision + const preferred = localStorage.getItem(SOURCE_PREFERENCE_KEY) + let available: boolean | null = null + try { + available = (await (await fetch("/api/mode", { cache: "no-store" })).json()).mode === "sqlite" + } catch { + /* Saved browser visits also work offline. */ + } + const selectedSource = resolveSaveSource(preferred, available, localStorage.getItem(ACTIVE_SOURCE_KEY)) + const next = await worldStoreFor(selectedSource).load() + if (token !== revision) return + source = selectedSource + visit = next + applyVisit() + if (dialog.open) { + status.textContent = "Loading habitat guide…" + const nextModel = await loadModel(selectedRegion ?? visit.regionId, source) + if (token !== revision) return + model = nextModel + render() + status.textContent = "" + } + } + const report = (error: unknown) => { + status.textContent = error instanceof Error ? error.message : "Could not load regions." + } + const open = () => { + selectedRegion = null + content.innerHTML = "" + status.textContent = "Loading regions…" + if (!dialog.open) dialog.showModal() + void refresh().catch(report) + } + document.querySelector("#world-button")!.addEventListener("click", open) + window.addEventListener("message", (event) => { + if ( + event.origin === location.origin && + frames.some((frame) => frame.contentWindow === event.source) && + event.data?.type === "digital-pet:world-open" + ) + open() + }) + window.addEventListener("digital-pet:save-updated", () => { + void refresh().catch(report) + }) + window.addEventListener("storage", (event) => { + if (event.key === SOURCE_PREFERENCE_KEY || event.key === COMPUTER_WORLD_KEY) void refresh().catch(report) + }) + content.addEventListener("click", (event) => { + const button = (event.target as HTMLElement).closest("button") + if (!button) return + if (button.dataset.region) { + const regionId = button.dataset.region + selectedRegion = regionId + status.textContent = "Loading habitat guide…" + const token = ++revision + void loadModel(regionId, source) + .then((nextModel) => { + if (token !== revision) return + model = nextModel + render() + status.textContent = "" + }) + .catch(report) + } else if (button.hasAttribute("data-world-back")) { + ++revision + selectedRegion = null + render() + status.textContent = "" + } else if (button.dataset.worldDex) { + dialog.close() + options.close() + window.dispatchEvent( + new CustomEvent("digital-pet:navigate", { + detail: `/dex?selected=${encodeURIComponent(button.dataset.worldDex)}`, + }), + ) + } else if (button.id === "world-travel") { + const place = getLocation(button.dataset.location!) + const next = { regionId: place.regionId, locationId: place.id } + const travelSource = source + button.disabled = true + void worldStoreFor(travelSource) + .save(next) + .then(async () => { + await refresh() + dialog.close() + options.close() + window.dispatchEvent(new CustomEvent("digital-pet:navigate", { detail: "/" })) + }) + .catch((error) => { + button.disabled = false + report(error) + }) + } else if (button.dataset.location) { + const place = getLocation(button.dataset.location) + for (const item of Array.from(content.querySelectorAll(".world-locations button"))) + item.setAttribute("aria-pressed", String(item === button)) + const preview = content.querySelector("#world-preview-image")! + preview.src = `/regions/${place.id}/scene.svg` + preview.alt = `${place.name} pixel landscape` + const travel = content.querySelector("#world-travel")! + travel.dataset.location = place.id + travel.querySelector("span")!.textContent = visit.locationId === place.id ? "YOU ARE HERE" : "TRAVEL HERE" + } + }) + await refresh() +} diff --git a/packages/web-digital-pet/src/database.ts b/packages/web-digital-pet/src/database.ts new file mode 100644 index 0000000..38bfa2e --- /dev/null +++ b/packages/web-digital-pet/src/database.ts @@ -0,0 +1,92 @@ +import { existsSync } from "node:fs" +import { DatabaseSync } from "node:sqlite" + +import { + ACTIVE_PARTNER_SELECT, + ARCHIVE_PARTNER_EVENTS_SELECT, + ARCHIVE_PARTNERS_SELECT, + type PersistedPartnerEventRow, + type PersistedPartnerRow, + type TrainerStateRow, + toPartner, +} from "@jcendal/digital-pet-core/adapters/sqlite/sqlite-digital-pet-schema.ts" +import { resolveHostDatabasePath } from "@jcendal/digital-pet-core/adapters/sqlite/app-data-path.ts" +import type { DigitalPetArchiveResult } from "@jcendal/digital-pet-core/application/models/digital-pet-archive.ts" +import type { SidebarSnapshot } from "@jcendal/digital-pet-core/application/ports/sidebar-snapshot.ts" + +export const databasePath = process.env.DIGITAL_PET_DATABASE_PATH || resolveHostDatabasePath() +export const hasHostDatabase = (): boolean => existsSync(databasePath) + +const readDatabase = (read: (database: DatabaseSync) => T, fallback: T): T => { + if (!existsSync(databasePath)) return fallback + const database = new DatabaseSync(databasePath, { readOnly: true }) + try { + return read(database) + } catch (error) { + if (error instanceof Error && /sqlite|database|no such table/i.test(error.message)) return fallback + throw error + } finally { + database.close() + } +} + +export const readSidebarSnapshot = (): SidebarSnapshot | null => + readDatabase((database) => { + const trainer = database.prepare("SELECT total_tokens FROM trainer_state WHERE trainer_id = 1").get() as + | TrainerStateRow + | undefined + const control = database + .prepare("SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1") + .get() as { frozen: number; cheat_node_id: string | null } | undefined + if (control?.cheat_node_id) { + return { + currentNodeId: control.cheat_node_id, + gauge: 0, + isTerminal: true, + frozen: false, + isSetOverride: true, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: null, + battleOpponentNodeId: null, + } + } + const row = database.prepare(ACTIVE_PARTNER_SELECT).get() as PersistedPartnerRow | undefined + if (row === undefined) return null + const partner = toPartner(row) + return { + currentNodeId: partner.currentNodeId, + gauge: partner.gauge, + isTerminal: partner.isTerminal, + frozen: control?.frozen === 1, + isSetOverride: false, + trainerTotalTokens: trainer?.total_tokens ?? 0, + pendingEvolutionTargetId: partner.pendingEvolutionTargetId, + battleOpponentNodeId: partner.battleOpponentNodeId, + } + }, null) + +export const readArchive = (): DigitalPetArchiveResult => + readDatabase( + (database) => { + const partners = database.prepare(ARCHIVE_PARTNERS_SELECT).all() as PersistedPartnerRow[] + if (partners.length === 0) return { kind: "empty" } + const events = database.prepare(ARCHIVE_PARTNER_EVENTS_SELECT).all() as PersistedPartnerEventRow[] + const eventsByPartner = new Map() + for (const event of events) { + const list = eventsByPartner.get(event.partner_id) ?? [] + list.push({ eventId: event.event_id, currentNodeId: event.current_node_id, createdAt: event.created_at }) + eventsByPartner.set(event.partner_id, list) + } + return { + kind: "available", + partners: partners.map((partner) => ({ + partnerId: partner.partner_id, + generation: partner.generation, + createdAt: partner.created_at, + retiredAt: partner.retired_at, + events: eventsByPartner.get(partner.partner_id) ?? [], + })), + } + }, + { kind: existsSync(databasePath) ? "unavailable" : "empty", message: "Digital Pet archive is unavailable." }, + ) diff --git a/packages/web-digital-pet/src/local-progress.ts b/packages/web-digital-pet/src/local-progress.ts new file mode 100644 index 0000000..c6de571 --- /dev/null +++ b/packages/web-digital-pet/src/local-progress.ts @@ -0,0 +1,117 @@ +import type { WorldVisit } from "@jcendal/digital-pet-fields/domain/world.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { + applyTokenProgress, + resolveEvolutionBattle, + STAGE_GAUGE_THRESHOLDS, + type EvolutionSelector, + type StageThresholds, +} from "@jcendal/digital-pet-core/domain/evolution.ts" + +export const EXPERIENCE_INTERVAL_MS = 5 * 60 * 1000 +const TICKS_PER_STAGE = 24 + +export type ExperienceLevel = "low" | "normal" | "high" +export const isExperienceLevel = (value: unknown): value is ExperienceLevel => + value === "low" || value === "normal" || value === "high" + +export const experienceMultiplier = (level: ExperienceLevel = "high"): number => + level === "low" ? 0.1 : level === "normal" ? 0.5 : 1 + +export const experienceThresholds = (level: ExperienceLevel = "high"): StageThresholds => + Object.freeze( + Object.fromEntries( + Object.entries(STAGE_GAUGE_THRESHOLDS).map(([stage, threshold]) => [ + stage, + threshold * experienceMultiplier(level), + ]), + ), + ) as StageThresholds + +export type LocalArchivedPartner = { + readonly partnerId: string + readonly createdAt: string + readonly retiredAt: string + readonly events: readonly { readonly currentNodeId: string; readonly createdAt: string }[] +} + +export type LocalPetState = { + readonly partnerId: string + readonly createdAt: string + readonly currentNodeId: string + readonly gauge: number + readonly isTerminal: boolean + readonly lastTickAt: number + readonly events: readonly { readonly currentNodeId: string; readonly createdAt: string }[] + readonly worldVisit?: WorldVisit + readonly experienceLevel?: ExperienceLevel + readonly retiredPartners?: readonly LocalArchivedPartner[] +} + +export const advanceLocalPet = ( + initial: LocalPetState, + now: number, + selector: EvolutionSelector = Math.random, +): LocalPetState => { + const ticks = Math.min(1000, Math.max(0, Math.floor((now - initial.lastTickAt) / EXPERIENCE_INTERVAL_MS))) + if (ticks === 0) return initial + + let currentNodeId = initial.currentNodeId + let gauge = initial.gauge + let isTerminal = initial.isTerminal + const events = [...initial.events] + const thresholds = experienceThresholds(initial.experienceLevel) + + for (let index = 0; index < ticks && !isTerminal; index++) { + const current = DIGIMON_CATALOG.byId.get(currentNodeId) + if (!current) throw new Error(`Unknown browser partner: ${currentNodeId}`) + const amount = Math.ceil(STAGE_GAUGE_THRESHOLDS[current.stage] / TICKS_PER_STAGE) + let next = applyTokenProgress( + { current, gauge, isTerminal, pendingEvolutionTargetId: null, battleOpponentNodeId: null }, + amount, + selector, + DIGIMON_CATALOG.byId, + DIGIMON_CATALOG.nodes, + thresholds, + ) + if (next.pendingEvolutionTargetId !== null) next = resolveEvolutionBattle(next, true, DIGIMON_CATALOG.byId) + if (next.current.id !== currentNodeId) { + events.push({ + currentNodeId: next.current.id, + createdAt: new Date(initial.lastTickAt + (index + 1) * EXPERIENCE_INTERVAL_MS).toISOString(), + }) + } + currentNodeId = next.current.id + gauge = next.gauge + isTerminal = next.isTerminal + } + + return { + ...initial, + currentNodeId, + gauge, + isTerminal, + lastTickAt: initial.lastTickAt + ticks * EXPERIENCE_INTERVAL_MS, + events, + } +} + +export const beginNewPartner = (previous: LocalPetState, partnerId: string, now: number): LocalPetState => { + const settled = advanceLocalPet(previous, now) + const createdAt = new Date(now).toISOString() + return { + partnerId, + createdAt, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + lastTickAt: now, + experienceLevel: settled.experienceLevel ?? "high", + ...(settled.worldVisit ? { worldVisit: settled.worldVisit } : {}), + events: [{ currentNodeId: "0-001", createdAt }], + retiredPartners: [ + ...(settled.retiredPartners ?? []), + { partnerId: settled.partnerId, createdAt: settled.createdAt, retiredAt: createdAt, events: settled.events }, + ], + } +} diff --git a/packages/web-digital-pet/src/options-view.ts b/packages/web-digital-pet/src/options-view.ts new file mode 100644 index 0000000..dd91c32 --- /dev/null +++ b/packages/web-digital-pet/src/options-view.ts @@ -0,0 +1,157 @@ +const deviceIcon = /* html */ `` +const eggIcon = /* html */ `` + +export const optionsMarkup = /* html */ ` + +

DIGITAL MONSTER

OPTIONS

+
+

YOUR SAVE

+
+ + +
+

Switching keeps both saves. Choice saved on this device.

+ +
+

YOUR WORLD

Explore a new home with your companion.

Scrolls gently in the direction your companion walks.

+
Browser companion options +

EXPERIENCE TO EVOLVE

100%
+

Set the pace of your companion's journey.

+
+ + +
+
+

+
+
${deviceIcon}

CONNECT DEVICES

Your companion, on another screen.

+ + + +

+
+
${eggIcon}

A NEW JOURNEY

Start fresh. Keep your past companions.

+ +

Your current companion moves to History.

+ +
+
+
+

+
+ +

CONNECT DEVICES

PAIR DEVICES

+
+

Enter the other device's code to bring its companion here. Both apps must be open with THIS BROWSER selected.

+
LOADING…
+
+

Connecting…

+ + + +
+
` + +export const optionsStyles = /* css */ ` + .web-dialog { width: min(94vw, 410px); max-height: min(90dvh, 820px); padding: 0; overflow: hidden; + color: var(--ink); background: var(--lcd); border: 6px double var(--case-edge); + box-shadow: 0 10px 35px #0009; font: 13px 'Digital Pet Pixel', monospace; } + .web-dialog[open] { display: flex; flex-direction: column; } + #options-dialog { height: min(90dvh, 820px); } + .web-dialog::backdrop { background: #071211b8; } + .web-dialog h2 { margin: 0; font-size: 19px; line-height: 1.4; } + .web-dialog h3 { margin: 0; font-size: 13px; line-height: 1.5; } + .web-dialog p { margin: 0; line-height: 1.65; } + .web-dialog small, .web-dialog .section-description, .setting-caption { font-size: 11px; line-height: 1.65; } + .web-dialog button, .web-dialog input { font: inherit; color: var(--ink); background: transparent; border-radius: 0; } + .web-dialog button, .web-dialog input:not([type=radio]):not([type=range]) { border: 2px solid var(--muted); padding: 10px; } + .web-dialog button { cursor: pointer; min-height: 44px; } + .web-dialog button:hover:not(:disabled) { background: var(--ink); color: var(--lcd); border-color: var(--ink); } + .web-dialog button:disabled { cursor: default; opacity: .55; } + .web-dialog button:focus-visible, .web-dialog input:focus-visible { outline: 2px dotted var(--ink); outline-offset: 3px; } + .dialog-head { flex: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; + padding: 12px 16px; border-bottom: 2px solid var(--muted); } + .web-dialog .dialog-eyebrow { margin-bottom: 3px; color: var(--muted); font-size: 9px; letter-spacing: 1px; } + .web-dialog .dialog-close { display: grid; place-items: center; width: 36px; min-height: 36px; padding: 0; border: 2px solid var(--line); font-size: 16px; } + .dialog-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 18px 16px 20px; + overscroll-behavior: contain; scrollbar-color: var(--muted) var(--line); scrollbar-width: thin; } + .dialog-body::-webkit-scrollbar { width: 6px; } + .dialog-body::-webkit-scrollbar-track { background: var(--line); } + .dialog-body::-webkit-scrollbar-thumb { background: var(--muted); border-radius: 0; } + .web-dialog section { border-top: 2px solid var(--line); margin-top: 22px; padding-top: 20px; } + .web-dialog .save-section { border: 0; margin: 0; padding: 0; } + .web-dialog [hidden] { display: none !important; } + .web-dialog fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } + .web-dialog fieldset:disabled .experience-control, .web-dialog fieldset:disabled .option-icon { opacity: .55; } + .web-dialog fieldset:disabled .connection-status { display: none; } + #landscape-motion { margin-top: 12px; } + #landscape-motion[aria-pressed=true] { border-color: var(--ink); } + .save-choices { display: grid; gap: 10px; margin-top: 12px; } + .save-choice { display: block; position: relative; cursor: pointer; } + .save-choice input { appearance: none; -webkit-appearance: none; position: absolute; z-index: 1; + width: 16px; height: 16px; top: 14px; left: 12px; margin: 0; border: 2px solid var(--muted); } + .save-choice input:checked { border-color: var(--lcd); box-shadow: inset 0 0 0 3px var(--ink); background: var(--lcd); } + .save-choice input:checked:focus-visible { outline-color: var(--lcd); } + .save-choice input:disabled { opacity: .5; cursor: default; } + .save-card { display: block; padding: 12px 12px 12px 38px; border: 2px solid var(--line); } + .save-choice input:checked + .save-card { color: var(--lcd); background: var(--ink); border-color: var(--ink); } + .save-choice input:disabled + .save-card { color: var(--muted); border-style: dashed; } + .save-choice:has(input:disabled) { cursor: default; } + .save-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; } + .save-card strong { font-size: 12px; font-weight: 400; } + .web-dialog .save-badge { font-size: 8px; padding: 2px 4px; border: 1px solid currentColor; line-height: 1.4; } + .save-description { display: block; margin-top: 7px; } + .web-dialog .save-note { display: flex; gap: 8px; align-items: start; margin-top: 12px; font-size: 10px; color: var(--muted); } + .note-mark { flex: none; width: 7px; height: 7px; background: var(--muted); margin-top: 5px; } + .source-notice { margin-top: 14px; padding: 12px; background: var(--line); border-left: 4px solid var(--muted); } + .source-notice strong { font-size: 10px; font-weight: 400; } + .source-notice p { margin-top: 6px; font-size: 10px; } + .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .value-badge { flex: none; padding: 4px 6px; font-size: 11px; background: var(--ink); color: var(--lcd); } + .web-dialog .section-description { margin-top: 5px; color: var(--muted); } + .experience-control { margin-top: 12px; --range-fill: 100%; } + .experience-slider { appearance: none; -webkit-appearance: none; display: block; width: 100%; height: 38px; min-width: 0; + padding: 0; margin: 0; cursor: pointer; background: transparent; } + .experience-slider::-webkit-slider-runnable-track { height: 12px; border: 2px solid var(--muted); background: + repeating-linear-gradient(90deg, transparent 0 5px, var(--lcd) 5px 7px), + linear-gradient(90deg, var(--ink) 0 var(--range-fill), var(--line) var(--range-fill) 100%); } + .experience-slider::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 20px; height: 24px; + margin-top: -8px; border: 2px solid var(--ink); background: var(--lcd); box-shadow: inset 0 0 0 3px var(--lcd), inset 0 0 0 7px var(--ink); } + .experience-slider::-moz-range-track { height: 8px; border: 2px solid var(--muted); background: var(--line); } + .experience-slider::-moz-range-progress { height: 8px; background: repeating-linear-gradient(90deg, var(--ink) 0 5px, var(--lcd) 5px 7px); } + .experience-slider::-moz-range-thumb { width: 16px; height: 20px; border: 2px solid var(--ink); border-radius: 0; + background: var(--lcd); box-shadow: inset 0 0 0 3px var(--lcd), inset 0 0 0 7px var(--ink); } + .range-labels { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .web-dialog .range-labels button { font-size: 11px; padding: 5px 2px; border: 2px solid transparent; } + .range-labels button span { display: block; margin-top: 4px; font-size: 9px; } + .web-dialog .range-labels button[aria-pressed=true] { border-color: var(--muted); } + .web-dialog .setting-caption { margin-top: 10px; color: var(--muted); font-size: 10px; } + .action-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; } + .option-icon { width: 32px; height: 32px; flex: none; color: var(--ink); } + .wide-action, .primary-action { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; } + .web-dialog .primary-action { background: var(--ink); border-color: var(--ink); color: var(--lcd); } + .web-dialog .primary-action:hover:not(:disabled) { box-shadow: inset 0 0 0 2px var(--lcd); } + .web-dialog .primary-action:focus-visible { outline-color: var(--muted); } + .devices-section:has(.paired-card[hidden]) #pair-button { background: var(--ink); color: var(--lcd); border-color: var(--ink); } + .paired-card { padding: 12px; border: 2px solid var(--line); margin-bottom: 10px; } + .card-label { display: block; font-size: 9px; color: var(--muted); } + #pair-linked { margin: 6px 0 12px; overflow-wrap: anywhere; font-size: 11px; } + .web-dialog .text-action { width: 100%; min-height: 44px; margin-top: 6px; border: 0; font-size: 9px; color: var(--muted); text-decoration: underline; text-underline-offset: 4px; } + .web-dialog .connection-status { margin-top: 12px; padding: 10px 0 0 14px; border-top: 2px dotted var(--line); position: relative; font-size: 10px; color: var(--muted); } + .connection-status:not(:empty)::before { content: ''; position: absolute; left: 0; top: 16px; width: 6px; height: 6px; background: var(--muted); } + .web-dialog .connection-status:empty, .web-dialog .dialog-feedback:empty { display: none; } + .web-dialog .dialog-feedback { flex: none; padding: 10px 16px; font-size: 10px; border-top: 2px solid var(--muted); background: var(--line); } + .confirm-box { border: 2px solid var(--muted); padding: 12px; margin-top: 12px; } + .confirm-box strong { font-size: 11px; font-weight: 400; } + .confirm-box p { font-size: 10px; margin-top: 8px; } + .dialog-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } + .dialog-actions button { flex: 1 1 auto; width: auto; font-size: 11px; justify-content: center; } + .pair-code-row { display: flex; align-items: center; gap: 6px; } + .pair-code-row input { flex: 1; min-width: 0; width: 0; text-transform: uppercase; font-size: 11px; } + .pair-code-row output { flex: 1; overflow-wrap: anywhere; font-size: 12px; } + #pair-dialog label { display: block; margin: 18px 0 8px; font-size: 11px; } + #pair-restore { margin-top: 20px; font-size: 11px; } + .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } + @media (max-width: 360px) { .dialog-head { padding: 10px 12px; } .dialog-body { padding: 14px 12px 18px; } + .save-card { padding-right: 8px; } .section-heading { gap: 6px; } .web-dialog h3 { font-size: 12px; } } +` diff --git a/packages/web-digital-pet/src/scene-motion.ts b/packages/web-digital-pet/src/scene-motion.ts new file mode 100644 index 0000000..2dca47d --- /dev/null +++ b/packages/web-digital-pet/src/scene-motion.ts @@ -0,0 +1,43 @@ +import type { MonsterAnimationOutput } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" + +export const LANDSCAPE_MOTION_KEY = "digital-pet:landscape-motion" +export const landscapeMotionEnabled = (preference: string | null, reducedMotion: boolean): boolean => + preference === "on" || (preference !== "off" && !reducedMotion) + +export type SceneMotion = { + readonly walking: boolean + readonly offset: number + readonly facing: "left" | "right" + readonly partnerKey: string + readonly columns: number +} + +export const sceneMotionFor = (frame: MonsterAnimationOutput, partnerKey: string, columns: number): SceneMotion => ({ + walking: frame.kind === "walking", + offset: frame.offset, + facing: frame.facing, + partnerKey, + columns, +}) + +export const landscapeDirection = (previous: SceneMotion | undefined, next: SceneMotion): -1 | 0 | 1 => { + if ( + !previous?.walking || + !next.walking || + previous.partnerKey !== next.partnerKey || + previous.columns !== next.columns + ) + return 0 + const delta = next.offset - previous.offset + if (delta < 0 && next.facing === "left") return -1 + if (delta > 0 && next.facing === "right") return 1 + return 0 +} + +/** The reflected landscape repeats every two tiles. Four tiles cover either direction. */ +export const advanceLandscape = (offset: number, direction: -1 | 0 | 1, elapsedMs: number, width: number): number => { + if (width <= 0) return offset + const period = width * 2 + const moved = offset + (direction * 3 * Math.min(100, Math.max(0, elapsedMs))) / 1000 + return ((((moved + width) % period) + period) % period) - width +} diff --git a/packages/web-digital-pet/src/server.ts b/packages/web-digital-pet/src/server.ts new file mode 100644 index 0000000..d236d4f --- /dev/null +++ b/packages/web-digital-pet/src/server.ts @@ -0,0 +1,519 @@ +import { createServer, type ServerResponse } from "node:http" +import { existsSync } from "node:fs" +import { readFile } from "node:fs/promises" +import { randomBytes } from "node:crypto" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { gzipSync } from "node:zlib" + +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { MONSTER_FRAME_CATALOG } from "@jcendal/digital-pet-core/data/monster-frame-catalog.ts" +import { MonsterAnimationController } from "@jcendal/digital-pet-animation/idle/monster-animation.ts" +import { renderPositionedArtwork } from "@jcendal/digital-pet-animation/render/positioned-artwork.ts" +import { buildDexPanelModel } from "@jcendal/digital-pet-webviews/panels/dex/dex-model.ts" +import { buildDexWebviewHtml } from "@jcendal/digital-pet-webviews/panels/dex/dex-render.ts" +import { buildHistoryPanelModel } from "@jcendal/digital-pet-webviews/panels/history/history-model.ts" +import { buildHistoryWebviewHtml } from "@jcendal/digital-pet-webviews/panels/history/history-render.ts" +import { buildSidebarWebviewHtml } from "@jcendal/digital-pet-webviews/sidebar/sidebar-document.ts" +import { buildSidebarPresentation } from "@jcendal/digital-pet-webviews/sidebar/sidebar-presenter.ts" +import { LOCATIONS, REGIONS } from "@jcendal/digital-pet-fields/data/regions.ts" +import { buildWorldPanelModel } from "@jcendal/digital-pet-webviews/panels/world/world-model.ts" +import { worldMarkup, worldStyles } from "@jcendal/digital-pet-webviews/panels/world/world-render.ts" +import { PANEL_THEME } from "@jcendal/digital-pet-webviews/shared/theme.ts" + +import { databasePath, hasHostDatabase, readArchive, readSidebarSnapshot } from "./database.ts" +import { optionsMarkup, optionsStyles } from "./options-view.ts" +import { sceneMotionFor } from "./scene-motion.ts" + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const fontPath = resolve(packageRoot, "..", "digital-pet-webviews", "media", "fonts", "Silkscreen-Regular.ttf") +const fieldsAssets = resolve(packageRoot, "..", "digital-pet-fields", "assets") +const backgroundPath = resolve(fieldsAssets, "backgrounds", "dragon-eye-lake.png") +const availablePhotos = LOCATIONS.filter((place) => + existsSync(resolve(fieldsAssets, "backgrounds", place.backgroundFile)), +).map((place) => place.id) +const browserWorldScript = readFile(resolve(packageRoot, "dist", "browser-world.js")) +const browserSceneryScript = readFile(resolve(packageRoot, "dist", "browser-scenery.js")) +const manifestPath = resolve(packageRoot, "assets", "manifest.webmanifest") +const serviceWorkerPath = resolve(packageRoot, "assets", "service-worker.js") +const browserLocalPath = resolve(packageRoot, "dist", "browser-local.js") +const browserLocalScript = readFile(browserLocalPath) +const browserLocalGzip = browserLocalScript.then((script) => gzipSync(script)) +const browserOptionsPath = resolve(packageRoot, "dist", "browser-options.js") +const browserOptionsScript = readFile(browserOptionsPath) +const browserOptionsGzip = browserOptionsScript.then((script) => gzipSync(script)) +const browserPairingPath = resolve(packageRoot, "dist", "browser-pairing.js") +const browserPairingScript = readFile(browserPairingPath) +const browserPairingGzip = browserPairingScript.then((script) => gzipSync(script)) +const faviconPath = resolve(packageRoot, "assets", "favicon.ico") +const icon16Path = resolve(packageRoot, "assets", "icons", "digital-pet-16.png") +const icon32Path = resolve(packageRoot, "assets", "icons", "digital-pet-32.png") +const icon180Path = resolve(packageRoot, "assets", "icons", "digital-pet-180.png") +const icon192Path = resolve(packageRoot, "assets", "icons", "digital-pet-192.png") +const icon512Path = resolve(packageRoot, "assets", "icons", "digital-pet-512.png") +const maskable192Path = resolve(packageRoot, "assets", "icons", "digital-pet-maskable-192.png") +const maskable512Path = resolve(packageRoot, "assets", "icons", "digital-pet-maskable-512.png") +const animation = new MonsterAnimationController(MONSTER_FRAME_CATALOG) +let currentPartner = "" + +const dexModel = () => buildDexPanelModel(readArchive(), DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) +const historyModel = () => buildHistoryPanelModel(readArchive(), DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + +const sidebarData = (requestedWidth: number) => { + const presentation = buildSidebarPresentation(readSidebarSnapshot()) + const identity = presentation.partner + const partnerKey = identity ? `${identity.sprite}:${identity.isDigitama}` : "" + if (partnerKey !== currentPartner) { + currentPartner = partnerKey + animation.dispatch({ kind: "partner_changed", partner: identity }) + } + const width = Math.max(16, Math.min(120, Math.floor(requestedWidth) || 40)) + animation.dispatch({ kind: "viewport_resized", width }) + const frame = animation.dispatch({ kind: "tick" }) + return { + model: presentation.payload, + frame: { + type: "animation-frame", + artwork: renderPositionedArtwork(frame, width), + motion: sceneMotionFor(frame, partnerKey, width), + }, + } +} + +const webStyles = /* css */ ` + html { width: 100%; height: 100%; min-height: 100dvh; overflow: hidden; display: grid; place-items: center; + background: linear-gradient(#0b171a44, #0b171a55), url('/images/digital-world-lake-background.png') center / cover no-repeat #101b1b; } + body { width: min(100vw, 430px); height: min(100dvh, 880px); min-height: 0; margin: 0; padding: 0; + overflow: hidden; display: flex; flex-direction: column; background: var(--case); box-shadow: 0 20px 65px #000b; } + .web-nav { display: flex; flex: none; gap: 0; min-height: 64px; padding: 0 8px 6px; + background: var(--case); border: 4px solid var(--case-edge); border-top: 0; } + .web-nav a, .web-nav button { flex: 1; display: grid; place-items: center; padding: 8px 3px; color: var(--case-ink); + text-align: center; text-decoration: none; font: inherit; font-size: 12px; border: 0; border-top: 3px solid #342b21; + background: transparent; cursor: pointer; } + .web-nav button[hidden] { display: none; } + .web-nav a[aria-current=page] { color: var(--lcd); border-color: var(--lcd); } + .web-nav a:focus-visible, .web-nav button:focus-visible { outline: 2px solid var(--lcd); } + .device { width: 100%; height: 100%; min-height: 0; max-width: none; margin: 0; flex: 1; overflow: hidden; } + .screen { overflow-x: hidden; overflow-y: auto; scrollbar-color: var(--muted) var(--line); scrollbar-width: thin; } + .workspace { display: flex; flex: none; flex-direction: column; gap: 18px; min-height: 0; } + .catalog, .detail { flex: none; min-height: 0; } + .entries { flex: 0 0 min(28dvh, 250px); height: min(28dvh, 250px); max-height: none; overflow-y: auto; } + .detail { border-left: 0; border-top: 4px solid var(--line); padding: 16px 0 0; } + .detail-body { overflow: visible; } + .toolbar { max-height: none; overflow: visible; } + .dex .toolbar { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .history .toolbar { grid-template-columns: minmax(0, 1fr) auto; } + .toolbar .field, .toolbar select { min-width: 0; max-width: 100%; } + .panel-filters { flex: none; margin-bottom: 12px; } + .partner-device .screen { display: flex; flex-direction: column; overflow: hidden; } + .partner-device .arena { --artwork-pixel-size: 7.5; background: url("/regions/dragon-eye-lake/scene.svg") center / 100% 100% no-repeat; } + .partner-device .arena:not(.with-battle-hud) #artwork { height: 85%; align-self: start; } + .world-scenery { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } + .world-scenery-track { position: absolute; inset: 0 auto 0 -100%; width: 400%; display: flex; } + .world-scenery-track img { width: 25%; height: 100%; flex: none; display: block; image-rendering: pixelated; } + .world-scenery-track img:nth-child(odd) { transform: scaleX(-1); } + .partner-device .arena #artwork { position: relative; } + #world-location { flex: none; padding: 8px 6px; margin: 0 0 8px; text-align: left; font: inherit; font-size: 10px; color: var(--muted); border: 0; border-bottom: 2px dotted var(--line); background: transparent; cursor: pointer; } + #world-location:hover { color: var(--ink); } + #world-location:focus-visible { outline: 2px dotted var(--ink); } + @media (max-width: 390px) { .partner-device .arena { --artwork-pixel-size: 6; } } + .pet-module { flex: 1; display: flex; flex-direction: column; width: 100%; min-height: 0; padding: 0; border: 0; + background: transparent; box-shadow: none; } + .pet-header { flex: none; padding: 8px 6px 12px; } + .pet-module #content { flex: 1; grid-template-rows: 40px minmax(0, 1fr) 76px; min-height: 0; } + .pet-module .arena { height: 100%; min-height: 0; } + .pet-module .identity { padding: 0 6px; } + .pet-module #name { font-size: 18px; } + .pet-module #stage { font-size: 14px; } + .pet-module .progress { height: auto; padding: 12px 6px; } + .pet-module .pet-actions, .partner-spacer { display: none; } + .pet-module #empty { flex: 1; display: grid; place-items: center; text-align: center; } + .pet-module.animating #content { grid-template-rows: 40px minmax(0, 1fr); } + .pet-module.animating .arena { height: 100%; } + @media (max-height: 590px) { .pet-header { padding: 4px 6px 6px; } .footer { padding-top: 5px; } + .web-nav { min-height: 48px; } .pet-module #content { grid-template-rows: 30px minmax(0, 1fr) 65px; } } +` + +const bridgeScript = (page: "sidebar" | "dex" | "history") => /* javascript */ ` + (() => { + const page = ${JSON.stringify(page)}; + const key = 'digital-pet:' + page; + let width = 40; + let busy = false; + let refreshRequested = false; + let scenery; + if (page === 'sidebar') window.addEventListener('DOMContentLoaded', async () => { + scenery = (await import('/browser-scenery.js')).initPartnerScenery(); + }, { once: true }); + const deliver = data => window.dispatchEvent(new MessageEvent('message', { data })); + const navigate = path => { + if (window.parent === window) { location.href = path; return; } + window.parent.postMessage({ type: 'digital-pet:navigate', path }, location.origin); + }; + const refresh = async () => { + if (busy) { refreshRequested = true; return; } + busy = true; + const preferred = localStorage.getItem('digital-pet:preferred-source'); + const deliverCurrent = data => { + if (preferred !== localStorage.getItem('digital-pet:preferred-source')) { refreshRequested = true; return; } + deliver(data); + if (page === 'sidebar' && data.type === 'animation-frame') scenery?.update(data.motion); + }; + try { + const path = page === 'sidebar' ? '/api/sidebar?width=' + width : '/api/' + page; + let data; + try { + if (preferred === 'browser') data = { mode: 'browser' }; + else { + const response = await fetch(path, { cache: 'no-store' }); + if (!response.ok) throw new Error('Could not read Digital Pet data'); + data = await response.json(); + } + } catch (error) { + if (preferred === 'sqlite' || localStorage.getItem('digital-pet:source') !== 'browser') throw error; + data = { mode: 'browser' }; + } + if (preferred !== localStorage.getItem('digital-pet:preferred-source')) { refreshRequested = true; return; } + if (data.mode === 'browser') { + localStorage.setItem('digital-pet:source', 'browser'); + const local = await import('/browser-local.js'); + if (page === 'sidebar') { + const snapshot = await local.sidebar(width); + deliverCurrent(snapshot.model); deliverCurrent(snapshot.frame); + } else { + deliverCurrent({ type: page + '-model', model: await local[page]() }); + } + } else { + localStorage.setItem('digital-pet:source', 'sqlite'); + if (page === 'sidebar') { deliverCurrent(data.model); deliverCurrent(data.frame); } + else { deliverCurrent({ type: page + '-model', model: data }); } + } + } catch (error) { + const notice = document.getElementById('notice') || document.getElementById('empty'); + if (notice) { notice.hidden = false; notice.textContent = String(error); } + } finally { + busy = false; + if (refreshRequested) { refreshRequested = false; queueMicrotask(refresh); } + } + }; + window.digitalPetBridge = { + getState: () => { try { return JSON.parse(sessionStorage.getItem(key) || 'null'); } catch { return null; } }, + setState: state => sessionStorage.setItem(key, JSON.stringify(state)), + postMessage: message => { + if (message.type === 'artwork-width') { width = message.width; return; } + if (message.type === 'open-panel') { navigate('/' + message.panel); return; } + if (message.type === 'history-dex') { navigate('/dex?selected=' + encodeURIComponent(message.id)); return; } + if (message.type === 'dex-reference') { window.open('/api/reference?id=' + encodeURIComponent(message.id) + '&source=' + localStorage.getItem('digital-pet:source'), '_blank', 'noopener'); return; } + if (message.type.endsWith('-ready')) { + refresh(); + setInterval(refresh, page === 'sidebar' ? 800 : 5000); + if (page === 'dex') { + const selected = new URLSearchParams(location.search).get('selected'); + if (selected) setTimeout(() => deliver({ type: 'dex-select', id: selected }), 0); + } + return; + } + if (message.type.endsWith('-refresh')) refresh(); + } + }; + window.addEventListener('storage', event => { + if (event.key === 'digital-pet:preferred-source') refresh(); + }); + if (page === 'sidebar') { + document.addEventListener('click', event => { + if (event.target.closest('#world-location')) window.parent.postMessage({ type: 'digital-pet:world-open' }, location.origin); + }); + window.addEventListener('message', event => { + if (event.source !== window.parent || event.origin !== location.origin || event.data?.type !== 'digital-pet:world-changed') return; + if (!${JSON.stringify(LOCATIONS.map((place) => place.id))}.includes(event.data.locationId)) return; + document.querySelector('.arena').style.backgroundImage = "url('/regions/" + event.data.locationId + "/scene.svg')"; + document.querySelector('.arena').dataset.locationId = event.data.locationId; + scenery?.setLocation(event.data.locationId); + document.getElementById('world-location').textContent = event.data.name + ' →'; + }); + } + window.addEventListener('message', event => { + if (event.source === window.parent && event.origin === location.origin && event.data?.type === 'browser-save-updated') refresh(); + }); + })(); +` + +const renderPage = (page: "sidebar" | "dex" | "history"): string => { + const nonce = randomBytes(16).toString("hex") + const resources = { nonce, fontUri: "/fonts/Silkscreen-Regular.ttf", cspSource: "'self'", webShell: true } + const html = + page === "sidebar" + ? buildSidebarWebviewHtml(nonce, resources) + : page === "dex" + ? buildDexWebviewHtml(dexModel(), resources) + : buildHistoryWebviewHtml(historyModel(), resources) + const withWorld = + page === "sidebar" + ? html.replace( + '
', + '
', + ) + : html + return withWorld + .replace('", + `${webStyles}html { display: block; min-height: 0; background: transparent; } body { width: 100%; height: 100dvh; box-shadow: none; }`, + ) + .replace("", ``) +} + +const renderShell = (page: "sidebar" | "dex" | "history"): string => { + const nonce = randomBytes(16).toString("hex") + const views = (["sidebar", "dex", "history"] as const) + .map( + (view) => + ``, + ) + .join("") + const links = (["sidebar", "dex", "history"] as const) + .map( + (view) => + `${view === "sidebar" ? "PARTNER" : view.toUpperCase()}`, + ) + .join("") + const script = /* javascript */ ` + (() => { + const frames = Array.from(document.querySelectorAll('.web-view')); + const links = Array.from(document.querySelectorAll('.web-nav a')); + const pageFromPath = path => path === '/dex' ? 'dex' : path === '/history' ? 'history' : 'sidebar'; + const selectRecord = path => { + const id = new URL(path, location.origin).searchParams.get('selected'); + const frame = frames.find(node => node.dataset.page === 'dex'); + if (!id || !frame) return; + const deliver = () => frame.contentWindow?.postMessage({ type: 'dex-select', id }, location.origin); + if (frame.contentDocument?.readyState === 'complete') deliver(); + else frame.addEventListener('load', deliver, { once: true }); + }; + const show = path => { + const page = pageFromPath(new URL(path, location.origin).pathname); + for (const frame of frames) { + const active = frame.dataset.page === page; + frame.classList.toggle('active', active); + if (active) frame.removeAttribute('aria-hidden'); + else frame.setAttribute('aria-hidden', 'true'); + } + for (const link of links) { + if (link.dataset.page === page) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } + if (page === 'dex') selectRecord(path); + }; + const navigate = path => { history.pushState(null, '', path); show(path); }; + for (const link of links) link.addEventListener('click', event => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); navigate(link.getAttribute('href')); + }); + window.addEventListener('message', event => { + if (event.origin !== location.origin || event.data?.type !== 'digital-pet:navigate') return; + if (!frames.some(frame => frame.contentWindow === event.source)) return; + navigate(event.data.path); + }); + window.addEventListener('digital-pet:navigate', event => { + const path = event.detail; + if (typeof path === 'string' && /^\\/(?:dex(?:\\?selected=[0-7]-[0-9]{3})?|history)?$/.test(path)) navigate(path); + }); + import('/browser-world.js').then(module => module.initBrowserWorld()).catch(() => {}); + window.addEventListener('popstate' , () => show(location.pathname + location.search)); + show(location.pathname + location.search); + import('/browser-options.js').then(module => module.initBrowserOptions()).catch(error => { + const dialog = document.getElementById('options-dialog'); + document.getElementById('options-status').textContent = String(error); + document.getElementById('options-button').addEventListener('click', () => { if (!dialog.open) dialog.showModal(); }); + }); + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => navigator.serviceWorker.register('/service-worker.js').catch(() => {})); + } + })(); + ` + return `Digital Pet
${views}
${optionsMarkup}${worldMarkup(availablePhotos)}` +} + +const send = (response: ServerResponse, status: number, contentType: string, body: string | Buffer): void => { + response.writeHead(status, { + "Content-Type": contentType, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + response.end(body) +} + +const port = Number(process.env.PORT ?? 4173) +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT must be between 1 and 65535") +const server = createServer(async (request, response) => { + try { + if (request.method !== "GET" || !request.url) + return send(response, 405, "text/plain; charset=utf-8", "Method not allowed") + const host = request.headers.host ?? "" + if (!/^(localhost|127\.0\.0\.1)(:\d+)?$/.test(host)) + return send(response, 403, "text/plain; charset=utf-8", "Forbidden") + const url = new URL(request.url, `http://${host}`) + if (url.pathname === "/fonts/Silkscreen-Regular.ttf") { + return send(response, 200, "font/ttf", await readFile(fontPath)) + } + if (url.pathname === "/images/digital-world-lake-background.png") { + return send(response, 200, "image/png", await readFile(backgroundPath)) + } + if (url.pathname === "/browser-world.js") + return send(response, 200, "text/javascript; charset=utf-8", await browserWorldScript) + if (url.pathname === "/browser-scenery.js") + return send(response, 200, "text/javascript; charset=utf-8", await browserSceneryScript) + const worldAsset = /^\/regions\/([a-z-]+)\/(scene\.svg|background\.png)$/.exec(url.pathname) + if (worldAsset) { + const place = LOCATIONS.find((candidate) => candidate.id === worldAsset[1]) + if (!place) return send(response, 404, "text/plain; charset=utf-8", "Unknown location") + if (worldAsset[2] === "scene.svg") + return send( + response, + 200, + "image/svg+xml", + await readFile(resolve(fieldsAssets, "scenes", `${place.scene}.svg`)), + ) + if (!availablePhotos.includes(place.id)) + return send(response, 404, "text/plain; charset=utf-8", "Background coming soon") + return send( + response, + 200, + "image/png", + await readFile(resolve(fieldsAssets, "backgrounds", place.backgroundFile)), + ) + } + if (url.pathname === "/manifest.webmanifest") { + return send(response, 200, "application/manifest+json; charset=utf-8", await readFile(manifestPath)) + } + if (url.pathname === "/service-worker.js") { + return send(response, 200, "text/javascript; charset=utf-8", await readFile(serviceWorkerPath)) + } + if (url.pathname === "/browser-local.js") { + if (request.headers["accept-encoding"]?.includes("gzip")) { + response.writeHead(200, { + "Content-Type": "text/javascript; charset=utf-8", + "Content-Encoding": "gzip", + Vary: "Accept-Encoding", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + return response.end(await browserLocalGzip) + } + return send(response, 200, "text/javascript; charset=utf-8", await browserLocalScript) + } + if (url.pathname === "/browser-options.js") { + if (request.headers["accept-encoding"]?.includes("gzip")) { + response.writeHead(200, { + "Content-Type": "text/javascript; charset=utf-8", + "Content-Encoding": "gzip", + Vary: "Accept-Encoding", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + return response.end(await browserOptionsGzip) + } + return send(response, 200, "text/javascript; charset=utf-8", await browserOptionsScript) + } + if (url.pathname === "/browser-pairing.js") { + if (request.headers["accept-encoding"]?.includes("gzip")) { + response.writeHead(200, { + "Content-Type": "text/javascript; charset=utf-8", + "Content-Encoding": "gzip", + Vary: "Accept-Encoding", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + return response.end(await browserPairingGzip) + } + return send(response, 200, "text/javascript; charset=utf-8", await browserPairingScript) + } + if (url.pathname === "/favicon.ico") { + return send(response, 200, "image/x-icon", await readFile(faviconPath)) + } + if (url.pathname === "/icons/digital-pet-16.png") { + return send(response, 200, "image/png", await readFile(icon16Path)) + } + if (url.pathname === "/icons/digital-pet-32.png") { + return send(response, 200, "image/png", await readFile(icon32Path)) + } + if (url.pathname === "/icons/digital-pet-180.png") { + return send(response, 200, "image/png", await readFile(icon180Path)) + } + if (url.pathname === "/icons/digital-pet-192.png") { + return send(response, 200, "image/png", await readFile(icon192Path)) + } + if (url.pathname === "/icons/digital-pet-512.png") { + return send(response, 200, "image/png", await readFile(icon512Path)) + } + if (url.pathname === "/icons/digital-pet-maskable-192.png") { + return send(response, 200, "image/png", await readFile(maskable192Path)) + } + if (url.pathname === "/icons/digital-pet-maskable-512.png") { + return send(response, 200, "image/png", await readFile(maskable512Path)) + } + if (url.pathname === "/api/mode") { + return send( + response, + 200, + "application/json; charset=utf-8", + JSON.stringify({ mode: hasHostDatabase() ? "sqlite" : "browser" }), + ) + } + if (url.pathname === "/api/world") { + const regionId = url.searchParams.get("region") ?? "digital-ocean" + if (!REGIONS.some((region) => region.id === regionId)) + return send(response, 400, "text/plain; charset=utf-8", "Unknown region") + if (!hasHostDatabase()) return send(response, 200, "application/json; charset=utf-8", '{"mode":"browser"}') + return send( + response, + 200, + "application/json; charset=utf-8", + JSON.stringify(buildWorldPanelModel(regionId, readArchive(), DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS)), + ) + } + if (url.pathname === "/api/sidebar") { + if (!hasHostDatabase()) return send(response, 200, "application/json; charset=utf-8", '{"mode":"browser"}') + const width = Number(url.searchParams.get("width") ?? 40) + return send(response, 200, "application/json; charset=utf-8", JSON.stringify(sidebarData(width))) + } + if (url.pathname === "/api/dex") { + if (!hasHostDatabase()) return send(response, 200, "application/json; charset=utf-8", '{"mode":"browser"}') + return send(response, 200, "application/json; charset=utf-8", JSON.stringify(dexModel())) + } + if (url.pathname === "/api/history") { + if (!hasHostDatabase()) return send(response, 200, "application/json; charset=utf-8", '{"mode":"browser"}') + return send(response, 200, "application/json; charset=utf-8", JSON.stringify(historyModel())) + } + if (url.pathname === "/api/reference") { + const id = url.searchParams.get("id") + const entry = + hasHostDatabase() && url.searchParams.get("source") !== "browser" + ? dexModel().entries.find((candidate) => candidate.id === id && candidate.discovered) + : DIGIMON_CATALOG.byId.get(id ?? "") + if (entry?.url.startsWith("https://digimon.net/")) { + response.writeHead(302, { Location: entry.url, "Cache-Control": "no-store" }) + return response.end() + } + return send(response, 404, "text/plain; charset=utf-8", "Record unavailable") + } + if (url.pathname === "/view/sidebar" || url.pathname === "/view/dex" || url.pathname === "/view/history") { + const page = url.pathname.slice(6) as "sidebar" | "dex" | "history" + return send(response, 200, "text/html; charset=utf-8", renderPage(page)) + } + if (url.pathname === "/" || url.pathname === "/dex" || url.pathname === "/history") { + const page = url.pathname === "/" ? "sidebar" : (url.pathname.slice(1) as "dex" | "history") + return send(response, 200, "text/html; charset=utf-8", renderShell(page)) + } + return send(response, 404, "text/plain; charset=utf-8", "Not found") + } catch (error) { + console.error(error) + return send(response, 500, "text/plain; charset=utf-8", "Digital Pet could not load") + } +}) + +server.listen(port, "127.0.0.1", () => { + console.log(`Digital Pet Web: http://localhost:${port} (database: ${databasePath})`) +}) diff --git a/packages/web-digital-pet/src/transfer-protocol.ts b/packages/web-digital-pet/src/transfer-protocol.ts new file mode 100644 index 0000000..807faf9 --- /dev/null +++ b/packages/web-digital-pet/src/transfer-protocol.ts @@ -0,0 +1,113 @@ +import { isWorldVisit } from "@jcendal/digital-pet-fields/application/world.ts" +import type { ExperienceLevel, LocalArchivedPartner, LocalPetState } from "./local-progress.ts" + +export const TRANSFER_VERSION = 1 +export const MAX_TRANSFER_BYTES = 64 * 1024 + +export type PetTransfer = { readonly version: 1; readonly state: LocalPetState } + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const validDate = (value: unknown): value is string => + typeof value === "string" && value.length <= 32 && Number.isFinite(Date.parse(value)) + +const validNodeId = (value: unknown): value is string => typeof value === "string" && /^[0-7]-\d{3}$/.test(value) + +const parseEvents = (value: unknown): LocalPetState["events"] => { + if (!Array.isArray(value) || value.length < 1 || value.length > 256) + throw new Error("The received save contains an invalid history") + let previousTime = 0 + return value.map((event) => { + if (!isRecord(event) || !validNodeId(event.currentNodeId) || !validDate(event.createdAt)) + throw new Error("The received save contains an invalid history") + const timestamp = Date.parse(event.createdAt) + if (timestamp < previousTime) throw new Error("The received save has an unordered history") + previousTime = timestamp + return { currentNodeId: event.currentNodeId, createdAt: event.createdAt } + }) +} + +export const parsePetTransfer = (input: unknown): PetTransfer => { + if (!isRecord(input) || input.version !== TRANSFER_VERSION || !isRecord(input.state)) + throw new Error("Unsupported save format") + if (new TextEncoder().encode(JSON.stringify(input)).byteLength > MAX_TRANSFER_BYTES) + throw new Error("The received save is too large") + + const state = input.state + if ( + typeof state.partnerId !== "string" || + state.partnerId.length < 1 || + state.partnerId.length > 100 || + !validDate(state.createdAt) || + !validNodeId(state.currentNodeId) || + typeof state.gauge !== "number" || + !Number.isSafeInteger(state.gauge) || + state.gauge < 0 || + state.gauge > 300_000_000 || + typeof state.isTerminal !== "boolean" || + typeof state.lastTickAt !== "number" || + !Number.isSafeInteger(state.lastTickAt) || + state.lastTickAt < 0 || + state.lastTickAt > Date.now() + 24 * 60 * 60 * 1000 || + !Array.isArray(state.events) || + state.events.length < 1 || + state.events.length > 256 + ) + throw new Error("The received save contains invalid partner data") + + const events = parseEvents(state.events) + if (events.at(-1)?.currentNodeId !== state.currentNodeId) + throw new Error("The received save does not match its history") + + if ( + state.experienceLevel !== undefined && + state.experienceLevel !== "low" && + state.experienceLevel !== "normal" && + state.experienceLevel !== "high" + ) + throw new Error("The received save has an invalid experience setting") + if (state.worldVisit !== undefined && !isWorldVisit(state.worldVisit)) + throw new Error("The received save has an invalid destination") + let retiredPartners: LocalArchivedPartner[] | undefined + if (state.retiredPartners !== undefined) { + if (!Array.isArray(state.retiredPartners) || state.retiredPartners.length > 128) + throw new Error("The received save has an invalid archive") + retiredPartners = state.retiredPartners.map((partner) => { + if ( + !isRecord(partner) || + typeof partner.partnerId !== "string" || + partner.partnerId.length < 1 || + partner.partnerId.length > 100 || + !validDate(partner.createdAt) || + !validDate(partner.retiredAt) || + Date.parse(partner.retiredAt) < Date.parse(partner.createdAt) + ) + throw new Error("The received save has an invalid archived partner") + return { + partnerId: partner.partnerId, + createdAt: partner.createdAt, + retiredAt: partner.retiredAt, + events: parseEvents(partner.events), + } + }) + } + + return { + version: TRANSFER_VERSION, + state: { + partnerId: state.partnerId as string, + createdAt: state.createdAt as string, + currentNodeId: state.currentNodeId as string, + gauge: state.gauge as number, + isTerminal: state.isTerminal as boolean, + lastTickAt: state.lastTickAt as number, + events, + ...(isWorldVisit(state.worldVisit) + ? { worldVisit: { regionId: state.worldVisit.regionId, locationId: state.worldVisit.locationId } } + : {}), + ...(state.experienceLevel !== undefined ? { experienceLevel: state.experienceLevel as ExperienceLevel } : {}), + ...(retiredPartners !== undefined ? { retiredPartners } : {}), + }, + } +} diff --git a/packages/web-digital-pet/src/world-store.ts b/packages/web-digital-pet/src/world-store.ts new file mode 100644 index 0000000..96242e2 --- /dev/null +++ b/packages/web-digital-pet/src/world-store.ts @@ -0,0 +1,23 @@ +import { resolveWorldVisit, isWorldVisit } from "@jcendal/digital-pet-fields/application/world.ts" +import type { WorldVisit, WorldVisitStore } from "@jcendal/digital-pet-fields/domain/world.ts" +import { readLocalState, setWorldVisit } from "./browser-store.ts" +import type { SaveSource } from "./browser-source.ts" + +export const COMPUTER_WORLD_KEY = "digital-pet:computer-world" + +/** Computer travel is a web preference. Browser travel belongs to the transferable save. */ +export const worldStoreFor = (source: SaveSource): WorldVisitStore => ({ + async load() { + if (source === "browser") return resolveWorldVisit((await readLocalState()).worldVisit) + try { + return resolveWorldVisit(JSON.parse(localStorage.getItem(COMPUTER_WORLD_KEY) ?? "null")) + } catch { + return resolveWorldVisit(null) + } + }, + async save(visit: WorldVisit) { + if (!isWorldVisit(visit)) throw new Error("Unknown destination") + if (source === "browser") await setWorldVisit(visit) + else localStorage.setItem(COMPUTER_WORLD_KEY, JSON.stringify(resolveWorldVisit(visit))) + }, +}) diff --git a/packages/web-digital-pet/tests/browser-source.test.ts b/packages/web-digital-pet/tests/browser-source.test.ts new file mode 100644 index 0000000..cd18c88 --- /dev/null +++ b/packages/web-digital-pet/tests/browser-source.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test" + +import { isBrowserSaveSelected, resolveSaveSource } from "../src/browser-source.ts" + +describe("web save selection", () => { + it("respects the chosen save even when an older tab writes a stale active source", () => { + expect(isBrowserSaveSelected("browser", "sqlite")).toBe(true) + expect(isBrowserSaveSelected("sqlite", "browser")).toBe(false) + expect(isBrowserSaveSelected(null, "browser")).toBe(true) + expect(isBrowserSaveSelected(null, "sqlite")).toBe(false) + }) + it("defaults to the computer when available and allows a separate browser save", () => { + expect(resolveSaveSource(null, true, null)).toBe("sqlite") + expect(resolveSaveSource("browser", true, "sqlite")).toBe("browser") + expect(resolveSaveSource("sqlite", true, "browser")).toBe("sqlite") + }) + + it("uses the browser when no computer save exists and preserves explicit choices offline", () => { + expect(resolveSaveSource("sqlite", false, "sqlite")).toBe("browser") + expect(resolveSaveSource("browser", null, "sqlite")).toBe("browser") + expect(resolveSaveSource("sqlite", null, "browser")).toBe("sqlite") + expect(resolveSaveSource(null, null, "browser")).toBe("browser") + }) +}) diff --git a/packages/web-digital-pet/tests/local-progress.test.ts b/packages/web-digital-pet/tests/local-progress.test.ts new file mode 100644 index 0000000..b582060 --- /dev/null +++ b/packages/web-digital-pet/tests/local-progress.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test" + +import { + advanceLocalPet, + beginNewPartner, + experienceThresholds, + EXPERIENCE_INTERVAL_MS, + type LocalPetState, +} from "../src/local-progress.ts" + +const initial: LocalPetState = { + partnerId: "local-partner", + createdAt: "2026-10-07T00:00:00.000Z", + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + lastTickAt: Date.parse("2026-10-07T00:00:00.000Z"), + events: [{ currentNodeId: "0-001", createdAt: "2026-10-07T00:00:00.000Z" }], +} + +describe("browser pet progression", () => { + it("adds experience once per complete five-minute interval", () => { + expect(advanceLocalPet(initial, initial.lastTickAt + EXPERIENCE_INTERVAL_MS - 1)).toBe(initial) + const afterOne = advanceLocalPet(initial, initial.lastTickAt + EXPERIENCE_INTERVAL_MS, () => 0) + expect(afterOne.gauge).toBeGreaterThan(0) + expect(afterOne.gauge).toBeLessThan(5_000_000) + expect(afterOne.lastTickAt).toBe(initial.lastTickAt + EXPERIENCE_INTERVAL_MS) + expect(advanceLocalPet(afterOne, initial.lastTickAt + EXPERIENCE_INTERVAL_MS, () => 0)).toBe(afterOne) + }) + + it("catches up after the app is closed and records an evolution once", () => { + const now = initial.lastTickAt + 24 * EXPERIENCE_INTERVAL_MS + const evolved = advanceLocalPet(initial, now, () => 0) + expect(evolved.currentNodeId).not.toBe(initial.currentNodeId) + expect(evolved.gauge).toBe(0) + expect(evolved.events).toHaveLength(2) + expect(evolved.events[1]?.createdAt).toBe(new Date(now).toISOString()) + expect(advanceLocalPet(evolved, now, () => 0)).toBe(evolved) + }) + + it("uses the exact selected experience requirements without changing experience earned per tick", () => { + const high = experienceThresholds("high") + const normal = experienceThresholds("normal") + const low = experienceThresholds("low") + for (const stage of [0, 1, 2, 3, 4, 5, 6, 7] as const) { + expect(normal[stage]).toBe(high[stage] / 2) + expect(low[stage]).toBe(high[stage] / 10) + } + for (const [level, ticks] of [ + ["high", 24], + ["normal", 12], + ["low", 3], + ] as const) { + const configured = { ...initial, experienceLevel: level } + const before = advanceLocalPet(configured, initial.lastTickAt + (ticks - 1) * EXPERIENCE_INTERVAL_MS, () => 0) + expect(before.currentNodeId).toBe(initial.currentNodeId) + const after = advanceLocalPet(configured, initial.lastTickAt + ticks * EXPERIENCE_INTERVAL_MS, () => 0) + expect(after.currentNodeId).not.toBe(initial.currentNodeId) + } + }) + + it("starts a fresh egg while preserving retired companions and the experience setting", () => { + const previous = { ...initial, experienceLevel: "normal" as const } + const next = beginNewPartner(previous, "new-partner", initial.lastTickAt + EXPERIENCE_INTERVAL_MS) + expect(next.partnerId).toBe("new-partner") + expect(next.currentNodeId).toBe("0-001") + expect(next.gauge).toBe(0) + expect(next.experienceLevel).toBe("normal") + expect(next.retiredPartners?.[0]?.partnerId).toBe(previous.partnerId) + expect(next.retiredPartners?.[0]?.events).toEqual(previous.events) + const third = beginNewPartner(next, "third-partner", next.lastTickAt + EXPERIENCE_INTERVAL_MS) + expect(third.retiredPartners?.map((partner) => partner.partnerId)).toEqual(["local-partner", "new-partner"]) + }) +}) diff --git a/packages/web-digital-pet/tests/scene-motion.test.ts b/packages/web-digital-pet/tests/scene-motion.test.ts new file mode 100644 index 0000000..dcbcaba --- /dev/null +++ b/packages/web-digital-pet/tests/scene-motion.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "bun:test" +import { + advanceLandscape, + landscapeDirection, + landscapeMotionEnabled, + sceneMotionFor, + type SceneMotion, +} from "../src/scene-motion.ts" + +const left: SceneMotion = { walking: true, offset: 0, facing: "left", partnerKey: "agumon:false", columns: 40 } + +describe("walking landscape", () => { + it("respects reduced motion unless the user explicitly turns the effect on", () => { + expect(landscapeMotionEnabled(null, true)).toBe(false) + expect(landscapeMotionEnabled(null, false)).toBe(true) + expect(landscapeMotionEnabled("on", true)).toBe(true) + expect(landscapeMotionEnabled("off", false)).toBe(false) + expect(landscapeMotionEnabled("invalid", true)).toBe(false) + }) + it("follows actual steps in the same direction and pauses between them", () => { + expect(landscapeDirection(left, { ...left, offset: -1 })).toBe(-1) + expect(landscapeDirection(left, { ...left, offset: 1, facing: "right" })).toBe(1) + expect(landscapeDirection(left, left)).toBe(0) + expect(landscapeDirection(left, { ...left, offset: -1, walking: false })).toBe(0) + expect(landscapeDirection({ ...left, walking: false }, left)).toBe(0) + expect(landscapeDirection(undefined, left)).toBe(0) + }) + it("does not mistake a new pet or resized viewport for walking", () => { + expect(landscapeDirection(left, { ...left, offset: -5, partnerKey: "gabumon:false" })).toBe(0) + expect(landscapeDirection(left, { ...left, offset: -5, columns: 32 })).toBe(0) + expect( + sceneMotionFor( + { kind: "digitama", result: { kind: "frame", frame: { content: "█" } }, offset: 0, facing: "left" }, + "egg:true", + 40, + ).walking, + ).toBe(false) + }) + it("moves slowly and wraps at an equivalent reflected tile without exposing edges", () => { + expect(advanceLandscape(0, 1, 100, 50)).toBeCloseTo(0.3) + expect(advanceLandscape(0, -1, 100, 50)).toBeCloseTo(-0.3) + expect(advanceLandscape(49.9, 1, 100, 50)).toBeCloseTo(-49.8) + expect(advanceLandscape(-49.9, -1, 100, 50)).toBeCloseTo(49.8) + expect(advanceLandscape(12, 0, 100, 50)).toBe(12) + expect(advanceLandscape(12, 1, 100, 0)).toBe(12) + expect(advanceLandscape(0, 1, 60_000, 50)).toBeCloseTo(0.3) + }) +}) diff --git a/packages/web-digital-pet/tests/transfer-protocol.test.ts b/packages/web-digital-pet/tests/transfer-protocol.test.ts new file mode 100644 index 0000000..843e7d5 --- /dev/null +++ b/packages/web-digital-pet/tests/transfer-protocol.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" + +import { parsePetTransfer } from "../src/transfer-protocol.ts" + +const state = { + partnerId: "sample-partner", + createdAt: "2026-10-07T00:00:00.000Z", + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + lastTickAt: Date.parse("2026-10-07T00:00:00.000Z"), + events: [{ currentNodeId: "0-001", createdAt: "2026-10-07T00:00:00.000Z" }], +} + +describe("browser save transfer", () => { + it("accepts a versioned save and discards unknown fields", () => { + const transfer = parsePetTransfer({ version: 1, state: { ...state, unwanted: "data" } }) + expect(transfer.state).toEqual(state) + }) + + it("rejects malformed and inconsistent histories before import", () => { + expect(() => parsePetTransfer({ version: 2, state })).toThrow() + expect(() => parsePetTransfer({ version: 1, state: { ...state, currentNodeId: "1-001" } })).toThrow() + expect(() => parsePetTransfer({ version: 1, state: { ...state, gauge: -1 } })).toThrow() + expect(() => + parsePetTransfer({ version: 1, state: { ...state, events: [{ ...state.events[0], createdAt: "bad" }] } }), + ).toThrow() + }) + + it("transfers experience preferences and archived companions, and rejects invalid settings", () => { + const extended = { + ...state, + experienceLevel: "low", + retiredPartners: [ + { partnerId: "previous", createdAt: state.createdAt, retiredAt: state.createdAt, events: state.events }, + ], + } + expect(parsePetTransfer({ version: 1, state: extended }).state).toEqual(extended) + expect(() => parsePetTransfer({ version: 1, state: { ...state, experienceLevel: ["low"] } })).toThrow() + expect(() => + parsePetTransfer({ + version: 1, + state: { ...extended, retiredPartners: [{ ...extended.retiredPartners[0], events: [] }] }, + }), + ).toThrow() + }) +}) diff --git a/packages/web-digital-pet/tests/world.test.ts b/packages/web-digital-pet/tests/world.test.ts new file mode 100644 index 0000000..4aa0636 --- /dev/null +++ b/packages/web-digital-pet/tests/world.test.ts @@ -0,0 +1,97 @@ +import { getRegion } from "@jcendal/digital-pet-fields/application/world.ts" +import { describe, expect, it } from "bun:test" +import { DIGIMON_CATALOG } from "@jcendal/digital-pet-core/data/catalog.ts" +import { DEFAULT_DIGITAL_PET_SETTINGS } from "@jcendal/digital-pet-core/config/defaults.ts" +import { buildWorldPanelModel } from "@jcendal/digital-pet-webviews/panels/world/world-model.ts" +import { renderWorldRegion } from "@jcendal/digital-pet-webviews/panels/world/world-render.ts" +import { artworkToCenteredPixelArt } from "@jcendal/digital-pet-webviews/shared/pixel-artwork.ts" +import { parsePetTransfer } from "../src/transfer-protocol.ts" +import { advanceLocalPet, beginNewPartner } from "../src/local-progress.ts" + +const worldVisit = { regionId: "digital-city", locationId: "digital-city" } +const createdAt = new Date(Date.now() - 600_000).toISOString() +const state = { + partnerId: "sample", + createdAt, + currentNodeId: "0-001", + gauge: 0, + isTerminal: false, + lastTickAt: Date.parse(createdAt), + events: [{ currentNodeId: "0-001", createdAt }], + worldVisit, +} + +describe("web world integration", () => { + it("centers visible sprite pixels without stretching smaller species", () => { + const art = artworkToCenteredPixelArt(" ▄\n ▀") + expect(art.path).toBe("M2 1h1v1h-1zM3 2h1v1h-1z") + expect(art.viewBox).toBe("-13 -14 32 32") + expect(artworkToCenteredPixelArt(" ")).toEqual({ path: "", viewBox: "0 0 32 32" }) + }) + it("shows five guide cards before a closed, keyboard-accessible disclosure", () => { + const region = getRegion("digital-ocean") + const model = buildWorldPanelModel(region.id, { kind: "empty" }, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + const html = renderWorldRegion(region, model, { regionId: region.id, locationId: "dragon-eye-lake" }) + const [preview, remainder] = html.split('
') + expect(preview!.match(/class="resident-row"/g)).toHaveLength(5) + expect(remainder!.match(/class="resident-row"/g)).toHaveLength(model.residents.length - 5) + expect(remainder).toContain(`SHOW ${model.residents.length - 5} MORE`) + expect(html).not.toContain('
{ + const region = getRegion("village-of-beginnings") + const model = buildWorldPanelModel(region.id, { kind: "empty" }, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + expect(model.residents.some((resident) => resident.id === "2-033" && resident.artwork === "")).toBe(true) + const html = renderWorldRegion(region, model, { regionId: region.id, locationId: region.defaultLocationId }) + expect(html).toContain('aria-label="Artwork unavailable"') + expect(html).toContain("Minomon") + }) + it("transfers the destination and keeps older saves compatible", () => { + expect(parsePetTransfer({ version: 1, state }).state.worldVisit).toEqual(worldVisit) + const { worldVisit: _visit, ...legacy } = state + expect(parsePetTransfer({ version: 1, state: legacy }).state.worldVisit).toBeUndefined() + expect(() => + parsePetTransfer({ + version: 1, + state: { ...state, worldVisit: { regionId: "digital-ocean", locationId: "digital-city" } }, + }), + ).toThrow("invalid destination") + }) + it("keeps the place when starting a new egg and does not alter progression", () => { + expect(beginNewPartner(state, "next", Date.now()).worldVisit).toEqual(worldVisit) + const other = { ...state, worldVisit: { regionId: "digital-ocean", locationId: "dragon-eye-lake" } } + const a = advanceLocalPet(state, state.lastTickAt + 24 * 300_000, () => 0) + const b = advanceLocalPet(other, state.lastTickAt + 24 * 300_000, () => 0) + expect(a.currentNodeId).toBe(b.currentNodeId) + expect(a.gauge).toBe(b.gauge) + expect(a.events).toEqual(b.events) + }) + it("builds a public guide while marking only actual discoveries", () => { + const residentId = getRegion("digital-ocean").residentIds[0]! + const events = [{ eventId: "1", currentNodeId: residentId, createdAt }] + const archive = { + kind: "available" as const, + partners: [ + { partnerId: "previous", generation: 1, createdAt, retiredAt: createdAt, events }, + { partnerId: "sample", generation: 2, createdAt, retiredAt: null, events }, + ], + } + const model = buildWorldPanelModel("digital-ocean", archive, DIGIMON_CATALOG, DEFAULT_DIGITAL_PET_SETTINGS) + expect(model.registeredIds).toEqual([residentId]) + expect(model.residents.length).toBeGreaterThan(0) + expect(model.residents.every((resident) => resident.name.length > 0)).toBe(true) + expect(model.residents.some((resident) => resident.artwork.length > 0)).toBe(true) + expect(model.residents.filter((resident) => resident.registered).map((resident) => resident.id)).toEqual([ + residentId, + ]) + expect(archive.partners[0]!.events).toEqual(events) + }) +}) diff --git a/packages/web-digital-pet/tsconfig.json b/packages/web-digital-pet/tsconfig.json new file mode 100644 index 0000000..ae646cf --- /dev/null +++ b/packages/web-digital-pet/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["node"], + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/scripts/build.ts b/scripts/build.ts deleted file mode 100644 index e81b42e..0000000 --- a/scripts/build.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { rm } from "node:fs/promises" - -await rm("dist", { recursive: true, force: true }) - -const pluginResult = await Bun.build({ - entrypoints: ["./src/index.ts", "./src/tui.tsx"], - outdir: "./dist", - target: "bun", - format: "esm", - splitting: false, - external: [ - "@opencode-ai/plugin", - "@opencode-ai/plugin/tui", - "@opencode-ai/sdk", - "@opencode-ai/sdk/v2", - "@opentui/core", - "@opentui/keymap", - "@opentui/solid", - "solid-js", - ], - sourcemap: "linked", -}) - -const cliResult = await Bun.build({ - entrypoints: ["./src/cli.ts"], - outdir: "./dist", - target: "node", - format: "esm", - splitting: false, - sourcemap: "linked", -}) - -if (!pluginResult.success || !cliResult.success) { - await rm("dist", { recursive: true, force: true }) - throw new Error("Build failed.") -} - -const declarations = Bun.spawnSync(["bunx", "tsc", "-p", "tsconfig.build.json"]) -if (declarations.exitCode !== 0) throw new Error(new TextDecoder().decode(declarations.stderr)) diff --git a/scripts/check-workspace-dependencies.mjs b/scripts/check-workspace-dependencies.mjs new file mode 100644 index 0000000..ed9b95b --- /dev/null +++ b/scripts/check-workspace-dependencies.mjs @@ -0,0 +1,38 @@ +import { readFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +import semver from "semver" + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const workspaces = [ + "digital-pet-core", + "digital-pet-fields", + "digital-pet-animation", + "digital-pet-webviews", + "web-digital-pet", + "opencode-digital-pet", + "cursor-digital-pet", +] +const manifests = new Map() + +for (const workspace of workspaces) { + const path = resolve(root, "packages", workspace, "package.json") + const manifest = JSON.parse(await readFile(path, "utf8")) + if (semver.valid(manifest.version) !== manifest.version) { + throw new Error(`${workspace} must have an exact SemVer version.`) + } + manifests.set(manifest.name, manifest) +} + +for (const manifest of manifests.values()) { + for (const [name, version] of Object.entries(manifest.dependencies ?? {})) { + const internal = manifests.get(name) + if (internal?.private !== true) continue + if (version !== internal.version) { + throw new Error(`${manifest.name} depends on ${name}@${version}, but the workspace is ${internal.version}.`) + } + } +} + +console.log("Workspace dependency versions match their private packages.") diff --git a/scripts/dispatch-merged-releases.ts b/scripts/dispatch-merged-releases.ts new file mode 100644 index 0000000..9962a15 --- /dev/null +++ b/scripts/dispatch-merged-releases.ts @@ -0,0 +1,143 @@ +type ReleaseBump = "patch" | "minor" | "major" +type Product = "opencode" | "cursor" + +type SelectedRelease = { + readonly release: boolean + readonly bump: ReleaseBump +} + +export type DispatchOptions = { + readonly repository: string + readonly token: string + readonly releaseNote: string + readonly opencode: SelectedRelease + readonly cursor: SelectedRelease +} + +type DispatchResponse = { + readonly workflow_run_id?: number + readonly html_url?: string +} + +type RunResponse = { + readonly status?: string + readonly conclusion?: string | null + readonly html_url?: string +} + +const API_VERSION = "2026-03-10" +const MAX_POLLS = 120 +const POLL_INTERVAL_MS = 15_000 + +const workflowFor = (product: Product): string => + product === "opencode" ? "release-opencode.yml" : "release-cursor.yml" + +const pause = async (milliseconds: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +const readJson = async (response: Response, operation: string): Promise => { + if (!response.ok) throw new Error(`${operation} failed: HTTP ${response.status} ${await response.text()}`) + return (await response.json()) as T +} + +const readRun = async ( + request: typeof fetch, + runUrl: string, + headers: HeadersInit, + workflow: string, +): Promise => { + const response = await request(runUrl, { headers }) + if (response.status === 404) return undefined + return readJson(response, `Checking ${workflow}`) +} + +export const dispatchSelectedReleases = async ( + options: DispatchOptions, + request: typeof fetch = fetch, + wait: (milliseconds: number) => Promise = pause, + report: (message: string) => void = (message) => process.stdout.write(`${message}\n`), +): Promise => { + if (options.repository !== "jcendal/digital-pet" || options.token.length === 0) { + throw new Error("Release dispatch requires the jcendal/digital-pet repository and a GitHub token.") + } + + const baseUrl = `https://api.github.com/repos/${options.repository}` + const headers = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${options.token}`, + "X-GitHub-Api-Version": API_VERSION, + } + + const selected = ( + [ + ["opencode", options.opencode], + ["cursor", options.cursor], + ] as const + ).filter(([, release]) => release.release) + + const started = [] + for (const [product, release] of selected) { + const workflow = workflowFor(product) + const dispatch = await readJson( + await request(`${baseUrl}/actions/workflows/${workflow}/dispatches`, { + method: "POST", + headers, + body: JSON.stringify({ + ref: "main", + inputs: { bump: release.bump, release_note: options.releaseNote }, + return_run_details: true, + }), + }), + `Dispatching ${workflow}`, + ) + if (!Number.isSafeInteger(dispatch.workflow_run_id) || (dispatch.workflow_run_id ?? 0) <= 0) { + throw new Error(`GitHub did not return a run ID for ${workflow}.`) + } + const runUrl = `${baseUrl}/actions/runs/${dispatch.workflow_run_id}` + report(`Started ${workflow}: ${dispatch.html_url ?? runUrl}`) + started.push({ product, workflow, runUrl }) + } + + const results = await Promise.all( + started.map(async ({ product, workflow, runUrl }) => { + for (let attempt = 0; attempt < MAX_POLLS; attempt += 1) { + if (attempt > 0) await wait(POLL_INTERVAL_MS) + const run = await readRun(request, runUrl, headers, workflow) + if (run === undefined || run.status !== "completed" || run.conclusion == null) continue + if (run.conclusion !== "success") { + return `${workflow} finished with ${run.conclusion}: ${run.html_url ?? runUrl}` + } + report(`Published ${product}: ${run.html_url ?? runUrl}`) + return undefined + } + return `${workflow} did not finish successfully within 30 minutes: ${runUrl}` + }), + ) + const failures = results.filter((failure) => failure !== undefined) + if (failures.length > 0) throw new Error(failures.join("\n")) +} + +const parseBump = (value: string | undefined): ReleaseBump => { + if (value === "patch" || value === "minor" || value === "major") return value + throw new Error(`Invalid release bump: ${value ?? "missing"}.`) +} + +if (import.meta.main) { + const { + GITHUB_REPOSITORY, + GITHUB_TOKEN, + RELEASE_NOTE, + RELEASE_OPENCODE, + OPENCODE_BUMP, + RELEASE_CURSOR, + CURSOR_BUMP, + } = process.env + await dispatchSelectedReleases({ + repository: GITHUB_REPOSITORY ?? "", + token: GITHUB_TOKEN ?? "", + releaseNote: RELEASE_NOTE ?? "", + opencode: { release: RELEASE_OPENCODE === "true", bump: parseBump(OPENCODE_BUMP) }, + cursor: { release: RELEASE_CURSOR === "true", bump: parseBump(CURSOR_BUMP) }, + }) +} diff --git a/scripts/migrate-imports.mjs b/scripts/migrate-imports.mjs new file mode 100644 index 0000000..bff98ef --- /dev/null +++ b/scripts/migrate-imports.mjs @@ -0,0 +1,51 @@ +import { readdir, readFile, writeFile } from "node:fs/promises" +import { join } from "node:path" + +const ROOT = join(import.meta.dirname, "..", "packages", "opencode-digital-pet") + +const REPLACEMENTS = [ + [/from "(\.\.\/)+domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "(\.\.\/)+application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "(\.\.\/)+data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "(\.\.\/)+config\/types\.ts"/g, 'from "@jcendal/digital-pet-core/config/types.ts"'], + [/from "\.\/config\/types\.ts"/g, 'from "@jcendal/digital-pet-core/config/types.ts"'], + [/from "(\.\.\/)+config\/defaults\.ts"/g, 'from "@jcendal/digital-pet-core/config/defaults.ts"'], + [/from "\.\/config\/defaults\.ts"/g, 'from "@jcendal/digital-pet-core/config/defaults.ts"'], + [/from "(\.\.\/)+config\/normalize\.ts"/g, 'from "@jcendal/digital-pet-core/config/normalize.ts"'], + [/from "\.\/config\/normalize\.ts"/g, 'from "@jcendal/digital-pet-core/config/normalize.ts"'], + [/from "\.\/sidebar-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts"'], + [/from "\.\/dex-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/dex-view-model.ts"'], + [/from "\.\/history-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/history-view-model.ts"'], + [/from "\.\.\/tui\/sidebar-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/sidebar-view-model.ts"'], + [/from "\.\.\/tui\/dex-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/dex-view-model.ts"'], + [/from "\.\.\/tui\/history-view-model\.ts"/g, 'from "@jcendal/digital-pet-core/view-models/history-view-model.ts"'], + [/from "\.\.\/src\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\.\/\.\.\/src\/domain\//g, 'from "@jcendal/digital-pet-core/domain/'], + [/from "\.\.\/src\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\.\/\.\.\/src\/application\//g, 'from "@jcendal/digital-pet-core/application/'], + [/from "\.\.\/src\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\.\/\.\.\/src\/data\//g, 'from "@jcendal/digital-pet-core/data/'], + [/from "\.\.\/src\/data\/digimon-data"/g, 'from "@jcendal/digital-pet-core/data/digimon-data.ts"'], +] + +const walk = async (dir) => { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const path = join(dir, entry.name) + if (entry.isDirectory()) await walk(path) + else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { + let content = await readFile(path, "utf8") + for (const [pattern, replacement] of REPLACEMENTS) { + content = content.replace(pattern, replacement) + } + await writeFile(path, content) + } + } +} + +await walk(join(ROOT, "src")) +await walk(join(ROOT, "tests")) +console.log("Import migration complete") diff --git a/scripts/plan-merged-release.ts b/scripts/plan-merged-release.ts new file mode 100644 index 0000000..1134c9c --- /dev/null +++ b/scripts/plan-merged-release.ts @@ -0,0 +1,135 @@ +import { spawnSync } from "node:child_process" +import { readFile, writeFile } from "node:fs/promises" + +import semver from "semver" + +type Product = "opencode" | "cursor" +type ReleaseBump = "patch" | "minor" | "major" + +type ReleasePlanInput = { + readonly changedFiles: readonly string[] + readonly tags: readonly string[] + readonly title: string + readonly labels: readonly string[] + readonly opencodeVersion: string + readonly alreadyReleased: Readonly> +} + +export type ReleasePlan = { + readonly opencode: { readonly release: boolean; readonly bump: ReleaseBump } + readonly cursor: { readonly release: boolean; readonly bump: ReleaseBump } +} + +const PRODUCT_TAG_PREFIX: Readonly> = { + opencode: "opencode-digital-pet-v", + cursor: "cursor-digital-pet-v", +} + +const latestStableTag = (tags: readonly string[], prefix: string): string | undefined => { + const versions = tags.flatMap((tag) => { + if (!tag.startsWith(prefix)) return [] + const version = tag.slice(prefix.length) + return semver.valid(version) === version && semver.prerelease(version) === null ? [version] : [] + }) + const latest = semver.rsort(versions)[0] + return latest === undefined ? undefined : `${prefix}${latest}` +} + +const bumpFromPullRequest = (title: string, labels: readonly string[]): ReleaseBump => { + const selected = (["major", "minor", "patch"] as const).filter((bump) => labels.includes(`release:${bump}`)) + if (selected.length > 1) throw new Error("A pull request may have only one release:major/minor/patch label.") + if (selected[0] !== undefined) return selected[0] + if (/^[a-z]+(?:\([^)]*\))?!:/i.test(title) || /BREAKING CHANGE/i.test(title)) return "major" + if (/^feat(?:\([^)]*\))?(?::|\/)/i.test(title)) return "minor" + return "patch" +} + +const firstOpenCodeBump = (tags: readonly string[], sourceVersion: string, fallback: ReleaseBump): ReleaseBump => { + const legacyTag = latestStableTag(tags, "v") + if (legacyTag === undefined) return fallback + const candidate = sourceVersion.replace(/-dev\.0$/, "") + const difference = semver.diff(legacyTag.slice(1), candidate) + if (difference === "major" || difference === "minor" || difference === "patch") return difference + throw new Error(`Cannot derive the first OpenCode release from ${legacyTag} and ${sourceVersion}.`) +} + +export const planMergedRelease = (input: ReleasePlanInput): ReleasePlan => { + const firstOpenCodeRelease = latestStableTag(input.tags, PRODUCT_TAG_PREFIX.opencode) === undefined + const firstCursorRelease = latestStableTag(input.tags, PRODUCT_TAG_PREFIX.cursor) === undefined + const sharedChanged = input.changedFiles.some( + (path) => path.startsWith("packages/digital-pet-core/") || path.startsWith("packages/digital-pet-animation/"), + ) + const opencodeChanged = + sharedChanged || input.changedFiles.some((path) => path.startsWith("packages/opencode-digital-pet/")) + const cursorChanged = + sharedChanged || input.changedFiles.some((path) => path.startsWith("packages/cursor-digital-pet/")) + const requestedBump = bumpFromPullRequest(input.title, input.labels) + + return { + opencode: { + release: !input.alreadyReleased.opencode && (firstOpenCodeRelease || opencodeChanged), + bump: firstOpenCodeRelease ? firstOpenCodeBump(input.tags, input.opencodeVersion, requestedBump) : requestedBump, + }, + cursor: { + release: !input.alreadyReleased.cursor && (firstCursorRelease || cursorChanged), + bump: requestedBump, + }, + } +} + +type PullRequestEvent = { + readonly pull_request?: { + readonly merged?: boolean + readonly merge_commit_sha?: string | null + readonly title?: string + readonly labels?: readonly { readonly name?: string }[] + } +} + +const isAlreadyReleased = (mergeSha: string, tag: string | undefined): boolean => { + if (tag === undefined) return false + const result = spawnSync("git", ["merge-base", "--is-ancestor", mergeSha, `${tag}^{commit}`]) + if (result.status === 0) return true + if (result.status === 1) return false + throw new Error(`Could not compare pull request commit with ${tag}: ${result.stderr.toString()}`) +} + +const run = async (): Promise => { + const [changedFilesPath] = process.argv.slice(2) + const eventPath = process.env["GITHUB_EVENT_PATH"] + const outputPath = process.env["GITHUB_OUTPUT"] + if (changedFilesPath === undefined || eventPath === undefined || outputPath === undefined) { + throw new Error("Expected changed-files path, GITHUB_EVENT_PATH and GITHUB_OUTPUT.") + } + + const event: PullRequestEvent = JSON.parse(await readFile(eventPath, "utf8")) + const pullRequest = event.pull_request + if (pullRequest?.merged !== true || !pullRequest.merge_commit_sha || !pullRequest.title) { + throw new Error("Expected a merged pull request with a merge commit and title.") + } + + let tagInput = "" + for await (const chunk of process.stdin) tagInput += chunk.toString() + const tags = tagInput.split(/\r?\n/).filter(Boolean) + const changedFiles = (await readFile(changedFilesPath, "utf8")).split(/\r?\n/).filter(Boolean) + const opencodePackage = JSON.parse(await readFile("packages/opencode-digital-pet/package.json", "utf8")) + const plan = planMergedRelease({ + changedFiles, + tags, + title: pullRequest.title, + labels: (pullRequest.labels ?? []).flatMap((label) => (label.name === undefined ? [] : [label.name])), + opencodeVersion: opencodePackage.version, + alreadyReleased: { + opencode: isAlreadyReleased(pullRequest.merge_commit_sha, latestStableTag(tags, PRODUCT_TAG_PREFIX.opencode)), + cursor: isAlreadyReleased(pullRequest.merge_commit_sha, latestStableTag(tags, PRODUCT_TAG_PREFIX.cursor)), + }, + }) + await writeFile( + outputPath, + `release_opencode=${plan.opencode.release}\nopencode_bump=${plan.opencode.bump}\nrelease_cursor=${plan.cursor.release}\ncursor_bump=${plan.cursor.bump}\n`, + { flag: "a" }, + ) + process.stdout.write(`${JSON.stringify(plan)}\n`) +} + +if (import.meta.main) await run() diff --git a/scripts/prepack.ts b/scripts/prepack.ts deleted file mode 100644 index 1de84fa..0000000 --- a/scripts/prepack.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { resolve } from "node:path" - -type JsonObject = { readonly [key: string]: unknown } - -const isJsonObject = (value: unknown): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value) - -const collectPaths = (value: unknown, paths: Set): void => { - if (typeof value === "string") { - paths.add(value) - return - } - if (!isJsonObject(value)) return - for (const nestedValue of Object.values(value)) collectPaths(nestedValue, paths) -} - -await Bun.$`bun run build` - -const packageRoot = resolve(import.meta.dir, "..") -const packageJson: unknown = JSON.parse(await Bun.file(resolve(packageRoot, "package.json")).text()) -if (!isJsonObject(packageJson)) throw new Error("package.json must contain an object.") - -const publishedPaths = new Set() -collectPaths(packageJson.main, publishedPaths) -collectPaths(packageJson.types, publishedPaths) -collectPaths(packageJson.exports, publishedPaths) -collectPaths(packageJson.bin, publishedPaths) - -for (const publishedPath of publishedPaths) { - const artifactPath = resolve(packageRoot, publishedPath) - const artifact = Bun.file(artifactPath) - if (!(await artifact.exists()) || artifact.size === 0) { - throw new Error(`Published artifact is missing or empty: ${publishedPath}`) - } -} - -const binPaths = new Set() -collectPaths(packageJson.bin, binPaths) -const cliPath = binPaths.values().next().value -if (typeof cliPath !== "string") throw new Error("Package metadata does not define a bin path.") - -const cliResult = Bun.spawnSync(["node", resolve(packageRoot, cliPath), "--help"], { - stdout: "inherit", - stderr: "inherit", -}) -if (cliResult.exitCode !== 0) throw new Error(`Node CLI help failed: ${cliPath}`) diff --git a/scripts/push-release.ts b/scripts/push-release.ts new file mode 100644 index 0000000..f6b18df --- /dev/null +++ b/scripts/push-release.ts @@ -0,0 +1,64 @@ +import { spawnSync } from "node:child_process" + +const MAX_ATTEMPTS = 5 + +const run = (command: string, args: readonly string[], allowFailure = false): string => { + const result = spawnSync(command, args, { encoding: "utf8" }) + if (result.error) throw result.error + if (!allowFailure && result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed:\n${result.stderr}`) + } + return result.stdout +} + +const git = (args: readonly string[], allowFailure = false): string => run("git", args, allowFailure) + +const succeeded = (args: readonly string[]): boolean => spawnSync("git", args, { encoding: "utf8" }).status === 0 + +const conflictedPaths = (): string[] => + git(["diff", "--name-only", "--diff-filter=U"]) + .split("\n") + .map((path) => path.trim()) + .filter((path) => path.length > 0) + +const resolveLockfileConflict = (): void => { + const conflicts = conflictedPaths() + const unexpected = conflicts.filter((path) => path !== "package-lock.json") + if (unexpected.length > 0) { + throw new Error( + `Cannot retry the release push. Conflicts are not limited to package-lock.json: ${unexpected.join(", ")}`, + ) + } + git(["checkout", "--ours", "--", "package-lock.json"]) + run("npm", ["install", "--package-lock-only", "--ignore-scripts"]) + git(["add", "package-lock.json"]) + git(["-c", "core.editor=true", "cherry-pick", "--continue"]) +} + +const replay = (commit: string): void => { + if (!succeeded(["cherry-pick", commit])) resolveLockfileConflict() +} + +export const pushRelease = (tag: string): void => { + const releaseCommit = git(["rev-parse", "HEAD~1"]).trim() + const devCommit = git(["rev-parse", "HEAD"]).trim() + const message = git(["for-each-ref", "--format=%(contents)", `refs/tags/${tag}`]).trim() || tag + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + if (succeeded(["push", "--atomic", "origin", "HEAD:refs/heads/main", `refs/tags/${tag}`])) return + if (attempt === MAX_ATTEMPTS) break + process.stderr.write(`main moved during the release. Retrying push ${attempt} of ${MAX_ATTEMPTS - 1}.\n`) + git(["fetch", "origin", "main"]) + git(["switch", "--detach", "origin/main"]) + replay(releaseCommit) + git(["tag", "-f", "-a", tag, "HEAD", "-m", message]) + replay(devCommit) + } + throw new Error(`Could not push ${tag} after main moved.`) +} + +if (import.meta.main) { + const tag = process.argv[2] + if (tag === undefined || tag.length === 0) throw new Error("Expected the release tag to push.") + pushRelease(tag) +} diff --git a/scripts/release-metadata.ts b/scripts/release-metadata.ts index 5fcbdf7..72aab1a 100644 --- a/scripts/release-metadata.ts +++ b/scripts/release-metadata.ts @@ -10,7 +10,10 @@ type ReleaseMetadataCliOptions = { readonly bump: ReleaseBump readonly changelogPath: string readonly date: string + readonly legacyTagPrefix?: string readonly packageJsonPath: string + readonly releaseNote?: string + readonly tagPrefix: string } type JsonObject = { @@ -44,14 +47,19 @@ const parseReleaseBump = (value: string | undefined): ReleaseBump => { return value } -const getStableTagVersions = (tags: readonly string[]): readonly string[] => +const getStableTagVersions = (tags: readonly string[], prefix: string): readonly string[] => tags.flatMap((tag) => { - const match = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag) - return match === null ? [] : [`${match[1]}.${match[2]}.${match[3]}`] + if (!tag.startsWith(prefix)) return [] + const version = tag.slice(prefix.length) + return semver.valid(version) === version && semver.prerelease(version) === null ? [version] : [] }) -const getLatestStableVersion = (tags: readonly string[]): string | undefined => - semver.rsort([...getStableTagVersions(tags)])[0] +const getLatestStableVersion = (tags: readonly string[], prefix: string): string | undefined => + semver.rsort([...getStableTagVersions(tags, prefix)])[0] + +const getReleaseLineVersion = (tags: readonly string[], prefix: string, legacyPrefix?: string): string | undefined => + getLatestStableVersion(tags, prefix) ?? + (legacyPrefix === undefined ? undefined : getLatestStableVersion(tags, legacyPrefix)) const parsePackageMetadata = (source: string): PackageMetadata => { const value: unknown = JSON.parse(source) @@ -80,15 +88,19 @@ const parseCliOptions = (arguments_: readonly string[]): ReleaseMetadataCliOptio const packageJsonPath = values.get("--package-json") const changelogPath = values.get("--changelog") const date = values.get("--date") + const tagPrefix = values.get("--tag-prefix") ?? "v" + const legacyTagPrefix = values.get("--legacy-tag-prefix") + const releaseNote = values.get("--release-note") if (packageJsonPath === undefined || changelogPath === undefined || date === undefined) { throw new Error("Expected --package-json, --changelog, and --date.") } + if (tagPrefix.length === 0 || legacyTagPrefix === "") throw new Error("Tag prefixes must not be empty.") - return { bump, changelogPath, date, packageJsonPath } + return { bump, changelogPath, date, legacyTagPrefix, packageJsonPath, releaseNote, tagPrefix } } -export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBump): string => { - const latestVersion = getLatestStableVersion(tags) ?? "0.0.0" +export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBump, prefix = "v"): string => { + const latestVersion = getLatestStableVersion(tags, prefix) ?? "0.0.0" const releaseVersion = semver.inc(latestVersion, bump) if (releaseVersion === null) throw new Error(`Cannot calculate ${bump} release from ${latestVersion}.`) return releaseVersion @@ -96,25 +108,47 @@ export const calculateReleaseVersion = (tags: readonly string[], bump: ReleaseBu export const calculateNextDevelopmentVersion = (releaseVersion: string): string => { if (!isStableVersion(releaseVersion)) throw new Error(`Release version must be stable SemVer: ${releaseVersion}.`) - const nextDevVersion = semver.inc(releaseVersion, "preminor", "dev") + const nextDevVersion = semver.inc(releaseVersion, "prepatch", "dev") if (nextDevVersion === null) throw new Error(`Cannot calculate next development version from ${releaseVersion}.`) return nextDevVersion } -const validatePrepareSourceVersion = (sourceVersion: string, tags: readonly string[]): void => { - const latestStableVersion = getLatestStableVersion(tags) +const resolveReleaseVersion = ( + sourceVersion: string, + tags: readonly string[], + options: ReleaseMetadataCliOptions, +): string => { + const latestProductVersion = getLatestStableVersion(tags, options.tagPrefix) + const latestStableVersion = + latestProductVersion ?? getReleaseLineVersion(tags, options.tagPrefix, options.legacyTagPrefix) if (latestStableVersion === undefined) { - if (!isStableVersion(sourceVersion)) - throw new Error(`Initial release source must be stable SemVer: ${sourceVersion}.`) - return + if (isStableVersion(sourceVersion)) return calculateReleaseVersion([], options.bump) + const initialVersion = sourceVersion.replace(/-dev\.0$/, "") + if (initialVersion === sourceVersion || !isStableVersion(initialVersion)) { + throw new Error(`Initial release source must be stable SemVer or an exact -dev.0 candidate: ${sourceVersion}.`) + } + return initialVersion } const expectedSourceVersion = calculateNextDevelopmentVersion(latestStableVersion) - if (sourceVersion !== expectedSourceVersion) { + const legacySourceVersion = semver.inc(latestStableVersion, "preminor", "dev") + const migratingLegacyTags = latestProductVersion === undefined && options.legacyTagPrefix !== undefined + if (sourceVersion !== expectedSourceVersion && !(migratingLegacyTags && sourceVersion === legacySourceVersion)) { throw new Error(`Release source version must match the expected development version: ${expectedSourceVersion}.`) } + const releaseVersion = semver.inc(latestStableVersion, options.bump) + if (releaseVersion === null) throw new Error(`Cannot calculate ${options.bump} release from ${latestStableVersion}.`) + if (migratingLegacyTags && sourceVersion === legacySourceVersion && sourceVersion !== `${releaseVersion}-dev.0`) { + throw new Error(`The first product release must match its existing development candidate: ${sourceVersion}.`) + } + return releaseVersion } -export const insertReleaseIntoChangelog = (changelog: string, version: string, date: string): string => { +export const insertReleaseIntoChangelog = ( + changelog: string, + version: string, + date: string, + releaseNote?: string, +): string => { const unreleasedHeading = "## [Unreleased]" const unreleasedHeadings = changelog.match(/^## \[Unreleased\]$/gm) ?? [] if (unreleasedHeadings.length !== 1) throw new Error("CHANGELOG.md must contain exactly one ## [Unreleased] section.") @@ -124,25 +158,43 @@ export const insertReleaseIntoChangelog = (changelog: string, version: string, d if (changelog.includes(releaseHeading)) throw new Error(`CHANGELOG.md already contains release ${version}.`) const offset = changelog.indexOf(unreleasedHeading) const insertionPoint = offset + unreleasedHeading.length - return `${changelog.slice(0, insertionPoint)}\n\n## [${version}] - ${date}${changelog.slice(insertionPoint)}` + const remaining = changelog.slice(insertionPoint) + const nextHeading = /^## /m.exec(remaining) + const unreleasedNotes = remaining.slice(0, nextHeading?.index) + const safeNote = releaseNote?.replace(/\s+/g, " ").trim() + const generatedNotes = unreleasedNotes.trim().length === 0 && safeNote ? `\n\n### Changed\n\n- ${safeNote}\n` : "" + return `${changelog.slice(0, insertionPoint)}\n\n## [${version}] - ${date}${generatedNotes}${remaining}` +} + +export const extractReleaseNotes = (changelog: string, version: string): string => { + if (!isStableVersion(version)) throw new Error(`Release version must be stable SemVer: ${version}.`) + const heading = new RegExp(`^## \\[${version.replace(/\./g, "\\.")}\\] - \\d{4}-\\d{2}-\\d{2}$`, "m") + const match = heading.exec(changelog) + if (match === null) throw new Error(`CHANGELOG.md is missing release ${version}.`) + const rest = changelog.slice(match.index + match[0].length) + const nextHeading = /^## /m.exec(rest) + const notes = rest.slice(0, nextHeading?.index).trim() + if (notes.length === 0) throw new Error(`CHANGELOG.md has no notes for release ${version}.`) + return `${notes}\n` } export const runReleaseMetadataCli = async ( arguments_: readonly string[], ): Promise<{ readonly nextDevVersion: string; readonly releaseVersion: string }> => { const options = parseCliOptions(arguments_) + let tagInput = "" + for await (const chunk of process.stdin) tagInput += chunk.toString() const [packageJsonSource, changelog, tags] = await Promise.all([ readFile(options.packageJsonPath, "utf8"), readFile(options.changelogPath, "utf8"), - Bun.stdin.text(), + Promise.resolve(tagInput), ]) const stableTags = tags.split(/\r?\n/).filter(Boolean) const packageMetadata = parsePackageMetadata(packageJsonSource) - validatePrepareSourceVersion(packageMetadata.version, stableTags) - const releaseVersion = calculateReleaseVersion(stableTags, options.bump) + const releaseVersion = resolveReleaseVersion(packageMetadata.version, stableTags, options) const nextDevVersion = calculateNextDevelopmentVersion(releaseVersion) const releasePackageJson = `${JSON.stringify({ ...packageMetadata, version: releaseVersion }, null, 2)}\n` - const releaseChangelog = insertReleaseIntoChangelog(changelog, releaseVersion, options.date) + const releaseChangelog = insertReleaseIntoChangelog(changelog, releaseVersion, options.date, options.releaseNote) await Promise.all([ writeFile(options.packageJsonPath, releasePackageJson), writeFile(options.changelogPath, releaseChangelog), @@ -150,6 +202,15 @@ export const runReleaseMetadataCli = async ( return { nextDevVersion, releaseVersion } } +export const setStablePackageVersion = async (packageJsonPath: string, version: string): Promise => { + if (!isStableVersion(version)) { + throw new Error(`Package version must be stable SemVer: ${version}.`) + } + const packageMetadata = parsePackageMetadata(await readFile(packageJsonPath, "utf8")) + if (packageMetadata.version === version) return + await writeFile(packageJsonPath, `${JSON.stringify({ ...packageMetadata, version }, null, 2)}\n`) +} + export const setPackageVersion = async (packageJsonPath: string, version: string): Promise => { if (semver.valid(version) !== version || semver.prerelease(version) === null) { throw new Error(`Package version must be an exact prerelease SemVer version: ${version}.`) @@ -173,15 +234,27 @@ const writeGithubOutputs = async (result: { } if (import.meta.main) { - const [command, ...arguments_] = Bun.argv.slice(2) - if (command === "set-package-version") { + const [command, ...arguments_] = process.argv.slice(2) + if (command === "release-notes") { + const [changelogPath, version] = arguments_ + if (changelogPath === undefined || version === undefined || arguments_.length !== 2) { + throw new Error("Expected release-notes .") + } + process.stdout.write(extractReleaseNotes(await readFile(changelogPath, "utf8"), version)) + } else if (command === "set-package-stable-version") { + const [packageJsonPath, version] = arguments_ + if (packageJsonPath === undefined || version === undefined || arguments_.length !== 2) { + throw new Error("Expected set-package-stable-version .") + } + await setStablePackageVersion(packageJsonPath, version) + } else if (command === "set-package-version") { const [packageJsonPath, version] = arguments_ if (packageJsonPath === undefined || version === undefined || arguments_.length !== 2) { throw new Error("Expected set-package-version .") } await setPackageVersion(packageJsonPath, version) } else { - const result = await runReleaseMetadataCli(Bun.argv.slice(2)) + const result = await runReleaseMetadataCli(process.argv.slice(2)) await writeGithubOutputs(result) process.stdout.write(`${JSON.stringify(result)}\n`) } diff --git a/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts b/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts deleted file mode 100644 index 115e367..0000000 --- a/src/adapters/sqlite/sqlite-sidebar-snapshot-reader.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { existsSync } from "node:fs" - -import type { SidebarSnapshot, SidebarSnapshotReader } from "../../application/ports/sidebar-snapshot.ts" -import { resolveHostDatabasePath, type HostPathOptions } from "./app-data-path.ts" -import { createExecutor, openReadonlyDatabase } from "./bun-sqlite-driver.ts" -import { - ACTIVE_PARTNER_SELECT, - toPartner, - type PersistedPartnerRow, - type TrainerStateRow, -} from "./sqlite-vpet-schema.ts" - -export type CreateSqliteSidebarSnapshotReaderOptions = HostPathOptions & { readonly databasePath?: string } - -const TRAINER_STATE_SELECT = "SELECT total_tokens FROM trainer_state WHERE trainer_id = 1" -const CONTROL_STATE_SELECT = "SELECT frozen, cheat_node_id FROM vpet_control_state WHERE control_id = 1" - -type ControlStateRow = { - readonly frozen: number - readonly cheat_node_id: string | null -} - -const isRecoverableReadError = (error: unknown): boolean => { - return ( - error instanceof Error && - (error.message.includes("sqlite") || error.message.includes("database") || error.message.includes("no such table")) - ) -} - -export const createSqliteSidebarSnapshotReader = ( - options: CreateSqliteSidebarSnapshotReaderOptions = {}, -): SidebarSnapshotReader => { - const databasePath = options.databasePath ?? resolveHostDatabasePath(options) - - return { - getSidebarSnapshot(): SidebarSnapshot | null { - if (!existsSync(databasePath)) return null - - try { - const database = openReadonlyDatabase(databasePath) - try { - const executor = createExecutor(database) - const trainer = executor.get(TRAINER_STATE_SELECT) - const control = executor.get(CONTROL_STATE_SELECT) - if (control?.cheat_node_id !== null && control?.cheat_node_id !== undefined) { - return { - currentNodeId: control.cheat_node_id, - gauge: 0, - isTerminal: true, - frozen: false, - isSetOverride: true, - trainerTotalTokens: trainer?.total_tokens ?? 0, - } - } - - const partnerRow = executor.get(ACTIVE_PARTNER_SELECT) - if (partnerRow === null) return null - - const partner = toPartner(partnerRow) - return { - currentNodeId: partner.currentNodeId, - gauge: partner.gauge, - isTerminal: partner.isTerminal, - frozen: control?.frozen === 1, - isSetOverride: false, - trainerTotalTokens: trainer?.total_tokens ?? 0, - } - } finally { - database.close() - } - } catch (error) { - if (isRecoverableReadError(error)) return null - throw error - } - }, - } -} diff --git a/src/application/models/vpet-control.ts b/src/application/models/vpet-control.ts deleted file mode 100644 index 8ff8f37..0000000 --- a/src/application/models/vpet-control.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type FreezeVpetOutcome = { readonly kind: "frozen" } | { readonly kind: "already_frozen" } - -export type UnfreezeVpetOutcome = { readonly kind: "unfrozen" } | { readonly kind: "already_unfrozen" } - -export type SetCheatNodeOutcome = - | { readonly kind: "set"; readonly cheatNodeId: string } - | { readonly kind: "already_set"; readonly cheatNodeId: string } diff --git a/src/application/ports/vpet-archive.ts b/src/application/ports/vpet-archive.ts deleted file mode 100644 index 461f4a3..0000000 --- a/src/application/ports/vpet-archive.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { VpetArchiveResult } from "../models/vpet-archive.ts" - -export type VpetArchiveReader = { - getArchive: () => VpetArchiveResult -} diff --git a/src/application/ports/vpet-control.ts b/src/application/ports/vpet-control.ts deleted file mode 100644 index 9f78de1..0000000 --- a/src/application/ports/vpet-control.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { FreezeVpetOutcome, SetCheatNodeOutcome, UnfreezeVpetOutcome } from "../models/vpet-control.ts" - -export type VpetControl = { - freeze: () => FreezeVpetOutcome - unfreeze: () => UnfreezeVpetOutcome - setCheatNode: (validatedNodeId: string) => SetCheatNodeOutcome -} diff --git a/src/application/use-cases/freeze-vpet.ts b/src/application/use-cases/freeze-vpet.ts deleted file mode 100644 index bbd8ffc..0000000 --- a/src/application/use-cases/freeze-vpet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { FreezeVpetOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const freezeVpet = (control: Pick): FreezeVpetOutcome => control.freeze() diff --git a/src/application/use-cases/set-vpet-cheat-node.ts b/src/application/use-cases/set-vpet-cheat-node.ts deleted file mode 100644 index 642982d..0000000 --- a/src/application/use-cases/set-vpet-cheat-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SetCheatNodeOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const setVpetCheatNode = ( - control: Pick, - validatedNodeId: string, -): SetCheatNodeOutcome => control.setCheatNode(validatedNodeId) diff --git a/src/application/use-cases/unfreeze-vpet.ts b/src/application/use-cases/unfreeze-vpet.ts deleted file mode 100644 index 10d4001..0000000 --- a/src/application/use-cases/unfreeze-vpet.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { UnfreezeVpetOutcome } from "../models/vpet-control.ts" -import type { VpetControl } from "../ports/vpet-control.ts" - -export const unfreezeVpet = (control: Pick): UnfreezeVpetOutcome => control.unfreeze() diff --git a/src/commands/vpet-freeze.ts b/src/commands/vpet-freeze.ts deleted file mode 100644 index 337cbd6..0000000 --- a/src/commands/vpet-freeze.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { freezeVpet } from "../application/use-cases/freeze-vpet.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" - -export type VpetFreezeContext = { - readonly sessionID: string - readonly messageID: string -} - -export const runVpetFreezeCommand = async ( - control: Pick, - context: VpetFreezeContext, -): Promise => { - const outcome = freezeVpet(control) - const parts = [ - { - id: context.messageID, - sessionID: context.sessionID, - messageID: context.messageID, - type: "text" as const, - text: "VPet frozen.", - }, - ] - - switch (outcome.kind) { - case "frozen": - return { parts, event: { kind: "frozen" } } - case "already_frozen": - return { parts } - default: { - const unexpectedOutcome: never = outcome - return unexpectedOutcome - } - } -} diff --git a/src/commands/vpet-unfreeze.ts b/src/commands/vpet-unfreeze.ts deleted file mode 100644 index 1383760..0000000 --- a/src/commands/vpet-unfreeze.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { VpetControl } from "../application/ports/vpet-control.ts" -import { unfreezeVpet } from "../application/use-cases/unfreeze-vpet.ts" -import type { VpetCommandResult } from "./vpet-command-result.ts" - -export type VpetUnfreezeContext = { - readonly sessionID: string - readonly messageID: string -} - -export const runVpetUnfreezeCommand = async ( - control: Pick, - context: VpetUnfreezeContext, -): Promise => { - const outcome = unfreezeVpet(control) - const parts = [ - { - id: context.messageID, - sessionID: context.sessionID, - messageID: context.messageID, - type: "text" as const, - text: "VPet unfrozen.", - }, - ] - - switch (outcome.kind) { - case "unfrozen": - return { parts, event: { kind: "unfrozen" } } - case "already_unfrozen": - return { parts } - default: { - const unexpectedOutcome: never = outcome - return unexpectedOutcome - } - } -} diff --git a/src/config/global-vpet-settings.ts b/src/config/global-vpet-settings.ts deleted file mode 100644 index 5529952..0000000 --- a/src/config/global-vpet-settings.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readFileSync } from "node:fs" -import { readFile } from "node:fs/promises" -import { homedir } from "node:os" -import { join } from "node:path" - -import { DEFAULT_VPET_SETTINGS } from "./defaults.ts" -import { normalizeVpetSettings } from "./normalize.ts" -import type { ResolvedVpetSettings } from "./types.ts" - -type PathApi = Readonly<{ - join: (...paths: readonly string[]) => string -}> - -export type GlobalVpetConfigOptions = Readonly<{ - readonly env?: Readonly<{ - readonly APPDATA?: string - }> - readonly home?: string - readonly pathApi?: PathApi - readonly platform?: NodeJS.Platform -}> - -export const resolveGlobalVpetConfigPath = (options: GlobalVpetConfigOptions = {}): string => { - const pathApi = options.pathApi ?? { join } - const env = options.env ?? process.env - const home = options.home ?? homedir() - - if ((options.platform ?? process.platform) === "win32") { - const { APPDATA } = env - return pathApi.join(APPDATA ?? pathApi.join(home, "AppData", "Roaming"), "opencode-vpet.json") - } - - return pathApi.join(home, ".config", "opencode-vpet.json") -} - -export const loadGlobalVpetSettings = async (options: GlobalVpetConfigOptions = {}): Promise => { - try { - return normalizeVpetSettings(JSON.parse(await readFile(resolveGlobalVpetConfigPath(options), "utf8"))) - } catch { - return DEFAULT_VPET_SETTINGS - } -} - -export const loadGlobalVpetSettingsSync = (options: GlobalVpetConfigOptions = {}): ResolvedVpetSettings => { - try { - return normalizeVpetSettings(JSON.parse(readFileSync(resolveGlobalVpetConfigPath(options), "utf8"))) - } catch { - return DEFAULT_VPET_SETTINGS - } -} diff --git a/src/domain/evolution.ts b/src/domain/evolution.ts deleted file mode 100644 index 25f0f5b..0000000 --- a/src/domain/evolution.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { DigimonNode } from "./digimon-node.ts" -import { DIGIMON_STAGES, type DigimonStage } from "./stage.ts" - -export type StageThresholds = Readonly> - -export const STAGE_GAUGE_THRESHOLDS = Object.freeze({ - 0: 500_000, - 1: 1_000_000, - 2: 2_000_000, - 3: 4_000_000, - 4: 7_500_000, - 5: 12_500_000, - 6: 20_000_000, - 7: 30_000_000, -} as const satisfies StageThresholds) - -export type PartnerEvolutionState = { - readonly current: DigimonNode - readonly gauge: number - readonly isTerminal: boolean -} - -export type EvolutionSelector = () => number - -const isRecord = (value: unknown): value is Readonly> => { - return typeof value === "object" && value !== null -} - -const isStageThresholds = (thresholds: unknown): thresholds is StageThresholds => { - if (!isRecord(thresholds) || !Object.isFrozen(thresholds)) return false - - return DIGIMON_STAGES.every((stage) => { - const threshold = thresholds[stage] - return typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0 - }) -} - -export const applyTokenProgress = ( - state: PartnerEvolutionState, - tokenDelta: number, - selector: EvolutionSelector, - digimonById: ReadonlyMap, - thresholds: unknown, -): PartnerEvolutionState => { - if (!isStageThresholds(thresholds)) { - throw new Error("Evolution thresholds must be a complete frozen policy of positive finite numbers") - } - if (state.isTerminal) return state - - const gauge = state.gauge + tokenDelta - if (gauge < thresholds[state.current.stage]) return { ...state, gauge } - if (state.current.nextEvolutions.length === 0) return { ...state, gauge: 0, isTerminal: true } - - const selection = selector() - if (!Number.isFinite(selection) || selection < 0 || selection >= 1) { - throw new Error(`Evolution selector must return a finite number in [0, 1), received ${selection}`) - } - - const targetId = state.current.nextEvolutions[Math.floor(selection * state.current.nextEvolutions.length)] - if (targetId === undefined) throw new Error("Evolution target selection failed") - const current = digimonById.get(targetId) - if (current === undefined) throw new Error(`Evolution target ${targetId} is missing from the catalog`) - - return { current, gauge: 0, isTerminal: current.nextEvolutions.length === 0 } -} diff --git a/src/tui.tsx b/src/tui.tsx deleted file mode 100644 index c9a0583..0000000 --- a/src/tui.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { PluginOptions } from "@opencode-ai/plugin" -import type { EventMessagePartUpdated, EventMessageUpdated, EventSessionStatus } from "@opencode-ai/sdk/v2" -import type { TuiPlugin, TuiDialogProps, TuiDialogStack, TuiTheme, TuiKeymap } from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import { createSignal } from "solid-js" - -import { createSqliteSidebarSnapshotReader } from "./adapters/sqlite/sqlite-sidebar-snapshot-reader.ts" -import { createSqliteVpetArchiveReader } from "./adapters/sqlite/sqlite-vpet-archive-reader.ts" -import type { SidebarCardInputs } from "./application/models/sidebar-card-inputs.ts" -import type { VpetArchiveReader } from "./application/ports/vpet-archive.ts" -import { getSidebarCardInputs } from "./application/use-cases/get-sidebar-card-inputs.ts" -import { loadGlobalVpetSettings } from "./config/global-vpet-settings.ts" -import type { ResolvedVpetSettings } from "./config/types.ts" -import { DIGIMON_CATALOG } from "./data/catalog.ts" -import { MONSTER_FRAME_CATALOG } from "./data/monster-frame-catalog.ts" -import { VpetSidebarCard } from "./tui/sidebar-card.tsx" -import { MonsterAnimationController, type MonsterAnimationOutput } from "./tui/monster-animation.ts" -import { createSidebarPollLoop } from "./tui/sidebar-poll-loop.ts" -import { buildSidebarCardModel } from "./tui/sidebar-view-model.ts" -import { registerVpetCommandLayer } from "./tui/vpet-command-layer.tsx" - -const VISUAL_INTERVAL_MS = 500 - -const getStringOption = (options: PluginOptions | undefined, name: string): string | undefined => { - const value = options?.[name] - return typeof value === "string" ? value : undefined -} - -const readerOptions = (options: PluginOptions | undefined): Parameters[0] => { - const appDataRoot = getStringOption(options, "appDataRoot") - const databasePath = getStringOption(options, "databasePath") - return { - ...(appDataRoot === undefined ? {} : { appDataRoot }), - ...(databasePath === undefined ? {} : { databasePath }), - } -} - -export type TuiCompositionApi = { - readonly event: { readonly on: (...subscription: ActivitySubscription) => () => void } - readonly renderer: { readonly requestRender: () => void } - readonly lifecycle: { readonly onDispose: (callback: () => void) => () => void } - readonly slots: { - readonly register: (plugin: { readonly slots: { readonly sidebar_content: () => JSX.Element } }) => string - } - readonly keymap?: { readonly registerLayer: TuiKeymap["registerLayer"] } - readonly ui?: { - readonly dialog: Pick - readonly Dialog: (props: TuiDialogProps) => JSX.Element - } - readonly theme?: TuiTheme -} - -type ActivitySubscription = - | readonly [type: "message.updated", handler: (event: EventMessageUpdated) => void] - | readonly [type: "message.part.updated", handler: (event: EventMessagePartUpdated) => void] - | readonly [type: "session.status", handler: (event: EventSessionStatus) => void] - -type TuiSchedulingOptions = { - readonly scheduleVisualInterval?: (callback: () => void, intervalMs: number) => () => void - readonly schedulePollTimeout?: (callback: () => void, intervalMs: number) => () => void - readonly nowMs?: () => number - readonly random?: () => number - readonly onAnimation?: (output: MonsterAnimationOutput) => void - readonly archiveReader?: VpetArchiveReader -} - -const sameAnimation = (left: MonsterAnimationOutput, right: MonsterAnimationOutput): boolean => - left.kind === right.kind && - left.result.kind === right.result.kind && - (left.result.kind !== "frame" || right.result.kind !== "frame" || left.result.frame === right.result.frame) && - (left.result.kind !== "unavailable" || - right.result.kind !== "unavailable" || - left.result.sprite === right.result.sprite) && - left.offset === right.offset && - left.facing === right.facing - -export const createTui = - ( - loadInputs: () => SidebarCardInputs | Promise, - settings: ResolvedVpetSettings, - scheduling: TuiSchedulingOptions = {}, - ) => - async (api: TuiCompositionApi): Promise => { - const [inputs, setInputs] = createSignal({ kind: "no_partner" }) - const [animation, setAnimation] = createSignal({ - kind: "blank", - result: { kind: "blank" }, - offset: 0, - facing: "left", - }) - const controller = new MonsterAnimationController( - MONSTER_FRAME_CATALOG, - scheduling.random ?? Math.random, - scheduling.nowMs, - ) - let disposed = false - const publish = (nextAnimation: MonsterAnimationOutput): boolean => { - if (sameAnimation(animation(), nextAnimation)) return false - setAnimation(nextAnimation) - return true - } - const syncAnimation = (nextInputs: SidebarCardInputs): void => { - const cardModel = buildSidebarCardModel(nextInputs, settings) - publish( - controller.dispatch({ - kind: "partner_changed", - partner: - cardModel.kind === "partner" - ? { sprite: cardModel.sprite, isDigitama: cardModel.stageNumber === 0 } - : undefined, - }), - ) - } - const poller = createSidebarPollLoop({ - intervalMs: VISUAL_INTERVAL_MS, - load: async () => loadInputs(), - apply: (nextInputs) => { - setInputs(nextInputs) - syncAnimation(nextInputs) - api.renderer.requestRender() - }, - schedule: (callback, intervalMs) => - ( - scheduling.schedulePollTimeout ?? - ((scheduledCallback, delay) => { - const handle = setTimeout(scheduledCallback, delay) - return () => clearTimeout(handle) - }) - )(callback, intervalMs), - clear: (stop) => stop(), - }) - const stopVisualInterval = ( - scheduling.scheduleVisualInterval ?? - ((callback, intervalMs) => { - const handle = setInterval(callback, intervalMs) - return () => clearInterval(handle) - }) - )(() => { - if (disposed) return - const nextAnimation = controller.dispatch({ kind: "tick" }) - publish(nextAnimation) - scheduling.onAnimation?.(nextAnimation) - api.renderer.requestRender() - }, VISUAL_INTERVAL_MS) - - const activity = (): void => { - if (disposed) return - poller.refresh() - if (publish(controller.dispatch({ kind: "activity" }))) api.renderer.requestRender() - } - const unsubscribes = [ - api.event.on("message.updated", activity), - api.event.on("message.part.updated", activity), - api.event.on("session.status", (event: EventSessionStatus) => { - switch (event.properties.status.type) { - case "busy": - case "retry": - activity() - return - case "idle": - return - default: { - const unexpectedStatus: never = event.properties.status - return unexpectedStatus - } - } - }), - ] - - poller.start() - - const disposeLayer = - api.keymap === undefined || api.ui === undefined || api.theme === undefined - ? () => undefined - : scheduling.archiveReader === undefined - ? api.keymap.registerLayer({ - name: "opencode-vpet.layer", - namespace: "opencode-vpet", - commands: [], - }) - : registerVpetCommandLayer({ - api: { keymap: api.keymap, ui: api.ui, theme: api.theme }, - reader: scheduling.archiveReader, - catalog: DIGIMON_CATALOG, - settings, - isDisposed: () => disposed, - }) - - api.lifecycle.onDispose(() => { - if (disposed) return - disposed = true - disposeLayer() - poller.dispose() - stopVisualInterval() - for (const unsubscribe of unsubscribes) unsubscribe() - }) - - api.slots.register({ - slots: { - sidebar_content() { - return ( - buildSidebarCardModel(inputs(), settings)} - animation={animation} - onArtworkWidthChange={(width) => { - if (disposed) return - if (publish(controller.dispatch({ kind: "viewport_resized", width }))) api.renderer.requestRender() - }} - /> - ) - }, - }, - }) - } - -export const tui: TuiPlugin = async (api, options) => { - const reader = createSqliteSidebarSnapshotReader(readerOptions(options)) - const archiveReader = createSqliteVpetArchiveReader(readerOptions(options)) - const settings = await loadGlobalVpetSettings() - await createTui(() => getSidebarCardInputs(reader, DIGIMON_CATALOG), settings, { archiveReader })({ - event: { - on: (...subscription) => { - switch (subscription[0]) { - case "message.updated": - return api.event.on(subscription[0], subscription[1]) - case "message.part.updated": - return api.event.on(subscription[0], subscription[1]) - case "session.status": - return api.event.on(subscription[0], subscription[1]) - } - }, - }, - renderer: api.renderer, - lifecycle: api.lifecycle, - slots: api.slots, - keymap: { registerLayer: api.keymap.registerLayer.bind(api.keymap) }, - ui: { - dialog: { - replace: api.ui.dialog.replace.bind(api.ui.dialog), - clear: api.ui.dialog.clear.bind(api.ui.dialog), - setSize: api.ui.dialog.setSize.bind(api.ui.dialog), - }, - Dialog: api.ui.Dialog, - }, - theme: api.theme, - }) -} - -const plugin = { id: "opencode-vpet", tui } as const - -export default plugin diff --git a/src/tui/vpet-command-layer.tsx b/src/tui/vpet-command-layer.tsx deleted file mode 100644 index 47fbe37..0000000 --- a/src/tui/vpet-command-layer.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import type { TuiDialogStack, TuiKeymap, TuiTheme } from "@opencode-ai/plugin/tui" - -import type { VpetArchiveResult } from "../application/models/vpet-archive.ts" -import type { VpetArchiveReader } from "../application/ports/vpet-archive.ts" -import type { ResolvedVpetSettings } from "../config/types.ts" -import type { DigimonCatalog } from "../data/catalog.ts" -import { buildDexViewModel } from "./dex-view-model.ts" -import { buildHistoryViewModel } from "./history-view-model.ts" -import { VpetDexDialog } from "./vpet-dex-dialog.tsx" -import { VpetHistoryDialog } from "./vpet-history-dialog.tsx" - -export type VpetCommandLayerApi = { - readonly keymap: Pick - readonly ui: { - readonly dialog: Pick - } - readonly theme: TuiTheme -} - -export type VpetCommandLayerDependencies = { - readonly api: VpetCommandLayerApi - readonly reader: VpetArchiveReader - readonly catalog: DigimonCatalog - readonly settings: ResolvedVpetSettings - readonly isDisposed: () => boolean -} - -const unavailableArchive = (): VpetArchiveResult => ({ - kind: "unavailable", - message: "VPet archive is unavailable.", -}) - -const readArchive = (reader: VpetArchiveReader): VpetArchiveResult => { - try { - return reader.getArchive() - } catch { - return unavailableArchive() - } -} - -export const registerVpetCommandLayer = ({ - api, - reader, - catalog, - settings, - isDisposed, -}: VpetCommandLayerDependencies): ReturnType => { - let isDialogOpen = false - const closeDialog = (): void => { - if (isDisposed() || !isDialogOpen) return - isDialogOpen = false - api.ui.dialog.clear() - } - - const openDex = (): void => { - if (isDisposed()) return - const model = buildDexViewModel(readArchive(reader), catalog, settings) - isDialogOpen = true - api.ui.dialog.replace(() => , closeDialog) - api.ui.dialog.setSize("medium") - } - const openHistory = (): void => { - if (isDisposed()) return - const model = buildHistoryViewModel(readArchive(reader), catalog, settings) - isDialogOpen = true - api.ui.dialog.replace( - () => , - closeDialog, - ) - api.ui.dialog.setSize("medium") - } - - const disposeLayer = api.keymap.registerLayer({ - name: "opencode-vpet.layer", - namespace: "opencode-vpet", - commands: [ - { - name: "vpet.dex", - title: "VPet Dex", - description: "Browse the VPet discovery archive.", - category: "VPet", - namespace: "palette", - slashName: "vpet-dex", - run: openDex, - }, - { - name: "vpet.history", - title: "VPet History", - description: "Browse VPet generation history.", - category: "VPet", - namespace: "palette", - slashName: "vpet-history", - run: openHistory, - }, - ], - }) - - return () => { - if (isDialogOpen) { - api.ui.dialog.clear() - isDialogOpen = false - } - disposeLayer() - } -} diff --git a/tests/monster-animation.test.ts b/tests/monster-animation.test.ts deleted file mode 100644 index 0483089..0000000 --- a/tests/monster-animation.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, test } from "bun:test" - -import { MonsterFrame } from "../src/data/monster-frame-catalog.ts" -import { MonsterAnimationController } from "../src/tui/monster-animation.ts" -import { catalogFor, frameContent, partnerChanged } from "./monster-animation-test-utils.ts" - -describe("MonsterAnimationController identity", () => { - test("Given no active partner When it is selected Then animation is blank at the centered origin", () => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - - const output = controller.dispatch(partnerChanged(undefined)) - - expect(output).toEqual({ kind: "blank", result: { kind: "blank" }, offset: 0, facing: "left" }) - expect(controller.get()).toEqual({ kind: "blank" }) - }) - - test.each(["unknown", ""])( - "Given active %p artwork without walk frames When selected Then it is unavailable with the raw key", - (sprite) => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - - const output = controller.dispatch(partnerChanged({ sprite, isDigitama: false })) - - expect(output).toEqual({ - kind: "unavailable", - result: { kind: "unavailable", sprite }, - offset: 0, - facing: "left", - }) - }, - ) - - test("Given an unavailable partner When every event occurs Then no hidden semantic motion accumulates", () => { - const controller = new MonsterAnimationController(catalogFor(new Map())) - const identity = { sprite: "missing", isDigitama: false } as const - - controller.dispatch(partnerChanged(identity)) - const ticked = controller.dispatch({ kind: "tick" }) - const active = controller.dispatch({ kind: "activity" }) - const resized = controller.dispatch({ kind: "viewport_resized", width: 80 }) - - expect(ticked).toEqual(active) - expect(active).toEqual(resized) - expect(resized).toEqual({ - kind: "unavailable", - result: { kind: "unavailable", sprite: "missing" }, - offset: 0, - facing: "left", - }) - }) - - test("Given a non-Digitama partner with two walk frames When selected Then walking starts on walk_1 facing left", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["agumon", ["angry", "walk_1", "walk_2"]]]))) - - const output = controller.dispatch(partnerChanged({ sprite: "agumon", isDigitama: false })) - - expect(output).toEqual({ - kind: "walking", - result: { kind: "frame", frame: new MonsterFrame("agumon:walk_1") }, - offset: 0, - facing: "left", - }) - }) - - test("Given a non-Digitama partner with only walk_2 When selected Then walking starts on its available walk frame", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["sparse", ["happy", "walk_2"]]]))) - - const output = controller.dispatch(partnerChanged({ sprite: "sparse", isDigitama: false })) - - expect(frameContent(output)).toBe("sparse:walk_2") - expect(output.kind).toBe("walking") - }) - - test("Given a stage-zero identity with a non-egg sprite When ticks occur Then explicit Digitama mode loops walk frames at the origin", () => { - const controller = new MonsterAnimationController( - catalogFor(new Map([["unexpected", ["angry", "walk_1", "walk_2"]]])), - ) - - const first = controller.dispatch(partnerChanged({ sprite: "unexpected", isDigitama: true })) - const second = controller.dispatch({ kind: "tick" }) - const wrapped = controller.dispatch({ kind: "tick" }) - - expect([first, second, wrapped].map(frameContent)).toEqual([ - "unexpected:walk_1", - "unexpected:walk_2", - "unexpected:walk_1", - ]) - expect([first, second, wrapped].map(({ kind, offset, facing }) => ({ kind, offset, facing }))).toEqual([ - { kind: "digitama", offset: 0, facing: "left" }, - { kind: "digitama", offset: 0, facing: "left" }, - { kind: "digitama", offset: 0, facing: "left" }, - ]) - }) - - test("Given a selected identity at its second frame When the same identity is selected Then its phase is preserved", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["egg", ["walk_1", "walk_2"]]]))) - const identity = { sprite: "egg", isDigitama: true } as const - - controller.dispatch(partnerChanged(identity)) - controller.dispatch({ kind: "tick" }) - const preserved = controller.dispatch(partnerChanged(identity)) - - expect(frameContent(preserved)).toBe("egg:walk_2") - }) - - test.each([ - ["sprite change", { sprite: "gatomon", isDigitama: false }], - ["Digitama-status change", { sprite: "agumon", isDigitama: true }], - ] as const)( - "Given a walking partner at its second frame When its %s occurs Then all transient state resets", - (_change, nextIdentity) => { - const controller = new MonsterAnimationController( - catalogFor( - new Map([ - ["agumon", ["walk_1", "walk_2"]], - ["gatomon", ["walk_1", "walk_2"]], - ]), - ), - ) - - controller.dispatch(partnerChanged({ sprite: "agumon", isDigitama: false })) - controller.dispatch({ kind: "tick" }) - const reset = controller.dispatch(partnerChanged(nextIdentity)) - - expect(frameContent(reset)).toBe(`${nextIdentity.sprite}:walk_1`) - expect(reset.offset).toBe(0) - expect(reset.facing).toBe("left") - }, - ) - - test("Given compatibility callers When sprite selection and advancement are used Then they observe semantic walk frames", () => { - const controller = new MonsterAnimationController(catalogFor(new Map([["agumon", ["walk_1", "walk_2"]]]))) - - controller.setSprite("agumon") - const first = controller.get() - controller.advance() - const second = controller.get() - - expect(first).toEqual({ kind: "frame", frame: new MonsterFrame("agumon:walk_1") }) - expect(second).toEqual({ kind: "frame", frame: new MonsterFrame("agumon:walk_2") }) - }) -}) diff --git a/tests/persistence/sqlite-vpet-repository.persistence.ts b/tests/persistence/sqlite-vpet-repository.persistence.ts deleted file mode 100644 index 6b37753..0000000 --- a/tests/persistence/sqlite-vpet-repository.persistence.ts +++ /dev/null @@ -1,4 +0,0 @@ -import "./sqlite-vpet-repository.lifecycle.persistence.ts" -import "./sqlite-vpet-repository.receipts.persistence.ts" -import "./sqlite-vpet-repository.control-ledger.persistence.ts" -import "./sqlite-vpet-archive-history.persistence.ts" diff --git a/tsconfig.json b/tsconfig.json index 6e32e0e..73b32d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,13 @@ { - "compilerOptions": { - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "target": "ESNext", - "lib": ["ESNext", "DOM"], - "types": ["bun"], - "jsx": "react-jsx", - "jsxImportSource": "@opentui/solid", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist", "tests"] + "files": [], + "references": [ + { "path": "./packages/digital-pet-core" }, + { "path": "./packages/opencode-digital-pet" }, + { "path": "./packages/opencode-digital-pet/tsconfig.scripts.json" }, + { "path": "./packages/opencode-digital-pet/tsconfig.test.json" }, + { "path": "./packages/cursor-digital-pet" }, + { "path": "./packages/cursor-digital-pet/tsconfig.scripts.json" }, + { "path": "./packages/cursor-digital-pet/tsconfig.test.json" }, + { "path": "./tsconfig.release-metadata.json" } + ] } diff --git a/tsconfig.release-metadata.json b/tsconfig.release-metadata.json index a49dcd4..b1d0c8b 100644 --- a/tsconfig.release-metadata.json +++ b/tsconfig.release-metadata.json @@ -1,8 +1,18 @@ { - "extends": "./tsconfig.json", "compilerOptions": { + "strict": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["bun", "node"], "noEmit": true, - "rootDir": "scripts" + "rootDir": "scripts", + "skipLibCheck": true }, - "include": ["scripts/release-metadata.ts"] + "include": [ + "scripts/release-metadata.ts", + "scripts/plan-merged-release.ts", + "scripts/dispatch-merged-releases.ts", + "scripts/push-release.ts" + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index 5f3ad31..0000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "jsx": "react-jsx", - "jsxImportSource": "@opentui/solid" - }, - "include": ["src", "tests"] -}