A CLI for publishing standard.site documents to ATProto

Compare changes

Choose any two refs to compare.

+5040 -1142
+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 ."
+3
.vscode/settings.json
··· 1 + { 2 + "typescript.tsdk": "node_modules/typescript/lib" 3 + }
+130
CHANGELOG.md
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### ๐Ÿ› Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### โš™๏ธ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 17 + ## [0.3.1] - 2026-02-04 18 + 19 + ### ๐Ÿ› Bug Fixes 20 + 21 + - Asset subdirectories 22 + 23 + ### โš™๏ธ Miscellaneous Tasks 24 + 25 + - Updated authentication ux 26 + - Release 0.3.1 27 + - Bumped version 28 + ## [0.3.0] - 2026-02-04 29 + 30 + ### ๐Ÿš€ Features 31 + 32 + - Initial oauth implementation 33 + - Add stripDatePrefix option for Jekyll-style filenames 34 + - Add `update` command 35 + 36 + ### โš™๏ธ Miscellaneous Tasks 37 + 38 + - Update changelog 39 + - Added workflows 40 + - Updated workflows 41 + - Updated workflows 42 + - Cleaned up types 43 + - Updated icon styles 44 + - Updated og image 45 + - Updated docs 46 + - Docs updates 47 + - Bumped version 48 + ## [0.2.1] - 2026-02-02 49 + 50 + ### โš™๏ธ Miscellaneous Tasks 51 + 52 + - Added CHANGELOG 53 + - Merge main into chore/fronmatter-config-updates 54 + - Added linting and formatting 55 + - Linting updates 56 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 57 + - Version bump 58 + ## [0.2.0] - 2026-02-01 59 + 60 + ### ๐Ÿš€ Features 61 + 62 + - Added bskyPostRef 63 + - Added draft field to frontmatter config 64 + 65 + ### โš™๏ธ Miscellaneous Tasks 66 + 67 + - Resolved action items from issue #3 68 + - Adjusted tags to accept yaml multiline arrays for tags 69 + - Updated inject to handle new slug options 70 + - Updated comments 71 + - Update blog post 72 + - Fix blog build error 73 + - Adjust blog post 74 + - Updated docs 75 + - Version bump 76 + ## [0.1.1] - 2026-01-31 77 + 78 + ### ๐Ÿ› Bug Fixes 79 + 80 + - Fix tangled url to repo 81 + 82 + ### โš™๏ธ Miscellaneous Tasks 83 + 84 + - Merge branch 'main' into feat/blog-post 85 + - Updated blog post 86 + - Updated date 87 + - Added publishing 88 + - Spelling and grammar 89 + - Updated package scripts 90 + - Refactored codebase to use node and fs instead of bun 91 + - Version bump 92 + ## [0.1.0] - 2026-01-30 93 + 94 + ### ๐Ÿš€ Features 95 + 96 + - Init 97 + - Added blog post 98 + 99 + ### โš™๏ธ Miscellaneous Tasks 100 + 101 + - Updated package.json 102 + - Cleaned up commands and libs 103 + - Updated init commands 104 + - Updated greeting 105 + - Updated readme 106 + - Link updates 107 + - Version bump 108 + - Added hugo support through frontmatter parsing 109 + - Version bump 110 + - Updated docs 111 + - Adapted inject.ts pattern 112 + - Updated docs 113 + - Version bump" 114 + - Updated package scripts 115 + - Updated scripts 116 + - Added ignore field to config 117 + - Udpate docs 118 + - Version bump 119 + - Added tags to flow 120 + - Added ability to exit during init flow 121 + - Version bump 122 + - Updated docs 123 + - Updated links 124 + - Updated docs 125 + - Initial refactor 126 + - Checkpoint 127 + - Refactored mapping 128 + - Docs updates 129 + - Docs updates 130 + - Version bump
+85
CLAUDE.md
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS. 8 + 9 + Website: <https://sequoia.pub> 10 + 11 + ## Monorepo Structure 12 + 13 + - **`packages/cli/`** โ€” Main CLI package (the core product) 14 + - **`docs/`** โ€” Documentation website (Vocs-based, deployed to Cloudflare Pages) 15 + 16 + Bun workspaces manage the monorepo. 17 + 18 + ## Commands 19 + 20 + ```bash 21 + # Build CLI 22 + bun run build:cli 23 + 24 + # Run CLI in dev (build + link) 25 + cd packages/cli && bun run dev 26 + 27 + # Run tests 28 + bun run test:cli 29 + 30 + # Run a single test file 31 + cd packages/cli && bun test src/lib/markdown.test.ts 32 + 33 + # Lint (auto-fix) 34 + cd packages/cli && bun run lint 35 + 36 + # Format (auto-fix) 37 + cd packages/cli && bun run format 38 + 39 + # Docs dev server 40 + bun run dev:docs 41 + ``` 42 + 43 + ## Architecture 44 + 45 + **Entry point:** `packages/cli/src/index.ts` โ€” Uses `cmd-ts` for type-safe subcommand routing. 46 + 47 + **Commands** (`src/commands/`): 48 + 49 + - `publish` โ€” Core workflow: scans markdown files, publishes to ATProto 50 + - `sync` โ€” Fetches published records state from ATProto 51 + - `update` โ€” Updates existing records 52 + - `auth` โ€” Multi-identity management (app-password + OAuth) 53 + - `init` โ€” Interactive config setup 54 + - `inject` โ€” Injects verification links into static HTML output 55 + - `login` โ€” Legacy auth (deprecated) 56 + 57 + **Libraries** (`src/lib/`): 58 + 59 + - `atproto.ts` โ€” ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client) 60 + - `config.ts` โ€” Loads `sequoia.json` config and `.sequoia-state.json` state files 61 + - `credentials.ts` โ€” Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions) 62 + - `markdown.ts` โ€” Frontmatter parsing (YAML/TOML), content hashing, atUri injection 63 + 64 + **Extensions** (`src/extensions/`): 65 + 66 + - `litenote.ts` โ€” Creates `space.litenote.note` records with embedded images 67 + 68 + ## Key Patterns 69 + 70 + - **Config resolution:** `sequoia.json` is found by searching up the directory tree 71 + - **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters 72 + - **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle) 73 + - **Build:** `bun build src/index.ts --target node --outdir dist` 74 + 75 + ## Tooling 76 + 77 + - **Runtime/bundler:** Bun 78 + - **Linter/formatter:** Biome (tabs, double quotes) 79 + - **Test runner:** Bun's native test runner 80 + - **CLI framework:** `cmd-ts` 81 + - **Interactive UI:** `@clack/prompts` 82 + 83 + ## Git Conventions 84 + 85 + Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
··· 1 + name: 'Sequoia Publish' 2 + description: 'Publish your markdown content to ATProtocol using Sequoia CLI' 3 + branding: 4 + icon: 'upload-cloud' 5 + color: 'green' 6 + 7 + inputs: 8 + identifier: 9 + description: 'ATProto handle or DID (e.g. yourname.bsky.social)' 10 + required: true 11 + app-password: 12 + description: 'ATProto app password' 13 + required: true 14 + pds-url: 15 + description: 'PDS URL (defaults to https://bsky.social)' 16 + required: false 17 + default: 'https://bsky.social' 18 + force: 19 + description: 'Force publish all posts, ignoring change detection' 20 + required: false 21 + default: 'false' 22 + commit-back: 23 + description: 'Commit updated frontmatter and state file back to the repo' 24 + required: false 25 + default: 'true' 26 + working-directory: 27 + description: 'Directory containing sequoia.json (defaults to repo root)' 28 + required: false 29 + default: '.' 30 + 31 + runs: 32 + using: 'composite' 33 + steps: 34 + - name: Setup Bun 35 + uses: oven-sh/setup-bun@v2 36 + 37 + - name: Build and install Sequoia CLI 38 + shell: bash 39 + run: | 40 + cd ${{ github.action_path }} 41 + bun install 42 + bun run build:cli 43 + bun link --cwd packages/cli 44 + 45 + - name: Sync state from ATProtocol 46 + shell: bash 47 + working-directory: ${{ inputs.working-directory }} 48 + env: 49 + ATP_IDENTIFIER: ${{ inputs.identifier }} 50 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 51 + PDS_URL: ${{ inputs.pds-url }} 52 + run: sequoia sync 53 + 54 + - name: Publish 55 + shell: bash 56 + working-directory: ${{ inputs.working-directory }} 57 + env: 58 + ATP_IDENTIFIER: ${{ inputs.identifier }} 59 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 60 + PDS_URL: ${{ inputs.pds-url }} 61 + run: | 62 + FLAGS="" 63 + if [ "${{ inputs.force }}" = "true" ]; then 64 + FLAGS="--force" 65 + fi 66 + sequoia publish $FLAGS 67 + 68 + - name: Commit back changes 69 + if: inputs.commit-back == 'true' 70 + shell: bash 71 + working-directory: ${{ inputs.working-directory }} 72 + run: | 73 + git config user.name "$(git log -1 --format='%an')" 74 + git config user.email "$(git log -1 --format='%ae')" 75 + git add -A -- '**/*.md' || true 76 + if git diff --cached --quiet; then 77 + echo "No changes to commit" 78 + else 79 + git commit -m "chore: update sequoia state [skip ci]" 80 + git push 81 + fi
+86 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.1.0", 27 + "version": "0.3.3", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 }, 31 31 "dependencies": { 32 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 33 34 "@clack/prompts": "^1.0.0", 34 35 "cmd-ts": "^0.14.3", 35 36 "glob": "^13.0.0", 36 37 "mime-types": "^2.1.35", 37 38 "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 38 40 }, 39 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 40 43 "@types/mime-types": "^3.0.1", 41 44 "@types/node": "^20", 42 45 }, ··· 48 51 "packages": { 49 52 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 50 53 54 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 55 + 56 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 57 + 58 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 59 + 60 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 61 + 62 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], 63 + 64 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 65 + 66 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 67 + 68 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 69 + 70 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 71 + 51 72 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 52 73 53 74 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 54 75 76 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 77 + 78 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 79 + 80 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 81 + 82 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 83 + 55 84 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 56 85 57 86 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 58 87 59 88 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 89 + 90 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 91 + 92 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 93 + 94 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 60 95 61 96 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 62 97 ··· 104 139 105 140 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 106 141 142 + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], 143 + 144 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 145 + 146 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 147 + 148 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 149 + 150 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 151 + 152 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 153 + 154 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 155 + 156 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 157 + 158 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 159 + 107 160 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 108 161 109 162 "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], ··· 596 649 597 650 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 598 651 652 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 653 + 599 654 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 600 655 601 656 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 643 698 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 644 699 645 700 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 701 + 702 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 646 703 647 704 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 648 705 ··· 741 798 "deep-object-diff": ["deep-object-diff@1.1.9", "", {}, "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA=="], 742 799 743 800 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 801 + 802 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 803 + 804 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 805 + 806 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 744 807 745 808 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 746 809 ··· 902 965 903 966 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 904 967 968 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 969 + 905 970 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 906 971 907 972 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 908 973 909 974 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 975 + 976 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 910 977 911 978 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 912 979 980 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 981 + 982 + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], 983 + 913 984 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 914 985 915 986 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 917 988 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 918 989 919 990 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 991 + 992 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 920 993 921 994 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 922 995 ··· 925 998 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 926 999 927 1000 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1001 + 1002 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 928 1003 929 1004 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 930 1005 ··· 1148 1223 1149 1224 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1150 1225 1226 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1227 + 1151 1228 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1152 1229 1153 1230 "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], ··· 1189 1266 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1190 1267 1191 1268 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1269 + 1270 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1192 1271 1193 1272 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1194 1273 ··· 1264 1343 1265 1344 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1266 1345 1346 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1347 + 1267 1348 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1268 1349 1269 1350 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1356 1437 1357 1438 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1358 1439 1440 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1441 + 1359 1442 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1360 1443 1361 1444 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], ··· 1425 1508 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1426 1509 1427 1510 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1511 + 1512 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1428 1513 1429 1514 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1430 1515
+10 -1
docs/docs/pages/blog/introducing-sequoia.mdx
··· 24 24 25 25 It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 26 27 - <iframe width="560" height="315" src="https://www.youtube.com/embed/sxursUHq5kw?si=aZSCmkMdYPiYns8u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe> 27 + <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 + src="https://www.youtube.com/embed/sxursUHq5kw" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe> 28 37 29 38 ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 30 39
+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
+41 -2
docs/docs/pages/config.mdx
··· 14 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 15 | `identity` | `string` | No | - | Which stored identity to use | 16 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 17 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 + | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 + | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 23 + | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 18 24 19 25 ### Example 20 26 ··· 31 37 "frontmatter": { 32 38 "publishDate": "date" 33 39 }, 34 - "ignore": ["_index.md"] 40 + "ignore": ["_index.md"], 41 + "bluesky": { 42 + "enabled": true, 43 + "maxAgeDays": 30 44 + } 35 45 } 36 46 ``` 37 47 ··· 44 54 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 45 55 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 46 56 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 57 + | `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish | 47 58 48 59 ### Example 49 60 ··· 54 65 publishDate: 2024-01-15 55 66 ogImage: cover.jpg 56 67 tags: [welcome, intro] 68 + draft: false 57 69 --- 58 70 ``` 59 71 ··· 65 77 { 66 78 "frontmatter": { 67 79 "publishDate": "date", 68 - "coverImage": "thumbnail" 80 + "coverImage": "thumbnail", 81 + "draft": "private" 82 + } 83 + } 84 + ``` 85 + 86 + ### Slug Configuration 87 + 88 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 89 + 90 + ```json 91 + { 92 + "frontmatter": { 93 + "slugField": "url" 69 94 } 70 95 } 71 96 ``` 97 + 98 + If the frontmatter field is not found, it falls back to the filepath. 99 + 100 + ### Jekyll-Style Date Prefixes 101 + 102 + Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs: 103 + 104 + ```json 105 + { 106 + "stripDatePrefix": true 107 + } 108 + ``` 109 + 110 + This transforms `2024-01-15-my-post.md` into the slug `my-post`. 72 111 73 112 ### Ignoring Files 74 113
+39 -1
docs/docs/pages/publishing.mdx
··· 10 10 sequoia publish --dry-run 11 11 ``` 12 12 13 - This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it! 13 + This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it! 14 14 15 15 ```bash [Terminal] 16 16 sequoia publish ··· 27 27 ``` 28 28 29 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 + 31 + ## Bluesky Posting 32 + 33 + Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config: 34 + 35 + ```json 36 + { 37 + "bluesky": { 38 + "enabled": true, 39 + "maxAgeDays": 30 40 + } 41 + } 42 + ``` 43 + 44 + When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters. 45 + 46 + The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky. 47 + 48 + ## Draft Posts 49 + 50 + Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it. 51 + 52 + ```yaml 53 + --- 54 + title: Work in Progress 55 + draft: true 56 + --- 57 + ``` 58 + 59 + If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`: 60 + 61 + ```json 62 + { 63 + "frontmatter": { 64 + "draft": "private" 65 + } 66 + } 67 + ``` 30 68 31 69 ## Troubleshooting 32 70
+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.

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