feat: initial oauth implementation 960999bb
Steve · 2026-02-02 10:25 10 file(s) · +812 −44
bun.lock +67 −1
24 24
    },
25 25
    "packages/cli": {
26 26
      "name": "sequoia-cli",
27 -
      "version": "0.2.0",
27 +
      "version": "0.2.1",
28 28
      "bin": {
29 29
        "sequoia": "dist/index.js",
30 30
      },
31 31
      "dependencies": {
32 32
        "@atproto/api": "^0.18.17",
33 +
        "@atproto/oauth-client-node": "^0.3.16",
33 34
        "@clack/prompts": "^1.0.0",
34 35
        "cmd-ts": "^0.14.3",
35 36
        "glob": "^13.0.0",
36 37
        "mime-types": "^2.1.35",
37 38
        "minimatch": "^10.1.1",
39 +
        "open": "^11.0.0",
38 40
      },
39 41
      "devDependencies": {
40 42
        "@biomejs/biome": "^2.3.13",
49 51
  "packages": {
50 52
    "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
51 53
54 +
    "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="],
55 +
56 +
    "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="],
57 +
58 +
    "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="],
59 +
60 +
    "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="],
61 +
62 +
    "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="],
63 +
64 +
    "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="],
65 +
66 +
    "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="],
67 +
68 +
    "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="],
69 +
70 +
    "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
71 +
52 72
    "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="],
53 73
54 74
    "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="],
55 75
76 +
    "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="],
77 +
78 +
    "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="],
79 +
80 +
    "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="],
81 +
82 +
    "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
83 +
56 84
    "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="],
57 85
58 86
    "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="],
59 87
60 88
    "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="],
61 89
90 +
    "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="],
91 +
92 +
    "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="],
93 +
94 +
    "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="],
95 +
62 96
    "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="],
63 97
64 98
    "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="],
615 649
616 650
    "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
617 651
652 +
    "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
653 +
618 654
    "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
619 655
620 656
    "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
662 698
    "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
663 699
664 700
    "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
701 +
702 +
    "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="],
665 703
666 704
    "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
667 705
761 799
762 800
    "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
763 801
802 +
    "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="],
803 +
804 +
    "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
805 +
806 +
    "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
807 +
764 808
    "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
765 809
766 810
    "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
921 965
922 966
    "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
923 967
968 +
    "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
969 +
924 970
    "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
925 971
926 972
    "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
927 973
928 974
    "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
929 975
976 +
    "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
977 +
930 978
    "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
931 979
980 +
    "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
981 +
982 +
    "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
983 +
932 984
    "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
933 985
934 986
    "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
937 989
938 990
    "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
939 991
992 +
    "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
993 +
940 994
    "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
941 995
942 996
    "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
944 998
    "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="],
945 999
946 1000
    "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
1001 +
1002 +
    "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
947 1003
948 1004
    "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
949 1005
1166 1222
    "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
1167 1223
1168 1224
    "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
1225 +
1226 +
    "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
1169 1227
1170 1228
    "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="],
1171 1229
1209 1267
1210 1268
    "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
1211 1269
1270 +
    "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
1271 +
1212 1272
    "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
1213 1273
1214 1274
    "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
1282 1342
    "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
1283 1343
1284 1344
    "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
1345 +
1346 +
    "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
1285 1347
1286 1348
    "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
1287 1349
1375 1437
1376 1438
    "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
1377 1439
1440 +
    "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
1441 +
1378 1442
    "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
1379 1443
1380 1444
    "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="],
1444 1508
    "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
1445 1509
1446 1510
    "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
1511 +
1512 +
    "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
1447 1513
1448 1514
    "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
1449 1515
packages/cli/package.json +3 −1
30 30
	},
31 31
	"dependencies": {
32 32
		"@atproto/api": "^0.18.17",
33 +
		"@atproto/oauth-client-node": "^0.3.16",
33 34
		"@clack/prompts": "^1.0.0",
34 35
		"cmd-ts": "^0.14.3",
35 36
		"glob": "^13.0.0",
36 37
		"mime-types": "^2.1.35",
37 -
		"minimatch": "^10.1.1"
38 +
		"minimatch": "^10.1.1",
39 +
		"open": "^11.0.0"
38 40
	}
39 41
}
packages/cli/src/commands/auth.ts +1 −0
158 158
159 159
			// Save credentials
160 160
			await saveCredentials({
161 +
				type: "app-password",
161 162
				pdsUrl,
162 163
				identifier: identifier,
163 164
				password: appPassword,
packages/cli/src/commands/login.ts (added) +296 −0
1 +
import * as http from "node:http";
2 +
import { log, note, select, spinner, text } from "@clack/prompts";
3 +
import { command, flag, option, optional, string } from "cmd-ts";
4 +
import { resolveHandleToDid } from "../lib/atproto";
5 +
import {
6 +
	getCallbackPort,
7 +
	getCallbackUrl,
8 +
	getOAuthClient,
9 +
	getOAuthScope,
10 +
} from "../lib/oauth-client";
11 +
import {
12 +
	deleteOAuthSession,
13 +
	getOAuthStorePath,
14 +
	listOAuthSessions,
15 +
} from "../lib/oauth-store";
16 +
import { exitOnCancel } from "../lib/prompts";
17 +
18 +
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
19 +
20 +
export const loginCommand = command({
21 +
	name: "login",
22 +
	description: "Login with OAuth (browser-based authentication)",
23 +
	args: {
24 +
		logout: option({
25 +
			long: "logout",
26 +
			description: "Remove OAuth session for a specific DID",
27 +
			type: optional(string),
28 +
		}),
29 +
		list: flag({
30 +
			long: "list",
31 +
			description: "List all stored OAuth sessions",
32 +
		}),
33 +
	},
34 +
	handler: async ({ logout, list }) => {
35 +
		// List sessions
36 +
		if (list) {
37 +
			const sessions = await listOAuthSessions();
38 +
			if (sessions.length === 0) {
39 +
				log.info("No OAuth sessions stored");
40 +
			} else {
41 +
				log.info("OAuth sessions:");
42 +
				for (const did of sessions) {
43 +
					console.log(`  - ${did}`);
44 +
				}
45 +
			}
46 +
			return;
47 +
		}
48 +
49 +
		// Logout
50 +
		if (logout !== undefined) {
51 +
			const did = logout || undefined;
52 +
53 +
			if (!did) {
54 +
				// No DID provided - show available and prompt
55 +
				const sessions = await listOAuthSessions();
56 +
				if (sessions.length === 0) {
57 +
					log.info("No OAuth sessions found");
58 +
					return;
59 +
				}
60 +
				if (sessions.length === 1) {
61 +
					const deleted = await deleteOAuthSession(sessions[0]!);
62 +
					if (deleted) {
63 +
						log.success(`Removed OAuth session for ${sessions[0]}`);
64 +
					}
65 +
					return;
66 +
				}
67 +
				// Multiple sessions - prompt
68 +
				const selected = exitOnCancel(
69 +
					await select({
70 +
						message: "Select session to remove:",
71 +
						options: sessions.map((d) => ({ value: d, label: d })),
72 +
					}),
73 +
				);
74 +
				const deleted = await deleteOAuthSession(selected);
75 +
				if (deleted) {
76 +
					log.success(`Removed OAuth session for ${selected}`);
77 +
				}
78 +
				return;
79 +
			}
80 +
81 +
			const deleted = await deleteOAuthSession(did);
82 +
			if (deleted) {
83 +
				log.success(`Removed OAuth session for ${did}`);
84 +
			} else {
85 +
				log.info(`No OAuth session found for ${did}`);
86 +
			}
87 +
			return;
88 +
		}
89 +
90 +
		// OAuth login flow
91 +
		note(
92 +
			"OAuth login will open your browser to authenticate.\n\n" +
93 +
				"This is more secure than app passwords and tokens refresh automatically.",
94 +
			"OAuth Login",
95 +
		);
96 +
97 +
		const handle = exitOnCancel(
98 +
			await text({
99 +
				message: "Handle or DID:",
100 +
				placeholder: "yourhandle.bsky.social",
101 +
			}),
102 +
		);
103 +
104 +
		if (!handle) {
105 +
			log.error("Handle is required");
106 +
			process.exit(1);
107 +
		}
108 +
109 +
		const s = spinner();
110 +
		s.start("Resolving identity...");
111 +
112 +
		let did: string;
113 +
		try {
114 +
			did = await resolveHandleToDid(handle);
115 +
			s.stop(`Identity resolved`);
116 +
		} catch (error) {
117 +
			s.stop("Failed to resolve identity");
118 +
			if (error instanceof Error) {
119 +
				log.error(`Error: ${error.message}`);
120 +
			} else {
121 +
				log.error(`Error: ${error}`);
122 +
			}
123 +
			process.exit(1);
124 +
		}
125 +
126 +
		s.start("Initializing OAuth...");
127 +
128 +
		try {
129 +
			const client = await getOAuthClient();
130 +
131 +
			// Generate authorization URL using the resolved DID
132 +
			const authUrl = await client.authorize(did, {
133 +
				scope: getOAuthScope(),
134 +
			});
135 +
136 +
			log.info(`Login URL: ${authUrl}`);
137 +
138 +
			s.message("Opening browser...");
139 +
140 +
			// Try to open browser
141 +
			let browserOpened = true;
142 +
			try {
143 +
				const open = (await import("open")).default;
144 +
				await open(authUrl.toString());
145 +
			} catch {
146 +
				browserOpened = false;
147 +
			}
148 +
149 +
			s.message("Waiting for authentication...");
150 +
151 +
			// Show URL info
152 +
			if (!browserOpened) {
153 +
				s.stop("Could not open browser automatically");
154 +
				log.warn("Please open the following URL in your browser:");
155 +
				log.info(authUrl.toString());
156 +
				s.start("Waiting for authentication...");
157 +
			}
158 +
159 +
			// Start HTTP server to receive callback
160 +
			const result = await waitForCallback();
161 +
162 +
			if (!result.success) {
163 +
				s.stop("Authentication failed");
164 +
				log.error(result.error || "OAuth callback failed");
165 +
				process.exit(1);
166 +
			}
167 +
168 +
			s.message("Completing authentication...");
169 +
170 +
			// Exchange code for tokens
171 +
			const { session } = await client.callback(
172 +
				new URLSearchParams(result.params!),
173 +
			);
174 +
175 +
			// Try to get the handle for display (use the original handle input as fallback)
176 +
			let displayName = handle;
177 +
			try {
178 +
				// The session should have the DID, we can use the original handle they entered
179 +
				// or we could fetch the profile to get the current handle
180 +
				displayName = handle.startsWith("did:") ? session.did : handle;
181 +
			} catch {
182 +
				displayName = session.did;
183 +
			}
184 +
185 +
			s.stop(`Logged in as ${displayName}`);
186 +
187 +
			log.success(`OAuth session saved to ${getOAuthStorePath()}`);
188 +
			log.info("Your session will refresh automatically when needed.");
189 +
190 +
			// Exit cleanly - the OAuth client may have background processes
191 +
			process.exit(0);
192 +
		} catch (error) {
193 +
			s.stop("OAuth login failed");
194 +
			if (error instanceof Error) {
195 +
				log.error(`Error: ${error.message}`);
196 +
			} else {
197 +
				log.error(`Error: ${error}`);
198 +
			}
199 +
			process.exit(1);
200 +
		}
201 +
	},
202 +
});
203 +
204 +
interface CallbackResult {
205 +
	success: boolean;
206 +
	params?: Record<string, string>;
207 +
	error?: string;
208 +
}
209 +
210 +
function waitForCallback(): Promise<CallbackResult> {
211 +
	return new Promise((resolve) => {
212 +
		const port = getCallbackPort();
213 +
		let timeoutId: ReturnType<typeof setTimeout> | undefined;
214 +
215 +
		const server = http.createServer((req, res) => {
216 +
			const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
217 +
218 +
			if (url.pathname === "/oauth/callback") {
219 +
				const params: Record<string, string> = {};
220 +
				url.searchParams.forEach((value, key) => {
221 +
					params[key] = value;
222 +
				});
223 +
224 +
				// Clear the timeout
225 +
				if (timeoutId) clearTimeout(timeoutId);
226 +
227 +
				// Check for error
228 +
				if (params.error) {
229 +
					res.writeHead(200, { "Content-Type": "text/html" });
230 +
					res.end(`
231 +
						<html>
232 +
							<body style="font-family: system-ui; padding: 2rem; text-align: center;">
233 +
								<h1>Authentication Failed</h1>
234 +
								<p>${params.error_description || params.error}</p>
235 +
								<p>You can close this window.</p>
236 +
							</body>
237 +
						</html>
238 +
					`);
239 +
					server.close(() => {
240 +
						resolve({
241 +
							success: false,
242 +
							error: params.error_description || params.error,
243 +
						});
244 +
					});
245 +
					return;
246 +
				}
247 +
248 +
				// Success
249 +
				res.writeHead(200, { "Content-Type": "text/html" });
250 +
				res.end(`
251 +
					<html>
252 +
						<body style="font-family: system-ui; padding: 2rem; text-align: center;">
253 +
							<h1>Authentication Successful</h1>
254 +
							<p>You can close this window and return to the terminal.</p>
255 +
						</body>
256 +
					</html>
257 +
				`);
258 +
				server.close(() => {
259 +
					resolve({ success: true, params });
260 +
				});
261 +
				return;
262 +
			}
263 +
264 +
			// Not the callback path
265 +
			res.writeHead(404);
266 +
			res.end("Not found");
267 +
		});
268 +
269 +
		server.on("error", (err: NodeJS.ErrnoException) => {
270 +
			if (timeoutId) clearTimeout(timeoutId);
271 +
			if (err.code === "EADDRINUSE") {
272 +
				resolve({
273 +
					success: false,
274 +
					error: `Port ${port} is already in use. Please close the application using that port and try again.`,
275 +
				});
276 +
			} else {
277 +
				resolve({
278 +
					success: false,
279 +
					error: `Server error: ${err.message}`,
280 +
				});
281 +
			}
282 +
		});
283 +
284 +
		server.listen(port, "127.0.0.1");
285 +
286 +
		// Timeout after 5 minutes
287 +
		timeoutId = setTimeout(() => {
288 +
			server.close(() => {
289 +
				resolve({
290 +
					success: false,
291 +
					error: "Timeout waiting for OAuth callback. Please try again.",
292 +
				});
293 +
			});
294 +
		}, CALLBACK_TIMEOUT_MS);
295 +
	});
296 +
}
packages/cli/src/index.ts +2 −0
4 4
import { authCommand } from "./commands/auth";
5 5
import { initCommand } from "./commands/init";
6 6
import { injectCommand } from "./commands/inject";
7 +
import { loginCommand } from "./commands/login";
7 8
import { publishCommand } from "./commands/publish";
8 9
import { syncCommand } from "./commands/sync";
9 10
38 39
		auth: authCommand,
39 40
		init: initCommand,
40 41
		inject: injectCommand,
42 +
		login: loginCommand,
41 43
		publish: publishCommand,
42 44
		sync: syncCommand,
43 45
	},
packages/cli/src/lib/atproto.ts +61 −15
1 -
import { AtpAgent } from "@atproto/api";
1 +
import { Agent, AtpAgent } from "@atproto/api";
2 2
import * as mimeTypes from "mime-types";
3 3
import * as fs from "node:fs/promises";
4 4
import * as path from "node:path";
5 5
import { stripMarkdownForText } from "./markdown";
6 +
import { getOAuthClient } from "./oauth-client";
6 7
import type {
7 8
	BlobObject,
8 9
	BlogPost,
10 11
	PublisherConfig,
11 12
	StrongRef,
12 13
} from "./types";
14 +
import { isAppPasswordCredentials, isOAuthCredentials } from "./types";
13 15
14 16
async function fileExists(filePath: string): Promise<boolean> {
15 17
	try {
20 22
	}
21 23
}
22 24
25 +
/**
26 +
 * Resolve a handle to a DID
27 +
 */
28 +
export async function resolveHandleToDid(handle: string): Promise<string> {
29 +
	if (handle.startsWith("did:")) {
30 +
		return handle;
31 +
	}
32 +
33 +
	// Try to resolve handle via Bluesky API
34 +
	const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
35 +
	const resolveResponse = await fetch(resolveUrl);
36 +
	if (!resolveResponse.ok) {
37 +
		throw new Error("Could not resolve handle");
38 +
	}
39 +
	const resolveData = (await resolveResponse.json()) as { did: string };
40 +
	return resolveData.did;
41 +
}
42 +
23 43
export async function resolveHandleToPDS(handle: string): Promise<string> {
24 44
	// First, resolve the handle to a DID
25 -
	let did: string;
26 -
27 -
	if (handle.startsWith("did:")) {
28 -
		did = handle;
29 -
	} else {
30 -
		// Try to resolve handle via Bluesky API
31 -
		const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
32 -
		const resolveResponse = await fetch(resolveUrl);
33 -
		if (!resolveResponse.ok) {
34 -
			throw new Error("Could not resolve handle");
35 -
		}
36 -
		const resolveData = (await resolveResponse.json()) as { did: string };
37 -
		did = resolveData.did;
38 -
	}
45 +
	const did = await resolveHandleToDid(handle);
39 46
40 47
	// Now resolve the DID to get the PDS URL from the DID document
41 48
	let pdsUrl: string | undefined;
90 97
}
91 98
92 99
export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
100 +
	if (isOAuthCredentials(credentials)) {
101 +
		// OAuth flow - restore session from stored tokens
102 +
		const client = await getOAuthClient();
103 +
		try {
104 +
			const oauthSession = await client.restore(credentials.did);
105 +
			// Wrap the OAuth session in an Agent which provides the atproto API
106 +
			const agent = new Agent(oauthSession) as unknown as AtpAgent;
107 +
108 +
			// The Agent class doesn't have session.did like AtpAgent does
109 +
			// We need to set up a compatible session object for the rest of our code
110 +
			agent.session = {
111 +
				did: oauthSession.did,
112 +
				handle: credentials.handle,
113 +
				accessJwt: "",
114 +
				refreshJwt: "",
115 +
				active: true,
116 +
			};
117 +
118 +
			return agent;
119 +
		} catch (error) {
120 +
			if (error instanceof Error) {
121 +
				// Check for common OAuth errors
122 +
				if (
123 +
					error.message.includes("expired") ||
124 +
					error.message.includes("revoked")
125 +
				) {
126 +
					throw new Error(
127 +
						`OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`,
128 +
					);
129 +
				}
130 +
			}
131 +
			throw error;
132 +
		}
133 +
	}
134 +
135 +
	// App password flow
136 +
	if (!isAppPasswordCredentials(credentials)) {
137 +
		throw new Error("Invalid credential type");
138 +
	}
93 139
	const agent = new AtpAgent({ service: credentials.pdsUrl });
94 140
95 141
	await agent.login({
packages/cli/src/lib/credentials.ts +133 −26
1 1
import * as fs from "node:fs/promises";
2 2
import * as os from "node:os";
3 3
import * as path from "node:path";
4 -
import type { Credentials } from "./types";
4 +
import { getOAuthSession, listOAuthSessions } from "./oauth-store";
5 +
import type {
6 +
	AppPasswordCredentials,
7 +
	Credentials,
8 +
	LegacyCredentials,
9 +
	OAuthCredentials,
10 +
} from "./types";
5 11
6 12
const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
7 13
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
8 14
9 -
// Stored credentials keyed by identifier
10 -
type CredentialsStore = Record<string, Credentials>;
15 +
// Stored credentials keyed by identifier (can be legacy or typed)
16 +
type CredentialsStore = Record<
17 +
	string,
18 +
	AppPasswordCredentials | LegacyCredentials
19 +
>;
11 20
12 21
async function fileExists(filePath: string): Promise<boolean> {
13 22
	try {
19 28
}
20 29
21 30
/**
22 -
 * Load all stored credentials
31 +
 * Normalize credentials to have explicit type
23 32
 */
33 +
function normalizeCredentials(
34 +
	creds: AppPasswordCredentials | LegacyCredentials,
35 +
): AppPasswordCredentials {
36 +
	// If it already has type, return as-is
37 +
	if ("type" in creds && creds.type === "app-password") {
38 +
		return creds;
39 +
	}
40 +
	// Migrate legacy format
41 +
	return {
42 +
		type: "app-password",
43 +
		pdsUrl: creds.pdsUrl,
44 +
		identifier: creds.identifier,
45 +
		password: creds.password,
46 +
	};
47 +
}
48 +
24 49
async function loadCredentialsStore(): Promise<CredentialsStore> {
25 50
	if (!(await fileExists(CREDENTIALS_FILE))) {
26 51
		return {};
32 57
33 58
		// Handle legacy single-credential format (migrate on read)
34 59
		if (parsed.identifier && parsed.password) {
35 -
			const legacy = parsed as Credentials;
60 +
			const legacy = parsed as LegacyCredentials;
36 61
			return { [legacy.identifier]: legacy };
37 62
		}
38 63
52 77
}
53 78
54 79
/**
80 +
 * Try to load OAuth credentials for a given profile (DID or handle)
81 +
 */
82 +
async function tryLoadOAuthCredentials(
83 +
	profile: string,
84 +
): Promise<OAuthCredentials | null> {
85 +
	// If it looks like a DID, try to get the session directly
86 +
	if (profile.startsWith("did:")) {
87 +
		const session = await getOAuthSession(profile);
88 +
		if (session) {
89 +
			return {
90 +
				type: "oauth",
91 +
				did: profile,
92 +
				handle: profile, // We don't have the handle stored, use DID
93 +
				pdsUrl: "https://bsky.social", // Will be resolved from DID doc
94 +
			};
95 +
		}
96 +
	}
97 +
98 +
	// Otherwise, check all OAuth sessions to find a matching handle
99 +
	// (This is a fallback - handle matching isn't perfect without storing handles)
100 +
	const sessions = await listOAuthSessions();
101 +
	for (const did of sessions) {
102 +
		// Could enhance this by storing handle with session, but for now
103 +
		// just return null if profile isn't a DID
104 +
	}
105 +
106 +
	return null;
107 +
}
108 +
109 +
/**
55 110
 * Load credentials for a specific identity or resolve which to use.
56 111
 *
57 112
 * Priority:
58 113
 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD)
59 -
 * 2. SEQUOIA_PROFILE env var - selects from stored credentials
114 +
 * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID)
60 115
 * 3. projectIdentity parameter (from sequoia.json)
61 -
 * 4. If only one identity stored, use it
116 +
 * 4. If only one identity stored (app-password or OAuth), use it
62 117
 * 5. Return null (caller should prompt user)
63 118
 */
64 119
export async function loadCredentials(
71 126
72 127
	if (envIdentifier && envPassword) {
73 128
		return {
129 +
			type: "app-password",
74 130
			identifier: envIdentifier,
75 131
			password: envPassword,
76 132
			pdsUrl: envPdsUrl || "https://bsky.social",
78 134
	}
79 135
80 136
	const store = await loadCredentialsStore();
81 -
	const identifiers = Object.keys(store);
82 -
83 -
	if (identifiers.length === 0) {
84 -
		return null;
85 -
	}
137 +
	const appPasswordIds = Object.keys(store);
138 +
	const oauthDids = await listOAuthSessions();
86 139
87 140
	// 2. SEQUOIA_PROFILE env var
88 141
	const profileEnv = process.env.SEQUOIA_PROFILE;
89 -
	if (profileEnv && store[profileEnv]) {
90 -
		return store[profileEnv];
142 +
	if (profileEnv) {
143 +
		// Try app-password credentials first
144 +
		if (store[profileEnv]) {
145 +
			return normalizeCredentials(store[profileEnv]);
146 +
		}
147 +
		// Try OAuth session (profile could be a DID)
148 +
		const oauth = await tryLoadOAuthCredentials(profileEnv);
149 +
		if (oauth) {
150 +
			return oauth;
151 +
		}
91 152
	}
92 153
93 154
	// 3. Project-specific identity (from sequoia.json)
94 -
	if (projectIdentity && store[projectIdentity]) {
95 -
		return store[projectIdentity];
155 +
	if (projectIdentity) {
156 +
		if (store[projectIdentity]) {
157 +
			return normalizeCredentials(store[projectIdentity]);
158 +
		}
159 +
		const oauth = await tryLoadOAuthCredentials(projectIdentity);
160 +
		if (oauth) {
161 +
			return oauth;
162 +
		}
96 163
	}
97 164
98 -
	// 4. If only one identity, use it
99 -
	if (identifiers.length === 1 && identifiers[0]) {
100 -
		return store[identifiers[0]] ?? null;
165 +
	// 4. If only one identity total, use it
166 +
	const totalIdentities = appPasswordIds.length + oauthDids.length;
167 +
	if (totalIdentities === 1) {
168 +
		if (appPasswordIds.length === 1 && appPasswordIds[0]) {
169 +
			return normalizeCredentials(store[appPasswordIds[0]]!);
170 +
		}
171 +
		if (oauthDids.length === 1 && oauthDids[0]) {
172 +
			const session = await getOAuthSession(oauthDids[0]);
173 +
			if (session) {
174 +
				return {
175 +
					type: "oauth",
176 +
					did: oauthDids[0],
177 +
					handle: oauthDids[0],
178 +
					pdsUrl: "https://bsky.social",
179 +
				};
180 +
			}
181 +
		}
101 182
	}
102 183
103 -
	// Multiple identities exist but none selected
184 +
	// Multiple identities exist but none selected, or no identities
104 185
	return null;
105 186
}
106 187
107 188
/**
108 -
 * Get a specific identity by identifier
189 +
 * Get a specific identity by identifier (app-password only)
109 190
 */
110 191
export async function getCredentials(
111 192
	identifier: string,
112 -
): Promise<Credentials | null> {
193 +
): Promise<AppPasswordCredentials | null> {
113 194
	const store = await loadCredentialsStore();
114 -
	return store[identifier] || null;
195 +
	const creds = store[identifier];
196 +
	if (!creds) return null;
197 +
	return normalizeCredentials(creds);
115 198
}
116 199
117 200
/**
118 -
 * List all stored identities
201 +
 * List all stored app-password identities
119 202
 */
120 203
export async function listCredentials(): Promise<string[]> {
121 204
	const store = await loadCredentialsStore();
123 206
}
124 207
125 208
/**
126 -
 * Save credentials for an identity (adds or updates)
209 +
 * List all credentials (both app-password and OAuth)
210 +
 */
211 +
export async function listAllCredentials(): Promise<
212 +
	Array<{ id: string; type: "app-password" | "oauth" }>
213 +
> {
214 +
	const store = await loadCredentialsStore();
215 +
	const oauthDids = await listOAuthSessions();
216 +
217 +
	const result: Array<{ id: string; type: "app-password" | "oauth" }> = [];
218 +
219 +
	for (const id of Object.keys(store)) {
220 +
		result.push({ id, type: "app-password" });
221 +
	}
222 +
223 +
	for (const did of oauthDids) {
224 +
		result.push({ id: did, type: "oauth" });
225 +
	}
226 +
227 +
	return result;
228 +
}
229 +
230 +
/**
231 +
 * Save app-password credentials for an identity (adds or updates)
127 232
 */
128 -
export async function saveCredentials(credentials: Credentials): Promise<void> {
233 +
export async function saveCredentials(
234 +
	credentials: AppPasswordCredentials,
235 +
): Promise<void> {
129 236
	const store = await loadCredentialsStore();
130 237
	store[credentials.identifier] = credentials;
131 238
	await saveCredentialsStore(store);
packages/cli/src/lib/oauth-client.ts (added) +91 −0
1 +
import {
2 +
	NodeOAuthClient,
3 +
	type NodeOAuthClientOptions,
4 +
} from "@atproto/oauth-client-node";
5 +
import { sessionStore, stateStore } from "./oauth-store";
6 +
7 +
const CALLBACK_PORT = 4000;
8 +
const CALLBACK_HOST = "127.0.0.1";
9 +
const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/oauth/callback`;
10 +
11 +
// OAuth scope for Sequoia CLI - includes atproto base scope plus our collections
12 +
const OAUTH_SCOPE =
13 +
	"atproto repo:site.standard.document repo:site.standard.publication repo:app.bsky.feed.post blob:*/*";
14 +
15 +
let oauthClient: NodeOAuthClient | null = null;
16 +
17 +
// Simple lock implementation for CLI (single process, no contention)
18 +
// This prevents the "No lock mechanism provided" warning
19 +
const locks = new Map<string, Promise<void>>();
20 +
21 +
async function requestLock(key: string, fn: () => Promise<void>): Promise<void> {
22 +
	// Wait for any existing lock on this key
23 +
	while (locks.has(key)) {
24 +
		await locks.get(key);
25 +
	}
26 +
27 +
	// Create our lock
28 +
	let resolve: () => void;
29 +
	const lockPromise = new Promise<void>((r) => {
30 +
		resolve = r;
31 +
	});
32 +
	locks.set(key, lockPromise);
33 +
34 +
	try {
35 +
		await fn();
36 +
	} finally {
37 +
		locks.delete(key);
38 +
		resolve!();
39 +
	}
40 +
}
41 +
42 +
/**
43 +
 * Get or create the OAuth client singleton
44 +
 */
45 +
export async function getOAuthClient(): Promise<NodeOAuthClient> {
46 +
	if (oauthClient) {
47 +
		return oauthClient;
48 +
	}
49 +
50 +
	// Build client_id with required parameters
51 +
	const clientIdParams = new URLSearchParams();
52 +
	clientIdParams.append("redirect_uri", CALLBACK_URL);
53 +
	clientIdParams.append("scope", OAUTH_SCOPE);
54 +
55 +
	const clientOptions: NodeOAuthClientOptions = {
56 +
		clientMetadata: {
57 +
			client_id: `http://localhost?${clientIdParams.toString()}`,
58 +
			client_name: "Sequoia CLI",
59 +
			client_uri: "https://github.com/stevedylandev/sequoia",
60 +
			redirect_uris: [CALLBACK_URL],
61 +
			grant_types: ["authorization_code", "refresh_token"],
62 +
			response_types: ["code"],
63 +
			token_endpoint_auth_method: "none",
64 +
			application_type: "web",
65 +
			scope: OAUTH_SCOPE,
66 +
			dpop_bound_access_tokens: false,
67 +
		},
68 +
		stateStore,
69 +
		sessionStore,
70 +
		// Configure identity resolution
71 +
		plcDirectoryUrl: "https://plc.directory",
72 +
		// Provide lock mechanism to prevent warning
73 +
		requestLock,
74 +
	};
75 +
76 +
	oauthClient = new NodeOAuthClient(clientOptions);
77 +
78 +
	return oauthClient;
79 +
}
80 +
81 +
export function getOAuthScope(): string {
82 +
	return OAUTH_SCOPE;
83 +
}
84 +
85 +
export function getCallbackUrl(): string {
86 +
	return CALLBACK_URL;
87 +
}
88 +
89 +
export function getCallbackPort(): number {
90 +
	return CALLBACK_PORT;
91 +
}
packages/cli/src/lib/oauth-store.ts (added) +124 −0
1 +
import * as fs from "node:fs/promises";
2 +
import * as os from "node:os";
3 +
import * as path from "node:path";
4 +
import type {
5 +
	NodeSavedSession,
6 +
	NodeSavedSessionStore,
7 +
	NodeSavedState,
8 +
	NodeSavedStateStore,
9 +
} from "@atproto/oauth-client-node";
10 +
11 +
const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
12 +
const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json");
13 +
14 +
interface OAuthStore {
15 +
	states: Record<string, NodeSavedState>;
16 +
	sessions: Record<string, NodeSavedSession>;
17 +
}
18 +
19 +
async function fileExists(filePath: string): Promise<boolean> {
20 +
	try {
21 +
		await fs.access(filePath);
22 +
		return true;
23 +
	} catch {
24 +
		return false;
25 +
	}
26 +
}
27 +
28 +
async function loadOAuthStore(): Promise<OAuthStore> {
29 +
	if (!(await fileExists(OAUTH_FILE))) {
30 +
		return { states: {}, sessions: {} };
31 +
	}
32 +
33 +
	try {
34 +
		const content = await fs.readFile(OAUTH_FILE, "utf-8");
35 +
		return JSON.parse(content) as OAuthStore;
36 +
	} catch {
37 +
		return { states: {}, sessions: {} };
38 +
	}
39 +
}
40 +
41 +
async function saveOAuthStore(store: OAuthStore): Promise<void> {
42 +
	await fs.mkdir(CONFIG_DIR, { recursive: true });
43 +
	await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2));
44 +
	await fs.chmod(OAUTH_FILE, 0o600);
45 +
}
46 +
47 +
/**
48 +
 * State store for PKCE flow (temporary, used during auth)
49 +
 */
50 +
export const stateStore: NodeSavedStateStore = {
51 +
	async set(key: string, state: NodeSavedState): Promise<void> {
52 +
		const store = await loadOAuthStore();
53 +
		store.states[key] = state;
54 +
		await saveOAuthStore(store);
55 +
	},
56 +
57 +
	async get(key: string): Promise<NodeSavedState | undefined> {
58 +
		const store = await loadOAuthStore();
59 +
		return store.states[key];
60 +
	},
61 +
62 +
	async del(key: string): Promise<void> {
63 +
		const store = await loadOAuthStore();
64 +
		delete store.states[key];
65 +
		await saveOAuthStore(store);
66 +
	},
67 +
};
68 +
69 +
/**
70 +
 * Session store for OAuth tokens (persistent)
71 +
 */
72 +
export const sessionStore: NodeSavedSessionStore = {
73 +
	async set(sub: string, session: NodeSavedSession): Promise<void> {
74 +
		const store = await loadOAuthStore();
75 +
		store.sessions[sub] = session;
76 +
		await saveOAuthStore(store);
77 +
	},
78 +
79 +
	async get(sub: string): Promise<NodeSavedSession | undefined> {
80 +
		const store = await loadOAuthStore();
81 +
		return store.sessions[sub];
82 +
	},
83 +
84 +
	async del(sub: string): Promise<void> {
85 +
		const store = await loadOAuthStore();
86 +
		delete store.sessions[sub];
87 +
		await saveOAuthStore(store);
88 +
	},
89 +
};
90 +
91 +
/**
92 +
 * List all stored OAuth session DIDs
93 +
 */
94 +
export async function listOAuthSessions(): Promise<string[]> {
95 +
	const store = await loadOAuthStore();
96 +
	return Object.keys(store.sessions);
97 +
}
98 +
99 +
/**
100 +
 * Get an OAuth session by DID
101 +
 */
102 +
export async function getOAuthSession(
103 +
	did: string,
104 +
): Promise<NodeSavedSession | undefined> {
105 +
	const store = await loadOAuthStore();
106 +
	return store.sessions[did];
107 +
}
108 +
109 +
/**
110 +
 * Delete an OAuth session by DID
111 +
 */
112 +
export async function deleteOAuthSession(did: string): Promise<boolean> {
113 +
	const store = await loadOAuthStore();
114 +
	if (!store.sessions[did]) {
115 +
		return false;
116 +
	}
117 +
	delete store.sessions[did];
118 +
	await saveOAuthStore(store);
119 +
	return true;
120 +
}
121 +
122 +
export function getOAuthStorePath(): string {
123 +
	return OAUTH_FILE;
124 +
}
packages/cli/src/lib/types.ts +34 −1
37 37
	bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
38 38
}
39 39
40 -
export interface Credentials {
40 +
// Legacy credentials format (for backward compatibility during migration)
41 +
export interface LegacyCredentials {
41 42
	pdsUrl: string;
42 43
	identifier: string;
43 44
	password: string;
45 +
}
46 +
47 +
// App password credentials (explicit type)
48 +
export interface AppPasswordCredentials {
49 +
	type: "app-password";
50 +
	pdsUrl: string;
51 +
	identifier: string;
52 +
	password: string;
53 +
}
54 +
55 +
// OAuth credentials (references stored OAuth session)
56 +
export interface OAuthCredentials {
57 +
	type: "oauth";
58 +
	did: string;
59 +
	handle: string;
60 +
	pdsUrl: string;
61 +
}
62 +
63 +
// Union type for all credential types
64 +
export type Credentials = AppPasswordCredentials | OAuthCredentials;
65 +
66 +
// Helper to check credential type
67 +
export function isOAuthCredentials(
68 +
	creds: Credentials,
69 +
): creds is OAuthCredentials {
70 +
	return creds.type === "oauth";
71 +
}
72 +
73 +
export function isAppPasswordCredentials(
74 +
	creds: Credentials,
75 +
): creds is AppPasswordCredentials {
76 +
	return creds.type === "app-password";
44 77
}
45 78
46 79
export interface PostFrontmatter {