├── .depcheckrc.json ├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ └── general-question.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── build-lint-test.yml │ ├── main.yml │ └── publish-release.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── prettier.config.js ├── scripts ├── build-test.sh └── pds-confirm-email.sh ├── src ├── cli.test.ts ├── cli.ts ├── commands │ ├── index.test.ts │ ├── index.ts │ └── migrate │ │ ├── credentials.test.ts │ │ ├── credentials.ts │ │ ├── index.ts │ │ ├── interactive.test.ts │ │ ├── interactive.ts │ │ ├── pipe.test.ts │ │ ├── pipe.ts │ │ └── prompts.ts ├── index.test.ts ├── index.ts ├── main.test.ts ├── main.ts ├── migration │ ├── Migration.test.ts │ ├── Migration.ts │ ├── index.ts │ ├── operations │ │ ├── account.test.ts │ │ ├── account.ts │ │ ├── data.test.ts │ │ ├── data.ts │ │ ├── finalize.test.ts │ │ ├── finalize.ts │ │ ├── identity.test.ts │ │ ├── identity.ts │ │ ├── index.ts │ │ ├── initialize.test.ts │ │ ├── initialize.ts │ │ ├── request-plc-operation.test.ts │ │ └── request-plc-operation.ts │ ├── types.test.ts │ ├── types.ts │ └── utils.ts └── utils │ ├── cli.test.ts │ ├── cli.ts │ ├── handle.test.ts │ ├── handle.ts │ ├── index.ts │ ├── misc.ts │ └── terminal.ts ├── test ├── e2e │ └── cli.test.ts ├── echoMigration.js ├── mocks │ ├── Migration.ts │ ├── credentials.ts │ ├── operations.ts │ └── prompts.ts ├── utils.ts └── utils │ └── cli.ts ├── tsconfig.build.json ├── tsconfig.json ├── vitest.config.e2e.ts └── vitest.config.ts /.depcheckrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignores": [ 3 | "@lavamoat/preinstall-always-fail", 4 | "@metamask/create-release-branch", 5 | "@tsconfig/node22", 6 | "@types/*", 7 | "@vitest/coverage-istanbul", 8 | "bluesky-account-migrator", 9 | "eslint-config-prettier", 10 | "eslint-import-resolver-typescript", 11 | "eslint-plugin-import-x", 12 | "eslint-plugin-jsdoc", 13 | "eslint-plugin-n", 14 | "eslint-plugin-prettier", 15 | "eslint-plugin-promise", 16 | "eslint-plugin-vitest" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @rekmarks 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Report a bug in the project. 4 | labels: bug 5 | --- 6 | 7 | 11 | 12 | ## Description 13 | 14 | 17 | 18 | ## Steps to reproduce 19 | 20 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature request 3 | about: Suggest an idea for the project. 4 | labels: enhancement 5 | --- 6 | 7 | 11 | 12 | ## Description 13 | 14 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ General question 3 | about: For issues that do not fit into the other categories. 4 | labels: question 5 | --- 6 | 7 | 11 | 12 | ## Description 13 | 14 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'weekly' 10 | day: 'sunday' 11 | time: '06:00' # UTC 12 | allow: 13 | - dependency-name: '@atproto/*' 14 | target-branch: 'main' 15 | versioning-strategy: 'increase-if-necessary' 16 | open-pull-requests-limit: 5 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: Build, Lint, and Test 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | prepare: 8 | name: Prepare 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | - name: Install pnpm 14 | uses: pnpm/action-setup@v4 15 | with: 16 | run_install: false 17 | - name: Install Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version-file: '.nvmrc' 21 | cache: 'pnpm' 22 | - name: Install dependencies 23 | run: pnpm install --frozen-lockfile 24 | 25 | build: 26 | name: Build 27 | runs-on: ubuntu-latest 28 | needs: 29 | - prepare 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v4 33 | - name: Install pnpm 34 | uses: pnpm/action-setup@v4 35 | with: 36 | run_install: false 37 | - name: Install Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version-file: '.nvmrc' 41 | cache: 'pnpm' 42 | - name: Install dependencies 43 | run: pnpm install --frozen-lockfile 44 | - name: Build 45 | run: pnpm build 46 | - name: Create build cache 47 | uses: actions/cache@v4 48 | with: 49 | path: ./dist 50 | key: build-${{ github.sha }} 51 | - name: Require clean working directory 52 | shell: bash 53 | run: | 54 | if ! git diff --exit-code; then 55 | echo "Working tree dirty at end of job" 56 | exit 1 57 | fi 58 | 59 | lint: 60 | name: Lint 61 | runs-on: ubuntu-latest 62 | needs: 63 | - prepare 64 | steps: 65 | - name: Checkout repository 66 | uses: actions/checkout@v4 67 | - name: Install pnpm 68 | uses: pnpm/action-setup@v4 69 | with: 70 | run_install: false 71 | - name: Install Node.js 72 | uses: actions/setup-node@v4 73 | with: 74 | node-version-file: '.nvmrc' 75 | cache: 'pnpm' 76 | - name: Install dependencies 77 | run: pnpm install --frozen-lockfile 78 | - name: Lint 79 | run: pnpm lint 80 | - name: Require clean working directory 81 | shell: bash 82 | run: | 83 | if ! git diff --exit-code; then 84 | echo "Working tree dirty at end of job" 85 | exit 1 86 | fi 87 | 88 | test: 89 | name: Test 90 | runs-on: ubuntu-latest 91 | needs: 92 | - prepare 93 | strategy: 94 | matrix: 95 | node-version: [18.x, 20.x, 22.x] 96 | steps: 97 | - name: Checkout repository 98 | uses: actions/checkout@v4 99 | - name: Install pnpm 100 | uses: pnpm/action-setup@v4 101 | with: 102 | run_install: false 103 | - name: Install Node.js ${{ matrix.node-version }} 104 | uses: actions/setup-node@v4 105 | with: 106 | node-version: ${{ matrix.node-version }} 107 | cache: 'pnpm' 108 | - name: Install dependencies 109 | run: pnpm install --frozen-lockfile 110 | - name: Test 111 | run: pnpm test:unit 112 | - name: Require clean working directory 113 | shell: bash 114 | run: | 115 | if ! git diff --exit-code; then 116 | echo "Working tree dirty at end of job" 117 | exit 1 118 | fi 119 | 120 | test-e2e: 121 | name: End-to-end test 122 | runs-on: ubuntu-latest 123 | needs: 124 | - build 125 | strategy: 126 | matrix: 127 | node-version: [18.x, 20.x, 22.x] 128 | steps: 129 | - name: Checkout repository 130 | uses: actions/checkout@v4 131 | - name: Install pnpm 132 | uses: pnpm/action-setup@v4 133 | with: 134 | run_install: false 135 | - name: Install Node.js ${{ matrix.node-version }} 136 | uses: actions/setup-node@v4 137 | with: 138 | node-version: ${{ matrix.node-version }} 139 | cache: 'pnpm' 140 | - name: Restore build cache 141 | uses: actions/cache@v4 142 | with: 143 | path: ./dist 144 | key: build-${{ github.sha }} 145 | fail-on-cache-miss: true 146 | - name: Install dependencies 147 | run: pnpm install --frozen-lockfile 148 | - name: Make test build modifications 149 | run: pnpm build:test:mocks 150 | - name: End-to-end test 151 | run: pnpm test:e2e 152 | - name: Require clean working directory 153 | shell: bash 154 | run: | 155 | if ! git diff --exit-code; then 156 | echo "Working tree dirty at end of job" 157 | exit 1 158 | fi 159 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | check-workflows: 11 | name: Check workflows 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | - name: Download actionlint 17 | id: download-actionlint 18 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/c6bd06256dd700a45e483869bcdcf304239393a6/scripts/download-actionlint.bash) 1.6.27 19 | shell: bash 20 | - name: Check workflow files 21 | run: ${{ steps.download-actionlint.outputs.executable }} -color 22 | shell: bash 23 | 24 | build-lint-test: 25 | name: Build, lint, and test 26 | uses: ./.github/workflows/build-lint-test.yml 27 | 28 | all-jobs-complete: 29 | name: All jobs complete 30 | runs-on: ubuntu-latest 31 | needs: 32 | - check-workflows 33 | - build-lint-test 34 | outputs: 35 | PASSED: ${{ steps.set-output.outputs.PASSED }} 36 | steps: 37 | - name: Set PASSED output 38 | id: set-output 39 | run: echo "PASSED=true" >> "$GITHUB_OUTPUT" 40 | 41 | all-jobs-pass: 42 | name: All jobs pass 43 | if: ${{ always() }} 44 | runs-on: ubuntu-latest 45 | needs: all-jobs-complete 46 | steps: 47 | - name: Check that all jobs have passed 48 | run: | 49 | passed="${{ needs.all-jobs-complete.outputs.PASSED }}" 50 | if [[ $passed != "true" ]]; then 51 | exit 1 52 | fi 53 | 54 | is-release: 55 | name: Determine whether this is a release merge commit 56 | needs: build-lint-test 57 | if: github.event_name == 'push' 58 | runs-on: ubuntu-latest 59 | outputs: 60 | IS_RELEASE: ${{ steps.is-release.outputs.IS_RELEASE }} 61 | steps: 62 | - id: is-release 63 | uses: MetaMask/action-is-release@v2 64 | with: 65 | commit-starts-with: '[version],Release [version],Release/[version]' 66 | 67 | publish-release: 68 | name: Publish release 69 | needs: is-release 70 | if: needs.is-release.outputs.IS_RELEASE == 'true' 71 | permissions: 72 | contents: write 73 | pages: write 74 | id-token: write 75 | uses: ./.github/workflows/publish-release.yml 76 | secrets: 77 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 78 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | workflow_call: 5 | secrets: 6 | NPM_TOKEN: 7 | required: true 8 | 9 | jobs: 10 | publish-release: 11 | name: Publish release 12 | permissions: 13 | contents: write 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | ref: ${{ github.sha }} 20 | - name: Install pnpm 21 | uses: pnpm/action-setup@v4 22 | with: 23 | run_install: false 24 | - name: Install Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: '.nvmrc' 28 | cache: 'pnpm' 29 | - name: Create build cache 30 | uses: actions/cache@v4 31 | id: restore-build 32 | with: 33 | path: ./dist 34 | key: ${{ github.sha }} 35 | - name: Publish release to GitHub 36 | uses: MetaMask/action-publish-release@v3 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | - name: Install dependencies 40 | run: pnpm install --frozen-lockfile 41 | - name: Build 42 | run: pnpm build 43 | 44 | publish-npm-dry-run: 45 | name: Dry run publish to npm 46 | runs-on: ubuntu-latest 47 | needs: publish-release 48 | steps: 49 | - name: Checkout repository 50 | uses: actions/checkout@v4 51 | with: 52 | ref: ${{ github.sha }} 53 | - name: Restore build cache 54 | uses: actions/cache@v4 55 | id: restore-build 56 | with: 57 | path: ./dist 58 | key: ${{ github.sha }} 59 | fail-on-cache-miss: true 60 | - name: Dry run publish 61 | # Omit npm token to perform a dry run. 62 | uses: MetaMask/action-npm-publish@v5 63 | env: 64 | SKIP_PREPACK: true 65 | 66 | publish-npm: 67 | name: Publish to npm 68 | environment: npm-publish 69 | runs-on: ubuntu-latest 70 | needs: publish-npm-dry-run 71 | steps: 72 | - name: Checkout repository 73 | uses: actions/checkout@v4 74 | with: 75 | ref: ${{ github.sha }} 76 | - name: Restore build cache 77 | uses: actions/cache@v4 78 | with: 79 | path: ./dist 80 | key: ${{ github.sha }} 81 | fail-on-cache-miss: true 82 | - name: Publish 83 | uses: MetaMask/action-npm-publish@v5 84 | with: 85 | npm-token: ${{ secrets.NPM_TOKEN }} 86 | env: 87 | SKIP_PREPACK: true 88 | 89 | # TODO: Add docs and docs publishing 90 | # publish-docs: 91 | # name: Publish docs 92 | # needs: publish-npm 93 | # permissions: 94 | # contents: read 95 | # pages: write 96 | # id-token: write 97 | # uses: ./.github/workflows/publish-docs.yml 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.5.0] 11 | 12 | ### Added 13 | 14 | - Add loading indicator to interactive mode ([#77](https://github.com/rekmarks/bluesky-account-migrator.git/pull/77)) 15 | 16 | ### Fixed 17 | 18 | - Ensure custom handle credentials are set correctly in interactive mode ([#53](https://github.com/rekmarks/bluesky-account-migrator.git/pull/53)) 19 | 20 | ## [0.4.0] 21 | 22 | ### Added 23 | 24 | - **BREAKING:** Support custom handles on the new PDS ([#49](https://github.com/rekmarks/bluesky-account-migrator.git/pull/49)) 25 | - This required breaking the format of the `newHandle` credentials property. See documentation 26 | for details. 27 | 28 | ## [0.3.0] 29 | 30 | ### Added 31 | 32 | - Add `--pipe` mode ([#28](https://github.com/rekmarks/bluesky-account-migrator/pull/28)) 33 | - This mode reads from `stdin` and writes to `stdout`. 34 | - Add `--debug` flag ([#29](https://github.com/rekmarks/bluesky-account-migrator/pull/29)) 35 | - This currently just controls whether stack traces are shown. 36 | - Add `serialize()`/`deserialize()` methods to `Migration` class ([#28](https://github.com/rekmarks/bluesky-account-migrator/pull/28)) 37 | - This makes it easier to restore/resume partially completed migrations. 38 | 39 | ### Changed 40 | 41 | - **BREAKING:** Refactor main module exports ([#28](https://github.com/rekmarks/bluesky-account-migrator/pull/28)) 42 | - The `migration` module is removed and its names are instead floated to the top. 43 | - **BREAKING:** Replace `--mode` option with `--interactive` and `--pipe` flags ([#29](https://github.com/rekmarks/bluesky-account-migrator/pull/29)) 44 | - Basically, modes are now mutually exclusive flags instead of a single string option. 45 | - Make credential validation more stringent ([#28](https://github.com/rekmarks/bluesky-account-migrator/pull/28)) 46 | - This should catch errors at earlier stage. 47 | 48 | ## [0.2.1] 49 | 50 | ### Fixed 51 | 52 | - Prevent submission of invalid new handles during interactive migrations 53 | - The Bluesky PDS implementation requires that, for e.g. a PDS hosted at `pds.foo.com`, 54 | all created accounts must have handles of the form `*.pds.foo.com`. 55 | - If you are migrating an existing custom handle, you can restore it after the migration. 56 | 57 | ## [0.2.0] 58 | 59 | ### Changed 60 | 61 | - Make `migrate` the default CLI command 62 | 63 | ### Fixed 64 | 65 | - Fix support for custom handles on old PDS 66 | - Permits handles such as `foo.com` even if your old PDS is something else, like `bsky.social`. 67 | - The full handle must now always be entered, including the PDS URL (if it's in the handle). 68 | 69 | ## [0.1.0] 70 | 71 | ### Added 72 | 73 | - Initial release ([#12](https://github.com/rekmarks/bluesky-account-migrator/pull/12)) 74 | 75 | [Unreleased]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.5.0...HEAD 76 | [0.5.0]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.4.0...v0.5.0 77 | [0.4.0]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.3.0...v0.4.0 78 | [0.3.0]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.2.1...v0.3.0 79 | [0.2.1]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.2.0...v0.2.1 80 | [0.2.0]: https://github.com/rekmarks/bluesky-account-migrator.git/compare/v0.1.0...v0.2.0 81 | [0.1.0]: https://github.com/rekmarks/bluesky-account-migrator.git/releases/tag/v0.1.0 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Erik Marks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bluesky-account-migrator 2 | 3 | A Node.js CLI for migrating Bluesky accounts from one PDS to another. 4 | 5 | > [!WARNING] 6 | > This is community-maintained software. It has no affiliation with Bluesky the company. 7 | > Use at your own risk. 8 | > 9 | > `bluesky-account-migrator` is currently in a beta state. If a migration fails, you will 10 | > have to figure out how to recover on your own. See [troubleshooting](#troubleshooting) 11 | > for more details. 12 | 13 | ## Installation 14 | 15 | ```text 16 | # In a package 17 | npm install bluesky-account-migrator 18 | 19 | # Globally 20 | npm install -g bluesky-account-migrator 21 | ``` 22 | 23 | Or the equivalent incantation using your package manager of choice. 24 | 25 | ## Usage 26 | 27 | Migrating your Bluesky account is a potentially destructive operation that can result in 28 | losing access to the account. This CLI files away some of the rough edges, but it's far 29 | from perfect, and can't help you recover if the migration fails (although you may be able 30 | to do so yourself, see [troubleshooting](#troubleshooting)). 31 | 32 | To get a better understanding of the risks and steps involved in account migration, see 33 | [Bluesky's account migration guide](https://github.com/bluesky-social/pds/blob/9ac9461ce2e4ed7ac66889bb1017662a2f846c98/ACCOUNT_MIGRATION.md). The implementation 34 | of this package is based on the snippet in that guide. 35 | 36 | ### Requirements 37 | 38 | - A `did:plc` Bluesky account 39 | - If you don't know what kind of account you have, you should be good to go. 40 | - A PDS to migrate to 41 | - Ideally, this PDS has SMTP enabled in order to verify your email. 42 | Bluesky the app will ask you to do this for the new account. 43 | - See [Confirming your email](#confirming-your-email) for a potential way 44 | around SMTP. 45 | - Two-factor authentication (2FA) disabled on your Bluesky account. 46 | - This is only required for the duration of the migration. 47 | 48 | ### Gotchas 49 | 50 | #### Custom handles 51 | 52 | A "custom" handle is a handle that is not a subdomain of the PDS URL. For example, 53 | for a account hosted on `bsky.social`, `foo.bsky.social` would be a "normal" handle, 54 | whereas `foo.bar` would be a custom handle. 55 | 56 | Bluesky's PDS implementation currently does not support creating new accounts with 57 | custom handles. However, the handle can be updated after migration. If you submit 58 | a custom handle as your new handle, the CLI will perform this update at the end 59 | of the migration, after activating the new account on the new PDS. 60 | 61 | For further deatils, see e.g. 62 | [this discussion](https://github.com/bluesky-social/atproto/discussions/2909) 63 | and 64 | [this issue](https://github.com/bluesky-social/pds/issues/110#issuecomment-2439866348). 65 | 66 | If you are using [the `pipe` command](#pipe), you will need to provide the temporary and final 67 | handles in the passed-in credentials, for example: 68 | 69 | ```json5 70 | { 71 | "credentials": { 72 | // The other credentials as normal 73 | // ... 74 | "newHandle": { 75 | "temporaryHandle": "new-temp.pds.com" 76 | "finalHandle": "foo.com", 77 | }, 78 | } 79 | } 80 | ``` 81 | 82 | #### Confirming your email 83 | 84 | Even though you probably already confirmed your email on the old PDS, Bluesky will want you 85 | to confirm your email on the new PDS as well. (There's not really a point to this, but this is 86 | how it works.) 87 | 88 | To do this, you can either configure SMTP on your PDS, _or_ copypaste 89 | [this script](https://github.com/rekmarks/bluesky-account-migrator/blob/main/scripts/pds-confirm-email.sh) 90 | to your PDS and run it with the DID of your migrated account. 91 | 92 | ### CLI 93 | 94 | The CLI has a single command `migrate`, which you can run using e.g. `npx`: 95 | 96 | ```text 97 | npx bluesky-account-migrator migrate [--mode ] 98 | ``` 99 | 100 | The CLI has two modes. 101 | 102 | #### `interactive` 103 | 104 | This will interactively walk you through migrating your Bluesky account from one PDS to 105 | another. It will collect most of the necessary information upfront, such as the PDS URLs, 106 | account handles, etc., then ask if you want to start the migration: 107 | 108 | ```text 109 | ? Perform the migration with these credentials? (Y/n) 110 | ``` 111 | 112 | Migrating your account requires completing an email challenge. Assuming all goes well, 113 | the migration will run until the challenge email has been sent. You will have to retrieve 114 | the confirmation token in this email and provide it to the CLI to complete the migration: 115 | 116 | ```text 117 | An email should have been sent to the old account's email address. 118 | 119 | ? Enter the confirmation token from the challenge email 120 | ``` 121 | 122 | If the challenge token is correct, the migration should complete successfully. 123 | At the end of the migration, the private recovery key will be printed to the terminal. 124 | You must save this key in a secure location, or you could lose access to your account. 125 | 126 | #### `pipe` 127 | 128 | This causes the CLI to read from `stdin` and write to `stdout`. It will only output 129 | the results of running the migraiton to `stdout`, and any errors or other logs will 130 | be written to `stderr`. 131 | 132 | Given a file `credentials.json` with the following contents: 133 | 134 | ```json 135 | { 136 | "credentials": { 137 | "oldPdsUrl": "https://bsky.social", 138 | "newPdsUrl": "https://pds.com", 139 | "oldHandle": "old.handle", 140 | "oldPassword": "oldpass123", 141 | "newHandle": { "handle": "new.pds.com" }, 142 | "newEmail": "new@email.com", 143 | "newPassword": "newpass123", 144 | "inviteCode": "invite-123" 145 | } 146 | } 147 | ``` 148 | 149 | The CLI can then be invoked as follows: 150 | 151 | ```bash 152 | cat credentials.json | npx bluesky-account-migrator --mode pipe > result.json 153 | ``` 154 | 155 | If the credentials are correct, `result.json` should look like this: 156 | 157 | ```json 158 | { 159 | "state": "RequestedPlcOperation", 160 | "credentials": { 161 | "oldPdsUrl": "https://bsky.social", 162 | "newPdsUrl": "https://pds.com", 163 | "oldHandle": "old.handle", 164 | "oldPassword": "oldpass123", 165 | "newHandle": { "handle": "new.pds.com" }, 166 | "newEmail": "new@email.com", 167 | "newPassword": "newpass123", 168 | "inviteCode": "invite-123" 169 | } 170 | } 171 | ``` 172 | 173 | In this state, the migration should have dispatched a challenge email to the email 174 | associated with the account on the old PDS. Once you have retrieved the confirmation 175 | token from the email, you can complete the migration like so: 176 | 177 | ```bash 178 | cat result.json | \ 179 | jq '. + {"confirmationToken": ""}' | \ 180 | npx bluesky-account-migrator migrate --mode pipe > \ 181 | finalResult.json 182 | ``` 183 | 184 | If the confirmation token is correct, `finalResult.json` should look like this: 185 | 186 | ```json 187 | { 188 | "state": "Finalized", 189 | "credentials": { 190 | "oldPdsUrl": "https://bsky.social", 191 | "newPdsUrl": "https://pds.com", 192 | "oldHandle": "old.handle", 193 | "oldPassword": "oldpass123", 194 | "newHandle": { "handle": "new.pds.com" }, 195 | "newEmail": "new@email.com", 196 | "newPassword": "newpass123", 197 | "inviteCode": "invite-123" 198 | }, 199 | "confirmationToken": "", 200 | "newPrivateKey": "" 201 | } 202 | ``` 203 | 204 | > [!IMPORTANT] 205 | > If the migration fails the CLI will exit with a non-zero error code, but the result 206 | > will still be written to `stdout`. This enables retrieving the generated private key, 207 | > if any. 208 | > 209 | > To retrieve the migration output, you **must** ensure that your script handles failures 210 | > appropriately. For example, you cannot naively use `set -e` in Bash, since that would 211 | > prevent capturing the output on failure. 212 | > Instead, capture the output and check the exit code separately: 213 | > 214 | > ```bash 215 | > output=$(cat result.json | npx bluesky-account-migrator migrate --mode pipe) 216 | > exit_code=$? 217 | > 218 | > if [ $exit_code -ne 0 ]; then 219 | > echo "Migration failed with exit code $exit_code" >&2 220 | > echo "Output was:" >&2 221 | > echo "$output" >&2 222 | > exit $exit_code 223 | > fi 224 | > 225 | > echo "$output" > finalResult.json 226 | > ``` 227 | 228 | ### API 229 | 230 | The migration is implemented as a state machine in the form of the `Migration` class. 231 | You can run a migration programmatically as follows: 232 | 233 | ```ts 234 | import { Migration, MigrationState } from 'bluesky-account-migrator'; 235 | 236 | const credentials = { 237 | oldPdsUrl: 'https://bsky.social', 238 | oldHandle: 'old.handle', 239 | oldPassword: 'oldpass123', 240 | inviteCode: 'invite-123', 241 | newPdsUrl: 'https://pds.com', 242 | newHandle: { handle: 'new.pds.com' }, 243 | newEmail: 'new@email.com', 244 | newPassword: 'newpass123', 245 | }; 246 | 247 | const migration = new Migration({ credentials }); 248 | 249 | let result = await migration.run(); 250 | if (result !== 'RequestedPlcOperation') { 251 | // Something has gone extremely wrong if this happens 252 | throw new Error('unexpected migration state'); 253 | } 254 | 255 | // You have to get this from the challenge email and make it available 256 | // to your program somehow 257 | const confirmationToken = '...'; 258 | migration.confirmationToken = confirmationToken; 259 | 260 | result = await migration.run(); 261 | if (result !== 'Finalized') { 262 | // Again, something has gone extremely wrong if this happens 263 | throw new Error('unexpected migration state'); 264 | } 265 | 266 | // This is the recovery private key for the account, which must be stored 267 | // somewhere or risk the loss of the account 268 | storeSomewhereSafe(migration.newPrivateKey); 269 | ``` 270 | 271 | If you need to persist an unfinished migration, e.g. on failure or when getting 272 | the confirmation token, you can use the `serialize()`/`deserialize()` methods: 273 | 274 | ```ts 275 | const migration = new Migration({ credentials }); 276 | await migration.run(); 277 | 278 | // NOTE: This will output the user's passwords and private key in plaintext, 279 | // if present. 280 | const serialized = migration.serialize(); 281 | saveMigration(JSON.stringify(serialized, null, 2)); 282 | 283 | // Later 284 | const savedMigration = loadMigration(); 285 | const migration = Migration.deserialize(JSON.parse(savedMigration)); 286 | migration.confirmationToken = confirmationToken; 287 | await migration.run(); 288 | ``` 289 | 290 | ### Troubleshooting 291 | 292 | > [!IMPORTANT] 293 | > If you encounter any problems with `bluesky-account-migrator`, please 294 | > [file an issue](https://github.com/rekmarks/bluesky-account-migrator/issues/new). 295 | 296 | #### Migration failure 297 | 298 | If your migration fails, you are alone in strange territory. However, all is not lost. 299 | While `bluesky-account-migrator` is not (yet) equipped to resume partial migrations, 300 | the error should tell you where it failed. In addition, the migration is implemented 301 | as a state machine, and you should be able to figure out what's left to do by consulting 302 | [this file](./src/migration/Migration.ts). In brief, each state maps to an "operation", 303 | which is essentially a function wrapping a set of logically associated API calls. By 304 | identifying the error and the remaining API calls, you can likely compose a script that 305 | completes the rest of the migration. 306 | 307 | #### Other issues 308 | 309 | - Missing data / blobs 310 | - It may be the case that your migrated account is missing data / blobs. 311 | - You can verify this manually using `com.atproto.server.checkAccountStatus`. 312 | - Finding missing data is currently out of scope for this CLI. 313 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import base, { createConfig } from '@metamask/eslint-config'; 2 | import nodejs from '@metamask/eslint-config-nodejs'; 3 | import typescript from '@metamask/eslint-config-typescript'; 4 | import vitest from '@metamask/eslint-config-vitest'; 5 | 6 | const config = createConfig([ 7 | { 8 | ignores: ['**/dist/', 'docs/'], 9 | }, 10 | 11 | { 12 | extends: [base, nodejs], 13 | 14 | languageOptions: { 15 | sourceType: 'module', 16 | parserOptions: { 17 | tsconfigRootDir: import.meta.dirname, 18 | project: ['./tsconfig.json'], 19 | }, 20 | }, 21 | 22 | settings: { 23 | 'import-x/extensions': ['.js', '.mjs'], 24 | }, 25 | 26 | rules: { 27 | 'no-plusplus': 'off', 28 | 'no-void': 'off', 29 | 'prefer-template': 'off', 30 | 31 | 'import-x/no-useless-path-segments': 'off', 32 | 33 | 'jsdoc/require-description': 'off', 34 | 'jsdoc/require-jsdoc': [ 35 | 'error', 36 | { 37 | publicOnly: true, 38 | require: { 39 | FunctionDeclaration: false, 40 | }, 41 | }, 42 | ], 43 | 44 | 'n/hashbang': [ 45 | 'error', 46 | { 47 | additionalExecutables: ['src/main.ts'], 48 | }, 49 | ], 50 | }, 51 | }, 52 | 53 | { 54 | files: ['**/*.ts'], 55 | extends: [typescript], 56 | 57 | rules: { 58 | '@typescript-eslint/ban-ts-comment': [ 59 | 'error', 60 | { 61 | 'ts-check': false, 62 | 'ts-expect-error': false, 63 | 'ts-ignore': true, 64 | 'ts-nocheck': true, 65 | }, 66 | ], 67 | '@typescript-eslint/explicit-function-return-type': 'off', 68 | '@typescript-eslint/prefer-reduce-type-parameter': 'off', 69 | '@typescript-eslint/unbound-method': 'off', 70 | }, 71 | }, 72 | 73 | { 74 | files: ['**/*.cjs'], 75 | 76 | languageOptions: { 77 | sourceType: 'script', 78 | }, 79 | }, 80 | 81 | { 82 | files: ['**/*.test.ts', '**/*.test.js'], 83 | 84 | extends: [vitest], 85 | }, 86 | 87 | { 88 | files: ['test/**/*'], 89 | rules: { 90 | 'n/no-process-env': 'off', 91 | }, 92 | }, 93 | ]); 94 | 95 | export default config; 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bluesky-account-migrator", 3 | "version": "0.5.0", 4 | "description": "A CLI for migrating Bluesky accounts from one PDS to another.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/rekmarks/bluesky-account-migrator.git" 8 | }, 9 | "keywords": [ 10 | "bluesky", 11 | "pds", 12 | "atproto", 13 | "cli" 14 | ], 15 | "author": "Erik Marks ", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/rekmarks/bluesky-account-migrator/issues" 19 | }, 20 | "homepage": "https://github.com/rekmarks/bluesky-account-migrator#readme", 21 | "sideEffects": false, 22 | "exports": { 23 | ".": { 24 | "import": { 25 | "types": "./dist/index.d.mts", 26 | "default": "./dist/index.mjs" 27 | }, 28 | "require": { 29 | "types": "./dist/index.d.cts", 30 | "default": "./dist/index.cjs" 31 | } 32 | }, 33 | "./package.json": "./package.json" 34 | }, 35 | "main": "dist/index.cjs", 36 | "types": "dist/index.d.cts", 37 | "type": "module", 38 | "files": [ 39 | "dist/" 40 | ], 41 | "bin": { 42 | "bam": "dist/main.mjs" 43 | }, 44 | "scripts": { 45 | "build": "pnpm build:ts-bridge && pnpm build:chmod", 46 | "build:ts-bridge": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", 47 | "build:chmod": "chmod +x ./dist/main.mjs && chmod +x ./dist/main.cjs", 48 | "build:test": "pnpm build && pnpm build:test:mocks", 49 | "build:test:mocks": "./scripts/build-test.sh", 50 | "build:qa": "pnpm build && pnpm build:qa:mocks", 51 | "build:qa:mocks": "esbuild ./test/mocks/Migration.ts --outfile=./dist/migration/Migration.mjs --bundle --format=esm --target=es2022 --platform=node --packages=external", 52 | "lint": "pnpm lint:eslint && pnpm lint:misc --check && pnpm lint:types && pnpm lint:changelog && pnpm lint:dependencies --check", 53 | "lint:changelog": "auto-changelog validate --prettier --repo https://github.com/rekmarks/bluesky-account-migrator.git", 54 | "lint:dependencies": "depcheck && pnpm dedupe", 55 | "lint:eslint": "eslint . --cache", 56 | "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.yml' '!CHANGELOG.md' '!pnpm-lock.yaml' --ignore-path .gitignore --no-error-on-unmatched-pattern", 57 | "lint:types": "tsc --noEmit", 58 | "lint:fix": "pnpm lint:eslint --fix && pnpm lint:misc --write && pnpm lint:types && pnpm lint:changelog && pnpm lint:dependencies", 59 | "test": "pnpm test:unit && pnpm test:e2e", 60 | "test:unit": "vitest run", 61 | "test:e2e": "vitest run -c vitest.config.e2e.ts" 62 | }, 63 | "dependencies": { 64 | "@atproto/api": "^0.14.19", 65 | "@atproto/crypto": "^0.4.2", 66 | "@inquirer/core": "^10.1.4", 67 | "@inquirer/prompts": "^7.2.0", 68 | "boxen": "^8.0.1", 69 | "uint8arrays": "^5.1.0", 70 | "wrap-ansi": "^9.0.0", 71 | "yargs": "^17.7.2", 72 | "yocto-spinner": "^0.2.1", 73 | "yoctocolors": "^2.1.1" 74 | }, 75 | "devDependencies": { 76 | "@atproto/xrpc": "^0.6.5", 77 | "@lavamoat/preinstall-always-fail": "^2.1.0", 78 | "@metamask/auto-changelog": "^4.0.0", 79 | "@metamask/create-release-branch": "^3.1.0", 80 | "@metamask/eslint-config": "^14.0.0", 81 | "@metamask/eslint-config-nodejs": "^14.0.0", 82 | "@metamask/eslint-config-typescript": "^14.0.0", 83 | "@metamask/eslint-config-vitest": "^1.0.0", 84 | "@ts-bridge/cli": "^0.6.1", 85 | "@tsconfig/node22": "^22.0.0", 86 | "@types/node": "^22.10.1", 87 | "@types/yargs": "^17.0.33", 88 | "@vitest/coverage-istanbul": "^2.1.8", 89 | "depcheck": "^1.4.7", 90 | "esbuild": "^0.24.0", 91 | "eslint": "^9.17.0", 92 | "eslint-config-prettier": "^9.1.0", 93 | "eslint-import-resolver-typescript": "^3.7.0", 94 | "eslint-plugin-import-x": "^4.5.0", 95 | "eslint-plugin-jsdoc": "^50.6.1", 96 | "eslint-plugin-n": "^17.15.0", 97 | "eslint-plugin-prettier": "^5.2.1", 98 | "eslint-plugin-promise": "^7.2.1", 99 | "eslint-plugin-vitest": "^0.5.4", 100 | "prettier": "^3.4.2", 101 | "typescript": "^5.7.2", 102 | "vitest": "^2.1.8", 103 | "zod": "^3.24.1" 104 | }, 105 | "engines": { 106 | "node": "^18.20 || ^20.18 || >=22" 107 | }, 108 | "publishConfig": { 109 | "access": "public", 110 | "registry": "https://registry.npmjs.org/" 111 | }, 112 | "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c", 113 | "pnpm": { 114 | "onlyBuiltDependencies": [] 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // All of these are defaults except singleQuote, but we specify them 2 | // for explicitness 3 | const config = { 4 | quoteProps: 'as-needed', 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | }; 9 | export default config; 10 | -------------------------------------------------------------------------------- /scripts/build-test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | args=(--bundle --format=esm --target=es2022 --platform=node --packages=external) 8 | credentials_file="./dist/commands/migrate/credentials.mjs" 9 | 10 | pnpm esbuild ./test/mocks/credentials.ts --outfile="$credentials_file" "${args[@]}" 11 | # For some reason esbuild imports vi from vitest. This blows up, so we remove it. 12 | tmpfile=$(mktemp) 13 | sed '/import.*vi.*from.*vitest.*/d' "$credentials_file" > "$tmpfile" 14 | mv "$tmpfile" "$credentials_file" 15 | 16 | pnpm esbuild ./test/mocks/operations.ts --outfile=./dist/migration/operations/index.mjs "${args[@]}" 17 | 18 | pnpm esbuild ./test/mocks/prompts.ts --outfile=./dist/commands/migrate/prompts.mjs "${args[@]}" 19 | -------------------------------------------------------------------------------- /scripts/pds-confirm-email.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | # Check if running as root/sudo 8 | if [ "$EUID" -ne 0 ]; then 9 | echo "Error: This script must be run with sudo privileges" 10 | echo "Usage: sudo $0 " 11 | exit 1 12 | fi 13 | 14 | # Check if DID parameter is provided 15 | if [ $# -ne 1 ]; then 16 | echo "Usage: sudo $0 " 17 | exit 1 18 | fi 19 | 20 | # Trim any trailing whitespace or newlines from the input 21 | ACCOUNT_DID=$(echo "$1" | tr -d '\n') 22 | # Format timestamp as ISO string with Z suffix 23 | CURRENT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ") 24 | DB_PATH="/pds/account.sqlite" 25 | 26 | # Check if database exists 27 | if [ ! -f "$DB_PATH" ]; then 28 | echo "Error: Database not found at $DB_PATH" 29 | exit 1 30 | fi 31 | 32 | # Update the emailConfirmedAt field for the specified account 33 | # No need for sudo here since we're already running as root 34 | sqlite3 "$DB_PATH" < { 8 | // Proxy whose every property is a self-returning function, 9 | // except for "parseAsync", which returns a promise. 10 | const proxy: unknown = new Proxy( 11 | {}, 12 | { 13 | get: (_target, prop) => { 14 | if (prop === 'parseAsync') { 15 | return async () => Promise.resolve(); 16 | } 17 | return () => proxy; 18 | }, 19 | apply: () => proxy, 20 | }, 21 | ); 22 | return { 23 | default: vi.fn(() => proxy), 24 | }; 25 | }); 26 | 27 | // Mock yargs/helpers 28 | vi.mock('yargs/helpers', () => ({ 29 | hideBin: vi.fn((args) => args.slice(2)), 30 | })); 31 | 32 | // Coverage smoke test 33 | describe('cli', () => { 34 | // @ts-expect-error 35 | const mockCommands: Commands = [{ name: 'foo' }]; 36 | 37 | it('should initialize yargs with correct configuration', async () => { 38 | const argv = ['node', 'script.js', 'test']; 39 | await cli(argv, mockCommands); 40 | 41 | expect(vi.mocked(yargs)).toHaveBeenCalledTimes(1); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | 4 | import type { Commands } from './commands/index.js'; 5 | import packageJson from 'bluesky-account-migrator/package.json' assert { type: 'json' }; 6 | 7 | const usageDescription = ` 8 | Usage: 9 | $0 [migrate] [options] 10 | bam [migrate] [options]`; 11 | 12 | export async function cli(argv: string[], commands: Commands) { 13 | await yargs(hideBin(argv)) 14 | .scriptName('bluesky-account-migrator') 15 | .strict() 16 | .usage(usageDescription) 17 | .version(packageJson.version) 18 | .help() 19 | .showHelpOnFail(false) 20 | .alias('help', 'h') 21 | .alias('version', 'v') 22 | .option('debug', { 23 | describe: 'Show error stack traces', 24 | type: 'boolean', 25 | default: false, 26 | }) 27 | .command(commands) 28 | .demandCommand(1) 29 | .parseAsync(); 30 | } 31 | -------------------------------------------------------------------------------- /src/commands/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { commands } from './index.js'; 4 | import { migrateCommand } from './migrate/index.js'; 5 | 6 | describe('commands', () => { 7 | it('should export an array containing the migrate command', () => { 8 | expect(commands).toStrictEqual([migrateCommand]); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { migrateCommand } from './migrate/index.js'; 2 | 3 | export const commands = [migrateCommand]; 4 | 5 | export type Commands = typeof commands; 6 | -------------------------------------------------------------------------------- /src/commands/migrate/credentials.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | 3 | import { 4 | getCredentialsInteractive, 5 | promptMessages, 6 | stripHandlePrefix, 7 | validateUrl, 8 | validateString, 9 | validateEmail, 10 | validateHandle, 11 | validateTemporaryHandle, 12 | } from './credentials.js'; 13 | import * as prompts from './prompts.js'; 14 | 15 | vi.mock('boxen', () => ({ 16 | default: vi.fn(), 17 | })); 18 | 19 | vi.mock('yoctocolors', () => ({ 20 | bold: vi.fn((value) => value), 21 | green: vi.fn((value) => value), 22 | })); 23 | 24 | vi.mock('./prompts.js', () => ({ 25 | input: vi.fn(), 26 | password: vi.fn(), 27 | confirm: vi.fn(), 28 | })); 29 | 30 | describe('getCredentialsInteractive', () => { 31 | it('should collect credentials in correct order with direct PDS handle', async () => { 32 | const mockInputs = { 33 | oldPdsUrl: 'https://bsky.social', 34 | oldHandle: 'user.bsky.social', 35 | oldPassword: 'oldpass123', 36 | inviteCode: 'invite123', 37 | newPdsUrl: 'https://new-pds.social', 38 | newHandle: 'user.new-pds.social', 39 | newEmail: 'user@example.com', 40 | newPassword: 'newpass123', 41 | }; 42 | 43 | vi.mocked(prompts.input) 44 | .mockResolvedValueOnce(mockInputs.oldPdsUrl) 45 | .mockResolvedValueOnce(mockInputs.oldHandle) 46 | .mockResolvedValueOnce(mockInputs.inviteCode) 47 | .mockResolvedValueOnce(mockInputs.newPdsUrl) 48 | .mockResolvedValueOnce(mockInputs.newHandle) 49 | .mockResolvedValueOnce(mockInputs.newEmail); 50 | 51 | vi.mocked(prompts.password) 52 | .mockResolvedValueOnce(mockInputs.oldPassword) 53 | .mockResolvedValueOnce(mockInputs.newPassword) 54 | .mockResolvedValueOnce(mockInputs.newPassword); // confirmPassword 55 | 56 | // "Complete migration with these credentials?" 57 | vi.mocked(prompts.confirm).mockResolvedValueOnce(true); 58 | 59 | const credentials = await getCredentialsInteractive(); 60 | 61 | // Verify prompts were called in correct order 62 | expect(prompts.input).toHaveBeenNthCalledWith( 63 | 1, 64 | expect.objectContaining({ 65 | message: promptMessages.oldPdsUrl, 66 | }), 67 | ); 68 | expect(prompts.input).toHaveBeenNthCalledWith( 69 | 2, 70 | expect.objectContaining({ 71 | message: promptMessages.oldHandle, 72 | }), 73 | ); 74 | expect(prompts.password).toHaveBeenNthCalledWith( 75 | 1, 76 | expect.objectContaining({ 77 | message: promptMessages.oldPassword, 78 | }), 79 | ); 80 | expect(prompts.input).toHaveBeenNthCalledWith( 81 | 3, 82 | expect.objectContaining({ 83 | message: promptMessages.inviteCode, 84 | }), 85 | ); 86 | expect(prompts.input).toHaveBeenNthCalledWith( 87 | 4, 88 | expect.objectContaining({ 89 | message: promptMessages.newPdsUrl, 90 | }), 91 | ); 92 | expect(prompts.input).toHaveBeenNthCalledWith( 93 | 5, 94 | expect.objectContaining({ 95 | message: promptMessages.newHandle('new-pds.social'), 96 | }), 97 | ); 98 | expect(prompts.input).toHaveBeenNthCalledWith( 99 | 6, 100 | expect.objectContaining({ 101 | message: promptMessages.newEmail, 102 | }), 103 | ); 104 | 105 | // Verify returned credentials 106 | expect(credentials).toStrictEqual({ 107 | oldPdsUrl: mockInputs.oldPdsUrl, 108 | oldHandle: mockInputs.oldHandle, 109 | oldPassword: mockInputs.oldPassword, 110 | inviteCode: mockInputs.inviteCode, 111 | newPdsUrl: mockInputs.newPdsUrl, 112 | newHandle: { handle: mockInputs.newHandle }, 113 | newEmail: mockInputs.newEmail, 114 | newPassword: mockInputs.newPassword, 115 | }); 116 | }); 117 | 118 | it('should handle temporary handle flow for custom domains', async () => { 119 | const mockInputs = { 120 | oldPdsUrl: 'https://bsky.social', 121 | oldHandle: 'user.bsky.social', 122 | oldPassword: 'oldpass123', 123 | inviteCode: 'invite123', 124 | newPdsUrl: 'https://new-pds.social', 125 | newHandle: 'user.custom.com', 126 | newTemporaryHandle: 'user-temp.new-pds.social', 127 | newEmail: 'user@example.com', 128 | newPassword: 'newpass123', 129 | }; 130 | 131 | vi.mocked(prompts.input) 132 | .mockResolvedValueOnce(mockInputs.oldPdsUrl) 133 | .mockResolvedValueOnce(mockInputs.oldHandle) 134 | .mockResolvedValueOnce(mockInputs.inviteCode) 135 | .mockResolvedValueOnce(mockInputs.newPdsUrl) 136 | .mockResolvedValueOnce(mockInputs.newHandle) 137 | .mockResolvedValueOnce(mockInputs.newTemporaryHandle) 138 | .mockResolvedValueOnce(mockInputs.newEmail); 139 | 140 | vi.mocked(prompts.password) 141 | .mockResolvedValueOnce(mockInputs.oldPassword) 142 | .mockResolvedValueOnce(mockInputs.newPassword) 143 | .mockResolvedValueOnce(mockInputs.newPassword); 144 | 145 | vi.mocked(prompts.confirm).mockResolvedValueOnce(true); 146 | 147 | const credentials = await getCredentialsInteractive(); 148 | 149 | // Verify temporary handle prompt was shown 150 | expect(prompts.input).toHaveBeenCalledWith( 151 | expect.objectContaining({ 152 | message: promptMessages.newTemporaryHandle, 153 | }), 154 | ); 155 | 156 | // Verify returned credentials include both handles 157 | expect(credentials).toStrictEqual({ 158 | oldPdsUrl: mockInputs.oldPdsUrl, 159 | oldHandle: mockInputs.oldHandle, 160 | oldPassword: mockInputs.oldPassword, 161 | inviteCode: mockInputs.inviteCode, 162 | newPdsUrl: mockInputs.newPdsUrl, 163 | newHandle: { 164 | temporaryHandle: mockInputs.newTemporaryHandle, 165 | finalHandle: mockInputs.newHandle, 166 | }, 167 | newEmail: mockInputs.newEmail, 168 | newPassword: mockInputs.newPassword, 169 | }); 170 | }); 171 | 172 | it('should return undefined if user does not confirm', async () => { 173 | const mockInputs = { 174 | oldPdsUrl: 'https://bsky.social', 175 | oldHandle: 'user.bsky.social', 176 | oldPassword: 'test123', 177 | inviteCode: 'test-invite', 178 | newPdsUrl: 'https://new-pds.social', 179 | newHandle: 'user.new-pds.social', 180 | newEmail: 'test@example.com', 181 | newPassword: 'newpass123', 182 | }; 183 | 184 | vi.mocked(prompts.input) 185 | .mockResolvedValueOnce(mockInputs.oldPdsUrl) 186 | .mockResolvedValueOnce(mockInputs.oldHandle) 187 | .mockResolvedValueOnce(mockInputs.inviteCode) 188 | .mockResolvedValueOnce(mockInputs.newPdsUrl) 189 | .mockResolvedValueOnce(mockInputs.newHandle) 190 | .mockResolvedValueOnce(mockInputs.newEmail); 191 | 192 | vi.mocked(prompts.password) 193 | .mockResolvedValueOnce(mockInputs.oldPassword) 194 | .mockResolvedValueOnce(mockInputs.newPassword) 195 | .mockResolvedValueOnce(mockInputs.newPassword); 196 | 197 | vi.mocked(prompts.confirm).mockResolvedValueOnce(false); 198 | 199 | const credentials = await getCredentialsInteractive(); 200 | 201 | expect(credentials).toBeUndefined(); 202 | }); 203 | }); 204 | 205 | describe('stripHandlePrefix', () => { 206 | it('should strip the @ prefix from the handle', () => { 207 | expect(stripHandlePrefix('@foo')).toBe('foo'); 208 | }); 209 | 210 | it('should return the same string if no @ prefix exists', () => { 211 | expect(stripHandlePrefix('foo')).toBe('foo'); 212 | }); 213 | 214 | it('should only strip @ from the start of the string', () => { 215 | expect(stripHandlePrefix('foo@bar')).toBe('foo@bar'); 216 | expect(stripHandlePrefix('@foo@bar')).toBe('foo@bar'); 217 | }); 218 | 219 | it('should handle empty strings', () => { 220 | expect(stripHandlePrefix('')).toBe(''); 221 | expect(stripHandlePrefix('@')).toBe(''); 222 | }); 223 | 224 | it('should handle multiple @ prefixes', () => { 225 | expect(stripHandlePrefix('@@foo')).toBe('@foo'); 226 | }); 227 | }); 228 | 229 | describe('validateUrl', () => { 230 | it.each([ 231 | 'http://example.com', 232 | 'https://sub.example.com', 233 | 'https://example.com/path', 234 | ])('should accept valid URL: %s', (url) => { 235 | expect(validateUrl(url)).toBe(true); 236 | }); 237 | 238 | it.each([ 239 | ['not-a-url'], 240 | ['ftp://example.com'], 241 | ['example.com'], 242 | ['http://'], 243 | ['https://'], 244 | [''], 245 | ])('should reject invalid URL: %s', (url) => { 246 | expect(validateUrl(url)).toBe('Must be a valid HTTP or HTTPS URL string'); 247 | }); 248 | }); 249 | 250 | describe('validateString', () => { 251 | it.each(['hello', ' ', 'any non-empty string'])( 252 | 'should accept non-empty string: %s', 253 | (str) => { 254 | expect(validateString(str)).toBe(true); 255 | }, 256 | ); 257 | 258 | it('should reject empty string', () => { 259 | expect(validateString('')).toBe('Must be a non-empty string'); 260 | }); 261 | }); 262 | 263 | describe('validateEmail', () => { 264 | it.each([ 265 | 'user@example.com', 266 | 'user+tag@sub.example.com', 267 | 'complex.email+tag@sub.domain.com', 268 | ])('should accept valid email: %s', (email) => { 269 | expect(validateEmail(email)).toBe(true); 270 | }); 271 | 272 | it.each([ 273 | ['not-an-email'], 274 | ['@example.com'], 275 | ['user@'], 276 | [''], 277 | ['missing@tld'], 278 | ['spaces in@email.com'], 279 | ])('should reject invalid email: %s', (email) => { 280 | expect(validateEmail(email)).toBe('Must be a valid email address'); 281 | }); 282 | }); 283 | 284 | describe('validateHandle', () => { 285 | it.each([ 286 | 'user.bsky.social', 287 | 'user.custom.com', 288 | 'complex-user.domain.social', 289 | ])('should accept valid handle: %s', (handle) => { 290 | expect(validateHandle(handle)).toBe(true); 291 | }); 292 | 293 | it.each([ 294 | ['not-a-handle'], 295 | ['@user.com'], 296 | [''], 297 | ['no-dots'], 298 | ['invalid..dots'], 299 | ])('should reject invalid handle: %s', (handle) => { 300 | expect(validateHandle(handle)).toBe('Must be a valid handle'); 301 | }); 302 | }); 303 | 304 | describe('validateTemporaryHandle', () => { 305 | it.each([ 306 | ['user.bsky.social', 'bsky.social'], 307 | ['temp-user.bsky.social', 'bsky.social'], 308 | ['complex-temp.bsky.social', 'bsky.social'], 309 | ])( 310 | 'should accept valid temporary handle: %s for PDS: %s', 311 | (handle, hostname) => { 312 | expect(validateTemporaryHandle(handle, hostname)).toBe(true); 313 | }, 314 | ); 315 | 316 | it.each([ 317 | ['user.other.social', 'bsky.social'], 318 | ['user.custom.com', 'bsky.social'], 319 | ['not-a-handle', 'bsky.social'], 320 | ['', 'bsky.social'], 321 | ['wrong.domain.social', 'bsky.social'], 322 | ])( 323 | 'should reject invalid temporary handle: %s for PDS: %s', 324 | (handle, hostname) => { 325 | expect(validateTemporaryHandle(handle, hostname)).toBe( 326 | 'Must be a valid handle and a subdomain of the new PDS hostname', 327 | ); 328 | }, 329 | ); 330 | }); 331 | -------------------------------------------------------------------------------- /src/commands/migrate/credentials.ts: -------------------------------------------------------------------------------- 1 | import boxen from 'boxen'; 2 | import { bold, green } from 'yoctocolors'; 3 | 4 | import { confirm, input, password } from './prompts.js'; 5 | import { 6 | isPdsSubdomain, 7 | makeMigrationCredentials, 8 | type MigrationCredentials, 9 | } from '../../migration/index.js'; 10 | import { 11 | isEmail, 12 | isHttpUrl, 13 | isHandle, 14 | handleUnknownError, 15 | stringify, 16 | isPlainObject, 17 | } from '../../utils/index.js'; 18 | 19 | /** 20 | * @param handle - The handle to extract the leaf domain from. 21 | * @returns The leaf domain of the handle. 22 | */ 23 | const extractLeafDomain = (handle: string) => handle.split('.').shift(); 24 | 25 | export const validateUrl = (value: string) => 26 | isHttpUrl(value) || 'Must be a valid HTTP or HTTPS URL string'; 27 | 28 | export const validateString = (value: string) => 29 | value.length > 0 || 'Must be a non-empty string'; 30 | 31 | export const validateEmail = (value: string) => 32 | isEmail(value) || 'Must be a valid email address'; 33 | 34 | export const validateHandle = (value: string) => 35 | isHandle(value) || 'Must be a valid handle'; 36 | 37 | export const validateTemporaryHandle = ( 38 | newHandle: string, 39 | newPdsHostname: string, 40 | ) => 41 | (isHandle(newHandle) && isPdsSubdomain(newHandle, newPdsHostname)) || 42 | 'Must be a valid handle and a subdomain of the new PDS hostname'; 43 | 44 | export const stripHandlePrefix = (value: string) => value.replace(/^@/u, ''); 45 | 46 | export const promptMessages = { 47 | oldPdsUrl: 'Enter the current PDS URL', 48 | oldHandle: 'Enter the full current handle (e.g. user.bsky.social, user.com)', 49 | oldPassword: 'Enter the password for the current account', 50 | inviteCode: 'Enter the invite code for the new account (from the new PDS)', 51 | newPdsUrl: 'Enter the new PDS URL', 52 | newHandle: (newPdsHostname: string) => 53 | `Enter the desired new handle (e.g. user.${newPdsHostname})`, 54 | newTemporaryHandle: 55 | 'You are using a custom handle. ' + 56 | 'This requires a temporary handle that will be used during the migration.\n\n' + 57 | `Enter the desired temporary new handle`, 58 | newEmail: 'Enter the desired email address for the new account', 59 | newPassword: 'Enter the desired password for the new account', 60 | confirmPassword: 'Confirm the password for the new account', 61 | } as const; 62 | 63 | export async function getCredentialsInteractive(): Promise< 64 | MigrationCredentials | undefined 65 | > { 66 | const oldPdsUrl = await input({ 67 | message: promptMessages.oldPdsUrl, 68 | default: 'https://bsky.social', 69 | validate: validateUrl, 70 | }); 71 | 72 | const oldHandle = await input({ 73 | message: promptMessages.oldHandle, 74 | validate: validateHandle, 75 | }); 76 | 77 | const oldPassword = await password({ 78 | message: promptMessages.oldPassword, 79 | validate: validateString, 80 | }); 81 | 82 | const inviteCode = await input({ 83 | message: promptMessages.inviteCode, 84 | validate: validateString, 85 | }); 86 | 87 | const newPdsUrl = await input({ 88 | message: promptMessages.newPdsUrl, 89 | validate: validateUrl, 90 | }); 91 | 92 | const newPdsHostname = new URL(newPdsUrl).hostname; 93 | 94 | const newHandle = await input({ 95 | message: promptMessages.newHandle(newPdsHostname), 96 | validate: (value) => validateHandle(value), 97 | }); 98 | 99 | let newTemporaryHandle: string | undefined; 100 | if (!isPdsSubdomain(newHandle, newPdsHostname)) { 101 | newTemporaryHandle = await input({ 102 | message: promptMessages.newTemporaryHandle, 103 | validate: (value) => validateTemporaryHandle(value, newPdsHostname), 104 | default: `${extractLeafDomain(newHandle)}-temp.${newPdsHostname}`, 105 | }); 106 | } 107 | 108 | const newEmail = await input({ 109 | message: promptMessages.newEmail, 110 | validate: validateEmail, 111 | }); 112 | 113 | const newPassword = await password({ 114 | message: promptMessages.newPassword, 115 | validate: validateString, 116 | }); 117 | await password({ 118 | message: promptMessages.confirmPassword, 119 | validate: (value) => value === newPassword || 'Passwords do not match', 120 | }); 121 | 122 | const rawCredentials: MigrationCredentials = { 123 | oldPdsUrl, 124 | oldHandle, 125 | oldPassword, 126 | inviteCode, 127 | newPdsUrl, 128 | newHandle: newTemporaryHandle 129 | ? { 130 | temporaryHandle: newTemporaryHandle, 131 | finalHandle: newHandle, 132 | } 133 | : { handle: newHandle }, 134 | newEmail, 135 | newPassword, 136 | }; 137 | 138 | let credentials: MigrationCredentials; 139 | try { 140 | credentials = makeMigrationCredentials(rawCredentials); 141 | } catch (error) { 142 | logCredentials(rawCredentials); 143 | throw handleUnknownError( 144 | `Fatal: Unexpected credential parsing error:\n${stringify(rawCredentials)}`, 145 | error, 146 | ); 147 | } 148 | 149 | logCredentials(credentials); 150 | 151 | const confirmResult = confirm({ 152 | message: 'Perform the migration with these credentials?', 153 | }); 154 | 155 | if (!(await confirmResult)) { 156 | process.exitCode = 0; 157 | return undefined; 158 | } 159 | 160 | return credentials; 161 | } 162 | 163 | const credentialLabels = { 164 | oldPdsUrl: 'Current PDS URL', 165 | oldHandle: 'Current handle', 166 | oldPassword: 'Current password', 167 | inviteCode: 'Invite code', 168 | newPdsUrl: 'New PDS URL', 169 | newEmail: 'New email', 170 | newPassword: 'New password', 171 | } as const; 172 | 173 | function logCredentials(credentials: MigrationCredentials) { 174 | const redacted = { 175 | ...credentials, 176 | oldPassword: '********', 177 | newPassword: '********', 178 | }; 179 | 180 | const getStringValue = (key: string, value: string) => 181 | `${bold(`${key}:`)}\n${green(value)}`; 182 | 183 | const content = Object.entries(redacted) 184 | .map(([key, value]) => { 185 | if (isPlainObject(value)) { 186 | if ('handle' in value) { 187 | return getStringValue('New handle', value.handle); 188 | } 189 | return `${getStringValue('New handle (temporary)', value.temporaryHandle)}\n${getStringValue('New handle (final)', value.finalHandle)}`; 190 | } 191 | return getStringValue( 192 | // @ts-expect-error 193 | credentialLabels[key], 194 | value, 195 | ); 196 | }) 197 | .join('\n'); 198 | 199 | console.log(); 200 | console.log(boxen(content, { title: bold('Credentials'), padding: 1 })); 201 | console.log(); 202 | } 203 | -------------------------------------------------------------------------------- /src/commands/migrate/index.ts: -------------------------------------------------------------------------------- 1 | import type { Argv, CommandModule as RawCommandModule } from 'yargs'; 2 | 3 | import { handleInteractive } from './interactive.js'; 4 | import { handlePipe } from './pipe.js'; 5 | import { makeHandler } from '../../utils/cli.js'; 6 | import type { BaseArgv } from '../../utils/cli.js'; 7 | 8 | export type MigrateOptions = BaseArgv & { 9 | interactive: boolean; 10 | pipe: boolean; 11 | }; 12 | 13 | type CommandModule = RawCommandModule, Args>; 14 | 15 | const modeGroupLabel = 'Mode (choose one):'; 16 | 17 | export const migrateCommand: CommandModule = { 18 | command: '$0', 19 | aliases: ['migrate', 'm'], 20 | describe: 'Perform a migration', 21 | builder: (yarg: Argv) => { 22 | return yarg 23 | .option('interactive', { 24 | alias: 'i', 25 | defaultDescription: 'true', 26 | describe: 'Run in interactive mode', 27 | conflicts: 'pipe', 28 | group: modeGroupLabel, 29 | type: 'boolean', 30 | }) 31 | .option('pipe', { 32 | alias: 'p', 33 | describe: 'Run in pipe mode', 34 | conflicts: 'interactive', 35 | group: modeGroupLabel, 36 | type: 'boolean', 37 | }) 38 | .middleware((argv) => { 39 | if (!argv.interactive && !argv.pipe) { 40 | argv.interactive = true; 41 | } 42 | }) 43 | .showHelpOnFail(true) as Argv; 44 | }, 45 | handler: makeHandler(async (argv) => { 46 | if (argv.interactive) { 47 | await handleInteractive(); 48 | } else if (argv.pipe) { 49 | await handlePipe(); 50 | } else { 51 | // This should never happen 52 | throw new Error('Fatal: No mode specified'); 53 | } 54 | }), 55 | }; 56 | -------------------------------------------------------------------------------- /src/commands/migrate/interactive.test.ts: -------------------------------------------------------------------------------- 1 | import type { Mock, MockInstance } from 'vitest'; 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 | 4 | import { handleInteractive } from './interactive.js'; 5 | import { input } from './prompts.js'; 6 | import { makeMockCredentials } from '../../../test/utils.js'; 7 | import type { MockMigration } from '../../../test/utils.js'; 8 | import type { 9 | MigrationCredentials, 10 | MigrationState, 11 | } from '../../migration/index.js'; 12 | import { Migration } from '../../migration/index.js'; 13 | import { consume } from '../../utils/misc.js'; 14 | 15 | vi.mock('../../utils/index.js', async (importOriginal) => ({ 16 | ...(await importOriginal()), 17 | logCentered: (message: string) => console.log(message), 18 | logWrapped: (message: string) => console.log(message), 19 | logError: (message: string) => console.log(message), 20 | })); 21 | 22 | vi.mock('./prompts.js', () => ({ 23 | input: vi.fn(), 24 | pressEnter: vi.fn(), 25 | })); 26 | 27 | vi.mock('../../migration/index.js', async (importOriginal) => ({ 28 | ...(await importOriginal()), 29 | Migration: vi.fn(), 30 | })); 31 | 32 | vi.mock('./credentials.js', async (importOriginal) => ({ 33 | ...(await importOriginal()), 34 | getCredentialsInteractive: vi.fn(async () => 35 | Promise.resolve(makeMockCredentials()), 36 | ), 37 | })); 38 | 39 | describe('handleMigrateInteractive', () => { 40 | let mockPrivateKey: string | undefined; 41 | let logSpy: MockInstance<() => void>; 42 | const runMock: Mock<() => AsyncGenerator> = vi.fn(); 43 | 44 | beforeEach(() => { 45 | mockPrivateKey = undefined; 46 | logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); 47 | 48 | /* eslint-disable no-invalid-this */ 49 | vi.mocked(Migration).mockImplementation(function ( 50 | this: MockMigration, 51 | arg: { credentials: MigrationCredentials }, 52 | ) { 53 | this.credentials = arg.credentials; 54 | this.newPrivateKey = mockPrivateKey; 55 | this.runIter = runMock; 56 | // eslint-disable-next-line vitest/prefer-spy-on 57 | this.run = vi.fn(async () => consume(this.runIter())); 58 | this.state = 'Ready'; 59 | return this as unknown as Migration; 60 | }); 61 | /* eslint-enable no-invalid-this */ 62 | }); 63 | 64 | it('should run a migration', async () => { 65 | const mockToken = 'bar'; 66 | vi.mocked(input).mockResolvedValue(mockToken); 67 | 68 | mockPrivateKey = '0xdeadbeef'; 69 | runMock 70 | .mockImplementationOnce(async function* () { 71 | yield 'RequestedPlcOperation'; 72 | }) 73 | .mockImplementationOnce(async function* () { 74 | yield 'Finalized'; 75 | }); 76 | 77 | await handleInteractive(); 78 | 79 | expect(vi.mocked(Migration).mock.instances[0]?.confirmationToken).toBe( 80 | mockToken, 81 | ); 82 | expect(vi.mocked(Migration).mock.instances[0]?.newPrivateKey).toBe( 83 | mockPrivateKey, 84 | ); 85 | expect(logSpy).toHaveBeenCalledWith( 86 | expect.stringContaining('Migration completed successfully! ✅'), 87 | ); 88 | expect(logSpy).toHaveBeenCalledWith( 89 | expect.stringContaining(mockPrivateKey), 90 | ); 91 | expect.stringContaining( 92 | 'Thank you for using the Bluesky account migration tool 🙇', 93 | ); 94 | }); 95 | 96 | it('should handle a failed migration in the Ready state', async () => { 97 | const state = 'Ready'; 98 | runMock.mockImplementation(async function* (this: MockMigration) { 99 | // eslint-disable-next-line no-invalid-this 100 | this.state = state; 101 | throw new Error('foo'); 102 | yield state; 103 | }); 104 | 105 | await expect(handleInteractive()).rejects.toThrow(new Error('foo')); 106 | 107 | expect(logSpy).toHaveBeenCalledWith( 108 | expect.stringContaining(`Migration failed during state "${state}"`), 109 | ); 110 | expect(logSpy).not.toHaveBeenCalledWith( 111 | expect.stringContaining('The migration has created a new account'), 112 | ); 113 | expect(logSpy).not.toHaveBeenCalledWith( 114 | expect.stringContaining(`The new account's private key is:`), 115 | ); 116 | }); 117 | 118 | it('should handle a failed migration in the CreatedNewAccount state', async () => { 119 | const state = 'CreatedNewAccount'; 120 | runMock.mockImplementation(async function* (this: MockMigration) { 121 | // eslint-disable-next-line no-invalid-this 122 | this.state = state; 123 | throw new Error('foo'); 124 | yield state; 125 | }); 126 | 127 | await expect(handleInteractive()).rejects.toThrow(new Error('foo')); 128 | 129 | expect(logSpy).toHaveBeenCalledWith( 130 | expect.stringContaining(`Migration failed during state "${state}"`), 131 | ); 132 | expect(logSpy).toHaveBeenCalledWith( 133 | expect.stringContaining('The migration has created a new account'), 134 | ); 135 | expect(logSpy).not.toHaveBeenCalledWith( 136 | expect.stringContaining(`The new account's private key is:`), 137 | ); 138 | }); 139 | 140 | it('should handle a failed migration in the MigratedIdentity state', async () => { 141 | const state = 'MigratedIdentity'; 142 | mockPrivateKey = '0xdeadbeef'; 143 | runMock.mockImplementation(async function* (this: MockMigration) { 144 | // eslint-disable-next-line no-invalid-this 145 | this.state = state; 146 | throw new Error('foo'); 147 | yield state; 148 | }); 149 | 150 | await expect(handleInteractive()).rejects.toThrow(new Error('foo')); 151 | 152 | expect(logSpy).toHaveBeenCalledWith( 153 | expect.stringContaining(`Migration failed during state "${state}"`), 154 | ); 155 | expect(logSpy).toHaveBeenCalledWith( 156 | expect.stringContaining('The migration has created a new account'), 157 | ); 158 | expect(logSpy).toHaveBeenCalledWith( 159 | expect.stringContaining(`The new account's private key is:`), 160 | ); 161 | expect(logSpy).toHaveBeenCalledWith( 162 | expect.stringContaining(mockPrivateKey), 163 | ); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/commands/migrate/interactive.ts: -------------------------------------------------------------------------------- 1 | import { getCredentialsInteractive, validateString } from './credentials.js'; 2 | import { input, pressEnter } from './prompts.js'; 3 | import { Migration } from '../../migration/index.js'; 4 | import type { 5 | MigrationCredentials, 6 | MigrationState, 7 | } from '../../migration/index.js'; 8 | import { 9 | logError, 10 | logDelimiter, 11 | logWarning, 12 | logWelcome, 13 | logCentered, 14 | logWrapped, 15 | makeSpinner, 16 | } from '../../utils/index.js'; 17 | 18 | const spinner = makeSpinner(); 19 | 20 | /** 21 | * Handles the interactive migration mode. 22 | */ 23 | export async function handleInteractive(): Promise { 24 | logIntroduction(); 25 | 26 | const { credentials, migration } = (await initializeMigration()) ?? {}; 27 | if (!credentials || !migration) { 28 | return; 29 | } 30 | 31 | try { 32 | const privateKey = await executeMigration(credentials, migration); 33 | await handleSuccess(privateKey); 34 | } catch (error) { 35 | spinner.stop(); 36 | await handleFailure(migration); 37 | throw error; 38 | } 39 | } 40 | 41 | /** 42 | * Logs the introductory messages to the user. 43 | */ 44 | function logIntroduction(): void { 45 | logWelcome(); 46 | console.log(); 47 | logWarning(` 48 | This is a community-maintained tool that has no affiliation with Bluesky. 49 | Use at your own risk. 50 | 51 | 52 | At the end of the migration process, this tool will print the private key of the new account to the console. 53 | 54 | You must save this key in a secure location, or you could lose access to your account. 55 | `); 56 | console.log(); 57 | } 58 | 59 | /** 60 | * Collects credentials from the user and constructs the new Migration instance. 61 | * 62 | * @returns if successful, 63 | * undefined if the user cancels the process 64 | */ 65 | async function initializeMigration(): Promise< 66 | | { 67 | credentials: MigrationCredentials; 68 | migration: Migration; 69 | } 70 | | undefined 71 | > { 72 | const credentials = await getCredentialsInteractive(); 73 | if (!credentials) { 74 | return undefined; 75 | } 76 | 77 | return { credentials, migration: new Migration({ credentials }) }; 78 | } 79 | 80 | /** 81 | * Given a fresh migration instance, runs the migration until it reaches the `Finalized` state. 82 | * 83 | * @param credentials - The credentials for the migration. 84 | * @param migration - The migration instance. 85 | * @returns The private key of the new account. 86 | * @throws {Error} if anything goes wrong. 87 | */ 88 | async function executeMigration( 89 | credentials: MigrationCredentials, 90 | migration: Migration, 91 | ): Promise { 92 | await beginMigration(migration); 93 | 94 | // eslint-disable-next-line require-atomic-updates 95 | migration.confirmationToken = await promptForConfirmationToken(credentials); 96 | 97 | return await finalizeMigration(migration); 98 | } 99 | 100 | /** 101 | * Runs the migration until it reaches the `RequestedPlcOperation` state. 102 | * 103 | * @param migration - The migration instance. 104 | * @throws {Error} if the resulting migration state is not as expected 105 | */ 106 | async function beginMigration(migration: Migration): Promise { 107 | spinner.start(); 108 | let result: MigrationState | undefined; 109 | for await (const state of migration.runIter()) { 110 | if (state !== 'RequestedPlcOperation') { 111 | spinner.text = getLoadingMessage(state); 112 | } 113 | result = state; 114 | } 115 | spinner.stop(); 116 | 117 | if (result !== 'RequestedPlcOperation') { 118 | throw new Error( 119 | `Fatal: Unexpected migration state "${result}" after initial run. Please report this bug.`, 120 | ); 121 | } 122 | } 123 | 124 | /** 125 | * Requests a confirmation token from the user. 126 | * 127 | * @param credentials - The credentials for the migration. 128 | * @returns The confirmation token. 129 | * @throws {Error} if the user cancels the process 130 | */ 131 | async function promptForConfirmationToken( 132 | credentials: MigrationCredentials, 133 | ): Promise { 134 | console.log(); 135 | logWrapped( 136 | `Email challenge requested from old PDS (${credentials.oldPdsUrl}).`, 137 | ); 138 | logWrapped( 139 | `An email should have been sent to the old account's email address.`, 140 | ); 141 | console.log(); 142 | 143 | return await input({ 144 | message: 'Enter the confirmation token from the challenge email', 145 | validate: validateString, 146 | }); 147 | } 148 | 149 | /** 150 | * Runs the migration until it reaches the `Finalized` state. 151 | * 152 | * @param migration - The migration instance. 153 | * @returns The private key of the new account. 154 | * @throws {Error} if the resulting migration state is not as expected 155 | */ 156 | async function finalizeMigration(migration: Migration): Promise { 157 | spinner.start(); 158 | let result: MigrationState | undefined; 159 | for await (const state of migration.runIter()) { 160 | if (state !== 'Finalized') { 161 | spinner.text = getLoadingMessage(state); 162 | } 163 | result = state; 164 | } 165 | spinner.stop(); 166 | 167 | if (result !== 'Finalized') { 168 | throw new Error( 169 | `Fatal: Unexpected migration state "${result}" after resuming migration. Please report this bug.`, 170 | ); 171 | } 172 | if (!migration.newPrivateKey) { 173 | throw new Error( 174 | `Fatal: No private key found after migration. Please report this bug.`, 175 | ); 176 | } 177 | 178 | return migration.newPrivateKey; 179 | } 180 | 181 | async function handleSuccess(privateKey: string): Promise { 182 | console.log(); 183 | logCentered('Migration completed successfully! ✅'); 184 | console.log(); 185 | logWarning( 186 | `You must save the private key in a secure location, or you could lose access to your account.`, 187 | ); 188 | console.log(); 189 | await logPrivateKey(privateKey); 190 | console.log(); 191 | logCentered('Thank you for using the Bluesky account migration tool 🙇'); 192 | } 193 | 194 | async function handleFailure(migration: Migration): Promise { 195 | console.log(); 196 | logError(`Migration failed during state "${migration.state}"`); 197 | if (migration.state !== 'Ready') { 198 | console.log(); 199 | logError( 200 | 'The migration has created a new account, but it may not be ready to use yet.', 201 | ); 202 | } 203 | 204 | if (migration.newPrivateKey) { 205 | console.log(); 206 | logError('You should still save the private key in a secure location.'); 207 | console.log(); 208 | await logPrivateKey(migration.newPrivateKey); 209 | await pressEnter(); 210 | } 211 | } 212 | 213 | async function logPrivateKey(privateKey: string): Promise { 214 | await pressEnter("Press Enter to view the new account's private key..."); 215 | console.log(); 216 | logWrapped(`The new account's private key is:`); 217 | logDelimiter('='); 218 | console.log(); 219 | console.log(privateKey); 220 | console.log(); 221 | logDelimiter('='); 222 | console.log(); 223 | } 224 | 225 | function getLoadingMessage(state: MigrationState) { 226 | switch (state) { 227 | case 'Ready': 228 | return 'Initializing migration...'; 229 | case 'Initialized': 230 | return 'Creating new account...'; 231 | case 'CreatedNewAccount': 232 | return 'Migrating data... (this may take a while)'; 233 | case 'MigratedData': 234 | return 'Requesting PLC operation...'; 235 | case 'RequestedPlcOperation': 236 | return 'Migrating identity...'; 237 | case 'MigratedIdentity': 238 | return 'Finalizing migration...'; 239 | case 'Finalized': 240 | default: 241 | return ''; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/commands/migrate/pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'node:stream'; 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 | 4 | import { handlePipe } from './pipe.js'; 5 | import { 6 | makeMockCredentials, 7 | makeMockCredentialsWithFinalHandle, 8 | } from '../../../test/utils.js'; 9 | import { Migration, operations } from '../../migration/index.js'; 10 | import type { SerializedMigration } from '../../migration/types.js'; 11 | 12 | vi.mock('../../migration/operations/index.js', async () => { 13 | const { makeMockOperations } = await import('../../../test/utils.js'); 14 | return makeMockOperations({ 15 | initializeAgents: vi.fn().mockResolvedValue({ 16 | oldAgent: {}, 17 | newAgent: {}, 18 | accountDid: 'foo', 19 | }), 20 | }); 21 | }); 22 | 23 | type Stdin = typeof process.stdin; 24 | type Stdout = typeof process.stdout; 25 | 26 | describe('handlePipe', () => { 27 | let mockStdin: Readable; 28 | let mockStdout: Writable; 29 | let writtenOutput: string; 30 | 31 | beforeEach(() => { 32 | writtenOutput = ''; 33 | 34 | mockStdin = new Readable(); 35 | vi.spyOn(mockStdin, '_read').mockImplementation(() => undefined); 36 | vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as Stdin); 37 | 38 | mockStdout = new Writable(); 39 | vi.spyOn(mockStdout, '_write').mockImplementation( 40 | (chunk: Buffer | string, _encoding, callback) => { 41 | writtenOutput += chunk.toString(); 42 | callback(); 43 | }, 44 | ); 45 | vi.spyOn(process, 'stdout', 'get').mockReturnValue(mockStdout as Stdout); 46 | }); 47 | 48 | it('runs migration until RequestedPlcOperation when no confirmation token provided', async () => { 49 | const inputData = { 50 | state: 'Ready', 51 | credentials: makeMockCredentials(), 52 | }; 53 | 54 | mockStdin.push(JSON.stringify(inputData)); 55 | mockStdin.push(null); 56 | 57 | await handlePipe(); 58 | 59 | expect(JSON.parse(writtenOutput)).toStrictEqual({ 60 | state: 'RequestedPlcOperation', 61 | credentials: makeMockCredentials(), 62 | }); 63 | }); 64 | 65 | it('runs migration to completion when confirmation token provided', async () => { 66 | const inputData = { 67 | state: 'Ready', 68 | credentials: makeMockCredentials(), 69 | confirmationToken: 'test-token', 70 | }; 71 | 72 | mockStdin.push(JSON.stringify(inputData)); 73 | mockStdin.push(null); 74 | 75 | const expectedState = 'Finalized'; 76 | const expectedPrivateKey = 'test-private-key'; 77 | 78 | vi.mocked(operations.migrateIdentity).mockResolvedValue(expectedPrivateKey); 79 | 80 | await handlePipe(); 81 | 82 | expect(JSON.parse(writtenOutput)).toStrictEqual({ 83 | ...inputData, 84 | state: expectedState, 85 | newPrivateKey: expectedPrivateKey, 86 | }); 87 | }); 88 | 89 | it('handles partial migration', async () => { 90 | const inputData = { 91 | // No state 92 | credentials: makeMockCredentials(), 93 | }; 94 | 95 | mockStdin.push(JSON.stringify(inputData)); 96 | mockStdin.push(null); 97 | 98 | await handlePipe(); 99 | 100 | expect(JSON.parse(writtenOutput)).toStrictEqual({ 101 | state: 'RequestedPlcOperation', 102 | credentials: makeMockCredentials(), 103 | }); 104 | }); 105 | 106 | it('outputs current state even when migration fails', async () => { 107 | const inputData: SerializedMigration = { 108 | credentials: makeMockCredentials(), 109 | state: 'Ready', 110 | }; 111 | 112 | mockStdin.push(JSON.stringify(inputData)); 113 | mockStdin.push(null); 114 | 115 | vi.mocked(operations.createNewAccount).mockRejectedValue( 116 | new Error('Migration failed'), 117 | ); 118 | 119 | await expect(handlePipe()).rejects.toThrow('Migration failed'); 120 | 121 | expect(JSON.parse(writtenOutput)).toStrictEqual({ 122 | ...inputData, 123 | state: 'Initialized', 124 | }); 125 | }); 126 | 127 | it('handles invalid JSON input', async () => { 128 | mockStdin.push('invalid json'); 129 | mockStdin.push(null); 130 | 131 | await expect(handlePipe()).rejects.toThrow('Invalid input: must be JSON'); 132 | }); 133 | 134 | it('handles non-object JSON input', async () => { 135 | mockStdin.push('"string input"'); 136 | mockStdin.push(null); 137 | 138 | await expect(handlePipe()).rejects.toThrow( 139 | 'Invalid input: must be a plain JSON object', 140 | ); 141 | }); 142 | 143 | it('handles unexpected migration state', async () => { 144 | const inputData: SerializedMigration = { 145 | credentials: makeMockCredentials(), 146 | state: 'Ready', 147 | }; 148 | 149 | mockStdin.push(JSON.stringify(inputData)); 150 | mockStdin.push(null); 151 | 152 | const unexpectedState = 'Finalized'; 153 | vi.spyOn(Migration.prototype, 'run').mockResolvedValue(unexpectedState); 154 | 155 | await expect(handlePipe()).rejects.toThrow( 156 | `Fatal: Unexpected migration state "${unexpectedState}" after initial run`, 157 | ); 158 | }); 159 | 160 | it('handles invalid handles', async () => { 161 | const credentials = makeMockCredentialsWithFinalHandle('bar.baz'); 162 | credentials.newHandle.temporaryHandle = 'kaplar.kaplar'; 163 | const inputData: SerializedMigration = { 164 | credentials, 165 | state: 'Ready', 166 | }; 167 | 168 | mockStdin.push(JSON.stringify(inputData)); 169 | mockStdin.push(null); 170 | 171 | await expect(handlePipe()).rejects.toThrow('Invalid migration arguments'); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/commands/migrate/pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isPartialSerializedMigration, 3 | Migration, 4 | } from '../../migration/index.js'; 5 | import type { MigrationState } from '../../migration/types.js'; 6 | import { handleUnknownError, isPlainObject } from '../../utils/index.js'; 7 | 8 | /** 9 | * Handles the pipe migration mode. 10 | */ 11 | export async function handlePipe(): Promise { 12 | const input = await readStdin(); 13 | 14 | let rawCredentials: Record; 15 | try { 16 | rawCredentials = JSON.parse(input); 17 | } catch (error) { 18 | throw handleUnknownError('Invalid input: must be JSON', error); 19 | } 20 | 21 | if (!isPlainObject(rawCredentials)) { 22 | throw new Error('Invalid input: must be a plain JSON object'); 23 | } 24 | 25 | let migration: Migration; 26 | try { 27 | migration = isPartialSerializedMigration(rawCredentials) 28 | ? await Migration.deserialize({ 29 | ...rawCredentials, 30 | state: 'Ready', 31 | }) 32 | : await Migration.deserialize(rawCredentials); 33 | } catch (error) { 34 | throw handleUnknownError('Invalid migration arguments', error); 35 | } 36 | 37 | const expectedState: MigrationState = 38 | migration.confirmationToken === undefined 39 | ? 'RequestedPlcOperation' 40 | : 'Finalized'; 41 | 42 | try { 43 | const state = await migration.run(); 44 | if (state !== expectedState) { 45 | throw new Error( 46 | `Fatal: Unexpected migration state "${state}" after initial run`, 47 | ); 48 | } 49 | writeStdout(migration.serialize()); 50 | } catch (error) { 51 | writeStdout(migration.serialize()); 52 | throw error; 53 | } 54 | } 55 | 56 | async function readStdin(): Promise { 57 | const chunks: Buffer[] = []; 58 | for await (const chunk of process.stdin) { 59 | chunks.push(Buffer.from(chunk)); 60 | } 61 | return Buffer.concat(chunks).toString('utf8'); 62 | } 63 | 64 | function writeStdout(data: Record): void { 65 | // Plain JSON.stringify() for single-line output 66 | process.stdout.write(JSON.stringify(data)); 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/migrate/prompts.ts: -------------------------------------------------------------------------------- 1 | import { createPrompt, useKeypress, isEnterKey } from '@inquirer/core'; 2 | import { 3 | confirm as _confirm, 4 | input as _input, 5 | password as _password, 6 | } from '@inquirer/prompts'; 7 | 8 | import { wrap } from '../../utils/terminal.js'; 9 | 10 | const wrapPrompt = Promise>( 11 | prompt: PromptFn, 12 | ) => { 13 | return async (...args: Parameters) => { 14 | try { 15 | const result = await prompt(...args); 16 | return typeof result === 'string' ? result.trim() : result; 17 | } catch (error) { 18 | if (error instanceof Error && error.name === 'ExitPromptError') { 19 | // The user exited the prompt. Wait for the next tick to avoid 20 | // yielding control back to the caller before the process exits. 21 | return await new Promise(process.nextTick); 22 | } 23 | throw error; 24 | } 25 | }; 26 | }; 27 | 28 | export const input = wrapPrompt(_input) as typeof _input; 29 | export const password = wrapPrompt(_password) as typeof _password; 30 | export const confirm = wrapPrompt(_confirm) as typeof _confirm; 31 | 32 | const _pressEnter = wrapPrompt( 33 | createPrompt((message, done) => { 34 | useKeypress((key, _rl) => { 35 | if (isEnterKey(key)) { 36 | done(true); 37 | } 38 | }); 39 | return wrap(message); 40 | }), 41 | ); 42 | 43 | export const pressEnter = async (message = 'Press Enter to continue...') => 44 | _pressEnter(message); 45 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import * as index from './index.js'; 4 | 5 | describe('index', () => { 6 | it('should have the expected exports', () => { 7 | expect(Object.keys(index).sort()).toStrictEqual([ 8 | 'Migration', 9 | 'isHandle', 10 | 'isHttpUrl', 11 | 'operations', 12 | ]); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { MigrationState } from './migration/index.js'; 2 | export { Migration, operations } from './migration/index.js'; 3 | export type { 4 | MigrationCredentials, 5 | SerializedMigration, 6 | } from './migration/index.js'; 7 | 8 | export { isHttpUrl, isHandle } from './utils/index.js'; 9 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, afterEach } from 'vitest'; 2 | 3 | import { cli } from './cli.js'; 4 | import { commands } from './commands/index.js'; 5 | 6 | vi.mock('./cli.js', () => ({ 7 | cli: vi.fn(), 8 | })); 9 | 10 | describe('main', () => { 11 | afterEach(() => { 12 | vi.resetModules(); 13 | process.exitCode = undefined; 14 | }); 15 | 16 | it('should call cli with process.argv and commands', async () => { 17 | vi.mocked(cli).mockResolvedValueOnce(undefined); 18 | 19 | await import('./main.js'); 20 | 21 | expect(cli).toHaveBeenCalledWith(process.argv, commands); 22 | expect(process.exitCode).toBeUndefined(); 23 | }); 24 | 25 | it('should handle errors by logging to console.error and setting exit code', async () => { 26 | const consoleError = vi 27 | .spyOn(console, 'error') 28 | .mockImplementation(() => undefined); 29 | const testError = new Error('Test error'); 30 | vi.mocked(cli).mockRejectedValueOnce(testError); 31 | 32 | await import('./main.js'); 33 | 34 | await new Promise(process.nextTick); 35 | 36 | expect(consoleError).toHaveBeenCalledWith(testError); 37 | expect(process.exitCode).toBe(1); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { cli } from './cli.js'; 4 | import { commands } from './commands/index.js'; 5 | 6 | cli(process.argv, commands).catch((error) => { 7 | console.error(error); 8 | process.exitCode = 1; 9 | }); 10 | -------------------------------------------------------------------------------- /src/migration/Migration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | 3 | import { Migration } from './Migration.js'; 4 | import * as operations from './operations/index.js'; 5 | import { MigrationStateSchema, type AgentPair } from './types.js'; 6 | import type { MigrationCredentialsWithHandle } from '../../test/utils.js'; 7 | import { makeMockCredentials, mockAccountDid } from '../../test/utils.js'; 8 | 9 | vi.mock('./operations/index.js', () => ({ 10 | initializeAgents: vi.fn(), 11 | createNewAccount: vi.fn(), 12 | migrateData: vi.fn(), 13 | requestPlcOperation: vi.fn(), 14 | migrateIdentity: vi.fn(), 15 | finalize: vi.fn(), 16 | })); 17 | 18 | const makeMockAgents = (): AgentPair => 19 | ({ 20 | oldAgent: { login: vi.fn(), logout: vi.fn() }, 21 | newAgent: { login: vi.fn(), logout: vi.fn() }, 22 | accountDid: mockAccountDid, 23 | }) as unknown as AgentPair; 24 | 25 | const agentsMatcher = expect.objectContaining({ 26 | oldAgent: expect.objectContaining({ logout: expect.any(Function) }), 27 | newAgent: expect.objectContaining({ logout: expect.any(Function) }), 28 | accountDid: mockAccountDid, 29 | }); 30 | 31 | const mockPrivateKey = 'mock-private-key'; 32 | const mockToken = 'mock-token'; 33 | 34 | describe('Migration', () => { 35 | let mockCredentials: MigrationCredentialsWithHandle; 36 | 37 | beforeEach(() => { 38 | mockCredentials = makeMockCredentials(); 39 | vi.mocked(operations.initializeAgents).mockResolvedValue(makeMockAgents()); 40 | vi.mocked(operations.migrateIdentity).mockResolvedValue(mockPrivateKey); 41 | }); 42 | 43 | describe('constructor', () => { 44 | it('initializes with default state', () => { 45 | const migration = new Migration({ credentials: mockCredentials }); 46 | expect(migration.accountDid).toBeUndefined(); 47 | expect(migration.newPrivateKey).toBeUndefined(); 48 | }); 49 | 50 | it('accepts initial state', () => { 51 | const migration = new Migration( 52 | { credentials: mockCredentials }, 53 | 'CreatedNewAccount', 54 | makeMockAgents(), 55 | ); 56 | expect(migration.accountDid).toBe(mockAccountDid); 57 | }); 58 | }); 59 | 60 | describe('run', () => { 61 | it('executes all transitions in order', async () => { 62 | const migration = new Migration({ 63 | credentials: mockCredentials, 64 | confirmationToken: mockToken, 65 | }); 66 | 67 | expect(await migration.run()).toBe('Finalized'); 68 | 69 | // Verify all transitions were called in order 70 | expect(operations.initializeAgents).toHaveBeenCalledWith({ 71 | credentials: mockCredentials, 72 | }); 73 | expect(operations.createNewAccount).toHaveBeenCalledWith({ 74 | agents: agentsMatcher, 75 | credentials: mockCredentials, 76 | }); 77 | expect(operations.migrateData).toHaveBeenCalledWith(agentsMatcher); 78 | expect(operations.migrateIdentity).toHaveBeenCalledWith({ 79 | agents: agentsMatcher, 80 | confirmationToken: mockToken, 81 | }); 82 | expect(operations.finalize).toHaveBeenCalledWith({ 83 | agents: agentsMatcher, 84 | credentials: mockCredentials, 85 | }); 86 | 87 | // Verify final state 88 | expect(migration.newPrivateKey).toBe(mockPrivateKey); 89 | }); 90 | 91 | it('runs migration with given agents from Initialized state', async () => { 92 | const agents = makeMockAgents(); 93 | const migration = new Migration( 94 | { 95 | credentials: mockCredentials, 96 | confirmationToken: mockToken, 97 | }, 98 | 'Initialized', 99 | agents, 100 | ); 101 | 102 | expect(await migration.run()).toBe('Finalized'); 103 | expect(agents.oldAgent.logout).toHaveBeenCalled(); 104 | expect(agents.newAgent.logout).toHaveBeenCalled(); 105 | }); 106 | 107 | it('stops and resumes if confirmation token is missing during RequestedPlcOperation state', async () => { 108 | const migration = new Migration( 109 | { credentials: mockCredentials }, 110 | 'Ready', 111 | ); 112 | 113 | expect(await migration.run()).toBe('RequestedPlcOperation'); 114 | expect(operations.migrateIdentity).not.toHaveBeenCalled(); 115 | 116 | migration.confirmationToken = mockToken; 117 | expect(await migration.run()).toBe('Finalized'); 118 | }); 119 | }); 120 | 121 | describe('runIter', () => { 122 | it('iterates through all states', async () => { 123 | const states = []; 124 | const migration = new Migration({ 125 | credentials: mockCredentials, 126 | confirmationToken: mockToken, 127 | }); 128 | for await (const state of migration.runIter()) { 129 | states.push(state); 130 | } 131 | 132 | expect(states).toStrictEqual(Object.values(MigrationStateSchema.Values)); 133 | }); 134 | 135 | it('stops and resumes if confirmation token is missing during RequestedPlcOperation state', async () => { 136 | const states = []; 137 | const migration = new Migration({ credentials: mockCredentials }); 138 | for await (const state of migration.runIter()) { 139 | states.push(state); 140 | } 141 | 142 | expect(states).toStrictEqual([ 143 | 'Ready', 144 | 'Initialized', 145 | 'CreatedNewAccount', 146 | 'MigratedData', 147 | 'RequestedPlcOperation', 148 | ]); 149 | 150 | states.length = 0; 151 | migration.confirmationToken = mockToken; 152 | for await (const state of migration.runIter()) { 153 | states.push(state); 154 | } 155 | 156 | expect(states).toStrictEqual([ 157 | 'RequestedPlcOperation', 158 | 'MigratedIdentity', 159 | 'Finalized', 160 | ]); 161 | }); 162 | }); 163 | 164 | describe('getters and setters', () => { 165 | it('setting confirmationToken updates token in params', async () => { 166 | const migration = new Migration( 167 | { credentials: mockCredentials }, 168 | 'MigratedData', 169 | makeMockAgents(), 170 | ); 171 | 172 | migration.confirmationToken = mockToken; 173 | await migration.run(); 174 | 175 | expect(operations.migrateIdentity).toHaveBeenCalledWith({ 176 | agents: agentsMatcher, 177 | confirmationToken: mockToken, 178 | }); 179 | }); 180 | }); 181 | 182 | describe('serialize', () => { 183 | it('serializes a migration in the Ready state', () => { 184 | const migration = new Migration({ credentials: mockCredentials }); 185 | const serialized = migration.serialize(); 186 | expect(serialized).toStrictEqual({ 187 | state: 'Ready', 188 | credentials: mockCredentials, 189 | }); 190 | }); 191 | 192 | it('serializes a migration in the RequestedPlcOperation state', () => { 193 | const migration = new Migration( 194 | { 195 | credentials: mockCredentials, 196 | confirmationToken: mockToken, 197 | }, 198 | 'RequestedPlcOperation', 199 | makeMockAgents(), 200 | ); 201 | const initialState = migration.serialize(); 202 | 203 | expect(initialState).toStrictEqual({ 204 | state: 'RequestedPlcOperation', 205 | credentials: mockCredentials, 206 | confirmationToken: mockToken, 207 | }); 208 | }); 209 | 210 | it('serializes a migration after running from RequestedPlcOperation state', async () => { 211 | const migration = new Migration( 212 | { 213 | credentials: mockCredentials, 214 | confirmationToken: mockToken, 215 | }, 216 | 'RequestedPlcOperation', 217 | makeMockAgents(), 218 | ); 219 | 220 | vi.mocked(operations.migrateIdentity).mockResolvedValue(mockPrivateKey); 221 | await migration.run(); 222 | const finalState = migration.serialize(); 223 | 224 | expect(finalState).toStrictEqual({ 225 | state: 'Finalized', 226 | credentials: mockCredentials, 227 | confirmationToken: mockToken, 228 | newPrivateKey: mockPrivateKey, 229 | }); 230 | }); 231 | }); 232 | 233 | describe('deserialize', () => { 234 | it('deserializes a migration in the Ready state', async () => { 235 | const migration = await Migration.deserialize({ 236 | state: 'Ready', 237 | credentials: mockCredentials, 238 | }); 239 | 240 | expect(migration.state).toBe('Ready'); 241 | }); 242 | 243 | it('deserializes a migration in the RequestedPlcOperation state', async () => { 244 | vi.mocked(operations.initializeAgents).mockResolvedValue( 245 | makeMockAgents(), 246 | ); 247 | 248 | const migration = await Migration.deserialize({ 249 | state: 'RequestedPlcOperation', 250 | credentials: mockCredentials, 251 | confirmationToken: mockToken, 252 | }); 253 | 254 | expect(migration.state).toBe('RequestedPlcOperation'); 255 | expect(migration.confirmationToken).toBe(mockToken); 256 | }); 257 | 258 | it('deserializes a migration in the Finalized state', async () => { 259 | const migration = await Migration.deserialize({ 260 | state: 'Finalized', 261 | credentials: mockCredentials, 262 | confirmationToken: mockToken, 263 | newPrivateKey: mockPrivateKey, 264 | }); 265 | 266 | expect(migration.state).toBe('Finalized'); 267 | expect(migration.confirmationToken).toBe(mockToken); 268 | expect(migration.newPrivateKey).toBe(mockPrivateKey); 269 | }); 270 | 271 | it('deserializing a migration in the ready state does not restore agents', async () => { 272 | await Migration.deserialize({ 273 | state: 'Ready', 274 | credentials: mockCredentials, 275 | }); 276 | 277 | expect(operations.initializeAgents).not.toHaveBeenCalled(); 278 | }); 279 | 280 | it('deserializing a migration in the CreatedNewAccount state restores agents', async () => { 281 | const mockAgents = makeMockAgents(); 282 | vi.mocked(operations.initializeAgents).mockResolvedValue(mockAgents); 283 | 284 | await Migration.deserialize({ 285 | state: 'CreatedNewAccount', 286 | credentials: mockCredentials, 287 | }); 288 | 289 | expect(operations.initializeAgents).toHaveBeenCalledOnce(); 290 | expect(operations.initializeAgents).toHaveBeenCalledWith({ 291 | credentials: mockCredentials, 292 | }); 293 | expect(mockAgents.newAgent.login).toHaveBeenCalledOnce(); 294 | expect(mockAgents.newAgent.login).toHaveBeenCalledWith({ 295 | identifier: mockCredentials.newHandle.handle, 296 | password: mockCredentials.newPassword, 297 | }); 298 | }); 299 | 300 | it('deserializing a migration in the MigratedIdentity state restores agents', async () => { 301 | const mockAgents = makeMockAgents(); 302 | vi.mocked(operations.initializeAgents).mockResolvedValue(mockAgents); 303 | 304 | await Migration.deserialize({ 305 | state: 'MigratedIdentity', 306 | credentials: mockCredentials, 307 | confirmationToken: mockToken, 308 | newPrivateKey: mockPrivateKey, 309 | }); 310 | 311 | expect(operations.initializeAgents).toHaveBeenCalledOnce(); 312 | expect(operations.initializeAgents).toHaveBeenCalledWith({ 313 | credentials: mockCredentials, 314 | }); 315 | expect(mockAgents.newAgent.login).toHaveBeenCalledOnce(); 316 | expect(mockAgents.newAgent.login).toHaveBeenCalledWith({ 317 | identifier: mockCredentials.newHandle.handle, 318 | password: mockCredentials.newPassword, 319 | }); 320 | }); 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /src/migration/Migration.ts: -------------------------------------------------------------------------------- 1 | import * as operations from './operations/index.js'; 2 | import { initializeAgents } from './operations/index.js'; 3 | import type { 4 | AgentPair, 5 | MigrationCredentials, 6 | MigrationState, 7 | SerializedMigration, 8 | } from './types.js'; 9 | import { 10 | getMigrationHandle, 11 | MigrationStateSchema, 12 | SerializedMigrationSchema, 13 | stateUtils, 14 | } from './types.js'; 15 | import { consume, handleUnknownError } from '../utils/index.js'; 16 | 17 | type BaseData = { 18 | credentials: MigrationCredentials; 19 | confirmationToken?: string | undefined; 20 | }; 21 | 22 | type FinalData = { 23 | credentials: MigrationCredentials; 24 | confirmationToken: string; 25 | newPrivateKey: string; 26 | }; 27 | 28 | const { enum: stateEnum } = MigrationStateSchema; 29 | 30 | type Transitions = { 31 | [stateEnum.Ready]: { 32 | nextState: 'Initialized'; 33 | agents: AgentPair; 34 | }; 35 | [stateEnum.Initialized]: { 36 | nextState: 'CreatedNewAccount'; 37 | }; 38 | [stateEnum.CreatedNewAccount]: { 39 | nextState: 'MigratedData'; 40 | }; 41 | [stateEnum.MigratedData]: { 42 | nextState: 'RequestedPlcOperation'; 43 | }; 44 | [stateEnum.RequestedPlcOperation]: 45 | | { 46 | nextState: 'RequestedPlcOperation'; 47 | } 48 | | { 49 | nextState: 'MigratedIdentity'; 50 | data: FinalData; 51 | }; 52 | [stateEnum.MigratedIdentity]: { 53 | nextState: 'Finalized'; 54 | }; 55 | [stateEnum.Finalized]: never; 56 | }; 57 | 58 | type ExpectedData = { 59 | [stateEnum.Ready]: BaseData; 60 | [stateEnum.Initialized]: BaseData; 61 | [stateEnum.CreatedNewAccount]: BaseData; 62 | [stateEnum.MigratedData]: BaseData; 63 | [stateEnum.RequestedPlcOperation]: BaseData; 64 | [stateEnum.MigratedIdentity]: FinalData; 65 | [stateEnum.Finalized]: FinalData; 66 | }; 67 | 68 | type StateMachineConfig = { 69 | [S in MigrationState]: ( 70 | data: ExpectedData[S], 71 | agents: S extends 'Ready' ? never : AgentPair, 72 | ) => Promise; 73 | }; 74 | 75 | const stateMachineConfig: StateMachineConfig = { 76 | Ready: async ({ credentials }) => { 77 | return { 78 | nextState: 'Initialized', 79 | agents: await operations.initializeAgents({ credentials }), 80 | }; 81 | }, 82 | Initialized: async ({ credentials }, agents) => { 83 | await operations.createNewAccount({ agents, credentials }); 84 | return { 85 | nextState: 'CreatedNewAccount', 86 | }; 87 | }, 88 | CreatedNewAccount: async (_data, agents) => { 89 | await operations.migrateData(agents); 90 | return { 91 | nextState: 'MigratedData', 92 | }; 93 | }, 94 | MigratedData: async (_data, agents) => { 95 | await operations.requestPlcOperation(agents); 96 | return { 97 | nextState: 'RequestedPlcOperation', 98 | }; 99 | }, 100 | RequestedPlcOperation: async ({ credentials, confirmationToken }, agents) => { 101 | if (!confirmationToken) { 102 | return { 103 | nextState: 'RequestedPlcOperation', 104 | }; 105 | } 106 | 107 | const newPrivateKey = await operations.migrateIdentity({ 108 | agents, 109 | confirmationToken, 110 | }); 111 | return { 112 | nextState: 'MigratedIdentity', 113 | data: { credentials, confirmationToken, newPrivateKey }, 114 | }; 115 | }, 116 | MigratedIdentity: async ({ credentials }, agents) => { 117 | await operations.finalize({ agents, credentials }); 118 | return { 119 | nextState: 'Finalized', 120 | }; 121 | }, 122 | Finalized: async (_data) => { 123 | throw new Error('Cannot transition from Finalized state'); 124 | }, 125 | }; 126 | 127 | export class Migration { 128 | #state: MigrationState; 129 | 130 | #agents?: AgentPair | undefined; 131 | 132 | #data: ExpectedData[MigrationState]; 133 | 134 | constructor( 135 | initialData: BaseData, 136 | initialState: MigrationState = 'Ready', 137 | agents?: AgentPair, 138 | ) { 139 | this.#state = initialState; 140 | this.#data = initialData; 141 | this.#agents = agents; 142 | } 143 | 144 | /** 145 | * Serializes the migration state and data to a JSON string. 146 | * 147 | * **WARNING:** Will contain private keys and passwords in plaintext, if present. 148 | * 149 | * @returns The serialized migration data. 150 | */ 151 | serialize(): SerializedMigration { 152 | // @ts-expect-error The job of this class is to ensure that the data is valid. 153 | const data: SerializedMigration = { 154 | ...this.#data, 155 | state: this.#state, 156 | }; 157 | return data; 158 | } 159 | 160 | /** 161 | * Deserializes a migration from a JSON blob. 162 | * 163 | * @param data - The serialized migration data. 164 | * @throws {Error} If the migration data is invalid. 165 | * @returns The deserialized migration. 166 | */ 167 | static async deserialize(data: Record): Promise { 168 | const parsed = Migration.#parseSerializedMigration(data); 169 | const initialData: BaseData = { 170 | credentials: parsed.credentials, 171 | }; 172 | 173 | let agents: AgentPair | undefined; 174 | if (parsed.state !== 'Ready') { 175 | agents = await Migration.#restoreAgents(parsed); 176 | } 177 | if (parsed.confirmationToken !== undefined) { 178 | initialData.confirmationToken = parsed.confirmationToken; 179 | } 180 | 181 | const migration = new Migration(initialData, parsed.state, agents); 182 | 183 | if ('newPrivateKey' in parsed) { 184 | migration.#newPrivateKey = parsed.newPrivateKey; 185 | } 186 | return migration; 187 | } 188 | 189 | static #parseSerializedMigration( 190 | data: Record, 191 | ): SerializedMigration { 192 | try { 193 | return SerializedMigrationSchema.parse(data); 194 | } catch (error) { 195 | const message = 'Invalid migration data: failed to parse'; 196 | throw handleUnknownError(message, error); 197 | } 198 | } 199 | 200 | static async #restoreAgents(parsed: SerializedMigration): Promise { 201 | const agents = await initializeAgents({ credentials: parsed.credentials }); 202 | 203 | if (stateUtils.gte(parsed.state, 'CreatedNewAccount')) { 204 | await agents.newAgent.login({ 205 | identifier: getMigrationHandle(parsed.credentials), 206 | password: parsed.credentials.newPassword, 207 | }); 208 | } 209 | 210 | return agents; 211 | } 212 | 213 | get accountDid() { 214 | return this.#agents?.accountDid; 215 | } 216 | 217 | get newPrivateKey() { 218 | return 'newPrivateKey' in this.#data ? this.#data.newPrivateKey : undefined; 219 | } 220 | 221 | // eslint-disable-next-line accessor-pairs 222 | set #newPrivateKey(privateKey: string) { 223 | if ('newPrivateKey' in this.#data) { 224 | throw new Error(`Fatal: "newPrivateKey" already set`); 225 | } 226 | this.#data = { ...this.#data, newPrivateKey: privateKey }; 227 | } 228 | 229 | get state() { 230 | return this.#state; 231 | } 232 | 233 | set confirmationToken(token: string) { 234 | if (this.#data.confirmationToken !== undefined) { 235 | throw new Error(`Fatal: "confirmationToken" already set`); 236 | } 237 | this.#data = { ...this.#data, confirmationToken: token }; 238 | } 239 | 240 | get confirmationToken(): string | undefined { 241 | return 'confirmationToken' in this.#data 242 | ? this.#data.confirmationToken 243 | : undefined; 244 | } 245 | 246 | /** 247 | * Iterates through the migration state machine, yielding every state, including 248 | * the current state when this method is called. 249 | * 250 | * Either runs to completion or stops and returns after yielding the current 251 | * state if the migration is not ready to proceed. 252 | * 253 | * Calls {@link teardown} if the migration is finalized. 254 | * 255 | * @yields The current state of the migration. 256 | */ 257 | async *runIter(): AsyncGenerator { 258 | while (this.#state !== 'Finalized') { 259 | yield this.#state; 260 | if ( 261 | this.#state === 'RequestedPlcOperation' && 262 | !('confirmationToken' in this.#data) 263 | ) { 264 | return; 265 | } 266 | 267 | const config = stateMachineConfig[this.#state]; 268 | try { 269 | // @ts-expect-error TypeScript can't know whether this.#data is a valid parameter. 270 | const result = await config(this.#data, this.#agents); 271 | this.#state = result.nextState; 272 | if ('agents' in result) { 273 | this.#agents = result.agents; 274 | } 275 | if ('data' in result) { 276 | this.#data = { 277 | ...this.#data, 278 | ...result.data, 279 | }; 280 | } 281 | } catch (error) { 282 | throw new Error(`Migration failed during state "${this.#state}"`, { 283 | cause: error instanceof Error ? error : String(error), 284 | }); 285 | } 286 | } 287 | 288 | await this.teardown(); 289 | yield this.#state; 290 | } 291 | 292 | /** 293 | * Runs the migration state machine. Either runs to completion or stops and 294 | * returns the current state if the migration is not ready to proceed. 295 | * 296 | * Calls {@link teardown} if the migration is finalized. 297 | * 298 | * @returns The current state of the migration. 299 | */ 300 | async run(): Promise { 301 | await consume(this.runIter()); 302 | return this.#state; 303 | } 304 | 305 | /** 306 | * Sets state to Finalized and logs out the agents, if any. Idempotent. 307 | */ 308 | async teardown() { 309 | this.#state = 'Finalized'; 310 | const agents = this.#agents; 311 | // Prevent re-entrancy 312 | this.#agents = undefined; 313 | if (agents) { 314 | await Promise.allSettled([ 315 | agents.oldAgent.logout(), 316 | agents.newAgent.logout(), 317 | ]); 318 | } 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/migration/index.ts: -------------------------------------------------------------------------------- 1 | import * as operations from './operations/index.js'; 2 | 3 | export { operations }; 4 | 5 | export * from './Migration.js'; 6 | export * from './types.js'; 7 | -------------------------------------------------------------------------------- /src/migration/operations/account.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | 3 | import { createNewAccount } from './account.js'; 4 | import { 5 | makeMockAgent, 6 | makeMockCredentials, 7 | makeXrpcResponse, 8 | mockAccountDid, 9 | } from '../../../test/utils.js'; 10 | 11 | describe('createNewAccount', () => { 12 | it('should create and authenticate new account', async () => { 13 | const mockCredentials = makeMockCredentials(); 14 | const oldAgent = makeMockAgent(mockAccountDid); 15 | const newAgent = makeMockAgent(); 16 | const newServerDid = 'did:plc:newserver123'; 17 | 18 | vi.mocked(newAgent.com.atproto.server.describeServer).mockResolvedValue( 19 | makeXrpcResponse({ did: newServerDid, availableUserDomains: [] }), 20 | ); 21 | 22 | vi.mocked(oldAgent.com.atproto.server.getServiceAuth).mockResolvedValue( 23 | makeXrpcResponse({ token: 'jwt-token-123' }), 24 | ); 25 | 26 | await createNewAccount({ 27 | agents: { oldAgent, newAgent, accountDid: mockAccountDid }, 28 | credentials: mockCredentials, 29 | }); 30 | 31 | expect(oldAgent.com.atproto.server.getServiceAuth).toHaveBeenCalledWith({ 32 | aud: newServerDid, 33 | lxm: 'com.atproto.server.createAccount', 34 | }); 35 | 36 | expect(newAgent.com.atproto.server.createAccount).toHaveBeenCalledWith( 37 | { 38 | handle: mockCredentials.newHandle.handle, 39 | email: mockCredentials.newEmail, 40 | password: mockCredentials.newPassword, 41 | did: mockAccountDid, 42 | inviteCode: mockCredentials.inviteCode, 43 | }, 44 | { 45 | headers: { authorization: 'Bearer jwt-token-123' }, 46 | encoding: 'application/json', 47 | }, 48 | ); 49 | 50 | expect(newAgent.login).toHaveBeenCalledWith({ 51 | identifier: mockCredentials.newHandle.handle, 52 | password: mockCredentials.newPassword, 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/migration/operations/account.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type MigrationCredentials, 3 | type AgentPair, 4 | getMigrationHandle, 5 | } from '../types.js'; 6 | 7 | /** 8 | * Create a new account on the new PDS and login to it. 9 | * 10 | * @param options - Options bag. 11 | * @param options.agents - The agent pair. 12 | * @param options.credentials - The credentials. 13 | */ 14 | export async function createNewAccount({ 15 | agents: { accountDid, newAgent, oldAgent }, 16 | credentials, 17 | }: { 18 | agents: AgentPair; 19 | credentials: MigrationCredentials; 20 | }): Promise { 21 | const describeRes = await newAgent.com.atproto.server.describeServer(); 22 | const newServerDid = describeRes.data.did; 23 | 24 | const serviceJwtRes = await oldAgent.com.atproto.server.getServiceAuth({ 25 | aud: newServerDid, 26 | lxm: 'com.atproto.server.createAccount', 27 | }); 28 | const serviceJwt = serviceJwtRes.data.token; 29 | 30 | await newAgent.com.atproto.server.createAccount( 31 | { 32 | handle: getMigrationHandle(credentials), 33 | email: credentials.newEmail, 34 | password: credentials.newPassword, 35 | did: accountDid, 36 | inviteCode: credentials.inviteCode, 37 | }, 38 | { 39 | headers: { authorization: `Bearer ${serviceJwt}` }, 40 | encoding: 'application/json', 41 | }, 42 | ); 43 | 44 | await newAgent.login({ 45 | identifier: getMigrationHandle(credentials), 46 | password: credentials.newPassword, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/migration/operations/data.test.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorGetPreferences } from '@atproto/api'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | 4 | import { migrateData } from './data.js'; 5 | import { 6 | mockAccountDid, 7 | makeMockAgent, 8 | makeXrpcResponse, 9 | } from '../../../test/utils.js'; 10 | 11 | describe('migrateData', () => { 12 | it('should migrate repo, blobs, and preferences', async () => { 13 | const oldAgent = makeMockAgent(mockAccountDid); 14 | const newAgent = makeMockAgent(); 15 | 16 | const mockRepoData = new Uint8Array(); 17 | const mockPreferences = { 18 | preferences: {}, 19 | } as unknown as AppBskyActorGetPreferences.OutputSchema; 20 | const mockBlobData = { data: new Uint8Array() }; 21 | 22 | vi.mocked(oldAgent.com.atproto.sync.getRepo).mockResolvedValue( 23 | makeXrpcResponse(mockRepoData), 24 | ); 25 | vi.mocked(oldAgent.app.bsky.actor.getPreferences).mockResolvedValue( 26 | makeXrpcResponse(mockPreferences), 27 | ); 28 | vi.mocked(oldAgent.com.atproto.sync.listBlobs).mockResolvedValue( 29 | makeXrpcResponse({ 30 | cids: ['cid1', 'cid2'], 31 | cursor: '', 32 | }), 33 | ); 34 | 35 | vi.mocked(oldAgent.com.atproto.sync.getBlob).mockResolvedValue( 36 | makeXrpcResponse(mockBlobData.data, { 'content-type': 'image/jpeg' }), 37 | ); 38 | 39 | await migrateData({ 40 | oldAgent, 41 | newAgent, 42 | accountDid: mockAccountDid, 43 | }); 44 | 45 | expect(oldAgent.com.atproto.sync.getRepo).toHaveBeenCalledWith({ 46 | did: mockAccountDid, 47 | }); 48 | expect(newAgent.com.atproto.repo.importRepo).toHaveBeenCalledWith( 49 | mockRepoData, 50 | { encoding: 'application/vnd.ipld.car' }, 51 | ); 52 | 53 | expect(oldAgent.com.atproto.sync.listBlobs).toHaveBeenCalledWith({ 54 | did: mockAccountDid, 55 | }); 56 | 57 | expect(newAgent.com.atproto.repo.uploadBlob).toHaveBeenCalledTimes(2); 58 | expect(newAgent.app.bsky.actor.putPreferences).toHaveBeenCalledWith( 59 | mockPreferences, 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/migration/operations/data.ts: -------------------------------------------------------------------------------- 1 | import type { AgentPair } from '../types.js'; 2 | 3 | /** 4 | * Migrate the data repository, blobs, and preferences from the old PDS to the new PDS. 5 | * 6 | * @param agents - The agents for the migration. 7 | */ 8 | export async function migrateData(agents: AgentPair): Promise { 9 | const { oldAgent, newAgent, accountDid } = agents; 10 | // Migrate repo 11 | const repoRes = await oldAgent.com.atproto.sync.getRepo({ did: accountDid }); 12 | await newAgent.com.atproto.repo.importRepo(repoRes.data, { 13 | encoding: 'application/vnd.ipld.car', 14 | }); 15 | 16 | // Migrate blobs 17 | await migrateBlobs({ oldAgent, newAgent, accountDid }); 18 | 19 | // Migrate preferences 20 | const prefs = await oldAgent.app.bsky.actor.getPreferences(); 21 | await newAgent.app.bsky.actor.putPreferences(prefs.data); 22 | } 23 | 24 | async function migrateBlobs({ 25 | oldAgent, 26 | newAgent, 27 | accountDid, 28 | }: AgentPair): Promise { 29 | let blobCursor: string | undefined; 30 | do { 31 | const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 32 | did: accountDid, 33 | ...(blobCursor ? { cursor: blobCursor } : {}), 34 | }); 35 | 36 | for (const cid of listedBlobs.data.cids) { 37 | const blobRes = await oldAgent.com.atproto.sync.getBlob({ 38 | did: accountDid, 39 | cid, 40 | }); 41 | const contentType = blobRes.headers['content-type']; 42 | const opts = contentType ? { encoding: contentType } : undefined; 43 | 44 | await newAgent.com.atproto.repo.uploadBlob(blobRes.data, opts); 45 | } 46 | blobCursor = listedBlobs.data.cursor; 47 | } while (blobCursor); 48 | } 49 | -------------------------------------------------------------------------------- /src/migration/operations/finalize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | 3 | import { finalize } from './finalize.js'; 4 | import type { MigrationCredentialsWithFinalHandle } from '../../../test/utils.js'; 5 | import { 6 | assertHasFinalHandle, 7 | makeMockAgent, 8 | makeMockCredentials, 9 | makeMockCredentialsWithFinalHandle, 10 | mockAccountDid, 11 | } from '../../../test/utils.js'; 12 | import { makeAuthenticatedAgent } from '../utils.js'; 13 | 14 | vi.mock('../utils.js', () => ({ 15 | makeAuthenticatedAgent: vi.fn(), 16 | })); 17 | 18 | describe('finalize', () => { 19 | it('should activate new account and deactivate old account', async () => { 20 | const actualOldAgent = makeMockAgent(); 21 | const newAgent = makeMockAgent(); 22 | const mockCredentials = makeMockCredentials(); 23 | 24 | vi.mocked(makeAuthenticatedAgent).mockResolvedValue(actualOldAgent); 25 | 26 | await finalize({ 27 | agents: { 28 | oldAgent: makeMockAgent(), 29 | newAgent, 30 | accountDid: mockAccountDid, 31 | }, 32 | credentials: mockCredentials, 33 | }); 34 | 35 | expect(newAgent.com.atproto.server.activateAccount).toHaveBeenCalled(); 36 | expect( 37 | actualOldAgent.com.atproto.server.deactivateAccount, 38 | ).toHaveBeenCalled(); 39 | expect(newAgent.com.atproto.identity.updateHandle).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('should update handle if finalHandle is provided', async () => { 43 | const newAgent = makeMockAgent(); 44 | const mockCredentials: MigrationCredentialsWithFinalHandle = 45 | makeMockCredentialsWithFinalHandle('new.bar.com'); 46 | vi.mocked(makeAuthenticatedAgent).mockResolvedValue(makeMockAgent()); 47 | 48 | await finalize({ 49 | agents: { 50 | oldAgent: makeMockAgent(), 51 | newAgent, 52 | accountDid: mockAccountDid, 53 | }, 54 | credentials: mockCredentials, 55 | }); 56 | 57 | assertHasFinalHandle(mockCredentials); 58 | 59 | expect(newAgent.com.atproto.identity.updateHandle).toHaveBeenCalled(); 60 | expect(newAgent.com.atproto.identity.updateHandle).toHaveBeenCalledWith({ 61 | handle: mockCredentials.newHandle.finalHandle, 62 | }); 63 | }); 64 | 65 | it('should throw an error if updating handle fails', async () => { 66 | const newAgent = makeMockAgent(); 67 | vi.mocked(newAgent.com.atproto.identity.updateHandle).mockRejectedValue( 68 | new Error('foo'), 69 | ); 70 | const mockCredentials = makeMockCredentialsWithFinalHandle('new.bar.com'); 71 | vi.mocked(makeAuthenticatedAgent).mockResolvedValue(makeMockAgent()); 72 | 73 | const { temporaryHandle, finalHandle } = mockCredentials.newHandle; 74 | await expect( 75 | finalize({ 76 | agents: { 77 | oldAgent: makeMockAgent(), 78 | newAgent, 79 | accountDid: mockAccountDid, 80 | }, 81 | credentials: mockCredentials, 82 | }), 83 | ).rejects.toThrow( 84 | `Account successfully migrated, but failed to update handle to "${finalHandle}". The current handle is "${temporaryHandle}".`, 85 | ); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/migration/operations/finalize.ts: -------------------------------------------------------------------------------- 1 | import type { AgentPair, MigrationCredentials } from '../types.js'; 2 | import { makeAuthenticatedAgent } from '../utils.js'; 3 | 4 | /** 5 | * Finalize the migration by activating the new account on the new PDS and 6 | * deactivating the old account on the old PDS. 7 | * 8 | * @param options - Options bag. 9 | * @param options.agents - The agent pair. 10 | * @param options.credentials - The migration credentials. 11 | */ 12 | export async function finalize({ 13 | agents: { newAgent }, 14 | credentials, 15 | }: { 16 | agents: AgentPair; 17 | credentials: MigrationCredentials; 18 | }): Promise { 19 | await newAgent.com.atproto.server.activateAccount(); 20 | 21 | // Due to what is likely a bug in Bluesky's API, we need to re-authenticate 22 | // to the old PDS to deactivate the account. 23 | const oldAgent = await makeAuthenticatedAgent({ 24 | pdsUrl: credentials.oldPdsUrl, 25 | handle: credentials.oldHandle, 26 | password: credentials.oldPassword, 27 | }); 28 | // ATTN: The call will fail without the `{}` 29 | await oldAgent.com.atproto.server.deactivateAccount({}); 30 | 31 | if ('finalHandle' in credentials.newHandle) { 32 | try { 33 | await newAgent.com.atproto.identity.updateHandle({ 34 | handle: credentials.newHandle.finalHandle, 35 | }); 36 | } catch (error) { 37 | const { temporaryHandle, finalHandle } = credentials.newHandle; 38 | throw new Error( 39 | `Account successfully migrated, but failed to update handle to "${finalHandle}". The current handle is "${temporaryHandle}".`, 40 | { cause: error }, 41 | ); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/migration/operations/identity.test.ts: -------------------------------------------------------------------------------- 1 | import { Secp256k1Keypair } from '@atproto/crypto'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | 4 | import { migrateIdentity } from './identity.js'; 5 | import { 6 | makeMockAgent, 7 | makeXrpcResponse, 8 | mockAccountDid, 9 | } from '../../../test/utils.js'; 10 | 11 | vi.mock('@atproto/crypto', () => ({ 12 | Secp256k1Keypair: { 13 | create: vi.fn(), 14 | }, 15 | })); 16 | 17 | describe('migrateIdentity', () => { 18 | it('should perform identity migration successfully', async () => { 19 | const oldAgent = makeMockAgent(); 20 | const newAgent = makeMockAgent(); 21 | const mockToken = 'test-token'; 22 | const mockDid = 'did:key:mock123'; 23 | 24 | // @ts-expect-error 25 | vi.mocked(Secp256k1Keypair.create).mockResolvedValue({ 26 | did: () => mockDid, 27 | export: async () => Promise.resolve(new Uint8Array([1, 2, 3])), 28 | }); 29 | 30 | vi.mocked( 31 | newAgent.com.atproto.identity.getRecommendedDidCredentials, 32 | ).mockResolvedValue(makeXrpcResponse({ rotationKeys: ['existing-key'] })); 33 | 34 | vi.mocked(oldAgent.com.atproto.identity.signPlcOperation).mockResolvedValue( 35 | makeXrpcResponse({ operation: { signed: 'signed-operation' } }), 36 | ); 37 | 38 | const result = await migrateIdentity({ 39 | agents: { 40 | oldAgent, 41 | newAgent, 42 | accountDid: mockAccountDid, 43 | }, 44 | confirmationToken: mockToken, 45 | }); 46 | 47 | expect( 48 | newAgent.com.atproto.identity.getRecommendedDidCredentials, 49 | ).toHaveBeenCalled(); 50 | expect(oldAgent.com.atproto.identity.signPlcOperation).toHaveBeenCalledWith( 51 | { 52 | token: mockToken, 53 | rotationKeys: [mockDid, 'existing-key'], 54 | }, 55 | ); 56 | expect( 57 | newAgent.com.atproto.identity.submitPlcOperation, 58 | ).toHaveBeenCalledWith({ 59 | operation: { signed: 'signed-operation' }, 60 | }); 61 | expect(result).toBe('010203'); // hex string of mockPrivateKey 62 | }); 63 | 64 | it('should throw error if no rotation keys provided', async () => { 65 | const oldAgent = makeMockAgent(); 66 | const newAgent = makeMockAgent(); 67 | 68 | // @ts-expect-error 69 | vi.mocked(Secp256k1Keypair.create).mockResolvedValue({ 70 | did: () => 'did:key:mock123', 71 | export: async () => Promise.resolve(new Uint8Array([1, 2, 3])), 72 | }); 73 | 74 | vi.mocked( 75 | newAgent.com.atproto.identity.getRecommendedDidCredentials, 76 | ).mockResolvedValue(makeXrpcResponse({})); 77 | 78 | await expect( 79 | migrateIdentity({ 80 | agents: { 81 | oldAgent, 82 | newAgent, 83 | accountDid: mockAccountDid, 84 | }, 85 | confirmationToken: 'token', 86 | }), 87 | ).rejects.toThrow('New PDS did not provide any rotation keys'); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/migration/operations/identity.ts: -------------------------------------------------------------------------------- 1 | import { Secp256k1Keypair } from '@atproto/crypto'; 2 | import { toString as uint8ArrayToString } from 'uint8arrays/to-string'; 3 | 4 | import type { AgentPair, PlcOperationParams } from '../types.js'; 5 | 6 | /** 7 | * Migrate the identity from the old PDS to the new PDS. 8 | * 9 | * **NOTE:** The returned private key must be stored by the user. 10 | * 11 | * @param options - Options bag. 12 | * @param options.agents - The agent pair. 13 | * @param options.confirmationToken - The confirmation token to use for the operation. 14 | * @returns The private key of the recovery key for the new account. 15 | */ 16 | export async function migrateIdentity({ 17 | agents: { oldAgent, newAgent }, 18 | confirmationToken, 19 | }: { 20 | agents: AgentPair; 21 | confirmationToken: string; 22 | }): Promise { 23 | const { recoveryKey, privateKey } = await generateRecoveryKey(); 24 | 25 | const didCredentials = 26 | await newAgent.com.atproto.identity.getRecommendedDidCredentials(); 27 | const rotationKeys = didCredentials.data.rotationKeys ?? []; 28 | if (!didCredentials.data.rotationKeys) { 29 | throw new Error('New PDS did not provide any rotation keys'); 30 | } 31 | 32 | const plcCredentials: PlcOperationParams = { 33 | ...didCredentials.data, 34 | rotationKeys: [recoveryKey.did(), ...rotationKeys], 35 | token: confirmationToken, 36 | }; 37 | 38 | const plcOp = 39 | await oldAgent.com.atproto.identity.signPlcOperation(plcCredentials); 40 | 41 | await newAgent.com.atproto.identity.submitPlcOperation({ 42 | operation: plcOp.data.operation, 43 | }); 44 | 45 | return privateKey; 46 | } 47 | 48 | async function generateRecoveryKey() { 49 | const recoveryKey = await Secp256k1Keypair.create({ exportable: true }); 50 | const privateKeyBytes = await recoveryKey.export(); 51 | const privateKey = uint8ArrayToString(privateKeyBytes, 'hex'); 52 | return { recoveryKey, privateKey }; 53 | } 54 | -------------------------------------------------------------------------------- /src/migration/operations/index.ts: -------------------------------------------------------------------------------- 1 | export * from './account.js'; 2 | export * from './data.js'; 3 | export * from './finalize.js'; 4 | export * from './identity.js'; 5 | export * from './initialize.js'; 6 | export * from './request-plc-operation.js'; 7 | -------------------------------------------------------------------------------- /src/migration/operations/initialize.test.ts: -------------------------------------------------------------------------------- 1 | import { AtpAgent } from '@atproto/api'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | 4 | import { initializeAgents } from './initialize.js'; 5 | import { 6 | makeMockAgent, 7 | makeMockCredentials, 8 | mockAccountDid, 9 | } from '../../../test/utils.js'; 10 | 11 | vi.mock('@atproto/api', () => ({ 12 | AtpAgent: vi.fn(), 13 | })); 14 | 15 | describe('initializeAgents', () => { 16 | it('should initialize and authenticate agents successfully', async () => { 17 | const mockCredentials = makeMockCredentials(); 18 | const oldAgent = makeMockAgent(mockAccountDid); 19 | const newAgent = makeMockAgent(); 20 | 21 | vi.mocked(AtpAgent) 22 | .mockImplementationOnce(() => oldAgent) 23 | .mockImplementationOnce(() => newAgent); 24 | 25 | const result = await initializeAgents({ credentials: mockCredentials }); 26 | 27 | expect(oldAgent.login).toHaveBeenCalledWith({ 28 | identifier: mockCredentials.oldHandle, 29 | password: mockCredentials.oldPassword, 30 | }); 31 | expect(result.accountDid).toBe(mockAccountDid); 32 | expect(result.oldAgent).toBe(oldAgent); 33 | expect(result.newAgent).toBe(newAgent); 34 | }); 35 | 36 | it('should throw error if DID is not available after login', async () => { 37 | const mockCredentials = makeMockCredentials(); 38 | const oldAgent = makeMockAgent(); // no DID provided 39 | const newAgent = makeMockAgent(); 40 | 41 | vi.mocked(AtpAgent) 42 | .mockImplementationOnce(() => oldAgent) 43 | .mockImplementationOnce(() => newAgent); 44 | 45 | await expect( 46 | initializeAgents({ credentials: mockCredentials }), 47 | ).rejects.toThrow('Failed to get DID for old account'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/migration/operations/initialize.ts: -------------------------------------------------------------------------------- 1 | import { AtpAgent } from '@atproto/api'; 2 | 3 | import type { MigrationCredentials, AgentPair } from '../types.js'; 4 | import { makeAuthenticatedAgent } from '../utils.js'; 5 | 6 | /** 7 | * Initialize the agents and login to the old account on the old PDS. 8 | * 9 | * @param options - Options bag. 10 | * @param options.credentials - The credentials. 11 | * @returns The initialized agents. 12 | */ 13 | export async function initializeAgents({ 14 | credentials, 15 | }: { 16 | credentials: MigrationCredentials; 17 | }): Promise { 18 | const oldAgent = await makeAuthenticatedAgent({ 19 | pdsUrl: credentials.oldPdsUrl, 20 | handle: credentials.oldHandle, 21 | password: credentials.oldPassword, 22 | }); 23 | 24 | const accountDid = oldAgent.session?.did; 25 | if (!accountDid) { 26 | throw new Error('Failed to get DID for old account'); 27 | } 28 | 29 | const newAgent = new AtpAgent({ service: credentials.newPdsUrl }); 30 | 31 | return { oldAgent, newAgent, accountDid }; 32 | } 33 | -------------------------------------------------------------------------------- /src/migration/operations/request-plc-operation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { requestPlcOperation } from './request-plc-operation.js'; 4 | import { makeMockAgent, mockAccountDid } from '../../../test/utils.js'; 5 | 6 | describe('requestPlcOperation', () => { 7 | it('should request a PLC operation signature from the old PDS', async () => { 8 | const oldAgent = makeMockAgent(); 9 | await requestPlcOperation({ 10 | oldAgent, 11 | newAgent: makeMockAgent(), 12 | accountDid: mockAccountDid, 13 | }); 14 | 15 | expect( 16 | oldAgent.com.atproto.identity.requestPlcOperationSignature, 17 | ).toHaveBeenCalled(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/migration/operations/request-plc-operation.ts: -------------------------------------------------------------------------------- 1 | import type { AgentPair } from '../types.js'; 2 | 3 | /** 4 | * Request a PLC operation signature from the old PDS. 5 | * 6 | * @param options - Options bag. 7 | * @param options.oldAgent - The old agent. 8 | */ 9 | export async function requestPlcOperation({ 10 | oldAgent, 11 | }: AgentPair): Promise { 12 | await oldAgent.com.atproto.identity.requestPlcOperationSignature(); 13 | } 14 | -------------------------------------------------------------------------------- /src/migration/types.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { isPartialSerializedMigration } from './types.js'; 4 | import { makeMockCredentials } from '../../test/utils.js'; 5 | 6 | describe('isPartialMigration', () => { 7 | it('returns true for valid partial migration without token', () => { 8 | const credentials = makeMockCredentials(); 9 | expect(isPartialSerializedMigration({ credentials })).toBe(true); 10 | }); 11 | 12 | it('returns true for valid partial migration with token', () => { 13 | const credentials = makeMockCredentials(); 14 | expect( 15 | isPartialSerializedMigration({ 16 | credentials, 17 | confirmationToken: '123456', 18 | }), 19 | ).toBe(true); 20 | }); 21 | 22 | it.each([ 23 | ['null', null], 24 | ['undefined', undefined], 25 | ['number', 42], 26 | ['string', 'string'], 27 | ['array', []], 28 | ])('returns false for non-object: %s', (_, value) => { 29 | expect(isPartialSerializedMigration(value)).toBe(false); 30 | }); 31 | 32 | it.each([ 33 | ['empty object', {}], 34 | ['object with only token', { confirmationToken: '123456' }], 35 | ])('returns false for object without credentials: %s', (_, value) => { 36 | expect(isPartialSerializedMigration(value)).toBe(false); 37 | }); 38 | 39 | it.each([ 40 | ['state', { state: 'RequestedPlcOperation', credentials: {} }], 41 | [ 42 | 'state', 43 | { state: 'Finalized', credentials: {}, confirmationToken: '123456' }, 44 | ], 45 | ['state', { state: 'Foo', credentials: {}, confirmationToken: '123456' }], 46 | ])('returns false for object with state: %s', (_, value) => { 47 | expect(isPartialSerializedMigration(value)).toBe(false); 48 | }); 49 | 50 | it.each([ 51 | ['number token', { confirmationToken: 123 }], 52 | ['object token', { confirmationToken: {} }], 53 | ])('returns false for invalid confirmation token: %s', (_, value) => { 54 | const credentials = makeMockCredentials(); 55 | expect(isPartialSerializedMigration({ credentials, ...value })).toBe(false); 56 | }); 57 | 58 | it.each([ 59 | ['empty object', {}], 60 | ['object with properties', { foo: 'bar' }], 61 | ])('allows credentials as %s', (_, credentials) => { 62 | expect(isPartialSerializedMigration({ credentials })).toBe(true); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/migration/types.ts: -------------------------------------------------------------------------------- 1 | import type { AtpAgent } from '@atproto/api'; 2 | import type { TypeOf } from 'zod'; 3 | import { custom, enum as zEnum, object, string, union } from 'zod'; 4 | 5 | import { isEmail, isHandle, isHttpUrl } from '../utils/index.js'; 6 | 7 | export const migrationStateValues = Object.freeze([ 8 | 'Ready', 9 | 'Initialized', 10 | 'CreatedNewAccount', 11 | 'MigratedData', 12 | 'RequestedPlcOperation', 13 | 'MigratedIdentity', 14 | 'Finalized', 15 | ] as const); 16 | 17 | export const MigrationStateSchema = zEnum(migrationStateValues); 18 | 19 | export type MigrationState = TypeOf; 20 | 21 | const stateIndices = Object.freeze( 22 | migrationStateValues.reduce>( 23 | (acc, state, index) => { 24 | acc[state] = index; 25 | return acc; 26 | }, 27 | {} as Record, 28 | ), 29 | ); 30 | 31 | const getStateIndex = (state: MigrationState): number => stateIndices[state]; 32 | 33 | export const stateUtils = { 34 | gte: (state: MigrationState, other: MigrationState): boolean => 35 | getStateIndex(state) >= getStateIndex(other), 36 | }; 37 | 38 | const HttpUrl = custom(isHttpUrl, 'Must be a valid HTTP or HTTPS URL'); 39 | const Handle = custom(isHandle, 'Must be a valid handle'); 40 | const Email = custom(isEmail, 'Must be a valid email address'); 41 | export const NonEmptyStringSchema = custom( 42 | (value) => typeof value === 'string' && value.length > 0, 43 | 'Must be a non-empty string', 44 | ); 45 | 46 | /** 47 | * @param handle - The handle to validate. 48 | * @param pdsHostname - The PDS hostname. 49 | * @returns `true` if the handle is a subdomain of the PDS hostname, `false` otherwise. 50 | */ 51 | export const isPdsSubdomain = (handle: string, pdsHostname: string): boolean => 52 | handle.endsWith(`.${pdsHostname}`); 53 | 54 | export const MigrationCredentialsSchema = object({ 55 | oldPdsUrl: HttpUrl, 56 | newPdsUrl: HttpUrl, 57 | oldHandle: Handle, 58 | oldPassword: NonEmptyStringSchema, 59 | newHandle: union([ 60 | object({ 61 | temporaryHandle: Handle, 62 | finalHandle: Handle, 63 | }), 64 | object({ 65 | handle: Handle, 66 | }), 67 | ]), 68 | newEmail: Email, 69 | newPassword: NonEmptyStringSchema, 70 | inviteCode: NonEmptyStringSchema, 71 | }).refine( 72 | (data) => 73 | 'handle' in data.newHandle || 74 | isPdsSubdomain( 75 | data.newHandle.temporaryHandle, 76 | new URL(data.newPdsUrl).hostname, 77 | ), 78 | { 79 | message: 'Temporary new handle must be a subdomain of the new PDS hostname', 80 | }, 81 | ); 82 | 83 | export const makeMigrationCredentials = ( 84 | value: unknown, 85 | ): MigrationCredentials => MigrationCredentialsSchema.parse(value); 86 | 87 | const InitialSerializedMigration = object({ 88 | state: MigrationStateSchema.exclude(['MigratedIdentity', 'Finalized']), 89 | credentials: MigrationCredentialsSchema, 90 | confirmationToken: string().optional(), 91 | }); 92 | 93 | const UltimateSerializedMigration = object({ 94 | state: MigrationStateSchema.extract(['MigratedIdentity', 'Finalized']), 95 | credentials: MigrationCredentialsSchema, 96 | confirmationToken: string(), 97 | newPrivateKey: string(), 98 | }); 99 | 100 | export const SerializedMigrationSchema = union([ 101 | InitialSerializedMigration, 102 | UltimateSerializedMigration, 103 | ]); 104 | 105 | export type SerializedMigration = TypeOf; 106 | 107 | const PartialMigrationSchema = object({ 108 | credentials: object({}).passthrough(), 109 | confirmationToken: string().optional(), 110 | }).strict(); 111 | 112 | /** 113 | * A "partial" migration is a migration with only the credentials and, optionally, 114 | * a confirmation token set. We will assume that such a migration is in the 115 | * "Ready" state. 116 | */ 117 | export type PartialSerializedMigration = TypeOf; 118 | 119 | export const isPartialSerializedMigration = ( 120 | value: unknown, 121 | ): value is PartialSerializedMigration => 122 | PartialMigrationSchema.safeParse(value).success; 123 | 124 | export type MigrationCredentials = TypeOf; 125 | 126 | export const getMigrationHandle = (credentials: MigrationCredentials) => { 127 | if ('temporaryHandle' in credentials.newHandle) { 128 | return credentials.newHandle.temporaryHandle; 129 | } 130 | return credentials.newHandle.handle; 131 | }; 132 | 133 | export type AgentPair = { 134 | oldAgent: AtpAgent; 135 | newAgent: AtpAgent; 136 | accountDid: string; 137 | }; 138 | 139 | export type PlcOperationParams = { 140 | token: string; 141 | rotationKeys: string[]; 142 | alsoKnownAs?: string[]; 143 | services?: Record; 144 | }; 145 | -------------------------------------------------------------------------------- /src/migration/utils.ts: -------------------------------------------------------------------------------- 1 | import { AtpAgent } from '@atproto/api'; 2 | 3 | type AuthenticateAgentOptions = { 4 | pdsUrl: string; 5 | handle: string; 6 | password: string; 7 | }; 8 | 9 | /** 10 | * Get an authenticated agent for the given PDS URL, handle, and password. 11 | * 12 | * @param options - Options bag. 13 | * @param options.pdsUrl - The PDS URL. 14 | * @param options.handle - The handle. 15 | * @param options.password - The password. 16 | * @returns The authenticated agent. 17 | */ 18 | export const makeAuthenticatedAgent = async ({ 19 | pdsUrl, 20 | handle, 21 | password, 22 | }: AuthenticateAgentOptions): Promise => { 23 | const agent = new AtpAgent({ service: pdsUrl }); 24 | await agent.login({ 25 | identifier: handle, 26 | password, 27 | }); 28 | return agent; 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, 3 | it, 4 | expect, 5 | vi, 6 | beforeEach, 7 | beforeAll, 8 | afterAll, 9 | } from 'vitest'; 10 | 11 | import type { BaseArgv } from './cli.js'; 12 | import { makeHandler } from './cli.js'; 13 | 14 | const makeArgv = (debug: boolean): BaseArgv => { 15 | // eslint-disable-next-line @typescript-eslint/naming-convention 16 | return { debug, _: [], $0: '' }; 17 | }; 18 | 19 | describe('makeHandler', () => { 20 | let originalProcess: typeof process; 21 | let mockConsoleError: ReturnType; 22 | let mockProcessExitCode: ReturnType; 23 | 24 | beforeAll(() => { 25 | originalProcess = globalThis.process; 26 | // @ts-expect-error Yeah, you're not supposed to do this. 27 | globalThis.process = { 28 | exitCode: undefined, 29 | }; 30 | }); 31 | 32 | afterAll(() => { 33 | globalThis.process = originalProcess; 34 | }); 35 | 36 | beforeEach(() => { 37 | mockConsoleError = vi 38 | .spyOn(console, 'error') 39 | .mockImplementation(() => undefined); 40 | mockProcessExitCode = vi.spyOn(process, 'exitCode', 'set'); 41 | }); 42 | 43 | it('passes through successful execution', async () => { 44 | const mockFn = vi.fn(); 45 | const handler = makeHandler(mockFn); 46 | const argv = makeArgv(false); 47 | 48 | await handler(argv); 49 | 50 | expect(mockFn).toHaveBeenCalledOnce(); 51 | expect(mockFn).toHaveBeenCalledWith(argv); 52 | expect(mockConsoleError).not.toHaveBeenCalled(); 53 | expect(mockProcessExitCode).not.toHaveBeenCalled(); 54 | }); 55 | 56 | it('handles errors with debug=false', async () => { 57 | const error = new Error('test error'); 58 | const mockFn = vi.fn().mockRejectedValue(error); 59 | const handler = makeHandler(mockFn); 60 | const argv = makeArgv(false); 61 | 62 | await handler(argv); 63 | 64 | expect(mockConsoleError).toHaveBeenCalledWith('Error: test error'); 65 | expect(mockProcessExitCode).toHaveBeenCalledWith(1); 66 | }); 67 | 68 | it('handles errors with debug=true', async () => { 69 | const error = new Error('test error'); 70 | error.stack = ' test error\n at test'; 71 | const mockFn = vi.fn().mockRejectedValue(error); 72 | const handler = makeHandler(mockFn); 73 | const argv = makeArgv(true); 74 | 75 | await handler(argv); 76 | 77 | expect(mockConsoleError).toHaveBeenCalledWith(error); 78 | expect(error.message).toBe('Error: test error'); 79 | expect(mockProcessExitCode).toHaveBeenCalledWith(1); 80 | }); 81 | 82 | it('converts non-Error thrown values into errors', async () => { 83 | const mockFn = vi.fn().mockRejectedValue('string error'); 84 | const handler = makeHandler(mockFn); 85 | const argv = makeArgv(false); 86 | 87 | await handler(argv); 88 | 89 | expect(mockConsoleError).toHaveBeenCalledWith('Error: string error'); 90 | expect(mockProcessExitCode).toHaveBeenCalledWith(1); 91 | }); 92 | 93 | it('handles synchronous handlers', async () => { 94 | const mockFn = vi.fn(); 95 | const handler = makeHandler(mockFn); 96 | const argv = makeArgv(false); 97 | 98 | await handler(argv); 99 | 100 | expect(mockFn).toHaveBeenCalledOnce(); 101 | expect(mockFn).toHaveBeenCalledWith(argv); 102 | expect(mockConsoleError).not.toHaveBeenCalled(); 103 | expect(mockProcessExitCode).not.toHaveBeenCalled(); 104 | }); 105 | 106 | it('handles synchronous errors', async () => { 107 | const error = new Error('sync error'); 108 | const mockFn = vi.fn().mockImplementation(() => { 109 | throw error; 110 | }); 111 | const handler = makeHandler(mockFn); 112 | const argv = makeArgv(false); 113 | 114 | await handler(argv); 115 | 116 | expect(mockConsoleError).toHaveBeenCalledWith('Error: sync error'); 117 | expect(mockProcessExitCode).toHaveBeenCalledWith(1); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/utils/cli.ts: -------------------------------------------------------------------------------- 1 | import type { ArgumentsCamelCase } from 'yargs'; 2 | 3 | import { intoError } from './misc.js'; 4 | 5 | export type BaseArgv = ArgumentsCamelCase & { 6 | debug: boolean; 7 | }; 8 | 9 | type Handler = (argv: Argv) => void | Promise; 10 | 11 | /** 12 | * Wraps a handler function to catch errors and handle them based on the debug flag. 13 | * 14 | * @param handlerFn - The handler function to wrap. 15 | * @returns The wrapped handler function. 16 | */ 17 | export function makeHandler( 18 | handlerFn: Handler, 19 | ): Handler { 20 | return async (argv) => { 21 | try { 22 | await handlerFn(argv); 23 | } catch (thrown) { 24 | const error = intoError(thrown); 25 | prefixErrorMessage(error); 26 | console.error(argv.debug ? error : error.message); 27 | process.exitCode = 1; 28 | } 29 | }; 30 | } 31 | 32 | function prefixErrorMessage(error: Error): void { 33 | if (!error.message.startsWith('Error')) { 34 | error.message = 'Error: ' + error.message; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/handle.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import { isHandle } from './handle.js'; 4 | 5 | describe('isValidHandle', () => { 6 | it.each([ 7 | ['abc123.foo', true, 'handle with two segments'], 8 | ['abc123.ly', true, 'handle with two segments (two-character TLD)'], 9 | ['abc123.x', true, 'handle with two segments (one-character TLD)'], 10 | ['abc123.bsky.social', true, 'handle with three segments'], 11 | [ 12 | 'abc123.ly.social', 13 | true, 14 | 'handle with three segments (two-character inner segment)', 15 | ], 16 | [ 17 | 'abc123.x.social', 18 | true, 19 | 'handle with three segments (one-character inner segment)', 20 | ], 21 | ['valid-handle.bsky.social', true, 'with hyphen'], 22 | ['test.bsky.social', true, 'simple valid handle'], 23 | ['a1b2c3d4e5f6g7h8.bsky.social', true, 'long handle within limits'], 24 | ['abc.bsky.social', true, 'minimum length'], 25 | ['foo-bar-baz.bsky.social', true, 'multiple hyphens'], 26 | ['123handle.bsky.social', true, 'starts with number'], 27 | ['UPPERCASE.bsky.social', true, 'all uppercase'], 28 | ['mIxEdCaSe.bsky.social', true, 'mixed case'], 29 | ['aaa.bsky.social', true, 'minimum leftmost segment length'], 30 | [`${'a'.repeat(63)}.bsky.social`, true, 'maximum leftmost segment length'], 31 | ['foo', false, 'single segment, no periods'], 32 | ['foo.', false, 'single segment, ends with period'], 33 | ['.foo', false, 'single segment, starts with period'], 34 | ['foo..bar', false, 'two segments, multiple periods'], 35 | ['handle-.bsky.social', false, 'segment ends with hyphen'], 36 | ['-handle.bsky.social', false, 'segment starts with hyphen'], 37 | ['ab.bsky.social', false, 'leftmost segment too short (two characters)'], 38 | ['a.bsky.social', false, 'leftmost segment too short (one character)'], 39 | [`${'a'.repeat(64)}.bsky.social`, false, 'leftmost segment too long'], 40 | ['@handle.invalid', false, 'invalid character'], 41 | ])('%s should return %s (%s)', (handle, expected) => { 42 | expect(isHandle(handle)).toBe(expected); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils/handle.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: 2 | // https://github.com/bluesky-social/social-app/blob/704e36c2801c4c06a3763eaef90c6a3e532a326d/src/lib/strings/handles.ts#L5 3 | // 4 | // The RegEx is modified to match the behavior of the Bluesky app and PDS implementation, 5 | // both of which enforce a 3-character minimum for the leftmost handle segment. 6 | const VALIDATE_REGEX = 7 | /^([a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.)+([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/iu; 8 | 9 | /** 10 | * Based on the referenced sources, Bluesky handles are case-sensitive, 11 | * and each segment of a handle must: 12 | * - Be a string of alphanumerical characters and hyphens 13 | * - Be between 1 and 63 characters long, inclusive 14 | * - Not start or end with a hyphen 15 | * In addition, the leftmost segment must be at least 3 characters long. This is enforced 16 | * by both the Bluesky app and the PDS. 17 | * 18 | * Refs: 19 | * - https://github.com/bluesky-social/social-app/blob/704e36c2801c4c06a3763eaef90c6a3e532a326d/src/lib/strings/handles.ts#L41 20 | * - https://github.com/bluesky-social/atproto/blob/a940c3fceff7a03e434b12b4dc9ce71ceb3bb419/packages/pds/src/handle/index.ts 21 | * 22 | * @param value - The value to validate. 23 | * @returns Whether the value is a valid handle. 24 | */ 25 | export const isHandle = (value: string): boolean => VALIDATE_REGEX.test(value); 26 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handle.js'; 2 | export * from './misc.js'; 3 | export * from './terminal.js'; 4 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { ZodError } from 'zod'; 2 | 3 | export const isPlainObject = ( 4 | value: unknown, 5 | ): value is Record => 6 | typeof value === 'object' && value !== null && !Array.isArray(value); 7 | 8 | /** 9 | * @param value - The value to check. 10 | * @returns Whether the value is a valid HTTP or HTTPS URL. 11 | */ 12 | export const isHttpUrl = (value: unknown): boolean => { 13 | if (typeof value !== 'string') { 14 | return false; 15 | } 16 | 17 | try { 18 | return /^https?:$/u.test(new URL(value).protocol); 19 | } catch { 20 | return false; 21 | } 22 | }; 23 | 24 | export const isEmail = (value: unknown): boolean => { 25 | if (typeof value !== 'string') { 26 | return false; 27 | } 28 | 29 | return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); 30 | }; 31 | 32 | export const stringify = (value: unknown) => JSON.stringify(value, null, 2); 33 | 34 | /** 35 | * Handle an unknown error. Includes special handling for {@link ZodError} errors. 36 | * 37 | * @param message - The message to display. 38 | * @param error - The error to display. 39 | * @returns The readable error. 40 | */ 41 | export const handleUnknownError = (message: string, error: unknown): Error => { 42 | return error instanceof ZodError 43 | ? new Error( 44 | message + 45 | (error.issues ? '\n' + error.issues.map(stringify).join('\n') : ''), 46 | { cause: error }, 47 | ) 48 | : new Error(message, { cause: error }); 49 | }; 50 | 51 | export const intoError = (error: unknown): Error => { 52 | return error instanceof Error ? error : new Error(String(error)); 53 | }; 54 | 55 | /** 56 | * Pick non-`#` properties from a type. 57 | * 58 | * @template Type - The type to pick public properties from. 59 | */ 60 | export type PickPublic = Pick< 61 | Type, 62 | { 63 | [K in keyof Type]: K extends `#${string}` ? never : K; 64 | }[keyof Type] 65 | >; 66 | 67 | /** 68 | * Consume an async generator until it yields a value. 69 | * 70 | * @template Yield - The type of the value to yield. 71 | * @param generator - The generator to consume. 72 | * @returns The value yielded by the generator. 73 | */ 74 | export const consume = async ( 75 | generator: AsyncGenerator, 76 | ): Promise => { 77 | let result: Yield | undefined; 78 | for await (const value of generator) { 79 | result = value; 80 | } 81 | return result; 82 | }; 83 | -------------------------------------------------------------------------------- /src/utils/terminal.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import boxen from 'boxen'; 3 | import wrapAnsi from 'wrap-ansi'; 4 | import yoctoSpinner from 'yocto-spinner'; 5 | import { red } from 'yoctocolors'; 6 | 7 | const getTermWidth = () => process.stdout.columns || 50; 8 | 9 | export const logWelcome = () => { 10 | console.log( 11 | boxen('Welcome to the Bluesky account migration tool!', { 12 | padding: 1, 13 | borderColor: 'blue', 14 | title: '🦋', 15 | }), 16 | ); 17 | }; 18 | 19 | export const logWrapped = (message: string) => { 20 | console.log(wrap(message)); 21 | }; 22 | 23 | /** 24 | * Log a delimiter line to stdout. 25 | * 26 | * @param character - The character to use for the line. 27 | */ 28 | export const logDelimiter = (character: string) => { 29 | assert(character.length === 1, 'Must provide a single character'); 30 | console.log(character.repeat(getTermWidth())); 31 | }; 32 | 33 | export const logWarning = (message: string) => { 34 | console.log( 35 | boxen(message, { padding: 1, borderColor: 'yellow', title: '⚠️' }), 36 | ); 37 | }; 38 | 39 | export const logError = ( 40 | error: unknown, 41 | fallback = 'An unknown error occurred, please report this bug', 42 | ) => { 43 | let message; 44 | if (error instanceof Error) { 45 | message = error.message; 46 | } else if (typeof error === 'string') { 47 | message = error; 48 | } else { 49 | message = fallback; 50 | } 51 | 52 | console.log(wrap(red(message))); 53 | }; 54 | 55 | /** 56 | * Log a message to stdout, centered and wrapped to the terminal width. 57 | * 58 | * @param message - The message to log. 59 | * @param padding - The number of columns to pad the message with on the left _and_ right. 60 | */ 61 | export const logCentered = (message: string, padding: number = 3) => { 62 | const wrapped = wrap(message, padding); 63 | 64 | const output = wrapped 65 | .split('\n') 66 | .map((line) => ' '.repeat(padding) + line) 67 | .join('\n'); 68 | 69 | console.log(output); 70 | }; 71 | 72 | export function wrap(message: string, padding = 0) { 73 | return wrapAnsi(message, getTermWidth() - padding, { hard: true }); 74 | } 75 | 76 | export const makeSpinner = () => 77 | yoctoSpinner({ 78 | color: 'blue', 79 | }); 80 | -------------------------------------------------------------------------------- /test/e2e/cli.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import type { SerializedMigration } from '../../src/migration/types.js'; 4 | import type { MockOperationPlan } from '../mocks/operations.js'; 5 | import { getPackageJson, runCli } from '../utils/cli.js'; 6 | import { makeMockCredentials } from '../utils.js'; 7 | 8 | describe('CLI', () => { 9 | describe('pipe mode', () => { 10 | it('runs new migration to the RequestedPlcOperation state', async () => { 11 | const testPlan: MockOperationPlan = {}; 12 | 13 | const credentials = makeMockCredentials(); 14 | const input: SerializedMigration = { 15 | state: 'Ready', 16 | credentials, 17 | }; 18 | 19 | const { stdout } = await runCli(['--pipe'], { 20 | input: JSON.stringify(input), 21 | testPlan, 22 | }); 23 | 24 | const result = JSON.parse(stdout) as SerializedMigration; 25 | expect(result.state).toBe('RequestedPlcOperation'); 26 | expect(result.credentials).toStrictEqual(credentials); 27 | }); 28 | 29 | it('completes migration with confirmation token', async () => { 30 | const testPlan: MockOperationPlan = { 31 | newPrivateKey: '0xdeadbeef', 32 | }; 33 | 34 | const credentials = makeMockCredentials(); 35 | const input: SerializedMigration = { 36 | state: 'Ready', 37 | credentials, 38 | confirmationToken: '123456', 39 | }; 40 | 41 | const { stdout } = await runCli(['--pipe'], { 42 | input: JSON.stringify(input), 43 | testPlan, 44 | }); 45 | 46 | const result = JSON.parse(stdout) as Record; 47 | expect(result.state).toBe('Finalized'); 48 | expect(result.credentials).toStrictEqual(credentials); 49 | expect(result.newPrivateKey).toBe('0xdeadbeef'); 50 | }); 51 | 52 | it('handles invalid JSON input', async () => { 53 | const testPlan: MockOperationPlan = { 54 | failureState: 'Ready', 55 | }; 56 | 57 | const { stderr, code } = await runCli(['--pipe'], { 58 | input: 'invalid json', 59 | testPlan, 60 | }); 61 | 62 | expect(code).toBe(1); 63 | expect(stderr).toContain('Error: Invalid input: must be JSON'); 64 | }); 65 | 66 | it('handles migration failures', async () => { 67 | const testPlan: MockOperationPlan = { 68 | failureState: 'MigratedIdentity', 69 | }; 70 | 71 | const credentials = makeMockCredentials(); 72 | const input: SerializedMigration = { 73 | state: 'Ready', 74 | credentials, 75 | confirmationToken: '123456', 76 | }; 77 | 78 | const { stdout, stderr, code } = await runCli(['--pipe'], { 79 | input: JSON.stringify(input), 80 | testPlan, 81 | }); 82 | 83 | const result = JSON.parse(stdout) as SerializedMigration; 84 | expect(result.state).toBe(testPlan.failureState); 85 | expect(stderr).toContain( 86 | `Error: Migration failed during state "${testPlan.failureState}"`, 87 | ); 88 | expect(code).toBe(1); 89 | }); 90 | }); 91 | 92 | // All interactive elements are mocked, but we at least test that the CLI 93 | // behaves as expected given a set of valid inputs. We rely on unit tests 94 | // for the interactive elements themselves. 95 | describe('interactive mode', () => { 96 | it('completes migration in interactive mode', async () => { 97 | const testPlan: MockOperationPlan = { 98 | newPrivateKey: '0xdeadbeef', 99 | }; 100 | 101 | const { stdout, code } = await runCli(['--interactive'], { 102 | testPlan, 103 | }); 104 | 105 | expect(code).toBe(0); 106 | expect(stdout).toContain( 107 | 'Welcome to the Bluesky account migration tool!', 108 | ); 109 | expect(stdout).toContain('Migration completed successfully'); 110 | expect(stdout).toContain('0xdeadbeef'); 111 | }); 112 | 113 | it('handles migration failure before creating a new account', async () => { 114 | const testPlan: MockOperationPlan = { 115 | failureState: 'Ready', 116 | }; 117 | 118 | const { stdout, stderr, code } = await runCli(['--interactive'], { 119 | testPlan, 120 | }); 121 | 122 | expect(code).toBe(1); 123 | expect(stdout).toContain( 124 | 'Welcome to the Bluesky account migration tool!', 125 | ); 126 | expect(stdout).not.toContain( 127 | 'The migration has created a new account, but it may not be ready to use yet.', 128 | ); 129 | expect(stderr).toContain( 130 | `Error: Migration failed during state "${testPlan.failureState}"`, 131 | ); 132 | }); 133 | 134 | it('handles migration failure after creating a new account', async () => { 135 | const testPlan: MockOperationPlan = { 136 | failureState: 'RequestedPlcOperation', 137 | }; 138 | 139 | const { stdout, stderr, code } = await runCli(['--interactive'], { 140 | testPlan, 141 | }); 142 | 143 | expect(code).toBe(1); 144 | expect(stdout).toContain( 145 | 'Welcome to the Bluesky account migration tool!', 146 | ); 147 | expect(stdout).toContain( 148 | 'The migration has created a new account, but it may not be ready to use yet.', 149 | ); 150 | expect(stderr).toContain( 151 | `Error: Migration failed during state "${testPlan.failureState}"`, 152 | ); 153 | }); 154 | 155 | it('handles migration failure after private key is generated', async () => { 156 | const testPlan: MockOperationPlan = { 157 | failureState: 'MigratedIdentity', 158 | newPrivateKey: '0xdeadbeef', 159 | }; 160 | 161 | const { stdout, stderr, code } = await runCli(['--interactive'], { 162 | testPlan, 163 | }); 164 | 165 | expect(code).toBe(1); 166 | expect(stdout).toContain( 167 | 'Welcome to the Bluesky account migration tool!', 168 | ); 169 | expect(stdout).toContain( 170 | 'The migration has created a new account, but it may not be ready to use yet.', 171 | ); 172 | expect(stdout).toContain( 173 | 'You should still save the private key in a secure location.', 174 | ); 175 | expect(stdout).toContain('0xdeadbeef'); 176 | expect(stderr).toContain( 177 | `Error: Migration failed during state "${testPlan.failureState}"`, 178 | ); 179 | }); 180 | }); 181 | 182 | describe('help', () => { 183 | it('shows help text', async () => { 184 | const testPlan: MockOperationPlan = {}; 185 | const { stdout } = await runCli(['--help'], { 186 | testPlan, 187 | }); 188 | expect(stdout).toContain('Usage:'); 189 | expect(stdout).toContain('Options:'); 190 | expect(stdout).toContain('Mode (choose one):'); 191 | }); 192 | }); 193 | 194 | describe('version', () => { 195 | it('shows version', async () => { 196 | const testPlan: MockOperationPlan = {}; 197 | const { stdout } = await runCli(['--version'], { 198 | testPlan, 199 | }); 200 | const { version } = await getPackageJson(); 201 | expect(stdout).toContain(version); 202 | }); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /test/echoMigration.js: -------------------------------------------------------------------------------- 1 | // For getting a serialized migration object that can be piped to the CLI 2 | 3 | const serializedMigration = { 4 | state: 'Ready', 5 | credentials: { 6 | oldPdsUrl: 'https://old.bsky.social', 7 | newPdsUrl: 'https://new.bsky.social', 8 | oldHandle: 'old.handle.com', 9 | oldPassword: 'oldpass123', 10 | newHandle: 'new.handle.com', 11 | newEmail: 'new@email.com', 12 | newPassword: 'newpass123', 13 | inviteCode: 'invite-123', 14 | }, 15 | confirmationToken: '123456', 16 | newPrivateKey: '0xdeadbeef', 17 | }; 18 | 19 | console.log(JSON.stringify(serializedMigration)); 20 | 21 | export {}; 22 | -------------------------------------------------------------------------------- /test/mocks/Migration.ts: -------------------------------------------------------------------------------- 1 | import { 2 | migrationStateValues, 3 | MigrationStateSchema, 4 | SerializedMigrationSchema, 5 | stateUtils, 6 | } from '../../src/migration/index.js'; 7 | import type { 8 | AgentPair, 9 | MigrationCredentials, 10 | SerializedMigration, 11 | MigrationState, 12 | Migration as ActualMigration, 13 | } from '../../src/migration/index.js'; 14 | import { consume, type PickPublic } from '../../src/utils/misc.js'; 15 | 16 | const failureCondition = getFailureCondition(); 17 | 18 | const mockAccountDid = 'did:plc:testuser123'; 19 | 20 | const mockNewPrivateKey = '0xdeadbeef'; 21 | 22 | export class Migration implements PickPublic { 23 | state: MigrationState = 'Ready'; 24 | 25 | stateIndex = 0; 26 | 27 | credentials: MigrationCredentials; 28 | 29 | accountDid = mockAccountDid; 30 | 31 | newPrivateKey: string | undefined = mockNewPrivateKey; 32 | 33 | confirmationToken: string | undefined; 34 | 35 | agents = { 36 | oldAgent: {}, 37 | newAgent: {}, 38 | accountDid: mockAccountDid, 39 | } as unknown as AgentPair; 40 | 41 | constructor({ credentials }: { credentials: MigrationCredentials }) { 42 | this.credentials = credentials; 43 | } 44 | 45 | static async deserialize(data: SerializedMigration): Promise { 46 | const parsed = SerializedMigrationSchema.parse(data); 47 | 48 | const migration = new Migration(parsed); 49 | migration.state = parsed.state; 50 | migration.confirmationToken = 51 | 'confirmationToken' in parsed ? parsed.confirmationToken : undefined; 52 | migration.newPrivateKey = 53 | 'newPrivateKey' in parsed ? parsed.newPrivateKey : undefined; 54 | 55 | return migration; 56 | } 57 | 58 | async *runIter(): AsyncGenerator { 59 | while (this.state !== 'Finalized') { 60 | yield this.state; 61 | if ( 62 | this.state === 'RequestedPlcOperation' && 63 | this.confirmationToken === undefined 64 | ) { 65 | return; 66 | } 67 | this.stateIndex++; 68 | this.state = migrationStateValues[this.stateIndex] as MigrationState; 69 | await new Promise((resolve) => setTimeout(resolve, 1000)); 70 | } 71 | if (this.state === 'Finalized' && this.newPrivateKey === undefined) { 72 | this.newPrivateKey = mockNewPrivateKey; 73 | } 74 | this.#maybeFail(); 75 | yield this.state; 76 | } 77 | 78 | async run(): Promise { 79 | await consume(this.runIter()); 80 | return this.state; 81 | } 82 | 83 | #maybeFail() { 84 | if (failureCondition) { 85 | if (stateUtils.gte(this.state, failureCondition)) { 86 | this.state = failureCondition; 87 | throw new Error(`Migration failed during state "${this.state}"`); 88 | } 89 | } 90 | } 91 | 92 | async teardown(): Promise { 93 | this.state = 'Finalized'; 94 | } 95 | 96 | serialize(): SerializedMigration { 97 | const data: SerializedMigration = { 98 | state: this.state, 99 | credentials: this.credentials, 100 | // Lie about these 101 | confirmationToken: this.confirmationToken as string, 102 | newPrivateKey: this.newPrivateKey as string, 103 | }; 104 | // Strip undefined values 105 | return JSON.parse(JSON.stringify(data)); 106 | } 107 | } 108 | 109 | function getFailureCondition() { 110 | const condition = process.env.FAILURE_CONDITION ?? undefined; 111 | if (condition !== undefined) { 112 | if (MigrationStateSchema.safeParse(condition).success) { 113 | return condition as MigrationState; 114 | } 115 | throw new Error(`Invalid failure condition: ${condition}`); 116 | } 117 | return undefined; 118 | } 119 | -------------------------------------------------------------------------------- /test/mocks/credentials.ts: -------------------------------------------------------------------------------- 1 | import { makeMockCredentials } from '../utils.js'; 2 | 3 | export const validateString = (value: string) => 4 | value.length > 0 || 'Must be a non-empty string'; 5 | 6 | export const getCredentialsInteractive = async () => makeMockCredentials(); 7 | -------------------------------------------------------------------------------- /test/mocks/operations.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AgentPair, 3 | MigrationCredentials, 4 | MigrationState, 5 | } from '../../src/migration/types.js'; 6 | 7 | export type MockOperationPlan = { 8 | failureState?: MigrationState; 9 | newPrivateKey?: string; 10 | }; 11 | 12 | const envPlan = process.env.MOCK_OPERATIONS_PLAN; 13 | if (!envPlan) { 14 | throw new Error('MOCK_OPERATIONS_PLAN environment variable is not set'); 15 | } 16 | const plan: MockOperationPlan = JSON.parse(envPlan); 17 | 18 | const makeMockAgent = () => { 19 | return { 20 | login: async () => undefined, 21 | logout: async () => undefined, 22 | }; 23 | }; 24 | 25 | const failIfPlanned = (currentState: MigrationState) => { 26 | if (plan.failureState === currentState) { 27 | throw new Error(`Planned failure during state "${currentState}"`); 28 | } 29 | }; 30 | 31 | export const initializeAgents = async (_args: { 32 | credentials: MigrationCredentials; 33 | }) => { 34 | failIfPlanned('Ready'); 35 | const agents = { 36 | oldAgent: makeMockAgent(), 37 | newAgent: makeMockAgent(), 38 | accountDid: 'did:plc:testuser123', 39 | } as unknown as AgentPair; 40 | return agents; 41 | }; 42 | 43 | export const createNewAccount = async () => { 44 | failIfPlanned('Initialized'); 45 | }; 46 | 47 | export const migrateData = async () => { 48 | failIfPlanned('CreatedNewAccount'); 49 | }; 50 | 51 | export const requestPlcOperation = async () => { 52 | failIfPlanned('MigratedData'); 53 | }; 54 | 55 | export const migrateIdentity = async () => { 56 | failIfPlanned('RequestedPlcOperation'); 57 | return plan.newPrivateKey; 58 | }; 59 | 60 | export const finalize = async () => { 61 | failIfPlanned('MigratedIdentity'); 62 | }; 63 | -------------------------------------------------------------------------------- /test/mocks/prompts.ts: -------------------------------------------------------------------------------- 1 | export const input = async () => '123456'; 2 | 3 | export const pressEnter = async () => undefined; 4 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AtpAgent } from '@atproto/api'; 2 | import type { HeadersMap } from '@atproto/xrpc'; 3 | import type { Mock, Mocked } from 'vitest'; 4 | import { vi } from 'vitest'; 5 | 6 | import type { Migration, operations } from '../src/migration/index.js'; 7 | import type { 8 | MigrationCredentials, 9 | MigrationState, 10 | SerializedMigration, 11 | } from '../src/migration/types.js'; 12 | 13 | export type MockMigration = { 14 | credentials: MigrationCredentials; 15 | confirmationToken?: string | undefined; 16 | newPrivateKey?: string | undefined; 17 | run: Mock<() => Promise>; 18 | runIter: Mock<() => AsyncGenerator>; 19 | state: MigrationState; 20 | serialize: Mock<() => SerializedMigration>; 21 | deserialize: Mock<() => Migration>; 22 | }; 23 | 24 | export const makeMockCredentials = (): MigrationCredentialsWithHandle => ({ 25 | oldPdsUrl: 'https://bsky.social', 26 | newPdsUrl: 'https://foo.com', 27 | oldHandle: 'old.handle.com', 28 | oldPassword: 'oldpass123', 29 | newHandle: { handle: 'new.foo.com' }, 30 | newEmail: 'new@email.com', 31 | newPassword: 'newpass123', 32 | inviteCode: 'invite-123', 33 | }); 34 | 35 | export const makeMockCredentialsWithFinalHandle = ( 36 | finalHandle: string, 37 | ): MigrationCredentialsWithFinalHandle => ({ 38 | ...makeMockCredentials(), 39 | newHandle: { 40 | temporaryHandle: 'new.foo.com', 41 | finalHandle, 42 | }, 43 | }); 44 | 45 | export type MigrationCredentialsWithHandle = Exclude< 46 | MigrationCredentials, 47 | 'newHandle' 48 | > & { 49 | newHandle: { 50 | handle: string; 51 | }; 52 | }; 53 | 54 | export type MigrationCredentialsWithFinalHandle = Exclude< 55 | MigrationCredentials, 56 | 'newHandle' 57 | > & { 58 | newHandle: { 59 | temporaryHandle: string; 60 | finalHandle: string; 61 | }; 62 | }; 63 | 64 | export const assertHasHandle = ( 65 | credentials: MigrationCredentials, 66 | ): asserts credentials is MigrationCredentialsWithHandle => { 67 | if ('handle' in credentials.newHandle) { 68 | return; 69 | } 70 | throw new Error('handle is not defined'); 71 | }; 72 | 73 | export const assertHasFinalHandle: ( 74 | credentials: MigrationCredentials, 75 | ) => asserts credentials is MigrationCredentialsWithFinalHandle = ( 76 | credentials, 77 | ) => { 78 | if ('finalHandle' in credentials.newHandle) { 79 | return; 80 | } 81 | throw new Error('finalHandle is not defined'); 82 | }; 83 | 84 | export const makeMockOperations = ( 85 | mocks: Partial = {}, 86 | ): typeof operations => ({ 87 | initializeAgents: vi.fn(), 88 | createNewAccount: vi.fn(), 89 | migrateData: vi.fn(), 90 | requestPlcOperation: vi.fn(), 91 | migrateIdentity: vi.fn(), 92 | finalize: vi.fn(), 93 | ...mocks, 94 | }); 95 | 96 | export const mockAccountDid = 'did:plc:testuser123'; 97 | 98 | export const makeXrpcResponse = ( 99 | data: Data, 100 | headers: HeadersMap = {}, 101 | ) => ({ 102 | success: true, 103 | headers, 104 | data, 105 | }); 106 | 107 | export function makeMockAgent(did?: string): Mocked { 108 | return { 109 | login: vi.fn().mockResolvedValue(undefined), 110 | session: did ? { did } : undefined, 111 | app: { 112 | bsky: { 113 | actor: { 114 | getPreferences: vi.fn(), 115 | putPreferences: vi.fn(), 116 | }, 117 | }, 118 | }, 119 | com: { 120 | atproto: { 121 | server: { 122 | describeServer: vi.fn(), 123 | getServiceAuth: vi.fn(), 124 | createAccount: vi.fn(), 125 | activateAccount: vi.fn(), 126 | deactivateAccount: vi.fn(), 127 | }, 128 | sync: { 129 | getRepo: vi.fn(), 130 | listBlobs: vi.fn(), 131 | getBlob: vi.fn(), 132 | }, 133 | repo: { 134 | importRepo: vi.fn(), 135 | uploadBlob: vi.fn(), 136 | }, 137 | identity: { 138 | requestPlcOperationSignature: vi.fn(), 139 | getRecommendedDidCredentials: vi.fn(), 140 | signPlcOperation: vi.fn(), 141 | submitPlcOperation: vi.fn(), 142 | updateHandle: vi.fn(), 143 | }, 144 | }, 145 | }, 146 | } as unknown as Mocked; 147 | } 148 | -------------------------------------------------------------------------------- /test/utils/cli.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process'; 2 | import { readFile } from 'node:fs/promises'; 3 | import { join } from 'node:path'; 4 | 5 | import { stringify } from '../../src/utils/misc.js'; 6 | import type { MockOperationPlan } from '../mocks/operations.js'; 7 | 8 | type RunCliOptions = { 9 | input?: string; 10 | env?: Record; 11 | testPlan: MockOperationPlan; 12 | }; 13 | 14 | const normalizeOutput = (str: string) => 15 | // First strip ANSI codes 16 | str 17 | .replace( 18 | // eslint-disable-next-line no-control-regex 19 | /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/gu, 20 | '', 21 | ) 22 | // Then normalize whitespace: convert newlines to spaces and collapse multiple spaces 23 | .replace(/\s+/gu, ' ') 24 | .trim(); 25 | 26 | export async function runCli( 27 | args: string[], 28 | options: RunCliOptions, 29 | ): Promise<{ 30 | stdout: string; 31 | stderr: string; 32 | code: number; 33 | }> { 34 | const { input, env, testPlan } = options; 35 | const shouldFail = testPlan.failureState !== undefined; 36 | const cliPath = await getCliPath(); 37 | 38 | return new Promise((resolve, reject) => { 39 | const child = spawn(process.execPath, [cliPath, ...args], { 40 | env: { 41 | ...process.env, 42 | NODE_ENV: 'test', 43 | MOCK_OPERATIONS_PLAN: JSON.stringify(testPlan), 44 | ...env, 45 | }, 46 | stdio: ['pipe', 'pipe', 'pipe'], 47 | }); 48 | 49 | let stdout = ''; 50 | let stderr = ''; 51 | 52 | child.stdout.on('data', (data) => { 53 | stdout += data; 54 | }); 55 | child.stderr.on('data', (data) => { 56 | stderr += data; 57 | }); 58 | 59 | if (input) { 60 | child.stdin.write(input); 61 | child.stdin.end(); 62 | } 63 | 64 | child.on('close', (code) => { 65 | const failed = code !== 0; 66 | if (!failed && shouldFail) { 67 | reject( 68 | new Error( 69 | `Expected command to fail, but it succeeded: \n${stringify({ 70 | stdout, 71 | stderr, 72 | })}`, 73 | ), 74 | ); 75 | } else if (failed && !shouldFail) { 76 | reject( 77 | new Error( 78 | `Command failed unexpectedly: ${code}\n${stringify({ stdout, stderr })}`, 79 | ), 80 | ); 81 | } else { 82 | resolve({ 83 | stdout: normalizeOutput(stdout), 84 | stderr: normalizeOutput(stderr), 85 | code: code ?? 0, 86 | }); 87 | } 88 | }); 89 | }); 90 | } 91 | 92 | async function getCliPath() { 93 | const packageJson = await getPackageJson(); 94 | return join(getRootDir(), packageJson.bin.bam); 95 | } 96 | 97 | export async function getPackageJson() { 98 | const rootDir = getRootDir(); 99 | return JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf8')); 100 | } 101 | 102 | function getRootDir() { 103 | return join(import.meta.dirname, '../..'); 104 | } 105 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "exclude": ["**/*.test.ts"], 7 | "include": ["./src"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "rootDir": ".", 5 | "esModuleInterop": true, 6 | "exactOptionalPropertyTypes": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "lib": ["ESNext"], 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "noErrorTruncation": true, 12 | "noUncheckedIndexedAccess": true, 13 | "removeComments": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "target": "ES2022", 18 | "types": ["node"] 19 | }, 20 | "include": ["./src", "./test", "vitest.config*.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /vitest.config.e2e.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | restoreMocks: true, 6 | include: ['test/e2e/**/*.test.ts'], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | restoreMocks: true, 6 | include: ['src/**/*.test.ts'], 7 | coverage: { 8 | enabled: true, 9 | include: ['src/**/*'], 10 | provider: 'istanbul', 11 | reporter: ['text', 'json', 'html'], 12 | thresholds: { 13 | autoUpdate: true, 14 | lines: 85.5, 15 | functions: 82.29, 16 | statements: 85.71, 17 | branches: 64.7, 18 | }, 19 | }, 20 | }, 21 | }); 22 | --------------------------------------------------------------------------------