A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Compare changes

Choose any two refs to compare.

+3643 -1345
+1
.gitignore
··· 35 36 # Bun lockfile - keep but binary cache 37 bun.lockb
··· 35 36 # Bun lockfile - keep but binary cache 37 bun.lockb 38 + packages/ui
+22
.tangled/workflows/lint.yml
···
··· 1 + # Biome lint and format checks 2 + 3 + when: 4 + - event: ["push", "manual"] 5 + branch: ["main"] 6 + - event: ["pull_request"] 7 + branch: ["main"] 8 + 9 + engine: "nixery" 10 + 11 + dependencies: 12 + nixpkgs: 13 + - bun 14 + - biome 15 + 16 + steps: 17 + - name: "Install dependencies" 18 + command: "bun install" 19 + - name: "Lint check" 20 + command: "cd packages/cli && biome lint ." 21 + - name: "Format check" 22 + command: "cd packages/cli && biome format ."
+130
CHANGELOG.md
···
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### ๐Ÿ› Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### โš™๏ธ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 17 + ## [0.3.1] - 2026-02-04 18 + 19 + ### ๐Ÿ› Bug Fixes 20 + 21 + - Asset subdirectories 22 + 23 + ### โš™๏ธ Miscellaneous Tasks 24 + 25 + - Updated authentication ux 26 + - Release 0.3.1 27 + - Bumped version 28 + ## [0.3.0] - 2026-02-04 29 + 30 + ### ๐Ÿš€ Features 31 + 32 + - Initial oauth implementation 33 + - Add stripDatePrefix option for Jekyll-style filenames 34 + - Add `update` command 35 + 36 + ### โš™๏ธ Miscellaneous Tasks 37 + 38 + - Update changelog 39 + - Added workflows 40 + - Updated workflows 41 + - Updated workflows 42 + - Cleaned up types 43 + - Updated icon styles 44 + - Updated og image 45 + - Updated docs 46 + - Docs updates 47 + - Bumped version 48 + ## [0.2.1] - 2026-02-02 49 + 50 + ### โš™๏ธ Miscellaneous Tasks 51 + 52 + - Added CHANGELOG 53 + - Merge main into chore/fronmatter-config-updates 54 + - Added linting and formatting 55 + - Linting updates 56 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 57 + - Version bump 58 + ## [0.2.0] - 2026-02-01 59 + 60 + ### ๐Ÿš€ Features 61 + 62 + - Added bskyPostRef 63 + - Added draft field to frontmatter config 64 + 65 + ### โš™๏ธ Miscellaneous Tasks 66 + 67 + - Resolved action items from issue #3 68 + - Adjusted tags to accept yaml multiline arrays for tags 69 + - Updated inject to handle new slug options 70 + - Updated comments 71 + - Update blog post 72 + - Fix blog build error 73 + - Adjust blog post 74 + - Updated docs 75 + - Version bump 76 + ## [0.1.1] - 2026-01-31 77 + 78 + ### ๐Ÿ› Bug Fixes 79 + 80 + - Fix tangled url to repo 81 + 82 + ### โš™๏ธ Miscellaneous Tasks 83 + 84 + - Merge branch 'main' into feat/blog-post 85 + - Updated blog post 86 + - Updated date 87 + - Added publishing 88 + - Spelling and grammar 89 + - Updated package scripts 90 + - Refactored codebase to use node and fs instead of bun 91 + - Version bump 92 + ## [0.1.0] - 2026-01-30 93 + 94 + ### ๐Ÿš€ Features 95 + 96 + - Init 97 + - Added blog post 98 + 99 + ### โš™๏ธ Miscellaneous Tasks 100 + 101 + - Updated package.json 102 + - Cleaned up commands and libs 103 + - Updated init commands 104 + - Updated greeting 105 + - Updated readme 106 + - Link updates 107 + - Version bump 108 + - Added hugo support through frontmatter parsing 109 + - Version bump 110 + - Updated docs 111 + - Adapted inject.ts pattern 112 + - Updated docs 113 + - Version bump" 114 + - Updated package scripts 115 + - Updated scripts 116 + - Added ignore field to config 117 + - Udpate docs 118 + - Version bump 119 + - Added tags to flow 120 + - Added ability to exit during init flow 121 + - Version bump 122 + - Updated docs 123 + - Updated links 124 + - Updated docs 125 + - Initial refactor 126 + - Checkpoint 127 + - Refactored mapping 128 + - Docs updates 129 + - Docs updates 130 + - Version bump
+99 -1
bun.lock
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 - "version": "0.1.0", 28 "bin": { 29 "sequoia": "dist/index.js", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 "@clack/prompts": "^1.0.0", 34 "cmd-ts": "^0.14.3", 35 "glob": "^13.0.0", 36 "mime-types": "^2.1.35", 37 "minimatch": "^10.1.1", 38 }, 39 "devDependencies": { 40 "@types/mime-types": "^3.0.1", 41 "@types/node": "^20", 42 }, ··· 44 "typescript": "^5", 45 }, 46 }, 47 }, 48 "packages": { 49 "@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=="], 50 51 "@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=="], 52 53 "@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=="], 54 55 "@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=="], 56 57 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 58 59 "@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=="], 60 61 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 62 ··· 104 105 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 106 107 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 108 109 "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], ··· 596 597 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 598 599 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 600 601 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 643 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 644 645 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 646 647 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 648 ··· 742 743 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 744 745 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 746 747 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 902 903 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 904 905 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 906 907 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 908 909 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 910 911 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 912 913 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 914 915 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 918 919 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 920 921 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 922 923 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 925 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 926 927 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 928 929 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 930 ··· 1148 1149 "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=="], 1150 1151 "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=="], 1152 1153 "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], ··· 1189 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1190 1191 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1192 1193 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1194 ··· 1263 "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=="], 1264 1265 "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=="], 1266 1267 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1268 ··· 1278 1279 "sequoia-cli": ["sequoia-cli@workspace:packages/cli"], 1280 1281 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], 1282 1283 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], ··· 1356 1357 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1358 1359 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1360 1361 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1425 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1426 1427 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1428 1429 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1430
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 + "version": "0.3.2", 28 "bin": { 29 "sequoia": "dist/index.js", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 34 "@clack/prompts": "^1.0.0", 35 "cmd-ts": "^0.14.3", 36 "glob": "^13.0.0", 37 "mime-types": "^2.1.35", 38 "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 40 }, 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 43 "@types/mime-types": "^3.0.1", 44 "@types/node": "^20", 45 }, ··· 47 "typescript": "^5", 48 }, 49 }, 50 + "packages/ui": { 51 + "name": "sequoia-ui", 52 + "version": "0.1.0", 53 + "devDependencies": { 54 + "@biomejs/biome": "^2.3.13", 55 + "@types/node": "^20", 56 + }, 57 + "peerDependencies": { 58 + "typescript": "^5", 59 + }, 60 + }, 61 }, 62 "packages": { 63 "@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=="], 64 65 + "@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=="], 66 + 67 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 68 + 69 + "@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=="], 70 + 71 + "@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=="], 72 + 73 + "@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=="], 74 + 75 + "@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=="], 76 + 77 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 78 + 79 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 80 + 81 + "@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=="], 82 + 83 "@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=="], 84 85 "@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=="], 86 87 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 88 + 89 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 90 + 91 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 92 + 93 + "@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=="], 94 + 95 "@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=="], 96 97 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 98 99 "@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=="], 100 + 101 + "@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=="], 102 + 103 + "@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=="], 104 + 105 + "@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=="], 106 107 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 108 ··· 150 151 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 152 153 + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], 154 + 155 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 156 + 157 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 158 + 159 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 160 + 161 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 162 + 163 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 164 + 165 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 166 + 167 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 168 + 169 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 170 + 171 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 172 173 "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], ··· 660 661 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 662 663 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 664 + 665 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 666 667 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 709 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 710 711 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 712 + 713 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 714 715 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 716 ··· 810 811 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 812 813 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 814 + 815 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 816 + 817 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 818 + 819 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 820 821 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 976 977 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 978 979 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 980 + 981 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 982 983 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 984 985 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 986 987 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 988 + 989 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 990 991 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 992 + 993 + "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=="], 994 + 995 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 996 997 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 1000 1001 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 1002 1003 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 1004 + 1005 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 1006 1007 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 1009 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 1010 1011 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1012 + 1013 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1014 1015 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 1016 ··· 1234 1235 "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=="], 1236 1237 + "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=="], 1238 + 1239 "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=="], 1240 1241 "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], ··· 1277 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1278 1279 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1280 + 1281 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1282 1283 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1284 ··· 1353 "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=="], 1354 1355 "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=="], 1356 + 1357 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1358 1359 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1360 ··· 1370 1371 "sequoia-cli": ["sequoia-cli@workspace:packages/cli"], 1372 1373 + "sequoia-ui": ["sequoia-ui@workspace:packages/ui"], 1374 + 1375 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], 1376 1377 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], ··· 1450 1451 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1452 1453 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1454 + 1455 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1456 1457 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1521 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1522 1523 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1524 + 1525 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1526 1527 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1528
+34 -1
docs/docs/pages/cli-reference.mdx
··· 1 # CLI Reference 2 3 ## `auth` 4 5 ```bash [Terminal] 6 sequoia auth 7 - > Authenticate with your ATProto PDS 8 9 OPTIONS: 10 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 13 --list - List all stored identities [optional] 14 --help, -h - show help [optional] 15 ``` 16 17 ## `init` 18 ··· 61 --dry-run, -n - Preview what would be synced without making changes [optional] 62 --help, -h - show help [optional] 63 ```
··· 1 # CLI Reference 2 3 + ## `login` 4 + 5 + ```bash [Terminal] 6 + sequoia login 7 + > Login with OAuth (browser-based authentication) 8 + 9 + OPTIONS: 10 + --logout <str> - Remove OAuth session for a specific DID [optional] 11 + 12 + FLAGS: 13 + --list - List all stored OAuth sessions [optional] 14 + --help, -h - show help [optional] 15 + ``` 16 + 17 + OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically. 18 + 19 ## `auth` 20 21 ```bash [Terminal] 22 sequoia auth 23 + > Authenticate with your ATProto PDS using an app password 24 25 OPTIONS: 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 29 --list - List all stored identities [optional] 30 --help, -h - show help [optional] 31 ``` 32 + 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 35 ## `init` 36 ··· 79 --dry-run, -n - Preview what would be synced without making changes [optional] 80 --help, -h - show help [optional] 81 ``` 82 + 83 + ## `update` 84 + 85 + ```bash [Terminal] 86 + sequoia update 87 + > Update local config or ATProto publication record 88 + 89 + FLAGS: 90 + --help, -h - show help [optional] 91 + ``` 92 + 93 + Interactive command to modify your existing configuration. Choose between: 94 + 95 + - **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings 96 + - **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+29
docs/docs/pages/config.mdx
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 18 | `bluesky` | `object` | No | - | Bluesky posting configuration | 19 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 20 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 79 } 80 } 81 ``` 82 83 ### Ignoring Files 84
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 + | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 23 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 82 } 83 } 84 ``` 85 + 86 + ### Slug Configuration 87 + 88 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 89 + 90 + ```json 91 + { 92 + "frontmatter": { 93 + "slugField": "url" 94 + } 95 + } 96 + ``` 97 + 98 + If the frontmatter field is not found, it falls back to the filepath. 99 + 100 + ### Jekyll-Style Date Prefixes 101 + 102 + Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs: 103 + 104 + ```json 105 + { 106 + "stripDatePrefix": true 107 + } 108 + ``` 109 + 110 + This transforms `2024-01-15-my-post.md` into the slug `my-post`. 111 112 ### Ignoring Files 113
+9 -7
docs/docs/pages/quickstart.mdx
··· 31 sequoia 32 ``` 33 34 - ### Authorize 35 - 36 - In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password. 37 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 41 42 ```bash [Terminal] 43 - sequoia auth 44 ``` 45 46 ### Initialize 47
··· 31 sequoia 32 ``` 33 34 + ### Login 35 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 37 38 ```bash [Terminal] 39 + sequoia login 40 ``` 41 + 42 + This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI. 43 + 44 + :::tip 45 + Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth. 46 + ::: 47 48 ### Initialize 49
docs/docs/public/icon-dark.png

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

+37
packages/cli/biome.json
···
··· 1 + { 2 + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "includes": ["**", "!!**/dist"] 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "tab" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true, 19 + "style": { 20 + "noNonNullAssertion": "off" 21 + } 22 + } 23 + }, 24 + "javascript": { 25 + "formatter": { 26 + "quoteStyle": "double" 27 + } 28 + }, 29 + "assist": { 30 + "enabled": true, 31 + "actions": { 32 + "source": { 33 + "organizeImports": "on" 34 + } 35 + } 36 + } 37 + }
+7 -2
packages/cli/package.json
··· 1 { 2 "name": "sequoia-cli", 3 - "version": "0.2.0", 4 "type": "module", 5 "bin": { 6 "sequoia": "dist/index.js" ··· 14 ".": "./dist/index.js" 15 }, 16 "scripts": { 17 "build": "bun build src/index.ts --target node --outdir dist", 18 "dev": "bun run build && bun link", 19 "deploy": "bun run build && bun publish" 20 }, 21 "devDependencies": { 22 "@types/mime-types": "^3.0.1", 23 "@types/node": "^20" 24 }, ··· 27 }, 28 "dependencies": { 29 "@atproto/api": "^0.18.17", 30 "@clack/prompts": "^1.0.0", 31 "cmd-ts": "^0.14.3", 32 "glob": "^13.0.0", 33 "mime-types": "^2.1.35", 34 - "minimatch": "^10.1.1" 35 } 36 }
··· 1 { 2 "name": "sequoia-cli", 3 + "version": "0.3.3", 4 "type": "module", 5 "bin": { 6 "sequoia": "dist/index.js" ··· 14 ".": "./dist/index.js" 15 }, 16 "scripts": { 17 + "lint": "biome lint --write", 18 + "format": "biome format --write", 19 "build": "bun build src/index.ts --target node --outdir dist", 20 "dev": "bun run build && bun link", 21 "deploy": "bun run build && bun publish" 22 }, 23 "devDependencies": { 24 + "@biomejs/biome": "^2.3.13", 25 "@types/mime-types": "^3.0.1", 26 "@types/node": "^20" 27 }, ··· 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 34 "@clack/prompts": "^1.0.0", 35 "cmd-ts": "^0.14.3", 36 "glob": "^13.0.0", 37 "mime-types": "^2.1.35", 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0" 40 } 41 }
+153 -135
packages/cli/src/commands/auth.ts
··· 1 - import { command, flag, option, optional, string } from "cmd-ts"; 2 - import { note, text, password, confirm, select, spinner, log } from "@clack/prompts"; 3 import { AtpAgent } from "@atproto/api"; 4 import { 5 - saveCredentials, 6 - deleteCredentials, 7 - listCredentials, 8 - getCredentials, 9 - getCredentialsPath, 10 - } from "../lib/credentials"; 11 import { resolveHandleToPDS } from "../lib/atproto"; 12 import { exitOnCancel } from "../lib/prompts"; 13 14 export const authCommand = command({ 15 - name: "auth", 16 - description: "Authenticate with your ATProto PDS", 17 - args: { 18 - logout: option({ 19 - long: "logout", 20 - description: "Remove credentials for a specific identity (or all if only one exists)", 21 - type: optional(string), 22 - }), 23 - list: flag({ 24 - long: "list", 25 - description: "List all stored identities", 26 - }), 27 - }, 28 - handler: async ({ logout, list }) => { 29 - // List identities 30 - if (list) { 31 - const identities = await listCredentials(); 32 - if (identities.length === 0) { 33 - log.info("No stored identities"); 34 - } else { 35 - log.info("Stored identities:"); 36 - for (const id of identities) { 37 - console.log(` - ${id}`); 38 - } 39 - } 40 - return; 41 - } 42 43 - // Logout 44 - if (logout !== undefined) { 45 - // If --logout was passed without a value, it will be an empty string 46 - const identifier = logout || undefined; 47 48 - if (!identifier) { 49 - // No identifier provided - show available and prompt 50 - const identities = await listCredentials(); 51 - if (identities.length === 0) { 52 - log.info("No saved credentials found"); 53 - return; 54 - } 55 - if (identities.length === 1) { 56 - const deleted = await deleteCredentials(identities[0]); 57 - if (deleted) { 58 - log.success(`Removed credentials for ${identities[0]}`); 59 - } 60 - return; 61 - } 62 - // Multiple identities - prompt 63 - const selected = exitOnCancel(await select({ 64 - message: "Select identity to remove:", 65 - options: identities.map(id => ({ value: id, label: id })), 66 - })); 67 - const deleted = await deleteCredentials(selected); 68 - if (deleted) { 69 - log.success(`Removed credentials for ${selected}`); 70 - } 71 - return; 72 - } 73 74 - const deleted = await deleteCredentials(identifier); 75 - if (deleted) { 76 - log.success(`Removed credentials for ${identifier}`); 77 - } else { 78 - log.info(`No credentials found for ${identifier}`); 79 - } 80 - return; 81 - } 82 83 - note( 84 - "To authenticate, you'll need an App Password.\n\n" + 85 - "Create one at: https://bsky.app/settings/app-passwords\n\n" + 86 - "App Passwords are safer than your main password and can be revoked.", 87 - "Authentication" 88 - ); 89 90 - const identifier = exitOnCancel(await text({ 91 - message: "Handle or DID:", 92 - placeholder: "yourhandle.bsky.social", 93 - })); 94 95 - const appPassword = exitOnCancel(await password({ 96 - message: "App Password:", 97 - })); 98 99 - if (!identifier || !appPassword) { 100 - log.error("Handle and password are required"); 101 - process.exit(1); 102 - } 103 104 - // Check if this identity already exists 105 - const existing = await getCredentials(identifier); 106 - if (existing) { 107 - const overwrite = exitOnCancel(await confirm({ 108 - message: `Credentials for ${identifier} already exist. Update?`, 109 - initialValue: false, 110 - })); 111 - if (!overwrite) { 112 - log.info("Keeping existing credentials"); 113 - return; 114 - } 115 - } 116 117 - // Resolve PDS from handle 118 - const s = spinner(); 119 - s.start("Resolving PDS..."); 120 - let pdsUrl: string; 121 - try { 122 - pdsUrl = await resolveHandleToPDS(identifier); 123 - s.stop(`Found PDS: ${pdsUrl}`); 124 - } catch (error) { 125 - s.stop("Failed to resolve PDS"); 126 - log.error(`Failed to resolve PDS from handle: ${error}`); 127 - process.exit(1); 128 - } 129 130 - // Verify credentials 131 - s.start("Verifying credentials..."); 132 133 - try { 134 - const agent = new AtpAgent({ service: pdsUrl }); 135 - await agent.login({ 136 - identifier: identifier, 137 - password: appPassword, 138 - }); 139 140 - s.stop(`Logged in as ${agent.session?.handle}`); 141 142 - // Save credentials 143 - await saveCredentials({ 144 - pdsUrl, 145 - identifier: identifier, 146 - password: appPassword, 147 - }); 148 149 - log.success(`Credentials saved to ${getCredentialsPath()}`); 150 - } catch (error) { 151 - s.stop("Failed to login"); 152 - log.error(`Failed to login: ${error}`); 153 - process.exit(1); 154 - } 155 - }, 156 });
··· 1 import { AtpAgent } from "@atproto/api"; 2 import { 3 + confirm, 4 + log, 5 + note, 6 + password, 7 + select, 8 + spinner, 9 + text, 10 + } from "@clack/prompts"; 11 + import { command, flag, option, optional, string } from "cmd-ts"; 12 import { resolveHandleToPDS } from "../lib/atproto"; 13 + import { 14 + deleteCredentials, 15 + getCredentials, 16 + getCredentialsPath, 17 + listCredentials, 18 + saveCredentials, 19 + } from "../lib/credentials"; 20 import { exitOnCancel } from "../lib/prompts"; 21 22 export const authCommand = command({ 23 + name: "auth", 24 + description: "Authenticate with your ATProto PDS", 25 + args: { 26 + logout: option({ 27 + long: "logout", 28 + description: 29 + "Remove credentials for a specific identity (or all if only one exists)", 30 + type: optional(string), 31 + }), 32 + list: flag({ 33 + long: "list", 34 + description: "List all stored identities", 35 + }), 36 + }, 37 + handler: async ({ logout, list }) => { 38 + // List identities 39 + if (list) { 40 + const identities = await listCredentials(); 41 + if (identities.length === 0) { 42 + log.info("No stored identities"); 43 + } else { 44 + log.info("Stored identities:"); 45 + for (const id of identities) { 46 + console.log(` - ${id}`); 47 + } 48 + } 49 + return; 50 + } 51 52 + // Logout 53 + if (logout !== undefined) { 54 + // If --logout was passed without a value, it will be an empty string 55 + const identifier = logout || undefined; 56 57 + if (!identifier) { 58 + // No identifier provided - show available and prompt 59 + const identities = await listCredentials(); 60 + if (identities.length === 0) { 61 + log.info("No saved credentials found"); 62 + return; 63 + } 64 + if (identities.length === 1) { 65 + const deleted = await deleteCredentials(identities[0]); 66 + if (deleted) { 67 + log.success(`Removed credentials for ${identities[0]}`); 68 + } 69 + return; 70 + } 71 + // Multiple identities - prompt 72 + const selected = exitOnCancel( 73 + await select({ 74 + message: "Select identity to remove:", 75 + options: identities.map((id) => ({ value: id, label: id })), 76 + }), 77 + ); 78 + const deleted = await deleteCredentials(selected); 79 + if (deleted) { 80 + log.success(`Removed credentials for ${selected}`); 81 + } 82 + return; 83 + } 84 85 + const deleted = await deleteCredentials(identifier); 86 + if (deleted) { 87 + log.success(`Removed credentials for ${identifier}`); 88 + } else { 89 + log.info(`No credentials found for ${identifier}`); 90 + } 91 + return; 92 + } 93 94 + note( 95 + "To authenticate, you'll need an App Password.\n\n" + 96 + "Create one at: https://bsky.app/settings/app-passwords\n\n" + 97 + "App Passwords are safer than your main password and can be revoked.", 98 + "Authentication", 99 + ); 100 101 + const identifier = exitOnCancel( 102 + await text({ 103 + message: "Handle or DID:", 104 + placeholder: "yourhandle.bsky.social", 105 + }), 106 + ); 107 108 + const appPassword = exitOnCancel( 109 + await password({ 110 + message: "App Password:", 111 + }), 112 + ); 113 114 + if (!identifier || !appPassword) { 115 + log.error("Handle and password are required"); 116 + process.exit(1); 117 + } 118 119 + // Check if this identity already exists 120 + const existing = await getCredentials(identifier); 121 + if (existing) { 122 + const overwrite = exitOnCancel( 123 + await confirm({ 124 + message: `Credentials for ${identifier} already exist. Update?`, 125 + initialValue: false, 126 + }), 127 + ); 128 + if (!overwrite) { 129 + log.info("Keeping existing credentials"); 130 + return; 131 + } 132 + } 133 134 + // Resolve PDS from handle 135 + const s = spinner(); 136 + s.start("Resolving PDS..."); 137 + let pdsUrl: string; 138 + try { 139 + pdsUrl = await resolveHandleToPDS(identifier); 140 + s.stop(`Found PDS: ${pdsUrl}`); 141 + } catch (error) { 142 + s.stop("Failed to resolve PDS"); 143 + log.error(`Failed to resolve PDS from handle: ${error}`); 144 + process.exit(1); 145 + } 146 147 + // Verify credentials 148 + s.start("Verifying credentials..."); 149 150 + try { 151 + const agent = new AtpAgent({ service: pdsUrl }); 152 + await agent.login({ 153 + identifier: identifier, 154 + password: appPassword, 155 + }); 156 157 + s.stop(`Logged in as ${agent.session?.handle}`); 158 159 + // Save credentials 160 + await saveCredentials({ 161 + type: "app-password", 162 + pdsUrl, 163 + identifier: identifier, 164 + password: appPassword, 165 + }); 166 167 + log.success(`Credentials saved to ${getCredentialsPath()}`); 168 + } catch (error) { 169 + s.stop("Failed to login"); 170 + log.error(`Failed to login: ${error}`); 171 + process.exit(1); 172 + } 173 + }, 174 });
+34 -13
packages/cli/src/commands/init.ts
··· 1 - import * as fs from "fs/promises"; 2 import { command } from "cmd-ts"; 3 import { 4 intro, ··· 11 log, 12 group, 13 } from "@clack/prompts"; 14 - import * as path from "path"; 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 - import { loadCredentials } from "../lib/credentials"; 17 import { createAgent, createPublication } from "../lib/atproto"; 18 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 async function fileExists(filePath: string): Promise<boolean> { ··· 186 } 187 188 let publicationUri: string; 189 - const credentials = await loadCredentials(); 190 191 if (publicationChoice === "create") { 192 // Need credentials to create a publication 193 if (!credentials) { 194 log.error( 195 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 196 ); 197 process.exit(1); 198 } 199 200 const s = spinner(); 201 s.start("Connecting to ATProto..."); 202 - let agent; 203 try { 204 agent = await createAgent(credentials); 205 s.stop("Connected!"); 206 - } catch (error) { 207 s.stop("Failed to connect"); 208 log.error( 209 - "Failed to connect. Check your credentials with 'sequoia auth'.", 210 ); 211 process.exit(1); 212 } ··· 287 defaultValue: "7", 288 placeholder: "7", 289 validate: (value) => { 290 - const num = parseInt(value, 10); 291 - if (isNaN(num) || num < 1) { 292 return "Please enter a positive number"; 293 } 294 }, ··· 305 }; 306 } 307 308 - // Get PDS URL from credentials (already loaded earlier) 309 - const pdsUrl = credentials?.pdsUrl; 310 311 // Generate config file 312 const configContent = generateConfigTemplate({ ··· 351 if (!gitignoreContent.includes(stateFilename)) { 352 await fs.writeFile( 353 gitignorePath, 354 - gitignoreContent + `\n${stateFilename}\n`, 355 ); 356 log.info(`Added ${stateFilename} to .gitignore`); 357 }
··· 1 + import * as fs from "node:fs/promises"; 2 import { command } from "cmd-ts"; 3 import { 4 intro, ··· 11 log, 12 group, 13 } from "@clack/prompts"; 14 + import * as path from "node:path"; 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 17 import { createAgent, createPublication } from "../lib/atproto"; 18 + import { selectCredential } from "../lib/credential-select"; 19 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 20 21 async function fileExists(filePath: string): Promise<boolean> { ··· 187 } 188 189 let publicationUri: string; 190 + let credentials = await loadCredentials(); 191 192 if (publicationChoice === "create") { 193 // Need credentials to create a publication 194 if (!credentials) { 195 + // Check if there are multiple identities - if so, prompt to select 196 + const allCredentials = await listAllCredentials(); 197 + if (allCredentials.length > 1) { 198 + credentials = await selectCredential(allCredentials); 199 + } else if (allCredentials.length === 1) { 200 + // Single credential exists but couldn't be loaded - try to load it explicitly 201 + credentials = await selectCredential(allCredentials); 202 + } else { 203 + log.error( 204 + "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 205 + ); 206 + process.exit(1); 207 + } 208 + } 209 + 210 + if (!credentials) { 211 log.error( 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 213 ); 214 process.exit(1); 215 } 216 217 const s = spinner(); 218 s.start("Connecting to ATProto..."); 219 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 220 try { 221 agent = await createAgent(credentials); 222 s.stop("Connected!"); 223 + } catch (_error) { 224 s.stop("Failed to connect"); 225 log.error( 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 227 ); 228 process.exit(1); 229 } ··· 304 defaultValue: "7", 305 placeholder: "7", 306 validate: (value) => { 307 + if (!value) { 308 + return "Please enter a number"; 309 + } 310 + const num = Number.parseInt(value, 10); 311 + if (Number.isNaN(num) || num < 1) { 312 return "Please enter a positive number"; 313 } 314 }, ··· 325 }; 326 } 327 328 + // Get PDS URL from credentials (only available for app-password auth) 329 + const pdsUrl = 330 + credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 331 332 // Generate config file 333 const configContent = generateConfigTemplate({ ··· 372 if (!gitignoreContent.includes(stateFilename)) { 373 await fs.writeFile( 374 gitignorePath, 375 + `${gitignoreContent}\n${stateFilename}\n`, 376 ); 377 log.info(`Added ${stateFilename} to .gitignore`); 378 }
+32 -56
packages/cli/src/commands/inject.ts
··· 1 - import * as fs from "fs/promises"; 2 - import { command, flag, option, optional, string } from "cmd-ts"; 3 import { log } from "@clack/prompts"; 4 - import * as path from "path"; 5 import { glob } from "glob"; 6 - import { loadConfig, loadState, findConfig } from "../lib/config"; 7 8 export const injectCommand = command({ 9 name: "inject", 10 - description: 11 - "Inject site.standard.document link tags into built HTML files", 12 args: { 13 outputDir: option({ 14 long: "output", ··· 44 // Load state to get atUri mappings 45 const state = await loadState(configDir); 46 47 - // Generic filenames where the slug is the parent directory, not the filename 48 - // Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc. 49 - const genericFilenames = new Set([ 50 - "+page", 51 - "index", 52 - "_index", 53 - "page", 54 - "readme", 55 - ]); 56 - 57 - // Build a map of slug/path to atUri from state 58 - const pathToAtUri = new Map<string, string>(); 59 for (const [filePath, postState] of Object.entries(state.posts)) { 60 - if (postState.atUri) { 61 - // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 62 - let basename = path.basename(filePath, path.extname(filePath)); 63 - 64 - // If the filename is a generic convention name, use the parent directory as slug 65 - if (genericFilenames.has(basename.toLowerCase())) { 66 - // Split path and filter out route groups like (blog-article) 67 - const pathParts = filePath 68 - .split(/[/\\]/) 69 - .filter((p) => p && !(p.startsWith("(") && p.endsWith(")"))); 70 - // The slug should be the second-to-last part (last is the filename) 71 - if (pathParts.length >= 2) { 72 - const slug = pathParts[pathParts.length - 2]; 73 - if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") { 74 - basename = slug; 75 - } 76 - } 77 - } 78 - 79 - pathToAtUri.set(basename, postState.atUri); 80 81 - // Also add variations that might match HTML file paths 82 - // e.g., /blog/my-post, /posts/my-post, my-post/index 83 - const dirName = path.basename(path.dirname(filePath)); 84 - // Skip route groups and common directory names 85 - if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) { 86 - pathToAtUri.set(`${dirName}/${basename}`, postState.atUri); 87 } 88 } 89 } 90 91 - if (pathToAtUri.size === 0) { 92 log.warn( 93 "No published posts found in state. Run 'sequoia publish' first.", 94 ); 95 return; 96 } 97 98 - log.info(`Found ${pathToAtUri.size} published posts in state`); 99 100 // Scan for HTML files 101 const htmlFiles = await glob("**/*.html", { ··· 125 let atUri: string | undefined; 126 127 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post) 128 - atUri = pathToAtUri.get(htmlBasename); 129 130 - // Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post) 131 if (!atUri && htmlBasename === "index" && htmlDir !== ".") { 132 - const slug = path.basename(htmlDir); 133 - atUri = pathToAtUri.get(slug); 134 135 - // Also try parent/slug pattern 136 if (!atUri) { 137 - const parentDir = path.dirname(htmlDir); 138 - if (parentDir !== ".") { 139 - atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`); 140 - } 141 } 142 } 143 144 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post) 145 if (!atUri && htmlDir !== ".") { 146 - atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`); 147 } 148 149 if (!atUri) {
··· 1 import { log } from "@clack/prompts"; 2 + import { command, flag, option, optional, string } from "cmd-ts"; 3 import { glob } from "glob"; 4 + import * as fs from "node:fs/promises"; 5 + import * as path from "node:path"; 6 + import { findConfig, loadConfig, loadState } from "../lib/config"; 7 8 export const injectCommand = command({ 9 name: "inject", 10 + description: "Inject site.standard.document link tags into built HTML files", 11 args: { 12 outputDir: option({ 13 long: "output", ··· 43 // Load state to get atUri mappings 44 const state = await loadState(configDir); 45 46 + // Build a map of slug to atUri from state 47 + // The slug is stored in state by the publish command, using the configured slug options 48 + const slugToAtUri = new Map<string, string>(); 49 for (const [filePath, postState] of Object.entries(state.posts)) { 50 + if (postState.atUri && postState.slug) { 51 + // Use the slug stored in state (computed by publish with config options) 52 + slugToAtUri.set(postState.slug, postState.atUri); 53 54 + // Also add the last segment for simpler matching 55 + // e.g., "other/my-other-post" -> also map "my-other-post" 56 + const lastSegment = postState.slug.split("/").pop(); 57 + if (lastSegment && lastSegment !== postState.slug) { 58 + slugToAtUri.set(lastSegment, postState.atUri); 59 } 60 + } else if (postState.atUri) { 61 + // Fallback for older state files without slug field 62 + // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 63 + const basename = path.basename(filePath, path.extname(filePath)); 64 + slugToAtUri.set(basename.toLowerCase(), postState.atUri); 65 } 66 } 67 68 + if (slugToAtUri.size === 0) { 69 log.warn( 70 "No published posts found in state. Run 'sequoia publish' first.", 71 ); 72 return; 73 } 74 75 + log.info(`Found ${slugToAtUri.size} slug mappings from published posts`); 76 77 // Scan for HTML files 78 const htmlFiles = await glob("**/*.html", { ··· 102 let atUri: string | undefined; 103 104 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post) 105 + atUri = slugToAtUri.get(htmlBasename); 106 107 + // Strategy 2: For index.html, try the directory path 108 + // e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift 109 if (!atUri && htmlBasename === "index" && htmlDir !== ".") { 110 + // Try full directory path (for nested subdirectories) 111 + atUri = slugToAtUri.get(htmlDir); 112 113 + // Also try just the last directory segment 114 if (!atUri) { 115 + const lastDir = path.basename(htmlDir); 116 + atUri = slugToAtUri.get(lastDir); 117 } 118 } 119 120 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post) 121 if (!atUri && htmlDir !== ".") { 122 + atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`); 123 } 124 125 if (!atUri) {
+305
packages/cli/src/commands/login.ts
···
··· 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 + getOAuthClient, 8 + getOAuthScope, 9 + } from "../lib/oauth-client"; 10 + import { 11 + deleteOAuthSession, 12 + getOAuthStorePath, 13 + listOAuthSessions, 14 + listOAuthSessionsWithHandles, 15 + setOAuthHandle, 16 + } from "../lib/oauth-store"; 17 + import { exitOnCancel } from "../lib/prompts"; 18 + 19 + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 20 + 21 + export const loginCommand = command({ 22 + name: "login", 23 + description: "Login with OAuth (browser-based authentication)", 24 + args: { 25 + logout: option({ 26 + long: "logout", 27 + description: "Remove OAuth session for a specific DID", 28 + type: optional(string), 29 + }), 30 + list: flag({ 31 + long: "list", 32 + description: "List all stored OAuth sessions", 33 + }), 34 + }, 35 + handler: async ({ logout, list }) => { 36 + // List sessions 37 + if (list) { 38 + const sessions = await listOAuthSessionsWithHandles(); 39 + if (sessions.length === 0) { 40 + log.info("No OAuth sessions stored"); 41 + } else { 42 + log.info("OAuth sessions:"); 43 + for (const { did, handle } of sessions) { 44 + console.log(` - ${handle || did} (${did})`); 45 + } 46 + } 47 + return; 48 + } 49 + 50 + // Logout 51 + if (logout !== undefined) { 52 + const did = logout || undefined; 53 + 54 + if (!did) { 55 + // No DID provided - show available and prompt 56 + const sessions = await listOAuthSessions(); 57 + if (sessions.length === 0) { 58 + log.info("No OAuth sessions found"); 59 + return; 60 + } 61 + if (sessions.length === 1) { 62 + const deleted = await deleteOAuthSession(sessions[0]!); 63 + if (deleted) { 64 + log.success(`Removed OAuth session for ${sessions[0]}`); 65 + } 66 + return; 67 + } 68 + // Multiple sessions - prompt 69 + const selected = exitOnCancel( 70 + await select({ 71 + message: "Select session to remove:", 72 + options: sessions.map((d) => ({ value: d, label: d })), 73 + }), 74 + ); 75 + const deleted = await deleteOAuthSession(selected); 76 + if (deleted) { 77 + log.success(`Removed OAuth session for ${selected}`); 78 + } 79 + return; 80 + } 81 + 82 + const deleted = await deleteOAuthSession(did); 83 + if (deleted) { 84 + log.success(`Removed OAuth session for ${did}`); 85 + } else { 86 + log.info(`No OAuth session found for ${did}`); 87 + } 88 + return; 89 + } 90 + 91 + // OAuth login flow 92 + note( 93 + "OAuth login will open your browser to authenticate.\n\n" + 94 + "This is more secure than app passwords and tokens refresh automatically.", 95 + "OAuth Login", 96 + ); 97 + 98 + const handle = exitOnCancel( 99 + await text({ 100 + message: "Handle or DID:", 101 + placeholder: "yourhandle.bsky.social", 102 + }), 103 + ); 104 + 105 + if (!handle) { 106 + log.error("Handle is required"); 107 + process.exit(1); 108 + } 109 + 110 + const s = spinner(); 111 + s.start("Resolving identity..."); 112 + 113 + let did: string; 114 + try { 115 + did = await resolveHandleToDid(handle); 116 + s.stop(`Identity resolved`); 117 + } catch (error) { 118 + s.stop("Failed to resolve identity"); 119 + if (error instanceof Error) { 120 + log.error(`Error: ${error.message}`); 121 + } else { 122 + log.error(`Error: ${error}`); 123 + } 124 + process.exit(1); 125 + } 126 + 127 + s.start("Initializing OAuth..."); 128 + 129 + try { 130 + const client = await getOAuthClient(); 131 + 132 + // Generate authorization URL using the resolved DID 133 + const authUrl = await client.authorize(did, { 134 + scope: getOAuthScope(), 135 + }); 136 + 137 + log.info(`Login URL: ${authUrl}`); 138 + 139 + s.message("Opening browser..."); 140 + 141 + // Try to open browser 142 + let browserOpened = true; 143 + try { 144 + const open = (await import("open")).default; 145 + await open(authUrl.toString()); 146 + } catch { 147 + browserOpened = false; 148 + } 149 + 150 + s.message("Waiting for authentication..."); 151 + 152 + // Show URL info 153 + if (!browserOpened) { 154 + s.stop("Could not open browser automatically"); 155 + log.warn("Please open the following URL in your browser:"); 156 + log.info(authUrl.toString()); 157 + s.start("Waiting for authentication..."); 158 + } 159 + 160 + // Start HTTP server to receive callback 161 + const result = await waitForCallback(); 162 + 163 + if (!result.success) { 164 + s.stop("Authentication failed"); 165 + log.error(result.error || "OAuth callback failed"); 166 + process.exit(1); 167 + } 168 + 169 + s.message("Completing authentication..."); 170 + 171 + // Exchange code for tokens 172 + const { session } = await client.callback( 173 + new URLSearchParams(result.params!), 174 + ); 175 + 176 + // Store the handle for friendly display 177 + // Use the original handle input (unless it was a DID) 178 + const handleToStore = handle.startsWith("did:") ? undefined : handle; 179 + if (handleToStore) { 180 + await setOAuthHandle(session.did, handleToStore); 181 + } 182 + 183 + // Try to get the handle for display (use the original handle input as fallback) 184 + const displayName = handleToStore || session.did; 185 + 186 + s.stop(`Logged in as ${displayName}`); 187 + 188 + log.success(`OAuth session saved to ${getOAuthStorePath()}`); 189 + log.info("Your session will refresh automatically when needed."); 190 + 191 + // Exit cleanly - the OAuth client may have background processes 192 + process.exit(0); 193 + } catch (error) { 194 + s.stop("OAuth login failed"); 195 + if (error instanceof Error) { 196 + log.error(`Error: ${error.message}`); 197 + } else { 198 + log.error(`Error: ${error}`); 199 + } 200 + process.exit(1); 201 + } 202 + }, 203 + }); 204 + 205 + interface CallbackResult { 206 + success: boolean; 207 + params?: Record<string, string>; 208 + error?: string; 209 + } 210 + 211 + function waitForCallback(): Promise<CallbackResult> { 212 + return new Promise((resolve) => { 213 + const port = getCallbackPort(); 214 + let timeoutId: ReturnType<typeof setTimeout> | undefined; 215 + 216 + const server = http.createServer((req, res) => { 217 + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); 218 + 219 + if (url.pathname === "/oauth/callback") { 220 + const params: Record<string, string> = {}; 221 + url.searchParams.forEach((value, key) => { 222 + params[key] = value; 223 + }); 224 + 225 + // Clear the timeout 226 + if (timeoutId) clearTimeout(timeoutId); 227 + 228 + // Check for error 229 + if (params.error) { 230 + res.writeHead(200, { "Content-Type": "text/html" }); 231 + res.end(` 232 + <html> 233 + <head> 234 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 235 + </head> 236 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 237 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 238 + <h1 style="font-weight: 400;">Authentication Failed</h1> 239 + <p>${params.error_description || params.error}</p> 240 + <p>You can close this window.</p> 241 + </body> 242 + </html> 243 + `); 244 + server.close(() => { 245 + resolve({ 246 + success: false, 247 + error: params.error_description || params.error, 248 + }); 249 + }); 250 + return; 251 + } 252 + 253 + // Success 254 + res.writeHead(200, { "Content-Type": "text/html" }); 255 + res.end(` 256 + <html> 257 + <head> 258 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 259 + </head> 260 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 261 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 262 + <h1 style="font-weight: 400;">Authentication Successful</h1> 263 + <p>You can close this window and return to the terminal.</p> 264 + </body> 265 + </html> 266 + `); 267 + server.close(() => { 268 + resolve({ success: true, params }); 269 + }); 270 + return; 271 + } 272 + 273 + // Not the callback path 274 + res.writeHead(404); 275 + res.end("Not found"); 276 + }); 277 + 278 + server.on("error", (err: NodeJS.ErrnoException) => { 279 + if (timeoutId) clearTimeout(timeoutId); 280 + if (err.code === "EADDRINUSE") { 281 + resolve({ 282 + success: false, 283 + error: `Port ${port} is already in use. Please close the application using that port and try again.`, 284 + }); 285 + } else { 286 + resolve({ 287 + success: false, 288 + error: `Server error: ${err.message}`, 289 + }); 290 + } 291 + }); 292 + 293 + server.listen(port, "127.0.0.1"); 294 + 295 + // Timeout after 5 minutes 296 + timeoutId = setTimeout(() => { 297 + server.close(() => { 298 + resolve({ 299 + success: false, 300 + error: "Timeout waiting for OAuth callback. Please try again.", 301 + }); 302 + }); 303 + }, CALLBACK_TIMEOUT_MS); 304 + }); 305 + }
+347 -270
packages/cli/src/commands/publish.ts
··· 1 - import * as fs from "fs/promises"; 2 import { command, flag } from "cmd-ts"; 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 - import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath, createBlueskyPost, addBskyPostRefToDocument } from "../lib/atproto"; 8 import { 9 - scanContentDirectory, 10 - getContentHash, 11 - updateFrontmatterWithAtUri, 12 } from "../lib/markdown"; 13 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 14 import { exitOnCancel } from "../lib/prompts"; 15 16 export const publishCommand = command({ 17 - name: "publish", 18 - description: "Publish content to ATProto", 19 - args: { 20 - force: flag({ 21 - long: "force", 22 - short: "f", 23 - description: "Force publish all posts, ignoring change detection", 24 - }), 25 - dryRun: flag({ 26 - long: "dry-run", 27 - short: "n", 28 - description: "Preview what would be published without making changes", 29 - }), 30 - }, 31 - handler: async ({ force, dryRun }) => { 32 - // Load config 33 - const configPath = await findConfig(); 34 - if (!configPath) { 35 - log.error("No publisher.config.ts found. Run 'publisher init' first."); 36 - process.exit(1); 37 - } 38 39 - const config = await loadConfig(configPath); 40 - const configDir = path.dirname(configPath); 41 42 - log.info(`Site: ${config.siteUrl}`); 43 - log.info(`Content directory: ${config.contentDir}`); 44 45 - // Load credentials 46 - let credentials = await loadCredentials(config.identity); 47 48 - // If no credentials resolved, check if we need to prompt for identity selection 49 - if (!credentials) { 50 - const identities = await listCredentials(); 51 - if (identities.length === 0) { 52 - log.error("No credentials found. Run 'sequoia auth' first."); 53 - log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables."); 54 - process.exit(1); 55 - } 56 57 - // Multiple identities exist but none selected - prompt user 58 - log.info("Multiple identities found. Select one to use:"); 59 - const selected = exitOnCancel(await select({ 60 - message: "Identity:", 61 - options: identities.map(id => ({ value: id, label: id })), 62 - })); 63 64 - credentials = await getCredentials(selected); 65 - if (!credentials) { 66 - log.error("Failed to load selected credentials."); 67 - process.exit(1); 68 - } 69 70 - log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`); 71 - } 72 73 - // Resolve content directory 74 - const contentDir = path.isAbsolute(config.contentDir) 75 - ? config.contentDir 76 - : path.join(configDir, config.contentDir); 77 78 - const imagesDir = config.imagesDir 79 - ? path.isAbsolute(config.imagesDir) 80 - ? config.imagesDir 81 - : path.join(configDir, config.imagesDir) 82 - : undefined; 83 84 - // Load state 85 - const state = await loadState(configDir); 86 87 - // Scan for posts 88 - const s = spinner(); 89 - s.start("Scanning for posts..."); 90 - const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore); 91 - s.stop(`Found ${posts.length} posts`); 92 93 - // Determine which posts need publishing 94 - const postsToPublish: Array<{ 95 - post: BlogPost; 96 - action: "create" | "update"; 97 - reason: string; 98 - }> = []; 99 - const draftPosts: BlogPost[] = []; 100 101 - for (const post of posts) { 102 - // Skip draft posts 103 - if (post.frontmatter.draft) { 104 - draftPosts.push(post); 105 - continue; 106 - } 107 108 - const contentHash = await getContentHash(post.rawContent); 109 - const relativeFilePath = path.relative(configDir, post.filePath); 110 - const postState = state.posts[relativeFilePath]; 111 112 - if (force) { 113 - postsToPublish.push({ 114 - post, 115 - action: post.frontmatter.atUri ? "update" : "create", 116 - reason: "forced", 117 - }); 118 - } else if (!postState) { 119 - // New post 120 - postsToPublish.push({ 121 - post, 122 - action: "create", 123 - reason: "new post", 124 - }); 125 - } else if (postState.contentHash !== contentHash) { 126 - // Changed post 127 - postsToPublish.push({ 128 - post, 129 - action: post.frontmatter.atUri ? "update" : "create", 130 - reason: "content changed", 131 - }); 132 - } 133 - } 134 135 - if (draftPosts.length > 0) { 136 - log.info(`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`); 137 - } 138 139 - if (postsToPublish.length === 0) { 140 - log.success("All posts are up to date. Nothing to publish."); 141 - return; 142 - } 143 144 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 145 146 - // Bluesky posting configuration 147 - const blueskyEnabled = config.bluesky?.enabled ?? false; 148 - const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 149 - const cutoffDate = new Date(); 150 - cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 151 152 - for (const { post, action, reason } of postsToPublish) { 153 - const icon = action === "create" ? "+" : "~"; 154 - const relativeFilePath = path.relative(configDir, post.filePath); 155 - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 156 157 - let bskyNote = ""; 158 - if (blueskyEnabled) { 159 - if (existingBskyPostRef) { 160 - bskyNote = " [bsky: exists]"; 161 - } else { 162 - const publishDate = new Date(post.frontmatter.publishDate); 163 - if (publishDate < cutoffDate) { 164 - bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 165 - } else { 166 - bskyNote = " [bsky: will post]"; 167 - } 168 - } 169 - } 170 171 - log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 172 - } 173 174 - if (dryRun) { 175 - if (blueskyEnabled) { 176 - log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 177 - } 178 - log.info("\nDry run complete. No changes made."); 179 - return; 180 - } 181 182 - // Create agent 183 - s.start(`Connecting to ${credentials.pdsUrl}...`); 184 - let agent; 185 - try { 186 - agent = await createAgent(credentials); 187 - s.stop(`Logged in as ${agent.session?.handle}`); 188 - } catch (error) { 189 - s.stop("Failed to login"); 190 - log.error(`Failed to login: ${error}`); 191 - process.exit(1); 192 - } 193 194 - // Publish posts 195 - let publishedCount = 0; 196 - let updatedCount = 0; 197 - let errorCount = 0; 198 - let bskyPostCount = 0; 199 200 - for (const { post, action } of postsToPublish) { 201 - s.start(`Publishing: ${post.frontmatter.title}`); 202 203 - try { 204 - // Handle cover image upload 205 - let coverImage: BlobObject | undefined; 206 - if (post.frontmatter.ogImage) { 207 - const imagePath = await resolveImagePath( 208 - post.frontmatter.ogImage, 209 - imagesDir, 210 - contentDir 211 - ); 212 213 - if (imagePath) { 214 - log.info(` Uploading cover image: ${path.basename(imagePath)}`); 215 - coverImage = await uploadImage(agent, imagePath); 216 - if (coverImage) { 217 - log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 218 - } 219 - } else { 220 - log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 221 - } 222 - } 223 224 - // Track atUri, content for state saving, and bskyPostRef 225 - let atUri: string; 226 - let contentForHash: string; 227 - let bskyPostRef: StrongRef | undefined; 228 - const relativeFilePath = path.relative(configDir, post.filePath); 229 230 - // Check if bskyPostRef already exists in state 231 - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 232 233 - if (action === "create") { 234 - atUri = await createDocument(agent, post, config, coverImage); 235 - s.stop(`Created: ${atUri}`); 236 237 - // Update frontmatter with atUri 238 - const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri); 239 - await fs.writeFile(post.filePath, updatedContent); 240 - log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 241 242 - // Use updated content (with atUri) for hash so next run sees matching hash 243 - contentForHash = updatedContent; 244 - publishedCount++; 245 - } else { 246 - atUri = post.frontmatter.atUri!; 247 - await updateDocument(agent, post, atUri, config, coverImage); 248 - s.stop(`Updated: ${atUri}`); 249 250 - // For updates, rawContent already has atUri 251 - contentForHash = post.rawContent; 252 - updatedCount++; 253 - } 254 255 - // Create Bluesky post if enabled and conditions are met 256 - if (blueskyEnabled) { 257 - if (existingBskyPostRef) { 258 - log.info(` Bluesky post already exists, skipping`); 259 - bskyPostRef = existingBskyPostRef; 260 - } else { 261 - const publishDate = new Date(post.frontmatter.publishDate); 262 263 - if (publishDate < cutoffDate) { 264 - log.info(` Post is older than ${maxAgeDays} days, skipping Bluesky post`); 265 - } else { 266 - // Create Bluesky post 267 - try { 268 - const pathPrefix = config.pathPrefix || "/posts"; 269 - const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 270 271 - bskyPostRef = await createBlueskyPost(agent, { 272 - title: post.frontmatter.title, 273 - description: post.frontmatter.description, 274 - canonicalUrl, 275 - coverImage, 276 - publishedAt: post.frontmatter.publishDate, 277 - }); 278 279 - // Update document record with bskyPostRef 280 - await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 281 - log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 282 - bskyPostCount++; 283 - } catch (bskyError) { 284 - const errorMsg = bskyError instanceof Error ? bskyError.message : String(bskyError); 285 - log.warn(` Failed to create Bluesky post: ${errorMsg}`); 286 - } 287 - } 288 - } 289 - } 290 291 - // Update state (use relative path from config directory) 292 - const contentHash = await getContentHash(contentForHash); 293 - state.posts[relativeFilePath] = { 294 - contentHash, 295 - atUri, 296 - lastPublished: new Date().toISOString(), 297 - bskyPostRef, 298 - }; 299 - } catch (error) { 300 - const errorMessage = error instanceof Error ? error.message : String(error); 301 - s.stop(`Error publishing "${path.basename(post.filePath)}"`); 302 - log.error(` ${errorMessage}`); 303 - errorCount++; 304 - } 305 - } 306 307 - // Save state 308 - await saveState(configDir, state); 309 310 - // Summary 311 - log.message("\n---"); 312 - log.info(`Published: ${publishedCount}`); 313 - log.info(`Updated: ${updatedCount}`); 314 - if (bskyPostCount > 0) { 315 - log.info(`Bluesky posts: ${bskyPostCount}`); 316 - } 317 - if (errorCount > 0) { 318 - log.warn(`Errors: ${errorCount}`); 319 - } 320 - }, 321 });
··· 1 + import * as fs from "node:fs/promises"; 2 import { command, flag } from "cmd-ts"; 3 import { select, spinner, log } from "@clack/prompts"; 4 + import * as path from "node:path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 + import { 13 + createAgent, 14 + createDocument, 15 + updateDocument, 16 + uploadImage, 17 + resolveImagePath, 18 + createBlueskyPost, 19 + addBskyPostRefToDocument, 20 + } from "../lib/atproto"; 21 import { 22 + scanContentDirectory, 23 + getContentHash, 24 + updateFrontmatterWithAtUri, 25 } from "../lib/markdown"; 26 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 27 import { exitOnCancel } from "../lib/prompts"; 28 29 export const publishCommand = command({ 30 + name: "publish", 31 + description: "Publish content to ATProto", 32 + args: { 33 + force: flag({ 34 + long: "force", 35 + short: "f", 36 + description: "Force publish all posts, ignoring change detection", 37 + }), 38 + dryRun: flag({ 39 + long: "dry-run", 40 + short: "n", 41 + description: "Preview what would be published without making changes", 42 + }), 43 + }, 44 + handler: async ({ force, dryRun }) => { 45 + // Load config 46 + const configPath = await findConfig(); 47 + if (!configPath) { 48 + log.error("No publisher.config.ts found. Run 'publisher init' first."); 49 + process.exit(1); 50 + } 51 + 52 + const config = await loadConfig(configPath); 53 + const configDir = path.dirname(configPath); 54 + 55 + log.info(`Site: ${config.siteUrl}`); 56 + log.info(`Content directory: ${config.contentDir}`); 57 58 + // Load credentials 59 + let credentials = await loadCredentials(config.identity); 60 61 + // If no credentials resolved, check if we need to prompt for identity selection 62 + if (!credentials) { 63 + const identities = await listAllCredentials(); 64 + if (identities.length === 0) { 65 + log.error( 66 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 67 + ); 68 + log.info( 69 + "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 70 + ); 71 + process.exit(1); 72 + } 73 74 + // Build labels with handles for OAuth sessions 75 + const options = await Promise.all( 76 + identities.map(async (cred) => { 77 + if (cred.type === "oauth") { 78 + const handle = await getOAuthHandle(cred.id); 79 + return { 80 + value: cred.id, 81 + label: `${handle || cred.id} (OAuth)`, 82 + }; 83 + } 84 + return { 85 + value: cred.id, 86 + label: `${cred.id} (App Password)`, 87 + }; 88 + }), 89 + ); 90 91 + // Multiple identities exist but none selected - prompt user 92 + log.info("Multiple identities found. Select one to use:"); 93 + const selected = exitOnCancel( 94 + await select({ 95 + message: "Identity:", 96 + options, 97 + }), 98 + ); 99 100 + // Load the selected credentials 101 + const selectedCred = identities.find((c) => c.id === selected); 102 + if (selectedCred?.type === "oauth") { 103 + const session = await getOAuthSession(selected); 104 + if (session) { 105 + const handle = await getOAuthHandle(selected); 106 + credentials = { 107 + type: "oauth", 108 + did: selected, 109 + handle: handle || selected, 110 + }; 111 + } 112 + } else { 113 + credentials = await getCredentials(selected); 114 + } 115 116 + if (!credentials) { 117 + log.error("Failed to load selected credentials."); 118 + process.exit(1); 119 + } 120 121 + const displayId = 122 + credentials.type === "oauth" 123 + ? credentials.handle || credentials.did 124 + : credentials.identifier; 125 + log.info( 126 + `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 127 + ); 128 + } 129 130 + // Resolve content directory 131 + const contentDir = path.isAbsolute(config.contentDir) 132 + ? config.contentDir 133 + : path.join(configDir, config.contentDir); 134 135 + const imagesDir = config.imagesDir 136 + ? path.isAbsolute(config.imagesDir) 137 + ? config.imagesDir 138 + : path.join(configDir, config.imagesDir) 139 + : undefined; 140 141 + // Load state 142 + const state = await loadState(configDir); 143 144 + // Scan for posts 145 + const s = spinner(); 146 + s.start("Scanning for posts..."); 147 + const posts = await scanContentDirectory(contentDir, { 148 + frontmatterMapping: config.frontmatter, 149 + ignorePatterns: config.ignore, 150 + slugField: config.frontmatter?.slugField, 151 + removeIndexFromSlug: config.removeIndexFromSlug, 152 + stripDatePrefix: config.stripDatePrefix, 153 + }); 154 + s.stop(`Found ${posts.length} posts`); 155 156 + // Determine which posts need publishing 157 + const postsToPublish: Array<{ 158 + post: BlogPost; 159 + action: "create" | "update"; 160 + reason: string; 161 + }> = []; 162 + const draftPosts: BlogPost[] = []; 163 164 + for (const post of posts) { 165 + // Skip draft posts 166 + if (post.frontmatter.draft) { 167 + draftPosts.push(post); 168 + continue; 169 + } 170 171 + const contentHash = await getContentHash(post.rawContent); 172 + const relativeFilePath = path.relative(configDir, post.filePath); 173 + const postState = state.posts[relativeFilePath]; 174 175 + if (force) { 176 + postsToPublish.push({ 177 + post, 178 + action: post.frontmatter.atUri ? "update" : "create", 179 + reason: "forced", 180 + }); 181 + } else if (!postState) { 182 + // New post 183 + postsToPublish.push({ 184 + post, 185 + action: "create", 186 + reason: "new post", 187 + }); 188 + } else if (postState.contentHash !== contentHash) { 189 + // Changed post 190 + postsToPublish.push({ 191 + post, 192 + action: post.frontmatter.atUri ? "update" : "create", 193 + reason: "content changed", 194 + }); 195 + } 196 + } 197 198 + if (draftPosts.length > 0) { 199 + log.info( 200 + `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, 201 + ); 202 + } 203 204 + if (postsToPublish.length === 0) { 205 + log.success("All posts are up to date. Nothing to publish."); 206 + return; 207 + } 208 209 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 210 211 + // Bluesky posting configuration 212 + const blueskyEnabled = config.bluesky?.enabled ?? false; 213 + const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 214 + const cutoffDate = new Date(); 215 + cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 216 217 + for (const { post, action, reason } of postsToPublish) { 218 + const icon = action === "create" ? "+" : "~"; 219 + const relativeFilePath = path.relative(configDir, post.filePath); 220 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 221 222 + let bskyNote = ""; 223 + if (blueskyEnabled) { 224 + if (existingBskyPostRef) { 225 + bskyNote = " [bsky: exists]"; 226 + } else { 227 + const publishDate = new Date(post.frontmatter.publishDate); 228 + if (publishDate < cutoffDate) { 229 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 230 + } else { 231 + bskyNote = " [bsky: will post]"; 232 + } 233 + } 234 + } 235 236 + log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 237 + } 238 239 + if (dryRun) { 240 + if (blueskyEnabled) { 241 + log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 242 + } 243 + log.info("\nDry run complete. No changes made."); 244 + return; 245 + } 246 247 + // Create agent 248 + const connectingTo = 249 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 250 + s.start(`Connecting as ${connectingTo}...`); 251 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 252 + try { 253 + agent = await createAgent(credentials); 254 + s.stop(`Logged in as ${agent.did}`); 255 + } catch (error) { 256 + s.stop("Failed to login"); 257 + log.error(`Failed to login: ${error}`); 258 + process.exit(1); 259 + } 260 261 + // Publish posts 262 + let publishedCount = 0; 263 + let updatedCount = 0; 264 + let errorCount = 0; 265 + let bskyPostCount = 0; 266 267 + for (const { post, action } of postsToPublish) { 268 + s.start(`Publishing: ${post.frontmatter.title}`); 269 270 + try { 271 + // Handle cover image upload 272 + let coverImage: BlobObject | undefined; 273 + if (post.frontmatter.ogImage) { 274 + const imagePath = await resolveImagePath( 275 + post.frontmatter.ogImage, 276 + imagesDir, 277 + contentDir, 278 + ); 279 280 + if (imagePath) { 281 + log.info(` Uploading cover image: ${path.basename(imagePath)}`); 282 + coverImage = await uploadImage(agent, imagePath); 283 + if (coverImage) { 284 + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 285 + } 286 + } else { 287 + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 288 + } 289 + } 290 291 + // Track atUri, content for state saving, and bskyPostRef 292 + let atUri: string; 293 + let contentForHash: string; 294 + let bskyPostRef: StrongRef | undefined; 295 + const relativeFilePath = path.relative(configDir, post.filePath); 296 297 + // Check if bskyPostRef already exists in state 298 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 299 300 + if (action === "create") { 301 + atUri = await createDocument(agent, post, config, coverImage); 302 + s.stop(`Created: ${atUri}`); 303 304 + // Update frontmatter with atUri 305 + const updatedContent = updateFrontmatterWithAtUri( 306 + post.rawContent, 307 + atUri, 308 + ); 309 + await fs.writeFile(post.filePath, updatedContent); 310 + log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 311 312 + // Use updated content (with atUri) for hash so next run sees matching hash 313 + contentForHash = updatedContent; 314 + publishedCount++; 315 + } else { 316 + atUri = post.frontmatter.atUri!; 317 + await updateDocument(agent, post, atUri, config, coverImage); 318 + s.stop(`Updated: ${atUri}`); 319 320 + // For updates, rawContent already has atUri 321 + contentForHash = post.rawContent; 322 + updatedCount++; 323 + } 324 325 + // Create Bluesky post if enabled and conditions are met 326 + if (blueskyEnabled) { 327 + if (existingBskyPostRef) { 328 + log.info(` Bluesky post already exists, skipping`); 329 + bskyPostRef = existingBskyPostRef; 330 + } else { 331 + const publishDate = new Date(post.frontmatter.publishDate); 332 333 + if (publishDate < cutoffDate) { 334 + log.info( 335 + ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, 336 + ); 337 + } else { 338 + // Create Bluesky post 339 + try { 340 + const pathPrefix = config.pathPrefix || "/posts"; 341 + const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 342 343 + bskyPostRef = await createBlueskyPost(agent, { 344 + title: post.frontmatter.title, 345 + description: post.frontmatter.description, 346 + canonicalUrl, 347 + coverImage, 348 + publishedAt: post.frontmatter.publishDate, 349 + }); 350 351 + // Update document record with bskyPostRef 352 + await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 353 + log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 354 + bskyPostCount++; 355 + } catch (bskyError) { 356 + const errorMsg = 357 + bskyError instanceof Error 358 + ? bskyError.message 359 + : String(bskyError); 360 + log.warn(` Failed to create Bluesky post: ${errorMsg}`); 361 + } 362 + } 363 + } 364 + } 365 366 + // Update state (use relative path from config directory) 367 + const contentHash = await getContentHash(contentForHash); 368 + state.posts[relativeFilePath] = { 369 + contentHash, 370 + atUri, 371 + lastPublished: new Date().toISOString(), 372 + slug: post.slug, 373 + bskyPostRef, 374 + }; 375 + } catch (error) { 376 + const errorMessage = 377 + error instanceof Error ? error.message : String(error); 378 + s.stop(`Error publishing "${path.basename(post.filePath)}"`); 379 + log.error(` ${errorMessage}`); 380 + errorCount++; 381 + } 382 + } 383 384 + // Save state 385 + await saveState(configDir, state); 386 387 + // Summary 388 + log.message("\n---"); 389 + log.info(`Published: ${publishedCount}`); 390 + log.info(`Updated: ${updatedCount}`); 391 + if (bskyPostCount > 0) { 392 + log.info(`Bluesky posts: ${bskyPostCount}`); 393 + } 394 + if (errorCount > 0) { 395 + log.warn(`Errors: ${errorCount}`); 396 + } 397 + }, 398 });
+209 -151
packages/cli/src/commands/sync.ts
··· 1 - import * as fs from "fs/promises"; 2 import { command, flag } from "cmd-ts"; 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 7 import { createAgent, listDocuments } from "../lib/atproto"; 8 - import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; 9 import { exitOnCancel } from "../lib/prompts"; 10 11 export const syncCommand = command({ 12 - name: "sync", 13 - description: "Sync state from ATProto to restore .sequoia-state.json", 14 - args: { 15 - updateFrontmatter: flag({ 16 - long: "update-frontmatter", 17 - short: "u", 18 - description: "Update frontmatter atUri fields in local markdown files", 19 - }), 20 - dryRun: flag({ 21 - long: "dry-run", 22 - short: "n", 23 - description: "Preview what would be synced without making changes", 24 - }), 25 - }, 26 - handler: async ({ updateFrontmatter, dryRun }) => { 27 - // Load config 28 - const configPath = await findConfig(); 29 - if (!configPath) { 30 - log.error("No sequoia.json found. Run 'sequoia init' first."); 31 - process.exit(1); 32 - } 33 34 - const config = await loadConfig(configPath); 35 - const configDir = path.dirname(configPath); 36 37 - log.info(`Site: ${config.siteUrl}`); 38 - log.info(`Publication: ${config.publicationUri}`); 39 40 - // Load credentials 41 - let credentials = await loadCredentials(config.identity); 42 43 - if (!credentials) { 44 - const identities = await listCredentials(); 45 - if (identities.length === 0) { 46 - log.error("No credentials found. Run 'sequoia auth' first."); 47 - process.exit(1); 48 - } 49 50 - log.info("Multiple identities found. Select one to use:"); 51 - const selected = exitOnCancel(await select({ 52 - message: "Identity:", 53 - options: identities.map(id => ({ value: id, label: id })), 54 - })); 55 56 - credentials = await getCredentials(selected); 57 - if (!credentials) { 58 - log.error("Failed to load selected credentials."); 59 - process.exit(1); 60 - } 61 - } 62 63 - // Create agent 64 - const s = spinner(); 65 - s.start(`Connecting to ${credentials.pdsUrl}...`); 66 - let agent; 67 - try { 68 - agent = await createAgent(credentials); 69 - s.stop(`Logged in as ${agent.session?.handle}`); 70 - } catch (error) { 71 - s.stop("Failed to login"); 72 - log.error(`Failed to login: ${error}`); 73 - process.exit(1); 74 - } 75 76 - // Fetch documents from PDS 77 - s.start("Fetching documents from PDS..."); 78 - const documents = await listDocuments(agent, config.publicationUri); 79 - s.stop(`Found ${documents.length} documents on PDS`); 80 81 - if (documents.length === 0) { 82 - log.info("No documents found for this publication."); 83 - return; 84 - } 85 86 - // Resolve content directory 87 - const contentDir = path.isAbsolute(config.contentDir) 88 - ? config.contentDir 89 - : path.join(configDir, config.contentDir); 90 91 - // Scan local posts 92 - s.start("Scanning local content..."); 93 - const localPosts = await scanContentDirectory(contentDir, config.frontmatter); 94 - s.stop(`Found ${localPosts.length} local posts`); 95 96 - // Build a map of path -> local post for matching 97 - // Document path is like /posts/my-post-slug 98 - const postsByPath = new Map<string, typeof localPosts[0]>(); 99 - for (const post of localPosts) { 100 - const postPath = `/posts/${post.slug}`; 101 - postsByPath.set(postPath, post); 102 - } 103 104 - // Load existing state 105 - const state = await loadState(configDir); 106 - const originalPostCount = Object.keys(state.posts).length; 107 108 - // Track changes 109 - let matchedCount = 0; 110 - let unmatchedCount = 0; 111 - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 112 113 - log.message("\nMatching documents to local files:\n"); 114 115 - for (const doc of documents) { 116 - const docPath = doc.value.path; 117 - const localPost = postsByPath.get(docPath); 118 119 - if (localPost) { 120 - matchedCount++; 121 - log.message(` โœ“ ${doc.value.title}`); 122 - log.message(` Path: ${docPath}`); 123 - log.message(` URI: ${doc.uri}`); 124 - log.message(` File: ${path.basename(localPost.filePath)}`); 125 126 - // Update state (use relative path from config directory) 127 - const contentHash = await getContentHash(localPost.rawContent); 128 - const relativeFilePath = path.relative(configDir, localPost.filePath); 129 - state.posts[relativeFilePath] = { 130 - contentHash, 131 - atUri: doc.uri, 132 - lastPublished: doc.value.publishedAt, 133 - }; 134 135 - // Check if frontmatter needs updating 136 - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 137 - frontmatterUpdates.push({ 138 - filePath: localPost.filePath, 139 - atUri: doc.uri, 140 - }); 141 - log.message(` โ†’ Will update frontmatter`); 142 - } 143 - } else { 144 - unmatchedCount++; 145 - log.message(` โœ— ${doc.value.title} (no matching local file)`); 146 - log.message(` Path: ${docPath}`); 147 - log.message(` URI: ${doc.uri}`); 148 - } 149 - log.message(""); 150 - } 151 152 - // Summary 153 - log.message("---"); 154 - log.info(`Matched: ${matchedCount} documents`); 155 - if (unmatchedCount > 0) { 156 - log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); 157 - } 158 159 - if (dryRun) { 160 - log.info("\nDry run complete. No changes made."); 161 - return; 162 - } 163 164 - // Save updated state 165 - await saveState(configDir, state); 166 - const newPostCount = Object.keys(state.posts).length; 167 - log.success(`\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`); 168 169 - // Update frontmatter if requested 170 - if (frontmatterUpdates.length > 0) { 171 - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 172 - for (const { filePath, atUri } of frontmatterUpdates) { 173 - const content = await fs.readFile(filePath, "utf-8"); 174 - const updated = updateFrontmatterWithAtUri(content, atUri); 175 - await fs.writeFile(filePath, updated); 176 - log.message(` Updated: ${path.basename(filePath)}`); 177 - } 178 - s.stop("Frontmatter updated"); 179 - } 180 181 - log.success("\nSync complete!"); 182 - }, 183 });
··· 1 + import * as fs from "node:fs/promises"; 2 import { command, flag } from "cmd-ts"; 3 import { select, spinner, log } from "@clack/prompts"; 4 + import * as path from "node:path"; 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 import { createAgent, listDocuments } from "../lib/atproto"; 13 + import { 14 + scanContentDirectory, 15 + getContentHash, 16 + updateFrontmatterWithAtUri, 17 + } from "../lib/markdown"; 18 import { exitOnCancel } from "../lib/prompts"; 19 20 export const syncCommand = command({ 21 + name: "sync", 22 + description: "Sync state from ATProto to restore .sequoia-state.json", 23 + args: { 24 + updateFrontmatter: flag({ 25 + long: "update-frontmatter", 26 + short: "u", 27 + description: "Update frontmatter atUri fields in local markdown files", 28 + }), 29 + dryRun: flag({ 30 + long: "dry-run", 31 + short: "n", 32 + description: "Preview what would be synced without making changes", 33 + }), 34 + }, 35 + handler: async ({ updateFrontmatter, dryRun }) => { 36 + // Load config 37 + const configPath = await findConfig(); 38 + if (!configPath) { 39 + log.error("No sequoia.json found. Run 'sequoia init' first."); 40 + process.exit(1); 41 + } 42 43 + const config = await loadConfig(configPath); 44 + const configDir = path.dirname(configPath); 45 46 + log.info(`Site: ${config.siteUrl}`); 47 + log.info(`Publication: ${config.publicationUri}`); 48 49 + // Load credentials 50 + let credentials = await loadCredentials(config.identity); 51 52 + if (!credentials) { 53 + const identities = await listAllCredentials(); 54 + if (identities.length === 0) { 55 + log.error( 56 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 57 + ); 58 + process.exit(1); 59 + } 60 61 + // Build labels with handles for OAuth sessions 62 + const options = await Promise.all( 63 + identities.map(async (cred) => { 64 + if (cred.type === "oauth") { 65 + const handle = await getOAuthHandle(cred.id); 66 + return { 67 + value: cred.id, 68 + label: `${handle || cred.id} (OAuth)`, 69 + }; 70 + } 71 + return { 72 + value: cred.id, 73 + label: `${cred.id} (App Password)`, 74 + }; 75 + }), 76 + ); 77 78 + log.info("Multiple identities found. Select one to use:"); 79 + const selected = exitOnCancel( 80 + await select({ 81 + message: "Identity:", 82 + options, 83 + }), 84 + ); 85 86 + // Load the selected credentials 87 + const selectedCred = identities.find((c) => c.id === selected); 88 + if (selectedCred?.type === "oauth") { 89 + const session = await getOAuthSession(selected); 90 + if (session) { 91 + const handle = await getOAuthHandle(selected); 92 + credentials = { 93 + type: "oauth", 94 + did: selected, 95 + handle: handle || selected, 96 + }; 97 + } 98 + } else { 99 + credentials = await getCredentials(selected); 100 + } 101 102 + if (!credentials) { 103 + log.error("Failed to load selected credentials."); 104 + process.exit(1); 105 + } 106 + } 107 108 + // Create agent 109 + const s = spinner(); 110 + const connectingTo = 111 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 112 + s.start(`Connecting as ${connectingTo}...`); 113 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 114 + try { 115 + agent = await createAgent(credentials); 116 + s.stop(`Logged in as ${agent.did}`); 117 + } catch (error) { 118 + s.stop("Failed to login"); 119 + log.error(`Failed to login: ${error}`); 120 + process.exit(1); 121 + } 122 123 + // Fetch documents from PDS 124 + s.start("Fetching documents from PDS..."); 125 + const documents = await listDocuments(agent, config.publicationUri); 126 + s.stop(`Found ${documents.length} documents on PDS`); 127 128 + if (documents.length === 0) { 129 + log.info("No documents found for this publication."); 130 + return; 131 + } 132 133 + // Resolve content directory 134 + const contentDir = path.isAbsolute(config.contentDir) 135 + ? config.contentDir 136 + : path.join(configDir, config.contentDir); 137 138 + // Scan local posts 139 + s.start("Scanning local content..."); 140 + const localPosts = await scanContentDirectory(contentDir, { 141 + frontmatterMapping: config.frontmatter, 142 + ignorePatterns: config.ignore, 143 + slugField: config.frontmatter?.slugField, 144 + removeIndexFromSlug: config.removeIndexFromSlug, 145 + stripDatePrefix: config.stripDatePrefix, 146 + }); 147 + s.stop(`Found ${localPosts.length} local posts`); 148 149 + // Build a map of path -> local post for matching 150 + // Document path is like /posts/my-post-slug (or custom pathPrefix) 151 + const pathPrefix = config.pathPrefix || "/posts"; 152 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 153 + for (const post of localPosts) { 154 + const postPath = `${pathPrefix}/${post.slug}`; 155 + postsByPath.set(postPath, post); 156 + } 157 158 + // Load existing state 159 + const state = await loadState(configDir); 160 + const originalPostCount = Object.keys(state.posts).length; 161 162 + // Track changes 163 + let matchedCount = 0; 164 + let unmatchedCount = 0; 165 + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 166 167 + log.message("\nMatching documents to local files:\n"); 168 169 + for (const doc of documents) { 170 + const docPath = doc.value.path; 171 + const localPost = postsByPath.get(docPath); 172 173 + if (localPost) { 174 + matchedCount++; 175 + log.message(` โœ“ ${doc.value.title}`); 176 + log.message(` Path: ${docPath}`); 177 + log.message(` URI: ${doc.uri}`); 178 + log.message(` File: ${path.basename(localPost.filePath)}`); 179 180 + // Update state (use relative path from config directory) 181 + const contentHash = await getContentHash(localPost.rawContent); 182 + const relativeFilePath = path.relative(configDir, localPost.filePath); 183 + state.posts[relativeFilePath] = { 184 + contentHash, 185 + atUri: doc.uri, 186 + lastPublished: doc.value.publishedAt, 187 + }; 188 189 + // Check if frontmatter needs updating 190 + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 191 + frontmatterUpdates.push({ 192 + filePath: localPost.filePath, 193 + atUri: doc.uri, 194 + }); 195 + log.message(` โ†’ Will update frontmatter`); 196 + } 197 + } else { 198 + unmatchedCount++; 199 + log.message(` โœ— ${doc.value.title} (no matching local file)`); 200 + log.message(` Path: ${docPath}`); 201 + log.message(` URI: ${doc.uri}`); 202 + } 203 + log.message(""); 204 + } 205 206 + // Summary 207 + log.message("---"); 208 + log.info(`Matched: ${matchedCount} documents`); 209 + if (unmatchedCount > 0) { 210 + log.warn( 211 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 212 + ); 213 + } 214 215 + if (dryRun) { 216 + log.info("\nDry run complete. No changes made."); 217 + return; 218 + } 219 + 220 + // Save updated state 221 + await saveState(configDir, state); 222 + const newPostCount = Object.keys(state.posts).length; 223 + log.success( 224 + `\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`, 225 + ); 226 + 227 + // Update frontmatter if requested 228 + if (frontmatterUpdates.length > 0) { 229 + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 230 + for (const { filePath, atUri } of frontmatterUpdates) { 231 + const content = await fs.readFile(filePath, "utf-8"); 232 + const updated = updateFrontmatterWithAtUri(content, atUri); 233 + await fs.writeFile(filePath, updated); 234 + log.message(` Updated: ${path.basename(filePath)}`); 235 + } 236 + s.stop("Frontmatter updated"); 237 + } 238 239 + log.success("\nSync complete!"); 240 + }, 241 });
+624
packages/cli/src/commands/update.ts
···
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command } from "cmd-ts"; 3 + import { 4 + intro, 5 + outro, 6 + note, 7 + text, 8 + confirm, 9 + select, 10 + spinner, 11 + log, 12 + } from "@clack/prompts"; 13 + import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 + import { 15 + loadCredentials, 16 + listAllCredentials, 17 + getCredentials, 18 + } from "../lib/credentials"; 19 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 20 + import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 21 + import { exitOnCancel } from "../lib/prompts"; 22 + import type { 23 + PublisherConfig, 24 + FrontmatterMapping, 25 + BlueskyConfig, 26 + } from "../lib/types"; 27 + 28 + export const updateCommand = command({ 29 + name: "update", 30 + description: "Update local config or ATProto publication record", 31 + args: {}, 32 + handler: async () => { 33 + intro("Sequoia Update"); 34 + 35 + // Check if config exists 36 + const configPath = await findConfig(); 37 + if (!configPath) { 38 + log.error("No configuration found. Run 'sequoia init' first."); 39 + process.exit(1); 40 + } 41 + 42 + const config = await loadConfig(configPath); 43 + 44 + // Ask what to update 45 + const updateChoice = exitOnCancel( 46 + await select({ 47 + message: "What would you like to update?", 48 + options: [ 49 + { label: "Local configuration (sequoia.json)", value: "config" }, 50 + { label: "ATProto publication record", value: "publication" }, 51 + ], 52 + }), 53 + ); 54 + 55 + if (updateChoice === "config") { 56 + await updateConfigFlow(config, configPath); 57 + } else { 58 + await updatePublicationFlow(config); 59 + } 60 + 61 + outro("Update complete!"); 62 + }, 63 + }); 64 + 65 + async function updateConfigFlow( 66 + config: PublisherConfig, 67 + configPath: string, 68 + ): Promise<void> { 69 + // Show current config summary 70 + const configSummary = [ 71 + `Site URL: ${config.siteUrl}`, 72 + `Content Dir: ${config.contentDir}`, 73 + `Path Prefix: ${config.pathPrefix || "/posts"}`, 74 + `Publication URI: ${config.publicationUri}`, 75 + config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, 76 + config.outputDir ? `Output Dir: ${config.outputDir}` : null, 77 + config.bluesky?.enabled ? `Bluesky: enabled` : null, 78 + ] 79 + .filter(Boolean) 80 + .join("\n"); 81 + 82 + note(configSummary, "Current Configuration"); 83 + 84 + let configUpdated = { ...config }; 85 + let editing = true; 86 + 87 + while (editing) { 88 + const section = exitOnCancel( 89 + await select({ 90 + message: "Select a section to edit:", 91 + options: [ 92 + { label: "Site settings (siteUrl, pathPrefix)", value: "site" }, 93 + { 94 + label: 95 + "Directory paths (contentDir, imagesDir, publicDir, outputDir)", 96 + value: "directories", 97 + }, 98 + { 99 + label: 100 + "Frontmatter mappings (title, description, publishDate, etc.)", 101 + value: "frontmatter", 102 + }, 103 + { 104 + label: 105 + "Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)", 106 + value: "advanced", 107 + }, 108 + { 109 + label: "Bluesky settings (enabled, maxAgeDays)", 110 + value: "bluesky", 111 + }, 112 + { label: "Done editing", value: "done" }, 113 + ], 114 + }), 115 + ); 116 + 117 + if (section === "done") { 118 + editing = false; 119 + continue; 120 + } 121 + 122 + switch (section) { 123 + case "site": 124 + configUpdated = await editSiteSettings(configUpdated); 125 + break; 126 + case "directories": 127 + configUpdated = await editDirectories(configUpdated); 128 + break; 129 + case "frontmatter": 130 + configUpdated = await editFrontmatter(configUpdated); 131 + break; 132 + case "advanced": 133 + configUpdated = await editAdvanced(configUpdated); 134 + break; 135 + case "bluesky": 136 + configUpdated = await editBluesky(configUpdated); 137 + break; 138 + } 139 + } 140 + 141 + // Confirm before saving 142 + const shouldSave = exitOnCancel( 143 + await confirm({ 144 + message: "Save changes to sequoia.json?", 145 + initialValue: true, 146 + }), 147 + ); 148 + 149 + if (shouldSave) { 150 + const configContent = generateConfigTemplate({ 151 + siteUrl: configUpdated.siteUrl, 152 + contentDir: configUpdated.contentDir, 153 + imagesDir: configUpdated.imagesDir, 154 + publicDir: configUpdated.publicDir, 155 + outputDir: configUpdated.outputDir, 156 + pathPrefix: configUpdated.pathPrefix, 157 + publicationUri: configUpdated.publicationUri, 158 + pdsUrl: configUpdated.pdsUrl, 159 + frontmatter: configUpdated.frontmatter, 160 + ignore: configUpdated.ignore, 161 + removeIndexFromSlug: configUpdated.removeIndexFromSlug, 162 + stripDatePrefix: configUpdated.stripDatePrefix, 163 + textContentField: configUpdated.textContentField, 164 + bluesky: configUpdated.bluesky, 165 + }); 166 + 167 + await fs.writeFile(configPath, configContent); 168 + log.success("Configuration saved!"); 169 + } else { 170 + log.info("Changes discarded."); 171 + } 172 + } 173 + 174 + async function editSiteSettings( 175 + config: PublisherConfig, 176 + ): Promise<PublisherConfig> { 177 + const siteUrl = exitOnCancel( 178 + await text({ 179 + message: "Site URL:", 180 + initialValue: config.siteUrl, 181 + validate: (value) => { 182 + if (!value) return "Site URL is required"; 183 + try { 184 + new URL(value); 185 + } catch { 186 + return "Please enter a valid URL"; 187 + } 188 + }, 189 + }), 190 + ); 191 + 192 + const pathPrefix = exitOnCancel( 193 + await text({ 194 + message: "URL path prefix for posts:", 195 + initialValue: config.pathPrefix || "/posts", 196 + }), 197 + ); 198 + 199 + return { 200 + ...config, 201 + siteUrl, 202 + pathPrefix: pathPrefix || undefined, 203 + }; 204 + } 205 + 206 + async function editDirectories( 207 + config: PublisherConfig, 208 + ): Promise<PublisherConfig> { 209 + const contentDir = exitOnCancel( 210 + await text({ 211 + message: "Content directory:", 212 + initialValue: config.contentDir, 213 + validate: (value) => { 214 + if (!value) return "Content directory is required"; 215 + }, 216 + }), 217 + ); 218 + 219 + const imagesDir = exitOnCancel( 220 + await text({ 221 + message: "Cover images directory (leave empty to skip):", 222 + initialValue: config.imagesDir || "", 223 + }), 224 + ); 225 + 226 + const publicDir = exitOnCancel( 227 + await text({ 228 + message: "Public/static directory:", 229 + initialValue: config.publicDir || "./public", 230 + }), 231 + ); 232 + 233 + const outputDir = exitOnCancel( 234 + await text({ 235 + message: "Build output directory:", 236 + initialValue: config.outputDir || "./dist", 237 + }), 238 + ); 239 + 240 + return { 241 + ...config, 242 + contentDir, 243 + imagesDir: imagesDir || undefined, 244 + publicDir: publicDir || undefined, 245 + outputDir: outputDir || undefined, 246 + }; 247 + } 248 + 249 + async function editFrontmatter( 250 + config: PublisherConfig, 251 + ): Promise<PublisherConfig> { 252 + const currentFrontmatter = config.frontmatter || {}; 253 + 254 + log.info("Press Enter to keep current value, or type a new field name."); 255 + 256 + const titleField = exitOnCancel( 257 + await text({ 258 + message: "Field name for title:", 259 + initialValue: currentFrontmatter.title || "title", 260 + }), 261 + ); 262 + 263 + const descField = exitOnCancel( 264 + await text({ 265 + message: "Field name for description:", 266 + initialValue: currentFrontmatter.description || "description", 267 + }), 268 + ); 269 + 270 + const dateField = exitOnCancel( 271 + await text({ 272 + message: "Field name for publish date:", 273 + initialValue: currentFrontmatter.publishDate || "publishDate", 274 + }), 275 + ); 276 + 277 + const coverField = exitOnCancel( 278 + await text({ 279 + message: "Field name for cover image:", 280 + initialValue: currentFrontmatter.coverImage || "ogImage", 281 + }), 282 + ); 283 + 284 + const tagsField = exitOnCancel( 285 + await text({ 286 + message: "Field name for tags:", 287 + initialValue: currentFrontmatter.tags || "tags", 288 + }), 289 + ); 290 + 291 + const draftField = exitOnCancel( 292 + await text({ 293 + message: "Field name for draft status:", 294 + initialValue: currentFrontmatter.draft || "draft", 295 + }), 296 + ); 297 + 298 + const slugField = exitOnCancel( 299 + await text({ 300 + message: "Field name for slug (leave empty to use filepath):", 301 + initialValue: currentFrontmatter.slugField || "", 302 + }), 303 + ); 304 + 305 + // Build frontmatter mapping, only including non-default values 306 + const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ 307 + ["title", titleField, "title"], 308 + ["description", descField, "description"], 309 + ["publishDate", dateField, "publishDate"], 310 + ["coverImage", coverField, "ogImage"], 311 + ["tags", tagsField, "tags"], 312 + ["draft", draftField, "draft"], 313 + ]; 314 + 315 + const builtMapping = fieldMappings.reduce<FrontmatterMapping>( 316 + (acc, [key, value, defaultValue]) => { 317 + if (value !== defaultValue) { 318 + acc[key] = value; 319 + } 320 + return acc; 321 + }, 322 + {}, 323 + ); 324 + 325 + // Handle slugField separately since it has no default 326 + if (slugField) { 327 + builtMapping.slugField = slugField; 328 + } 329 + 330 + const frontmatter = 331 + Object.keys(builtMapping).length > 0 ? builtMapping : undefined; 332 + 333 + return { 334 + ...config, 335 + frontmatter, 336 + }; 337 + } 338 + 339 + async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> { 340 + const pdsUrl = exitOnCancel( 341 + await text({ 342 + message: "PDS URL (leave empty for default bsky.social):", 343 + initialValue: config.pdsUrl || "", 344 + }), 345 + ); 346 + 347 + const identity = exitOnCancel( 348 + await text({ 349 + message: "Identity/profile to use (leave empty for auto-detect):", 350 + initialValue: config.identity || "", 351 + }), 352 + ); 353 + 354 + const ignoreInput = exitOnCancel( 355 + await text({ 356 + message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):", 357 + initialValue: config.ignore?.join(", ") || "", 358 + }), 359 + ); 360 + 361 + const removeIndexFromSlug = exitOnCancel( 362 + await confirm({ 363 + message: "Remove /index or /_index suffix from paths?", 364 + initialValue: config.removeIndexFromSlug || false, 365 + }), 366 + ); 367 + 368 + const stripDatePrefix = exitOnCancel( 369 + await confirm({ 370 + message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?", 371 + initialValue: config.stripDatePrefix || false, 372 + }), 373 + ); 374 + 375 + const textContentField = exitOnCancel( 376 + await text({ 377 + message: 378 + "Frontmatter field for textContent (leave empty to use markdown body):", 379 + initialValue: config.textContentField || "", 380 + }), 381 + ); 382 + 383 + // Parse ignore patterns 384 + const ignore = ignoreInput 385 + ? ignoreInput 386 + .split(",") 387 + .map((p) => p.trim()) 388 + .filter(Boolean) 389 + : undefined; 390 + 391 + return { 392 + ...config, 393 + pdsUrl: pdsUrl || undefined, 394 + identity: identity || undefined, 395 + ignore: ignore && ignore.length > 0 ? ignore : undefined, 396 + removeIndexFromSlug: removeIndexFromSlug || undefined, 397 + stripDatePrefix: stripDatePrefix || undefined, 398 + textContentField: textContentField || undefined, 399 + }; 400 + } 401 + 402 + async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> { 403 + const enabled = exitOnCancel( 404 + await confirm({ 405 + message: "Enable automatic Bluesky posting when publishing?", 406 + initialValue: config.bluesky?.enabled || false, 407 + }), 408 + ); 409 + 410 + if (!enabled) { 411 + return { 412 + ...config, 413 + bluesky: undefined, 414 + }; 415 + } 416 + 417 + const maxAgeDaysInput = exitOnCancel( 418 + await text({ 419 + message: "Maximum age (in days) for posts to be shared on Bluesky:", 420 + initialValue: String(config.bluesky?.maxAgeDays || 7), 421 + validate: (value) => { 422 + if (!value) return "Please enter a number"; 423 + const num = Number.parseInt(value, 10); 424 + if (Number.isNaN(num) || num < 1) { 425 + return "Please enter a positive number"; 426 + } 427 + }, 428 + }), 429 + ); 430 + 431 + const maxAgeDays = parseInt(maxAgeDaysInput, 10); 432 + 433 + const bluesky: BlueskyConfig = { 434 + enabled: true, 435 + ...(maxAgeDays !== 7 && { maxAgeDays }), 436 + }; 437 + 438 + return { 439 + ...config, 440 + bluesky, 441 + }; 442 + } 443 + 444 + async function updatePublicationFlow(config: PublisherConfig): Promise<void> { 445 + // Load credentials 446 + let credentials = await loadCredentials(config.identity); 447 + 448 + if (!credentials) { 449 + const identities = await listAllCredentials(); 450 + if (identities.length === 0) { 451 + log.error( 452 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 453 + ); 454 + process.exit(1); 455 + } 456 + 457 + // Build labels with handles for OAuth sessions 458 + const options = await Promise.all( 459 + identities.map(async (cred) => { 460 + if (cred.type === "oauth") { 461 + const handle = await getOAuthHandle(cred.id); 462 + return { 463 + value: cred.id, 464 + label: `${handle || cred.id} (OAuth)`, 465 + }; 466 + } 467 + return { 468 + value: cred.id, 469 + label: `${cred.id} (App Password)`, 470 + }; 471 + }), 472 + ); 473 + 474 + log.info("Multiple identities found. Select one to use:"); 475 + const selected = exitOnCancel( 476 + await select({ 477 + message: "Identity:", 478 + options, 479 + }), 480 + ); 481 + 482 + // Load the selected credentials 483 + const selectedCred = identities.find((c) => c.id === selected); 484 + if (selectedCred?.type === "oauth") { 485 + const session = await getOAuthSession(selected); 486 + if (session) { 487 + const handle = await getOAuthHandle(selected); 488 + credentials = { 489 + type: "oauth", 490 + did: selected, 491 + handle: handle || selected, 492 + }; 493 + } 494 + } else { 495 + credentials = await getCredentials(selected); 496 + } 497 + 498 + if (!credentials) { 499 + log.error("Failed to load selected credentials."); 500 + process.exit(1); 501 + } 502 + } 503 + 504 + const s = spinner(); 505 + s.start("Connecting to ATProto..."); 506 + 507 + let agent: Awaited<ReturnType<typeof createAgent>>; 508 + try { 509 + agent = await createAgent(credentials); 510 + s.stop("Connected!"); 511 + } catch (error) { 512 + s.stop("Failed to connect"); 513 + log.error(`Failed to connect: ${error}`); 514 + process.exit(1); 515 + } 516 + 517 + // Fetch existing publication 518 + s.start("Fetching publication..."); 519 + const publication = await getPublication(agent, config.publicationUri); 520 + 521 + if (!publication) { 522 + s.stop("Publication not found"); 523 + log.error(`Could not find publication: ${config.publicationUri}`); 524 + process.exit(1); 525 + } 526 + s.stop("Publication loaded!"); 527 + 528 + // Show current publication info 529 + const pubRecord = publication.value; 530 + const pubSummary = [ 531 + `Name: ${pubRecord.name}`, 532 + `URL: ${pubRecord.url}`, 533 + pubRecord.description ? `Description: ${pubRecord.description}` : null, 534 + pubRecord.icon ? `Icon: (uploaded)` : null, 535 + `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`, 536 + `Created: ${pubRecord.createdAt}`, 537 + ] 538 + .filter(Boolean) 539 + .join("\n"); 540 + 541 + note(pubSummary, "Current Publication"); 542 + 543 + // Collect updates with pre-populated values 544 + const name = exitOnCancel( 545 + await text({ 546 + message: "Publication name:", 547 + initialValue: pubRecord.name, 548 + validate: (value) => { 549 + if (!value) return "Publication name is required"; 550 + }, 551 + }), 552 + ); 553 + 554 + const description = exitOnCancel( 555 + await text({ 556 + message: "Publication description (leave empty to clear):", 557 + initialValue: pubRecord.description || "", 558 + }), 559 + ); 560 + 561 + const url = exitOnCancel( 562 + await text({ 563 + message: "Publication URL:", 564 + initialValue: pubRecord.url, 565 + validate: (value) => { 566 + if (!value) return "URL is required"; 567 + try { 568 + new URL(value); 569 + } catch { 570 + return "Please enter a valid URL"; 571 + } 572 + }, 573 + }), 574 + ); 575 + 576 + const iconPath = exitOnCancel( 577 + await text({ 578 + message: "New icon path (leave empty to keep existing):", 579 + initialValue: "", 580 + }), 581 + ); 582 + 583 + const showInDiscover = exitOnCancel( 584 + await confirm({ 585 + message: "Show in Discover feed?", 586 + initialValue: pubRecord.preferences?.showInDiscover ?? true, 587 + }), 588 + ); 589 + 590 + // Confirm before updating 591 + const shouldUpdate = exitOnCancel( 592 + await confirm({ 593 + message: "Update publication on ATProto?", 594 + initialValue: true, 595 + }), 596 + ); 597 + 598 + if (!shouldUpdate) { 599 + log.info("Update cancelled."); 600 + return; 601 + } 602 + 603 + // Perform update 604 + s.start("Updating publication..."); 605 + try { 606 + await updatePublication( 607 + agent, 608 + config.publicationUri, 609 + { 610 + name, 611 + description, 612 + url, 613 + iconPath: iconPath || undefined, 614 + showInDiscover, 615 + }, 616 + pubRecord, 617 + ); 618 + s.stop("Publication updated!"); 619 + } catch (error) { 620 + s.stop("Failed to update publication"); 621 + log.error(`Failed to update: ${error}`); 622 + process.exit(1); 623 + } 624 + }
+5 -1
packages/cli/src/index.ts
··· 4 import { authCommand } from "./commands/auth"; 5 import { initCommand } from "./commands/init"; 6 import { injectCommand } from "./commands/inject"; 7 import { publishCommand } from "./commands/publish"; 8 import { syncCommand } from "./commands/sync"; 9 10 const app = subcommands({ 11 name: "sequoia", ··· 33 34 > https://tangled.org/stevedylan.dev/sequoia 35 `, 36 - version: "0.2.0", 37 cmds: { 38 auth: authCommand, 39 init: initCommand, 40 inject: injectCommand, 41 publish: publishCommand, 42 sync: syncCommand, 43 }, 44 }); 45
··· 4 import { authCommand } from "./commands/auth"; 5 import { initCommand } from "./commands/init"; 6 import { injectCommand } from "./commands/inject"; 7 + import { loginCommand } from "./commands/login"; 8 import { publishCommand } from "./commands/publish"; 9 import { syncCommand } from "./commands/sync"; 10 + import { updateCommand } from "./commands/update"; 11 12 const app = subcommands({ 13 name: "sequoia", ··· 35 36 > https://tangled.org/stevedylan.dev/sequoia 37 `, 38 + version: "0.3.3", 39 cmds: { 40 auth: authCommand, 41 init: initCommand, 42 inject: injectCommand, 43 + login: loginCommand, 44 publish: publishCommand, 45 sync: syncCommand, 46 + update: updateCommand, 47 }, 48 }); 49
+616 -400
packages/cli/src/lib/atproto.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 2 - import * as fs from "fs/promises"; 3 - import * as path from "path"; 4 import * as mimeTypes from "mime-types"; 5 - import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; 6 import { stripMarkdownForText } from "./markdown"; 7 8 async function fileExists(filePath: string): Promise<boolean> { 9 - try { 10 - await fs.access(filePath); 11 - return true; 12 - } catch { 13 - return false; 14 - } 15 } 16 17 - export async function resolveHandleToPDS(handle: string): Promise<string> { 18 - // First, resolve the handle to a DID 19 - let did: string; 20 21 - if (handle.startsWith("did:")) { 22 - did = handle; 23 - } else { 24 - // Try to resolve handle via Bluesky API 25 - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 26 - const resolveResponse = await fetch(resolveUrl); 27 - if (!resolveResponse.ok) { 28 - throw new Error("Could not resolve handle"); 29 - } 30 - const resolveData = (await resolveResponse.json()) as { did: string }; 31 - did = resolveData.did; 32 - } 33 34 - // Now resolve the DID to get the PDS URL from the DID document 35 - let pdsUrl: string | undefined; 36 37 - if (did.startsWith("did:plc:")) { 38 - // Fetch DID document from plc.directory 39 - const didDocUrl = `https://plc.directory/${did}`; 40 - const didDocResponse = await fetch(didDocUrl); 41 - if (!didDocResponse.ok) { 42 - throw new Error("Could not fetch DID document"); 43 - } 44 - const didDoc = (await didDocResponse.json()) as { 45 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 46 - }; 47 48 - // Find the PDS service endpoint 49 - const pdsService = didDoc.service?.find( 50 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 51 - ); 52 - pdsUrl = pdsService?.serviceEndpoint; 53 - } else if (did.startsWith("did:web:")) { 54 - // For did:web, fetch the DID document from the domain 55 - const domain = did.replace("did:web:", ""); 56 - const didDocUrl = `https://${domain}/.well-known/did.json`; 57 - const didDocResponse = await fetch(didDocUrl); 58 - if (!didDocResponse.ok) { 59 - throw new Error("Could not fetch DID document"); 60 - } 61 - const didDoc = (await didDocResponse.json()) as { 62 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 63 - }; 64 65 - const pdsService = didDoc.service?.find( 66 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 67 - ); 68 - pdsUrl = pdsService?.serviceEndpoint; 69 - } 70 71 - if (!pdsUrl) { 72 - throw new Error("Could not find PDS URL for user"); 73 - } 74 75 - return pdsUrl; 76 } 77 78 export interface CreatePublicationOptions { 79 - url: string; 80 - name: string; 81 - description?: string; 82 - iconPath?: string; 83 - showInDiscover?: boolean; 84 } 85 86 - export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 87 - const agent = new AtpAgent({ service: credentials.pdsUrl }); 88 89 - await agent.login({ 90 - identifier: credentials.identifier, 91 - password: credentials.password, 92 - }); 93 94 - return agent; 95 } 96 97 export async function uploadImage( 98 - agent: AtpAgent, 99 - imagePath: string 100 ): Promise<BlobObject | undefined> { 101 - if (!(await fileExists(imagePath))) { 102 - return undefined; 103 - } 104 105 - try { 106 - const imageBuffer = await fs.readFile(imagePath); 107 - const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 108 109 - const response = await agent.com.atproto.repo.uploadBlob( 110 - new Uint8Array(imageBuffer), 111 - { 112 - encoding: mimeType, 113 - } 114 - ); 115 116 - return { 117 - $type: "blob", 118 - ref: { 119 - $link: response.data.blob.ref.toString(), 120 - }, 121 - mimeType, 122 - size: imageBuffer.byteLength, 123 - }; 124 - } catch (error) { 125 - console.error(`Error uploading image ${imagePath}:`, error); 126 - return undefined; 127 - } 128 } 129 130 export async function resolveImagePath( 131 - ogImage: string, 132 - imagesDir: string | undefined, 133 - contentDir: string 134 ): Promise<string | null> { 135 - // Try multiple resolution strategies 136 - const filename = path.basename(ogImage); 137 138 - // 1. If imagesDir is specified, look there 139 - if (imagesDir) { 140 - const imagePath = path.join(imagesDir, filename); 141 - if (await fileExists(imagePath)) { 142 - const stat = await fs.stat(imagePath); 143 - if (stat.size > 0) { 144 - return imagePath; 145 - } 146 - } 147 - } 148 149 - // 2. Try the ogImage path directly (if it's absolute) 150 - if (path.isAbsolute(ogImage)) { 151 - return ogImage; 152 - } 153 154 - // 3. Try relative to content directory 155 - const contentRelative = path.join(contentDir, ogImage); 156 - if (await fileExists(contentRelative)) { 157 - const stat = await fs.stat(contentRelative); 158 - if (stat.size > 0) { 159 - return contentRelative; 160 - } 161 - } 162 163 - return null; 164 } 165 166 export async function createDocument( 167 - agent: AtpAgent, 168 - post: BlogPost, 169 - config: PublisherConfig, 170 - coverImage?: BlobObject 171 ): Promise<string> { 172 - const pathPrefix = config.pathPrefix || "/posts"; 173 - const postPath = `${pathPrefix}/${post.slug}`; 174 - const textContent = stripMarkdownForText(post.content); 175 - const publishDate = new Date(post.frontmatter.publishDate); 176 177 - const record: Record<string, unknown> = { 178 - $type: "site.standard.document", 179 - title: post.frontmatter.title, 180 - site: config.publicationUri, 181 - path: postPath, 182 - textContent: textContent.slice(0, 10000), 183 - publishedAt: publishDate.toISOString(), 184 - canonicalUrl: `${config.siteUrl}${postPath}`, 185 - }; 186 187 - if (coverImage) { 188 - record.coverImage = coverImage; 189 - } 190 191 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 192 - record.tags = post.frontmatter.tags; 193 - } 194 195 - const response = await agent.com.atproto.repo.createRecord({ 196 - repo: agent.session!.did, 197 - collection: "site.standard.document", 198 - record, 199 - }); 200 201 - return response.data.uri; 202 } 203 204 export async function updateDocument( 205 - agent: AtpAgent, 206 - post: BlogPost, 207 - atUri: string, 208 - config: PublisherConfig, 209 - coverImage?: BlobObject 210 ): Promise<void> { 211 - // Parse the atUri to get the collection and rkey 212 - // Format: at://did:plc:xxx/collection/rkey 213 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 214 - if (!uriMatch) { 215 - throw new Error(`Invalid atUri format: ${atUri}`); 216 - } 217 218 - const [, , collection, rkey] = uriMatch; 219 220 - const pathPrefix = config.pathPrefix || "/posts"; 221 - const postPath = `${pathPrefix}/${post.slug}`; 222 - const textContent = stripMarkdownForText(post.content); 223 - const publishDate = new Date(post.frontmatter.publishDate); 224 225 - const record: Record<string, unknown> = { 226 - $type: "site.standard.document", 227 - title: post.frontmatter.title, 228 - site: config.publicationUri, 229 - path: postPath, 230 - textContent: textContent.slice(0, 10000), 231 - publishedAt: publishDate.toISOString(), 232 - canonicalUrl: `${config.siteUrl}${postPath}`, 233 - }; 234 235 - if (coverImage) { 236 - record.coverImage = coverImage; 237 - } 238 239 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 240 - record.tags = post.frontmatter.tags; 241 - } 242 243 - await agent.com.atproto.repo.putRecord({ 244 - repo: agent.session!.did, 245 - collection: collection!, 246 - rkey: rkey!, 247 - record, 248 - }); 249 } 250 251 - export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { 252 - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 253 - if (!match) return null; 254 - return { 255 - did: match[1]!, 256 - collection: match[2]!, 257 - rkey: match[3]!, 258 - }; 259 } 260 261 export interface DocumentRecord { 262 - $type: "site.standard.document"; 263 - title: string; 264 - site: string; 265 - path: string; 266 - textContent: string; 267 - publishedAt: string; 268 - canonicalUrl?: string; 269 - coverImage?: BlobObject; 270 - tags?: string[]; 271 - location?: string; 272 } 273 274 export interface ListDocumentsResult { 275 - uri: string; 276 - cid: string; 277 - value: DocumentRecord; 278 } 279 280 export async function listDocuments( 281 - agent: AtpAgent, 282 - publicationUri?: string 283 ): Promise<ListDocumentsResult[]> { 284 - const documents: ListDocumentsResult[] = []; 285 - let cursor: string | undefined; 286 287 - do { 288 - const response = await agent.com.atproto.repo.listRecords({ 289 - repo: agent.session!.did, 290 - collection: "site.standard.document", 291 - limit: 100, 292 - cursor, 293 - }); 294 295 - for (const record of response.data.records) { 296 - const value = record.value as unknown as DocumentRecord; 297 298 - // If publicationUri is specified, only include documents from that publication 299 - if (publicationUri && value.site !== publicationUri) { 300 - continue; 301 - } 302 303 - documents.push({ 304 - uri: record.uri, 305 - cid: record.cid, 306 - value, 307 - }); 308 - } 309 310 - cursor = response.data.cursor; 311 - } while (cursor); 312 313 - return documents; 314 } 315 316 export async function createPublication( 317 - agent: AtpAgent, 318 - options: CreatePublicationOptions 319 ): Promise<string> { 320 - let icon: BlobObject | undefined; 321 322 - if (options.iconPath) { 323 - icon = await uploadImage(agent, options.iconPath); 324 - } 325 326 - const record: Record<string, unknown> = { 327 - $type: "site.standard.publication", 328 - url: options.url, 329 - name: options.name, 330 - createdAt: new Date().toISOString(), 331 - }; 332 333 - if (options.description) { 334 - record.description = options.description; 335 - } 336 337 - if (icon) { 338 - record.icon = icon; 339 - } 340 341 - if (options.showInDiscover !== undefined) { 342 - record.preferences = { 343 - showInDiscover: options.showInDiscover, 344 - }; 345 - } 346 347 - const response = await agent.com.atproto.repo.createRecord({ 348 - repo: agent.session!.did, 349 - collection: "site.standard.publication", 350 - record, 351 - }); 352 353 - return response.data.uri; 354 } 355 356 // --- Bluesky Post Creation --- 357 358 export interface CreateBlueskyPostOptions { 359 - title: string; 360 - description?: string; 361 - canonicalUrl: string; 362 - coverImage?: BlobObject; 363 - publishedAt: string; // Used as createdAt for the post 364 } 365 366 /** 367 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 368 */ 369 function countGraphemes(str: string): number { 370 - // Use Intl.Segmenter if available, otherwise fallback to spread operator 371 - if (typeof Intl !== "undefined" && Intl.Segmenter) { 372 - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 373 - return [...segmenter.segment(str)].length; 374 - } 375 - return [...str].length; 376 } 377 378 /** 379 * Truncate a string to a maximum number of graphemes 380 */ 381 function truncateToGraphemes(str: string, maxGraphemes: number): string { 382 - if (typeof Intl !== "undefined" && Intl.Segmenter) { 383 - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 384 - const segments = [...segmenter.segment(str)]; 385 - if (segments.length <= maxGraphemes) return str; 386 - return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "..."; 387 - } 388 - // Fallback 389 - const chars = [...str]; 390 - if (chars.length <= maxGraphemes) return str; 391 - return chars.slice(0, maxGraphemes - 3).join("") + "..."; 392 } 393 394 /** 395 * Create a Bluesky post with external link embed 396 */ 397 export async function createBlueskyPost( 398 - agent: AtpAgent, 399 - options: CreateBlueskyPostOptions 400 ): Promise<StrongRef> { 401 - const { title, description, canonicalUrl, coverImage, publishedAt } = options; 402 403 - // Build post text: title + description + URL 404 - // Max 300 graphemes for Bluesky posts 405 - const MAX_GRAPHEMES = 300; 406 407 - let postText: string; 408 - const urlPart = `\n\n${canonicalUrl}`; 409 - const urlGraphemes = countGraphemes(urlPart); 410 411 - if (description) { 412 - // Try: title + description + URL 413 - const fullText = `${title}\n\n${description}${urlPart}`; 414 - if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 415 - postText = fullText; 416 - } else { 417 - // Truncate description to fit 418 - const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n"); 419 - if (availableForDesc > 10) { 420 - const truncatedDesc = truncateToGraphemes(description, availableForDesc); 421 - postText = `${title}\n\n${truncatedDesc}${urlPart}`; 422 - } else { 423 - // Just title + URL 424 - postText = `${title}${urlPart}`; 425 - } 426 - } 427 - } else { 428 - // Just title + URL 429 - postText = `${title}${urlPart}`; 430 - } 431 432 - // Final truncation if still too long (shouldn't happen but safety check) 433 - if (countGraphemes(postText) > MAX_GRAPHEMES) { 434 - postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 435 - } 436 437 - // Calculate byte indices for the URL facet 438 - const encoder = new TextEncoder(); 439 - const urlStartInText = postText.lastIndexOf(canonicalUrl); 440 - const beforeUrl = postText.substring(0, urlStartInText); 441 - const byteStart = encoder.encode(beforeUrl).length; 442 - const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 443 444 - // Build facets for the URL link 445 - const facets = [ 446 - { 447 - index: { 448 - byteStart, 449 - byteEnd, 450 - }, 451 - features: [ 452 - { 453 - $type: "app.bsky.richtext.facet#link", 454 - uri: canonicalUrl, 455 - }, 456 - ], 457 - }, 458 - ]; 459 460 - // Build external embed 461 - const embed: Record<string, unknown> = { 462 - $type: "app.bsky.embed.external", 463 - external: { 464 - uri: canonicalUrl, 465 - title: title.substring(0, 500), // Max 500 chars for title 466 - description: (description || "").substring(0, 1000), // Max 1000 chars for description 467 - }, 468 - }; 469 470 - // Add thumbnail if coverImage is available 471 - if (coverImage) { 472 - (embed.external as Record<string, unknown>).thumb = coverImage; 473 - } 474 475 - // Create the post record 476 - const record: Record<string, unknown> = { 477 - $type: "app.bsky.feed.post", 478 - text: postText, 479 - facets, 480 - embed, 481 - createdAt: new Date(publishedAt).toISOString(), 482 - }; 483 484 - const response = await agent.com.atproto.repo.createRecord({ 485 - repo: agent.session!.did, 486 - collection: "app.bsky.feed.post", 487 - record, 488 - }); 489 490 - return { 491 - uri: response.data.uri, 492 - cid: response.data.cid, 493 - }; 494 } 495 496 /** 497 * Add bskyPostRef to an existing document record 498 */ 499 export async function addBskyPostRefToDocument( 500 - agent: AtpAgent, 501 - documentAtUri: string, 502 - bskyPostRef: StrongRef 503 ): Promise<void> { 504 - const parsed = parseAtUri(documentAtUri); 505 - if (!parsed) { 506 - throw new Error(`Invalid document URI: ${documentAtUri}`); 507 - } 508 509 - // Fetch existing record 510 - const existingRecord = await agent.com.atproto.repo.getRecord({ 511 - repo: parsed.did, 512 - collection: parsed.collection, 513 - rkey: parsed.rkey, 514 - }); 515 516 - // Add bskyPostRef to the record 517 - const updatedRecord = { 518 - ...(existingRecord.data.value as Record<string, unknown>), 519 - bskyPostRef, 520 - }; 521 522 - // Update the record 523 - await agent.com.atproto.repo.putRecord({ 524 - repo: parsed.did, 525 - collection: parsed.collection, 526 - rkey: parsed.rkey, 527 - record: updatedRecord, 528 - }); 529 }
··· 1 + import { Agent, AtpAgent } from "@atproto/api"; 2 import * as mimeTypes from "mime-types"; 3 + import * as fs from "node:fs/promises"; 4 + import * as path from "node:path"; 5 import { stripMarkdownForText } from "./markdown"; 6 + import { getOAuthClient } from "./oauth-client"; 7 + import type { 8 + BlobObject, 9 + BlogPost, 10 + Credentials, 11 + PublicationRecord, 12 + PublisherConfig, 13 + StrongRef, 14 + } from "./types"; 15 + import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 + 17 + /** 18 + * Type guard to check if a record value is a DocumentRecord 19 + */ 20 + function isDocumentRecord(value: unknown): value is DocumentRecord { 21 + if (!value || typeof value !== "object") return false; 22 + const v = value as Record<string, unknown>; 23 + return ( 24 + v.$type === "site.standard.document" && 25 + typeof v.title === "string" && 26 + typeof v.site === "string" && 27 + typeof v.path === "string" && 28 + typeof v.textContent === "string" && 29 + typeof v.publishedAt === "string" 30 + ); 31 + } 32 33 async function fileExists(filePath: string): Promise<boolean> { 34 + try { 35 + await fs.access(filePath); 36 + return true; 37 + } catch { 38 + return false; 39 + } 40 } 41 42 + /** 43 + * Resolve a handle to a DID 44 + */ 45 + export async function resolveHandleToDid(handle: string): Promise<string> { 46 + if (handle.startsWith("did:")) { 47 + return handle; 48 + } 49 50 + // Try to resolve handle via Bluesky API 51 + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 52 + const resolveResponse = await fetch(resolveUrl); 53 + if (!resolveResponse.ok) { 54 + throw new Error("Could not resolve handle"); 55 + } 56 + const resolveData = (await resolveResponse.json()) as { did: string }; 57 + return resolveData.did; 58 + } 59 60 + export async function resolveHandleToPDS(handle: string): Promise<string> { 61 + // First, resolve the handle to a DID 62 + const did = await resolveHandleToDid(handle); 63 64 + // Now resolve the DID to get the PDS URL from the DID document 65 + let pdsUrl: string | undefined; 66 67 + if (did.startsWith("did:plc:")) { 68 + // Fetch DID document from plc.directory 69 + const didDocUrl = `https://plc.directory/${did}`; 70 + const didDocResponse = await fetch(didDocUrl); 71 + if (!didDocResponse.ok) { 72 + throw new Error("Could not fetch DID document"); 73 + } 74 + const didDoc = (await didDocResponse.json()) as { 75 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 76 + }; 77 78 + // Find the PDS service endpoint 79 + const pdsService = didDoc.service?.find( 80 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 81 + ); 82 + pdsUrl = pdsService?.serviceEndpoint; 83 + } else if (did.startsWith("did:web:")) { 84 + // For did:web, fetch the DID document from the domain 85 + const domain = did.replace("did:web:", ""); 86 + const didDocUrl = `https://${domain}/.well-known/did.json`; 87 + const didDocResponse = await fetch(didDocUrl); 88 + if (!didDocResponse.ok) { 89 + throw new Error("Could not fetch DID document"); 90 + } 91 + const didDoc = (await didDocResponse.json()) as { 92 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 93 + }; 94 95 + const pdsService = didDoc.service?.find( 96 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 97 + ); 98 + pdsUrl = pdsService?.serviceEndpoint; 99 + } 100 101 + if (!pdsUrl) { 102 + throw new Error("Could not find PDS URL for user"); 103 + } 104 + 105 + return pdsUrl; 106 } 107 108 export interface CreatePublicationOptions { 109 + url: string; 110 + name: string; 111 + description?: string; 112 + iconPath?: string; 113 + showInDiscover?: boolean; 114 } 115 116 + export async function createAgent(credentials: Credentials): Promise<Agent> { 117 + if (isOAuthCredentials(credentials)) { 118 + // OAuth flow - restore session from stored tokens 119 + const client = await getOAuthClient(); 120 + try { 121 + const oauthSession = await client.restore(credentials.did); 122 + // Wrap the OAuth session in an Agent which provides the atproto API 123 + return new Agent(oauthSession); 124 + } catch (error) { 125 + if (error instanceof Error) { 126 + // Check for common OAuth errors 127 + if ( 128 + error.message.includes("expired") || 129 + error.message.includes("revoked") 130 + ) { 131 + throw new Error( 132 + `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 133 + ); 134 + } 135 + } 136 + throw error; 137 + } 138 + } 139 + 140 + // App password flow 141 + if (!isAppPasswordCredentials(credentials)) { 142 + throw new Error("Invalid credential type"); 143 + } 144 + const agent = new AtpAgent({ service: credentials.pdsUrl }); 145 146 + await agent.login({ 147 + identifier: credentials.identifier, 148 + password: credentials.password, 149 + }); 150 151 + return agent; 152 } 153 154 export async function uploadImage( 155 + agent: Agent, 156 + imagePath: string, 157 ): Promise<BlobObject | undefined> { 158 + if (!(await fileExists(imagePath))) { 159 + return undefined; 160 + } 161 162 + try { 163 + const imageBuffer = await fs.readFile(imagePath); 164 + const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 165 166 + const response = await agent.com.atproto.repo.uploadBlob( 167 + new Uint8Array(imageBuffer), 168 + { 169 + encoding: mimeType, 170 + }, 171 + ); 172 173 + return { 174 + $type: "blob", 175 + ref: { 176 + $link: response.data.blob.ref.toString(), 177 + }, 178 + mimeType, 179 + size: imageBuffer.byteLength, 180 + }; 181 + } catch (error) { 182 + console.error(`Error uploading image ${imagePath}:`, error); 183 + return undefined; 184 + } 185 } 186 187 export async function resolveImagePath( 188 + ogImage: string, 189 + imagesDir: string | undefined, 190 + contentDir: string, 191 ): Promise<string | null> { 192 + // Try multiple resolution strategies 193 194 + // 1. If imagesDir is specified, look there 195 + if (imagesDir) { 196 + // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") 197 + const imagesDirBaseName = path.basename(imagesDir); 198 199 + // Check if ogImage contains the images directory name and extract the relative path 200 + // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" 201 + const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); 202 + let relativePath: string; 203 204 + if (imagesDirIndex !== -1) { 205 + // Extract everything after "blog-images/" 206 + const afterImagesDir = ogImage.substring( 207 + imagesDirIndex + imagesDirBaseName.length, 208 + ); 209 + // Remove leading slash if present 210 + relativePath = afterImagesDir.replace(/^[/\\]/, ""); 211 + } else { 212 + // Fall back to just the filename 213 + relativePath = path.basename(ogImage); 214 + } 215 + 216 + const imagePath = path.join(imagesDir, relativePath); 217 + if (await fileExists(imagePath)) { 218 + const stat = await fs.stat(imagePath); 219 + if (stat.size > 0) { 220 + return imagePath; 221 + } 222 + } 223 + } 224 + 225 + // 2. Try the ogImage path directly (if it's absolute) 226 + if (path.isAbsolute(ogImage)) { 227 + return ogImage; 228 + } 229 + 230 + // 3. Try relative to content directory 231 + const contentRelative = path.join(contentDir, ogImage); 232 + if (await fileExists(contentRelative)) { 233 + const stat = await fs.stat(contentRelative); 234 + if (stat.size > 0) { 235 + return contentRelative; 236 + } 237 + } 238 239 + return null; 240 } 241 242 export async function createDocument( 243 + agent: Agent, 244 + post: BlogPost, 245 + config: PublisherConfig, 246 + coverImage?: BlobObject, 247 ): Promise<string> { 248 + const pathPrefix = config.pathPrefix || "/posts"; 249 + const postPath = `${pathPrefix}/${post.slug}`; 250 + const publishDate = new Date(post.frontmatter.publishDate); 251 252 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 253 + let textContent: string; 254 + if ( 255 + config.textContentField && 256 + post.rawFrontmatter?.[config.textContentField] 257 + ) { 258 + textContent = String(post.rawFrontmatter[config.textContentField]); 259 + } else { 260 + textContent = stripMarkdownForText(post.content); 261 + } 262 263 + const record: Record<string, unknown> = { 264 + $type: "site.standard.document", 265 + title: post.frontmatter.title, 266 + site: config.publicationUri, 267 + path: postPath, 268 + textContent: textContent.slice(0, 10000), 269 + publishedAt: publishDate.toISOString(), 270 + canonicalUrl: `${config.siteUrl}${postPath}`, 271 + }; 272 273 + if (post.frontmatter.description) { 274 + record.description = post.frontmatter.description; 275 + } 276 277 + if (coverImage) { 278 + record.coverImage = coverImage; 279 + } 280 281 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 282 + record.tags = post.frontmatter.tags; 283 + } 284 + 285 + const response = await agent.com.atproto.repo.createRecord({ 286 + repo: agent.did!, 287 + collection: "site.standard.document", 288 + record, 289 + }); 290 + 291 + return response.data.uri; 292 } 293 294 export async function updateDocument( 295 + agent: Agent, 296 + post: BlogPost, 297 + atUri: string, 298 + config: PublisherConfig, 299 + coverImage?: BlobObject, 300 ): Promise<void> { 301 + // Parse the atUri to get the collection and rkey 302 + // Format: at://did:plc:xxx/collection/rkey 303 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 304 + if (!uriMatch) { 305 + throw new Error(`Invalid atUri format: ${atUri}`); 306 + } 307 308 + const [, , collection, rkey] = uriMatch; 309 310 + const pathPrefix = config.pathPrefix || "/posts"; 311 + const postPath = `${pathPrefix}/${post.slug}`; 312 + const publishDate = new Date(post.frontmatter.publishDate); 313 + 314 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 315 + let textContent: string; 316 + if ( 317 + config.textContentField && 318 + post.rawFrontmatter?.[config.textContentField] 319 + ) { 320 + textContent = String(post.rawFrontmatter[config.textContentField]); 321 + } else { 322 + textContent = stripMarkdownForText(post.content); 323 + } 324 + 325 + const record: Record<string, unknown> = { 326 + $type: "site.standard.document", 327 + title: post.frontmatter.title, 328 + site: config.publicationUri, 329 + path: postPath, 330 + textContent: textContent.slice(0, 10000), 331 + publishedAt: publishDate.toISOString(), 332 + canonicalUrl: `${config.siteUrl}${postPath}`, 333 + }; 334 335 + if (post.frontmatter.description) { 336 + record.description = post.frontmatter.description; 337 + } 338 339 + if (coverImage) { 340 + record.coverImage = coverImage; 341 + } 342 343 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 344 + record.tags = post.frontmatter.tags; 345 + } 346 347 + await agent.com.atproto.repo.putRecord({ 348 + repo: agent.did!, 349 + collection: collection!, 350 + rkey: rkey!, 351 + record, 352 + }); 353 } 354 355 + export function parseAtUri( 356 + atUri: string, 357 + ): { did: string; collection: string; rkey: string } | null { 358 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 359 + if (!match) return null; 360 + return { 361 + did: match[1]!, 362 + collection: match[2]!, 363 + rkey: match[3]!, 364 + }; 365 } 366 367 export interface DocumentRecord { 368 + $type: "site.standard.document"; 369 + title: string; 370 + site: string; 371 + path: string; 372 + textContent: string; 373 + publishedAt: string; 374 + canonicalUrl?: string; 375 + description?: string; 376 + coverImage?: BlobObject; 377 + tags?: string[]; 378 + location?: string; 379 } 380 381 export interface ListDocumentsResult { 382 + uri: string; 383 + cid: string; 384 + value: DocumentRecord; 385 } 386 387 export async function listDocuments( 388 + agent: Agent, 389 + publicationUri?: string, 390 ): Promise<ListDocumentsResult[]> { 391 + const documents: ListDocumentsResult[] = []; 392 + let cursor: string | undefined; 393 394 + do { 395 + const response = await agent.com.atproto.repo.listRecords({ 396 + repo: agent.did!, 397 + collection: "site.standard.document", 398 + limit: 100, 399 + cursor, 400 + }); 401 402 + for (const record of response.data.records) { 403 + if (!isDocumentRecord(record.value)) { 404 + continue; 405 + } 406 407 + // If publicationUri is specified, only include documents from that publication 408 + if (publicationUri && record.value.site !== publicationUri) { 409 + continue; 410 + } 411 412 + documents.push({ 413 + uri: record.uri, 414 + cid: record.cid, 415 + value: record.value, 416 + }); 417 + } 418 419 + cursor = response.data.cursor; 420 + } while (cursor); 421 422 + return documents; 423 } 424 425 export async function createPublication( 426 + agent: Agent, 427 + options: CreatePublicationOptions, 428 ): Promise<string> { 429 + let icon: BlobObject | undefined; 430 431 + if (options.iconPath) { 432 + icon = await uploadImage(agent, options.iconPath); 433 + } 434 435 + const record: Record<string, unknown> = { 436 + $type: "site.standard.publication", 437 + url: options.url, 438 + name: options.name, 439 + createdAt: new Date().toISOString(), 440 + }; 441 442 + if (options.description) { 443 + record.description = options.description; 444 + } 445 446 + if (icon) { 447 + record.icon = icon; 448 + } 449 450 + if (options.showInDiscover !== undefined) { 451 + record.preferences = { 452 + showInDiscover: options.showInDiscover, 453 + }; 454 + } 455 456 + const response = await agent.com.atproto.repo.createRecord({ 457 + repo: agent.did!, 458 + collection: "site.standard.publication", 459 + record, 460 + }); 461 + 462 + return response.data.uri; 463 + } 464 + 465 + export interface GetPublicationResult { 466 + uri: string; 467 + cid: string; 468 + value: PublicationRecord; 469 + } 470 + 471 + export async function getPublication( 472 + agent: Agent, 473 + publicationUri: string, 474 + ): Promise<GetPublicationResult | null> { 475 + const parsed = parseAtUri(publicationUri); 476 + if (!parsed) { 477 + return null; 478 + } 479 + 480 + try { 481 + const response = await agent.com.atproto.repo.getRecord({ 482 + repo: parsed.did, 483 + collection: parsed.collection, 484 + rkey: parsed.rkey, 485 + }); 486 + 487 + return { 488 + uri: publicationUri, 489 + cid: response.data.cid!, 490 + value: response.data.value as unknown as PublicationRecord, 491 + }; 492 + } catch { 493 + return null; 494 + } 495 + } 496 + 497 + export interface UpdatePublicationOptions { 498 + url?: string; 499 + name?: string; 500 + description?: string; 501 + iconPath?: string; 502 + showInDiscover?: boolean; 503 + } 504 + 505 + export async function updatePublication( 506 + agent: Agent, 507 + publicationUri: string, 508 + options: UpdatePublicationOptions, 509 + existingRecord: PublicationRecord, 510 + ): Promise<void> { 511 + const parsed = parseAtUri(publicationUri); 512 + if (!parsed) { 513 + throw new Error(`Invalid publication URI: ${publicationUri}`); 514 + } 515 + 516 + // Build updated record, preserving createdAt and $type 517 + const record: Record<string, unknown> = { 518 + $type: existingRecord.$type, 519 + url: options.url ?? existingRecord.url, 520 + name: options.name ?? existingRecord.name, 521 + createdAt: existingRecord.createdAt, 522 + }; 523 + 524 + // Handle description - can be cleared with empty string 525 + if (options.description !== undefined) { 526 + if (options.description) { 527 + record.description = options.description; 528 + } 529 + // If empty string, don't include description (clears it) 530 + } else if (existingRecord.description) { 531 + record.description = existingRecord.description; 532 + } 533 + 534 + // Handle icon - upload new if provided, otherwise keep existing 535 + if (options.iconPath) { 536 + const icon = await uploadImage(agent, options.iconPath); 537 + if (icon) { 538 + record.icon = icon; 539 + } 540 + } else if (existingRecord.icon) { 541 + record.icon = existingRecord.icon; 542 + } 543 + 544 + // Handle preferences 545 + if (options.showInDiscover !== undefined) { 546 + record.preferences = { 547 + showInDiscover: options.showInDiscover, 548 + }; 549 + } else if (existingRecord.preferences) { 550 + record.preferences = existingRecord.preferences; 551 + } 552 553 + await agent.com.atproto.repo.putRecord({ 554 + repo: parsed.did, 555 + collection: parsed.collection, 556 + rkey: parsed.rkey, 557 + record, 558 + }); 559 } 560 561 // --- Bluesky Post Creation --- 562 563 export interface CreateBlueskyPostOptions { 564 + title: string; 565 + description?: string; 566 + canonicalUrl: string; 567 + coverImage?: BlobObject; 568 + publishedAt: string; // Used as createdAt for the post 569 } 570 571 /** 572 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 573 */ 574 function countGraphemes(str: string): number { 575 + // Use Intl.Segmenter if available, otherwise fallback to spread operator 576 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 577 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 578 + return [...segmenter.segment(str)].length; 579 + } 580 + return [...str].length; 581 } 582 583 /** 584 * Truncate a string to a maximum number of graphemes 585 */ 586 function truncateToGraphemes(str: string, maxGraphemes: number): string { 587 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 588 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 589 + const segments = [...segmenter.segment(str)]; 590 + if (segments.length <= maxGraphemes) return str; 591 + return `${segments 592 + .slice(0, maxGraphemes - 3) 593 + .map((s) => s.segment) 594 + .join("")}...`; 595 + } 596 + // Fallback 597 + const chars = [...str]; 598 + if (chars.length <= maxGraphemes) return str; 599 + return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 600 } 601 602 /** 603 * Create a Bluesky post with external link embed 604 */ 605 export async function createBlueskyPost( 606 + agent: Agent, 607 + options: CreateBlueskyPostOptions, 608 ): Promise<StrongRef> { 609 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 610 611 + // Build post text: title + description + URL 612 + // Max 300 graphemes for Bluesky posts 613 + const MAX_GRAPHEMES = 300; 614 615 + let postText: string; 616 + const urlPart = `\n\n${canonicalUrl}`; 617 + const urlGraphemes = countGraphemes(urlPart); 618 619 + if (description) { 620 + // Try: title + description + URL 621 + const fullText = `${title}\n\n${description}${urlPart}`; 622 + if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 623 + postText = fullText; 624 + } else { 625 + // Truncate description to fit 626 + const availableForDesc = 627 + MAX_GRAPHEMES - 628 + countGraphemes(title) - 629 + countGraphemes("\n\n") - 630 + urlGraphemes - 631 + countGraphemes("\n\n"); 632 + if (availableForDesc > 10) { 633 + const truncatedDesc = truncateToGraphemes( 634 + description, 635 + availableForDesc, 636 + ); 637 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 638 + } else { 639 + // Just title + URL 640 + postText = `${title}${urlPart}`; 641 + } 642 + } 643 + } else { 644 + // Just title + URL 645 + postText = `${title}${urlPart}`; 646 + } 647 648 + // Final truncation if still too long (shouldn't happen but safety check) 649 + if (countGraphemes(postText) > MAX_GRAPHEMES) { 650 + postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 651 + } 652 653 + // Calculate byte indices for the URL facet 654 + const encoder = new TextEncoder(); 655 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 656 + const beforeUrl = postText.substring(0, urlStartInText); 657 + const byteStart = encoder.encode(beforeUrl).length; 658 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 659 660 + // Build facets for the URL link 661 + const facets = [ 662 + { 663 + index: { 664 + byteStart, 665 + byteEnd, 666 + }, 667 + features: [ 668 + { 669 + $type: "app.bsky.richtext.facet#link", 670 + uri: canonicalUrl, 671 + }, 672 + ], 673 + }, 674 + ]; 675 676 + // Build external embed 677 + const embed: Record<string, unknown> = { 678 + $type: "app.bsky.embed.external", 679 + external: { 680 + uri: canonicalUrl, 681 + title: title.substring(0, 500), // Max 500 chars for title 682 + description: (description || "").substring(0, 1000), // Max 1000 chars for description 683 + }, 684 + }; 685 686 + // Add thumbnail if coverImage is available 687 + if (coverImage) { 688 + (embed.external as Record<string, unknown>).thumb = coverImage; 689 + } 690 691 + // Create the post record 692 + const record: Record<string, unknown> = { 693 + $type: "app.bsky.feed.post", 694 + text: postText, 695 + facets, 696 + embed, 697 + createdAt: new Date(publishedAt).toISOString(), 698 + }; 699 700 + const response = await agent.com.atproto.repo.createRecord({ 701 + repo: agent.did!, 702 + collection: "app.bsky.feed.post", 703 + record, 704 + }); 705 706 + return { 707 + uri: response.data.uri, 708 + cid: response.data.cid, 709 + }; 710 } 711 712 /** 713 * Add bskyPostRef to an existing document record 714 */ 715 export async function addBskyPostRefToDocument( 716 + agent: Agent, 717 + documentAtUri: string, 718 + bskyPostRef: StrongRef, 719 ): Promise<void> { 720 + const parsed = parseAtUri(documentAtUri); 721 + if (!parsed) { 722 + throw new Error(`Invalid document URI: ${documentAtUri}`); 723 + } 724 725 + // Fetch existing record 726 + const existingRecord = await agent.com.atproto.repo.getRecord({ 727 + repo: parsed.did, 728 + collection: parsed.collection, 729 + rkey: parsed.rkey, 730 + }); 731 732 + // Add bskyPostRef to the record 733 + const updatedRecord = { 734 + ...(existingRecord.data.value as Record<string, unknown>), 735 + bskyPostRef, 736 + }; 737 738 + // Update the record 739 + await agent.com.atproto.repo.putRecord({ 740 + repo: parsed.did, 741 + collection: parsed.collection, 742 + rkey: parsed.rkey, 743 + record: updatedRecord, 744 + }); 745 }
+22 -3
packages/cli/src/lib/config.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 3 - import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types"; 4 5 const CONFIG_FILENAME = "sequoia.json"; 6 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 pdsUrl?: string; 77 frontmatter?: FrontmatterMapping; 78 ignore?: string[]; 79 bluesky?: BlueskyConfig; 80 }): string { 81 const config: Record<string, unknown> = { ··· 113 config.ignore = options.ignore; 114 } 115 116 if (options.bluesky) { 117 config.bluesky = options.bluesky; 118 }
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import type { 4 + PublisherConfig, 5 + PublisherState, 6 + FrontmatterMapping, 7 + BlueskyConfig, 8 + } from "./types"; 9 10 const CONFIG_FILENAME = "sequoia.json"; 11 const STATE_FILENAME = ".sequoia-state.json"; ··· 81 pdsUrl?: string; 82 frontmatter?: FrontmatterMapping; 83 ignore?: string[]; 84 + removeIndexFromSlug?: boolean; 85 + stripDatePrefix?: boolean; 86 + textContentField?: string; 87 bluesky?: BlueskyConfig; 88 }): string { 89 const config: Record<string, unknown> = { ··· 121 config.ignore = options.ignore; 122 } 123 124 + if (options.removeIndexFromSlug) { 125 + config.removeIndexFromSlug = options.removeIndexFromSlug; 126 + } 127 + 128 + if (options.stripDatePrefix) { 129 + config.stripDatePrefix = options.stripDatePrefix; 130 + } 131 + 132 + if (options.textContentField) { 133 + config.textContentField = options.textContentField; 134 + } 135 if (options.bluesky) { 136 config.bluesky = options.bluesky; 137 }
+54
packages/cli/src/lib/credential-select.ts
···
··· 1 + import { select } from "@clack/prompts"; 2 + import { getOAuthHandle, getOAuthSession } from "./oauth-store"; 3 + import { getCredentials } from "./credentials"; 4 + import type { Credentials } from "./types"; 5 + import { exitOnCancel } from "./prompts"; 6 + 7 + /** 8 + * Prompt user to select from multiple credentials 9 + */ 10 + export async function selectCredential( 11 + allCredentials: Array<{ id: string; type: "app-password" | "oauth" }>, 12 + ): Promise<Credentials | null> { 13 + // Build options with friendly labels 14 + const options = await Promise.all( 15 + allCredentials.map(async ({ id, type }) => { 16 + let label = id; 17 + if (type === "oauth") { 18 + const handle = await getOAuthHandle(id); 19 + label = handle ? `${handle} (${id})` : id; 20 + } 21 + return { 22 + value: { id, type }, 23 + label: `${label} [${type}]`, 24 + }; 25 + }), 26 + ); 27 + 28 + const selected = exitOnCancel( 29 + await select({ 30 + message: "Multiple identities found. Select one:", 31 + options, 32 + }), 33 + ); 34 + 35 + // Load the full credentials for the selected identity 36 + if (selected.type === "oauth") { 37 + const session = await getOAuthSession(selected.id); 38 + if (session) { 39 + const handle = await getOAuthHandle(selected.id); 40 + return { 41 + type: "oauth", 42 + did: selected.id, 43 + handle: handle || selected.id, 44 + }; 45 + } 46 + } else { 47 + const creds = await getCredentials(selected.id); 48 + if (creds) { 49 + return creds; 50 + } 51 + } 52 + 53 + return null; 54 + }
+217 -102
packages/cli/src/lib/credentials.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 3 - import * as os from "os"; 4 - import type { Credentials } from "./types"; 5 6 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 7 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 8 9 - // Stored credentials keyed by identifier 10 - type CredentialsStore = Record<string, Credentials>; 11 12 async function fileExists(filePath: string): Promise<boolean> { 13 - try { 14 - await fs.access(filePath); 15 - return true; 16 - } catch { 17 - return false; 18 - } 19 } 20 21 /** 22 - * Load all stored credentials 23 */ 24 async function loadCredentialsStore(): Promise<CredentialsStore> { 25 - if (!(await fileExists(CREDENTIALS_FILE))) { 26 - return {}; 27 - } 28 29 - try { 30 - const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 31 - const parsed = JSON.parse(content); 32 33 - // Handle legacy single-credential format (migrate on read) 34 - if (parsed.identifier && parsed.password) { 35 - const legacy = parsed as Credentials; 36 - return { [legacy.identifier]: legacy }; 37 - } 38 39 - return parsed as CredentialsStore; 40 - } catch { 41 - return {}; 42 - } 43 } 44 45 /** 46 * Save the entire credentials store 47 */ 48 async function saveCredentialsStore(store: CredentialsStore): Promise<void> { 49 - await fs.mkdir(CONFIG_DIR, { recursive: true }); 50 - await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 51 - await fs.chmod(CREDENTIALS_FILE, 0o600); 52 } 53 54 /** ··· 56 * 57 * Priority: 58 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 59 - * 2. SEQUOIA_PROFILE env var - selects from stored credentials 60 * 3. projectIdentity parameter (from sequoia.json) 61 - * 4. If only one identity stored, use it 62 * 5. Return null (caller should prompt user) 63 */ 64 export async function loadCredentials( 65 - projectIdentity?: string 66 ): Promise<Credentials | null> { 67 - // 1. Check environment variables first (full override) 68 - const envIdentifier = process.env.ATP_IDENTIFIER; 69 - const envPassword = process.env.ATP_APP_PASSWORD; 70 - const envPdsUrl = process.env.PDS_URL; 71 72 - if (envIdentifier && envPassword) { 73 - return { 74 - identifier: envIdentifier, 75 - password: envPassword, 76 - pdsUrl: envPdsUrl || "https://bsky.social", 77 - }; 78 - } 79 80 - const store = await loadCredentialsStore(); 81 - const identifiers = Object.keys(store); 82 83 - if (identifiers.length === 0) { 84 - return null; 85 - } 86 - 87 - // 2. SEQUOIA_PROFILE env var 88 - const profileEnv = process.env.SEQUOIA_PROFILE; 89 - if (profileEnv && store[profileEnv]) { 90 - return store[profileEnv]; 91 - } 92 93 - // 3. Project-specific identity (from sequoia.json) 94 - if (projectIdentity && store[projectIdentity]) { 95 - return store[projectIdentity]; 96 - } 97 98 - // 4. If only one identity, use it 99 - if (identifiers.length === 1 && identifiers[0]) { 100 - return store[identifiers[0]] ?? null; 101 - } 102 103 - // Multiple identities exist but none selected 104 - return null; 105 } 106 107 /** 108 - * Get a specific identity by identifier 109 */ 110 export async function getCredentials( 111 - identifier: string 112 - ): Promise<Credentials | null> { 113 - const store = await loadCredentialsStore(); 114 - return store[identifier] || null; 115 } 116 117 /** 118 - * List all stored identities 119 */ 120 export async function listCredentials(): Promise<string[]> { 121 - const store = await loadCredentialsStore(); 122 - return Object.keys(store); 123 } 124 125 /** 126 - * Save credentials for an identity (adds or updates) 127 */ 128 - export async function saveCredentials(credentials: Credentials): Promise<void> { 129 - const store = await loadCredentialsStore(); 130 - store[credentials.identifier] = credentials; 131 - await saveCredentialsStore(store); 132 } 133 134 /** 135 * Delete credentials for a specific identity 136 */ 137 export async function deleteCredentials(identifier?: string): Promise<boolean> { 138 - const store = await loadCredentialsStore(); 139 - const identifiers = Object.keys(store); 140 141 - if (identifiers.length === 0) { 142 - return false; 143 - } 144 145 - // If identifier specified, delete just that one 146 - if (identifier) { 147 - if (!store[identifier]) { 148 - return false; 149 - } 150 - delete store[identifier]; 151 - await saveCredentialsStore(store); 152 - return true; 153 - } 154 155 - // If only one identity, delete it (backwards compat behavior) 156 - if (identifiers.length === 1 && identifiers[0]) { 157 - delete store[identifiers[0]]; 158 - await saveCredentialsStore(store); 159 - return true; 160 - } 161 162 - // Multiple identities but none specified 163 - return false; 164 } 165 166 export function getCredentialsPath(): string { 167 - return CREDENTIALS_FILE; 168 }
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import { 5 + getOAuthHandle, 6 + getOAuthSession, 7 + listOAuthSessions, 8 + listOAuthSessionsWithHandles, 9 + } from "./oauth-store"; 10 + import type { 11 + AppPasswordCredentials, 12 + Credentials, 13 + LegacyCredentials, 14 + OAuthCredentials, 15 + } from "./types"; 16 17 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 18 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 19 20 + // Stored credentials keyed by identifier (can be legacy or typed) 21 + type CredentialsStore = Record< 22 + string, 23 + AppPasswordCredentials | LegacyCredentials 24 + >; 25 26 async function fileExists(filePath: string): Promise<boolean> { 27 + try { 28 + await fs.access(filePath); 29 + return true; 30 + } catch { 31 + return false; 32 + } 33 } 34 35 /** 36 + * Normalize credentials to have explicit type 37 */ 38 + function normalizeCredentials( 39 + creds: AppPasswordCredentials | LegacyCredentials, 40 + ): AppPasswordCredentials { 41 + // If it already has type, return as-is 42 + if ("type" in creds && creds.type === "app-password") { 43 + return creds; 44 + } 45 + // Migrate legacy format 46 + return { 47 + type: "app-password", 48 + pdsUrl: creds.pdsUrl, 49 + identifier: creds.identifier, 50 + password: creds.password, 51 + }; 52 + } 53 + 54 async function loadCredentialsStore(): Promise<CredentialsStore> { 55 + if (!(await fileExists(CREDENTIALS_FILE))) { 56 + return {}; 57 + } 58 59 + try { 60 + const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 61 + const parsed = JSON.parse(content); 62 63 + // Handle legacy single-credential format (migrate on read) 64 + if (parsed.identifier && parsed.password) { 65 + const legacy = parsed as LegacyCredentials; 66 + return { [legacy.identifier]: legacy }; 67 + } 68 69 + return parsed as CredentialsStore; 70 + } catch { 71 + return {}; 72 + } 73 } 74 75 /** 76 * Save the entire credentials store 77 */ 78 async function saveCredentialsStore(store: CredentialsStore): Promise<void> { 79 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 80 + await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 81 + await fs.chmod(CREDENTIALS_FILE, 0o600); 82 + } 83 + 84 + /** 85 + * Try to load OAuth credentials for a given profile (DID or handle) 86 + */ 87 + async function tryLoadOAuthCredentials( 88 + profile: string, 89 + ): Promise<OAuthCredentials | null> { 90 + // If it looks like a DID, try to get the session directly 91 + if (profile.startsWith("did:")) { 92 + const session = await getOAuthSession(profile); 93 + if (session) { 94 + const handle = await getOAuthHandle(profile); 95 + return { 96 + type: "oauth", 97 + did: profile, 98 + handle: handle || profile, 99 + }; 100 + } 101 + } 102 + 103 + // Try to find OAuth session by handle 104 + const sessions = await listOAuthSessionsWithHandles(); 105 + const match = sessions.find((s) => s.handle === profile); 106 + if (match) { 107 + return { 108 + type: "oauth", 109 + did: match.did, 110 + handle: match.handle || match.did, 111 + }; 112 + } 113 + 114 + return null; 115 } 116 117 /** ··· 119 * 120 * Priority: 121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 122 + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 123 * 3. projectIdentity parameter (from sequoia.json) 124 + * 4. If only one identity stored (app-password or OAuth), use it 125 * 5. Return null (caller should prompt user) 126 */ 127 export async function loadCredentials( 128 + projectIdentity?: string, 129 ): Promise<Credentials | null> { 130 + // 1. Check environment variables first (full override) 131 + const envIdentifier = process.env.ATP_IDENTIFIER; 132 + const envPassword = process.env.ATP_APP_PASSWORD; 133 + const envPdsUrl = process.env.PDS_URL; 134 135 + if (envIdentifier && envPassword) { 136 + return { 137 + type: "app-password", 138 + identifier: envIdentifier, 139 + password: envPassword, 140 + pdsUrl: envPdsUrl || "https://bsky.social", 141 + }; 142 + } 143 144 + const store = await loadCredentialsStore(); 145 + const appPasswordIds = Object.keys(store); 146 + const oauthDids = await listOAuthSessions(); 147 148 + // 2. SEQUOIA_PROFILE env var 149 + const profileEnv = process.env.SEQUOIA_PROFILE; 150 + if (profileEnv) { 151 + // Try app-password credentials first 152 + if (store[profileEnv]) { 153 + return normalizeCredentials(store[profileEnv]); 154 + } 155 + // Try OAuth session (profile could be a DID) 156 + const oauth = await tryLoadOAuthCredentials(profileEnv); 157 + if (oauth) { 158 + return oauth; 159 + } 160 + } 161 162 + // 3. Project-specific identity (from sequoia.json) 163 + if (projectIdentity) { 164 + if (store[projectIdentity]) { 165 + return normalizeCredentials(store[projectIdentity]); 166 + } 167 + const oauth = await tryLoadOAuthCredentials(projectIdentity); 168 + if (oauth) { 169 + return oauth; 170 + } 171 + } 172 173 + // 4. If only one identity total, use it 174 + const totalIdentities = appPasswordIds.length + oauthDids.length; 175 + if (totalIdentities === 1) { 176 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 177 + return normalizeCredentials(store[appPasswordIds[0]]!); 178 + } 179 + if (oauthDids.length === 1 && oauthDids[0]) { 180 + const session = await getOAuthSession(oauthDids[0]); 181 + if (session) { 182 + const handle = await getOAuthHandle(oauthDids[0]); 183 + return { 184 + type: "oauth", 185 + did: oauthDids[0], 186 + handle: handle || oauthDids[0], 187 + }; 188 + } 189 + } 190 + } 191 192 + // Multiple identities exist but none selected, or no identities 193 + return null; 194 } 195 196 /** 197 + * Get a specific identity by identifier (app-password only) 198 */ 199 export async function getCredentials( 200 + identifier: string, 201 + ): Promise<AppPasswordCredentials | null> { 202 + const store = await loadCredentialsStore(); 203 + const creds = store[identifier]; 204 + if (!creds) return null; 205 + return normalizeCredentials(creds); 206 } 207 208 /** 209 + * List all stored app-password identities 210 */ 211 export async function listCredentials(): Promise<string[]> { 212 + const store = await loadCredentialsStore(); 213 + return Object.keys(store); 214 + } 215 + 216 + /** 217 + * List all credentials (both app-password and OAuth) 218 + */ 219 + export async function listAllCredentials(): Promise< 220 + Array<{ id: string; type: "app-password" | "oauth" }> 221 + > { 222 + const store = await loadCredentialsStore(); 223 + const oauthDids = await listOAuthSessions(); 224 + 225 + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 226 + 227 + for (const id of Object.keys(store)) { 228 + result.push({ id, type: "app-password" }); 229 + } 230 + 231 + for (const did of oauthDids) { 232 + result.push({ id: did, type: "oauth" }); 233 + } 234 + 235 + return result; 236 } 237 238 /** 239 + * Save app-password credentials for an identity (adds or updates) 240 */ 241 + export async function saveCredentials( 242 + credentials: AppPasswordCredentials, 243 + ): Promise<void> { 244 + const store = await loadCredentialsStore(); 245 + store[credentials.identifier] = credentials; 246 + await saveCredentialsStore(store); 247 } 248 249 /** 250 * Delete credentials for a specific identity 251 */ 252 export async function deleteCredentials(identifier?: string): Promise<boolean> { 253 + const store = await loadCredentialsStore(); 254 + const identifiers = Object.keys(store); 255 256 + if (identifiers.length === 0) { 257 + return false; 258 + } 259 260 + // If identifier specified, delete just that one 261 + if (identifier) { 262 + if (!store[identifier]) { 263 + return false; 264 + } 265 + delete store[identifier]; 266 + await saveCredentialsStore(store); 267 + return true; 268 + } 269 270 + // If only one identity, delete it (backwards compat behavior) 271 + if (identifiers.length === 1 && identifiers[0]) { 272 + delete store[identifiers[0]]; 273 + await saveCredentialsStore(store); 274 + return true; 275 + } 276 277 + // Multiple identities but none specified 278 + return false; 279 } 280 281 export function getCredentialsPath(): string { 282 + return CREDENTIALS_FILE; 283 }
+336 -176
packages/cli/src/lib/markdown.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 3 import { glob } from "glob"; 4 import { minimatch } from "minimatch"; 5 - import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types"; 6 7 - export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { 8 - frontmatter: PostFrontmatter; 9 - body: string; 10 } { 11 - // Support multiple frontmatter delimiters: 12 - // --- (YAML) - Jekyll, Astro, most SSGs 13 - // +++ (TOML) - Hugo 14 - // *** - Alternative format 15 - const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 16 - const match = content.match(frontmatterRegex); 17 18 - if (!match) { 19 - throw new Error("Could not parse frontmatter"); 20 - } 21 22 - const delimiter = match[1]; 23 - const frontmatterStr = match[2] ?? ""; 24 - const body = match[3] ?? ""; 25 26 - // Determine format based on delimiter: 27 - // +++ uses TOML (key = value) 28 - // --- and *** use YAML (key: value) 29 - const isToml = delimiter === "+++"; 30 - const separator = isToml ? "=" : ":"; 31 32 - // Parse frontmatter manually 33 - const raw: Record<string, unknown> = {}; 34 - const lines = frontmatterStr.split("\n"); 35 36 - for (const line of lines) { 37 - const sepIndex = line.indexOf(separator); 38 - if (sepIndex === -1) continue; 39 40 - const key = line.slice(0, sepIndex).trim(); 41 - let value = line.slice(sepIndex + 1).trim(); 42 43 - // Handle quoted strings 44 - if ( 45 - (value.startsWith('"') && value.endsWith('"')) || 46 - (value.startsWith("'") && value.endsWith("'")) 47 - ) { 48 - value = value.slice(1, -1); 49 - } 50 51 - // Handle arrays (simple case for tags) 52 - if (value.startsWith("[") && value.endsWith("]")) { 53 - const arrayContent = value.slice(1, -1); 54 - raw[key] = arrayContent 55 - .split(",") 56 - .map((item) => item.trim().replace(/^["']|["']$/g, "")); 57 - } else if (value === "true") { 58 - raw[key] = true; 59 - } else if (value === "false") { 60 - raw[key] = false; 61 - } else { 62 - raw[key] = value; 63 - } 64 - } 65 66 - // Apply field mappings to normalize to standard PostFrontmatter fields 67 - const frontmatter: Record<string, unknown> = {}; 68 69 - // Title mapping 70 - const titleField = mapping?.title || "title"; 71 - frontmatter.title = raw[titleField] || raw.title; 72 73 - // Description mapping 74 - const descField = mapping?.description || "description"; 75 - frontmatter.description = raw[descField] || raw.description; 76 77 - // Publish date mapping - check custom field first, then fallbacks 78 - const dateField = mapping?.publishDate; 79 - if (dateField && raw[dateField]) { 80 - frontmatter.publishDate = raw[dateField]; 81 - } else if (raw.publishDate) { 82 - frontmatter.publishDate = raw.publishDate; 83 - } else { 84 - // Fallback to common date field names 85 - const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 86 - for (const field of dateFields) { 87 - if (raw[field]) { 88 - frontmatter.publishDate = raw[field]; 89 - break; 90 - } 91 - } 92 - } 93 94 - // Cover image mapping 95 - const coverField = mapping?.coverImage || "ogImage"; 96 - frontmatter.ogImage = raw[coverField] || raw.ogImage; 97 98 - // Tags mapping 99 - const tagsField = mapping?.tags || "tags"; 100 - frontmatter.tags = raw[tagsField] || raw.tags; 101 102 - // Draft mapping 103 - const draftField = mapping?.draft || "draft"; 104 - const draftValue = raw[draftField] ?? raw.draft; 105 - if (draftValue !== undefined) { 106 - frontmatter.draft = draftValue === true || draftValue === "true"; 107 - } 108 109 - // Always preserve atUri (internal field) 110 - frontmatter.atUri = raw.atUri; 111 112 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; 113 } 114 115 export function getSlugFromFilename(filename: string): string { 116 - return filename 117 - .replace(/\.mdx?$/, "") 118 - .toLowerCase() 119 - .replace(/\s+/g, "-"); 120 } 121 122 export async function getContentHash(content: string): Promise<string> { 123 - const encoder = new TextEncoder(); 124 - const data = encoder.encode(content); 125 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); 126 - const hashArray = Array.from(new Uint8Array(hashBuffer)); 127 - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 128 } 129 130 function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { 131 - for (const pattern of ignorePatterns) { 132 - if (minimatch(relativePath, pattern)) { 133 - return true; 134 - } 135 - } 136 - return false; 137 } 138 139 export async function scanContentDirectory( 140 - contentDir: string, 141 - frontmatterMapping?: FrontmatterMapping, 142 - ignorePatterns: string[] = [] 143 ): Promise<BlogPost[]> { 144 - const patterns = ["**/*.md", "**/*.mdx"]; 145 - const posts: BlogPost[] = []; 146 147 - for (const pattern of patterns) { 148 - const files = await glob(pattern, { 149 - cwd: contentDir, 150 - absolute: false, 151 - }); 152 153 - for (const relativePath of files) { 154 - // Skip files matching ignore patterns 155 - if (shouldIgnore(relativePath, ignorePatterns)) { 156 - continue; 157 - } 158 159 - const filePath = path.join(contentDir, relativePath); 160 - const rawContent = await fs.readFile(filePath, "utf-8"); 161 162 - try { 163 - const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); 164 - const filename = path.basename(relativePath); 165 - const slug = getSlugFromFilename(filename); 166 167 - posts.push({ 168 - filePath, 169 - slug, 170 - frontmatter, 171 - content: body, 172 - rawContent, 173 - }); 174 - } catch (error) { 175 - console.error(`Error parsing ${relativePath}:`, error); 176 - } 177 - } 178 - } 179 180 - // Sort by publish date (newest first) 181 - posts.sort((a, b) => { 182 - const dateA = new Date(a.frontmatter.publishDate); 183 - const dateB = new Date(b.frontmatter.publishDate); 184 - return dateB.getTime() - dateA.getTime(); 185 - }); 186 187 - return posts; 188 } 189 190 - export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string { 191 - // Detect which delimiter is used (---, +++, or ***) 192 - const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 193 - const delimiter = delimiterMatch?.[1] ?? "---"; 194 - const isToml = delimiter === "+++"; 195 196 - // Format the atUri entry based on frontmatter type 197 - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 198 199 - // Check if atUri already exists in frontmatter (handle both formats) 200 - if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 201 - // Replace existing atUri (match both YAML and TOML formats) 202 - return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`); 203 - } 204 205 - // Insert atUri before the closing delimiter 206 - const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 207 - if (frontmatterEndIndex === -1) { 208 - throw new Error("Could not find frontmatter end"); 209 - } 210 211 - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 212 - const afterEnd = rawContent.slice(frontmatterEndIndex); 213 214 - return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 215 } 216 217 export function stripMarkdownForText(markdown: string): string { 218 - return markdown 219 - .replace(/#{1,6}\s/g, "") // Remove headers 220 - .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 221 - .replace(/\*([^*]+)\*/g, "$1") // Remove italic 222 - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 223 - .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 224 - .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 225 - .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 226 - .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 227 - .trim(); 228 }
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 import { glob } from "glob"; 4 import { minimatch } from "minimatch"; 5 + import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; 6 7 + export function parseFrontmatter( 8 + content: string, 9 + mapping?: FrontmatterMapping, 10 + ): { 11 + frontmatter: PostFrontmatter; 12 + body: string; 13 + rawFrontmatter: Record<string, unknown>; 14 } { 15 + // Support multiple frontmatter delimiters: 16 + // --- (YAML) - Jekyll, Astro, most SSGs 17 + // +++ (TOML) - Hugo 18 + // *** - Alternative format 19 + const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 20 + const match = content.match(frontmatterRegex); 21 22 + if (!match) { 23 + throw new Error("Could not parse frontmatter"); 24 + } 25 26 + const delimiter = match[1]; 27 + const frontmatterStr = match[2] ?? ""; 28 + const body = match[3] ?? ""; 29 30 + // Determine format based on delimiter: 31 + // +++ uses TOML (key = value) 32 + // --- and *** use YAML (key: value) 33 + const isToml = delimiter === "+++"; 34 + const separator = isToml ? "=" : ":"; 35 36 + // Parse frontmatter manually 37 + const raw: Record<string, unknown> = {}; 38 + const lines = frontmatterStr.split("\n"); 39 40 + let i = 0; 41 + while (i < lines.length) { 42 + const line = lines[i]; 43 + if (line === undefined) { 44 + i++; 45 + continue; 46 + } 47 + const sepIndex = line.indexOf(separator); 48 + if (sepIndex === -1) { 49 + i++; 50 + continue; 51 + } 52 53 + const key = line.slice(0, sepIndex).trim(); 54 + let value = line.slice(sepIndex + 1).trim(); 55 56 + // Handle quoted strings 57 + if ( 58 + (value.startsWith('"') && value.endsWith('"')) || 59 + (value.startsWith("'") && value.endsWith("'")) 60 + ) { 61 + value = value.slice(1, -1); 62 + } 63 64 + // Handle inline arrays (simple case for tags) 65 + if (value.startsWith("[") && value.endsWith("]")) { 66 + const arrayContent = value.slice(1, -1); 67 + raw[key] = arrayContent 68 + .split(",") 69 + .map((item) => item.trim().replace(/^["']|["']$/g, "")); 70 + } else if (value === "" && !isToml) { 71 + // Check for YAML-style multiline array (key with no value followed by - items) 72 + const arrayItems: string[] = []; 73 + let j = i + 1; 74 + while (j < lines.length) { 75 + const nextLine = lines[j]; 76 + if (nextLine === undefined) { 77 + j++; 78 + continue; 79 + } 80 + // Check if line is a list item (starts with whitespace and -) 81 + const listMatch = nextLine.match(/^\s+-\s*(.*)$/); 82 + if (listMatch && listMatch[1] !== undefined) { 83 + let itemValue = listMatch[1].trim(); 84 + // Remove quotes if present 85 + if ( 86 + (itemValue.startsWith('"') && itemValue.endsWith('"')) || 87 + (itemValue.startsWith("'") && itemValue.endsWith("'")) 88 + ) { 89 + itemValue = itemValue.slice(1, -1); 90 + } 91 + arrayItems.push(itemValue); 92 + j++; 93 + } else if (nextLine.trim() === "") { 94 + // Skip empty lines within the array 95 + j++; 96 + } else { 97 + // Hit a new key or non-list content 98 + break; 99 + } 100 + } 101 + if (arrayItems.length > 0) { 102 + raw[key] = arrayItems; 103 + i = j; 104 + continue; 105 + } else { 106 + raw[key] = value; 107 + } 108 + } else if (value === "true") { 109 + raw[key] = true; 110 + } else if (value === "false") { 111 + raw[key] = false; 112 + } else { 113 + raw[key] = value; 114 + } 115 + i++; 116 + } 117 118 + // Apply field mappings to normalize to standard PostFrontmatter fields 119 + const frontmatter: Record<string, unknown> = {}; 120 121 + // Title mapping 122 + const titleField = mapping?.title || "title"; 123 + frontmatter.title = raw[titleField] || raw.title; 124 125 + // Description mapping 126 + const descField = mapping?.description || "description"; 127 + frontmatter.description = raw[descField] || raw.description; 128 129 + // Publish date mapping - check custom field first, then fallbacks 130 + const dateField = mapping?.publishDate; 131 + if (dateField && raw[dateField]) { 132 + frontmatter.publishDate = raw[dateField]; 133 + } else if (raw.publishDate) { 134 + frontmatter.publishDate = raw.publishDate; 135 + } else { 136 + // Fallback to common date field names 137 + const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 138 + for (const field of dateFields) { 139 + if (raw[field]) { 140 + frontmatter.publishDate = raw[field]; 141 + break; 142 + } 143 + } 144 + } 145 146 + // Cover image mapping 147 + const coverField = mapping?.coverImage || "ogImage"; 148 + frontmatter.ogImage = raw[coverField] || raw.ogImage; 149 150 + // Tags mapping 151 + const tagsField = mapping?.tags || "tags"; 152 + frontmatter.tags = raw[tagsField] || raw.tags; 153 154 + // Draft mapping 155 + const draftField = mapping?.draft || "draft"; 156 + const draftValue = raw[draftField] ?? raw.draft; 157 + if (draftValue !== undefined) { 158 + frontmatter.draft = draftValue === true || draftValue === "true"; 159 + } 160 161 + // Always preserve atUri (internal field) 162 + frontmatter.atUri = raw.atUri; 163 164 + return { 165 + frontmatter: frontmatter as unknown as PostFrontmatter, 166 + body, 167 + rawFrontmatter: raw, 168 + }; 169 } 170 171 export function getSlugFromFilename(filename: string): string { 172 + return filename 173 + .replace(/\.mdx?$/, "") 174 + .toLowerCase() 175 + .replace(/\s+/g, "-"); 176 + } 177 + 178 + export interface SlugOptions { 179 + slugField?: string; 180 + removeIndexFromSlug?: boolean; 181 + stripDatePrefix?: boolean; 182 + } 183 + 184 + export function getSlugFromOptions( 185 + relativePath: string, 186 + rawFrontmatter: Record<string, unknown>, 187 + options: SlugOptions = {}, 188 + ): string { 189 + const { 190 + slugField, 191 + removeIndexFromSlug = false, 192 + stripDatePrefix = false, 193 + } = options; 194 + 195 + let slug: string; 196 + 197 + // If slugField is set, try to get the value from frontmatter 198 + if (slugField) { 199 + const frontmatterValue = rawFrontmatter[slugField]; 200 + if (frontmatterValue && typeof frontmatterValue === "string") { 201 + // Remove leading slash if present 202 + slug = frontmatterValue 203 + .replace(/^\//, "") 204 + .toLowerCase() 205 + .replace(/\s+/g, "-"); 206 + } else { 207 + // Fallback to filepath if frontmatter field not found 208 + slug = relativePath 209 + .replace(/\.mdx?$/, "") 210 + .toLowerCase() 211 + .replace(/\s+/g, "-"); 212 + } 213 + } else { 214 + // Default: use filepath 215 + slug = relativePath 216 + .replace(/\.mdx?$/, "") 217 + .toLowerCase() 218 + .replace(/\s+/g, "-"); 219 + } 220 + 221 + // Remove /index or /_index suffix if configured 222 + if (removeIndexFromSlug) { 223 + slug = slug.replace(/\/_?index$/, ""); 224 + } 225 + 226 + // Strip Jekyll-style date prefix (YYYY-MM-DD-) from filename 227 + if (stripDatePrefix) { 228 + slug = slug.replace(/(^|\/)(\d{4}-\d{2}-\d{2})-/g, "$1"); 229 + } 230 + 231 + return slug; 232 } 233 234 export async function getContentHash(content: string): Promise<string> { 235 + const encoder = new TextEncoder(); 236 + const data = encoder.encode(content); 237 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 238 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 239 + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 240 } 241 242 function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { 243 + for (const pattern of ignorePatterns) { 244 + if (minimatch(relativePath, pattern)) { 245 + return true; 246 + } 247 + } 248 + return false; 249 + } 250 + 251 + export interface ScanOptions { 252 + frontmatterMapping?: FrontmatterMapping; 253 + ignorePatterns?: string[]; 254 + slugField?: string; 255 + removeIndexFromSlug?: boolean; 256 + stripDatePrefix?: boolean; 257 } 258 259 export async function scanContentDirectory( 260 + contentDir: string, 261 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 262 + ignorePatterns: string[] = [], 263 ): Promise<BlogPost[]> { 264 + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 265 + let options: ScanOptions; 266 + if ( 267 + frontmatterMappingOrOptions && 268 + ("frontmatterMapping" in frontmatterMappingOrOptions || 269 + "ignorePatterns" in frontmatterMappingOrOptions || 270 + "slugField" in frontmatterMappingOrOptions) 271 + ) { 272 + options = frontmatterMappingOrOptions as ScanOptions; 273 + } else { 274 + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 275 + options = { 276 + frontmatterMapping: frontmatterMappingOrOptions as 277 + | FrontmatterMapping 278 + | undefined, 279 + ignorePatterns, 280 + }; 281 + } 282 283 + const { 284 + frontmatterMapping, 285 + ignorePatterns: ignore = [], 286 + slugField, 287 + removeIndexFromSlug, 288 + stripDatePrefix, 289 + } = options; 290 291 + const patterns = ["**/*.md", "**/*.mdx"]; 292 + const posts: BlogPost[] = []; 293 294 + for (const pattern of patterns) { 295 + const files = await glob(pattern, { 296 + cwd: contentDir, 297 + absolute: false, 298 + }); 299 300 + for (const relativePath of files) { 301 + // Skip files matching ignore patterns 302 + if (shouldIgnore(relativePath, ignore)) { 303 + continue; 304 + } 305 306 + const filePath = path.join(contentDir, relativePath); 307 + const rawContent = await fs.readFile(filePath, "utf-8"); 308 309 + try { 310 + const { frontmatter, body, rawFrontmatter } = parseFrontmatter( 311 + rawContent, 312 + frontmatterMapping, 313 + ); 314 + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 315 + slugField, 316 + removeIndexFromSlug, 317 + stripDatePrefix, 318 + }); 319 320 + posts.push({ 321 + filePath, 322 + slug, 323 + frontmatter, 324 + content: body, 325 + rawContent, 326 + rawFrontmatter, 327 + }); 328 + } catch (error) { 329 + console.error(`Error parsing ${relativePath}:`, error); 330 + } 331 + } 332 + } 333 + 334 + // Sort by publish date (newest first) 335 + posts.sort((a, b) => { 336 + const dateA = new Date(a.frontmatter.publishDate); 337 + const dateB = new Date(b.frontmatter.publishDate); 338 + return dateB.getTime() - dateA.getTime(); 339 + }); 340 + 341 + return posts; 342 } 343 344 + export function updateFrontmatterWithAtUri( 345 + rawContent: string, 346 + atUri: string, 347 + ): string { 348 + // Detect which delimiter is used (---, +++, or ***) 349 + const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 350 + const delimiter = delimiterMatch?.[1] ?? "---"; 351 + const isToml = delimiter === "+++"; 352 353 + // Format the atUri entry based on frontmatter type 354 + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 355 356 + // Check if atUri already exists in frontmatter (handle both formats) 357 + if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 358 + // Replace existing atUri (match both YAML and TOML formats) 359 + return rawContent.replace( 360 + /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, 361 + `${atUriEntry}\n`, 362 + ); 363 + } 364 365 + // Insert atUri before the closing delimiter 366 + const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 367 + if (frontmatterEndIndex === -1) { 368 + throw new Error("Could not find frontmatter end"); 369 + } 370 371 + const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 372 + const afterEnd = rawContent.slice(frontmatterEndIndex); 373 374 + return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 375 } 376 377 export function stripMarkdownForText(markdown: string): string { 378 + return markdown 379 + .replace(/#{1,6}\s/g, "") // Remove headers 380 + .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 381 + .replace(/\*([^*]+)\*/g, "$1") // Remove italic 382 + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 383 + .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 384 + .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 385 + .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 386 + .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 387 + .trim(); 388 }
+94
packages/cli/src/lib/oauth-client.ts
···
··· 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<T>( 22 + key: string, 23 + fn: () => T | PromiseLike<T>, 24 + ): Promise<T> { 25 + // Wait for any existing lock on this key 26 + while (locks.has(key)) { 27 + await locks.get(key); 28 + } 29 + 30 + // Create our lock 31 + let resolve: () => void; 32 + const lockPromise = new Promise<void>((r) => { 33 + resolve = r; 34 + }); 35 + locks.set(key, lockPromise); 36 + 37 + try { 38 + return await fn(); 39 + } finally { 40 + locks.delete(key); 41 + resolve!(); 42 + } 43 + } 44 + 45 + /** 46 + * Get or create the OAuth client singleton 47 + */ 48 + export async function getOAuthClient(): Promise<NodeOAuthClient> { 49 + if (oauthClient) { 50 + return oauthClient; 51 + } 52 + 53 + // Build client_id with required parameters 54 + const clientIdParams = new URLSearchParams(); 55 + clientIdParams.append("redirect_uri", CALLBACK_URL); 56 + clientIdParams.append("scope", OAUTH_SCOPE); 57 + 58 + const clientOptions: NodeOAuthClientOptions = { 59 + clientMetadata: { 60 + client_id: `http://localhost?${clientIdParams.toString()}`, 61 + client_name: "Sequoia CLI", 62 + client_uri: "https://github.com/stevedylandev/sequoia", 63 + redirect_uris: [CALLBACK_URL], 64 + grant_types: ["authorization_code", "refresh_token"], 65 + response_types: ["code"], 66 + token_endpoint_auth_method: "none", 67 + application_type: "web", 68 + scope: OAUTH_SCOPE, 69 + dpop_bound_access_tokens: false, 70 + }, 71 + stateStore, 72 + sessionStore, 73 + // Configure identity resolution 74 + plcDirectoryUrl: "https://plc.directory", 75 + // Provide lock mechanism to prevent warning 76 + requestLock, 77 + }; 78 + 79 + oauthClient = new NodeOAuthClient(clientOptions); 80 + 81 + return oauthClient; 82 + } 83 + 84 + export function getOAuthScope(): string { 85 + return OAUTH_SCOPE; 86 + } 87 + 88 + export function getCallbackUrl(): string { 89 + return CALLBACK_URL; 90 + } 91 + 92 + export function getCallbackPort(): number { 93 + return CALLBACK_PORT; 94 + }
+161
packages/cli/src/lib/oauth-store.ts
···
··· 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 + handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat) 18 + } 19 + 20 + async function fileExists(filePath: string): Promise<boolean> { 21 + try { 22 + await fs.access(filePath); 23 + return true; 24 + } catch { 25 + return false; 26 + } 27 + } 28 + 29 + async function loadOAuthStore(): Promise<OAuthStore> { 30 + if (!(await fileExists(OAUTH_FILE))) { 31 + return { states: {}, sessions: {} }; 32 + } 33 + 34 + try { 35 + const content = await fs.readFile(OAUTH_FILE, "utf-8"); 36 + return JSON.parse(content) as OAuthStore; 37 + } catch { 38 + return { states: {}, sessions: {} }; 39 + } 40 + } 41 + 42 + async function saveOAuthStore(store: OAuthStore): Promise<void> { 43 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 44 + await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2)); 45 + await fs.chmod(OAUTH_FILE, 0o600); 46 + } 47 + 48 + /** 49 + * State store for PKCE flow (temporary, used during auth) 50 + */ 51 + export const stateStore: NodeSavedStateStore = { 52 + async set(key: string, state: NodeSavedState): Promise<void> { 53 + const store = await loadOAuthStore(); 54 + store.states[key] = state; 55 + await saveOAuthStore(store); 56 + }, 57 + 58 + async get(key: string): Promise<NodeSavedState | undefined> { 59 + const store = await loadOAuthStore(); 60 + return store.states[key]; 61 + }, 62 + 63 + async del(key: string): Promise<void> { 64 + const store = await loadOAuthStore(); 65 + delete store.states[key]; 66 + await saveOAuthStore(store); 67 + }, 68 + }; 69 + 70 + /** 71 + * Session store for OAuth tokens (persistent) 72 + */ 73 + export const sessionStore: NodeSavedSessionStore = { 74 + async set(sub: string, session: NodeSavedSession): Promise<void> { 75 + const store = await loadOAuthStore(); 76 + store.sessions[sub] = session; 77 + await saveOAuthStore(store); 78 + }, 79 + 80 + async get(sub: string): Promise<NodeSavedSession | undefined> { 81 + const store = await loadOAuthStore(); 82 + return store.sessions[sub]; 83 + }, 84 + 85 + async del(sub: string): Promise<void> { 86 + const store = await loadOAuthStore(); 87 + delete store.sessions[sub]; 88 + await saveOAuthStore(store); 89 + }, 90 + }; 91 + 92 + /** 93 + * List all stored OAuth session DIDs 94 + */ 95 + export async function listOAuthSessions(): Promise<string[]> { 96 + const store = await loadOAuthStore(); 97 + return Object.keys(store.sessions); 98 + } 99 + 100 + /** 101 + * Get an OAuth session by DID 102 + */ 103 + export async function getOAuthSession( 104 + did: string, 105 + ): Promise<NodeSavedSession | undefined> { 106 + const store = await loadOAuthStore(); 107 + return store.sessions[did]; 108 + } 109 + 110 + /** 111 + * Delete an OAuth session by DID 112 + */ 113 + export async function deleteOAuthSession(did: string): Promise<boolean> { 114 + const store = await loadOAuthStore(); 115 + if (!store.sessions[did]) { 116 + return false; 117 + } 118 + delete store.sessions[did]; 119 + await saveOAuthStore(store); 120 + return true; 121 + } 122 + 123 + export function getOAuthStorePath(): string { 124 + return OAUTH_FILE; 125 + } 126 + 127 + /** 128 + * Store handle for an OAuth session (DID -> handle mapping) 129 + */ 130 + export async function setOAuthHandle( 131 + did: string, 132 + handle: string, 133 + ): Promise<void> { 134 + const store = await loadOAuthStore(); 135 + if (!store.handles) { 136 + store.handles = {}; 137 + } 138 + store.handles[did] = handle; 139 + await saveOAuthStore(store); 140 + } 141 + 142 + /** 143 + * Get handle for an OAuth session by DID 144 + */ 145 + export async function getOAuthHandle(did: string): Promise<string | undefined> { 146 + const store = await loadOAuthStore(); 147 + return store.handles?.[did]; 148 + } 149 + 150 + /** 151 + * List all stored OAuth sessions with their handles 152 + */ 153 + export async function listOAuthSessionsWithHandles(): Promise< 154 + Array<{ did: string; handle?: string }> 155 + > { 156 + const store = await loadOAuthStore(); 157 + return Object.keys(store.sessions).map((did) => ({ 158 + did, 159 + handle: store.handles?.[did], 160 + })); 161 + }
+6 -6
packages/cli/src/lib/prompts.ts
··· 1 - import { isCancel, cancel } from "@clack/prompts"; 2 3 export function exitOnCancel<T>(value: T | symbol): T { 4 - if (isCancel(value)) { 5 - cancel("Cancelled"); 6 - process.exit(0); 7 - } 8 - return value as T; 9 }
··· 1 + import { cancel, isCancel } from "@clack/prompts"; 2 3 export function exitOnCancel<T>(value: T | symbol): T { 4 + if (isCancel(value)) { 5 + cancel("Cancelled"); 6 + process.exit(0); 7 + } 8 + return value as T; 9 }
+40 -1
packages/cli/src/lib/types.ts
··· 5 coverImage?: string; // Field name for cover image (default: "ogImage") 6 tags?: string; // Field name for tags (default: "tags") 7 draft?: string; // Field name for draft status (default: "draft") 8 } 9 10 // Strong reference for Bluesky post (com.atproto.repo.strongRef) ··· 31 identity?: string; // Which stored identity to use (matches identifier) 32 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 33 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 34 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 35 } 36 37 - export interface Credentials { 38 pdsUrl: string; 39 identifier: string; 40 password: string; 41 } 42 43 export interface PostFrontmatter { 44 title: string; 45 description?: string; ··· 56 frontmatter: PostFrontmatter; 57 content: string; 58 rawContent: string; 59 } 60 61 export interface BlobRef { ··· 77 contentHash: string; 78 atUri?: string; 79 lastPublished?: string; 80 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 81 } 82
··· 5 coverImage?: string; // Field name for cover image (default: "ogImage") 6 tags?: string; // Field name for tags (default: "tags") 7 draft?: string; // Field name for draft status (default: "draft") 8 + slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath) 9 } 10 11 // Strong reference for Bluesky post (com.atproto.repo.strongRef) ··· 32 identity?: string; // Which stored identity to use (matches identifier) 33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 35 + removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false) 36 + stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false) 37 + textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 38 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 39 } 40 41 + // Legacy credentials format (for backward compatibility during migration) 42 + export interface LegacyCredentials { 43 + pdsUrl: string; 44 + identifier: string; 45 + password: string; 46 + } 47 + 48 + // App password credentials (explicit type) 49 + export interface AppPasswordCredentials { 50 + type: "app-password"; 51 pdsUrl: string; 52 identifier: string; 53 password: string; 54 } 55 56 + // OAuth credentials (references stored OAuth session) 57 + // Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID 58 + export interface OAuthCredentials { 59 + type: "oauth"; 60 + did: string; 61 + handle: string; 62 + } 63 + 64 + // Union type for all credential types 65 + export type Credentials = AppPasswordCredentials | OAuthCredentials; 66 + 67 + // Helper to check credential type 68 + export function isOAuthCredentials( 69 + creds: Credentials, 70 + ): creds is OAuthCredentials { 71 + return creds.type === "oauth"; 72 + } 73 + 74 + export function isAppPasswordCredentials( 75 + creds: Credentials, 76 + ): creds is AppPasswordCredentials { 77 + return creds.type === "app-password"; 78 + } 79 + 80 export interface PostFrontmatter { 81 title: string; 82 description?: string; ··· 93 frontmatter: PostFrontmatter; 94 content: string; 95 rawContent: string; 96 + rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField 97 } 98 99 export interface BlobRef { ··· 115 contentHash: string; 116 atUri?: string; 117 lastPublished?: string; 118 + slug?: string; // The generated slug for this post (used by inject command) 119 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 120 } 121
+20 -20
packages/cli/tsconfig.json
··· 1 { 2 - "compilerOptions": { 3 - "lib": ["ES2022"], 4 - "target": "ES2022", 5 - "module": "ESNext", 6 - "moduleResolution": "bundler", 7 - "outDir": "./dist", 8 - "rootDir": "./src", 9 - "declaration": true, 10 - "sourceMap": true, 11 - "strict": true, 12 - "skipLibCheck": true, 13 - "esModuleInterop": true, 14 - "resolveJsonModule": true, 15 - "forceConsistentCasingInFileNames": true, 16 - "noFallthroughCasesInSwitch": true, 17 - "noUncheckedIndexedAccess": true, 18 - "noUnusedLocals": false, 19 - "noUnusedParameters": false 20 - }, 21 - "include": ["src"] 22 }
··· 1 { 2 + "compilerOptions": { 3 + "lib": ["ES2022"], 4 + "target": "ES2022", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "outDir": "./dist", 8 + "rootDir": "./src", 9 + "declaration": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "skipLibCheck": true, 13 + "esModuleInterop": true, 14 + "resolveJsonModule": true, 15 + "forceConsistentCasingInFileNames": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "noUncheckedIndexedAccess": true, 18 + "noUnusedLocals": false, 19 + "noUnusedParameters": false 20 + }, 21 + "include": ["src"] 22 }