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

Compare changes

Choose any two refs to compare.

+2583 -1339
+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 ."
+83
CHANGELOG.md
··· 1 + ## [0.2.1] - 2026-02-02 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Added CHANGELOG 6 + - Merge main into chore/fronmatter-config-updates 7 + - Added linting and formatting 8 + - Linting updates 9 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 10 + - Version bump 11 + ## [0.2.0] - 2026-02-01 12 + 13 + ### ๐Ÿš€ Features 14 + 15 + - Added bskyPostRef 16 + - Added draft field to frontmatter config 17 + 18 + ### โš™๏ธ Miscellaneous Tasks 19 + 20 + - Resolved action items from issue #3 21 + - Adjusted tags to accept yaml multiline arrays for tags 22 + - Updated inject to handle new slug options 23 + - Updated comments 24 + - Update blog post 25 + - Fix blog build error 26 + - Adjust blog post 27 + - Updated docs 28 + - Version bump 29 + ## [0.1.1] - 2026-01-31 30 + 31 + ### ๐Ÿ› Bug Fixes 32 + 33 + - Fix tangled url to repo 34 + 35 + ### โš™๏ธ Miscellaneous Tasks 36 + 37 + - Merge branch 'main' into feat/blog-post 38 + - Updated blog post 39 + - Updated date 40 + - Added publishing 41 + - Spelling and grammar 42 + - Updated package scripts 43 + - Refactored codebase to use node and fs instead of bun 44 + - Version bump 45 + ## [0.1.0] - 2026-01-30 46 + 47 + ### ๐Ÿš€ Features 48 + 49 + - Init 50 + - Added blog post 51 + 52 + ### โš™๏ธ Miscellaneous Tasks 53 + 54 + - Updated package.json 55 + - Cleaned up commands and libs 56 + - Updated init commands 57 + - Updated greeting 58 + - Updated readme 59 + - Link updates 60 + - Version bump 61 + - Added hugo support through frontmatter parsing 62 + - Version bump 63 + - Updated docs 64 + - Adapted inject.ts pattern 65 + - Updated docs 66 + - Version bump" 67 + - Updated package scripts 68 + - Updated scripts 69 + - Added ignore field to config 70 + - Udpate docs 71 + - Version bump 72 + - Added tags to flow 73 + - Added ability to exit during init flow 74 + - Version bump 75 + - Updated docs 76 + - Updated links 77 + - Updated docs 78 + - Initial refactor 79 + - Checkpoint 80 + - Refactored mapping 81 + - Docs updates 82 + - Docs updates 83 + - Version bump
+86 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.1.0", 27 + "version": "0.2.1", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 33 34 "@clack/prompts": "^1.0.0", 34 35 "cmd-ts": "^0.14.3", 35 36 "glob": "^13.0.0", 36 37 "mime-types": "^2.1.35", 37 38 "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 38 40 }, 39 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 40 43 "@types/mime-types": "^3.0.1", 41 44 "@types/node": "^20", 42 45 }, ··· 48 51 "packages": { 49 52 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 50 53 54 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 55 + 56 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 57 + 58 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 59 + 60 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 61 + 62 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], 63 + 64 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 65 + 66 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 67 + 68 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 69 + 70 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 71 + 51 72 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 52 73 53 74 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 54 75 76 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 77 + 78 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 79 + 80 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 81 + 82 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 83 + 55 84 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 56 85 57 86 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 58 87 59 88 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 89 + 90 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 91 + 92 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 93 + 94 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 60 95 61 96 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 62 97 ··· 104 139 105 140 "@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 141 142 + "@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=="], 143 + 144 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 145 + 146 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 147 + 148 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 149 + 150 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 151 + 152 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 153 + 154 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 155 + 156 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 157 + 158 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 159 + 107 160 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 108 161 109 162 "@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 649 597 650 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 598 651 652 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 653 + 599 654 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 600 655 601 656 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 643 698 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 644 699 645 700 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 701 + 702 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 646 703 647 704 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 648 705 ··· 741 798 "deep-object-diff": ["deep-object-diff@1.1.9", "", {}, "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA=="], 742 799 743 800 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 801 + 802 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 803 + 804 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 805 + 806 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 744 807 745 808 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 746 809 ··· 902 965 903 966 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 904 967 968 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 969 + 905 970 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 906 971 907 972 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 908 973 909 974 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 975 + 976 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 910 977 911 978 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 912 979 980 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 981 + 982 + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], 983 + 913 984 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 914 985 915 986 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 917 988 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 918 989 919 990 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 991 + 992 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 920 993 921 994 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 922 995 ··· 925 998 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 926 999 927 1000 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1001 + 1002 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 928 1003 929 1004 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 930 1005 ··· 1148 1223 1149 1224 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1150 1225 1226 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1227 + 1151 1228 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1152 1229 1153 1230 "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], ··· 1189 1266 "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 1267 1191 1268 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1269 + 1270 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1192 1271 1193 1272 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1194 1273 ··· 1264 1343 1265 1344 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1266 1345 1346 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1347 + 1267 1348 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1268 1349 1269 1350 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1356 1437 1357 1438 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1358 1439 1440 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1441 + 1359 1442 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1360 1443 1361 1444 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1425 1508 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1426 1509 1427 1510 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1511 + 1512 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1428 1513 1429 1514 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1430 1515
+19 -1
docs/docs/pages/cli-reference.mdx
··· 1 1 # CLI Reference 2 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 + 3 19 ## `auth` 4 20 5 21 ```bash [Terminal] 6 22 sequoia auth 7 - > Authenticate with your ATProto PDS 23 + > Authenticate with your ATProto PDS using an app password 8 24 9 25 OPTIONS: 10 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 13 29 --list - List all stored identities [optional] 14 30 --help, -h - show help [optional] 15 31 ``` 32 + 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 16 34 17 35 ## `init` 18 36
+16
docs/docs/pages/config.mdx
··· 14 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 15 | `identity` | `string` | No | - | Which stored identity to use | 16 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 17 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 18 20 | `bluesky` | `object` | No | - | Bluesky posting configuration | 19 21 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 20 22 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 79 81 } 80 82 } 81 83 ``` 84 + 85 + ### Slug Configuration 86 + 87 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 88 + 89 + ```json 90 + { 91 + "frontmatter": { 92 + "slugField": "url" 93 + } 94 + } 95 + ``` 96 + 97 + If the frontmatter field is not found, it falls back to the filepath. 82 98 83 99 ### Ignoring Files 84 100
+9 -7
docs/docs/pages/quickstart.mdx
··· 31 31 sequoia 32 32 ``` 33 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. 34 + ### Login 37 35 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 41 37 42 38 ```bash [Terminal] 43 - sequoia auth 39 + sequoia login 44 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 + ::: 45 47 46 48 ### Initialize 47 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 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.2.0", 3 + "version": "0.2.1", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js" ··· 14 14 ".": "./dist/index.js" 15 15 }, 16 16 "scripts": { 17 + "lint": "biome lint --write", 18 + "format": "biome format --write", 17 19 "build": "bun build src/index.ts --target node --outdir dist", 18 20 "dev": "bun run build && bun link", 19 21 "deploy": "bun run build && bun publish" 20 22 }, 21 23 "devDependencies": { 24 + "@biomejs/biome": "^2.3.13", 22 25 "@types/mime-types": "^3.0.1", 23 26 "@types/node": "^20" 24 27 }, ··· 27 30 }, 28 31 "dependencies": { 29 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 30 34 "@clack/prompts": "^1.0.0", 31 35 "cmd-ts": "^0.14.3", 32 36 "glob": "^13.0.0", 33 37 "mime-types": "^2.1.35", 34 - "minimatch": "^10.1.1" 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0" 35 40 } 36 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 1 import { AtpAgent } from "@atproto/api"; 4 2 import { 5 - saveCredentials, 6 - deleteCredentials, 7 - listCredentials, 8 - getCredentials, 9 - getCredentialsPath, 10 - } from "../lib/credentials"; 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"; 11 12 import { resolveHandleToPDS } from "../lib/atproto"; 13 + import { 14 + deleteCredentials, 15 + getCredentials, 16 + getCredentialsPath, 17 + listCredentials, 18 + saveCredentials, 19 + } from "../lib/credentials"; 12 20 import { exitOnCancel } from "../lib/prompts"; 13 21 14 22 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 - } 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 + } 42 51 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; 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; 47 56 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 - } 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 + } 73 84 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 - } 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 + } 82 93 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 - ); 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 + ); 89 100 90 - const identifier = exitOnCancel(await text({ 91 - message: "Handle or DID:", 92 - placeholder: "yourhandle.bsky.social", 93 - })); 101 + const identifier = exitOnCancel( 102 + await text({ 103 + message: "Handle or DID:", 104 + placeholder: "yourhandle.bsky.social", 105 + }), 106 + ); 94 107 95 - const appPassword = exitOnCancel(await password({ 96 - message: "App Password:", 97 - })); 108 + const appPassword = exitOnCancel( 109 + await password({ 110 + message: "App Password:", 111 + }), 112 + ); 98 113 99 - if (!identifier || !appPassword) { 100 - log.error("Handle and password are required"); 101 - process.exit(1); 102 - } 114 + if (!identifier || !appPassword) { 115 + log.error("Handle and password are required"); 116 + process.exit(1); 117 + } 103 118 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 - } 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 + } 116 133 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 - } 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 + } 129 146 130 - // Verify credentials 131 - s.start("Verifying credentials..."); 147 + // Verify credentials 148 + s.start("Verifying credentials..."); 132 149 133 - try { 134 - const agent = new AtpAgent({ service: pdsUrl }); 135 - await agent.login({ 136 - identifier: identifier, 137 - password: appPassword, 138 - }); 150 + try { 151 + const agent = new AtpAgent({ service: pdsUrl }); 152 + await agent.login({ 153 + identifier: identifier, 154 + password: appPassword, 155 + }); 139 156 140 - s.stop(`Logged in as ${agent.session?.handle}`); 157 + s.stop(`Logged in as ${agent.session?.handle}`); 141 158 142 - // Save credentials 143 - await saveCredentials({ 144 - pdsUrl, 145 - identifier: identifier, 146 - password: appPassword, 147 - }); 159 + // Save credentials 160 + await saveCredentials({ 161 + type: "app-password", 162 + pdsUrl, 163 + identifier: identifier, 164 + password: appPassword, 165 + }); 148 166 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 - }, 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 + }, 156 174 });
+10 -7
packages/cli/src/commands/init.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command } from "cmd-ts"; 3 3 import { 4 4 intro, ··· 11 11 log, 12 12 group, 13 13 } from "@clack/prompts"; 14 - import * as path from "path"; 14 + import * as path from "node:path"; 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 16 import { loadCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; ··· 199 199 200 200 const s = spinner(); 201 201 s.start("Connecting to ATProto..."); 202 - let agent; 202 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 203 203 try { 204 204 agent = await createAgent(credentials); 205 205 s.stop("Connected!"); 206 - } catch (error) { 206 + } catch (_error) { 207 207 s.stop("Failed to connect"); 208 208 log.error( 209 209 "Failed to connect. Check your credentials with 'sequoia auth'.", ··· 287 287 defaultValue: "7", 288 288 placeholder: "7", 289 289 validate: (value) => { 290 - const num = parseInt(value, 10); 291 - if (isNaN(num) || num < 1) { 290 + if (!value) { 291 + return "Please enter a number"; 292 + } 293 + const num = Number.parseInt(value, 10); 294 + if (Number.isNaN(num) || num < 1) { 292 295 return "Please enter a positive number"; 293 296 } 294 297 }, ··· 351 354 if (!gitignoreContent.includes(stateFilename)) { 352 355 await fs.writeFile( 353 356 gitignorePath, 354 - gitignoreContent + `\n${stateFilename}\n`, 357 + `${gitignoreContent}\n${stateFilename}\n`, 355 358 ); 356 359 log.info(`Added ${stateFilename} to .gitignore`); 357 360 }
+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 1 import { log } from "@clack/prompts"; 4 - import * as path from "path"; 2 + import { command, flag, option, optional, string } from "cmd-ts"; 5 3 import { glob } from "glob"; 6 - import { loadConfig, loadState, findConfig } from "../lib/config"; 4 + import * as fs from "node:fs/promises"; 5 + import * as path from "node:path"; 6 + import { findConfig, loadConfig, loadState } from "../lib/config"; 7 7 8 8 export const injectCommand = command({ 9 9 name: "inject", 10 - description: 11 - "Inject site.standard.document link tags into built HTML files", 10 + description: "Inject site.standard.document link tags into built HTML files", 12 11 args: { 13 12 outputDir: option({ 14 13 long: "output", ··· 44 43 // Load state to get atUri mappings 45 44 const state = await loadState(configDir); 46 45 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>(); 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>(); 59 49 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); 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); 80 53 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); 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); 87 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); 88 65 } 89 66 } 90 67 91 - if (pathToAtUri.size === 0) { 68 + if (slugToAtUri.size === 0) { 92 69 log.warn( 93 70 "No published posts found in state. Run 'sequoia publish' first.", 94 71 ); 95 72 return; 96 73 } 97 74 98 - log.info(`Found ${pathToAtUri.size} published posts in state`); 75 + log.info(`Found ${slugToAtUri.size} slug mappings from published posts`); 99 76 100 77 // Scan for HTML files 101 78 const htmlFiles = await glob("**/*.html", { ··· 125 102 let atUri: string | undefined; 126 103 127 104 // Strategy 1: Direct basename match (e.g., my-post.html -> my-post) 128 - atUri = pathToAtUri.get(htmlBasename); 105 + atUri = slugToAtUri.get(htmlBasename); 129 106 130 - // Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post) 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 131 109 if (!atUri && htmlBasename === "index" && htmlDir !== ".") { 132 - const slug = path.basename(htmlDir); 133 - atUri = pathToAtUri.get(slug); 110 + // Try full directory path (for nested subdirectories) 111 + atUri = slugToAtUri.get(htmlDir); 134 112 135 - // Also try parent/slug pattern 113 + // Also try just the last directory segment 136 114 if (!atUri) { 137 - const parentDir = path.dirname(htmlDir); 138 - if (parentDir !== ".") { 139 - atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`); 140 - } 115 + const lastDir = path.basename(htmlDir); 116 + atUri = slugToAtUri.get(lastDir); 141 117 } 142 118 } 143 119 144 120 // Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post) 145 121 if (!atUri && htmlDir !== ".") { 146 - atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`); 122 + atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`); 147 123 } 148 124 149 125 if (!atUri) {
+303
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 + } from "../lib/oauth-store"; 15 + import { exitOnCancel } from "../lib/prompts"; 16 + 17 + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 18 + 19 + export const loginCommand = command({ 20 + name: "login", 21 + description: "Login with OAuth (browser-based authentication)", 22 + args: { 23 + logout: option({ 24 + long: "logout", 25 + description: "Remove OAuth session for a specific DID", 26 + type: optional(string), 27 + }), 28 + list: flag({ 29 + long: "list", 30 + description: "List all stored OAuth sessions", 31 + }), 32 + }, 33 + handler: async ({ logout, list }) => { 34 + // List sessions 35 + if (list) { 36 + const sessions = await listOAuthSessions(); 37 + if (sessions.length === 0) { 38 + log.info("No OAuth sessions stored"); 39 + } else { 40 + log.info("OAuth sessions:"); 41 + for (const did of sessions) { 42 + console.log(` - ${did}`); 43 + } 44 + } 45 + return; 46 + } 47 + 48 + // Logout 49 + if (logout !== undefined) { 50 + const did = logout || undefined; 51 + 52 + if (!did) { 53 + // No DID provided - show available and prompt 54 + const sessions = await listOAuthSessions(); 55 + if (sessions.length === 0) { 56 + log.info("No OAuth sessions found"); 57 + return; 58 + } 59 + if (sessions.length === 1) { 60 + const deleted = await deleteOAuthSession(sessions[0]!); 61 + if (deleted) { 62 + log.success(`Removed OAuth session for ${sessions[0]}`); 63 + } 64 + return; 65 + } 66 + // Multiple sessions - prompt 67 + const selected = exitOnCancel( 68 + await select({ 69 + message: "Select session to remove:", 70 + options: sessions.map((d) => ({ value: d, label: d })), 71 + }), 72 + ); 73 + const deleted = await deleteOAuthSession(selected); 74 + if (deleted) { 75 + log.success(`Removed OAuth session for ${selected}`); 76 + } 77 + return; 78 + } 79 + 80 + const deleted = await deleteOAuthSession(did); 81 + if (deleted) { 82 + log.success(`Removed OAuth session for ${did}`); 83 + } else { 84 + log.info(`No OAuth session found for ${did}`); 85 + } 86 + return; 87 + } 88 + 89 + // OAuth login flow 90 + note( 91 + "OAuth login will open your browser to authenticate.\n\n" + 92 + "This is more secure than app passwords and tokens refresh automatically.", 93 + "OAuth Login", 94 + ); 95 + 96 + const handle = exitOnCancel( 97 + await text({ 98 + message: "Handle or DID:", 99 + placeholder: "yourhandle.bsky.social", 100 + }), 101 + ); 102 + 103 + if (!handle) { 104 + log.error("Handle is required"); 105 + process.exit(1); 106 + } 107 + 108 + const s = spinner(); 109 + s.start("Resolving identity..."); 110 + 111 + let did: string; 112 + try { 113 + did = await resolveHandleToDid(handle); 114 + s.stop(`Identity resolved`); 115 + } catch (error) { 116 + s.stop("Failed to resolve identity"); 117 + if (error instanceof Error) { 118 + log.error(`Error: ${error.message}`); 119 + } else { 120 + log.error(`Error: ${error}`); 121 + } 122 + process.exit(1); 123 + } 124 + 125 + s.start("Initializing OAuth..."); 126 + 127 + try { 128 + const client = await getOAuthClient(); 129 + 130 + // Generate authorization URL using the resolved DID 131 + const authUrl = await client.authorize(did, { 132 + scope: getOAuthScope(), 133 + }); 134 + 135 + log.info(`Login URL: ${authUrl}`); 136 + 137 + s.message("Opening browser..."); 138 + 139 + // Try to open browser 140 + let browserOpened = true; 141 + try { 142 + const open = (await import("open")).default; 143 + await open(authUrl.toString()); 144 + } catch { 145 + browserOpened = false; 146 + } 147 + 148 + s.message("Waiting for authentication..."); 149 + 150 + // Show URL info 151 + if (!browserOpened) { 152 + s.stop("Could not open browser automatically"); 153 + log.warn("Please open the following URL in your browser:"); 154 + log.info(authUrl.toString()); 155 + s.start("Waiting for authentication..."); 156 + } 157 + 158 + // Start HTTP server to receive callback 159 + const result = await waitForCallback(); 160 + 161 + if (!result.success) { 162 + s.stop("Authentication failed"); 163 + log.error(result.error || "OAuth callback failed"); 164 + process.exit(1); 165 + } 166 + 167 + s.message("Completing authentication..."); 168 + 169 + // Exchange code for tokens 170 + const { session } = await client.callback( 171 + new URLSearchParams(result.params!), 172 + ); 173 + 174 + // Try to get the handle for display (use the original handle input as fallback) 175 + let displayName = handle; 176 + try { 177 + // The session should have the DID, we can use the original handle they entered 178 + // or we could fetch the profile to get the current handle 179 + displayName = handle.startsWith("did:") ? session.did : handle; 180 + } catch { 181 + displayName = session.did; 182 + } 183 + 184 + s.stop(`Logged in as ${displayName}`); 185 + 186 + log.success(`OAuth session saved to ${getOAuthStorePath()}`); 187 + log.info("Your session will refresh automatically when needed."); 188 + 189 + // Exit cleanly - the OAuth client may have background processes 190 + process.exit(0); 191 + } catch (error) { 192 + s.stop("OAuth login failed"); 193 + if (error instanceof Error) { 194 + log.error(`Error: ${error.message}`); 195 + } else { 196 + log.error(`Error: ${error}`); 197 + } 198 + process.exit(1); 199 + } 200 + }, 201 + }); 202 + 203 + interface CallbackResult { 204 + success: boolean; 205 + params?: Record<string, string>; 206 + error?: string; 207 + } 208 + 209 + function waitForCallback(): Promise<CallbackResult> { 210 + return new Promise((resolve) => { 211 + const port = getCallbackPort(); 212 + let timeoutId: ReturnType<typeof setTimeout> | undefined; 213 + 214 + const server = http.createServer((req, res) => { 215 + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); 216 + 217 + if (url.pathname === "/oauth/callback") { 218 + const params: Record<string, string> = {}; 219 + url.searchParams.forEach((value, key) => { 220 + params[key] = value; 221 + }); 222 + 223 + // Clear the timeout 224 + if (timeoutId) clearTimeout(timeoutId); 225 + 226 + // Check for error 227 + if (params.error) { 228 + res.writeHead(200, { "Content-Type": "text/html" }); 229 + res.end(` 230 + <html> 231 + <head> 232 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 233 + </head> 234 + <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;"> 235 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 236 + <h1 style="font-weight: 400;">Authentication Failed</h1> 237 + <p>${params.error_description || params.error}</p> 238 + <p>You can close this window.</p> 239 + </body> 240 + </html> 241 + `); 242 + server.close(() => { 243 + resolve({ 244 + success: false, 245 + error: params.error_description || params.error, 246 + }); 247 + }); 248 + return; 249 + } 250 + 251 + // Success 252 + res.writeHead(200, { "Content-Type": "text/html" }); 253 + res.end(` 254 + <html> 255 + <head> 256 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 257 + </head> 258 + <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;"> 259 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 260 + <h1 style="font-weight: 400;">Authentication Successful</h1> 261 + <p>You can close this window and return to the terminal.</p> 262 + </body> 263 + </html> 264 + `); 265 + server.close(() => { 266 + resolve({ success: true, params }); 267 + }); 268 + return; 269 + } 270 + 271 + // Not the callback path 272 + res.writeHead(404); 273 + res.end("Not found"); 274 + }); 275 + 276 + server.on("error", (err: NodeJS.ErrnoException) => { 277 + if (timeoutId) clearTimeout(timeoutId); 278 + if (err.code === "EADDRINUSE") { 279 + resolve({ 280 + success: false, 281 + error: `Port ${port} is already in use. Please close the application using that port and try again.`, 282 + }); 283 + } else { 284 + resolve({ 285 + success: false, 286 + error: `Server error: ${err.message}`, 287 + }); 288 + } 289 + }); 290 + 291 + server.listen(port, "127.0.0.1"); 292 + 293 + // Timeout after 5 minutes 294 + timeoutId = setTimeout(() => { 295 + server.close(() => { 296 + resolve({ 297 + success: false, 298 + error: "Timeout waiting for OAuth callback. Please try again.", 299 + }); 300 + }); 301 + }, CALLBACK_TIMEOUT_MS); 302 + }); 303 + }
+305 -270
packages/cli/src/commands/publish.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command, flag } from "cmd-ts"; 3 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 4 + import * as path from "node:path"; 5 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 6 import { 9 - scanContentDirectory, 10 - getContentHash, 11 - updateFrontmatterWithAtUri, 7 + loadCredentials, 8 + listCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { 12 + createAgent, 13 + createDocument, 14 + updateDocument, 15 + uploadImage, 16 + resolveImagePath, 17 + createBlueskyPost, 18 + addBskyPostRefToDocument, 19 + } from "../lib/atproto"; 20 + import { 21 + scanContentDirectory, 22 + getContentHash, 23 + updateFrontmatterWithAtUri, 12 24 } from "../lib/markdown"; 13 25 import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 14 26 import { exitOnCancel } from "../lib/prompts"; 15 27 16 28 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 - } 29 + name: "publish", 30 + description: "Publish content to ATProto", 31 + args: { 32 + force: flag({ 33 + long: "force", 34 + short: "f", 35 + description: "Force publish all posts, ignoring change detection", 36 + }), 37 + dryRun: flag({ 38 + long: "dry-run", 39 + short: "n", 40 + description: "Preview what would be published without making changes", 41 + }), 42 + }, 43 + handler: async ({ force, dryRun }) => { 44 + // Load config 45 + const configPath = await findConfig(); 46 + if (!configPath) { 47 + log.error("No publisher.config.ts found. Run 'publisher init' first."); 48 + process.exit(1); 49 + } 38 50 39 - const config = await loadConfig(configPath); 40 - const configDir = path.dirname(configPath); 51 + const config = await loadConfig(configPath); 52 + const configDir = path.dirname(configPath); 41 53 42 - log.info(`Site: ${config.siteUrl}`); 43 - log.info(`Content directory: ${config.contentDir}`); 54 + log.info(`Site: ${config.siteUrl}`); 55 + log.info(`Content directory: ${config.contentDir}`); 44 56 45 - // Load credentials 46 - let credentials = await loadCredentials(config.identity); 57 + // Load credentials 58 + let credentials = await loadCredentials(config.identity); 47 59 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 - } 60 + // If no credentials resolved, check if we need to prompt for identity selection 61 + if (!credentials) { 62 + const identities = await listCredentials(); 63 + if (identities.length === 0) { 64 + log.error("No credentials found. Run 'sequoia auth' first."); 65 + log.info( 66 + "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 67 + ); 68 + process.exit(1); 69 + } 56 70 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 - })); 71 + // Multiple identities exist but none selected - prompt user 72 + log.info("Multiple identities found. Select one to use:"); 73 + const selected = exitOnCancel( 74 + await select({ 75 + message: "Identity:", 76 + options: identities.map((id) => ({ value: id, label: id })), 77 + }), 78 + ); 63 79 64 - credentials = await getCredentials(selected); 65 - if (!credentials) { 66 - log.error("Failed to load selected credentials."); 67 - process.exit(1); 68 - } 80 + credentials = await getCredentials(selected); 81 + if (!credentials) { 82 + log.error("Failed to load selected credentials."); 83 + process.exit(1); 84 + } 69 85 70 - log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`); 71 - } 86 + log.info( 87 + `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`, 88 + ); 89 + } 72 90 73 - // Resolve content directory 74 - const contentDir = path.isAbsolute(config.contentDir) 75 - ? config.contentDir 76 - : path.join(configDir, config.contentDir); 91 + // Resolve content directory 92 + const contentDir = path.isAbsolute(config.contentDir) 93 + ? config.contentDir 94 + : path.join(configDir, config.contentDir); 77 95 78 - const imagesDir = config.imagesDir 79 - ? path.isAbsolute(config.imagesDir) 80 - ? config.imagesDir 81 - : path.join(configDir, config.imagesDir) 82 - : undefined; 96 + const imagesDir = config.imagesDir 97 + ? path.isAbsolute(config.imagesDir) 98 + ? config.imagesDir 99 + : path.join(configDir, config.imagesDir) 100 + : undefined; 83 101 84 - // Load state 85 - const state = await loadState(configDir); 102 + // Load state 103 + const state = await loadState(configDir); 86 104 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`); 105 + // Scan for posts 106 + const s = spinner(); 107 + s.start("Scanning for posts..."); 108 + const posts = await scanContentDirectory(contentDir, { 109 + frontmatterMapping: config.frontmatter, 110 + ignorePatterns: config.ignore, 111 + slugField: config.frontmatter?.slugField, 112 + removeIndexFromSlug: config.removeIndexFromSlug, 113 + }); 114 + s.stop(`Found ${posts.length} posts`); 92 115 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[] = []; 116 + // Determine which posts need publishing 117 + const postsToPublish: Array<{ 118 + post: BlogPost; 119 + action: "create" | "update"; 120 + reason: string; 121 + }> = []; 122 + const draftPosts: BlogPost[] = []; 100 123 101 - for (const post of posts) { 102 - // Skip draft posts 103 - if (post.frontmatter.draft) { 104 - draftPosts.push(post); 105 - continue; 106 - } 124 + for (const post of posts) { 125 + // Skip draft posts 126 + if (post.frontmatter.draft) { 127 + draftPosts.push(post); 128 + continue; 129 + } 107 130 108 - const contentHash = await getContentHash(post.rawContent); 109 - const relativeFilePath = path.relative(configDir, post.filePath); 110 - const postState = state.posts[relativeFilePath]; 131 + const contentHash = await getContentHash(post.rawContent); 132 + const relativeFilePath = path.relative(configDir, post.filePath); 133 + const postState = state.posts[relativeFilePath]; 111 134 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 - } 135 + if (force) { 136 + postsToPublish.push({ 137 + post, 138 + action: post.frontmatter.atUri ? "update" : "create", 139 + reason: "forced", 140 + }); 141 + } else if (!postState) { 142 + // New post 143 + postsToPublish.push({ 144 + post, 145 + action: "create", 146 + reason: "new post", 147 + }); 148 + } else if (postState.contentHash !== contentHash) { 149 + // Changed post 150 + postsToPublish.push({ 151 + post, 152 + action: post.frontmatter.atUri ? "update" : "create", 153 + reason: "content changed", 154 + }); 155 + } 156 + } 134 157 135 - if (draftPosts.length > 0) { 136 - log.info(`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`); 137 - } 158 + if (draftPosts.length > 0) { 159 + log.info( 160 + `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, 161 + ); 162 + } 138 163 139 - if (postsToPublish.length === 0) { 140 - log.success("All posts are up to date. Nothing to publish."); 141 - return; 142 - } 164 + if (postsToPublish.length === 0) { 165 + log.success("All posts are up to date. Nothing to publish."); 166 + return; 167 + } 143 168 144 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 169 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 145 170 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); 171 + // Bluesky posting configuration 172 + const blueskyEnabled = config.bluesky?.enabled ?? false; 173 + const maxAgeDays = config.bluesky?.maxAgeDays ?? 7; 174 + const cutoffDate = new Date(); 175 + cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays); 151 176 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; 177 + for (const { post, action, reason } of postsToPublish) { 178 + const icon = action === "create" ? "+" : "~"; 179 + const relativeFilePath = path.relative(configDir, post.filePath); 180 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 156 181 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 - } 182 + let bskyNote = ""; 183 + if (blueskyEnabled) { 184 + if (existingBskyPostRef) { 185 + bskyNote = " [bsky: exists]"; 186 + } else { 187 + const publishDate = new Date(post.frontmatter.publishDate); 188 + if (publishDate < cutoffDate) { 189 + bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`; 190 + } else { 191 + bskyNote = " [bsky: will post]"; 192 + } 193 + } 194 + } 170 195 171 - log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 172 - } 196 + log.message(` ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`); 197 + } 173 198 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 - } 199 + if (dryRun) { 200 + if (blueskyEnabled) { 201 + log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`); 202 + } 203 + log.info("\nDry run complete. No changes made."); 204 + return; 205 + } 181 206 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 - } 207 + // Create agent 208 + s.start(`Connecting to ${credentials.pdsUrl}...`); 209 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 210 + try { 211 + agent = await createAgent(credentials); 212 + s.stop(`Logged in as ${agent.did}`); 213 + } catch (error) { 214 + s.stop("Failed to login"); 215 + log.error(`Failed to login: ${error}`); 216 + process.exit(1); 217 + } 193 218 194 - // Publish posts 195 - let publishedCount = 0; 196 - let updatedCount = 0; 197 - let errorCount = 0; 198 - let bskyPostCount = 0; 219 + // Publish posts 220 + let publishedCount = 0; 221 + let updatedCount = 0; 222 + let errorCount = 0; 223 + let bskyPostCount = 0; 199 224 200 - for (const { post, action } of postsToPublish) { 201 - s.start(`Publishing: ${post.frontmatter.title}`); 225 + for (const { post, action } of postsToPublish) { 226 + s.start(`Publishing: ${post.frontmatter.title}`); 202 227 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 - ); 228 + try { 229 + // Handle cover image upload 230 + let coverImage: BlobObject | undefined; 231 + if (post.frontmatter.ogImage) { 232 + const imagePath = await resolveImagePath( 233 + post.frontmatter.ogImage, 234 + imagesDir, 235 + contentDir, 236 + ); 212 237 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 - } 238 + if (imagePath) { 239 + log.info(` Uploading cover image: ${path.basename(imagePath)}`); 240 + coverImage = await uploadImage(agent, imagePath); 241 + if (coverImage) { 242 + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 243 + } 244 + } else { 245 + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 246 + } 247 + } 223 248 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); 249 + // Track atUri, content for state saving, and bskyPostRef 250 + let atUri: string; 251 + let contentForHash: string; 252 + let bskyPostRef: StrongRef | undefined; 253 + const relativeFilePath = path.relative(configDir, post.filePath); 229 254 230 - // Check if bskyPostRef already exists in state 231 - const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 255 + // Check if bskyPostRef already exists in state 256 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 232 257 233 - if (action === "create") { 234 - atUri = await createDocument(agent, post, config, coverImage); 235 - s.stop(`Created: ${atUri}`); 258 + if (action === "create") { 259 + atUri = await createDocument(agent, post, config, coverImage); 260 + s.stop(`Created: ${atUri}`); 236 261 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)}`); 262 + // Update frontmatter with atUri 263 + const updatedContent = updateFrontmatterWithAtUri( 264 + post.rawContent, 265 + atUri, 266 + ); 267 + await fs.writeFile(post.filePath, updatedContent); 268 + log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 241 269 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}`); 270 + // Use updated content (with atUri) for hash so next run sees matching hash 271 + contentForHash = updatedContent; 272 + publishedCount++; 273 + } else { 274 + atUri = post.frontmatter.atUri!; 275 + await updateDocument(agent, post, atUri, config, coverImage); 276 + s.stop(`Updated: ${atUri}`); 249 277 250 - // For updates, rawContent already has atUri 251 - contentForHash = post.rawContent; 252 - updatedCount++; 253 - } 278 + // For updates, rawContent already has atUri 279 + contentForHash = post.rawContent; 280 + updatedCount++; 281 + } 254 282 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); 283 + // Create Bluesky post if enabled and conditions are met 284 + if (blueskyEnabled) { 285 + if (existingBskyPostRef) { 286 + log.info(` Bluesky post already exists, skipping`); 287 + bskyPostRef = existingBskyPostRef; 288 + } else { 289 + const publishDate = new Date(post.frontmatter.publishDate); 262 290 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}`; 291 + if (publishDate < cutoffDate) { 292 + log.info( 293 + ` Post is older than ${maxAgeDays} days, skipping Bluesky post`, 294 + ); 295 + } else { 296 + // Create Bluesky post 297 + try { 298 + const pathPrefix = config.pathPrefix || "/posts"; 299 + const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`; 270 300 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 - }); 301 + bskyPostRef = await createBlueskyPost(agent, { 302 + title: post.frontmatter.title, 303 + description: post.frontmatter.description, 304 + canonicalUrl, 305 + coverImage, 306 + publishedAt: post.frontmatter.publishDate, 307 + }); 278 308 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 - } 309 + // Update document record with bskyPostRef 310 + await addBskyPostRefToDocument(agent, atUri, bskyPostRef); 311 + log.info(` Created Bluesky post: ${bskyPostRef.uri}`); 312 + bskyPostCount++; 313 + } catch (bskyError) { 314 + const errorMsg = 315 + bskyError instanceof Error 316 + ? bskyError.message 317 + : String(bskyError); 318 + log.warn(` Failed to create Bluesky post: ${errorMsg}`); 319 + } 320 + } 321 + } 322 + } 290 323 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 - } 324 + // Update state (use relative path from config directory) 325 + const contentHash = await getContentHash(contentForHash); 326 + state.posts[relativeFilePath] = { 327 + contentHash, 328 + atUri, 329 + lastPublished: new Date().toISOString(), 330 + slug: post.slug, 331 + bskyPostRef, 332 + }; 333 + } catch (error) { 334 + const errorMessage = 335 + error instanceof Error ? error.message : String(error); 336 + s.stop(`Error publishing "${path.basename(post.filePath)}"`); 337 + log.error(` ${errorMessage}`); 338 + errorCount++; 339 + } 340 + } 306 341 307 - // Save state 308 - await saveState(configDir, state); 342 + // Save state 343 + await saveState(configDir, state); 309 344 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 - }, 345 + // Summary 346 + log.message("\n---"); 347 + log.info(`Published: ${publishedCount}`); 348 + log.info(`Updated: ${updatedCount}`); 349 + if (bskyPostCount > 0) { 350 + log.info(`Bluesky posts: ${bskyPostCount}`); 351 + } 352 + if (errorCount > 0) { 353 + log.warn(`Errors: ${errorCount}`); 354 + } 355 + }, 321 356 });
+171 -151
packages/cli/src/commands/sync.ts
··· 1 - import * as fs from "fs/promises"; 1 + import * as fs from "node:fs/promises"; 2 2 import { command, flag } from "cmd-ts"; 3 3 import { select, spinner, log } from "@clack/prompts"; 4 - import * as path from "path"; 4 + import * as path from "node:path"; 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 6 + import { 7 + loadCredentials, 8 + listCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 7 11 import { createAgent, listDocuments } from "../lib/atproto"; 8 - import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; 12 + import { 13 + scanContentDirectory, 14 + getContentHash, 15 + updateFrontmatterWithAtUri, 16 + } from "../lib/markdown"; 9 17 import { exitOnCancel } from "../lib/prompts"; 10 18 11 19 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 - } 20 + name: "sync", 21 + description: "Sync state from ATProto to restore .sequoia-state.json", 22 + args: { 23 + updateFrontmatter: flag({ 24 + long: "update-frontmatter", 25 + short: "u", 26 + description: "Update frontmatter atUri fields in local markdown files", 27 + }), 28 + dryRun: flag({ 29 + long: "dry-run", 30 + short: "n", 31 + description: "Preview what would be synced without making changes", 32 + }), 33 + }, 34 + handler: async ({ updateFrontmatter, dryRun }) => { 35 + // Load config 36 + const configPath = await findConfig(); 37 + if (!configPath) { 38 + log.error("No sequoia.json found. Run 'sequoia init' first."); 39 + process.exit(1); 40 + } 33 41 34 - const config = await loadConfig(configPath); 35 - const configDir = path.dirname(configPath); 42 + const config = await loadConfig(configPath); 43 + const configDir = path.dirname(configPath); 36 44 37 - log.info(`Site: ${config.siteUrl}`); 38 - log.info(`Publication: ${config.publicationUri}`); 45 + log.info(`Site: ${config.siteUrl}`); 46 + log.info(`Publication: ${config.publicationUri}`); 39 47 40 - // Load credentials 41 - let credentials = await loadCredentials(config.identity); 48 + // Load credentials 49 + let credentials = await loadCredentials(config.identity); 42 50 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 - } 51 + if (!credentials) { 52 + const identities = await listCredentials(); 53 + if (identities.length === 0) { 54 + log.error("No credentials found. Run 'sequoia auth' first."); 55 + process.exit(1); 56 + } 49 57 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 - })); 58 + log.info("Multiple identities found. Select one to use:"); 59 + const selected = exitOnCancel( 60 + await select({ 61 + message: "Identity:", 62 + options: identities.map((id) => ({ value: id, label: id })), 63 + }), 64 + ); 55 65 56 - credentials = await getCredentials(selected); 57 - if (!credentials) { 58 - log.error("Failed to load selected credentials."); 59 - process.exit(1); 60 - } 61 - } 66 + credentials = await getCredentials(selected); 67 + if (!credentials) { 68 + log.error("Failed to load selected credentials."); 69 + process.exit(1); 70 + } 71 + } 62 72 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 - } 73 + // Create agent 74 + const s = spinner(); 75 + s.start(`Connecting to ${credentials.pdsUrl}...`); 76 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 77 + try { 78 + agent = await createAgent(credentials); 79 + s.stop(`Logged in as ${agent.did}`); 80 + } catch (error) { 81 + s.stop("Failed to login"); 82 + log.error(`Failed to login: ${error}`); 83 + process.exit(1); 84 + } 75 85 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`); 86 + // Fetch documents from PDS 87 + s.start("Fetching documents from PDS..."); 88 + const documents = await listDocuments(agent, config.publicationUri); 89 + s.stop(`Found ${documents.length} documents on PDS`); 80 90 81 - if (documents.length === 0) { 82 - log.info("No documents found for this publication."); 83 - return; 84 - } 91 + if (documents.length === 0) { 92 + log.info("No documents found for this publication."); 93 + return; 94 + } 85 95 86 - // Resolve content directory 87 - const contentDir = path.isAbsolute(config.contentDir) 88 - ? config.contentDir 89 - : path.join(configDir, config.contentDir); 96 + // Resolve content directory 97 + const contentDir = path.isAbsolute(config.contentDir) 98 + ? config.contentDir 99 + : path.join(configDir, config.contentDir); 90 100 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`); 101 + // Scan local posts 102 + s.start("Scanning local content..."); 103 + const localPosts = await scanContentDirectory(contentDir, { 104 + frontmatterMapping: config.frontmatter, 105 + ignorePatterns: config.ignore, 106 + slugField: config.frontmatter?.slugField, 107 + removeIndexFromSlug: config.removeIndexFromSlug, 108 + }); 109 + s.stop(`Found ${localPosts.length} local posts`); 95 110 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 - } 111 + // Build a map of path -> local post for matching 112 + // Document path is like /posts/my-post-slug (or custom pathPrefix) 113 + const pathPrefix = config.pathPrefix || "/posts"; 114 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 115 + for (const post of localPosts) { 116 + const postPath = `${pathPrefix}/${post.slug}`; 117 + postsByPath.set(postPath, post); 118 + } 103 119 104 - // Load existing state 105 - const state = await loadState(configDir); 106 - const originalPostCount = Object.keys(state.posts).length; 120 + // Load existing state 121 + const state = await loadState(configDir); 122 + const originalPostCount = Object.keys(state.posts).length; 107 123 108 - // Track changes 109 - let matchedCount = 0; 110 - let unmatchedCount = 0; 111 - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 124 + // Track changes 125 + let matchedCount = 0; 126 + let unmatchedCount = 0; 127 + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 112 128 113 - log.message("\nMatching documents to local files:\n"); 129 + log.message("\nMatching documents to local files:\n"); 114 130 115 - for (const doc of documents) { 116 - const docPath = doc.value.path; 117 - const localPost = postsByPath.get(docPath); 131 + for (const doc of documents) { 132 + const docPath = doc.value.path; 133 + const localPost = postsByPath.get(docPath); 118 134 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)}`); 135 + if (localPost) { 136 + matchedCount++; 137 + log.message(` โœ“ ${doc.value.title}`); 138 + log.message(` Path: ${docPath}`); 139 + log.message(` URI: ${doc.uri}`); 140 + log.message(` File: ${path.basename(localPost.filePath)}`); 125 141 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 - }; 142 + // Update state (use relative path from config directory) 143 + const contentHash = await getContentHash(localPost.rawContent); 144 + const relativeFilePath = path.relative(configDir, localPost.filePath); 145 + state.posts[relativeFilePath] = { 146 + contentHash, 147 + atUri: doc.uri, 148 + lastPublished: doc.value.publishedAt, 149 + }; 134 150 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 + // Check if frontmatter needs updating 152 + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 153 + frontmatterUpdates.push({ 154 + filePath: localPost.filePath, 155 + atUri: doc.uri, 156 + }); 157 + log.message(` โ†’ Will update frontmatter`); 158 + } 159 + } else { 160 + unmatchedCount++; 161 + log.message(` โœ— ${doc.value.title} (no matching local file)`); 162 + log.message(` Path: ${docPath}`); 163 + log.message(` URI: ${doc.uri}`); 164 + } 165 + log.message(""); 166 + } 151 167 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 - } 168 + // Summary 169 + log.message("---"); 170 + log.info(`Matched: ${matchedCount} documents`); 171 + if (unmatchedCount > 0) { 172 + log.warn( 173 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 174 + ); 175 + } 158 176 159 - if (dryRun) { 160 - log.info("\nDry run complete. No changes made."); 161 - return; 162 - } 177 + if (dryRun) { 178 + log.info("\nDry run complete. No changes made."); 179 + return; 180 + } 163 181 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)`); 182 + // Save updated state 183 + await saveState(configDir, state); 184 + const newPostCount = Object.keys(state.posts).length; 185 + log.success( 186 + `\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`, 187 + ); 168 188 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 - } 189 + // Update frontmatter if requested 190 + if (frontmatterUpdates.length > 0) { 191 + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 192 + for (const { filePath, atUri } of frontmatterUpdates) { 193 + const content = await fs.readFile(filePath, "utf-8"); 194 + const updated = updateFrontmatterWithAtUri(content, atUri); 195 + await fs.writeFile(filePath, updated); 196 + log.message(` Updated: ${path.basename(filePath)}`); 197 + } 198 + s.stop("Frontmatter updated"); 199 + } 180 200 181 - log.success("\nSync complete!"); 182 - }, 201 + log.success("\nSync complete!"); 202 + }, 183 203 });
+3 -1
packages/cli/src/index.ts
··· 4 4 import { authCommand } from "./commands/auth"; 5 5 import { initCommand } from "./commands/init"; 6 6 import { injectCommand } from "./commands/inject"; 7 + import { loginCommand } from "./commands/login"; 7 8 import { publishCommand } from "./commands/publish"; 8 9 import { syncCommand } from "./commands/sync"; 9 10 ··· 33 34 34 35 > https://tangled.org/stevedylan.dev/sequoia 35 36 `, 36 - version: "0.2.0", 37 + version: "0.2.1", 37 38 cmds: { 38 39 auth: authCommand, 39 40 init: initCommand, 40 41 inject: injectCommand, 42 + login: loginCommand, 41 43 publish: publishCommand, 42 44 sync: syncCommand, 43 45 },
+500 -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"; 1 + import { Agent, AtpAgent } from "@atproto/api"; 4 2 import * as mimeTypes from "mime-types"; 5 - import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types"; 3 + import * as fs from "node:fs/promises"; 4 + import * as path from "node:path"; 6 5 import { stripMarkdownForText } from "./markdown"; 6 + import { getOAuthClient } from "./oauth-client"; 7 + import type { 8 + BlobObject, 9 + BlogPost, 10 + Credentials, 11 + PublisherConfig, 12 + StrongRef, 13 + } from "./types"; 14 + import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 15 + 16 + /** 17 + * Type guard to check if a record value is a DocumentRecord 18 + */ 19 + function isDocumentRecord(value: unknown): value is DocumentRecord { 20 + if (!value || typeof value !== "object") return false; 21 + const v = value as Record<string, unknown>; 22 + return ( 23 + v.$type === "site.standard.document" && 24 + typeof v.title === "string" && 25 + typeof v.site === "string" && 26 + typeof v.path === "string" && 27 + typeof v.textContent === "string" && 28 + typeof v.publishedAt === "string" 29 + ); 30 + } 7 31 8 32 async function fileExists(filePath: string): Promise<boolean> { 9 - try { 10 - await fs.access(filePath); 11 - return true; 12 - } catch { 13 - return false; 14 - } 33 + try { 34 + await fs.access(filePath); 35 + return true; 36 + } catch { 37 + return false; 38 + } 39 + } 40 + 41 + /** 42 + * Resolve a handle to a DID 43 + */ 44 + export async function resolveHandleToDid(handle: string): Promise<string> { 45 + if (handle.startsWith("did:")) { 46 + return handle; 47 + } 48 + 49 + // Try to resolve handle via Bluesky API 50 + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 51 + const resolveResponse = await fetch(resolveUrl); 52 + if (!resolveResponse.ok) { 53 + throw new Error("Could not resolve handle"); 54 + } 55 + const resolveData = (await resolveResponse.json()) as { did: string }; 56 + return resolveData.did; 15 57 } 16 58 17 59 export async function resolveHandleToPDS(handle: string): Promise<string> { 18 - // First, resolve the handle to a DID 19 - let did: string; 60 + // First, resolve the handle to a DID 61 + const did = await resolveHandleToDid(handle); 20 62 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 - } 63 + // Now resolve the DID to get the PDS URL from the DID document 64 + let pdsUrl: string | undefined; 33 65 34 - // Now resolve the DID to get the PDS URL from the DID document 35 - let pdsUrl: string | undefined; 66 + if (did.startsWith("did:plc:")) { 67 + // Fetch DID document from plc.directory 68 + const didDocUrl = `https://plc.directory/${did}`; 69 + const didDocResponse = await fetch(didDocUrl); 70 + if (!didDocResponse.ok) { 71 + throw new Error("Could not fetch DID document"); 72 + } 73 + const didDoc = (await didDocResponse.json()) as { 74 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 75 + }; 36 76 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 - }; 77 + // Find the PDS service endpoint 78 + const pdsService = didDoc.service?.find( 79 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 80 + ); 81 + pdsUrl = pdsService?.serviceEndpoint; 82 + } else if (did.startsWith("did:web:")) { 83 + // For did:web, fetch the DID document from the domain 84 + const domain = did.replace("did:web:", ""); 85 + const didDocUrl = `https://${domain}/.well-known/did.json`; 86 + const didDocResponse = await fetch(didDocUrl); 87 + if (!didDocResponse.ok) { 88 + throw new Error("Could not fetch DID document"); 89 + } 90 + const didDoc = (await didDocResponse.json()) as { 91 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 92 + }; 47 93 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 - } 94 + const pdsService = didDoc.service?.find( 95 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 96 + ); 97 + pdsUrl = pdsService?.serviceEndpoint; 98 + } 70 99 71 - if (!pdsUrl) { 72 - throw new Error("Could not find PDS URL for user"); 73 - } 100 + if (!pdsUrl) { 101 + throw new Error("Could not find PDS URL for user"); 102 + } 74 103 75 - return pdsUrl; 104 + return pdsUrl; 76 105 } 77 106 78 107 export interface CreatePublicationOptions { 79 - url: string; 80 - name: string; 81 - description?: string; 82 - iconPath?: string; 83 - showInDiscover?: boolean; 108 + url: string; 109 + name: string; 110 + description?: string; 111 + iconPath?: string; 112 + showInDiscover?: boolean; 84 113 } 85 114 86 - export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 87 - const agent = new AtpAgent({ service: credentials.pdsUrl }); 115 + export async function createAgent(credentials: Credentials): Promise<Agent> { 116 + if (isOAuthCredentials(credentials)) { 117 + // OAuth flow - restore session from stored tokens 118 + const client = await getOAuthClient(); 119 + try { 120 + const oauthSession = await client.restore(credentials.did); 121 + // Wrap the OAuth session in an Agent which provides the atproto API 122 + return new Agent(oauthSession); 123 + } catch (error) { 124 + if (error instanceof Error) { 125 + // Check for common OAuth errors 126 + if ( 127 + error.message.includes("expired") || 128 + error.message.includes("revoked") 129 + ) { 130 + throw new Error( 131 + `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 132 + ); 133 + } 134 + } 135 + throw error; 136 + } 137 + } 88 138 89 - await agent.login({ 90 - identifier: credentials.identifier, 91 - password: credentials.password, 92 - }); 139 + // App password flow 140 + if (!isAppPasswordCredentials(credentials)) { 141 + throw new Error("Invalid credential type"); 142 + } 143 + const agent = new AtpAgent({ service: credentials.pdsUrl }); 93 144 94 - return agent; 145 + await agent.login({ 146 + identifier: credentials.identifier, 147 + password: credentials.password, 148 + }); 149 + 150 + return agent; 95 151 } 96 152 97 153 export async function uploadImage( 98 - agent: AtpAgent, 99 - imagePath: string 154 + agent: Agent, 155 + imagePath: string, 100 156 ): Promise<BlobObject | undefined> { 101 - if (!(await fileExists(imagePath))) { 102 - return undefined; 103 - } 157 + if (!(await fileExists(imagePath))) { 158 + return undefined; 159 + } 104 160 105 - try { 106 - const imageBuffer = await fs.readFile(imagePath); 107 - const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 161 + try { 162 + const imageBuffer = await fs.readFile(imagePath); 163 + const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 108 164 109 - const response = await agent.com.atproto.repo.uploadBlob( 110 - new Uint8Array(imageBuffer), 111 - { 112 - encoding: mimeType, 113 - } 114 - ); 165 + const response = await agent.com.atproto.repo.uploadBlob( 166 + new Uint8Array(imageBuffer), 167 + { 168 + encoding: mimeType, 169 + }, 170 + ); 115 171 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 - } 172 + return { 173 + $type: "blob", 174 + ref: { 175 + $link: response.data.blob.ref.toString(), 176 + }, 177 + mimeType, 178 + size: imageBuffer.byteLength, 179 + }; 180 + } catch (error) { 181 + console.error(`Error uploading image ${imagePath}:`, error); 182 + return undefined; 183 + } 128 184 } 129 185 130 186 export async function resolveImagePath( 131 - ogImage: string, 132 - imagesDir: string | undefined, 133 - contentDir: string 187 + ogImage: string, 188 + imagesDir: string | undefined, 189 + contentDir: string, 134 190 ): Promise<string | null> { 135 - // Try multiple resolution strategies 136 - const filename = path.basename(ogImage); 191 + // Try multiple resolution strategies 192 + const filename = path.basename(ogImage); 137 193 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 - } 194 + // 1. If imagesDir is specified, look there 195 + if (imagesDir) { 196 + const imagePath = path.join(imagesDir, filename); 197 + if (await fileExists(imagePath)) { 198 + const stat = await fs.stat(imagePath); 199 + if (stat.size > 0) { 200 + return imagePath; 201 + } 202 + } 203 + } 148 204 149 - // 2. Try the ogImage path directly (if it's absolute) 150 - if (path.isAbsolute(ogImage)) { 151 - return ogImage; 152 - } 205 + // 2. Try the ogImage path directly (if it's absolute) 206 + if (path.isAbsolute(ogImage)) { 207 + return ogImage; 208 + } 153 209 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 - } 210 + // 3. Try relative to content directory 211 + const contentRelative = path.join(contentDir, ogImage); 212 + if (await fileExists(contentRelative)) { 213 + const stat = await fs.stat(contentRelative); 214 + if (stat.size > 0) { 215 + return contentRelative; 216 + } 217 + } 162 218 163 - return null; 219 + return null; 164 220 } 165 221 166 222 export async function createDocument( 167 - agent: AtpAgent, 168 - post: BlogPost, 169 - config: PublisherConfig, 170 - coverImage?: BlobObject 223 + agent: Agent, 224 + post: BlogPost, 225 + config: PublisherConfig, 226 + coverImage?: BlobObject, 171 227 ): 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); 228 + const pathPrefix = config.pathPrefix || "/posts"; 229 + const postPath = `${pathPrefix}/${post.slug}`; 230 + const publishDate = new Date(post.frontmatter.publishDate); 231 + 232 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 233 + let textContent: string; 234 + if ( 235 + config.textContentField && 236 + post.rawFrontmatter?.[config.textContentField] 237 + ) { 238 + textContent = String(post.rawFrontmatter[config.textContentField]); 239 + } else { 240 + textContent = stripMarkdownForText(post.content); 241 + } 242 + 243 + const record: Record<string, unknown> = { 244 + $type: "site.standard.document", 245 + title: post.frontmatter.title, 246 + site: config.publicationUri, 247 + path: postPath, 248 + textContent: textContent.slice(0, 10000), 249 + publishedAt: publishDate.toISOString(), 250 + canonicalUrl: `${config.siteUrl}${postPath}`, 251 + }; 176 252 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 - }; 253 + if (post.frontmatter.description) { 254 + record.description = post.frontmatter.description; 255 + } 186 256 187 - if (coverImage) { 188 - record.coverImage = coverImage; 189 - } 257 + if (coverImage) { 258 + record.coverImage = coverImage; 259 + } 190 260 191 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 192 - record.tags = post.frontmatter.tags; 193 - } 261 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 262 + record.tags = post.frontmatter.tags; 263 + } 194 264 195 - const response = await agent.com.atproto.repo.createRecord({ 196 - repo: agent.session!.did, 197 - collection: "site.standard.document", 198 - record, 199 - }); 265 + const response = await agent.com.atproto.repo.createRecord({ 266 + repo: agent.did!, 267 + collection: "site.standard.document", 268 + record, 269 + }); 200 270 201 - return response.data.uri; 271 + return response.data.uri; 202 272 } 203 273 204 274 export async function updateDocument( 205 - agent: AtpAgent, 206 - post: BlogPost, 207 - atUri: string, 208 - config: PublisherConfig, 209 - coverImage?: BlobObject 275 + agent: Agent, 276 + post: BlogPost, 277 + atUri: string, 278 + config: PublisherConfig, 279 + coverImage?: BlobObject, 210 280 ): 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 - } 281 + // Parse the atUri to get the collection and rkey 282 + // Format: at://did:plc:xxx/collection/rkey 283 + const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 284 + if (!uriMatch) { 285 + throw new Error(`Invalid atUri format: ${atUri}`); 286 + } 217 287 218 - const [, , collection, rkey] = uriMatch; 288 + const [, , collection, rkey] = uriMatch; 219 289 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); 290 + const pathPrefix = config.pathPrefix || "/posts"; 291 + const postPath = `${pathPrefix}/${post.slug}`; 292 + const publishDate = new Date(post.frontmatter.publishDate); 224 293 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 - }; 294 + // Determine textContent: use configured field from frontmatter, or fallback to markdown body 295 + let textContent: string; 296 + if ( 297 + config.textContentField && 298 + post.rawFrontmatter?.[config.textContentField] 299 + ) { 300 + textContent = String(post.rawFrontmatter[config.textContentField]); 301 + } else { 302 + textContent = stripMarkdownForText(post.content); 303 + } 234 304 235 - if (coverImage) { 236 - record.coverImage = coverImage; 237 - } 305 + const record: Record<string, unknown> = { 306 + $type: "site.standard.document", 307 + title: post.frontmatter.title, 308 + site: config.publicationUri, 309 + path: postPath, 310 + textContent: textContent.slice(0, 10000), 311 + publishedAt: publishDate.toISOString(), 312 + canonicalUrl: `${config.siteUrl}${postPath}`, 313 + }; 238 314 239 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 240 - record.tags = post.frontmatter.tags; 241 - } 315 + if (post.frontmatter.description) { 316 + record.description = post.frontmatter.description; 317 + } 242 318 243 - await agent.com.atproto.repo.putRecord({ 244 - repo: agent.session!.did, 245 - collection: collection!, 246 - rkey: rkey!, 247 - record, 248 - }); 319 + if (coverImage) { 320 + record.coverImage = coverImage; 321 + } 322 + 323 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 324 + record.tags = post.frontmatter.tags; 325 + } 326 + 327 + await agent.com.atproto.repo.putRecord({ 328 + repo: agent.did!, 329 + collection: collection!, 330 + rkey: rkey!, 331 + record, 332 + }); 249 333 } 250 334 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 - }; 335 + export function parseAtUri( 336 + atUri: string, 337 + ): { did: string; collection: string; rkey: string } | null { 338 + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 339 + if (!match) return null; 340 + return { 341 + did: match[1]!, 342 + collection: match[2]!, 343 + rkey: match[3]!, 344 + }; 259 345 } 260 346 261 347 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; 348 + $type: "site.standard.document"; 349 + title: string; 350 + site: string; 351 + path: string; 352 + textContent: string; 353 + publishedAt: string; 354 + canonicalUrl?: string; 355 + description?: string; 356 + coverImage?: BlobObject; 357 + tags?: string[]; 358 + location?: string; 272 359 } 273 360 274 361 export interface ListDocumentsResult { 275 - uri: string; 276 - cid: string; 277 - value: DocumentRecord; 362 + uri: string; 363 + cid: string; 364 + value: DocumentRecord; 278 365 } 279 366 280 367 export async function listDocuments( 281 - agent: AtpAgent, 282 - publicationUri?: string 368 + agent: Agent, 369 + publicationUri?: string, 283 370 ): Promise<ListDocumentsResult[]> { 284 - const documents: ListDocumentsResult[] = []; 285 - let cursor: string | undefined; 371 + const documents: ListDocumentsResult[] = []; 372 + let cursor: string | undefined; 286 373 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 - }); 374 + do { 375 + const response = await agent.com.atproto.repo.listRecords({ 376 + repo: agent.did!, 377 + collection: "site.standard.document", 378 + limit: 100, 379 + cursor, 380 + }); 294 381 295 - for (const record of response.data.records) { 296 - const value = record.value as unknown as DocumentRecord; 382 + for (const record of response.data.records) { 383 + if (!isDocumentRecord(record.value)) { 384 + continue; 385 + } 297 386 298 - // If publicationUri is specified, only include documents from that publication 299 - if (publicationUri && value.site !== publicationUri) { 300 - continue; 301 - } 387 + // If publicationUri is specified, only include documents from that publication 388 + if (publicationUri && record.value.site !== publicationUri) { 389 + continue; 390 + } 302 391 303 - documents.push({ 304 - uri: record.uri, 305 - cid: record.cid, 306 - value, 307 - }); 308 - } 392 + documents.push({ 393 + uri: record.uri, 394 + cid: record.cid, 395 + value: record.value, 396 + }); 397 + } 309 398 310 - cursor = response.data.cursor; 311 - } while (cursor); 399 + cursor = response.data.cursor; 400 + } while (cursor); 312 401 313 - return documents; 402 + return documents; 314 403 } 315 404 316 405 export async function createPublication( 317 - agent: AtpAgent, 318 - options: CreatePublicationOptions 406 + agent: Agent, 407 + options: CreatePublicationOptions, 319 408 ): Promise<string> { 320 - let icon: BlobObject | undefined; 409 + let icon: BlobObject | undefined; 321 410 322 - if (options.iconPath) { 323 - icon = await uploadImage(agent, options.iconPath); 324 - } 411 + if (options.iconPath) { 412 + icon = await uploadImage(agent, options.iconPath); 413 + } 325 414 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 - }; 415 + const record: Record<string, unknown> = { 416 + $type: "site.standard.publication", 417 + url: options.url, 418 + name: options.name, 419 + createdAt: new Date().toISOString(), 420 + }; 332 421 333 - if (options.description) { 334 - record.description = options.description; 335 - } 422 + if (options.description) { 423 + record.description = options.description; 424 + } 336 425 337 - if (icon) { 338 - record.icon = icon; 339 - } 426 + if (icon) { 427 + record.icon = icon; 428 + } 340 429 341 - if (options.showInDiscover !== undefined) { 342 - record.preferences = { 343 - showInDiscover: options.showInDiscover, 344 - }; 345 - } 430 + if (options.showInDiscover !== undefined) { 431 + record.preferences = { 432 + showInDiscover: options.showInDiscover, 433 + }; 434 + } 346 435 347 - const response = await agent.com.atproto.repo.createRecord({ 348 - repo: agent.session!.did, 349 - collection: "site.standard.publication", 350 - record, 351 - }); 436 + const response = await agent.com.atproto.repo.createRecord({ 437 + repo: agent.did!, 438 + collection: "site.standard.publication", 439 + record, 440 + }); 352 441 353 - return response.data.uri; 442 + return response.data.uri; 354 443 } 355 444 356 445 // --- Bluesky Post Creation --- 357 446 358 447 export interface CreateBlueskyPostOptions { 359 - title: string; 360 - description?: string; 361 - canonicalUrl: string; 362 - coverImage?: BlobObject; 363 - publishedAt: string; // Used as createdAt for the post 448 + title: string; 449 + description?: string; 450 + canonicalUrl: string; 451 + coverImage?: BlobObject; 452 + publishedAt: string; // Used as createdAt for the post 364 453 } 365 454 366 455 /** 367 456 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 368 457 */ 369 458 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; 459 + // Use Intl.Segmenter if available, otherwise fallback to spread operator 460 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 461 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 462 + return [...segmenter.segment(str)].length; 463 + } 464 + return [...str].length; 376 465 } 377 466 378 467 /** 379 468 * Truncate a string to a maximum number of graphemes 380 469 */ 381 470 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("") + "..."; 471 + if (typeof Intl !== "undefined" && Intl.Segmenter) { 472 + const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 473 + const segments = [...segmenter.segment(str)]; 474 + if (segments.length <= maxGraphemes) return str; 475 + return `${segments 476 + .slice(0, maxGraphemes - 3) 477 + .map((s) => s.segment) 478 + .join("")}...`; 479 + } 480 + // Fallback 481 + const chars = [...str]; 482 + if (chars.length <= maxGraphemes) return str; 483 + return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 392 484 } 393 485 394 486 /** 395 487 * Create a Bluesky post with external link embed 396 488 */ 397 489 export async function createBlueskyPost( 398 - agent: AtpAgent, 399 - options: CreateBlueskyPostOptions 490 + agent: Agent, 491 + options: CreateBlueskyPostOptions, 400 492 ): Promise<StrongRef> { 401 - const { title, description, canonicalUrl, coverImage, publishedAt } = options; 493 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 402 494 403 - // Build post text: title + description + URL 404 - // Max 300 graphemes for Bluesky posts 405 - const MAX_GRAPHEMES = 300; 495 + // Build post text: title + description + URL 496 + // Max 300 graphemes for Bluesky posts 497 + const MAX_GRAPHEMES = 300; 406 498 407 - let postText: string; 408 - const urlPart = `\n\n${canonicalUrl}`; 409 - const urlGraphemes = countGraphemes(urlPart); 499 + let postText: string; 500 + const urlPart = `\n\n${canonicalUrl}`; 501 + const urlGraphemes = countGraphemes(urlPart); 410 502 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 - } 503 + if (description) { 504 + // Try: title + description + URL 505 + const fullText = `${title}\n\n${description}${urlPart}`; 506 + if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 507 + postText = fullText; 508 + } else { 509 + // Truncate description to fit 510 + const availableForDesc = 511 + MAX_GRAPHEMES - 512 + countGraphemes(title) - 513 + countGraphemes("\n\n") - 514 + urlGraphemes - 515 + countGraphemes("\n\n"); 516 + if (availableForDesc > 10) { 517 + const truncatedDesc = truncateToGraphemes( 518 + description, 519 + availableForDesc, 520 + ); 521 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 522 + } else { 523 + // Just title + URL 524 + postText = `${title}${urlPart}`; 525 + } 526 + } 527 + } else { 528 + // Just title + URL 529 + postText = `${title}${urlPart}`; 530 + } 431 531 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 - } 532 + // Final truncation if still too long (shouldn't happen but safety check) 533 + if (countGraphemes(postText) > MAX_GRAPHEMES) { 534 + postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 535 + } 436 536 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; 537 + // Calculate byte indices for the URL facet 538 + const encoder = new TextEncoder(); 539 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 540 + const beforeUrl = postText.substring(0, urlStartInText); 541 + const byteStart = encoder.encode(beforeUrl).length; 542 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 443 543 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 - ]; 544 + // Build facets for the URL link 545 + const facets = [ 546 + { 547 + index: { 548 + byteStart, 549 + byteEnd, 550 + }, 551 + features: [ 552 + { 553 + $type: "app.bsky.richtext.facet#link", 554 + uri: canonicalUrl, 555 + }, 556 + ], 557 + }, 558 + ]; 459 559 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 - }; 560 + // Build external embed 561 + const embed: Record<string, unknown> = { 562 + $type: "app.bsky.embed.external", 563 + external: { 564 + uri: canonicalUrl, 565 + title: title.substring(0, 500), // Max 500 chars for title 566 + description: (description || "").substring(0, 1000), // Max 1000 chars for description 567 + }, 568 + }; 469 569 470 - // Add thumbnail if coverImage is available 471 - if (coverImage) { 472 - (embed.external as Record<string, unknown>).thumb = coverImage; 473 - } 570 + // Add thumbnail if coverImage is available 571 + if (coverImage) { 572 + (embed.external as Record<string, unknown>).thumb = coverImage; 573 + } 474 574 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 - }; 575 + // Create the post record 576 + const record: Record<string, unknown> = { 577 + $type: "app.bsky.feed.post", 578 + text: postText, 579 + facets, 580 + embed, 581 + createdAt: new Date(publishedAt).toISOString(), 582 + }; 483 583 484 - const response = await agent.com.atproto.repo.createRecord({ 485 - repo: agent.session!.did, 486 - collection: "app.bsky.feed.post", 487 - record, 488 - }); 584 + const response = await agent.com.atproto.repo.createRecord({ 585 + repo: agent.did!, 586 + collection: "app.bsky.feed.post", 587 + record, 588 + }); 489 589 490 - return { 491 - uri: response.data.uri, 492 - cid: response.data.cid, 493 - }; 590 + return { 591 + uri: response.data.uri, 592 + cid: response.data.cid, 593 + }; 494 594 } 495 595 496 596 /** 497 597 * Add bskyPostRef to an existing document record 498 598 */ 499 599 export async function addBskyPostRefToDocument( 500 - agent: AtpAgent, 501 - documentAtUri: string, 502 - bskyPostRef: StrongRef 600 + agent: Agent, 601 + documentAtUri: string, 602 + bskyPostRef: StrongRef, 503 603 ): Promise<void> { 504 - const parsed = parseAtUri(documentAtUri); 505 - if (!parsed) { 506 - throw new Error(`Invalid document URI: ${documentAtUri}`); 507 - } 604 + const parsed = parseAtUri(documentAtUri); 605 + if (!parsed) { 606 + throw new Error(`Invalid document URI: ${documentAtUri}`); 607 + } 508 608 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 - }); 609 + // Fetch existing record 610 + const existingRecord = await agent.com.atproto.repo.getRecord({ 611 + repo: parsed.did, 612 + collection: parsed.collection, 613 + rkey: parsed.rkey, 614 + }); 515 615 516 - // Add bskyPostRef to the record 517 - const updatedRecord = { 518 - ...(existingRecord.data.value as Record<string, unknown>), 519 - bskyPostRef, 520 - }; 616 + // Add bskyPostRef to the record 617 + const updatedRecord = { 618 + ...(existingRecord.data.value as Record<string, unknown>), 619 + bskyPostRef, 620 + }; 521 621 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 - }); 622 + // Update the record 623 + await agent.com.atproto.repo.putRecord({ 624 + repo: parsed.did, 625 + collection: parsed.collection, 626 + rkey: parsed.rkey, 627 + record: updatedRecord, 628 + }); 529 629 }
+17 -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"; 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"; 4 9 5 10 const CONFIG_FILENAME = "sequoia.json"; 6 11 const STATE_FILENAME = ".sequoia-state.json"; ··· 76 81 pdsUrl?: string; 77 82 frontmatter?: FrontmatterMapping; 78 83 ignore?: string[]; 84 + removeIndexFromSlug?: boolean; 85 + textContentField?: string; 79 86 bluesky?: BlueskyConfig; 80 87 }): string { 81 88 const config: Record<string, unknown> = { ··· 113 120 config.ignore = options.ignore; 114 121 } 115 122 123 + if (options.removeIndexFromSlug) { 124 + config.removeIndexFromSlug = options.removeIndexFromSlug; 125 + } 126 + 127 + if (options.textContentField) { 128 + config.textContentField = options.textContentField; 129 + } 116 130 if (options.bluesky) { 117 131 config.bluesky = options.bluesky; 118 132 }
+204 -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"; 1 + import * as fs from "node:fs/promises"; 2 + import * as os from "node:os"; 3 + import * as path from "node:path"; 4 + import { getOAuthSession, listOAuthSessions } from "./oauth-store"; 5 + import type { 6 + AppPasswordCredentials, 7 + Credentials, 8 + LegacyCredentials, 9 + OAuthCredentials, 10 + } from "./types"; 5 11 6 12 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 7 13 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 8 14 9 - // Stored credentials keyed by identifier 10 - type CredentialsStore = Record<string, Credentials>; 15 + // Stored credentials keyed by identifier (can be legacy or typed) 16 + type CredentialsStore = Record< 17 + string, 18 + AppPasswordCredentials | LegacyCredentials 19 + >; 11 20 12 21 async function fileExists(filePath: string): Promise<boolean> { 13 - try { 14 - await fs.access(filePath); 15 - return true; 16 - } catch { 17 - return false; 18 - } 22 + try { 23 + await fs.access(filePath); 24 + return true; 25 + } catch { 26 + return false; 27 + } 19 28 } 20 29 21 30 /** 22 - * Load all stored credentials 31 + * Normalize credentials to have explicit type 23 32 */ 33 + function normalizeCredentials( 34 + creds: AppPasswordCredentials | LegacyCredentials, 35 + ): AppPasswordCredentials { 36 + // If it already has type, return as-is 37 + if ("type" in creds && creds.type === "app-password") { 38 + return creds; 39 + } 40 + // Migrate legacy format 41 + return { 42 + type: "app-password", 43 + pdsUrl: creds.pdsUrl, 44 + identifier: creds.identifier, 45 + password: creds.password, 46 + }; 47 + } 48 + 24 49 async function loadCredentialsStore(): Promise<CredentialsStore> { 25 - if (!(await fileExists(CREDENTIALS_FILE))) { 26 - return {}; 27 - } 50 + if (!(await fileExists(CREDENTIALS_FILE))) { 51 + return {}; 52 + } 28 53 29 - try { 30 - const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 31 - const parsed = JSON.parse(content); 54 + try { 55 + const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 56 + const parsed = JSON.parse(content); 32 57 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 - } 58 + // Handle legacy single-credential format (migrate on read) 59 + if (parsed.identifier && parsed.password) { 60 + const legacy = parsed as LegacyCredentials; 61 + return { [legacy.identifier]: legacy }; 62 + } 38 63 39 - return parsed as CredentialsStore; 40 - } catch { 41 - return {}; 42 - } 64 + return parsed as CredentialsStore; 65 + } catch { 66 + return {}; 67 + } 43 68 } 44 69 45 70 /** 46 71 * Save the entire credentials store 47 72 */ 48 73 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); 74 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 75 + await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 76 + await fs.chmod(CREDENTIALS_FILE, 0o600); 77 + } 78 + 79 + /** 80 + * Try to load OAuth credentials for a given profile (DID or handle) 81 + */ 82 + async function tryLoadOAuthCredentials( 83 + profile: string, 84 + ): Promise<OAuthCredentials | null> { 85 + // If it looks like a DID, try to get the session directly 86 + if (profile.startsWith("did:")) { 87 + const session = await getOAuthSession(profile); 88 + if (session) { 89 + return { 90 + type: "oauth", 91 + did: profile, 92 + handle: profile, // We don't have the handle stored, use DID 93 + pdsUrl: "https://bsky.social", // Will be resolved from DID doc 94 + }; 95 + } 96 + } 97 + 98 + // Otherwise, we would need to check all OAuth sessions to find a matching handle, 99 + // but handle matching isn't perfect without storing handles alongside sessions. 100 + // For now, just return null if profile isn't a DID. 101 + return null; 52 102 } 53 103 54 104 /** ··· 56 106 * 57 107 * Priority: 58 108 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 59 - * 2. SEQUOIA_PROFILE env var - selects from stored credentials 109 + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 60 110 * 3. projectIdentity parameter (from sequoia.json) 61 - * 4. If only one identity stored, use it 111 + * 4. If only one identity stored (app-password or OAuth), use it 62 112 * 5. Return null (caller should prompt user) 63 113 */ 64 114 export async function loadCredentials( 65 - projectIdentity?: string 115 + projectIdentity?: string, 66 116 ): 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 - } 117 + // 1. Check environment variables first (full override) 118 + const envIdentifier = process.env.ATP_IDENTIFIER; 119 + const envPassword = process.env.ATP_APP_PASSWORD; 120 + const envPdsUrl = process.env.PDS_URL; 79 121 80 - const store = await loadCredentialsStore(); 81 - const identifiers = Object.keys(store); 122 + if (envIdentifier && envPassword) { 123 + return { 124 + type: "app-password", 125 + identifier: envIdentifier, 126 + password: envPassword, 127 + pdsUrl: envPdsUrl || "https://bsky.social", 128 + }; 129 + } 82 130 83 - if (identifiers.length === 0) { 84 - return null; 85 - } 131 + const store = await loadCredentialsStore(); 132 + const appPasswordIds = Object.keys(store); 133 + const oauthDids = await listOAuthSessions(); 86 134 87 - // 2. SEQUOIA_PROFILE env var 88 - const profileEnv = process.env.SEQUOIA_PROFILE; 89 - if (profileEnv && store[profileEnv]) { 90 - return store[profileEnv]; 91 - } 135 + // 2. SEQUOIA_PROFILE env var 136 + const profileEnv = process.env.SEQUOIA_PROFILE; 137 + if (profileEnv) { 138 + // Try app-password credentials first 139 + if (store[profileEnv]) { 140 + return normalizeCredentials(store[profileEnv]); 141 + } 142 + // Try OAuth session (profile could be a DID) 143 + const oauth = await tryLoadOAuthCredentials(profileEnv); 144 + if (oauth) { 145 + return oauth; 146 + } 147 + } 92 148 93 - // 3. Project-specific identity (from sequoia.json) 94 - if (projectIdentity && store[projectIdentity]) { 95 - return store[projectIdentity]; 96 - } 149 + // 3. Project-specific identity (from sequoia.json) 150 + if (projectIdentity) { 151 + if (store[projectIdentity]) { 152 + return normalizeCredentials(store[projectIdentity]); 153 + } 154 + const oauth = await tryLoadOAuthCredentials(projectIdentity); 155 + if (oauth) { 156 + return oauth; 157 + } 158 + } 97 159 98 - // 4. If only one identity, use it 99 - if (identifiers.length === 1 && identifiers[0]) { 100 - return store[identifiers[0]] ?? null; 101 - } 160 + // 4. If only one identity total, use it 161 + const totalIdentities = appPasswordIds.length + oauthDids.length; 162 + if (totalIdentities === 1) { 163 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 164 + return normalizeCredentials(store[appPasswordIds[0]]!); 165 + } 166 + if (oauthDids.length === 1 && oauthDids[0]) { 167 + const session = await getOAuthSession(oauthDids[0]); 168 + if (session) { 169 + return { 170 + type: "oauth", 171 + did: oauthDids[0], 172 + handle: oauthDids[0], 173 + pdsUrl: "https://bsky.social", 174 + }; 175 + } 176 + } 177 + } 102 178 103 - // Multiple identities exist but none selected 104 - return null; 179 + // Multiple identities exist but none selected, or no identities 180 + return null; 105 181 } 106 182 107 183 /** 108 - * Get a specific identity by identifier 184 + * Get a specific identity by identifier (app-password only) 109 185 */ 110 186 export async function getCredentials( 111 - identifier: string 112 - ): Promise<Credentials | null> { 113 - const store = await loadCredentialsStore(); 114 - return store[identifier] || null; 187 + identifier: string, 188 + ): Promise<AppPasswordCredentials | null> { 189 + const store = await loadCredentialsStore(); 190 + const creds = store[identifier]; 191 + if (!creds) return null; 192 + return normalizeCredentials(creds); 115 193 } 116 194 117 195 /** 118 - * List all stored identities 196 + * List all stored app-password identities 119 197 */ 120 198 export async function listCredentials(): Promise<string[]> { 121 - const store = await loadCredentialsStore(); 122 - return Object.keys(store); 199 + const store = await loadCredentialsStore(); 200 + return Object.keys(store); 201 + } 202 + 203 + /** 204 + * List all credentials (both app-password and OAuth) 205 + */ 206 + export async function listAllCredentials(): Promise< 207 + Array<{ id: string; type: "app-password" | "oauth" }> 208 + > { 209 + const store = await loadCredentialsStore(); 210 + const oauthDids = await listOAuthSessions(); 211 + 212 + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 213 + 214 + for (const id of Object.keys(store)) { 215 + result.push({ id, type: "app-password" }); 216 + } 217 + 218 + for (const did of oauthDids) { 219 + result.push({ id: did, type: "oauth" }); 220 + } 221 + 222 + return result; 123 223 } 124 224 125 225 /** 126 - * Save credentials for an identity (adds or updates) 226 + * Save app-password credentials for an identity (adds or updates) 127 227 */ 128 - export async function saveCredentials(credentials: Credentials): Promise<void> { 129 - const store = await loadCredentialsStore(); 130 - store[credentials.identifier] = credentials; 131 - await saveCredentialsStore(store); 228 + export async function saveCredentials( 229 + credentials: AppPasswordCredentials, 230 + ): Promise<void> { 231 + const store = await loadCredentialsStore(); 232 + store[credentials.identifier] = credentials; 233 + await saveCredentialsStore(store); 132 234 } 133 235 134 236 /** 135 237 * Delete credentials for a specific identity 136 238 */ 137 239 export async function deleteCredentials(identifier?: string): Promise<boolean> { 138 - const store = await loadCredentialsStore(); 139 - const identifiers = Object.keys(store); 240 + const store = await loadCredentialsStore(); 241 + const identifiers = Object.keys(store); 140 242 141 - if (identifiers.length === 0) { 142 - return false; 143 - } 243 + if (identifiers.length === 0) { 244 + return false; 245 + } 144 246 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 - } 247 + // If identifier specified, delete just that one 248 + if (identifier) { 249 + if (!store[identifier]) { 250 + return false; 251 + } 252 + delete store[identifier]; 253 + await saveCredentialsStore(store); 254 + return true; 255 + } 154 256 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 - } 257 + // If only one identity, delete it (backwards compat behavior) 258 + if (identifiers.length === 1 && identifiers[0]) { 259 + delete store[identifiers[0]]; 260 + await saveCredentialsStore(store); 261 + return true; 262 + } 161 263 162 - // Multiple identities but none specified 163 - return false; 264 + // Multiple identities but none specified 265 + return false; 164 266 } 165 267 166 268 export function getCredentialsPath(): string { 167 - return CREDENTIALS_FILE; 269 + return CREDENTIALS_FILE; 168 270 }
+323 -176
packages/cli/src/lib/markdown.ts
··· 1 - import * as fs from "fs/promises"; 2 - import * as path from "path"; 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 3 import { glob } from "glob"; 4 4 import { minimatch } from "minimatch"; 5 - import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types"; 5 + import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; 6 6 7 - export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { 8 - frontmatter: PostFrontmatter; 9 - body: string; 7 + export function parseFrontmatter( 8 + content: string, 9 + mapping?: FrontmatterMapping, 10 + ): { 11 + frontmatter: PostFrontmatter; 12 + body: string; 13 + rawFrontmatter: Record<string, unknown>; 10 14 } { 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); 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); 17 21 18 - if (!match) { 19 - throw new Error("Could not parse frontmatter"); 20 - } 22 + if (!match) { 23 + throw new Error("Could not parse frontmatter"); 24 + } 21 25 22 - const delimiter = match[1]; 23 - const frontmatterStr = match[2] ?? ""; 24 - const body = match[3] ?? ""; 26 + const delimiter = match[1]; 27 + const frontmatterStr = match[2] ?? ""; 28 + const body = match[3] ?? ""; 25 29 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 ? "=" : ":"; 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 ? "=" : ":"; 31 35 32 - // Parse frontmatter manually 33 - const raw: Record<string, unknown> = {}; 34 - const lines = frontmatterStr.split("\n"); 36 + // Parse frontmatter manually 37 + const raw: Record<string, unknown> = {}; 38 + const lines = frontmatterStr.split("\n"); 35 39 36 - for (const line of lines) { 37 - const sepIndex = line.indexOf(separator); 38 - if (sepIndex === -1) continue; 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 + } 39 52 40 - const key = line.slice(0, sepIndex).trim(); 41 - let value = line.slice(sepIndex + 1).trim(); 53 + const key = line.slice(0, sepIndex).trim(); 54 + let value = line.slice(sepIndex + 1).trim(); 42 55 43 - // Handle quoted strings 44 - if ( 45 - (value.startsWith('"') && value.endsWith('"')) || 46 - (value.startsWith("'") && value.endsWith("'")) 47 - ) { 48 - value = value.slice(1, -1); 49 - } 56 + // Handle quoted strings 57 + if ( 58 + (value.startsWith('"') && value.endsWith('"')) || 59 + (value.startsWith("'") && value.endsWith("'")) 60 + ) { 61 + value = value.slice(1, -1); 62 + } 50 63 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 - } 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 + } 65 117 66 - // Apply field mappings to normalize to standard PostFrontmatter fields 67 - const frontmatter: Record<string, unknown> = {}; 118 + // Apply field mappings to normalize to standard PostFrontmatter fields 119 + const frontmatter: Record<string, unknown> = {}; 68 120 69 - // Title mapping 70 - const titleField = mapping?.title || "title"; 71 - frontmatter.title = raw[titleField] || raw.title; 121 + // Title mapping 122 + const titleField = mapping?.title || "title"; 123 + frontmatter.title = raw[titleField] || raw.title; 72 124 73 - // Description mapping 74 - const descField = mapping?.description || "description"; 75 - frontmatter.description = raw[descField] || raw.description; 125 + // Description mapping 126 + const descField = mapping?.description || "description"; 127 + frontmatter.description = raw[descField] || raw.description; 76 128 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 - } 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 + } 93 145 94 - // Cover image mapping 95 - const coverField = mapping?.coverImage || "ogImage"; 96 - frontmatter.ogImage = raw[coverField] || raw.ogImage; 146 + // Cover image mapping 147 + const coverField = mapping?.coverImage || "ogImage"; 148 + frontmatter.ogImage = raw[coverField] || raw.ogImage; 97 149 98 - // Tags mapping 99 - const tagsField = mapping?.tags || "tags"; 100 - frontmatter.tags = raw[tagsField] || raw.tags; 150 + // Tags mapping 151 + const tagsField = mapping?.tags || "tags"; 152 + frontmatter.tags = raw[tagsField] || raw.tags; 101 153 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 - } 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 + } 108 160 109 - // Always preserve atUri (internal field) 110 - frontmatter.atUri = raw.atUri; 161 + // Always preserve atUri (internal field) 162 + frontmatter.atUri = raw.atUri; 111 163 112 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; 164 + return { 165 + frontmatter: frontmatter as unknown as PostFrontmatter, 166 + body, 167 + rawFrontmatter: raw, 168 + }; 113 169 } 114 170 115 171 export function getSlugFromFilename(filename: string): string { 116 - return filename 117 - .replace(/\.mdx?$/, "") 118 - .toLowerCase() 119 - .replace(/\s+/g, "-"); 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 + } 182 + 183 + export function getSlugFromOptions( 184 + relativePath: string, 185 + rawFrontmatter: Record<string, unknown>, 186 + options: SlugOptions = {}, 187 + ): string { 188 + const { slugField, removeIndexFromSlug = false } = options; 189 + 190 + let slug: string; 191 + 192 + // If slugField is set, try to get the value from frontmatter 193 + if (slugField) { 194 + const frontmatterValue = rawFrontmatter[slugField]; 195 + if (frontmatterValue && typeof frontmatterValue === "string") { 196 + // Remove leading slash if present 197 + slug = frontmatterValue 198 + .replace(/^\//, "") 199 + .toLowerCase() 200 + .replace(/\s+/g, "-"); 201 + } else { 202 + // Fallback to filepath if frontmatter field not found 203 + slug = relativePath 204 + .replace(/\.mdx?$/, "") 205 + .toLowerCase() 206 + .replace(/\s+/g, "-"); 207 + } 208 + } else { 209 + // Default: use filepath 210 + slug = relativePath 211 + .replace(/\.mdx?$/, "") 212 + .toLowerCase() 213 + .replace(/\s+/g, "-"); 214 + } 215 + 216 + // Remove /index or /_index suffix if configured 217 + if (removeIndexFromSlug) { 218 + slug = slug.replace(/\/_?index$/, ""); 219 + } 220 + 221 + return slug; 120 222 } 121 223 122 224 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(""); 225 + const encoder = new TextEncoder(); 226 + const data = encoder.encode(content); 227 + const hashBuffer = await crypto.subtle.digest("SHA-256", data); 228 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 229 + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 128 230 } 129 231 130 232 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; 233 + for (const pattern of ignorePatterns) { 234 + if (minimatch(relativePath, pattern)) { 235 + return true; 236 + } 237 + } 238 + return false; 239 + } 240 + 241 + export interface ScanOptions { 242 + frontmatterMapping?: FrontmatterMapping; 243 + ignorePatterns?: string[]; 244 + slugField?: string; 245 + removeIndexFromSlug?: boolean; 137 246 } 138 247 139 248 export async function scanContentDirectory( 140 - contentDir: string, 141 - frontmatterMapping?: FrontmatterMapping, 142 - ignorePatterns: string[] = [] 249 + contentDir: string, 250 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 251 + ignorePatterns: string[] = [], 143 252 ): Promise<BlogPost[]> { 144 - const patterns = ["**/*.md", "**/*.mdx"]; 145 - const posts: BlogPost[] = []; 253 + // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options) 254 + let options: ScanOptions; 255 + if ( 256 + frontmatterMappingOrOptions && 257 + ("frontmatterMapping" in frontmatterMappingOrOptions || 258 + "ignorePatterns" in frontmatterMappingOrOptions || 259 + "slugField" in frontmatterMappingOrOptions) 260 + ) { 261 + options = frontmatterMappingOrOptions as ScanOptions; 262 + } else { 263 + // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?) 264 + options = { 265 + frontmatterMapping: frontmatterMappingOrOptions as 266 + | FrontmatterMapping 267 + | undefined, 268 + ignorePatterns, 269 + }; 270 + } 146 271 147 - for (const pattern of patterns) { 148 - const files = await glob(pattern, { 149 - cwd: contentDir, 150 - absolute: false, 151 - }); 272 + const { 273 + frontmatterMapping, 274 + ignorePatterns: ignore = [], 275 + slugField, 276 + removeIndexFromSlug, 277 + } = options; 278 + 279 + const patterns = ["**/*.md", "**/*.mdx"]; 280 + const posts: BlogPost[] = []; 281 + 282 + for (const pattern of patterns) { 283 + const files = await glob(pattern, { 284 + cwd: contentDir, 285 + absolute: false, 286 + }); 152 287 153 - for (const relativePath of files) { 154 - // Skip files matching ignore patterns 155 - if (shouldIgnore(relativePath, ignorePatterns)) { 156 - continue; 157 - } 288 + for (const relativePath of files) { 289 + // Skip files matching ignore patterns 290 + if (shouldIgnore(relativePath, ignore)) { 291 + continue; 292 + } 158 293 159 - const filePath = path.join(contentDir, relativePath); 160 - const rawContent = await fs.readFile(filePath, "utf-8"); 294 + const filePath = path.join(contentDir, relativePath); 295 + const rawContent = await fs.readFile(filePath, "utf-8"); 161 296 162 - try { 163 - const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping); 164 - const filename = path.basename(relativePath); 165 - const slug = getSlugFromFilename(filename); 297 + try { 298 + const { frontmatter, body, rawFrontmatter } = parseFrontmatter( 299 + rawContent, 300 + frontmatterMapping, 301 + ); 302 + const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 303 + slugField, 304 + removeIndexFromSlug, 305 + }); 166 306 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 - } 307 + posts.push({ 308 + filePath, 309 + slug, 310 + frontmatter, 311 + content: body, 312 + rawContent, 313 + rawFrontmatter, 314 + }); 315 + } catch (error) { 316 + console.error(`Error parsing ${relativePath}:`, error); 317 + } 318 + } 319 + } 179 320 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 - }); 321 + // Sort by publish date (newest first) 322 + posts.sort((a, b) => { 323 + const dateA = new Date(a.frontmatter.publishDate); 324 + const dateB = new Date(b.frontmatter.publishDate); 325 + return dateB.getTime() - dateA.getTime(); 326 + }); 186 327 187 - return posts; 328 + return posts; 188 329 } 189 330 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 === "+++"; 331 + export function updateFrontmatterWithAtUri( 332 + rawContent: string, 333 + atUri: string, 334 + ): string { 335 + // Detect which delimiter is used (---, +++, or ***) 336 + const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/); 337 + const delimiter = delimiterMatch?.[1] ?? "---"; 338 + const isToml = delimiter === "+++"; 195 339 196 - // Format the atUri entry based on frontmatter type 197 - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 340 + // Format the atUri entry based on frontmatter type 341 + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 198 342 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 - } 343 + // Check if atUri already exists in frontmatter (handle both formats) 344 + if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) { 345 + // Replace existing atUri (match both YAML and TOML formats) 346 + return rawContent.replace( 347 + /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, 348 + `${atUriEntry}\n`, 349 + ); 350 + } 204 351 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 - } 352 + // Insert atUri before the closing delimiter 353 + const frontmatterEndIndex = rawContent.indexOf(delimiter, 4); 354 + if (frontmatterEndIndex === -1) { 355 + throw new Error("Could not find frontmatter end"); 356 + } 210 357 211 - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 212 - const afterEnd = rawContent.slice(frontmatterEndIndex); 358 + const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 359 + const afterEnd = rawContent.slice(frontmatterEndIndex); 213 360 214 - return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 361 + return `${beforeEnd}${atUriEntry}\n${afterEnd}`; 215 362 } 216 363 217 364 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(); 365 + return markdown 366 + .replace(/#{1,6}\s/g, "") // Remove headers 367 + .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold 368 + .replace(/\*([^*]+)\*/g, "$1") // Remove italic 369 + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text 370 + .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks 371 + .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting 372 + .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images 373 + .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines 374 + .trim(); 228 375 }
+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 + }
+124
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 + } 18 + 19 + async function fileExists(filePath: string): Promise<boolean> { 20 + try { 21 + await fs.access(filePath); 22 + return true; 23 + } catch { 24 + return false; 25 + } 26 + } 27 + 28 + async function loadOAuthStore(): Promise<OAuthStore> { 29 + if (!(await fileExists(OAUTH_FILE))) { 30 + return { states: {}, sessions: {} }; 31 + } 32 + 33 + try { 34 + const content = await fs.readFile(OAUTH_FILE, "utf-8"); 35 + return JSON.parse(content) as OAuthStore; 36 + } catch { 37 + return { states: {}, sessions: {} }; 38 + } 39 + } 40 + 41 + async function saveOAuthStore(store: OAuthStore): Promise<void> { 42 + await fs.mkdir(CONFIG_DIR, { recursive: true }); 43 + await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2)); 44 + await fs.chmod(OAUTH_FILE, 0o600); 45 + } 46 + 47 + /** 48 + * State store for PKCE flow (temporary, used during auth) 49 + */ 50 + export const stateStore: NodeSavedStateStore = { 51 + async set(key: string, state: NodeSavedState): Promise<void> { 52 + const store = await loadOAuthStore(); 53 + store.states[key] = state; 54 + await saveOAuthStore(store); 55 + }, 56 + 57 + async get(key: string): Promise<NodeSavedState | undefined> { 58 + const store = await loadOAuthStore(); 59 + return store.states[key]; 60 + }, 61 + 62 + async del(key: string): Promise<void> { 63 + const store = await loadOAuthStore(); 64 + delete store.states[key]; 65 + await saveOAuthStore(store); 66 + }, 67 + }; 68 + 69 + /** 70 + * Session store for OAuth tokens (persistent) 71 + */ 72 + export const sessionStore: NodeSavedSessionStore = { 73 + async set(sub: string, session: NodeSavedSession): Promise<void> { 74 + const store = await loadOAuthStore(); 75 + store.sessions[sub] = session; 76 + await saveOAuthStore(store); 77 + }, 78 + 79 + async get(sub: string): Promise<NodeSavedSession | undefined> { 80 + const store = await loadOAuthStore(); 81 + return store.sessions[sub]; 82 + }, 83 + 84 + async del(sub: string): Promise<void> { 85 + const store = await loadOAuthStore(); 86 + delete store.sessions[sub]; 87 + await saveOAuthStore(store); 88 + }, 89 + }; 90 + 91 + /** 92 + * List all stored OAuth session DIDs 93 + */ 94 + export async function listOAuthSessions(): Promise<string[]> { 95 + const store = await loadOAuthStore(); 96 + return Object.keys(store.sessions); 97 + } 98 + 99 + /** 100 + * Get an OAuth session by DID 101 + */ 102 + export async function getOAuthSession( 103 + did: string, 104 + ): Promise<NodeSavedSession | undefined> { 105 + const store = await loadOAuthStore(); 106 + return store.sessions[did]; 107 + } 108 + 109 + /** 110 + * Delete an OAuth session by DID 111 + */ 112 + export async function deleteOAuthSession(did: string): Promise<boolean> { 113 + const store = await loadOAuthStore(); 114 + if (!store.sessions[did]) { 115 + return false; 116 + } 117 + delete store.sessions[did]; 118 + await saveOAuthStore(store); 119 + return true; 120 + } 121 + 122 + export function getOAuthStorePath(): string { 123 + return OAUTH_FILE; 124 + }
+6 -6
packages/cli/src/lib/prompts.ts
··· 1 - import { isCancel, cancel } from "@clack/prompts"; 1 + import { cancel, isCancel } from "@clack/prompts"; 2 2 3 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; 4 + if (isCancel(value)) { 5 + cancel("Cancelled"); 6 + process.exit(0); 7 + } 8 + return value as T; 9 9 }
+39 -1
packages/cli/src/lib/types.ts
··· 5 5 coverImage?: string; // Field name for cover image (default: "ogImage") 6 6 tags?: string; // Field name for tags (default: "tags") 7 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) 8 9 } 9 10 10 11 // Strong reference for Bluesky post (com.atproto.repo.strongRef) ··· 31 32 identity?: string; // Which stored identity to use (matches identifier) 32 33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 33 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 + textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 34 37 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 35 38 } 36 39 37 - export interface Credentials { 40 + // Legacy credentials format (for backward compatibility during migration) 41 + export interface LegacyCredentials { 38 42 pdsUrl: string; 39 43 identifier: string; 40 44 password: string; 41 45 } 42 46 47 + // App password credentials (explicit type) 48 + export interface AppPasswordCredentials { 49 + type: "app-password"; 50 + pdsUrl: string; 51 + identifier: string; 52 + password: string; 53 + } 54 + 55 + // OAuth credentials (references stored OAuth session) 56 + export interface OAuthCredentials { 57 + type: "oauth"; 58 + did: string; 59 + handle: string; 60 + pdsUrl: string; 61 + } 62 + 63 + // Union type for all credential types 64 + export type Credentials = AppPasswordCredentials | OAuthCredentials; 65 + 66 + // Helper to check credential type 67 + export function isOAuthCredentials( 68 + creds: Credentials, 69 + ): creds is OAuthCredentials { 70 + return creds.type === "oauth"; 71 + } 72 + 73 + export function isAppPasswordCredentials( 74 + creds: Credentials, 75 + ): creds is AppPasswordCredentials { 76 + return creds.type === "app-password"; 77 + } 78 + 43 79 export interface PostFrontmatter { 44 80 title: string; 45 81 description?: string; ··· 56 92 frontmatter: PostFrontmatter; 57 93 content: string; 58 94 rawContent: string; 95 + rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField 59 96 } 60 97 61 98 export interface BlobRef { ··· 77 114 contentHash: string; 78 115 atUri?: string; 79 116 lastPublished?: string; 117 + slug?: string; // The generated slug for this post (used by inject command) 80 118 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post 81 119 } 82 120
+20 -20
packages/cli/tsconfig.json
··· 1 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"] 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 22 }