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

Compare changes

Choose any two refs to compare.

+3840 -1191
+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 ."
+113
CHANGELOG.md
···
··· 1 + ## [0.3.1] - 2026-02-04 2 + 3 + ### ๐Ÿ› Bug Fixes 4 + 5 + - Asset subdirectories 6 + 7 + ### โš™๏ธ Miscellaneous Tasks 8 + 9 + - Updated authentication ux 10 + 11 + ## [0.3.0] - 2026-02-04 12 + 13 + ### ๐Ÿš€ Features 14 + 15 + - Initial oauth implementation 16 + - Add stripDatePrefix option for Jekyll-style filenames 17 + - Add `update` command 18 + 19 + ### โš™๏ธ Miscellaneous Tasks 20 + 21 + - Update changelog 22 + - Added workflows 23 + - Updated workflows 24 + - Updated workflows 25 + - Cleaned up types 26 + - Updated icon styles 27 + - Updated og image 28 + - Updated docs 29 + - Docs updates 30 + - Bumped version 31 + ## [0.2.1] - 2026-02-02 32 + 33 + ### โš™๏ธ Miscellaneous Tasks 34 + 35 + - Added CHANGELOG 36 + - Merge main into chore/fronmatter-config-updates 37 + - Added linting and formatting 38 + - Linting updates 39 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 40 + - Version bump 41 + ## [0.2.0] - 2026-02-01 42 + 43 + ### ๐Ÿš€ Features 44 + 45 + - Added bskyPostRef 46 + - Added draft field to frontmatter config 47 + 48 + ### โš™๏ธ Miscellaneous Tasks 49 + 50 + - Resolved action items from issue #3 51 + - Adjusted tags to accept yaml multiline arrays for tags 52 + - Updated inject to handle new slug options 53 + - Updated comments 54 + - Update blog post 55 + - Fix blog build error 56 + - Adjust blog post 57 + - Updated docs 58 + - Version bump 59 + ## [0.1.1] - 2026-01-31 60 + 61 + ### ๐Ÿ› Bug Fixes 62 + 63 + - Fix tangled url to repo 64 + 65 + ### โš™๏ธ Miscellaneous Tasks 66 + 67 + - Merge branch 'main' into feat/blog-post 68 + - Updated blog post 69 + - Updated date 70 + - Added publishing 71 + - Spelling and grammar 72 + - Updated package scripts 73 + - Refactored codebase to use node and fs instead of bun 74 + - Version bump 75 + ## [0.1.0] - 2026-01-30 76 + 77 + ### ๐Ÿš€ Features 78 + 79 + - Init 80 + - Added blog post 81 + 82 + ### โš™๏ธ Miscellaneous Tasks 83 + 84 + - Updated package.json 85 + - Cleaned up commands and libs 86 + - Updated init commands 87 + - Updated greeting 88 + - Updated readme 89 + - Link updates 90 + - Version bump 91 + - Added hugo support through frontmatter parsing 92 + - Version bump 93 + - Updated docs 94 + - Adapted inject.ts pattern 95 + - Updated docs 96 + - Version bump" 97 + - Updated package scripts 98 + - Updated scripts 99 + - Added ignore field to config 100 + - Udpate docs 101 + - Version bump 102 + - Added tags to flow 103 + - Added ability to exit during init flow 104 + - Version bump 105 + - Updated docs 106 + - Updated links 107 + - Updated docs 108 + - Initial refactor 109 + - Checkpoint 110 + - Refactored mapping 111 + - Docs updates 112 + - Docs updates 113 + - Version bump
+123 -10
bun.lock
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 - "version": "0.0.6", 28 "bin": { 29 - "sequoia": "dist/sequoia", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 "@clack/prompts": "^1.0.0", 34 "cmd-ts": "^0.14.3", 35 }, 36 "devDependencies": { 37 - "@types/bun": "latest", 38 }, 39 "peerDependencies": { 40 "typescript": "^5", ··· 44 "packages": { 45 "@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=="], 46 47 "@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=="], 48 49 "@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=="], 50 51 "@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=="], 52 53 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 54 55 "@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=="], 56 57 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 58 ··· 99 "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], 100 101 "@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=="], 102 103 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 104 ··· 187 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 188 189 "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], 190 191 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 192 ··· 524 525 "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], 526 527 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 528 529 - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 530 531 "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 532 ··· 586 587 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 588 589 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 590 591 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 633 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 634 635 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 636 637 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 638 ··· 732 733 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 734 735 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 736 737 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 834 835 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], 836 837 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 838 839 "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], ··· 890 891 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 892 893 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 894 895 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 896 897 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 898 899 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 900 901 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 902 903 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 905 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 906 907 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 908 909 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 910 ··· 913 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 914 915 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 916 917 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 918 ··· 1094 1095 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1096 1097 - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1098 1099 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 1100 1101 "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], 1102 1103 "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], 1104 1105 "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], ··· 1129 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1130 1131 "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=="], 1132 1133 "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=="], 1134 ··· 1150 1151 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1152 1153 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1154 1155 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], ··· 1169 "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=="], 1170 1171 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1172 1173 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1174 ··· 1244 1245 "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=="], 1246 1247 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1248 1249 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1336 1337 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1338 1339 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1340 1341 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 1342 ··· 1405 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1406 1407 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1408 1409 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1410 ··· 1442 1443 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1444 1445 "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], 1446 1447 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1448 ··· 1456 1457 "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], 1458 1459 "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 1460 1461 "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], ··· 1473 "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 1474 1475 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 1476 1477 "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 1478 ··· 1480 1481 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1482 1483 - "sequoia-cli/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 1484 - 1485 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1486 1487 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1488 1489 "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], 1490 1491 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1492 ··· 1498 1499 "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], 1500 1501 "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1502 1503 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1504 1505 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1506 - 1507 - "sequoia-cli/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 1508 } 1509 }
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 + "version": "0.2.1", 28 "bin": { 29 + "sequoia": "dist/index.js", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 34 "@clack/prompts": "^1.0.0", 35 "cmd-ts": "^0.14.3", 36 + "glob": "^13.0.0", 37 + "mime-types": "^2.1.35", 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 40 }, 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 43 + "@types/mime-types": "^3.0.1", 44 + "@types/node": "^20", 45 }, 46 "peerDependencies": { 47 "typescript": "^5", ··· 51 "packages": { 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=="], 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 + 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=="], 73 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=="], 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 + 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=="], 85 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=="], 87 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=="], 95 96 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 97 ··· 138 "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], 139 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=="], 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 160 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 161 ··· 244 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 245 246 "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], 247 + 248 + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], 249 + 250 + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], 251 252 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 253 ··· 585 586 "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], 587 588 + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], 589 + 590 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 591 592 + "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], 593 594 "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 595 ··· 649 650 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 651 652 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 653 + 654 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 655 656 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 698 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 699 700 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 701 + 702 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 703 704 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 705 ··· 799 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=="], 807 + 808 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 809 810 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 907 908 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], 909 910 + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], 911 + 912 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 913 914 "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], ··· 965 966 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 967 968 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 969 + 970 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 971 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=="], 973 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=="], 977 978 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 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 + 984 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 985 986 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 988 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 989 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=="], 993 994 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 995 ··· 998 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 999 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=="], 1003 1004 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 1005 ··· 1181 1182 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1183 1184 + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 1185 + 1186 + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 1187 1188 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 1189 1190 "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], 1191 1192 + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], 1193 + 1194 + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 1195 + 1196 "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], 1197 1198 "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], ··· 1222 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1223 1224 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1225 + 1226 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1227 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=="], 1229 ··· 1245 1246 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1247 1248 + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], 1249 + 1250 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1251 1252 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], ··· 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=="], 1267 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=="], 1271 1272 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1273 ··· 1343 1344 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1345 1346 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1347 + 1348 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1349 1350 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1437 1438 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1439 1440 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1441 + 1442 + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1443 1444 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 1445 ··· 1508 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1509 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=="], 1513 1514 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1515 ··· 1547 1548 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1549 1550 + "bun-types/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1551 + 1552 "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], 1553 + 1554 + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1555 1556 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1557 ··· 1565 1566 "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], 1567 1568 + "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1569 + 1570 "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 1571 1572 "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], ··· 1584 "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 1585 1586 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 1587 + 1588 + "path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], 1589 1590 "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 1591 ··· 1593 1594 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1595 1596 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1597 1598 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1599 1600 "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], 1601 + 1602 + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1603 1604 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1605 ··· 1611 1612 "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], 1613 1614 + "eval/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1615 + 1616 "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1617 1618 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1619 1620 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1621 } 1622 }
+2
docs/.gitignore
··· 1 .wrangler
··· 1 .wrangler 2 + 3 + .sequoia-state.json
+7
docs/docs/pages/blog/index.mdx
···
··· 1 + --- 2 + layout: minimal 3 + --- 4 + 5 + # Blog 6 + 7 + ::blog-posts
+54
docs/docs/pages/blog/introducing-sequoia.mdx
···
··· 1 + --- 2 + layout: minimal 3 + title: "Introducing Sequoia: Publishing for the Open Web" 4 + date: 2026-01-30 5 + atUri: "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v" 6 + --- 7 + 8 + # Introducing Sequoia: Publishing for the Open Web 9 + 10 + ![hero](/hero.png) 11 + 12 + Today I'm excited to release a new tool for the [AT Protocol](https://atproto.com): Sequoia. This is a CLI tool that can take your existing self-hosted blog and publish it to the ATmosphere using [Standard.site](https://standard.site) lexicons. 13 + 14 + If you haven't explored ATProto you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/), but in short, it's a new way to publish content to the web that puts ownership and control back in the hands of users. Blogs in some ways have already been doing this, but they've been missing a key piece: distribution. One of the unique features of ATProto is [lexicons](), which are schemas that apps build to create folders of content on a user's personal data server. The domain verified nature lets them be indexed and aggregated with ease. Outside of apps, lexicons can be extended by community members to build a common standard. That's exactly how [Standard.site](https://standard.site) was brought about, pushing a new way for standardizing publications and documents on ATProto. 15 + 16 + The founders and platforms behind the standard, [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), all serve to make creating and sharing blogs easy. If you are not a technical person and don't have a blog already, I would highly recommend checking all of them out! However, for those of us who already have blogs, there was a need for a tool that could make it easy to publish existing and new content with this new standard. Thus Sequoia was born. 17 + 18 + Sequoia is a relatively simple CLI that can do the following: 19 + - Authenticate with your ATProto handle 20 + - Configure your blog through an interactive setup process 21 + - Create publication and document records on your PDS 22 + - Add necessary verification pieces to your site 23 + - Sync with existing records on your PDS 24 + 25 + It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 + 27 + <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 + src="https://www.youtube.com/embed/sxursUHq5kw" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe> 37 + 38 + ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 39 + 40 + Install Sequoia today and check out the [quickstart guide](/quickstart) to publish your content into the ATmosphere ๐ŸŒณ 41 + 42 + :::code-group 43 + ```bash [npm] 44 + npm i -g sequoia-cli 45 + ``` 46 + 47 + ```bash [pnpm] 48 + pnpm i -g sequoia-cli 49 + ``` 50 + 51 + ```bash [bun] 52 + bun i -g sequoia-cli 53 + ``` 54 + :::
+34 -1
docs/docs/pages/cli-reference.mdx
··· 1 # CLI Reference 2 3 ## `auth` 4 5 ```bash [Terminal] 6 sequoia auth 7 - > Authenticate with your ATProto PDS 8 9 OPTIONS: 10 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 13 --list - List all stored identities [optional] 14 --help, -h - show help [optional] 15 ``` 16 17 ## `init` 18 ··· 61 --dry-run, -n - Preview what would be synced without making changes [optional] 62 --help, -h - show help [optional] 63 ```
··· 1 # CLI Reference 2 3 + ## `login` 4 + 5 + ```bash [Terminal] 6 + sequoia login 7 + > Login with OAuth (browser-based authentication) 8 + 9 + OPTIONS: 10 + --logout <str> - Remove OAuth session for a specific DID [optional] 11 + 12 + FLAGS: 13 + --list - List all stored OAuth sessions [optional] 14 + --help, -h - show help [optional] 15 + ``` 16 + 17 + OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically. 18 + 19 ## `auth` 20 21 ```bash [Terminal] 22 sequoia auth 23 + > Authenticate with your ATProto PDS using an app password 24 25 OPTIONS: 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 29 --list - List all stored identities [optional] 30 --help, -h - show help [optional] 31 ``` 32 + 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 35 ## `init` 36 ··· 79 --dry-run, -n - Preview what would be synced without making changes [optional] 80 --help, -h - show help [optional] 81 ``` 82 + 83 + ## `update` 84 + 85 + ```bash [Terminal] 86 + sequoia update 87 + > Update local config or ATProto publication record 88 + 89 + FLAGS: 90 + --help, -h - show help [optional] 91 + ``` 92 + 93 + Interactive command to modify your existing configuration. Choose between: 94 + 95 + - **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings 96 + - **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+41 -2
docs/docs/pages/config.mdx
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 18 19 ### Example 20 ··· 31 "frontmatter": { 32 "publishDate": "date" 33 }, 34 - "ignore": ["_index.md"] 35 } 36 ``` 37 ··· 44 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 45 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 46 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 47 48 ### Example 49 ··· 54 publishDate: 2024-01-15 55 ogImage: cover.jpg 56 tags: [welcome, intro] 57 --- 58 ``` 59 ··· 65 { 66 "frontmatter": { 67 "publishDate": "date", 68 - "coverImage": "thumbnail" 69 } 70 } 71 ``` 72 73 ### Ignoring Files 74
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 + | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 + | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 23 + | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 24 25 ### Example 26 ··· 37 "frontmatter": { 38 "publishDate": "date" 39 }, 40 + "ignore": ["_index.md"], 41 + "bluesky": { 42 + "enabled": true, 43 + "maxAgeDays": 30 44 + } 45 } 46 ``` 47 ··· 54 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 55 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 56 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 57 + | `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish | 58 59 ### Example 60 ··· 65 publishDate: 2024-01-15 66 ogImage: cover.jpg 67 tags: [welcome, intro] 68 + draft: false 69 --- 70 ``` 71 ··· 77 { 78 "frontmatter": { 79 "publishDate": "date", 80 + "coverImage": "thumbnail", 81 + "draft": "private" 82 + } 83 + } 84 + ``` 85 + 86 + ### Slug Configuration 87 + 88 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 89 + 90 + ```json 91 + { 92 + "frontmatter": { 93 + "slugField": "url" 94 } 95 } 96 ``` 97 + 98 + If the frontmatter field is not found, it falls back to the filepath. 99 + 100 + ### Jekyll-Style Date Prefixes 101 + 102 + Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs: 103 + 104 + ```json 105 + { 106 + "stripDatePrefix": true 107 + } 108 + ``` 109 + 110 + This transforms `2024-01-15-my-post.md` into the slug `my-post`. 111 112 ### Ignoring Files 113
+40 -2
docs/docs/pages/publishing.mdx
··· 10 sequoia publish --dry-run 11 ``` 12 13 - This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it! 14 15 ```bash [Terminal] 16 sequoia publish ··· 23 If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command. 24 25 ```bash [Terminal] 26 - seuqoia sync 27 ``` 28 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 31 ## Troubleshooting 32
··· 10 sequoia publish --dry-run 11 ``` 12 13 + This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it! 14 15 ```bash [Terminal] 16 sequoia publish ··· 23 If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command. 24 25 ```bash [Terminal] 26 + sequoia sync 27 ``` 28 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 + 31 + ## Bluesky Posting 32 + 33 + Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config: 34 + 35 + ```json 36 + { 37 + "bluesky": { 38 + "enabled": true, 39 + "maxAgeDays": 30 40 + } 41 + } 42 + ``` 43 + 44 + When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters. 45 + 46 + The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky. 47 + 48 + ## Draft Posts 49 + 50 + Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it. 51 + 52 + ```yaml 53 + --- 54 + title: Work in Progress 55 + draft: true 56 + --- 57 + ``` 58 + 59 + If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`: 60 + 61 + ```json 62 + { 63 + "frontmatter": { 64 + "draft": "private" 65 + } 66 + } 67 + ``` 68 69 ## Troubleshooting 70
+10 -8
docs/docs/pages/quickstart.mdx
··· 31 sequoia 32 ``` 33 34 - ### Authorize 35 - 36 - In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password. 37 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 41 42 ```bash [Terminal] 43 - sequoia auth 44 ``` 45 46 ### Initialize 47 ··· 59 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 60 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 61 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 62 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 63 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 64 - **Publication name** - The name of your blog 65 - **Publication description** - A description for your blog
··· 31 sequoia 32 ``` 33 34 + ### Login 35 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 37 38 ```bash [Terminal] 39 + sequoia login 40 ``` 41 + 42 + This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI. 43 + 44 + :::tip 45 + Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth. 46 + ::: 47 48 ### Initialize 49 ··· 61 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 62 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 63 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 64 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 65 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 66 - **Publication name** - The name of your blog 67 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/setup.mdx
··· 28 29 ## Authorize 30 31 - In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password. 32 33 :::tip 34 You can create an app password [here](https://bsky.app/settings/app-passwords) ··· 56 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 57 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 58 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 59 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 60 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 61 - **Publication name** - The name of your blog 62 - **Publication description** - A description for your blog
··· 28 29 ## Authorize 30 31 + 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. 32 33 :::tip 34 You can create an app password [here](https://bsky.app/settings/app-passwords) ··· 56 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 57 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 58 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 59 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 60 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 61 - **Publication name** - The name of your blog 62 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/verifying.mdx
··· 3 In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. 4 5 :::tip 6 - You an learn more about Standard.site verification [here](https://standard.site/) 7 ::: 8 9 ## Publication Verification ··· 22 23 ### pds.ls 24 25 - Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as perscribed on their website. 26 27 ### Standard.site Validator 28
··· 3 In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. 4 5 :::tip 6 + You can learn more about Standard.site verification [here](https://standard.site/) 7 ::: 8 9 ## Publication Verification ··· 22 23 ### pds.ls 24 25 + Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as prescribed on their website. 26 27 ### Standard.site Validator 28
+2 -2
docs/docs/pages/what-is-sequoia.mdx
··· 3 Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down. 4 5 - [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/). 6 - - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. The unique property to lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 - - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specailly designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 8 9 The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too. 10
··· 3 Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down. 4 5 - [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/). 6 + - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. A unique property of lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 + - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specially designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 8 9 The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too. 10
docs/docs/public/.well-known/.gitkeep

This is a binary file and will not be displayed.

+1
docs/docs/public/.well-known/site.standard.publication
···
··· 1 + at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v
docs/docs/public/hero.png

This is a binary file and will not be displayed.

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.

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