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

Compare changes

Choose any two refs to compare.

+1987 -95
+1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 + packages/ui
+22
.tangled/workflows/lint.yml
··· 1 + # Biome lint and format checks 2 + 3 + when: 4 + - event: ["push", "manual"] 5 + branch: ["main"] 6 + - event: ["pull_request"] 7 + branch: ["main"] 8 + 9 + engine: "nixery" 10 + 11 + dependencies: 12 + nixpkgs: 13 + - bun 14 + - biome 15 + 16 + steps: 17 + - name: "Install dependencies" 18 + command: "bun install" 19 + - name: "Lint check" 20 + command: "cd packages/cli && biome lint ." 21 + - name: "Format check" 22 + command: "cd packages/cli && biome format ."
+61
CHANGELOG.md
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### ๐Ÿ› Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### โš™๏ธ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 17 + ## [0.3.1] - 2026-02-04 18 + 19 + ### ๐Ÿ› Bug Fixes 20 + 21 + - Asset subdirectories 22 + 23 + ### โš™๏ธ Miscellaneous Tasks 24 + 25 + - Updated authentication ux 26 + - Release 0.3.1 27 + - Bumped version 28 + ## [0.3.0] - 2026-02-04 29 + 30 + ### ๐Ÿš€ Features 31 + 32 + - Initial oauth implementation 33 + - Add stripDatePrefix option for Jekyll-style filenames 34 + - Add `update` command 35 + 36 + ### โš™๏ธ Miscellaneous Tasks 37 + 38 + - Update changelog 39 + - Added workflows 40 + - Updated workflows 41 + - Updated workflows 42 + - Cleaned up types 43 + - Updated icon styles 44 + - Updated og image 45 + - Updated docs 46 + - Docs updates 47 + - Bumped version 48 + ## [0.2.1] - 2026-02-02 49 + 50 + ### โš™๏ธ Miscellaneous Tasks 51 + 52 + - Added CHANGELOG 53 + - Merge main into chore/fronmatter-config-updates 54 + - Added linting and formatting 55 + - Linting updates 56 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 57 + - Version bump 1 58 ## [0.2.0] - 2026-02-01 2 59 3 60 ### ๐Ÿš€ Features ··· 7 64 8 65 ### โš™๏ธ Miscellaneous Tasks 9 66 67 + - Resolved action items from issue #3 68 + - Adjusted tags to accept yaml multiline arrays for tags 69 + - Updated inject to handle new slug options 70 + - Updated comments 10 71 - Update blog post 11 72 - Fix blog build error 12 73 - Adjust blog post
+80 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.2.0", 27 + "version": "0.3.2", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 33 34 "@clack/prompts": "^1.0.0", 34 35 "cmd-ts": "^0.14.3", 35 36 "glob": "^13.0.0", 36 37 "mime-types": "^2.1.35", 37 38 "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 38 40 }, 39 41 "devDependencies": { 40 42 "@biomejs/biome": "^2.3.13", ··· 45 47 "typescript": "^5", 46 48 }, 47 49 }, 50 + "packages/ui": { 51 + "name": "sequoia-ui", 52 + "version": "0.1.0", 53 + "devDependencies": { 54 + "@biomejs/biome": "^2.3.13", 55 + "@types/node": "^20", 56 + }, 57 + "peerDependencies": { 58 + "typescript": "^5", 59 + }, 60 + }, 48 61 }, 49 62 "packages": { 50 63 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 51 64 65 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 66 + 67 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 68 + 69 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 70 + 71 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 72 + 73 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], 74 + 75 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 76 + 77 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 78 + 79 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 80 + 81 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 82 + 52 83 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 53 84 54 85 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 86 + 87 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 88 + 89 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 90 + 91 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 92 + 93 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 55 94 56 95 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 57 96 ··· 59 98 60 99 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 61 100 101 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 102 + 103 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 104 + 105 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 106 + 62 107 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 63 108 64 109 "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], ··· 615 660 616 661 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 617 662 663 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 664 + 618 665 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 619 666 620 667 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 662 709 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 663 710 664 711 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 712 + 713 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 665 714 666 715 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 667 716 ··· 761 810 762 811 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 763 812 813 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 814 + 815 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 816 + 817 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 818 + 764 819 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 765 820 766 821 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 921 976 922 977 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 923 978 979 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 980 + 924 981 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 925 982 926 983 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 927 984 928 985 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 986 + 987 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 929 988 930 989 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 931 990 991 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 992 + 993 + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], 994 + 932 995 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 933 996 934 997 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 937 1000 938 1001 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 939 1002 1003 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 1004 + 940 1005 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 941 1006 942 1007 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 944 1009 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 945 1010 946 1011 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1012 + 1013 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 947 1014 948 1015 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 949 1016 ··· 1166 1233 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1167 1234 1168 1235 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1236 + 1237 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1169 1238 1170 1239 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1171 1240 ··· 1209 1278 1210 1279 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1211 1280 1281 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1282 + 1212 1283 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1213 1284 1214 1285 "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], ··· 1283 1354 1284 1355 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1285 1356 1357 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1358 + 1286 1359 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1287 1360 1288 1361 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1297 1370 1298 1371 "sequoia-cli": ["sequoia-cli@workspace:packages/cli"], 1299 1372 1373 + "sequoia-ui": ["sequoia-ui@workspace:packages/ui"], 1374 + 1300 1375 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], 1301 1376 1302 1377 "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], ··· 1375 1450 1376 1451 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1377 1452 1453 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1454 + 1378 1455 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1379 1456 1380 1457 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1444 1521 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1445 1522 1446 1523 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1524 + 1525 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1447 1526 1448 1527 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1449 1528
+34 -1
docs/docs/pages/cli-reference.mdx
··· 1 1 # CLI Reference 2 2 3 + ## `login` 4 + 5 + ```bash [Terminal] 6 + sequoia login 7 + > Login with OAuth (browser-based authentication) 8 + 9 + OPTIONS: 10 + --logout <str> - Remove OAuth session for a specific DID [optional] 11 + 12 + FLAGS: 13 + --list - List all stored OAuth sessions [optional] 14 + --help, -h - show help [optional] 15 + ``` 16 + 17 + OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically. 18 + 3 19 ## `auth` 4 20 5 21 ```bash [Terminal] 6 22 sequoia auth 7 - > Authenticate with your ATProto PDS 23 + > Authenticate with your ATProto PDS using an app password 8 24 9 25 OPTIONS: 10 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 13 29 --list - List all stored identities [optional] 14 30 --help, -h - show help [optional] 15 31 ``` 32 + 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 16 34 17 35 ## `init` 18 36 ··· 61 79 --dry-run, -n - Preview what would be synced without making changes [optional] 62 80 --help, -h - show help [optional] 63 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
+13
docs/docs/pages/config.mdx
··· 17 17 | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 18 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 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) | 20 21 | `bluesky` | `object` | No | - | Bluesky posting configuration | 21 22 | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 22 23 | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | ··· 95 96 ``` 96 97 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`. 98 111 99 112 ### Ignoring Files 100 113
+9 -7
docs/docs/pages/quickstart.mdx
··· 31 31 sequoia 32 32 ``` 33 33 34 - ### Authorize 35 - 36 - In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password. 34 + ### Login 37 35 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 41 37 42 38 ```bash [Terminal] 43 - sequoia auth 39 + sequoia login 44 40 ``` 41 + 42 + This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI. 43 + 44 + :::tip 45 + Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth. 46 + ::: 45 47 46 48 ### Initialize 47 49
docs/docs/public/icon-dark.png

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

+4 -2
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.2.1", 3 + "version": "0.3.3", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js" ··· 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 33 34 "@clack/prompts": "^1.0.0", 34 35 "cmd-ts": "^0.14.3", 35 36 "glob": "^13.0.0", 36 37 "mime-types": "^2.1.35", 37 - "minimatch": "^10.1.1" 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0" 38 40 } 39 41 }
+1
packages/cli/src/commands/auth.ts
··· 158 158 159 159 // Save credentials 160 160 await saveCredentials({ 161 + type: "app-password", 161 162 pdsUrl, 162 163 identifier: identifier, 163 164 password: appPassword,
+28 -7
packages/cli/src/commands/init.ts
··· 13 13 } from "@clack/prompts"; 14 14 import * as path from "node:path"; 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 - import { loadCredentials } from "../lib/credentials"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 + import { selectCredential } from "../lib/credential-select"; 18 19 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 20 21 async function fileExists(filePath: string): Promise<boolean> { ··· 186 187 } 187 188 188 189 let publicationUri: string; 189 - const credentials = await loadCredentials(); 190 + let credentials = await loadCredentials(); 190 191 191 192 if (publicationChoice === "create") { 192 193 // Need credentials to create a publication 193 194 if (!credentials) { 195 + // Check if there are multiple identities - if so, prompt to select 196 + const allCredentials = await listAllCredentials(); 197 + if (allCredentials.length > 1) { 198 + credentials = await selectCredential(allCredentials); 199 + } else if (allCredentials.length === 1) { 200 + // Single credential exists but couldn't be loaded - try to load it explicitly 201 + credentials = await selectCredential(allCredentials); 202 + } else { 203 + log.error( 204 + "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 205 + ); 206 + process.exit(1); 207 + } 208 + } 209 + 210 + if (!credentials) { 194 211 log.error( 195 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 196 213 ); 197 214 process.exit(1); 198 215 } ··· 206 223 } catch (_error) { 207 224 s.stop("Failed to connect"); 208 225 log.error( 209 - "Failed to connect. Check your credentials with 'sequoia auth'.", 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 210 227 ); 211 228 process.exit(1); 212 229 } ··· 287 304 defaultValue: "7", 288 305 placeholder: "7", 289 306 validate: (value) => { 290 - const num = parseInt(value, 10); 307 + if (!value) { 308 + return "Please enter a number"; 309 + } 310 + const num = Number.parseInt(value, 10); 291 311 if (Number.isNaN(num) || num < 1) { 292 312 return "Please enter a positive number"; 293 313 } ··· 305 325 }; 306 326 } 307 327 308 - // Get PDS URL from credentials (already loaded earlier) 309 - const pdsUrl = credentials?.pdsUrl; 328 + // Get PDS URL from credentials (only available for app-password auth) 329 + const pdsUrl = 330 + credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 310 331 311 332 // Generate config file 312 333 const configContent = generateConfigTemplate({
+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 + }
+50 -8
packages/cli/src/commands/publish.ts
··· 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 6 import { 7 7 loadCredentials, 8 - listCredentials, 8 + listAllCredentials, 9 9 getCredentials, 10 10 } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 11 12 import { 12 13 createAgent, 13 14 createDocument, ··· 59 60 60 61 // If no credentials resolved, check if we need to prompt for identity selection 61 62 if (!credentials) { 62 - const identities = await listCredentials(); 63 + const identities = await listAllCredentials(); 63 64 if (identities.length === 0) { 64 - log.error("No credentials found. Run 'sequoia auth' first."); 65 + log.error( 66 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 67 + ); 65 68 log.info( 66 69 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 67 70 ); 68 71 process.exit(1); 69 72 } 70 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 + 71 91 // Multiple identities exist but none selected - prompt user 72 92 log.info("Multiple identities found. Select one to use:"); 73 93 const selected = exitOnCancel( 74 94 await select({ 75 95 message: "Identity:", 76 - options: identities.map((id) => ({ value: id, label: id })), 96 + options, 77 97 }), 78 98 ); 79 99 80 - credentials = await getCredentials(selected); 100 + // Load the selected credentials 101 + const selectedCred = identities.find((c) => c.id === selected); 102 + if (selectedCred?.type === "oauth") { 103 + const session = await getOAuthSession(selected); 104 + if (session) { 105 + const handle = await getOAuthHandle(selected); 106 + credentials = { 107 + type: "oauth", 108 + did: selected, 109 + handle: handle || selected, 110 + }; 111 + } 112 + } else { 113 + credentials = await getCredentials(selected); 114 + } 115 + 81 116 if (!credentials) { 82 117 log.error("Failed to load selected credentials."); 83 118 process.exit(1); 84 119 } 85 120 121 + const displayId = 122 + credentials.type === "oauth" 123 + ? credentials.handle || credentials.did 124 + : credentials.identifier; 86 125 log.info( 87 - `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`, 126 + `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 88 127 ); 89 128 } 90 129 ··· 110 149 ignorePatterns: config.ignore, 111 150 slugField: config.frontmatter?.slugField, 112 151 removeIndexFromSlug: config.removeIndexFromSlug, 152 + stripDatePrefix: config.stripDatePrefix, 113 153 }); 114 154 s.stop(`Found ${posts.length} posts`); 115 155 ··· 205 245 } 206 246 207 247 // Create agent 208 - s.start(`Connecting to ${credentials.pdsUrl}...`); 248 + const connectingTo = 249 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 250 + s.start(`Connecting as ${connectingTo}...`); 209 251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 210 252 try { 211 253 agent = await createAgent(credentials); 212 - s.stop(`Logged in as ${agent.session?.handle}`); 254 + s.stop(`Logged in as ${agent.did}`); 213 255 } catch (error) { 214 256 s.stop("Failed to login"); 215 257 log.error(`Failed to login: ${error}`);
+45 -7
packages/cli/src/commands/sync.ts
··· 5 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 6 import { 7 7 loadCredentials, 8 - listCredentials, 8 + listAllCredentials, 9 9 getCredentials, 10 10 } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 11 12 import { createAgent, listDocuments } from "../lib/atproto"; 12 13 import { 13 14 scanContentDirectory, ··· 49 50 let credentials = await loadCredentials(config.identity); 50 51 51 52 if (!credentials) { 52 - const identities = await listCredentials(); 53 + const identities = await listAllCredentials(); 53 54 if (identities.length === 0) { 54 - log.error("No credentials found. Run 'sequoia auth' first."); 55 + log.error( 56 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 57 + ); 55 58 process.exit(1); 56 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 + ); 57 77 58 78 log.info("Multiple identities found. Select one to use:"); 59 79 const selected = exitOnCancel( 60 80 await select({ 61 81 message: "Identity:", 62 - options: identities.map((id) => ({ value: id, label: id })), 82 + options, 63 83 }), 64 84 ); 65 85 66 - credentials = await getCredentials(selected); 86 + // Load the selected credentials 87 + const selectedCred = identities.find((c) => c.id === selected); 88 + if (selectedCred?.type === "oauth") { 89 + const session = await getOAuthSession(selected); 90 + if (session) { 91 + const handle = await getOAuthHandle(selected); 92 + credentials = { 93 + type: "oauth", 94 + did: selected, 95 + handle: handle || selected, 96 + }; 97 + } 98 + } else { 99 + credentials = await getCredentials(selected); 100 + } 101 + 67 102 if (!credentials) { 68 103 log.error("Failed to load selected credentials."); 69 104 process.exit(1); ··· 72 107 73 108 // Create agent 74 109 const s = spinner(); 75 - s.start(`Connecting to ${credentials.pdsUrl}...`); 110 + const connectingTo = 111 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 112 + s.start(`Connecting as ${connectingTo}...`); 76 113 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 77 114 try { 78 115 agent = await createAgent(credentials); 79 - s.stop(`Logged in as ${agent.session?.handle}`); 116 + s.stop(`Logged in as ${agent.did}`); 80 117 } catch (error) { 81 118 s.stop("Failed to login"); 82 119 log.error(`Failed to login: ${error}`); ··· 105 142 ignorePatterns: config.ignore, 106 143 slugField: config.frontmatter?.slugField, 107 144 removeIndexFromSlug: config.removeIndexFromSlug, 145 + stripDatePrefix: config.stripDatePrefix, 108 146 }); 109 147 s.stop(`Found ${localPosts.length} local posts`); 110 148
+624
packages/cli/src/commands/update.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command } from "cmd-ts"; 3 + import { 4 + intro, 5 + outro, 6 + note, 7 + text, 8 + confirm, 9 + select, 10 + spinner, 11 + log, 12 + } from "@clack/prompts"; 13 + import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 + import { 15 + loadCredentials, 16 + listAllCredentials, 17 + getCredentials, 18 + } from "../lib/credentials"; 19 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 20 + import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 21 + import { exitOnCancel } from "../lib/prompts"; 22 + import type { 23 + PublisherConfig, 24 + FrontmatterMapping, 25 + BlueskyConfig, 26 + } from "../lib/types"; 27 + 28 + export const updateCommand = command({ 29 + name: "update", 30 + description: "Update local config or ATProto publication record", 31 + args: {}, 32 + handler: async () => { 33 + intro("Sequoia Update"); 34 + 35 + // Check if config exists 36 + const configPath = await findConfig(); 37 + if (!configPath) { 38 + log.error("No configuration found. Run 'sequoia init' first."); 39 + process.exit(1); 40 + } 41 + 42 + const config = await loadConfig(configPath); 43 + 44 + // Ask what to update 45 + const updateChoice = exitOnCancel( 46 + await select({ 47 + message: "What would you like to update?", 48 + options: [ 49 + { label: "Local configuration (sequoia.json)", value: "config" }, 50 + { label: "ATProto publication record", value: "publication" }, 51 + ], 52 + }), 53 + ); 54 + 55 + if (updateChoice === "config") { 56 + await updateConfigFlow(config, configPath); 57 + } else { 58 + await updatePublicationFlow(config); 59 + } 60 + 61 + outro("Update complete!"); 62 + }, 63 + }); 64 + 65 + async function updateConfigFlow( 66 + config: PublisherConfig, 67 + configPath: string, 68 + ): Promise<void> { 69 + // Show current config summary 70 + const configSummary = [ 71 + `Site URL: ${config.siteUrl}`, 72 + `Content Dir: ${config.contentDir}`, 73 + `Path Prefix: ${config.pathPrefix || "/posts"}`, 74 + `Publication URI: ${config.publicationUri}`, 75 + config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, 76 + config.outputDir ? `Output Dir: ${config.outputDir}` : null, 77 + config.bluesky?.enabled ? `Bluesky: enabled` : null, 78 + ] 79 + .filter(Boolean) 80 + .join("\n"); 81 + 82 + note(configSummary, "Current Configuration"); 83 + 84 + let configUpdated = { ...config }; 85 + let editing = true; 86 + 87 + while (editing) { 88 + const section = exitOnCancel( 89 + await select({ 90 + message: "Select a section to edit:", 91 + options: [ 92 + { label: "Site settings (siteUrl, pathPrefix)", value: "site" }, 93 + { 94 + label: 95 + "Directory paths (contentDir, imagesDir, publicDir, outputDir)", 96 + value: "directories", 97 + }, 98 + { 99 + label: 100 + "Frontmatter mappings (title, description, publishDate, etc.)", 101 + value: "frontmatter", 102 + }, 103 + { 104 + label: 105 + "Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)", 106 + value: "advanced", 107 + }, 108 + { 109 + label: "Bluesky settings (enabled, maxAgeDays)", 110 + value: "bluesky", 111 + }, 112 + { label: "Done editing", value: "done" }, 113 + ], 114 + }), 115 + ); 116 + 117 + if (section === "done") { 118 + editing = false; 119 + continue; 120 + } 121 + 122 + switch (section) { 123 + case "site": 124 + configUpdated = await editSiteSettings(configUpdated); 125 + break; 126 + case "directories": 127 + configUpdated = await editDirectories(configUpdated); 128 + break; 129 + case "frontmatter": 130 + configUpdated = await editFrontmatter(configUpdated); 131 + break; 132 + case "advanced": 133 + configUpdated = await editAdvanced(configUpdated); 134 + break; 135 + case "bluesky": 136 + configUpdated = await editBluesky(configUpdated); 137 + break; 138 + } 139 + } 140 + 141 + // Confirm before saving 142 + const shouldSave = exitOnCancel( 143 + await confirm({ 144 + message: "Save changes to sequoia.json?", 145 + initialValue: true, 146 + }), 147 + ); 148 + 149 + if (shouldSave) { 150 + const configContent = generateConfigTemplate({ 151 + siteUrl: configUpdated.siteUrl, 152 + contentDir: configUpdated.contentDir, 153 + imagesDir: configUpdated.imagesDir, 154 + publicDir: configUpdated.publicDir, 155 + outputDir: configUpdated.outputDir, 156 + pathPrefix: configUpdated.pathPrefix, 157 + publicationUri: configUpdated.publicationUri, 158 + pdsUrl: configUpdated.pdsUrl, 159 + frontmatter: configUpdated.frontmatter, 160 + ignore: configUpdated.ignore, 161 + removeIndexFromSlug: configUpdated.removeIndexFromSlug, 162 + stripDatePrefix: configUpdated.stripDatePrefix, 163 + textContentField: configUpdated.textContentField, 164 + bluesky: configUpdated.bluesky, 165 + }); 166 + 167 + await fs.writeFile(configPath, configContent); 168 + log.success("Configuration saved!"); 169 + } else { 170 + log.info("Changes discarded."); 171 + } 172 + } 173 + 174 + async function editSiteSettings( 175 + config: PublisherConfig, 176 + ): Promise<PublisherConfig> { 177 + const siteUrl = exitOnCancel( 178 + await text({ 179 + message: "Site URL:", 180 + initialValue: config.siteUrl, 181 + validate: (value) => { 182 + if (!value) return "Site URL is required"; 183 + try { 184 + new URL(value); 185 + } catch { 186 + return "Please enter a valid URL"; 187 + } 188 + }, 189 + }), 190 + ); 191 + 192 + const pathPrefix = exitOnCancel( 193 + await text({ 194 + message: "URL path prefix for posts:", 195 + initialValue: config.pathPrefix || "/posts", 196 + }), 197 + ); 198 + 199 + return { 200 + ...config, 201 + siteUrl, 202 + pathPrefix: pathPrefix || undefined, 203 + }; 204 + } 205 + 206 + async function editDirectories( 207 + config: PublisherConfig, 208 + ): Promise<PublisherConfig> { 209 + const contentDir = exitOnCancel( 210 + await text({ 211 + message: "Content directory:", 212 + initialValue: config.contentDir, 213 + validate: (value) => { 214 + if (!value) return "Content directory is required"; 215 + }, 216 + }), 217 + ); 218 + 219 + const imagesDir = exitOnCancel( 220 + await text({ 221 + message: "Cover images directory (leave empty to skip):", 222 + initialValue: config.imagesDir || "", 223 + }), 224 + ); 225 + 226 + const publicDir = exitOnCancel( 227 + await text({ 228 + message: "Public/static directory:", 229 + initialValue: config.publicDir || "./public", 230 + }), 231 + ); 232 + 233 + const outputDir = exitOnCancel( 234 + await text({ 235 + message: "Build output directory:", 236 + initialValue: config.outputDir || "./dist", 237 + }), 238 + ); 239 + 240 + return { 241 + ...config, 242 + contentDir, 243 + imagesDir: imagesDir || undefined, 244 + publicDir: publicDir || undefined, 245 + outputDir: outputDir || undefined, 246 + }; 247 + } 248 + 249 + async function editFrontmatter( 250 + config: PublisherConfig, 251 + ): Promise<PublisherConfig> { 252 + const currentFrontmatter = config.frontmatter || {}; 253 + 254 + log.info("Press Enter to keep current value, or type a new field name."); 255 + 256 + const titleField = exitOnCancel( 257 + await text({ 258 + message: "Field name for title:", 259 + initialValue: currentFrontmatter.title || "title", 260 + }), 261 + ); 262 + 263 + const descField = exitOnCancel( 264 + await text({ 265 + message: "Field name for description:", 266 + initialValue: currentFrontmatter.description || "description", 267 + }), 268 + ); 269 + 270 + const dateField = exitOnCancel( 271 + await text({ 272 + message: "Field name for publish date:", 273 + initialValue: currentFrontmatter.publishDate || "publishDate", 274 + }), 275 + ); 276 + 277 + const coverField = exitOnCancel( 278 + await text({ 279 + message: "Field name for cover image:", 280 + initialValue: currentFrontmatter.coverImage || "ogImage", 281 + }), 282 + ); 283 + 284 + const tagsField = exitOnCancel( 285 + await text({ 286 + message: "Field name for tags:", 287 + initialValue: currentFrontmatter.tags || "tags", 288 + }), 289 + ); 290 + 291 + const draftField = exitOnCancel( 292 + await text({ 293 + message: "Field name for draft status:", 294 + initialValue: currentFrontmatter.draft || "draft", 295 + }), 296 + ); 297 + 298 + const slugField = exitOnCancel( 299 + await text({ 300 + message: "Field name for slug (leave empty to use filepath):", 301 + initialValue: currentFrontmatter.slugField || "", 302 + }), 303 + ); 304 + 305 + // Build frontmatter mapping, only including non-default values 306 + const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ 307 + ["title", titleField, "title"], 308 + ["description", descField, "description"], 309 + ["publishDate", dateField, "publishDate"], 310 + ["coverImage", coverField, "ogImage"], 311 + ["tags", tagsField, "tags"], 312 + ["draft", draftField, "draft"], 313 + ]; 314 + 315 + const builtMapping = fieldMappings.reduce<FrontmatterMapping>( 316 + (acc, [key, value, defaultValue]) => { 317 + if (value !== defaultValue) { 318 + acc[key] = value; 319 + } 320 + return acc; 321 + }, 322 + {}, 323 + ); 324 + 325 + // Handle slugField separately since it has no default 326 + if (slugField) { 327 + builtMapping.slugField = slugField; 328 + } 329 + 330 + const frontmatter = 331 + Object.keys(builtMapping).length > 0 ? builtMapping : undefined; 332 + 333 + return { 334 + ...config, 335 + frontmatter, 336 + }; 337 + } 338 + 339 + async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> { 340 + const pdsUrl = exitOnCancel( 341 + await text({ 342 + message: "PDS URL (leave empty for default bsky.social):", 343 + initialValue: config.pdsUrl || "", 344 + }), 345 + ); 346 + 347 + const identity = exitOnCancel( 348 + await text({ 349 + message: "Identity/profile to use (leave empty for auto-detect):", 350 + initialValue: config.identity || "", 351 + }), 352 + ); 353 + 354 + const ignoreInput = exitOnCancel( 355 + await text({ 356 + message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):", 357 + initialValue: config.ignore?.join(", ") || "", 358 + }), 359 + ); 360 + 361 + const removeIndexFromSlug = exitOnCancel( 362 + await confirm({ 363 + message: "Remove /index or /_index suffix from paths?", 364 + initialValue: config.removeIndexFromSlug || false, 365 + }), 366 + ); 367 + 368 + const stripDatePrefix = exitOnCancel( 369 + await confirm({ 370 + message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?", 371 + initialValue: config.stripDatePrefix || false, 372 + }), 373 + ); 374 + 375 + const textContentField = exitOnCancel( 376 + await text({ 377 + message: 378 + "Frontmatter field for textContent (leave empty to use markdown body):", 379 + initialValue: config.textContentField || "", 380 + }), 381 + ); 382 + 383 + // Parse ignore patterns 384 + const ignore = ignoreInput 385 + ? ignoreInput 386 + .split(",") 387 + .map((p) => p.trim()) 388 + .filter(Boolean) 389 + : undefined; 390 + 391 + return { 392 + ...config, 393 + pdsUrl: pdsUrl || undefined, 394 + identity: identity || undefined, 395 + ignore: ignore && ignore.length > 0 ? ignore : undefined, 396 + removeIndexFromSlug: removeIndexFromSlug || undefined, 397 + stripDatePrefix: stripDatePrefix || undefined, 398 + textContentField: textContentField || undefined, 399 + }; 400 + } 401 + 402 + async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> { 403 + const enabled = exitOnCancel( 404 + await confirm({ 405 + message: "Enable automatic Bluesky posting when publishing?", 406 + initialValue: config.bluesky?.enabled || false, 407 + }), 408 + ); 409 + 410 + if (!enabled) { 411 + return { 412 + ...config, 413 + bluesky: undefined, 414 + }; 415 + } 416 + 417 + const maxAgeDaysInput = exitOnCancel( 418 + await text({ 419 + message: "Maximum age (in days) for posts to be shared on Bluesky:", 420 + initialValue: String(config.bluesky?.maxAgeDays || 7), 421 + validate: (value) => { 422 + if (!value) return "Please enter a number"; 423 + const num = Number.parseInt(value, 10); 424 + if (Number.isNaN(num) || num < 1) { 425 + return "Please enter a positive number"; 426 + } 427 + }, 428 + }), 429 + ); 430 + 431 + const maxAgeDays = parseInt(maxAgeDaysInput, 10); 432 + 433 + const bluesky: BlueskyConfig = { 434 + enabled: true, 435 + ...(maxAgeDays !== 7 && { maxAgeDays }), 436 + }; 437 + 438 + return { 439 + ...config, 440 + bluesky, 441 + }; 442 + } 443 + 444 + async function updatePublicationFlow(config: PublisherConfig): Promise<void> { 445 + // Load credentials 446 + let credentials = await loadCredentials(config.identity); 447 + 448 + if (!credentials) { 449 + const identities = await listAllCredentials(); 450 + if (identities.length === 0) { 451 + log.error( 452 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 453 + ); 454 + process.exit(1); 455 + } 456 + 457 + // Build labels with handles for OAuth sessions 458 + const options = await Promise.all( 459 + identities.map(async (cred) => { 460 + if (cred.type === "oauth") { 461 + const handle = await getOAuthHandle(cred.id); 462 + return { 463 + value: cred.id, 464 + label: `${handle || cred.id} (OAuth)`, 465 + }; 466 + } 467 + return { 468 + value: cred.id, 469 + label: `${cred.id} (App Password)`, 470 + }; 471 + }), 472 + ); 473 + 474 + log.info("Multiple identities found. Select one to use:"); 475 + const selected = exitOnCancel( 476 + await select({ 477 + message: "Identity:", 478 + options, 479 + }), 480 + ); 481 + 482 + // Load the selected credentials 483 + const selectedCred = identities.find((c) => c.id === selected); 484 + if (selectedCred?.type === "oauth") { 485 + const session = await getOAuthSession(selected); 486 + if (session) { 487 + const handle = await getOAuthHandle(selected); 488 + credentials = { 489 + type: "oauth", 490 + did: selected, 491 + handle: handle || selected, 492 + }; 493 + } 494 + } else { 495 + credentials = await getCredentials(selected); 496 + } 497 + 498 + if (!credentials) { 499 + log.error("Failed to load selected credentials."); 500 + process.exit(1); 501 + } 502 + } 503 + 504 + const s = spinner(); 505 + s.start("Connecting to ATProto..."); 506 + 507 + let agent: Awaited<ReturnType<typeof createAgent>>; 508 + try { 509 + agent = await createAgent(credentials); 510 + s.stop("Connected!"); 511 + } catch (error) { 512 + s.stop("Failed to connect"); 513 + log.error(`Failed to connect: ${error}`); 514 + process.exit(1); 515 + } 516 + 517 + // Fetch existing publication 518 + s.start("Fetching publication..."); 519 + const publication = await getPublication(agent, config.publicationUri); 520 + 521 + if (!publication) { 522 + s.stop("Publication not found"); 523 + log.error(`Could not find publication: ${config.publicationUri}`); 524 + process.exit(1); 525 + } 526 + s.stop("Publication loaded!"); 527 + 528 + // Show current publication info 529 + const pubRecord = publication.value; 530 + const pubSummary = [ 531 + `Name: ${pubRecord.name}`, 532 + `URL: ${pubRecord.url}`, 533 + pubRecord.description ? `Description: ${pubRecord.description}` : null, 534 + pubRecord.icon ? `Icon: (uploaded)` : null, 535 + `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`, 536 + `Created: ${pubRecord.createdAt}`, 537 + ] 538 + .filter(Boolean) 539 + .join("\n"); 540 + 541 + note(pubSummary, "Current Publication"); 542 + 543 + // Collect updates with pre-populated values 544 + const name = exitOnCancel( 545 + await text({ 546 + message: "Publication name:", 547 + initialValue: pubRecord.name, 548 + validate: (value) => { 549 + if (!value) return "Publication name is required"; 550 + }, 551 + }), 552 + ); 553 + 554 + const description = exitOnCancel( 555 + await text({ 556 + message: "Publication description (leave empty to clear):", 557 + initialValue: pubRecord.description || "", 558 + }), 559 + ); 560 + 561 + const url = exitOnCancel( 562 + await text({ 563 + message: "Publication URL:", 564 + initialValue: pubRecord.url, 565 + validate: (value) => { 566 + if (!value) return "URL is required"; 567 + try { 568 + new URL(value); 569 + } catch { 570 + return "Please enter a valid URL"; 571 + } 572 + }, 573 + }), 574 + ); 575 + 576 + const iconPath = exitOnCancel( 577 + await text({ 578 + message: "New icon path (leave empty to keep existing):", 579 + initialValue: "", 580 + }), 581 + ); 582 + 583 + const showInDiscover = exitOnCancel( 584 + await confirm({ 585 + message: "Show in Discover feed?", 586 + initialValue: pubRecord.preferences?.showInDiscover ?? true, 587 + }), 588 + ); 589 + 590 + // Confirm before updating 591 + const shouldUpdate = exitOnCancel( 592 + await confirm({ 593 + message: "Update publication on ATProto?", 594 + initialValue: true, 595 + }), 596 + ); 597 + 598 + if (!shouldUpdate) { 599 + log.info("Update cancelled."); 600 + return; 601 + } 602 + 603 + // Perform update 604 + s.start("Updating publication..."); 605 + try { 606 + await updatePublication( 607 + agent, 608 + config.publicationUri, 609 + { 610 + name, 611 + description, 612 + url, 613 + iconPath: iconPath || undefined, 614 + showInDiscover, 615 + }, 616 + pubRecord, 617 + ); 618 + s.stop("Publication updated!"); 619 + } catch (error) { 620 + s.stop("Failed to update publication"); 621 + log.error(`Failed to update: ${error}`); 622 + process.exit(1); 623 + } 624 + }
+5 -1
packages/cli/src/index.ts
··· 4 4 import { authCommand } from "./commands/auth"; 5 5 import { initCommand } from "./commands/init"; 6 6 import { injectCommand } from "./commands/inject"; 7 + import { loginCommand } from "./commands/login"; 7 8 import { publishCommand } from "./commands/publish"; 8 9 import { syncCommand } from "./commands/sync"; 10 + import { updateCommand } from "./commands/update"; 9 11 10 12 const app = subcommands({ 11 13 name: "sequoia", ··· 33 35 34 36 > https://tangled.org/stevedylan.dev/sequoia 35 37 `, 36 - version: "0.2.1", 38 + version: "0.3.3", 37 39 cmds: { 38 40 auth: authCommand, 39 41 init: initCommand, 40 42 inject: injectCommand, 43 + login: loginCommand, 41 44 publish: publishCommand, 42 45 sync: syncCommand, 46 + update: updateCommand, 43 47 }, 44 48 }); 45 49
+201 -33
packages/cli/src/lib/atproto.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 1 + import { Agent, AtpAgent } from "@atproto/api"; 2 2 import * as mimeTypes from "mime-types"; 3 3 import * as fs from "node:fs/promises"; 4 4 import * as path from "node:path"; 5 5 import { stripMarkdownForText } from "./markdown"; 6 + import { getOAuthClient } from "./oauth-client"; 6 7 import type { 7 8 BlobObject, 8 9 BlogPost, 9 10 Credentials, 11 + PublicationRecord, 10 12 PublisherConfig, 11 13 StrongRef, 12 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 + } 13 32 14 33 async function fileExists(filePath: string): Promise<boolean> { 15 34 try { ··· 20 39 } 21 40 } 22 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 + 23 60 export async function resolveHandleToPDS(handle: string): Promise<string> { 24 61 // First, resolve the handle to a DID 25 - let did: string; 26 - 27 - if (handle.startsWith("did:")) { 28 - did = handle; 29 - } else { 30 - // Try to resolve handle via Bluesky API 31 - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 32 - const resolveResponse = await fetch(resolveUrl); 33 - if (!resolveResponse.ok) { 34 - throw new Error("Could not resolve handle"); 35 - } 36 - const resolveData = (await resolveResponse.json()) as { did: string }; 37 - did = resolveData.did; 38 - } 62 + const did = await resolveHandleToDid(handle); 39 63 40 64 // Now resolve the DID to get the PDS URL from the DID document 41 65 let pdsUrl: string | undefined; ··· 89 113 showInDiscover?: boolean; 90 114 } 91 115 92 - export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 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 + } 93 144 const agent = new AtpAgent({ service: credentials.pdsUrl }); 94 145 95 146 await agent.login({ ··· 101 152 } 102 153 103 154 export async function uploadImage( 104 - agent: AtpAgent, 155 + agent: Agent, 105 156 imagePath: string, 106 157 ): Promise<BlobObject | undefined> { 107 158 if (!(await fileExists(imagePath))) { ··· 139 190 contentDir: string, 140 191 ): Promise<string | null> { 141 192 // Try multiple resolution strategies 142 - const filename = path.basename(ogImage); 143 193 144 194 // 1. If imagesDir is specified, look there 145 195 if (imagesDir) { 146 - const imagePath = path.join(imagesDir, filename); 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); 147 217 if (await fileExists(imagePath)) { 148 218 const stat = await fs.stat(imagePath); 149 219 if (stat.size > 0) { ··· 170 240 } 171 241 172 242 export async function createDocument( 173 - agent: AtpAgent, 243 + agent: Agent, 174 244 post: BlogPost, 175 245 config: PublisherConfig, 176 246 coverImage?: BlobObject, ··· 213 283 } 214 284 215 285 const response = await agent.com.atproto.repo.createRecord({ 216 - repo: agent.session!.did, 286 + repo: agent.did!, 217 287 collection: "site.standard.document", 218 288 record, 219 289 }); ··· 222 292 } 223 293 224 294 export async function updateDocument( 225 - agent: AtpAgent, 295 + agent: Agent, 226 296 post: BlogPost, 227 297 atUri: string, 228 298 config: PublisherConfig, ··· 275 345 } 276 346 277 347 await agent.com.atproto.repo.putRecord({ 278 - repo: agent.session!.did, 348 + repo: agent.did!, 279 349 collection: collection!, 280 350 rkey: rkey!, 281 351 record, ··· 315 385 } 316 386 317 387 export async function listDocuments( 318 - agent: AtpAgent, 388 + agent: Agent, 319 389 publicationUri?: string, 320 390 ): Promise<ListDocumentsResult[]> { 321 391 const documents: ListDocumentsResult[] = []; ··· 323 393 324 394 do { 325 395 const response = await agent.com.atproto.repo.listRecords({ 326 - repo: agent.session!.did, 396 + repo: agent.did!, 327 397 collection: "site.standard.document", 328 398 limit: 100, 329 399 cursor, 330 400 }); 331 401 332 402 for (const record of response.data.records) { 333 - const value = record.value as unknown as DocumentRecord; 403 + if (!isDocumentRecord(record.value)) { 404 + continue; 405 + } 334 406 335 407 // If publicationUri is specified, only include documents from that publication 336 - if (publicationUri && value.site !== publicationUri) { 408 + if (publicationUri && record.value.site !== publicationUri) { 337 409 continue; 338 410 } 339 411 340 412 documents.push({ 341 413 uri: record.uri, 342 414 cid: record.cid, 343 - value, 415 + value: record.value, 344 416 }); 345 417 } 346 418 ··· 351 423 } 352 424 353 425 export async function createPublication( 354 - agent: AtpAgent, 426 + agent: Agent, 355 427 options: CreatePublicationOptions, 356 428 ): Promise<string> { 357 429 let icon: BlobObject | undefined; ··· 382 454 } 383 455 384 456 const response = await agent.com.atproto.repo.createRecord({ 385 - repo: agent.session!.did, 457 + repo: agent.did!, 386 458 collection: "site.standard.publication", 387 459 record, 388 460 }); ··· 390 462 return response.data.uri; 391 463 } 392 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 + 393 561 // --- Bluesky Post Creation --- 394 562 395 563 export interface CreateBlueskyPostOptions { ··· 435 603 * Create a Bluesky post with external link embed 436 604 */ 437 605 export async function createBlueskyPost( 438 - agent: AtpAgent, 606 + agent: Agent, 439 607 options: CreateBlueskyPostOptions, 440 608 ): Promise<StrongRef> { 441 609 const { title, description, canonicalUrl, coverImage, publishedAt } = options; ··· 530 698 }; 531 699 532 700 const response = await agent.com.atproto.repo.createRecord({ 533 - repo: agent.session!.did, 701 + repo: agent.did!, 534 702 collection: "app.bsky.feed.post", 535 703 record, 536 704 }); ··· 545 713 * Add bskyPostRef to an existing document record 546 714 */ 547 715 export async function addBskyPostRefToDocument( 548 - agent: AtpAgent, 716 + agent: Agent, 549 717 documentAtUri: string, 550 718 bskyPostRef: StrongRef, 551 719 ): Promise<void> {
+5
packages/cli/src/lib/config.ts
··· 82 82 frontmatter?: FrontmatterMapping; 83 83 ignore?: string[]; 84 84 removeIndexFromSlug?: boolean; 85 + stripDatePrefix?: boolean; 85 86 textContentField?: string; 86 87 bluesky?: BlueskyConfig; 87 88 }): string { ··· 122 123 123 124 if (options.removeIndexFromSlug) { 124 125 config.removeIndexFromSlug = options.removeIndexFromSlug; 126 + } 127 + 128 + if (options.stripDatePrefix) { 129 + config.stripDatePrefix = options.stripDatePrefix; 125 130 } 126 131 127 132 if (options.textContentField) {
+54
packages/cli/src/lib/credential-select.ts
··· 1 + import { select } from "@clack/prompts"; 2 + import { getOAuthHandle, getOAuthSession } from "./oauth-store"; 3 + import { getCredentials } from "./credentials"; 4 + import type { Credentials } from "./types"; 5 + import { exitOnCancel } from "./prompts"; 6 + 7 + /** 8 + * Prompt user to select from multiple credentials 9 + */ 10 + export async function selectCredential( 11 + allCredentials: Array<{ id: string; type: "app-password" | "oauth" }>, 12 + ): Promise<Credentials | null> { 13 + // Build options with friendly labels 14 + const options = await Promise.all( 15 + allCredentials.map(async ({ id, type }) => { 16 + let label = id; 17 + if (type === "oauth") { 18 + const handle = await getOAuthHandle(id); 19 + label = handle ? `${handle} (${id})` : id; 20 + } 21 + return { 22 + value: { id, type }, 23 + label: `${label} [${type}]`, 24 + }; 25 + }), 26 + ); 27 + 28 + const selected = exitOnCancel( 29 + await select({ 30 + message: "Multiple identities found. Select one:", 31 + options, 32 + }), 33 + ); 34 + 35 + // Load the full credentials for the selected identity 36 + if (selected.type === "oauth") { 37 + const session = await getOAuthSession(selected.id); 38 + if (session) { 39 + const handle = await getOAuthHandle(selected.id); 40 + return { 41 + type: "oauth", 42 + did: selected.id, 43 + handle: handle || selected.id, 44 + }; 45 + } 46 + } else { 47 + const creds = await getCredentials(selected.id); 48 + if (creds) { 49 + return creds; 50 + } 51 + } 52 + 53 + return null; 54 + }
+141 -26
packages/cli/src/lib/credentials.ts
··· 1 1 import * as fs from "node:fs/promises"; 2 2 import * as os from "node:os"; 3 3 import * as path from "node:path"; 4 - import type { Credentials } from "./types"; 4 + import { 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"; 5 16 6 17 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 7 18 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 8 19 9 - // Stored credentials keyed by identifier 10 - type CredentialsStore = Record<string, Credentials>; 20 + // Stored credentials keyed by identifier (can be legacy or typed) 21 + type CredentialsStore = Record< 22 + string, 23 + AppPasswordCredentials | LegacyCredentials 24 + >; 11 25 12 26 async function fileExists(filePath: string): Promise<boolean> { 13 27 try { ··· 19 33 } 20 34 21 35 /** 22 - * Load all stored credentials 36 + * Normalize credentials to have explicit type 23 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 + 24 54 async function loadCredentialsStore(): Promise<CredentialsStore> { 25 55 if (!(await fileExists(CREDENTIALS_FILE))) { 26 56 return {}; ··· 32 62 33 63 // Handle legacy single-credential format (migrate on read) 34 64 if (parsed.identifier && parsed.password) { 35 - const legacy = parsed as Credentials; 65 + const legacy = parsed as LegacyCredentials; 36 66 return { [legacy.identifier]: legacy }; 37 67 } 38 68 ··· 52 82 } 53 83 54 84 /** 85 + * Try to load OAuth credentials for a given profile (DID or handle) 86 + */ 87 + async function tryLoadOAuthCredentials( 88 + profile: string, 89 + ): Promise<OAuthCredentials | null> { 90 + // If it looks like a DID, try to get the session directly 91 + if (profile.startsWith("did:")) { 92 + const session = await getOAuthSession(profile); 93 + if (session) { 94 + const handle = await getOAuthHandle(profile); 95 + return { 96 + type: "oauth", 97 + did: profile, 98 + handle: handle || profile, 99 + }; 100 + } 101 + } 102 + 103 + // Try to find OAuth session by handle 104 + const sessions = await listOAuthSessionsWithHandles(); 105 + const match = sessions.find((s) => s.handle === profile); 106 + if (match) { 107 + return { 108 + type: "oauth", 109 + did: match.did, 110 + handle: match.handle || match.did, 111 + }; 112 + } 113 + 114 + return null; 115 + } 116 + 117 + /** 55 118 * Load credentials for a specific identity or resolve which to use. 56 119 * 57 120 * Priority: 58 121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 59 - * 2. SEQUOIA_PROFILE env var - selects from stored credentials 122 + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 60 123 * 3. projectIdentity parameter (from sequoia.json) 61 - * 4. If only one identity stored, use it 124 + * 4. If only one identity stored (app-password or OAuth), use it 62 125 * 5. Return null (caller should prompt user) 63 126 */ 64 127 export async function loadCredentials( ··· 71 134 72 135 if (envIdentifier && envPassword) { 73 136 return { 137 + type: "app-password", 74 138 identifier: envIdentifier, 75 139 password: envPassword, 76 140 pdsUrl: envPdsUrl || "https://bsky.social", ··· 78 142 } 79 143 80 144 const store = await loadCredentialsStore(); 81 - const identifiers = Object.keys(store); 82 - 83 - if (identifiers.length === 0) { 84 - return null; 85 - } 145 + const appPasswordIds = Object.keys(store); 146 + const oauthDids = await listOAuthSessions(); 86 147 87 148 // 2. SEQUOIA_PROFILE env var 88 149 const profileEnv = process.env.SEQUOIA_PROFILE; 89 - if (profileEnv && store[profileEnv]) { 90 - return store[profileEnv]; 150 + if (profileEnv) { 151 + // Try app-password credentials first 152 + if (store[profileEnv]) { 153 + return normalizeCredentials(store[profileEnv]); 154 + } 155 + // Try OAuth session (profile could be a DID) 156 + const oauth = await tryLoadOAuthCredentials(profileEnv); 157 + if (oauth) { 158 + return oauth; 159 + } 91 160 } 92 161 93 162 // 3. Project-specific identity (from sequoia.json) 94 - if (projectIdentity && store[projectIdentity]) { 95 - return store[projectIdentity]; 163 + if (projectIdentity) { 164 + if (store[projectIdentity]) { 165 + return normalizeCredentials(store[projectIdentity]); 166 + } 167 + const oauth = await tryLoadOAuthCredentials(projectIdentity); 168 + if (oauth) { 169 + return oauth; 170 + } 96 171 } 97 172 98 - // 4. If only one identity, use it 99 - if (identifiers.length === 1 && identifiers[0]) { 100 - return store[identifiers[0]] ?? null; 173 + // 4. If only one identity total, use it 174 + const totalIdentities = appPasswordIds.length + oauthDids.length; 175 + if (totalIdentities === 1) { 176 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 177 + return normalizeCredentials(store[appPasswordIds[0]]!); 178 + } 179 + if (oauthDids.length === 1 && oauthDids[0]) { 180 + const session = await getOAuthSession(oauthDids[0]); 181 + if (session) { 182 + const handle = await getOAuthHandle(oauthDids[0]); 183 + return { 184 + type: "oauth", 185 + did: oauthDids[0], 186 + handle: handle || oauthDids[0], 187 + }; 188 + } 189 + } 101 190 } 102 191 103 - // Multiple identities exist but none selected 192 + // Multiple identities exist but none selected, or no identities 104 193 return null; 105 194 } 106 195 107 196 /** 108 - * Get a specific identity by identifier 197 + * Get a specific identity by identifier (app-password only) 109 198 */ 110 199 export async function getCredentials( 111 200 identifier: string, 112 - ): Promise<Credentials | null> { 201 + ): Promise<AppPasswordCredentials | null> { 113 202 const store = await loadCredentialsStore(); 114 - return store[identifier] || null; 203 + const creds = store[identifier]; 204 + if (!creds) return null; 205 + return normalizeCredentials(creds); 115 206 } 116 207 117 208 /** 118 - * List all stored identities 209 + * List all stored app-password identities 119 210 */ 120 211 export async function listCredentials(): Promise<string[]> { 121 212 const store = await loadCredentialsStore(); ··· 123 214 } 124 215 125 216 /** 126 - * Save credentials for an identity (adds or updates) 217 + * List all credentials (both app-password and OAuth) 218 + */ 219 + export async function listAllCredentials(): Promise< 220 + Array<{ id: string; type: "app-password" | "oauth" }> 221 + > { 222 + const store = await loadCredentialsStore(); 223 + const oauthDids = await listOAuthSessions(); 224 + 225 + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 226 + 227 + for (const id of Object.keys(store)) { 228 + result.push({ id, type: "app-password" }); 229 + } 230 + 231 + for (const did of oauthDids) { 232 + result.push({ id: did, type: "oauth" }); 233 + } 234 + 235 + return result; 236 + } 237 + 238 + /** 239 + * Save app-password credentials for an identity (adds or updates) 127 240 */ 128 - export async function saveCredentials(credentials: Credentials): Promise<void> { 241 + export async function saveCredentials( 242 + credentials: AppPasswordCredentials, 243 + ): Promise<void> { 129 244 const store = await loadCredentialsStore(); 130 245 store[credentials.identifier] = credentials; 131 246 await saveCredentialsStore(store);
+14 -1
packages/cli/src/lib/markdown.ts
··· 178 178 export interface SlugOptions { 179 179 slugField?: string; 180 180 removeIndexFromSlug?: boolean; 181 + stripDatePrefix?: boolean; 181 182 } 182 183 183 184 export function getSlugFromOptions( ··· 185 186 rawFrontmatter: Record<string, unknown>, 186 187 options: SlugOptions = {}, 187 188 ): string { 188 - const { slugField, removeIndexFromSlug = false } = options; 189 + const { 190 + slugField, 191 + removeIndexFromSlug = false, 192 + stripDatePrefix = false, 193 + } = options; 189 194 190 195 let slug: string; 191 196 ··· 218 223 slug = slug.replace(/\/_?index$/, ""); 219 224 } 220 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 + 221 231 return slug; 222 232 } 223 233 ··· 243 253 ignorePatterns?: string[]; 244 254 slugField?: string; 245 255 removeIndexFromSlug?: boolean; 256 + stripDatePrefix?: boolean; 246 257 } 247 258 248 259 export async function scanContentDirectory( ··· 274 285 ignorePatterns: ignore = [], 275 286 slugField, 276 287 removeIndexFromSlug, 288 + stripDatePrefix, 277 289 } = options; 278 290 279 291 const patterns = ["**/*.md", "**/*.mdx"]; ··· 302 314 const slug = getSlugFromOptions(relativePath, rawFrontmatter, { 303 315 slugField, 304 316 removeIndexFromSlug, 317 + stripDatePrefix, 305 318 }); 306 319 307 320 posts.push({
+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 + }
+35 -1
packages/cli/src/lib/types.ts
··· 33 33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings 34 34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"]) 35 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) 36 37 textContentField?: string; // Frontmatter field to use for textContent instead of markdown body 37 38 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration 38 39 } 39 40 40 - export interface Credentials { 41 + // Legacy credentials format (for backward compatibility during migration) 42 + export interface LegacyCredentials { 41 43 pdsUrl: string; 42 44 identifier: string; 43 45 password: string; 46 + } 47 + 48 + // App password credentials (explicit type) 49 + export interface AppPasswordCredentials { 50 + type: "app-password"; 51 + pdsUrl: string; 52 + identifier: string; 53 + password: string; 54 + } 55 + 56 + // OAuth credentials (references stored OAuth session) 57 + // Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID 58 + export interface OAuthCredentials { 59 + type: "oauth"; 60 + did: string; 61 + handle: string; 62 + } 63 + 64 + // Union type for all credential types 65 + export type Credentials = AppPasswordCredentials | OAuthCredentials; 66 + 67 + // Helper to check credential type 68 + export function isOAuthCredentials( 69 + creds: Credentials, 70 + ): creds is OAuthCredentials { 71 + return creds.type === "oauth"; 72 + } 73 + 74 + export function isAppPasswordCredentials( 75 + creds: Credentials, 76 + ): creds is AppPasswordCredentials { 77 + return creds.type === "app-password"; 44 78 } 45 79 46 80 export interface PostFrontmatter {