A CLI for publishing standard.site documents to ATProto

Compare changes

Choose any two refs to compare.

+5218 -1198
+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 **/*.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
+123 -10
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.0.6", 27 + "version": "0.3.3", 28 28 "bin": { 29 - "sequoia": "dist/sequoia", 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", 36 + "glob": "^13.0.0", 37 + "mime-types": "^2.1.35", 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 35 40 }, 36 41 "devDependencies": { 37 - "@types/bun": "latest", 42 + "@biomejs/biome": "^2.3.13", 43 + "@types/mime-types": "^3.0.1", 44 + "@types/node": "^20", 38 45 }, 39 46 "peerDependencies": { 40 47 "typescript": "^5", ··· 44 51 "packages": { 45 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=="], 46 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 + 47 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=="], 48 73 49 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=="], 50 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 + 51 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=="], 52 85 53 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=="], 54 87 55 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=="], 56 95 57 96 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 58 97 ··· 99 138 "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], 100 139 101 140 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 141 + 142 + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], 143 + 144 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 145 + 146 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 147 + 148 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 149 + 150 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 151 + 152 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 153 + 154 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 155 + 156 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 157 + 158 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 102 159 103 160 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 104 161 ··· 187 244 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 188 245 189 246 "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], 247 + 248 + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], 249 + 250 + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], 190 251 191 252 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 192 253 ··· 524 585 525 586 "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], 526 587 588 + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], 589 + 527 590 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 528 591 529 - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 592 + "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], 530 593 531 594 "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 532 595 ··· 586 649 587 650 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 588 651 652 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 653 + 589 654 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 590 655 591 656 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 633 698 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 634 699 635 700 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 701 + 702 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 636 703 637 704 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 638 705 ··· 732 799 733 800 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 734 801 802 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 803 + 804 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 805 + 806 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 807 + 735 808 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 736 809 737 810 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 834 907 835 908 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], 836 909 910 + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], 911 + 837 912 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 838 913 839 914 "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], ··· 890 965 891 966 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 892 967 968 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 969 + 893 970 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 894 971 895 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=="], 896 973 897 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=="], 898 977 899 978 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 900 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 + 901 984 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 902 985 903 986 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 905 988 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 906 989 907 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=="], 908 993 909 994 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 910 995 ··· 913 998 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 914 999 915 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=="], 916 1003 917 1004 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 918 1005 ··· 1094 1181 1095 1182 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1096 1183 1097 - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1184 + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 1185 + 1186 + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 1098 1187 1099 1188 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 1100 1189 1101 1190 "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], 1102 1191 1192 + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], 1193 + 1194 + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 1195 + 1103 1196 "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], 1104 1197 1105 1198 "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], ··· 1129 1222 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1130 1223 1131 1224 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1225 + 1226 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1132 1227 1133 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=="], 1134 1229 ··· 1150 1245 1151 1246 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1152 1247 1248 + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], 1249 + 1153 1250 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1154 1251 1155 1252 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], ··· 1169 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=="], 1170 1267 1171 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=="], 1172 1271 1173 1272 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1174 1273 ··· 1244 1343 1245 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=="], 1246 1345 1346 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1347 + 1247 1348 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1248 1349 1249 1350 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1336 1437 1337 1438 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1338 1439 1339 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1440 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1441 + 1442 + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1340 1443 1341 1444 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 1342 1445 ··· 1405 1508 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1406 1509 1407 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=="], 1408 1513 1409 1514 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1410 1515 ··· 1442 1547 1443 1548 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1444 1549 1550 + "bun-types/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1551 + 1445 1552 "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], 1553 + 1554 + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1446 1555 1447 1556 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1448 1557 ··· 1456 1565 1457 1566 "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], 1458 1567 1568 + "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1569 + 1459 1570 "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 1460 1571 1461 1572 "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], ··· 1473 1584 "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 1474 1585 1475 1586 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 1587 + 1588 + "path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], 1476 1589 1477 1590 "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 1478 1591 ··· 1480 1593 1481 1594 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1482 1595 1483 - "sequoia-cli/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 1484 - 1485 1596 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1486 1597 1487 1598 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1488 1599 1489 1600 "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], 1601 + 1602 + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1490 1603 1491 1604 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1492 1605 ··· 1498 1611 1499 1612 "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], 1500 1613 1614 + "eval/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1615 + 1501 1616 "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1502 1617 1503 1618 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1504 1619 1505 1620 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1506 - 1507 - "sequoia-cli/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 1508 1621 } 1509 1622 }
+2
docs/.gitignore
··· 1 1 .wrangler 2 + 3 + .sequoia-state.json
+7
docs/docs/pages/blog/index.mdx
··· 1 + --- 2 + layout: minimal 3 + --- 4 + 5 + # Blog 6 + 7 + ::blog-posts
+54
docs/docs/pages/blog/introducing-sequoia.mdx
··· 1 + --- 2 + layout: minimal 3 + title: "Introducing Sequoia: Publishing for the Open Web" 4 + date: 2026-01-30 5 + atUri: "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v" 6 + --- 7 + 8 + # Introducing Sequoia: Publishing for the Open Web 9 + 10 + ![hero](/hero.png) 11 + 12 + Today I'm excited to release a new tool for the [AT Protocol](https://atproto.com): Sequoia. This is a CLI tool that can take your existing self-hosted blog and publish it to the ATmosphere using [Standard.site](https://standard.site) lexicons. 13 + 14 + If you haven't explored ATProto you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/), but in short, it's a new way to publish content to the web that puts ownership and control back in the hands of users. Blogs in some ways have already been doing this, but they've been missing a key piece: distribution. One of the unique features of ATProto is [lexicons](), which are schemas that apps build to create folders of content on a user's personal data server. The domain verified nature lets them be indexed and aggregated with ease. Outside of apps, lexicons can be extended by community members to build a common standard. That's exactly how [Standard.site](https://standard.site) was brought about, pushing a new way for standardizing publications and documents on ATProto. 15 + 16 + The founders and platforms behind the standard, [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), all serve to make creating and sharing blogs easy. If you are not a technical person and don't have a blog already, I would highly recommend checking all of them out! However, for those of us who already have blogs, there was a need for a tool that could make it easy to publish existing and new content with this new standard. Thus Sequoia was born. 17 + 18 + Sequoia is a relatively simple CLI that can do the following: 19 + - Authenticate with your ATProto handle 20 + - Configure your blog through an interactive setup process 21 + - Create publication and document records on your PDS 22 + - Add necessary verification pieces to your site 23 + - Sync with existing records on your PDS 24 + 25 + It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 + 27 + <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 + src="https://www.youtube.com/embed/sxursUHq5kw" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe> 37 + 38 + ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 39 + 40 + Install Sequoia today and check out the [quickstart guide](/quickstart) to publish your content into the ATmosphere ๐ŸŒณ 41 + 42 + :::code-group 43 + ```bash [npm] 44 + npm i -g sequoia-cli 45 + ``` 46 + 47 + ```bash [pnpm] 48 + pnpm i -g sequoia-cli 49 + ``` 50 + 51 + ```bash [bun] 52 + bun i -g sequoia-cli 53 + ``` 54 + :::
+34 -1
docs/docs/pages/cli-reference.mdx
··· 1 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
+40 -2
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 ··· 23 23 If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command. 24 24 25 25 ```bash [Terminal] 26 - seuqoia sync 26 + sequoia sync 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
+10 -8
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 authoize 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 ··· 59 61 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 60 62 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 61 63 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 62 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 64 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 63 65 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 64 66 - **Publication name** - The name of your blog 65 67 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/setup.mdx
··· 28 28 29 29 ## Authorize 30 30 31 - In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password. 31 + In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password. 32 32 33 33 :::tip 34 34 You can create an app password [here](https://bsky.app/settings/app-passwords) ··· 56 56 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 57 57 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 58 58 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 59 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 59 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 60 60 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 61 61 - **Publication name** - The name of your blog 62 62 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/verifying.mdx
··· 3 3 In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. 4 4 5 5 :::tip 6 - You an learn more about Standard.site verification [here](https://standard.site/) 6 + You can learn more about Standard.site verification [here](https://standard.site/) 7 7 ::: 8 8 9 9 ## Publication Verification ··· 22 22 23 23 ### pds.ls 24 24 25 - Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as perscribed on their website. 25 + Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as prescribed on their website. 26 26 27 27 ### Standard.site Validator 28 28
+2 -2
docs/docs/pages/what-is-sequoia.mdx
··· 3 3 Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down. 4 4 5 5 - [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/). 6 - - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. The unique property to lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 - - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specailly designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 6 + - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. A unique property of lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 + - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specially designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 8 8 9 9 The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too. 10 10
docs/docs/public/.well-known/.gitkeep

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

docs/docs/public/icon-dark.png

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

+1 -1
docs/package.json
··· 6 6 "scripts": { 7 7 "dev": "vocs dev", 8 8 "build": "vocs build && bun inject-og-tags.ts", 9 - "deploy": "bun run build && bunx wrangler pages deploy docs/dist", 9 + "deploy": "bun run build && sequoia inject && bunx wrangler pages deploy docs/dist", 10 10 "preview": "vocs preview" 11 11 }, 12 12 "dependencies": {
+14
docs/sequoia.json
··· 1 + { 2 + "siteUrl": "https://sequoia.pub", 3 + "contentDir": "docs/pages/blog", 4 + "imagesDir": "docs/public", 5 + "publicDir": "docs/public", 6 + "outputDir": "docs/dist", 7 + "pathPrefix": "/blog", 8 + "publicationUri": "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.publication/3mdnzt4rqr42v", 9 + "pdsUrl": "https://andromeda.social", 10 + "frontmatter": { 11 + "publishDate": "date" 12 + }, 13 + "ignore": ["index.mdx"] 14 + }
+1 -1
docs/vocs.config.ts
··· 17 17 topNav: [ 18 18 { text: "Docs", link: "/quickstart", match: "/" }, 19 19 { text: "Blog", link: "/blog" }, 20 - { text: "Tanlged", link: "https://tangled.org/stevedylan.dev/sequoia" }, 20 + { text: "Tangled", link: "https://tangled.org/stevedylan.dev/sequoia" }, 21 21 { text: "GitHub", link: "https://github.com/stevedylandev/sequoia" }, 22 22 ], 23 23 sidebar: [
+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 + }
+16 -8
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.1.0", 4 - "module": "dist/index.js", 3 + "version": "0.3.3", 5 4 "type": "module", 6 5 "bin": { 7 - "sequoia": "dist/sequoia" 6 + "sequoia": "dist/index.js" 8 7 }, 9 8 "files": [ 10 9 "dist", 11 10 "README.md" 12 11 ], 13 - "main": "./dist/sequoia", 12 + "main": "./dist/index.js", 14 13 "exports": { 15 - ".": "./dist/sequoia" 14 + ".": "./dist/index.js" 16 15 }, 17 16 "scripts": { 18 - "build": "bun build src/index.ts --compile --outfile dist/sequoia", 17 + "lint": "biome lint --write", 18 + "format": "biome format --write", 19 + "build": "bun build src/index.ts --target node --outdir dist", 19 20 "dev": "bun run build && bun link", 20 21 "deploy": "bun run build && bun publish" 21 22 }, 22 23 "devDependencies": { 23 - "@types/bun": "latest" 24 + "@biomejs/biome": "^2.3.13", 25 + "@types/mime-types": "^3.0.1", 26 + "@types/node": "^20" 24 27 }, 25 28 "peerDependencies": { 26 29 "typescript": "^5" 27 30 }, 28 31 "dependencies": { 29 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 34 + "@clack/prompts": "^1.0.0", 30 35 "cmd-ts": "^0.14.3", 31 - "@clack/prompts": "^1.0.0" 36 + "glob": "^13.0.0", 37 + "mime-types": "^2.1.35", 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0" 32 40 } 33 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 });
+93 -19
packages/cli/src/commands/init.ts
··· 1 + import * as fs from "node:fs/promises"; 1 2 import { command } from "cmd-ts"; 2 3 import { 3 4 intro, ··· 10 11 log, 11 12 group, 12 13 } from "@clack/prompts"; 13 - import * as path from "path"; 14 + import * as path from "node:path"; 14 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 15 - import { loadCredentials } from "../lib/credentials"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 16 17 import { createAgent, createPublication } from "../lib/atproto"; 17 - import type { FrontmatterMapping } from "../lib/types"; 18 + import { selectCredential } from "../lib/credential-select"; 19 + import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 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 + } 18 29 19 30 const onCancel = () => { 20 31 outro("Setup cancelled"); ··· 128 139 defaultValue: "tags", 129 140 placeholder: "tags, categories, keywords, etc.", 130 141 }), 142 + draftField: () => 143 + text({ 144 + message: "Field name for draft status:", 145 + defaultValue: "draft", 146 + placeholder: "draft, private, hidden, etc.", 147 + }), 131 148 }, 132 149 { onCancel }, 133 150 ); ··· 139 156 ["publishDate", frontmatterConfig.dateField, "publishDate"], 140 157 ["coverImage", frontmatterConfig.coverField, "ogImage"], 141 158 ["tags", frontmatterConfig.tagsField, "tags"], 159 + ["draft", frontmatterConfig.draftField, "draft"], 142 160 ]; 143 161 144 162 const builtMapping = fieldMappings.reduce<FrontmatterMapping>( ··· 169 187 } 170 188 171 189 let publicationUri: string; 172 - const credentials = await loadCredentials(); 190 + let credentials = await loadCredentials(); 173 191 174 192 if (publicationChoice === "create") { 175 193 // Need credentials to create a publication 176 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) { 177 211 log.error( 178 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 179 213 ); 180 214 process.exit(1); 181 215 } 182 216 183 217 const s = spinner(); 184 218 s.start("Connecting to ATProto..."); 185 - let agent; 219 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 186 220 try { 187 221 agent = await createAgent(credentials); 188 222 s.stop("Connected!"); 189 - } catch (error) { 223 + } catch (_error) { 190 224 s.stop("Failed to connect"); 191 225 log.error( 192 - "Failed to connect. Check your credentials with 'sequoia auth'.", 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 193 227 ); 194 228 process.exit(1); 195 229 } ··· 253 287 publicationUri = uri as string; 254 288 } 255 289 256 - // Get PDS URL from credentials (already loaded earlier) 257 - 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; 258 331 259 332 // Generate config file 260 333 const configContent = generateConfigTemplate({ ··· 267 340 publicationUri, 268 341 pdsUrl, 269 342 frontmatter: frontmatterMapping, 343 + bluesky: blueskyConfig, 270 344 }); 271 345 272 346 const configPath = path.join(process.cwd(), "sequoia.json"); 273 - await Bun.write(configPath, configContent); 347 + await fs.writeFile(configPath, configContent); 274 348 275 349 log.success(`Configuration saved to ${configPath}`); 276 350 ··· 283 357 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication"); 284 358 285 359 // Ensure .well-known directory exists 286 - await Bun.write(path.join(wellKnownDir, ".gitkeep"), ""); 287 - await Bun.write(wellKnownPath, publicationUri); 360 + await fs.mkdir(wellKnownDir, { recursive: true }); 361 + await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), ""); 362 + await fs.writeFile(wellKnownPath, publicationUri); 288 363 289 364 log.success(`Created ${wellKnownPath}`); 290 365 291 366 // Update .gitignore 292 367 const gitignorePath = path.join(process.cwd(), ".gitignore"); 293 - const gitignoreFile = Bun.file(gitignorePath); 294 368 const stateFilename = ".sequoia-state.json"; 295 369 296 - if (await gitignoreFile.exists()) { 297 - const gitignoreContent = await gitignoreFile.text(); 370 + if (await fileExists(gitignorePath)) { 371 + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); 298 372 if (!gitignoreContent.includes(stateFilename)) { 299 - await Bun.write( 373 + await fs.writeFile( 300 374 gitignorePath, 301 - gitignoreContent + `\n${stateFilename}\n`, 375 + `${gitignoreContent}\n${stateFilename}\n`, 302 376 ); 303 377 log.info(`Added ${stateFilename} to .gitignore`); 304 378 } 305 379 } else { 306 - await Bun.write(gitignorePath, `${stateFilename}\n`); 380 + await fs.writeFile(gitignorePath, `${stateFilename}\n`); 307 381 log.info(`Created .gitignore with ${stateFilename}`); 308 382 } 309 383
+42 -67
packages/cli/src/commands/inject.ts
··· 1 - import { command, flag, option, optional, string } from "cmd-ts"; 2 1 import { log } from "@clack/prompts"; 3 - import * as path from "path"; 4 - import { Glob } from "bun"; 5 - import { loadConfig, loadState, findConfig } from "../lib/config"; 2 + import { command, flag, option, optional, string } from "cmd-ts"; 3 + import { glob } from "glob"; 4 + import * as fs from "node:fs/promises"; 5 + import * as path from "node:path"; 6 + import { findConfig, loadConfig, loadState } from "../lib/config"; 6 7 7 8 export const injectCommand = command({ 8 9 name: "inject", 9 - description: 10 - "Inject site.standard.document link tags into built HTML files", 10 + description: "Inject site.standard.document link tags into built HTML files", 11 11 args: { 12 12 outputDir: option({ 13 13 long: "output", ··· 43 43 // Load state to get atUri mappings 44 44 const state = await loadState(configDir); 45 45 46 - // Generic filenames where the slug is the parent directory, not the filename 47 - // Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc. 48 - const genericFilenames = new Set([ 49 - "+page", 50 - "index", 51 - "_index", 52 - "page", 53 - "readme", 54 - ]); 55 - 56 - // Build a map of slug/path to atUri from state 57 - const pathToAtUri = new Map<string, string>(); 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>(); 58 49 for (const [filePath, postState] of Object.entries(state.posts)) { 59 - if (postState.atUri) { 60 - // Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post) 61 - let basename = path.basename(filePath, path.extname(filePath)); 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); 62 53 63 - // If the filename is a generic convention name, use the parent directory as slug 64 - if (genericFilenames.has(basename.toLowerCase())) { 65 - // Split path and filter out route groups like (blog-article) 66 - const pathParts = filePath 67 - .split(/[/\\]/) 68 - .filter((p) => p && !(p.startsWith("(") && p.endsWith(")"))); 69 - // The slug should be the second-to-last part (last is the filename) 70 - if (pathParts.length >= 2) { 71 - const slug = pathParts[pathParts.length - 2]; 72 - if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") { 73 - basename = slug; 74 - } 75 - } 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); 76 59 } 77 - 78 - pathToAtUri.set(basename, postState.atUri); 79 - 80 - // Also add variations that might match HTML file paths 81 - // e.g., /blog/my-post, /posts/my-post, my-post/index 82 - const dirName = path.basename(path.dirname(filePath)); 83 - // Skip route groups and common directory names 84 - if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) { 85 - pathToAtUri.set(`${dirName}/${basename}`, postState.atUri); 86 - } 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); 87 65 } 88 66 } 89 67 90 - if (pathToAtUri.size === 0) { 68 + if (slugToAtUri.size === 0) { 91 69 log.warn( 92 70 "No published posts found in state. Run 'sequoia publish' first.", 93 71 ); 94 72 return; 95 73 } 96 74 97 - log.info(`Found ${pathToAtUri.size} published posts in state`); 75 + log.info(`Found ${slugToAtUri.size} slug mappings from published posts`); 98 76 99 77 // Scan for HTML files 100 - const glob = new Glob("**/*.html"); 101 - const htmlFiles: string[] = []; 102 - 103 - for await (const file of glob.scan(resolvedOutputDir)) { 104 - htmlFiles.push(path.join(resolvedOutputDir, file)); 105 - } 78 + const htmlFiles = await glob("**/*.html", { 79 + cwd: resolvedOutputDir, 80 + absolute: false, 81 + }); 106 82 107 83 if (htmlFiles.length === 0) { 108 84 log.warn(`No HTML files found in ${resolvedOutputDir}`); ··· 115 91 let skippedCount = 0; 116 92 let alreadyHasCount = 0; 117 93 118 - for (const htmlPath of htmlFiles) { 94 + for (const file of htmlFiles) { 95 + const htmlPath = path.join(resolvedOutputDir, file); 119 96 // Try to match this HTML file to a published post 120 - const relativePath = path.relative(resolvedOutputDir, htmlPath); 97 + const relativePath = file; 121 98 const htmlDir = path.dirname(relativePath); 122 99 const htmlBasename = path.basename(relativePath, ".html"); 123 100 ··· 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) { ··· 152 128 } 153 129 154 130 // Read the HTML file 155 - const file = Bun.file(htmlPath); 156 - let content = await file.text(); 131 + let content = await fs.readFile(htmlPath, "utf-8"); 157 132 158 133 // Check if link tag already exists 159 134 const linkTag = `<link rel="site.standard.document" href="${atUri}">`; ··· 184 159 `${indent}${linkTag}\n${indent}` + 185 160 content.slice(headCloseIndex); 186 161 187 - await Bun.write(htmlPath, content); 162 + await fs.writeFile(htmlPath, content); 188 163 log.success(` Injected into: ${relativePath}`); 189 164 injectedCount++; 190 165 }
+305
packages/cli/src/commands/login.ts
··· 1 + import * as http from "node:http"; 2 + import { log, note, select, spinner, text } from "@clack/prompts"; 3 + import { command, flag, option, optional, string } from "cmd-ts"; 4 + import { resolveHandleToDid } from "../lib/atproto"; 5 + import { 6 + getCallbackPort, 7 + getOAuthClient, 8 + getOAuthScope, 9 + } from "../lib/oauth-client"; 10 + import { 11 + deleteOAuthSession, 12 + getOAuthStorePath, 13 + listOAuthSessions, 14 + listOAuthSessionsWithHandles, 15 + setOAuthHandle, 16 + } from "../lib/oauth-store"; 17 + import { exitOnCancel } from "../lib/prompts"; 18 + 19 + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 20 + 21 + export const loginCommand = command({ 22 + name: "login", 23 + description: "Login with OAuth (browser-based authentication)", 24 + args: { 25 + logout: option({ 26 + long: "logout", 27 + description: "Remove OAuth session for a specific DID", 28 + type: optional(string), 29 + }), 30 + list: flag({ 31 + long: "list", 32 + description: "List all stored OAuth sessions", 33 + }), 34 + }, 35 + handler: async ({ logout, list }) => { 36 + // List sessions 37 + if (list) { 38 + const sessions = await listOAuthSessionsWithHandles(); 39 + if (sessions.length === 0) { 40 + log.info("No OAuth sessions stored"); 41 + } else { 42 + log.info("OAuth sessions:"); 43 + for (const { did, handle } of sessions) { 44 + console.log(` - ${handle || did} (${did})`); 45 + } 46 + } 47 + return; 48 + } 49 + 50 + // Logout 51 + if (logout !== undefined) { 52 + const did = logout || undefined; 53 + 54 + if (!did) { 55 + // No DID provided - show available and prompt 56 + const sessions = await listOAuthSessions(); 57 + if (sessions.length === 0) { 58 + log.info("No OAuth sessions found"); 59 + return; 60 + } 61 + if (sessions.length === 1) { 62 + const deleted = await deleteOAuthSession(sessions[0]!); 63 + if (deleted) { 64 + log.success(`Removed OAuth session for ${sessions[0]}`); 65 + } 66 + return; 67 + } 68 + // Multiple sessions - prompt 69 + const selected = exitOnCancel( 70 + await select({ 71 + message: "Select session to remove:", 72 + options: sessions.map((d) => ({ value: d, label: d })), 73 + }), 74 + ); 75 + const deleted = await deleteOAuthSession(selected); 76 + if (deleted) { 77 + log.success(`Removed OAuth session for ${selected}`); 78 + } 79 + return; 80 + } 81 + 82 + const deleted = await deleteOAuthSession(did); 83 + if (deleted) { 84 + log.success(`Removed OAuth session for ${did}`); 85 + } else { 86 + log.info(`No OAuth session found for ${did}`); 87 + } 88 + return; 89 + } 90 + 91 + // OAuth login flow 92 + note( 93 + "OAuth login will open your browser to authenticate.\n\n" + 94 + "This is more secure than app passwords and tokens refresh automatically.", 95 + "OAuth Login", 96 + ); 97 + 98 + const handle = exitOnCancel( 99 + await text({ 100 + message: "Handle or DID:", 101 + placeholder: "yourhandle.bsky.social", 102 + }), 103 + ); 104 + 105 + if (!handle) { 106 + log.error("Handle is required"); 107 + process.exit(1); 108 + } 109 + 110 + const s = spinner(); 111 + s.start("Resolving identity..."); 112 + 113 + let did: string; 114 + try { 115 + did = await resolveHandleToDid(handle); 116 + s.stop(`Identity resolved`); 117 + } catch (error) { 118 + s.stop("Failed to resolve identity"); 119 + if (error instanceof Error) { 120 + log.error(`Error: ${error.message}`); 121 + } else { 122 + log.error(`Error: ${error}`); 123 + } 124 + process.exit(1); 125 + } 126 + 127 + s.start("Initializing OAuth..."); 128 + 129 + try { 130 + const client = await getOAuthClient(); 131 + 132 + // Generate authorization URL using the resolved DID 133 + const authUrl = await client.authorize(did, { 134 + scope: getOAuthScope(), 135 + }); 136 + 137 + log.info(`Login URL: ${authUrl}`); 138 + 139 + s.message("Opening browser..."); 140 + 141 + // Try to open browser 142 + let browserOpened = true; 143 + try { 144 + const open = (await import("open")).default; 145 + await open(authUrl.toString()); 146 + } catch { 147 + browserOpened = false; 148 + } 149 + 150 + s.message("Waiting for authentication..."); 151 + 152 + // Show URL info 153 + if (!browserOpened) { 154 + s.stop("Could not open browser automatically"); 155 + log.warn("Please open the following URL in your browser:"); 156 + log.info(authUrl.toString()); 157 + s.start("Waiting for authentication..."); 158 + } 159 + 160 + // Start HTTP server to receive callback 161 + const result = await waitForCallback(); 162 + 163 + if (!result.success) { 164 + s.stop("Authentication failed"); 165 + log.error(result.error || "OAuth callback failed"); 166 + process.exit(1); 167 + } 168 + 169 + s.message("Completing authentication..."); 170 + 171 + // Exchange code for tokens 172 + const { session } = await client.callback( 173 + new URLSearchParams(result.params!), 174 + ); 175 + 176 + // Store the handle for friendly display 177 + // Use the original handle input (unless it was a DID) 178 + const handleToStore = handle.startsWith("did:") ? undefined : handle; 179 + if (handleToStore) { 180 + await setOAuthHandle(session.did, handleToStore); 181 + } 182 + 183 + // Try to get the handle for display (use the original handle input as fallback) 184 + const displayName = handleToStore || session.did; 185 + 186 + s.stop(`Logged in as ${displayName}`); 187 + 188 + log.success(`OAuth session saved to ${getOAuthStorePath()}`); 189 + log.info("Your session will refresh automatically when needed."); 190 + 191 + // Exit cleanly - the OAuth client may have background processes 192 + process.exit(0); 193 + } catch (error) { 194 + s.stop("OAuth login failed"); 195 + if (error instanceof Error) { 196 + log.error(`Error: ${error.message}`); 197 + } else { 198 + log.error(`Error: ${error}`); 199 + } 200 + process.exit(1); 201 + } 202 + }, 203 + }); 204 + 205 + interface CallbackResult { 206 + success: boolean; 207 + params?: Record<string, string>; 208 + error?: string; 209 + } 210 + 211 + function waitForCallback(): Promise<CallbackResult> { 212 + return new Promise((resolve) => { 213 + const port = getCallbackPort(); 214 + let timeoutId: ReturnType<typeof setTimeout> | undefined; 215 + 216 + const server = http.createServer((req, res) => { 217 + const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); 218 + 219 + if (url.pathname === "/oauth/callback") { 220 + const params: Record<string, string> = {}; 221 + url.searchParams.forEach((value, key) => { 222 + params[key] = value; 223 + }); 224 + 225 + // Clear the timeout 226 + if (timeoutId) clearTimeout(timeoutId); 227 + 228 + // Check for error 229 + if (params.error) { 230 + res.writeHead(200, { "Content-Type": "text/html" }); 231 + res.end(` 232 + <html> 233 + <head> 234 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 235 + </head> 236 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 237 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 238 + <h1 style="font-weight: 400;">Authentication Failed</h1> 239 + <p>${params.error_description || params.error}</p> 240 + <p>You can close this window.</p> 241 + </body> 242 + </html> 243 + `); 244 + server.close(() => { 245 + resolve({ 246 + success: false, 247 + error: params.error_description || params.error, 248 + }); 249 + }); 250 + return; 251 + } 252 + 253 + // Success 254 + res.writeHead(200, { "Content-Type": "text/html" }); 255 + res.end(` 256 + <html> 257 + <head> 258 + <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 259 + </head> 260 + <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> 261 + <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 262 + <h1 style="font-weight: 400;">Authentication Successful</h1> 263 + <p>You can close this window and return to the terminal.</p> 264 + </body> 265 + </html> 266 + `); 267 + server.close(() => { 268 + resolve({ success: true, params }); 269 + }); 270 + return; 271 + } 272 + 273 + // Not the callback path 274 + res.writeHead(404); 275 + res.end("Not found"); 276 + }); 277 + 278 + server.on("error", (err: NodeJS.ErrnoException) => { 279 + if (timeoutId) clearTimeout(timeoutId); 280 + if (err.code === "EADDRINUSE") { 281 + resolve({ 282 + success: false, 283 + error: `Port ${port} is already in use. Please close the application using that port and try again.`, 284 + }); 285 + } else { 286 + resolve({ 287 + success: false, 288 + error: `Server error: ${err.message}`, 289 + }); 290 + } 291 + }); 292 + 293 + server.listen(port, "127.0.0.1"); 294 + 295 + // Timeout after 5 minutes 296 + timeoutId = setTimeout(() => { 297 + server.close(() => { 298 + resolve({ 299 + success: false, 300 + error: "Timeout waiting for OAuth callback. Please try again.", 301 + }); 302 + }); 303 + }, CALLBACK_TIMEOUT_MS); 304 + }); 305 + }
+427 -197
packages/cli/src/commands/publish.ts
··· 1 + import * as fs from "node:fs/promises"; 1 2 import { command, flag } from "cmd-ts"; 2 3 import { select, spinner, log } from "@clack/prompts"; 3 - import * as path from "path"; 4 + import * as path from "node:path"; 4 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 5 - import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials"; 6 - import { createAgent, createDocument, updateDocument, uploadImage, resolveImagePath } from "../lib/atproto"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 + import { 13 + createAgent, 14 + createDocument, 15 + updateDocument, 16 + uploadImage, 17 + resolveImagePath, 18 + createBlueskyPost, 19 + addBskyPostRefToDocument, 20 + } from "../lib/atproto"; 7 21 import { 8 - scanContentDirectory, 9 - getContentHash, 10 - updateFrontmatterWithAtUri, 22 + scanContentDirectory, 23 + getContentHash, 24 + updateFrontmatterWithAtUri, 11 25 } from "../lib/markdown"; 12 - import type { BlogPost, BlobObject } from "../lib/types"; 26 + import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; 13 27 import { exitOnCancel } from "../lib/prompts"; 28 + import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote" 14 29 15 30 export const publishCommand = command({ 16 - name: "publish", 17 - description: "Publish content to ATProto", 18 - args: { 19 - force: flag({ 20 - long: "force", 21 - short: "f", 22 - description: "Force publish all posts, ignoring change detection", 23 - }), 24 - dryRun: flag({ 25 - long: "dry-run", 26 - short: "n", 27 - description: "Preview what would be published without making changes", 28 - }), 29 - }, 30 - handler: async ({ force, dryRun }) => { 31 - // Load config 32 - const configPath = await findConfig(); 33 - if (!configPath) { 34 - log.error("No publisher.config.ts found. Run 'publisher init' first."); 35 - process.exit(1); 36 - } 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 + } 37 52 38 - const config = await loadConfig(configPath); 39 - const configDir = path.dirname(configPath); 53 + const config = await loadConfig(configPath); 54 + const configDir = path.dirname(configPath); 40 55 41 - log.info(`Site: ${config.siteUrl}`); 42 - log.info(`Content directory: ${config.contentDir}`); 56 + log.info(`Site: ${config.siteUrl}`); 57 + log.info(`Content directory: ${config.contentDir}`); 43 58 44 - // Load credentials 45 - let credentials = await loadCredentials(config.identity); 59 + // Load credentials 60 + let credentials = await loadCredentials(config.identity); 46 61 47 - // If no credentials resolved, check if we need to prompt for identity selection 48 - if (!credentials) { 49 - const identities = await listCredentials(); 50 - if (identities.length === 0) { 51 - log.error("No credentials found. Run 'sequoia auth' first."); 52 - log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables."); 53 - process.exit(1); 54 - } 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 + } 55 74 56 - // Multiple identities exist but none selected - prompt user 57 - log.info("Multiple identities found. Select one to use:"); 58 - const selected = exitOnCancel(await select({ 59 - message: "Identity:", 60 - options: identities.map(id => ({ value: id, label: id })), 61 - })); 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 + ); 62 91 63 - credentials = await getCredentials(selected); 64 - if (!credentials) { 65 - log.error("Failed to load selected credentials."); 66 - process.exit(1); 67 - } 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 + ); 68 100 69 - log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`); 70 - } 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 + } 71 116 72 - // Resolve content directory 73 - const contentDir = path.isAbsolute(config.contentDir) 74 - ? config.contentDir 75 - : path.join(configDir, config.contentDir); 117 + if (!credentials) { 118 + log.error("Failed to load selected credentials."); 119 + process.exit(1); 120 + } 76 121 77 - const imagesDir = config.imagesDir 78 - ? path.isAbsolute(config.imagesDir) 79 - ? config.imagesDir 80 - : path.join(configDir, config.imagesDir) 81 - : undefined; 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 + } 82 130 83 - // Load state 84 - const state = await loadState(configDir); 131 + // Resolve content directory 132 + const contentDir = path.isAbsolute(config.contentDir) 133 + ? config.contentDir 134 + : path.join(configDir, config.contentDir); 85 135 86 - // Scan for posts 87 - const s = spinner(); 88 - s.start("Scanning for posts..."); 89 - const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore); 90 - s.stop(`Found ${posts.length} posts`); 136 + const imagesDir = config.imagesDir 137 + ? path.isAbsolute(config.imagesDir) 138 + ? config.imagesDir 139 + : path.join(configDir, config.imagesDir) 140 + : undefined; 91 141 92 - // Determine which posts need publishing 93 - const postsToPublish: Array<{ 94 - post: BlogPost; 95 - action: "create" | "update"; 96 - reason: string; 97 - }> = []; 142 + // Load state 143 + const state = await loadState(configDir); 98 144 99 - for (const post of posts) { 100 - const contentHash = await getContentHash(post.rawContent); 101 - const relativeFilePath = path.relative(configDir, post.filePath); 102 - const postState = state.posts[relativeFilePath]; 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`); 103 156 104 - if (force) { 105 - postsToPublish.push({ 106 - post, 107 - action: post.frontmatter.atUri ? "update" : "create", 108 - reason: "forced", 109 - }); 110 - } else if (!postState) { 111 - // New post 112 - postsToPublish.push({ 113 - post, 114 - action: "create", 115 - reason: "new post", 116 - }); 117 - } else if (postState.contentHash !== contentHash) { 118 - // Changed post 119 - postsToPublish.push({ 120 - post, 121 - action: post.frontmatter.atUri ? "update" : "create", 122 - reason: "content changed", 123 - }); 124 - } 125 - } 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[] = []; 126 164 127 - if (postsToPublish.length === 0) { 128 - log.success("All posts are up to date. Nothing to publish."); 129 - return; 130 - } 165 + for (const post of posts) { 166 + // Skip draft posts 167 + if (post.frontmatter.draft) { 168 + draftPosts.push(post); 169 + continue; 170 + } 131 171 132 - log.info(`\n${postsToPublish.length} posts to publish:\n`); 133 - for (const { post, action, reason } of postsToPublish) { 134 - const icon = action === "create" ? "+" : "~"; 135 - log.message(` ${icon} ${post.frontmatter.title} (${reason})`); 136 - } 172 + const contentHash = await getContentHash(post.rawContent); 173 + const relativeFilePath = path.relative(configDir, post.filePath); 174 + const postState = state.posts[relativeFilePath]; 137 175 138 - if (dryRun) { 139 - log.info("\nDry run complete. No changes made."); 140 - return; 141 - } 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 + } 142 197 143 - // Create agent 144 - s.start(`Connecting to ${credentials.pdsUrl}...`); 145 - let agent; 146 - try { 147 - agent = await createAgent(credentials); 148 - s.stop(`Logged in as ${agent.session?.handle}`); 149 - } catch (error) { 150 - s.stop("Failed to login"); 151 - log.error(`Failed to login: ${error}`); 152 - process.exit(1); 153 - } 198 + if (draftPosts.length > 0) { 199 + log.info( 200 + `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`, 201 + ); 202 + } 154 203 155 - // Publish posts 156 - let publishedCount = 0; 157 - let updatedCount = 0; 158 - let errorCount = 0; 204 + if (postsToPublish.length === 0) { 205 + log.success("All posts are up to date. Nothing to publish."); 206 + return; 207 + } 159 208 160 - for (const { post, action } of postsToPublish) { 161 - s.start(`Publishing: ${post.frontmatter.title}`); 209 + log.info(`\n${postsToPublish.length} posts to publish:\n`); 162 210 163 - try { 164 - // Handle cover image upload 165 - let coverImage: BlobObject | undefined; 166 - if (post.frontmatter.ogImage) { 167 - const imagePath = resolveImagePath( 168 - post.frontmatter.ogImage, 169 - imagesDir, 170 - contentDir 171 - ); 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); 172 216 173 - if (imagePath) { 174 - log.info(` Uploading cover image: ${path.basename(imagePath)}`); 175 - coverImage = await uploadImage(agent, imagePath); 176 - if (coverImage) { 177 - log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 178 - } 179 - } else { 180 - log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 181 - } 182 - } 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; 183 221 184 - // Track atUri and content for state saving 185 - let atUri: string; 186 - let contentForHash: string; 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 + } 187 235 188 - if (action === "create") { 189 - atUri = await createDocument(agent, post, config, coverImage); 190 - s.stop(`Created: ${atUri}`); 236 + log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`); 237 + } 191 238 192 - // Update frontmatter with atUri 193 - const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri); 194 - await Bun.write(post.filePath, updatedContent); 195 - log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 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 + } 196 246 197 - // Use updated content (with atUri) for hash so next run sees matching hash 198 - contentForHash = updatedContent; 199 - publishedCount++; 200 - } else { 201 - atUri = post.frontmatter.atUri!; 202 - await updateDocument(agent, post, atUri, config, coverImage); 203 - s.stop(`Updated: ${atUri}`); 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 + } 204 260 205 - // For updates, rawContent already has atUri 206 - contentForHash = post.rawContent; 207 - updatedCount++; 208 - } 261 + // Publish posts 262 + let publishedCount = 0; 263 + let updatedCount = 0; 264 + let errorCount = 0; 265 + let bskyPostCount = 0; 266 + 267 + const context: NoteOptions = { 268 + contentDir, 269 + imagesDir, 270 + allPosts: posts, 271 + }; 272 + 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 + }> = []; 209 279 210 - // Update state (use relative path from config directory) 211 - const contentHash = await getContentHash(contentForHash); 212 - const relativeFilePath = path.relative(configDir, post.filePath); 213 - state.posts[relativeFilePath] = { 214 - contentHash, 215 - atUri, 216 - lastPublished: new Date().toISOString(), 217 - }; 218 - } catch (error) { 219 - const errorMessage = error instanceof Error ? error.message : String(error); 220 - s.stop(`Error publishing "${path.basename(post.filePath)}"`); 221 - log.error(` ${errorMessage}`); 222 - errorCount++; 280 + for (const { post, action } of postsToPublish) { 281 + s.start(`Publishing: ${post.frontmatter.title}`); 282 + 283 + // Init publish date 284 + if (!post.frontmatter.publishDate) { 285 + const [publishDate] = new Date().toISOString().split("T") 286 + post.frontmatter.publishDate = publishDate! 223 287 } 224 - } 225 288 226 - // Save state 227 - await saveState(configDir, state); 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 + ); 298 + 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 + } 228 309 229 - // Summary 230 - log.message("\n---"); 231 - log.info(`Published: ${publishedCount}`); 232 - log.info(`Updated: ${updatedCount}`); 233 - if (errorCount > 0) { 234 - log.warn(`Errors: ${errorCount}`); 235 - } 236 - }, 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); 315 + 316 + // Check if bskyPostRef already exists in state 317 + const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; 318 + 319 + if (action === "create") { 320 + atUri = await createDocument(agent, post, config, coverImage); 321 + post.frontmatter.atUri = atUri; 322 + s.stop(`Created: ${atUri}`); 323 + 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)}`); 331 + 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}`); 339 + 340 + // For updates, rawContent already has atUri 341 + contentForHash = post.rawContent; 342 + updatedCount++; 343 + } 344 + 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); 352 + 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}`; 362 + 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 + }); 370 + 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 + } 385 + 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 + }; 395 + 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 + } 405 + 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 + } 420 + 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 + }, 237 467 });
+224 -151
packages/cli/src/commands/sync.ts
··· 1 + import * as fs from "node:fs/promises"; 1 2 import { command, flag } from "cmd-ts"; 2 3 import { select, spinner, log } from "@clack/prompts"; 3 - import * as path from "path"; 4 + import * as path from "node:path"; 4 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 5 - 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"; 6 12 import { createAgent, listDocuments } from "../lib/atproto"; 7 - import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown"; 13 + import { 14 + scanContentDirectory, 15 + getContentHash, 16 + getTextContent, 17 + updateFrontmatterWithAtUri, 18 + } from "../lib/markdown"; 8 19 import { exitOnCancel } from "../lib/prompts"; 9 20 10 21 export const syncCommand = command({ 11 - name: "sync", 12 - description: "Sync state from ATProto to restore .sequoia-state.json", 13 - args: { 14 - updateFrontmatter: flag({ 15 - long: "update-frontmatter", 16 - short: "u", 17 - description: "Update frontmatter atUri fields in local markdown files", 18 - }), 19 - dryRun: flag({ 20 - long: "dry-run", 21 - short: "n", 22 - description: "Preview what would be synced without making changes", 23 - }), 24 - }, 25 - handler: async ({ updateFrontmatter, dryRun }) => { 26 - // Load config 27 - const configPath = await findConfig(); 28 - if (!configPath) { 29 - log.error("No sequoia.json found. Run 'sequoia init' first."); 30 - process.exit(1); 31 - } 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 + } 32 43 33 - const config = await loadConfig(configPath); 34 - const configDir = path.dirname(configPath); 44 + const config = await loadConfig(configPath); 45 + const configDir = path.dirname(configPath); 35 46 36 - log.info(`Site: ${config.siteUrl}`); 37 - log.info(`Publication: ${config.publicationUri}`); 47 + log.info(`Site: ${config.siteUrl}`); 48 + log.info(`Publication: ${config.publicationUri}`); 38 49 39 - // Load credentials 40 - let credentials = await loadCredentials(config.identity); 50 + // Load credentials 51 + let credentials = await loadCredentials(config.identity); 41 52 42 - if (!credentials) { 43 - const identities = await listCredentials(); 44 - if (identities.length === 0) { 45 - log.error("No credentials found. Run 'sequoia auth' first."); 46 - process.exit(1); 47 - } 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 + } 48 61 49 - log.info("Multiple identities found. Select one to use:"); 50 - const selected = exitOnCancel(await select({ 51 - message: "Identity:", 52 - options: identities.map(id => ({ value: id, label: id })), 53 - })); 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 + ); 54 78 55 - credentials = await getCredentials(selected); 56 - if (!credentials) { 57 - log.error("Failed to load selected credentials."); 58 - process.exit(1); 59 - } 60 - } 79 + log.info("Multiple identities found. Select one to use:"); 80 + const selected = exitOnCancel( 81 + await select({ 82 + message: "Identity:", 83 + options, 84 + }), 85 + ); 61 86 62 - // Create agent 63 - const s = spinner(); 64 - s.start(`Connecting to ${credentials.pdsUrl}...`); 65 - let agent; 66 - try { 67 - agent = await createAgent(credentials); 68 - s.stop(`Logged in as ${agent.session?.handle}`); 69 - } catch (error) { 70 - s.stop("Failed to login"); 71 - log.error(`Failed to login: ${error}`); 72 - process.exit(1); 73 - } 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 + } 74 102 75 - // Fetch documents from PDS 76 - s.start("Fetching documents from PDS..."); 77 - const documents = await listDocuments(agent, config.publicationUri); 78 - s.stop(`Found ${documents.length} documents on PDS`); 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 + } 79 133 80 - if (documents.length === 0) { 81 - log.info("No documents found for this publication."); 82 - return; 83 - } 134 + // Resolve content directory 135 + const contentDir = path.isAbsolute(config.contentDir) 136 + ? config.contentDir 137 + : path.join(configDir, config.contentDir); 84 138 85 - // Resolve content directory 86 - const contentDir = path.isAbsolute(config.contentDir) 87 - ? config.contentDir 88 - : 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`); 89 149 90 - // Scan local posts 91 - s.start("Scanning local content..."); 92 - const localPosts = await scanContentDirectory(contentDir, config.frontmatter); 93 - s.stop(`Found ${localPosts.length} local posts`); 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 + } 94 158 95 - // Build a map of path -> local post for matching 96 - // Document path is like /posts/my-post-slug 97 - const postsByPath = new Map<string, typeof localPosts[0]>(); 98 - for (const post of localPosts) { 99 - const postPath = `/posts/${post.slug}`; 100 - postsByPath.set(postPath, post); 101 - } 159 + // Load existing state 160 + const state = await loadState(configDir); 161 + const originalPostCount = Object.keys(state.posts).length; 102 162 103 - // Load existing state 104 - const state = await loadState(configDir); 105 - 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 }> = []; 106 167 107 - // Track changes 108 - let matchedCount = 0; 109 - let unmatchedCount = 0; 110 - let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 168 + log.message("\nMatching documents to local files:\n"); 111 169 112 - 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); 113 173 114 - for (const doc of documents) { 115 - const docPath = doc.value.path; 116 - 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)}`); 117 180 118 - if (localPost) { 119 - matchedCount++; 120 - log.message(` โœ“ ${doc.value.title}`); 121 - log.message(` Path: ${docPath}`); 122 - log.message(` URI: ${doc.uri}`); 123 - log.message(` File: ${path.basename(localPost.filePath)}`); 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; 124 191 125 - // Update state (use relative path from config directory) 126 - const contentHash = await getContentHash(localPost.rawContent); 127 - const relativeFilePath = path.relative(configDir, localPost.filePath); 128 - state.posts[relativeFilePath] = { 129 - contentHash, 130 - atUri: doc.uri, 131 - lastPublished: doc.value.publishedAt, 132 - }; 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 + }; 133 203 134 - // Check if frontmatter needs updating 135 - if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 136 - frontmatterUpdates.push({ 137 - filePath: localPost.filePath, 138 - atUri: doc.uri, 139 - }); 140 - log.message(` โ†’ Will update frontmatter`); 141 - } 142 - } else { 143 - unmatchedCount++; 144 - log.message(` โœ— ${doc.value.title} (no matching local file)`); 145 - log.message(` Path: ${docPath}`); 146 - log.message(` URI: ${doc.uri}`); 147 - } 148 - log.message(""); 149 - } 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 + } 150 220 151 - // Summary 152 - log.message("---"); 153 - log.info(`Matched: ${matchedCount} documents`); 154 - if (unmatchedCount > 0) { 155 - log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`); 156 - } 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 + } 157 229 158 - if (dryRun) { 159 - log.info("\nDry run complete. No changes made."); 160 - return; 161 - } 230 + if (dryRun) { 231 + log.info("\nDry run complete. No changes made."); 232 + return; 233 + } 162 234 163 - // Save updated state 164 - await saveState(configDir, state); 165 - const newPostCount = Object.keys(state.posts).length; 166 - log.success(`\nSaved .sequoia-state.json (${originalPostCount} โ†’ ${newPostCount} entries)`); 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 + ); 167 241 168 - // Update frontmatter if requested 169 - if (frontmatterUpdates.length > 0) { 170 - s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 171 - for (const { filePath, atUri } of frontmatterUpdates) { 172 - const file = Bun.file(filePath); 173 - const content = await file.text(); 174 - const updated = updateFrontmatterWithAtUri(content, atUri); 175 - await Bun.write(filePath, updated); 176 - log.message(` Updated: ${path.basename(filePath)}`); 177 - } 178 - s.stop("Frontmatter updated"); 179 - } 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 + }
+7 -3
packages/cli/src/index.ts
··· 1 - #!/usr/bin/env bun 1 + #!/usr/bin/env node 2 2 3 3 import { run, subcommands } from "cmd-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", ··· 31 33 32 34 Publish evergreen content to the ATmosphere 33 35 34 - > https://tanlged.org/stevedylan.dev/sequoia 36 + > https://tangled.org/stevedylan.dev/sequoia 35 37 `, 36 - version: "0.1.0", 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
+649 -272
packages/cli/src/lib/atproto.ts
··· 1 - import { AtpAgent } from "@atproto/api"; 2 - import * as path from "path"; 3 - import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types"; 4 - import { stripMarkdownForText } from "./markdown"; 1 + import { Agent, AtpAgent } from "@atproto/api"; 2 + import * as mimeTypes from "mime-types"; 3 + import * as fs from "node:fs/promises"; 4 + import * as path from "node:path"; 5 + import { 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 + } 32 + 33 + async function fileExists(filePath: string): Promise<boolean> { 34 + try { 35 + await fs.access(filePath); 36 + return true; 37 + } catch { 38 + return false; 39 + } 40 + } 41 + 42 + /** 43 + * Resolve a handle to a DID 44 + */ 45 + export async function resolveHandleToDid(handle: string): Promise<string> { 46 + if (handle.startsWith("did:")) { 47 + return handle; 48 + } 49 + 50 + // Try to resolve handle via Bluesky API 51 + const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 52 + const resolveResponse = await fetch(resolveUrl); 53 + if (!resolveResponse.ok) { 54 + throw new Error("Could not resolve handle"); 55 + } 56 + const resolveData = (await resolveResponse.json()) as { did: string }; 57 + return resolveData.did; 58 + } 5 59 6 60 export async function resolveHandleToPDS(handle: string): Promise<string> { 7 - // First, resolve the handle to a DID 8 - let did: string; 61 + // First, resolve the handle to a DID 62 + const did = await resolveHandleToDid(handle); 9 63 10 - if (handle.startsWith("did:")) { 11 - did = handle; 12 - } else { 13 - // Try to resolve handle via Bluesky API 14 - const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 15 - const resolveResponse = await fetch(resolveUrl); 16 - if (!resolveResponse.ok) { 17 - throw new Error("Could not resolve handle"); 18 - } 19 - const resolveData = (await resolveResponse.json()) as { did: string }; 20 - did = resolveData.did; 21 - } 64 + // Now resolve the DID to get the PDS URL from the DID document 65 + let pdsUrl: string | undefined; 22 66 23 - // Now resolve the DID to get the PDS URL from the DID document 24 - 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 + }; 25 77 26 - if (did.startsWith("did:plc:")) { 27 - // Fetch DID document from plc.directory 28 - const didDocUrl = `https://plc.directory/${did}`; 29 - const didDocResponse = await fetch(didDocUrl); 30 - if (!didDocResponse.ok) { 31 - throw new Error("Could not fetch DID document"); 32 - } 33 - const didDoc = (await didDocResponse.json()) as { 34 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 35 - }; 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 + }; 36 94 37 - // Find the PDS service endpoint 38 - const pdsService = didDoc.service?.find( 39 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 40 - ); 41 - pdsUrl = pdsService?.serviceEndpoint; 42 - } else if (did.startsWith("did:web:")) { 43 - // For did:web, fetch the DID document from the domain 44 - const domain = did.replace("did:web:", ""); 45 - const didDocUrl = `https://${domain}/.well-known/did.json`; 46 - const didDocResponse = await fetch(didDocUrl); 47 - if (!didDocResponse.ok) { 48 - throw new Error("Could not fetch DID document"); 49 - } 50 - const didDoc = (await didDocResponse.json()) as { 51 - service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 52 - }; 53 - 54 - const pdsService = didDoc.service?.find( 55 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 56 - ); 57 - pdsUrl = pdsService?.serviceEndpoint; 58 - } 95 + const pdsService = didDoc.service?.find( 96 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 97 + ); 98 + pdsUrl = pdsService?.serviceEndpoint; 99 + } 59 100 60 - if (!pdsUrl) { 61 - throw new Error("Could not find PDS URL for user"); 62 - } 101 + if (!pdsUrl) { 102 + throw new Error("Could not find PDS URL for user"); 103 + } 63 104 64 - return pdsUrl; 105 + return pdsUrl; 65 106 } 66 107 67 108 export interface CreatePublicationOptions { 68 - url: string; 69 - name: string; 70 - description?: string; 71 - iconPath?: string; 72 - showInDiscover?: boolean; 109 + url: string; 110 + name: string; 111 + description?: string; 112 + iconPath?: string; 113 + showInDiscover?: boolean; 73 114 } 74 115 75 - export async function createAgent(credentials: Credentials): Promise<AtpAgent> { 76 - 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 + } 139 + 140 + // App password flow 141 + if (!isAppPasswordCredentials(credentials)) { 142 + throw new Error("Invalid credential type"); 143 + } 144 + const agent = new AtpAgent({ service: credentials.pdsUrl }); 77 145 78 - await agent.login({ 79 - identifier: credentials.identifier, 80 - password: credentials.password, 81 - }); 146 + await agent.login({ 147 + identifier: credentials.identifier, 148 + password: credentials.password, 149 + }); 82 150 83 - return agent; 151 + return agent; 84 152 } 85 153 86 154 export async function uploadImage( 87 - agent: AtpAgent, 88 - imagePath: string 155 + agent: Agent, 156 + imagePath: string, 89 157 ): Promise<BlobObject | undefined> { 90 - const file = Bun.file(imagePath); 158 + if (!(await fileExists(imagePath))) { 159 + return undefined; 160 + } 91 161 92 - if (!(await file.exists())) { 93 - return undefined; 94 - } 162 + try { 163 + const imageBuffer = await fs.readFile(imagePath); 164 + const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 165 + 166 + const response = await agent.com.atproto.repo.uploadBlob( 167 + new Uint8Array(imageBuffer), 168 + { 169 + encoding: mimeType, 170 + }, 171 + ); 172 + 173 + return { 174 + $type: "blob", 175 + ref: { 176 + $link: response.data.blob.ref.toString(), 177 + }, 178 + mimeType, 179 + size: imageBuffer.byteLength, 180 + }; 181 + } catch (error) { 182 + console.error(`Error uploading image ${imagePath}:`, error); 183 + return undefined; 184 + } 185 + } 95 186 96 - try { 97 - const imageBuffer = await file.arrayBuffer(); 98 - const mimeType = file.type || "application/octet-stream"; 187 + export async function resolveImagePath( 188 + ogImage: string, 189 + imagesDir: string | undefined, 190 + contentDir: string, 191 + ): Promise<string | null> { 192 + // Try multiple resolution strategies 99 193 100 - const response = await agent.com.atproto.repo.uploadBlob( 101 - new Uint8Array(imageBuffer), 102 - { 103 - encoding: mimeType, 104 - } 105 - ); 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); 106 198 107 - return { 108 - $type: "blob", 109 - ref: { 110 - $link: response.data.blob.ref.toString(), 111 - }, 112 - mimeType, 113 - size: imageBuffer.byteLength, 114 - }; 115 - } catch (error) { 116 - console.error(`Error uploading image ${imagePath}:`, error); 117 - return undefined; 118 - } 119 - } 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; 120 203 121 - export function resolveImagePath( 122 - ogImage: string, 123 - imagesDir: string | undefined, 124 - contentDir: string 125 - ): string | null { 126 - // Try multiple resolution strategies 127 - const filename = path.basename(ogImage); 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 + } 128 215 129 - // 1. If imagesDir is specified, look there 130 - if (imagesDir) { 131 - const imagePath = path.join(imagesDir, filename); 132 - try { 133 - const stat = Bun.file(imagePath); 134 - if (stat.size > 0) { 135 - return imagePath; 136 - } 137 - } catch { 138 - // File doesn't exist, continue 139 - } 140 - } 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 + } 141 224 142 - // 2. Try the ogImage path directly (if it's absolute) 143 - if (path.isAbsolute(ogImage)) { 144 - return ogImage; 145 - } 225 + // 2. Try the ogImage path directly (if it's absolute) 226 + if (path.isAbsolute(ogImage)) { 227 + return ogImage; 228 + } 146 229 147 - // 3. Try relative to content directory 148 - const contentRelative = path.join(contentDir, ogImage); 149 - try { 150 - const stat = Bun.file(contentRelative); 151 - if (stat.size > 0) { 152 - return contentRelative; 153 - } 154 - } catch { 155 - // File doesn't exist 156 - } 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 + } 157 238 158 - return null; 239 + return null; 159 240 } 160 241 161 242 export async function createDocument( 162 - agent: AtpAgent, 163 - post: BlogPost, 164 - config: PublisherConfig, 165 - coverImage?: BlobObject 243 + agent: Agent, 244 + post: BlogPost, 245 + config: PublisherConfig, 246 + coverImage?: BlobObject, 166 247 ): Promise<string> { 167 - const pathPrefix = config.pathPrefix || "/posts"; 168 - const postPath = `${pathPrefix}/${post.slug}`; 169 - const textContent = stripMarkdownForText(post.content); 170 - const publishDate = new Date(post.frontmatter.publishDate); 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); 171 252 172 - const record: Record<string, unknown> = { 173 - $type: "site.standard.document", 174 - title: post.frontmatter.title, 175 - site: config.publicationUri, 176 - path: postPath, 177 - textContent: textContent.slice(0, 10000), 178 - publishedAt: publishDate.toISOString(), 179 - canonicalUrl: `${config.siteUrl}${postPath}`, 180 - }; 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 + }; 181 262 182 - if (coverImage) { 183 - record.coverImage = coverImage; 184 - } 263 + if (post.frontmatter.description) { 264 + record.description = post.frontmatter.description; 265 + } 266 + 267 + if (coverImage) { 268 + record.coverImage = coverImage; 269 + } 185 270 186 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 187 - record.tags = post.frontmatter.tags; 188 - } 271 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 272 + record.tags = post.frontmatter.tags; 273 + } 189 274 190 - const response = await agent.com.atproto.repo.createRecord({ 191 - repo: agent.session!.did, 192 - collection: "site.standard.document", 193 - record, 194 - }); 275 + const response = await agent.com.atproto.repo.createRecord({ 276 + repo: agent.did!, 277 + collection: "site.standard.document", 278 + record, 279 + }); 195 280 196 - return response.data.uri; 281 + return response.data.uri; 197 282 } 198 283 199 284 export async function updateDocument( 200 - agent: AtpAgent, 201 - post: BlogPost, 202 - atUri: string, 203 - config: PublisherConfig, 204 - coverImage?: BlobObject 285 + agent: Agent, 286 + post: BlogPost, 287 + atUri: string, 288 + config: PublisherConfig, 289 + coverImage?: BlobObject, 205 290 ): Promise<void> { 206 - // Parse the atUri to get the collection and rkey 207 - // Format: at://did:plc:xxx/collection/rkey 208 - const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 209 - if (!uriMatch) { 210 - throw new Error(`Invalid atUri format: ${atUri}`); 211 - } 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 + } 212 297 213 - const [, , collection, rkey] = uriMatch; 298 + const [, , collection, rkey] = uriMatch; 299 + 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); 214 305 215 - const pathPrefix = config.pathPrefix || "/posts"; 216 - const postPath = `${pathPrefix}/${post.slug}`; 217 - const textContent = stripMarkdownForText(post.content); 218 - const publishDate = new Date(post.frontmatter.publishDate); 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 + }; 219 315 220 - const record: Record<string, unknown> = { 221 - $type: "site.standard.document", 222 - title: post.frontmatter.title, 223 - site: config.publicationUri, 224 - path: postPath, 225 - textContent: textContent.slice(0, 10000), 226 - publishedAt: publishDate.toISOString(), 227 - canonicalUrl: `${config.siteUrl}${postPath}`, 228 - }; 316 + if (post.frontmatter.description) { 317 + record.description = post.frontmatter.description; 318 + } 229 319 230 - if (coverImage) { 231 - record.coverImage = coverImage; 232 - } 320 + if (coverImage) { 321 + record.coverImage = coverImage; 322 + } 233 323 234 - if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 235 - record.tags = post.frontmatter.tags; 236 - } 324 + if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 325 + record.tags = post.frontmatter.tags; 326 + } 237 327 238 - await agent.com.atproto.repo.putRecord({ 239 - repo: agent.session!.did, 240 - collection: collection!, 241 - rkey: rkey!, 242 - record, 243 - }); 328 + await agent.com.atproto.repo.putRecord({ 329 + repo: agent.did!, 330 + collection: collection!, 331 + rkey: rkey!, 332 + record, 333 + }); 244 334 } 245 335 246 - export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null { 247 - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 248 - if (!match) return null; 249 - return { 250 - did: match[1]!, 251 - collection: match[2]!, 252 - rkey: match[3]!, 253 - }; 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 + }; 254 346 } 255 347 256 348 export interface DocumentRecord { 257 - $type: "site.standard.document"; 258 - title: string; 259 - site: string; 260 - path: string; 261 - textContent: string; 262 - publishedAt: string; 263 - canonicalUrl?: string; 264 - coverImage?: BlobObject; 265 - tags?: string[]; 266 - location?: string; 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; 267 360 } 268 361 269 362 export interface ListDocumentsResult { 270 - uri: string; 271 - cid: string; 272 - value: DocumentRecord; 363 + uri: string; 364 + cid: string; 365 + value: DocumentRecord; 273 366 } 274 367 275 368 export async function listDocuments( 276 - agent: AtpAgent, 277 - publicationUri?: string 369 + agent: Agent, 370 + publicationUri?: string, 278 371 ): Promise<ListDocumentsResult[]> { 279 - const documents: ListDocumentsResult[] = []; 280 - let cursor: string | undefined; 372 + const documents: ListDocumentsResult[] = []; 373 + let cursor: string | undefined; 281 374 282 - do { 283 - const response = await agent.com.atproto.repo.listRecords({ 284 - repo: agent.session!.did, 285 - collection: "site.standard.document", 286 - limit: 100, 287 - cursor, 288 - }); 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 + }); 289 382 290 - for (const record of response.data.records) { 291 - const value = record.value as unknown as DocumentRecord; 383 + for (const record of response.data.records) { 384 + if (!isDocumentRecord(record.value)) { 385 + continue; 386 + } 292 387 293 - // If publicationUri is specified, only include documents from that publication 294 - if (publicationUri && value.site !== publicationUri) { 295 - continue; 296 - } 388 + // If publicationUri is specified, only include documents from that publication 389 + if (publicationUri && record.value.site !== publicationUri) { 390 + continue; 391 + } 297 392 298 - documents.push({ 299 - uri: record.uri, 300 - cid: record.cid, 301 - value, 302 - }); 303 - } 393 + documents.push({ 394 + uri: record.uri, 395 + cid: record.cid, 396 + value: record.value, 397 + }); 398 + } 304 399 305 - cursor = response.data.cursor; 306 - } while (cursor); 400 + cursor = response.data.cursor; 401 + } while (cursor); 307 402 308 - return documents; 403 + return documents; 309 404 } 310 405 311 406 export async function createPublication( 312 - agent: AtpAgent, 313 - options: CreatePublicationOptions 407 + agent: Agent, 408 + options: CreatePublicationOptions, 314 409 ): Promise<string> { 315 - let icon: BlobObject | undefined; 410 + let icon: BlobObject | undefined; 316 411 317 - if (options.iconPath) { 318 - icon = await uploadImage(agent, options.iconPath); 319 - } 412 + if (options.iconPath) { 413 + icon = await uploadImage(agent, options.iconPath); 414 + } 320 415 321 - const record: Record<string, unknown> = { 322 - $type: "site.standard.publication", 323 - url: options.url, 324 - name: options.name, 325 - createdAt: new Date().toISOString(), 326 - }; 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 + } 327 451 328 - if (options.description) { 329 - record.description = options.description; 330 - } 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 + } 331 460 332 - if (icon) { 333 - record.icon = icon; 334 - } 461 + try { 462 + const response = await agent.com.atproto.repo.getRecord({ 463 + repo: parsed.did, 464 + collection: parsed.collection, 465 + rkey: parsed.rkey, 466 + }); 335 467 336 - if (options.showInDiscover !== undefined) { 337 - record.preferences = { 338 - showInDiscover: options.showInDiscover, 339 - }; 340 - } 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 + } 341 477 342 - const response = await agent.com.atproto.repo.createRecord({ 343 - repo: agent.session!.did, 344 - collection: "site.standard.publication", 345 - record, 346 - }); 478 + export interface UpdatePublicationOptions { 479 + url?: string; 480 + name?: string; 481 + description?: string; 482 + iconPath?: string; 483 + showInDiscover?: boolean; 484 + } 347 485 348 - return response.data.uri; 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 + } 524 + 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 + } 533 + 534 + await agent.com.atproto.repo.putRecord({ 535 + repo: parsed.did, 536 + collection: parsed.collection, 537 + rkey: parsed.rkey, 538 + record, 539 + }); 540 + } 541 + 542 + // --- Bluesky Post Creation --- 543 + 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 + } 551 + 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 + } 563 + 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; 591 + 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 + }); 349 726 }
+41 -10
packages/cli/src/lib/config.ts
··· 1 - import * as path from "path"; 2 - 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"; 3 9 4 10 const CONFIG_FILENAME = "sequoia.json"; 5 11 const STATE_FILENAME = ".sequoia-state.json"; 6 12 13 + async function fileExists(filePath: string): Promise<boolean> { 14 + try { 15 + await fs.access(filePath); 16 + return true; 17 + } catch { 18 + return false; 19 + } 20 + } 21 + 7 22 export async function findConfig( 8 23 startDir: string = process.cwd(), 9 24 ): Promise<string | null> { ··· 11 26 12 27 while (true) { 13 28 const configPath = path.join(currentDir, CONFIG_FILENAME); 14 - const file = Bun.file(configPath); 15 29 16 - if (await file.exists()) { 30 + if (await fileExists(configPath)) { 17 31 return configPath; 18 32 } 19 33 ··· 38 52 } 39 53 40 54 try { 41 - const file = Bun.file(resolvedPath); 42 - const content = await file.text(); 55 + const content = await fs.readFile(resolvedPath, "utf-8"); 43 56 const config = JSON.parse(content) as PublisherConfig; 44 57 45 58 // Validate required fields ··· 68 81 pdsUrl?: string; 69 82 frontmatter?: FrontmatterMapping; 70 83 ignore?: string[]; 84 + removeIndexFromSlug?: boolean; 85 + stripDatePrefix?: boolean; 86 + textContentField?: string; 87 + bluesky?: BlueskyConfig; 71 88 }): string { 72 89 const config: Record<string, unknown> = { 73 90 siteUrl: options.siteUrl, ··· 104 121 config.ignore = options.ignore; 105 122 } 106 123 124 + if (options.removeIndexFromSlug) { 125 + config.removeIndexFromSlug = options.removeIndexFromSlug; 126 + } 127 + 128 + if (options.stripDatePrefix) { 129 + config.stripDatePrefix = options.stripDatePrefix; 130 + } 131 + 132 + if (options.textContentField) { 133 + config.textContentField = options.textContentField; 134 + } 135 + if (options.bluesky) { 136 + config.bluesky = options.bluesky; 137 + } 138 + 107 139 return JSON.stringify(config, null, 2); 108 140 } 109 141 110 142 export async function loadState(configDir: string): Promise<PublisherState> { 111 143 const statePath = path.join(configDir, STATE_FILENAME); 112 - const file = Bun.file(statePath); 113 144 114 - if (!(await file.exists())) { 145 + if (!(await fileExists(statePath))) { 115 146 return { posts: {} }; 116 147 } 117 148 118 149 try { 119 - const content = await file.text(); 150 + const content = await fs.readFile(statePath, "utf-8"); 120 151 return JSON.parse(content) as PublisherState; 121 152 } catch { 122 153 return { posts: {} }; ··· 128 159 state: PublisherState, 129 160 ): Promise<void> { 130 161 const statePath = path.join(configDir, STATE_FILENAME); 131 - await Bun.write(statePath, JSON.stringify(state, null, 2)); 162 + await fs.writeFile(statePath, JSON.stringify(state, null, 2)); 132 163 } 133 164 134 165 export function getStatePath(configDir: string): string {
+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 + }
+220 -96
packages/cli/src/lib/credentials.ts
··· 1 - import * as path from "path"; 2 - import * as os from "os"; 3 - import type { Credentials } from "./types"; 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"; 4 16 5 17 const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 6 18 const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 7 19 8 - // Stored credentials keyed by identifier 9 - 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 + >; 25 + 26 + async function fileExists(filePath: string): Promise<boolean> { 27 + try { 28 + await fs.access(filePath); 29 + return true; 30 + } catch { 31 + return false; 32 + } 33 + } 10 34 11 35 /** 12 - * Load all stored credentials 36 + * Normalize credentials to have explicit type 13 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 + 14 54 async function loadCredentialsStore(): Promise<CredentialsStore> { 15 - const file = Bun.file(CREDENTIALS_FILE); 16 - if (!(await file.exists())) { 17 - return {}; 18 - } 55 + if (!(await fileExists(CREDENTIALS_FILE))) { 56 + return {}; 57 + } 19 58 20 - try { 21 - const content = await file.text(); 22 - const parsed = JSON.parse(content); 59 + try { 60 + const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 61 + const parsed = JSON.parse(content); 23 62 24 - // Handle legacy single-credential format (migrate on read) 25 - if (parsed.identifier && parsed.password) { 26 - const legacy = parsed as Credentials; 27 - return { [legacy.identifier]: legacy }; 28 - } 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 + } 29 68 30 - return parsed as CredentialsStore; 31 - } catch { 32 - return {}; 33 - } 69 + return parsed as CredentialsStore; 70 + } catch { 71 + return {}; 72 + } 34 73 } 35 74 36 75 /** 37 76 * Save the entire credentials store 38 77 */ 39 78 async function saveCredentialsStore(store: CredentialsStore): Promise<void> { 40 - await Bun.$`mkdir -p ${CONFIG_DIR}`; 41 - await Bun.write(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 42 - await Bun.$`chmod 600 ${CREDENTIALS_FILE}`; 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; 43 115 } 44 116 45 117 /** ··· 47 119 * 48 120 * Priority: 49 121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 50 - * 2. SEQUOIA_PROFILE env var - selects from stored credentials 122 + * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 51 123 * 3. projectIdentity parameter (from sequoia.json) 52 - * 4. If only one identity stored, use it 124 + * 4. If only one identity stored (app-password or OAuth), use it 53 125 * 5. Return null (caller should prompt user) 54 126 */ 55 127 export async function loadCredentials( 56 - projectIdentity?: string 128 + projectIdentity?: string, 57 129 ): Promise<Credentials | null> { 58 - // 1. Check environment variables first (full override) 59 - const envIdentifier = process.env.ATP_IDENTIFIER; 60 - const envPassword = process.env.ATP_APP_PASSWORD; 61 - const envPdsUrl = process.env.PDS_URL; 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; 62 134 63 - if (envIdentifier && envPassword) { 64 - return { 65 - identifier: envIdentifier, 66 - password: envPassword, 67 - pdsUrl: envPdsUrl || "https://bsky.social", 68 - }; 69 - } 135 + if (envIdentifier && envPassword) { 136 + return { 137 + type: "app-password", 138 + identifier: envIdentifier, 139 + password: envPassword, 140 + pdsUrl: envPdsUrl || "https://bsky.social", 141 + }; 142 + } 70 143 71 - const store = await loadCredentialsStore(); 72 - const identifiers = Object.keys(store); 73 - 74 - if (identifiers.length === 0) { 75 - return null; 76 - } 144 + const store = await loadCredentialsStore(); 145 + const appPasswordIds = Object.keys(store); 146 + const oauthDids = await listOAuthSessions(); 77 147 78 - // 2. SEQUOIA_PROFILE env var 79 - const profileEnv = process.env.SEQUOIA_PROFILE; 80 - if (profileEnv && store[profileEnv]) { 81 - return store[profileEnv]; 82 - } 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 + } 83 161 84 - // 3. Project-specific identity (from sequoia.json) 85 - if (projectIdentity && store[projectIdentity]) { 86 - return store[projectIdentity]; 87 - } 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 + } 88 172 89 - // 4. If only one identity, use it 90 - if (identifiers.length === 1 && identifiers[0]) { 91 - return store[identifiers[0]] ?? null; 92 - } 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 + } 93 191 94 - // Multiple identities exist but none selected 95 - return null; 192 + // Multiple identities exist but none selected, or no identities 193 + return null; 96 194 } 97 195 98 196 /** 99 - * Get a specific identity by identifier 197 + * Get a specific identity by identifier (app-password only) 100 198 */ 101 199 export async function getCredentials( 102 - identifier: string 103 - ): Promise<Credentials | null> { 104 - const store = await loadCredentialsStore(); 105 - 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); 106 206 } 107 207 108 208 /** 109 - * List all stored identities 209 + * List all stored app-password identities 110 210 */ 111 211 export async function listCredentials(): Promise<string[]> { 112 - const store = await loadCredentialsStore(); 113 - return Object.keys(store); 212 + const store = await loadCredentialsStore(); 213 + return Object.keys(store); 114 214 } 115 215 116 216 /** 117 - * Save credentials for an identity (adds or updates) 217 + * List all credentials (both app-password and OAuth) 118 218 */ 119 - export async function saveCredentials(credentials: Credentials): Promise<void> { 120 - const store = await loadCredentialsStore(); 121 - store[credentials.identifier] = credentials; 122 - await saveCredentialsStore(store); 219 + export async function listAllCredentials(): Promise< 220 + Array<{ id: string; type: "app-password" | "oauth" }> 221 + > { 222 + const store = await loadCredentialsStore(); 223 + const oauthDids = await listOAuthSessions(); 224 + 225 + const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 226 + 227 + for (const id of Object.keys(store)) { 228 + result.push({ id, type: "app-password" }); 229 + } 230 + 231 + for (const did of oauthDids) { 232 + result.push({ id: did, type: "oauth" }); 233 + } 234 + 235 + return result; 236 + } 237 + 238 + /** 239 + * Save app-password credentials for an identity (adds or updates) 240 + */ 241 + export async function saveCredentials( 242 + credentials: AppPasswordCredentials, 243 + ): Promise<void> { 244 + const store = await loadCredentialsStore(); 245 + store[credentials.identifier] = credentials; 246 + await saveCredentialsStore(store); 123 247 } 124 248 125 249 /** 126 250 * Delete credentials for a specific identity 127 251 */ 128 252 export async function deleteCredentials(identifier?: string): Promise<boolean> { 129 - const store = await loadCredentialsStore(); 130 - const identifiers = Object.keys(store); 253 + const store = await loadCredentialsStore(); 254 + const identifiers = Object.keys(store); 131 255 132 - if (identifiers.length === 0) { 133 - return false; 134 - } 256 + if (identifiers.length === 0) { 257 + return false; 258 + } 135 259 136 - // If identifier specified, delete just that one 137 - if (identifier) { 138 - if (!store[identifier]) { 139 - return false; 140 - } 141 - delete store[identifier]; 142 - await saveCredentialsStore(store); 143 - return true; 144 - } 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 + } 145 269 146 - // If only one identity, delete it (backwards compat behavior) 147 - if (identifiers.length === 1 && identifiers[0]) { 148 - delete store[identifiers[0]]; 149 - await saveCredentialsStore(store); 150 - return true; 151 - } 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 + } 152 276 153 - // Multiple identities but none specified 154 - return false; 277 + // Multiple identities but none specified 278 + return false; 155 279 } 156 280 157 281 export function getCredentialsPath(): string { 158 - return CREDENTIALS_FILE; 282 + return CREDENTIALS_FILE; 159 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 + });
+369 -172
packages/cli/src/lib/markdown.ts
··· 1 - import * as path from "path"; 2 - import { Glob } from "bun"; 3 - import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types"; 1 + import { webcrypto as crypto } from "node:crypto"; 2 + import * as fs from "node:fs/promises"; 3 + import * as path from "node:path"; 4 + import { glob } from "glob"; 5 + import { minimatch } from "minimatch"; 6 + import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types"; 4 7 5 - export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): { 6 - frontmatter: PostFrontmatter; 7 - body: string; 8 + export function parseFrontmatter( 9 + content: string, 10 + mapping?: FrontmatterMapping, 11 + ): { 12 + frontmatter: PostFrontmatter; 13 + body: string; 14 + rawFrontmatter: Record<string, unknown>; 8 15 } { 9 - // Support multiple frontmatter delimiters: 10 - // --- (YAML) - Jekyll, Astro, most SSGs 11 - // +++ (TOML) - Hugo 12 - // *** - Alternative format 13 - const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/; 14 - const match = content.match(frontmatterRegex); 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); 15 22 16 - if (!match) { 17 - throw new Error("Could not parse frontmatter"); 18 - } 23 + if (!match) { 24 + const [, titleMatch] = content.trim().match(/^# (.+)$/m) || [] 25 + const title = titleMatch ?? "" 26 + const [publishDate] = new Date().toISOString().split("T") 19 27 20 - const delimiter = match[1]; 21 - const frontmatterStr = match[2] ?? ""; 22 - 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 + } 23 40 24 - // Determine format based on delimiter: 25 - // +++ uses TOML (key = value) 26 - // --- and *** use YAML (key: value) 27 - const isToml = delimiter === "+++"; 28 - const separator = isToml ? "=" : ":"; 41 + const delimiter = match[1]; 42 + const frontmatterStr = match[2] ?? ""; 43 + const body = match[3] ?? ""; 29 44 30 - // Parse frontmatter manually 31 - const raw: Record<string, unknown> = {}; 32 - const lines = frontmatterStr.split("\n"); 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 ? "=" : ":"; 33 50 34 - for (const line of lines) { 35 - const sepIndex = line.indexOf(separator); 36 - if (sepIndex === -1) continue; 51 + // Parse frontmatter manually 52 + const raw: Record<string, unknown> = {}; 53 + const lines = frontmatterStr.split("\n"); 37 54 38 - const key = line.slice(0, sepIndex).trim(); 39 - let value = line.slice(sepIndex + 1).trim(); 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 + } 40 67 41 - // Handle quoted strings 42 - if ( 43 - (value.startsWith('"') && value.endsWith('"')) || 44 - (value.startsWith("'") && value.endsWith("'")) 45 - ) { 46 - value = value.slice(1, -1); 47 - } 68 + const key = line.slice(0, sepIndex).trim(); 69 + let value = line.slice(sepIndex + 1).trim(); 70 + 71 + // Handle quoted strings 72 + if ( 73 + (value.startsWith('"') && value.endsWith('"')) || 74 + (value.startsWith("'") && value.endsWith("'")) 75 + ) { 76 + value = value.slice(1, -1); 77 + } 78 + 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 + } 48 132 49 - // Handle arrays (simple case for tags) 50 - if (value.startsWith("[") && value.endsWith("]")) { 51 - const arrayContent = value.slice(1, -1); 52 - raw[key] = arrayContent 53 - .split(",") 54 - .map((item) => item.trim().replace(/^["']|["']$/g, "")); 55 - } else if (value === "true") { 56 - raw[key] = true; 57 - } else if (value === "false") { 58 - raw[key] = false; 59 - } else { 60 - raw[key] = value; 61 - } 62 - } 133 + // Apply field mappings to normalize to standard PostFrontmatter fields 134 + const frontmatter: Record<string, unknown> = {}; 63 135 64 - // Apply field mappings to normalize to standard PostFrontmatter fields 65 - const frontmatter: Record<string, unknown> = {}; 136 + // Title mapping 137 + const titleField = mapping?.title || "title"; 138 + frontmatter.title = raw[titleField] || raw.title; 66 139 67 - // Title mapping 68 - const titleField = mapping?.title || "title"; 69 - frontmatter.title = raw[titleField] || raw.title; 140 + // Description mapping 141 + const descField = mapping?.description || "description"; 142 + frontmatter.description = raw[descField] || raw.description; 70 143 71 - // Description mapping 72 - const descField = mapping?.description || "description"; 73 - 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 + } 74 160 75 - // Publish date mapping - check custom field first, then fallbacks 76 - const dateField = mapping?.publishDate; 77 - if (dateField && raw[dateField]) { 78 - frontmatter.publishDate = raw[dateField]; 79 - } else if (raw.publishDate) { 80 - frontmatter.publishDate = raw.publishDate; 81 - } else { 82 - // Fallback to common date field names 83 - const dateFields = ["pubDate", "date", "createdAt", "created_at"]; 84 - for (const field of dateFields) { 85 - if (raw[field]) { 86 - frontmatter.publishDate = raw[field]; 87 - break; 88 - } 89 - } 90 - } 161 + // Cover image mapping 162 + const coverField = mapping?.coverImage || "ogImage"; 163 + frontmatter.ogImage = raw[coverField] || raw.ogImage; 91 164 92 - // Cover image mapping 93 - const coverField = mapping?.coverImage || "ogImage"; 94 - frontmatter.ogImage = raw[coverField] || raw.ogImage; 165 + // Tags mapping 166 + const tagsField = mapping?.tags || "tags"; 167 + frontmatter.tags = raw[tagsField] || raw.tags; 95 168 96 - // Tags mapping 97 - const tagsField = mapping?.tags || "tags"; 98 - 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 + } 99 175 100 - // Always preserve atUri (internal field) 101 - frontmatter.atUri = raw.atUri; 176 + // Always preserve atUri (internal field) 177 + frontmatter.atUri = raw.atUri; 102 178 103 - return { frontmatter: frontmatter as unknown as PostFrontmatter, body }; 179 + return { 180 + frontmatter: frontmatter as unknown as PostFrontmatter, 181 + body, 182 + rawFrontmatter: raw, 183 + }; 104 184 } 105 185 106 186 export function getSlugFromFilename(filename: string): string { 107 - return filename 108 - .replace(/\.mdx?$/, "") 109 - .toLowerCase() 110 - .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; 111 247 } 112 248 113 249 export async function getContentHash(content: string): Promise<string> { 114 - const encoder = new TextEncoder(); 115 - const data = encoder.encode(content); 116 - const hashBuffer = await crypto.subtle.digest("SHA-256", data); 117 - const hashArray = Array.from(new Uint8Array(hashBuffer)); 118 - return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 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(""); 119 255 } 120 256 121 257 function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean { 122 - for (const pattern of ignorePatterns) { 123 - const glob = new Glob(pattern); 124 - if (glob.match(relativePath)) { 125 - return true; 126 - } 127 - } 128 - return false; 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; 129 272 } 130 273 131 274 export async function scanContentDirectory( 132 - contentDir: string, 133 - frontmatterMapping?: FrontmatterMapping, 134 - ignorePatterns: string[] = [] 275 + contentDir: string, 276 + frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions, 277 + ignorePatterns: string[] = [], 135 278 ): Promise<BlogPost[]> { 136 - const patterns = ["**/*.md", "**/*.mdx"]; 137 - 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 + } 138 297 139 - for (const pattern of patterns) { 140 - const glob = new Glob(pattern); 298 + const { 299 + frontmatterMapping, 300 + ignorePatterns: ignore = [], 301 + slugField, 302 + removeIndexFromSlug, 303 + stripDatePrefix, 304 + } = options; 141 305 142 - for await (const relativePath of glob.scan({ 143 - cwd: contentDir, 144 - absolute: false, 145 - })) { 146 - // Skip files matching ignore patterns 147 - if (shouldIgnore(relativePath, ignorePatterns)) { 148 - continue; 149 - } 306 + const patterns = ["**/*.md", "**/*.mdx"]; 307 + const posts: BlogPost[] = []; 150 308 151 - const filePath = path.join(contentDir, relativePath); 152 - const file = Bun.file(filePath); 153 - const rawContent = await file.text(); 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"); 172 323 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 - }); 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 + }); 179 334 180 - return posts; 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 + } 348 + 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 + }); 355 + 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 === "+++"; 367 + 368 + // Format the atUri entry based on frontmatter type 369 + const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 188 370 189 - // Format the atUri entry based on frontmatter type 190 - const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`; 371 + // No frontmatter: create one with atUri 372 + if (!delimiterMatch) { 373 + return `---\n${atUriEntry}\n---\n\n${rawContent}`; 374 + } 191 375 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 - } 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 + } 197 384 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 - } 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 + } 203 390 204 - const beforeEnd = rawContent.slice(0, frontmatterEndIndex); 205 - const afterEnd = rawContent.slice(frontmatterEndIndex); 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 -29
packages/cli/tsconfig.json
··· 1 1 { 2 - "compilerOptions": { 3 - // Environment setup & latest features 4 - "lib": ["ESNext"], 5 - "target": "ESNext", 6 - "module": "Preserve", 7 - "moduleDetection": "force", 8 - "jsx": "react-jsx", 9 - "allowJs": true, 10 - 11 - // Bundler mode 12 - "moduleResolution": "bundler", 13 - "allowImportingTsExtensions": true, 14 - "verbatimModuleSyntax": true, 15 - "noEmit": true, 16 - 17 - // Best practices 18 - "strict": true, 19 - "skipLibCheck": true, 20 - "noFallthroughCasesInSwitch": true, 21 - "noUncheckedIndexedAccess": true, 22 - "noImplicitOverride": true, 23 - 24 - // Some stricter flags (disabled by default) 25 - "noUnusedLocals": false, 26 - "noUnusedParameters": false, 27 - "noPropertyAccessFromIndexSignature": false, 28 - "composite": true 29 - }, 30 - "include": ["src"] 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"] 31 22 }