A CLI for publishing standard.site documents to ATProto

Compare changes

Choose any two refs to compare.

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

This is a binary file and will not be displayed.

docs/docs/public/og.png

This is a binary file and will not be displayed.

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