A CLI for publishing standard.site documents to ATProto

Compare changes

Choose any two refs to compare.

+5218 -1198
+1
.gitignore
··· 35 36 # Bun lockfile - keep but binary cache 37 bun.lockb
··· 35 36 # Bun lockfile - keep but binary cache 37 bun.lockb 38 + packages/ui
+22
.tangled/workflows/lint.yml
···
··· 1 + # Biome lint and format checks 2 + 3 + when: 4 + - event: ["push", "manual"] 5 + branch: ["main"] 6 + - event: ["pull_request"] 7 + branch: ["main"] 8 + 9 + engine: "nixery" 10 + 11 + dependencies: 12 + nixpkgs: 13 + - bun 14 + - biome 15 + 16 + steps: 17 + - name: "Install dependencies" 18 + command: "bun install" 19 + - name: "Lint check" 20 + command: "cd packages/cli && biome lint ." 21 + - name: "Format check" 22 + command: "cd packages/cli && biome format ."
+3
.vscode/settings.json
···
··· 1 + { 2 + "typescript.tsdk": "node_modules/typescript/lib" 3 + }
+130
CHANGELOG.md
···
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### ๐Ÿ› Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### โš™๏ธ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 17 + ## [0.3.1] - 2026-02-04 18 + 19 + ### ๐Ÿ› Bug Fixes 20 + 21 + - Asset subdirectories 22 + 23 + ### โš™๏ธ Miscellaneous Tasks 24 + 25 + - Updated authentication ux 26 + - Release 0.3.1 27 + - Bumped version 28 + ## [0.3.0] - 2026-02-04 29 + 30 + ### ๐Ÿš€ Features 31 + 32 + - Initial oauth implementation 33 + - Add stripDatePrefix option for Jekyll-style filenames 34 + - Add `update` command 35 + 36 + ### โš™๏ธ Miscellaneous Tasks 37 + 38 + - Update changelog 39 + - Added workflows 40 + - Updated workflows 41 + - Updated workflows 42 + - Cleaned up types 43 + - Updated icon styles 44 + - Updated og image 45 + - Updated docs 46 + - Docs updates 47 + - Bumped version 48 + ## [0.2.1] - 2026-02-02 49 + 50 + ### โš™๏ธ Miscellaneous Tasks 51 + 52 + - Added CHANGELOG 53 + - Merge main into chore/fronmatter-config-updates 54 + - Added linting and formatting 55 + - Linting updates 56 + - Refactored to use fallback approach if frontmatter.slugField is provided or not 57 + - Version bump 58 + ## [0.2.0] - 2026-02-01 59 + 60 + ### ๐Ÿš€ Features 61 + 62 + - Added bskyPostRef 63 + - Added draft field to frontmatter config 64 + 65 + ### โš™๏ธ Miscellaneous Tasks 66 + 67 + - Resolved action items from issue #3 68 + - Adjusted tags to accept yaml multiline arrays for tags 69 + - Updated inject to handle new slug options 70 + - Updated comments 71 + - Update blog post 72 + - Fix blog build error 73 + - Adjust blog post 74 + - Updated docs 75 + - Version bump 76 + ## [0.1.1] - 2026-01-31 77 + 78 + ### ๐Ÿ› Bug Fixes 79 + 80 + - Fix tangled url to repo 81 + 82 + ### โš™๏ธ Miscellaneous Tasks 83 + 84 + - Merge branch 'main' into feat/blog-post 85 + - Updated blog post 86 + - Updated date 87 + - Added publishing 88 + - Spelling and grammar 89 + - Updated package scripts 90 + - Refactored codebase to use node and fs instead of bun 91 + - Version bump 92 + ## [0.1.0] - 2026-01-30 93 + 94 + ### ๐Ÿš€ Features 95 + 96 + - Init 97 + - Added blog post 98 + 99 + ### โš™๏ธ Miscellaneous Tasks 100 + 101 + - Updated package.json 102 + - Cleaned up commands and libs 103 + - Updated init commands 104 + - Updated greeting 105 + - Updated readme 106 + - Link updates 107 + - Version bump 108 + - Added hugo support through frontmatter parsing 109 + - Version bump 110 + - Updated docs 111 + - Adapted inject.ts pattern 112 + - Updated docs 113 + - Version bump" 114 + - Updated package scripts 115 + - Updated scripts 116 + - Added ignore field to config 117 + - Udpate docs 118 + - Version bump 119 + - Added tags to flow 120 + - Added ability to exit during init flow 121 + - Version bump 122 + - Updated docs 123 + - Updated links 124 + - Updated docs 125 + - Initial refactor 126 + - Checkpoint 127 + - Refactored mapping 128 + - Docs updates 129 + - Docs updates 130 + - Version bump
+85
CLAUDE.md
···
··· 1 + # CLAUDE.md 2 + 3 + This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 + 5 + ## Project Overview 6 + 7 + Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS. 8 + 9 + Website: <https://sequoia.pub> 10 + 11 + ## Monorepo Structure 12 + 13 + - **`packages/cli/`** โ€” Main CLI package (the core product) 14 + - **`docs/`** โ€” Documentation website (Vocs-based, deployed to Cloudflare Pages) 15 + 16 + Bun workspaces manage the monorepo. 17 + 18 + ## Commands 19 + 20 + ```bash 21 + # Build CLI 22 + bun run build:cli 23 + 24 + # Run CLI in dev (build + link) 25 + cd packages/cli && bun run dev 26 + 27 + # Run tests 28 + bun run test:cli 29 + 30 + # Run a single test file 31 + cd packages/cli && bun test src/lib/markdown.test.ts 32 + 33 + # Lint (auto-fix) 34 + cd packages/cli && bun run lint 35 + 36 + # Format (auto-fix) 37 + cd packages/cli && bun run format 38 + 39 + # Docs dev server 40 + bun run dev:docs 41 + ``` 42 + 43 + ## Architecture 44 + 45 + **Entry point:** `packages/cli/src/index.ts` โ€” Uses `cmd-ts` for type-safe subcommand routing. 46 + 47 + **Commands** (`src/commands/`): 48 + 49 + - `publish` โ€” Core workflow: scans markdown files, publishes to ATProto 50 + - `sync` โ€” Fetches published records state from ATProto 51 + - `update` โ€” Updates existing records 52 + - `auth` โ€” Multi-identity management (app-password + OAuth) 53 + - `init` โ€” Interactive config setup 54 + - `inject` โ€” Injects verification links into static HTML output 55 + - `login` โ€” Legacy auth (deprecated) 56 + 57 + **Libraries** (`src/lib/`): 58 + 59 + - `atproto.ts` โ€” ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client) 60 + - `config.ts` โ€” Loads `sequoia.json` config and `.sequoia-state.json` state files 61 + - `credentials.ts` โ€” Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions) 62 + - `markdown.ts` โ€” Frontmatter parsing (YAML/TOML), content hashing, atUri injection 63 + 64 + **Extensions** (`src/extensions/`): 65 + 66 + - `litenote.ts` โ€” Creates `space.litenote.note` records with embedded images 67 + 68 + ## Key Patterns 69 + 70 + - **Config resolution:** `sequoia.json` is found by searching up the directory tree 71 + - **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters 72 + - **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle) 73 + - **Build:** `bun build src/index.ts --target node --outdir dist` 74 + 75 + ## Tooling 76 + 77 + - **Runtime/bundler:** Bun 78 + - **Linter/formatter:** Biome (tabs, double quotes) 79 + - **Test runner:** Bun's native test runner 80 + - **CLI framework:** `cmd-ts` 81 + - **Interactive UI:** `@clack/prompts` 82 + 83 + ## Git Conventions 84 + 85 + Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
···
··· 1 + name: 'Sequoia Publish' 2 + description: 'Publish your markdown content to ATProtocol using Sequoia CLI' 3 + branding: 4 + icon: 'upload-cloud' 5 + color: 'green' 6 + 7 + inputs: 8 + identifier: 9 + description: 'ATProto handle or DID (e.g. yourname.bsky.social)' 10 + required: true 11 + app-password: 12 + description: 'ATProto app password' 13 + required: true 14 + pds-url: 15 + description: 'PDS URL (defaults to https://bsky.social)' 16 + required: false 17 + default: 'https://bsky.social' 18 + force: 19 + description: 'Force publish all posts, ignoring change detection' 20 + required: false 21 + default: 'false' 22 + commit-back: 23 + description: 'Commit updated frontmatter and state file back to the repo' 24 + required: false 25 + default: 'true' 26 + working-directory: 27 + description: 'Directory containing sequoia.json (defaults to repo root)' 28 + required: false 29 + default: '.' 30 + 31 + runs: 32 + using: 'composite' 33 + steps: 34 + - name: Setup Bun 35 + uses: oven-sh/setup-bun@v2 36 + 37 + - name: Build and install Sequoia CLI 38 + shell: bash 39 + run: | 40 + cd ${{ github.action_path }} 41 + bun install 42 + bun run build:cli 43 + bun link --cwd packages/cli 44 + 45 + - name: Sync state from ATProtocol 46 + shell: bash 47 + working-directory: ${{ inputs.working-directory }} 48 + env: 49 + ATP_IDENTIFIER: ${{ inputs.identifier }} 50 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 51 + PDS_URL: ${{ inputs.pds-url }} 52 + run: sequoia sync 53 + 54 + - name: Publish 55 + shell: bash 56 + working-directory: ${{ inputs.working-directory }} 57 + env: 58 + ATP_IDENTIFIER: ${{ inputs.identifier }} 59 + ATP_APP_PASSWORD: ${{ inputs.app-password }} 60 + PDS_URL: ${{ inputs.pds-url }} 61 + run: | 62 + FLAGS="" 63 + if [ "${{ inputs.force }}" = "true" ]; then 64 + FLAGS="--force" 65 + fi 66 + sequoia publish $FLAGS 67 + 68 + - name: Commit back changes 69 + if: inputs.commit-back == 'true' 70 + shell: bash 71 + working-directory: ${{ inputs.working-directory }} 72 + run: | 73 + git config user.name "$(git log -1 --format='%an')" 74 + git config user.email "$(git log -1 --format='%ae')" 75 + git add -A -- '**/*.md' || true 76 + if git diff --cached --quiet; then 77 + echo "No changes to commit" 78 + else 79 + git commit -m "chore: update sequoia state [skip ci]" 80 + git push 81 + fi
+123 -10
bun.lock
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 - "version": "0.0.6", 28 "bin": { 29 - "sequoia": "dist/sequoia", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 "@clack/prompts": "^1.0.0", 34 "cmd-ts": "^0.14.3", 35 }, 36 "devDependencies": { 37 - "@types/bun": "latest", 38 }, 39 "peerDependencies": { 40 "typescript": "^5", ··· 44 "packages": { 45 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 46 47 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 48 49 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 50 51 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 52 53 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 54 55 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 56 57 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 58 ··· 99 "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], 100 101 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 102 103 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 104 ··· 187 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 188 189 "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], 190 191 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 192 ··· 524 525 "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], 526 527 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 528 529 - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 530 531 "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 532 ··· 586 587 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 588 589 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 590 591 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 633 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 634 635 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 636 637 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 638 ··· 732 733 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 734 735 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 736 737 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 834 835 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], 836 837 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 838 839 "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], ··· 890 891 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 892 893 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 894 895 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 896 897 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 898 899 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 900 901 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 902 903 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 905 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 906 907 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 908 909 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 910 ··· 913 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 914 915 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 916 917 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 918 ··· 1094 1095 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1096 1097 - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1098 1099 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 1100 1101 "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], 1102 1103 "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], 1104 1105 "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], ··· 1129 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1130 1131 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1132 1133 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1134 ··· 1150 1151 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1152 1153 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1154 1155 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], ··· 1169 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1170 1171 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1172 1173 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1174 ··· 1244 1245 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1246 1247 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1248 1249 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1336 1337 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1338 1339 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1340 1341 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 1342 ··· 1405 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1406 1407 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1408 1409 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1410 ··· 1442 1443 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1444 1445 "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], 1446 1447 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1448 ··· 1456 1457 "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], 1458 1459 "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 1460 1461 "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], ··· 1473 "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 1474 1475 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 1476 1477 "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 1478 ··· 1480 1481 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1482 1483 - "sequoia-cli/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], 1484 - 1485 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1486 1487 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1488 1489 "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], 1490 1491 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1492 ··· 1498 1499 "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], 1500 1501 "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1502 1503 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1504 1505 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1506 - 1507 - "sequoia-cli/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], 1508 } 1509 }
··· 24 }, 25 "packages/cli": { 26 "name": "sequoia-cli", 27 + "version": "0.3.3", 28 "bin": { 29 + "sequoia": "dist/index.js", 30 }, 31 "dependencies": { 32 "@atproto/api": "^0.18.17", 33 + "@atproto/oauth-client-node": "^0.3.16", 34 "@clack/prompts": "^1.0.0", 35 "cmd-ts": "^0.14.3", 36 + "glob": "^13.0.0", 37 + "mime-types": "^2.1.35", 38 + "minimatch": "^10.1.1", 39 + "open": "^11.0.0", 40 }, 41 "devDependencies": { 42 + "@biomejs/biome": "^2.3.13", 43 + "@types/mime-types": "^3.0.1", 44 + "@types/node": "^20", 45 }, 46 "peerDependencies": { 47 "typescript": "^5", ··· 51 "packages": { 52 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], 53 54 + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.6", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg=="], 55 + 56 + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 57 + 58 + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 59 + 60 + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "zod": "^3.23.8" } }, "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA=="], 61 + 62 + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.25", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.6", "@atproto/did": "0.3.0" } }, "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw=="], 63 + 64 + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.6", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver": "0.3.6" } }, "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg=="], 65 + 66 + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 67 + 68 + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 69 + 70 + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 71 + 72 "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 73 74 "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 75 76 + "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 77 + 78 + "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 79 + 80 + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 81 + 82 + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 83 + 84 "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 85 86 "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 87 88 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 89 + 90 + "@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 91 + 92 + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.16", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/handle-resolver-node": "0.1.25", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.14", "@atproto/oauth-types": "0.6.2" } }, "sha512-2dooMzxAkiQ4MkOAZlEQ3iwbB9SEovrbIKMNuBbVCLQYORVNxe20tMdjs3lvhrzdpzvaHLlQnJJhw5dA9VELFw=="], 93 + 94 + "@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], 95 96 "@atproto/syntax": ["@atproto/syntax@0.4.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA=="], 97 ··· 138 "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], 139 140 "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], 141 + 142 + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], 143 + 144 + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], 145 + 146 + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], 147 + 148 + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], 149 + 150 + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], 151 + 152 + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], 153 + 154 + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], 155 + 156 + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], 157 + 158 + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], 159 160 "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], 161 ··· 244 "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], 245 246 "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], 247 + 248 + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], 249 + 250 + "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], 251 252 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 253 ··· 585 586 "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], 587 588 + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], 589 + 590 "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], 591 592 + "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], 593 594 "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 595 ··· 649 650 "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 651 652 + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], 653 + 654 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 655 656 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], ··· 698 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 699 700 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], 701 + 702 + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], 703 704 "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], 705 ··· 799 800 "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], 801 802 + "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], 803 + 804 + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], 805 + 806 + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], 807 + 808 "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], 809 810 "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], ··· 907 908 "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], 909 910 + "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], 911 + 912 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 913 914 "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], ··· 965 966 "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], 967 968 + "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 969 + 970 "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], 971 972 "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], 973 974 "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], 975 + 976 + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], 977 978 "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], 979 980 + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], 981 + 982 + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], 983 + 984 "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], 985 986 "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], ··· 988 "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 989 990 "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], 991 + 992 + "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], 993 994 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 995 ··· 998 "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], 999 1000 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 1001 + 1002 + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 1003 1004 "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], 1005 ··· 1181 1182 "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1183 1184 + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 1185 + 1186 + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 1187 1188 "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], 1189 1190 "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], 1191 1192 + "minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], 1193 + 1194 + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], 1195 + 1196 "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], 1197 1198 "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], ··· 1222 "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], 1223 1224 "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], 1225 + 1226 + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], 1227 1228 "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], 1229 ··· 1245 1246 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1247 1248 + "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], 1249 + 1250 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 1251 1252 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], ··· 1266 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1267 1268 "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], 1269 + 1270 + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], 1271 1272 "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], 1273 ··· 1343 1344 "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], 1345 1346 + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], 1347 + 1348 "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], 1349 1350 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], ··· 1437 1438 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1439 1440 + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 1441 + 1442 + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 1443 1444 "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 1445 ··· 1508 "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], 1509 1510 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1511 + 1512 + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], 1513 1514 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1515 ··· 1547 1548 "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1549 1550 + "bun-types/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1551 + 1552 "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], 1553 + 1554 + "compressible/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], 1555 1556 "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1557 ··· 1565 1566 "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], 1567 1568 + "eval/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], 1569 + 1570 "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 1571 1572 "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], ··· 1584 "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 1585 1586 "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], 1587 + 1588 + "path-scurry/lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], 1589 1590 "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], 1591 ··· 1593 1594 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1595 1596 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1597 1598 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1599 1600 "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], 1601 + 1602 + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1603 1604 "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1605 ··· 1611 1612 "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], 1613 1614 + "eval/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 1615 + 1616 "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1617 1618 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1619 1620 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1621 } 1622 }
+2
docs/.gitignore
··· 1 .wrangler
··· 1 .wrangler 2 + 3 + .sequoia-state.json
+7
docs/docs/pages/blog/index.mdx
···
··· 1 + --- 2 + layout: minimal 3 + --- 4 + 5 + # Blog 6 + 7 + ::blog-posts
+54
docs/docs/pages/blog/introducing-sequoia.mdx
···
··· 1 + --- 2 + layout: minimal 3 + title: "Introducing Sequoia: Publishing for the Open Web" 4 + date: 2026-01-30 5 + atUri: "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v" 6 + --- 7 + 8 + # Introducing Sequoia: Publishing for the Open Web 9 + 10 + ![hero](/hero.png) 11 + 12 + Today I'm excited to release a new tool for the [AT Protocol](https://atproto.com): Sequoia. This is a CLI tool that can take your existing self-hosted blog and publish it to the ATmosphere using [Standard.site](https://standard.site) lexicons. 13 + 14 + If you haven't explored ATProto you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/), but in short, it's a new way to publish content to the web that puts ownership and control back in the hands of users. Blogs in some ways have already been doing this, but they've been missing a key piece: distribution. One of the unique features of ATProto is [lexicons](), which are schemas that apps build to create folders of content on a user's personal data server. The domain verified nature lets them be indexed and aggregated with ease. Outside of apps, lexicons can be extended by community members to build a common standard. That's exactly how [Standard.site](https://standard.site) was brought about, pushing a new way for standardizing publications and documents on ATProto. 15 + 16 + The founders and platforms behind the standard, [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), all serve to make creating and sharing blogs easy. If you are not a technical person and don't have a blog already, I would highly recommend checking all of them out! However, for those of us who already have blogs, there was a need for a tool that could make it easy to publish existing and new content with this new standard. Thus Sequoia was born. 17 + 18 + Sequoia is a relatively simple CLI that can do the following: 19 + - Authenticate with your ATProto handle 20 + - Configure your blog through an interactive setup process 21 + - Create publication and document records on your PDS 22 + - Add necessary verification pieces to your site 23 + - Sync with existing records on your PDS 24 + 25 + It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action: 26 + 27 + <iframe 28 + class="w-full" 29 + style={{aspectRatio: "16/9"}} 30 + src="https://www.youtube.com/embed/sxursUHq5kw" 31 + title="YouTube video player" 32 + frameborder="0" 33 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 34 + referrerpolicy="strict-origin-when-cross-origin" 35 + allowfullscreen 36 + ></iframe> 37 + 38 + ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons. 39 + 40 + Install Sequoia today and check out the [quickstart guide](/quickstart) to publish your content into the ATmosphere ๐ŸŒณ 41 + 42 + :::code-group 43 + ```bash [npm] 44 + npm i -g sequoia-cli 45 + ``` 46 + 47 + ```bash [pnpm] 48 + pnpm i -g sequoia-cli 49 + ``` 50 + 51 + ```bash [bun] 52 + bun i -g sequoia-cli 53 + ``` 54 + :::
+34 -1
docs/docs/pages/cli-reference.mdx
··· 1 # CLI Reference 2 3 ## `auth` 4 5 ```bash [Terminal] 6 sequoia auth 7 - > Authenticate with your ATProto PDS 8 9 OPTIONS: 10 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 13 --list - List all stored identities [optional] 14 --help, -h - show help [optional] 15 ``` 16 17 ## `init` 18 ··· 61 --dry-run, -n - Preview what would be synced without making changes [optional] 62 --help, -h - show help [optional] 63 ```
··· 1 # CLI Reference 2 3 + ## `login` 4 + 5 + ```bash [Terminal] 6 + sequoia login 7 + > Login with OAuth (browser-based authentication) 8 + 9 + OPTIONS: 10 + --logout <str> - Remove OAuth session for a specific DID [optional] 11 + 12 + FLAGS: 13 + --list - List all stored OAuth sessions [optional] 14 + --help, -h - show help [optional] 15 + ``` 16 + 17 + OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically. 18 + 19 ## `auth` 20 21 ```bash [Terminal] 22 sequoia auth 23 + > Authenticate with your ATProto PDS using an app password 24 25 OPTIONS: 26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional] ··· 29 --list - List all stored identities [optional] 30 --help, -h - show help [optional] 31 ``` 32 + 33 + Use this as an alternative to `login` when OAuth isn't available or for CI environments. 34 35 ## `init` 36 ··· 79 --dry-run, -n - Preview what would be synced without making changes [optional] 80 --help, -h - show help [optional] 81 ``` 82 + 83 + ## `update` 84 + 85 + ```bash [Terminal] 86 + sequoia update 87 + > Update local config or ATProto publication record 88 + 89 + FLAGS: 90 + --help, -h - show help [optional] 91 + ``` 92 + 93 + Interactive command to modify your existing configuration. Choose between: 94 + 95 + - **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings 96 + - **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+41 -2
docs/docs/pages/config.mdx
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 18 19 ### Example 20 ··· 31 "frontmatter": { 32 "publishDate": "date" 33 }, 34 - "ignore": ["_index.md"] 35 } 36 ``` 37 ··· 44 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 45 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 46 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 47 48 ### Example 49 ··· 54 publishDate: 2024-01-15 55 ogImage: cover.jpg 56 tags: [welcome, intro] 57 --- 58 ``` 59 ··· 65 { 66 "frontmatter": { 67 "publishDate": "date", 68 - "coverImage": "thumbnail" 69 } 70 } 71 ``` 72 73 ### Ignoring Files 74
··· 14 | `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically | 15 | `identity` | `string` | No | - | Which stored identity to use | 16 | `frontmatter` | `object` | No | - | Custom frontmatter field mappings | 17 + | `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) | 18 | `ignore` | `string[]` | No | - | Glob patterns for files to ignore | 19 + | `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs | 20 + | `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) | 21 + | `bluesky` | `object` | No | - | Bluesky posting configuration | 22 + | `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents | 23 + | `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days | 24 25 ### Example 26 ··· 37 "frontmatter": { 38 "publishDate": "date" 39 }, 40 + "ignore": ["_index.md"], 41 + "bluesky": { 42 + "enabled": true, 43 + "maxAgeDays": 30 44 + } 45 } 46 ``` 47 ··· 54 | `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date | 55 | `coverImage` | `string` | No | `"ogImage"` | Cover image filename | 56 | `tags` | `string[]` | No | `"tags"` | Post tags/categories | 57 + | `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish | 58 59 ### Example 60 ··· 65 publishDate: 2024-01-15 66 ogImage: cover.jpg 67 tags: [welcome, intro] 68 + draft: false 69 --- 70 ``` 71 ··· 77 { 78 "frontmatter": { 79 "publishDate": "date", 80 + "coverImage": "thumbnail", 81 + "draft": "private" 82 + } 83 + } 84 + ``` 85 + 86 + ### Slug Configuration 87 + 88 + By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead: 89 + 90 + ```json 91 + { 92 + "frontmatter": { 93 + "slugField": "url" 94 } 95 } 96 ``` 97 + 98 + If the frontmatter field is not found, it falls back to the filepath. 99 + 100 + ### Jekyll-Style Date Prefixes 101 + 102 + Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs: 103 + 104 + ```json 105 + { 106 + "stripDatePrefix": true 107 + } 108 + ``` 109 + 110 + This transforms `2024-01-15-my-post.md` into the slug `my-post`. 111 112 ### Ignoring Files 113
+40 -2
docs/docs/pages/publishing.mdx
··· 10 sequoia publish --dry-run 11 ``` 12 13 - This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it! 14 15 ```bash [Terminal] 16 sequoia publish ··· 23 If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command. 24 25 ```bash [Terminal] 26 - seuqoia sync 27 ``` 28 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 31 ## Troubleshooting 32
··· 10 sequoia publish --dry-run 11 ``` 12 13 + This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it! 14 15 ```bash [Terminal] 16 sequoia publish ··· 23 If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command. 24 25 ```bash [Terminal] 26 + sequoia sync 27 ``` 28 29 Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config. 30 + 31 + ## Bluesky Posting 32 + 33 + Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config: 34 + 35 + ```json 36 + { 37 + "bluesky": { 38 + "enabled": true, 39 + "maxAgeDays": 30 40 + } 41 + } 42 + ``` 43 + 44 + When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters. 45 + 46 + The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky. 47 + 48 + ## Draft Posts 49 + 50 + Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it. 51 + 52 + ```yaml 53 + --- 54 + title: Work in Progress 55 + draft: true 56 + --- 57 + ``` 58 + 59 + If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`: 60 + 61 + ```json 62 + { 63 + "frontmatter": { 64 + "draft": "private" 65 + } 66 + } 67 + ``` 68 69 ## Troubleshooting 70
+10 -8
docs/docs/pages/quickstart.mdx
··· 31 sequoia 32 ``` 33 34 - ### Authorize 35 - 36 - In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password. 37 38 - :::tip 39 - You can create an app password [here](https://bsky.app/settings/app-passwords) 40 - ::: 41 42 ```bash [Terminal] 43 - sequoia auth 44 ``` 45 46 ### Initialize 47 ··· 59 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 60 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 61 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 62 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 63 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 64 - **Publication name** - The name of your blog 65 - **Publication description** - A description for your blog
··· 31 sequoia 32 ``` 33 34 + ### Login 35 36 + In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account. 37 38 ```bash [Terminal] 39 + sequoia login 40 ``` 41 + 42 + This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI. 43 + 44 + :::tip 45 + Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth. 46 + ::: 47 48 ### Initialize 49 ··· 61 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 62 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 63 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 64 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 65 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 66 - **Publication name** - The name of your blog 67 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/setup.mdx
··· 28 29 ## Authorize 30 31 - In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password. 32 33 :::tip 34 You can create an app password [here](https://bsky.app/settings/app-passwords) ··· 56 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 57 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 58 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 59 - - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 60 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 61 - **Publication name** - The name of your blog 62 - **Publication description** - A description for your blog
··· 28 29 ## Authorize 30 31 + In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password. 32 33 :::tip 34 You can create an app password [here](https://bsky.app/settings/app-passwords) ··· 56 - **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying). 57 - **Build output directory** - Where you published html css and js lives, e.g. `./dist` 58 - **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`. 59 + - **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents. 60 - **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`. 61 - **Publication name** - The name of your blog 62 - **Publication description** - A description for your blog
+2 -2
docs/docs/pages/verifying.mdx
··· 3 In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. 4 5 :::tip 6 - You an learn more about Standard.site verification [here](https://standard.site/) 7 ::: 8 9 ## Publication Verification ··· 22 23 ### pds.ls 24 25 - Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as perscribed on their website. 26 27 ### Standard.site Validator 28
··· 3 In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. 4 5 :::tip 6 + You can learn more about Standard.site verification [here](https://standard.site/) 7 ::: 8 9 ## Publication Verification ··· 22 23 ### pds.ls 24 25 + Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as prescribed on their website. 26 27 ### Standard.site Validator 28
+2 -2
docs/docs/pages/what-is-sequoia.mdx
··· 3 Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down. 4 5 - [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/). 6 - - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. The unique property to lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 - - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specailly designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 8 9 The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too. 10
··· 3 Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down. 4 5 - [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/). 6 + - [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. A unique property of lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more! 7 + - [Standard.site](https://standard.site) - Standard.site is a set of lexicons specially designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard. 8 9 The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too. 10
docs/docs/public/.well-known/.gitkeep

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

docs/docs/public/icon-dark.png

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

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