···11+## [0.3.3.] - 2026-02-04
22+33+### โ๏ธ Miscellaneous Tasks
44+55+- Cleaned up remaining auth implementations
66+- Format
77+88+## [0.3.2] - 2026-02-05
99+1010+### ๐ Bug Fixes
1111+1212+- Fixed issue with auth selection in init command
1313+1414+### โ๏ธ Miscellaneous Tasks
1515+1616+- Release 0.3.2
1717+## [0.3.1] - 2026-02-04
1818+1919+### ๐ Bug Fixes
2020+2121+- Asset subdirectories
2222+2323+### โ๏ธ Miscellaneous Tasks
2424+2525+- Updated authentication ux
2626+- Release 0.3.1
2727+- Bumped version
2828+## [0.3.0] - 2026-02-04
2929+3030+### ๐ Features
3131+3232+- Initial oauth implementation
3333+- Add stripDatePrefix option for Jekyll-style filenames
3434+- Add `update` command
3535+3636+### โ๏ธ Miscellaneous Tasks
3737+3838+- Update changelog
3939+- Added workflows
4040+- Updated workflows
4141+- Updated workflows
4242+- Cleaned up types
4343+- Updated icon styles
4444+- Updated og image
4545+- Updated docs
4646+- Docs updates
4747+- Bumped version
4848+## [0.2.1] - 2026-02-02
4949+5050+### โ๏ธ Miscellaneous Tasks
5151+5252+- Added CHANGELOG
5353+- Merge main into chore/fronmatter-config-updates
5454+- Added linting and formatting
5555+- Linting updates
5656+- Refactored to use fallback approach if frontmatter.slugField is provided or not
5757+- Version bump
5858+## [0.2.0] - 2026-02-01
5959+6060+### ๐ Features
6161+6262+- Added bskyPostRef
6363+- Added draft field to frontmatter config
6464+6565+### โ๏ธ Miscellaneous Tasks
6666+6767+- Resolved action items from issue #3
6868+- Adjusted tags to accept yaml multiline arrays for tags
6969+- Updated inject to handle new slug options
7070+- Updated comments
7171+- Update blog post
7272+- Fix blog build error
7373+- Adjust blog post
7474+- Updated docs
7575+- Version bump
7676+## [0.1.1] - 2026-01-31
7777+7878+### ๐ Bug Fixes
7979+8080+- Fix tangled url to repo
8181+8282+### โ๏ธ Miscellaneous Tasks
8383+8484+- Merge branch 'main' into feat/blog-post
8585+- Updated blog post
8686+- Updated date
8787+- Added publishing
8888+- Spelling and grammar
8989+- Updated package scripts
9090+- Refactored codebase to use node and fs instead of bun
9191+- Version bump
9292+## [0.1.0] - 2026-01-30
9393+9494+### ๐ Features
9595+9696+- Init
9797+- Added blog post
9898+9999+### โ๏ธ Miscellaneous Tasks
100100+101101+- Updated package.json
102102+- Cleaned up commands and libs
103103+- Updated init commands
104104+- Updated greeting
105105+- Updated readme
106106+- Link updates
107107+- Version bump
108108+- Added hugo support through frontmatter parsing
109109+- Version bump
110110+- Updated docs
111111+- Adapted inject.ts pattern
112112+- Updated docs
113113+- Version bump"
114114+- Updated package scripts
115115+- Updated scripts
116116+- Added ignore field to config
117117+- Udpate docs
118118+- Version bump
119119+- Added tags to flow
120120+- Added ability to exit during init flow
121121+- Version bump
122122+- Updated docs
123123+- Updated links
124124+- Updated docs
125125+- Initial refactor
126126+- Checkpoint
127127+- Refactored mapping
128128+- Docs updates
129129+- Docs updates
130130+- Version bump
+85
CLAUDE.md
···11+# CLAUDE.md
22+33+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44+55+## Project Overview
66+77+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.
88+99+Website: <https://sequoia.pub>
1010+1111+## Monorepo Structure
1212+1313+- **`packages/cli/`** โ Main CLI package (the core product)
1414+- **`docs/`** โ Documentation website (Vocs-based, deployed to Cloudflare Pages)
1515+1616+Bun workspaces manage the monorepo.
1717+1818+## Commands
1919+2020+```bash
2121+# Build CLI
2222+bun run build:cli
2323+2424+# Run CLI in dev (build + link)
2525+cd packages/cli && bun run dev
2626+2727+# Run tests
2828+bun run test:cli
2929+3030+# Run a single test file
3131+cd packages/cli && bun test src/lib/markdown.test.ts
3232+3333+# Lint (auto-fix)
3434+cd packages/cli && bun run lint
3535+3636+# Format (auto-fix)
3737+cd packages/cli && bun run format
3838+3939+# Docs dev server
4040+bun run dev:docs
4141+```
4242+4343+## Architecture
4444+4545+**Entry point:** `packages/cli/src/index.ts` โ Uses `cmd-ts` for type-safe subcommand routing.
4646+4747+**Commands** (`src/commands/`):
4848+4949+- `publish` โ Core workflow: scans markdown files, publishes to ATProto
5050+- `sync` โ Fetches published records state from ATProto
5151+- `update` โ Updates existing records
5252+- `auth` โ Multi-identity management (app-password + OAuth)
5353+- `init` โ Interactive config setup
5454+- `inject` โ Injects verification links into static HTML output
5555+- `login` โ Legacy auth (deprecated)
5656+5757+**Libraries** (`src/lib/`):
5858+5959+- `atproto.ts` โ ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client)
6060+- `config.ts` โ Loads `sequoia.json` config and `.sequoia-state.json` state files
6161+- `credentials.ts` โ Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions)
6262+- `markdown.ts` โ Frontmatter parsing (YAML/TOML), content hashing, atUri injection
6363+6464+**Extensions** (`src/extensions/`):
6565+6666+- `litenote.ts` โ Creates `space.litenote.note` records with embedded images
6767+6868+## Key Patterns
6969+7070+- **Config resolution:** `sequoia.json` is found by searching up the directory tree
7171+- **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters
7272+- **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle)
7373+- **Build:** `bun build src/index.ts --target node --outdir dist`
7474+7575+## Tooling
7676+7777+- **Runtime/bundler:** Bun
7878+- **Linter/formatter:** Biome (tabs, double quotes)
7979+- **Test runner:** Bun's native test runner
8080+- **CLI framework:** `cmd-ts`
8181+- **Interactive UI:** `@clack/prompts`
8282+8383+## Git Conventions
8484+8585+Never add 'Co-authored-by' lines to git commits unless explicitly asked.
+81
action.yml
···11+name: 'Sequoia Publish'
22+description: 'Publish your markdown content to ATProtocol using Sequoia CLI'
33+branding:
44+ icon: 'upload-cloud'
55+ color: 'green'
66+77+inputs:
88+ identifier:
99+ description: 'ATProto handle or DID (e.g. yourname.bsky.social)'
1010+ required: true
1111+ app-password:
1212+ description: 'ATProto app password'
1313+ required: true
1414+ pds-url:
1515+ description: 'PDS URL (defaults to https://bsky.social)'
1616+ required: false
1717+ default: 'https://bsky.social'
1818+ force:
1919+ description: 'Force publish all posts, ignoring change detection'
2020+ required: false
2121+ default: 'false'
2222+ commit-back:
2323+ description: 'Commit updated frontmatter and state file back to the repo'
2424+ required: false
2525+ default: 'true'
2626+ working-directory:
2727+ description: 'Directory containing sequoia.json (defaults to repo root)'
2828+ required: false
2929+ default: '.'
3030+3131+runs:
3232+ using: 'composite'
3333+ steps:
3434+ - name: Setup Bun
3535+ uses: oven-sh/setup-bun@v2
3636+3737+ - name: Build and install Sequoia CLI
3838+ shell: bash
3939+ run: |
4040+ cd ${{ github.action_path }}
4141+ bun install
4242+ bun run build:cli
4343+ bun link --cwd packages/cli
4444+4545+ - name: Sync state from ATProtocol
4646+ shell: bash
4747+ working-directory: ${{ inputs.working-directory }}
4848+ env:
4949+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5050+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
5151+ PDS_URL: ${{ inputs.pds-url }}
5252+ run: sequoia sync
5353+5454+ - name: Publish
5555+ shell: bash
5656+ working-directory: ${{ inputs.working-directory }}
5757+ env:
5858+ ATP_IDENTIFIER: ${{ inputs.identifier }}
5959+ ATP_APP_PASSWORD: ${{ inputs.app-password }}
6060+ PDS_URL: ${{ inputs.pds-url }}
6161+ run: |
6262+ FLAGS=""
6363+ if [ "${{ inputs.force }}" = "true" ]; then
6464+ FLAGS="--force"
6565+ fi
6666+ sequoia publish $FLAGS
6767+6868+ - name: Commit back changes
6969+ if: inputs.commit-back == 'true'
7070+ shell: bash
7171+ working-directory: ${{ inputs.working-directory }}
7272+ run: |
7373+ git config user.name "$(git log -1 --format='%an')"
7474+ git config user.email "$(git log -1 --format='%ae')"
7575+ git add -A -- '**/*.md' || true
7676+ if git diff --cached --quiet; then
7777+ echo "No changes to commit"
7878+ else
7979+ git commit -m "chore: update sequoia state [skip ci]"
8080+ git push
8181+ fi
···11# CLI Reference
2233+## `login`
44+55+```bash [Terminal]
66+sequoia login
77+> Login with OAuth (browser-based authentication)
88+99+OPTIONS:
1010+ --logout <str> - Remove OAuth session for a specific DID [optional]
1111+1212+FLAGS:
1313+ --list - List all stored OAuth sessions [optional]
1414+ --help, -h - show help [optional]
1515+```
1616+1717+OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically.
1818+319## `auth`
420521```bash [Terminal]
622sequoia auth
77-> Authenticate with your ATProto PDS
2323+> Authenticate with your ATProto PDS using an app password
824925OPTIONS:
1026 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···1329 --list - List all stored identities [optional]
1430 --help, -h - show help [optional]
1531```
3232+3333+Use this as an alternative to `login` when OAuth isn't available or for CI environments.
16341735## `init`
1836···6179 --dry-run, -n - Preview what would be synced without making changes [optional]
6280 --help, -h - show help [optional]
6381```
8282+8383+## `update`
8484+8585+```bash [Terminal]
8686+sequoia update
8787+> Update local config or ATProto publication record
8888+8989+FLAGS:
9090+ --help, -h - show help [optional]
9191+```
9292+9393+Interactive command to modify your existing configuration. Choose between:
9494+9595+- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
9696+- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+29
docs/docs/pages/config.mdx
···1414| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
1515| `identity` | `string` | No | - | Which stored identity to use |
1616| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
1717+| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
1718| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
1919+| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
2020+| `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) |
1821| `bluesky` | `object` | No | - | Bluesky posting configuration |
1922| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
2023| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
···7982 }
8083}
8184```
8585+8686+### Slug Configuration
8787+8888+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
8989+9090+```json
9191+{
9292+ "frontmatter": {
9393+ "slugField": "url"
9494+ }
9595+}
9696+```
9797+9898+If the frontmatter field is not found, it falls back to the filepath.
9999+100100+### Jekyll-Style Date Prefixes
101101+102102+Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs:
103103+104104+```json
105105+{
106106+ "stripDatePrefix": true
107107+}
108108+```
109109+110110+This transforms `2024-01-15-my-post.md` into the slug `my-post`.
8211183112### Ignoring Files
84113
+9-7
docs/docs/pages/quickstart.mdx
···3131sequoia
3232```
33333434-### Authorize
3535-3636-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.
3434+### Login
37353838-:::tip
3939-You can create an app password [here](https://bsky.app/settings/app-passwords)
4040-:::
3636+In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account.
41374238```bash [Terminal]
4343-sequoia auth
3939+sequoia login
4440```
4141+4242+This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI.
4343+4444+:::tip
4545+Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth.
4646+:::
45474648### Initialize
4749
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
···1111 "build:docs": "cd docs && bun run build",
1212 "build:cli": "cd packages/cli && bun run build",
1313 "deploy:docs": "cd docs && bun run deploy",
1414- "deploy:cli": "cd packages/cli && bun run deploy"
1414+ "deploy:cli": "cd packages/cli && bun run deploy",
1515+ "test:cli": "cd packages/cli && bun test"
1516 },
1617 "devDependencies": {
1718 "@types/bun": "latest",
···11-import * as fs from "fs/promises";
22-import * as path from "path";
11+import { webcrypto as crypto } from "node:crypto";
22+import * as fs from "node:fs/promises";
33+import * as path from "node:path";
34import { glob } from "glob";
45import { minimatch } from "minimatch";
55-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
66+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
6777-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
88- frontmatter: PostFrontmatter;
99- body: string;
88+export function parseFrontmatter(
99+ content: string,
1010+ mapping?: FrontmatterMapping,
1111+): {
1212+ frontmatter: PostFrontmatter;
1313+ body: string;
1414+ rawFrontmatter: Record<string, unknown>;
1015} {
1111- // Support multiple frontmatter delimiters:
1212- // --- (YAML) - Jekyll, Astro, most SSGs
1313- // +++ (TOML) - Hugo
1414- // *** - Alternative format
1515- const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
1616- const match = content.match(frontmatterRegex);
1616+ // Support multiple frontmatter delimiters:
1717+ // --- (YAML) - Jekyll, Astro, most SSGs
1818+ // +++ (TOML) - Hugo
1919+ // *** - Alternative format
2020+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
2121+ const match = content.match(frontmatterRegex);
17221818- if (!match) {
1919- throw new Error("Could not parse frontmatter");
2020- }
2323+ if (!match) {
2424+ const [, titleMatch] = content.trim().match(/^# (.+)$/m) || []
2525+ const title = titleMatch ?? ""
2626+ const [publishDate] = new Date().toISOString().split("T")
21272222- const delimiter = match[1];
2323- const frontmatterStr = match[2] ?? "";
2424- const body = match[3] ?? "";
2828+ return {
2929+ frontmatter: {
3030+ title,
3131+ publishDate: publishDate ?? ""
3232+ },
3333+ body: content,
3434+ rawFrontmatter: {
3535+ title:
3636+ publishDate
3737+ }
3838+ }
3939+ }
25402626- // Determine format based on delimiter:
2727- // +++ uses TOML (key = value)
2828- // --- and *** use YAML (key: value)
2929- const isToml = delimiter === "+++";
3030- const separator = isToml ? "=" : ":";
4141+ const delimiter = match[1];
4242+ const frontmatterStr = match[2] ?? "";
4343+ const body = match[3] ?? "";
31443232- // Parse frontmatter manually
3333- const raw: Record<string, unknown> = {};
3434- const lines = frontmatterStr.split("\n");
4545+ // Determine format based on delimiter:
4646+ // +++ uses TOML (key = value)
4747+ // --- and *** use YAML (key: value)
4848+ const isToml = delimiter === "+++";
4949+ const separator = isToml ? "=" : ":";
35503636- for (const line of lines) {
3737- const sepIndex = line.indexOf(separator);
3838- if (sepIndex === -1) continue;
5151+ // Parse frontmatter manually
5252+ const raw: Record<string, unknown> = {};
5353+ const lines = frontmatterStr.split("\n");
39544040- const key = line.slice(0, sepIndex).trim();
4141- let value = line.slice(sepIndex + 1).trim();
5555+ let i = 0;
5656+ while (i < lines.length) {
5757+ const line = lines[i];
5858+ if (line === undefined) {
5959+ i++;
6060+ continue;
6161+ }
6262+ const sepIndex = line.indexOf(separator);
6363+ if (sepIndex === -1) {
6464+ i++;
6565+ continue;
6666+ }
42674343- // Handle quoted strings
4444- if (
4545- (value.startsWith('"') && value.endsWith('"')) ||
4646- (value.startsWith("'") && value.endsWith("'"))
4747- ) {
4848- value = value.slice(1, -1);
4949- }
6868+ const key = line.slice(0, sepIndex).trim();
6969+ let value = line.slice(sepIndex + 1).trim();
50705151- // Handle arrays (simple case for tags)
5252- if (value.startsWith("[") && value.endsWith("]")) {
5353- const arrayContent = value.slice(1, -1);
5454- raw[key] = arrayContent
5555- .split(",")
5656- .map((item) => item.trim().replace(/^["']|["']$/g, ""));
5757- } else if (value === "true") {
5858- raw[key] = true;
5959- } else if (value === "false") {
6060- raw[key] = false;
6161- } else {
6262- raw[key] = value;
6363- }
6464- }
7171+ // Handle quoted strings
7272+ if (
7373+ (value.startsWith('"') && value.endsWith('"')) ||
7474+ (value.startsWith("'") && value.endsWith("'"))
7575+ ) {
7676+ value = value.slice(1, -1);
7777+ }
7878+7979+ // Handle inline arrays (simple case for tags)
8080+ if (value.startsWith("[") && value.endsWith("]")) {
8181+ const arrayContent = value.slice(1, -1);
8282+ raw[key] = arrayContent
8383+ .split(",")
8484+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
8585+ } else if (value === "" && !isToml) {
8686+ // Check for YAML-style multiline array (key with no value followed by - items)
8787+ const arrayItems: string[] = [];
8888+ let j = i + 1;
8989+ while (j < lines.length) {
9090+ const nextLine = lines[j];
9191+ if (nextLine === undefined) {
9292+ j++;
9393+ continue;
9494+ }
9595+ // Check if line is a list item (starts with whitespace and -)
9696+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
9797+ if (listMatch && listMatch[1] !== undefined) {
9898+ let itemValue = listMatch[1].trim();
9999+ // Remove quotes if present
100100+ if (
101101+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
102102+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
103103+ ) {
104104+ itemValue = itemValue.slice(1, -1);
105105+ }
106106+ arrayItems.push(itemValue);
107107+ j++;
108108+ } else if (nextLine.trim() === "") {
109109+ // Skip empty lines within the array
110110+ j++;
111111+ } else {
112112+ // Hit a new key or non-list content
113113+ break;
114114+ }
115115+ }
116116+ if (arrayItems.length > 0) {
117117+ raw[key] = arrayItems;
118118+ i = j;
119119+ continue;
120120+ } else {
121121+ raw[key] = value;
122122+ }
123123+ } else if (value === "true") {
124124+ raw[key] = true;
125125+ } else if (value === "false") {
126126+ raw[key] = false;
127127+ } else {
128128+ raw[key] = value;
129129+ }
130130+ i++;
131131+ }
651326666- // Apply field mappings to normalize to standard PostFrontmatter fields
6767- const frontmatter: Record<string, unknown> = {};
133133+ // Apply field mappings to normalize to standard PostFrontmatter fields
134134+ const frontmatter: Record<string, unknown> = {};
681356969- // Title mapping
7070- const titleField = mapping?.title || "title";
7171- frontmatter.title = raw[titleField] || raw.title;
136136+ // Title mapping
137137+ const titleField = mapping?.title || "title";
138138+ frontmatter.title = raw[titleField] || raw.title;
721397373- // Description mapping
7474- const descField = mapping?.description || "description";
7575- frontmatter.description = raw[descField] || raw.description;
140140+ // Description mapping
141141+ const descField = mapping?.description || "description";
142142+ frontmatter.description = raw[descField] || raw.description;
761437777- // Publish date mapping - check custom field first, then fallbacks
7878- const dateField = mapping?.publishDate;
7979- if (dateField && raw[dateField]) {
8080- frontmatter.publishDate = raw[dateField];
8181- } else if (raw.publishDate) {
8282- frontmatter.publishDate = raw.publishDate;
8383- } else {
8484- // Fallback to common date field names
8585- const dateFields = ["pubDate", "date", "createdAt", "created_at"];
8686- for (const field of dateFields) {
8787- if (raw[field]) {
8888- frontmatter.publishDate = raw[field];
8989- break;
9090- }
9191- }
9292- }
144144+ // Publish date mapping - check custom field first, then fallbacks
145145+ const dateField = mapping?.publishDate;
146146+ if (dateField && raw[dateField]) {
147147+ frontmatter.publishDate = raw[dateField];
148148+ } else if (raw.publishDate) {
149149+ frontmatter.publishDate = raw.publishDate;
150150+ } else {
151151+ // Fallback to common date field names
152152+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
153153+ for (const field of dateFields) {
154154+ if (raw[field]) {
155155+ frontmatter.publishDate = raw[field];
156156+ break;
157157+ }
158158+ }
159159+ }
931609494- // Cover image mapping
9595- const coverField = mapping?.coverImage || "ogImage";
9696- frontmatter.ogImage = raw[coverField] || raw.ogImage;
161161+ // Cover image mapping
162162+ const coverField = mapping?.coverImage || "ogImage";
163163+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
971649898- // Tags mapping
9999- const tagsField = mapping?.tags || "tags";
100100- frontmatter.tags = raw[tagsField] || raw.tags;
165165+ // Tags mapping
166166+ const tagsField = mapping?.tags || "tags";
167167+ frontmatter.tags = raw[tagsField] || raw.tags;
101168102102- // Draft mapping
103103- const draftField = mapping?.draft || "draft";
104104- const draftValue = raw[draftField] ?? raw.draft;
105105- if (draftValue !== undefined) {
106106- frontmatter.draft = draftValue === true || draftValue === "true";
107107- }
169169+ // Draft mapping
170170+ const draftField = mapping?.draft || "draft";
171171+ const draftValue = raw[draftField] ?? raw.draft;
172172+ if (draftValue !== undefined) {
173173+ frontmatter.draft = draftValue === true || draftValue === "true";
174174+ }
108175109109- // Always preserve atUri (internal field)
110110- frontmatter.atUri = raw.atUri;
176176+ // Always preserve atUri (internal field)
177177+ frontmatter.atUri = raw.atUri;
111178112112- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
179179+ return {
180180+ frontmatter: frontmatter as unknown as PostFrontmatter,
181181+ body,
182182+ rawFrontmatter: raw,
183183+ };
113184}
114185115186export function getSlugFromFilename(filename: string): string {
116116- return filename
117117- .replace(/\.mdx?$/, "")
118118- .toLowerCase()
119119- .replace(/\s+/g, "-");
187187+ return filename
188188+ .replace(/\.mdx?$/, "")
189189+ .toLowerCase()
190190+ .replace(/\s+/g, "-");
191191+}
192192+193193+export interface SlugOptions {
194194+ slugField?: string;
195195+ removeIndexFromSlug?: boolean;
196196+ stripDatePrefix?: boolean;
197197+}
198198+199199+export function getSlugFromOptions(
200200+ relativePath: string,
201201+ rawFrontmatter: Record<string, unknown>,
202202+ options: SlugOptions = {},
203203+): string {
204204+ const {
205205+ slugField,
206206+ removeIndexFromSlug = false,
207207+ stripDatePrefix = false,
208208+ } = options;
209209+210210+ let slug: string;
211211+212212+ // If slugField is set, try to get the value from frontmatter
213213+ if (slugField) {
214214+ const frontmatterValue = rawFrontmatter[slugField];
215215+ if (frontmatterValue && typeof frontmatterValue === "string") {
216216+ // Remove leading slash if present
217217+ slug = frontmatterValue
218218+ .replace(/^\//, "")
219219+ .toLowerCase()
220220+ .replace(/\s+/g, "-");
221221+ } else {
222222+ // Fallback to filepath if frontmatter field not found
223223+ slug = relativePath
224224+ .replace(/\.mdx?$/, "")
225225+ .toLowerCase()
226226+ .replace(/\s+/g, "-");
227227+ }
228228+ } else {
229229+ // Default: use filepath
230230+ slug = relativePath
231231+ .replace(/\.mdx?$/, "")
232232+ .toLowerCase()
233233+ .replace(/\s+/g, "-");
234234+ }
235235+236236+ // Remove /index or /_index suffix if configured
237237+ if (removeIndexFromSlug) {
238238+ slug = slug.replace(/\/_?index$/, "");
239239+ }
240240+241241+ // Strip Jekyll-style date prefix (YYYY-MM-DD-) from filename
242242+ if (stripDatePrefix) {
243243+ slug = slug.replace(/(^|\/)(\d{4}-\d{2}-\d{2})-/g, "$1");
244244+ }
245245+246246+ return slug;
120247}
121248122249export async function getContentHash(content: string): Promise<string> {
123123- const encoder = new TextEncoder();
124124- const data = encoder.encode(content);
125125- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
126126- const hashArray = Array.from(new Uint8Array(hashBuffer));
127127- return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
250250+ const encoder = new TextEncoder();
251251+ const data = encoder.encode(content);
252252+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
253253+ const hashArray = Array.from(new Uint8Array(hashBuffer));
254254+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
128255}
129256130257function shouldIgnore(relativePath: string, ignorePatterns: string[]): boolean {
131131- for (const pattern of ignorePatterns) {
132132- if (minimatch(relativePath, pattern)) {
133133- return true;
134134- }
135135- }
136136- return false;
258258+ for (const pattern of ignorePatterns) {
259259+ if (minimatch(relativePath, pattern)) {
260260+ return true;
261261+ }
262262+ }
263263+ return false;
264264+}
265265+266266+export interface ScanOptions {
267267+ frontmatterMapping?: FrontmatterMapping;
268268+ ignorePatterns?: string[];
269269+ slugField?: string;
270270+ removeIndexFromSlug?: boolean;
271271+ stripDatePrefix?: boolean;
137272}
138273139274export async function scanContentDirectory(
140140- contentDir: string,
141141- frontmatterMapping?: FrontmatterMapping,
142142- ignorePatterns: string[] = []
275275+ contentDir: string,
276276+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
277277+ ignorePatterns: string[] = [],
143278): Promise<BlogPost[]> {
144144- const patterns = ["**/*.md", "**/*.mdx"];
145145- const posts: BlogPost[] = [];
279279+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
280280+ let options: ScanOptions;
281281+ if (
282282+ frontmatterMappingOrOptions &&
283283+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
284284+ "ignorePatterns" in frontmatterMappingOrOptions ||
285285+ "slugField" in frontmatterMappingOrOptions)
286286+ ) {
287287+ options = frontmatterMappingOrOptions as ScanOptions;
288288+ } else {
289289+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
290290+ options = {
291291+ frontmatterMapping: frontmatterMappingOrOptions as
292292+ | FrontmatterMapping
293293+ | undefined,
294294+ ignorePatterns,
295295+ };
296296+ }
146297147147- for (const pattern of patterns) {
148148- const files = await glob(pattern, {
149149- cwd: contentDir,
150150- absolute: false,
151151- });
298298+ const {
299299+ frontmatterMapping,
300300+ ignorePatterns: ignore = [],
301301+ slugField,
302302+ removeIndexFromSlug,
303303+ stripDatePrefix,
304304+ } = options;
152305153153- for (const relativePath of files) {
154154- // Skip files matching ignore patterns
155155- if (shouldIgnore(relativePath, ignorePatterns)) {
156156- continue;
157157- }
306306+ const patterns = ["**/*.md", "**/*.mdx"];
307307+ const posts: BlogPost[] = [];
158308159159- const filePath = path.join(contentDir, relativePath);
160160- const rawContent = await fs.readFile(filePath, "utf-8");
309309+ for (const pattern of patterns) {
310310+ const files = await glob(pattern, {
311311+ cwd: contentDir,
312312+ absolute: false,
313313+ });
314314+315315+ for (const relativePath of files) {
316316+ // Skip files matching ignore patterns
317317+ if (shouldIgnore(relativePath, ignore)) {
318318+ continue;
319319+ }
320320+321321+ const filePath = path.join(contentDir, relativePath);
322322+ const rawContent = await fs.readFile(filePath, "utf-8");
161323162162- try {
163163- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
164164- const filename = path.basename(relativePath);
165165- const slug = getSlugFromFilename(filename);
324324+ try {
325325+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
326326+ rawContent,
327327+ frontmatterMapping,
328328+ );
329329+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
330330+ slugField,
331331+ removeIndexFromSlug,
332332+ stripDatePrefix,
333333+ });
166334167167- posts.push({
168168- filePath,
169169- slug,
170170- frontmatter,
171171- content: body,
172172- rawContent,
173173- });
174174- } catch (error) {
175175- console.error(`Error parsing ${relativePath}:`, error);
176176- }
177177- }
178178- }
335335+ posts.push({
336336+ filePath,
337337+ slug,
338338+ frontmatter,
339339+ content: body,
340340+ rawContent,
341341+ rawFrontmatter,
342342+ });
343343+ } catch (error) {
344344+ console.error(`Error parsing ${relativePath}:`, error);
345345+ }
346346+ }
347347+ }
179348180180- // Sort by publish date (newest first)
181181- posts.sort((a, b) => {
182182- const dateA = new Date(a.frontmatter.publishDate);
183183- const dateB = new Date(b.frontmatter.publishDate);
184184- return dateB.getTime() - dateA.getTime();
185185- });
349349+ // Sort by publish date (newest first)
350350+ posts.sort((a, b) => {
351351+ const dateA = new Date(a.frontmatter.publishDate);
352352+ const dateB = new Date(b.frontmatter.publishDate);
353353+ return dateB.getTime() - dateA.getTime();
354354+ });
186355187187- return posts;
356356+ return posts;
188357}
189358190190-export function updateFrontmatterWithAtUri(rawContent: string, atUri: string): string {
191191- // Detect which delimiter is used (---, +++, or ***)
192192- const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
193193- const delimiter = delimiterMatch?.[1] ?? "---";
194194- const isToml = delimiter === "+++";
359359+export function updateFrontmatterWithAtUri(
360360+ rawContent: string,
361361+ atUri: string,
362362+): string {
363363+ // Detect which delimiter is used (---, +++, or ***)
364364+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
365365+ const delimiter = delimiterMatch?.[1] ?? "---";
366366+ const isToml = delimiter === "+++";
367367+368368+ // Format the atUri entry based on frontmatter type
369369+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
195370196196- // Format the atUri entry based on frontmatter type
197197- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
371371+ // No frontmatter: create one with atUri
372372+ if (!delimiterMatch) {
373373+ return `---\n${atUriEntry}\n---\n\n${rawContent}`;
374374+ }
198375199199- // Check if atUri already exists in frontmatter (handle both formats)
200200- if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
201201- // Replace existing atUri (match both YAML and TOML formats)
202202- return rawContent.replace(/atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/, `${atUriEntry}\n`);
203203- }
376376+ // Check if atUri already exists in frontmatter (handle both formats)
377377+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
378378+ // Replace existing atUri (match both YAML and TOML formats)
379379+ return rawContent.replace(
380380+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
381381+ `${atUriEntry}\n`,
382382+ );
383383+ }
204384205205- // Insert atUri before the closing delimiter
206206- const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
207207- if (frontmatterEndIndex === -1) {
208208- throw new Error("Could not find frontmatter end");
209209- }
385385+ // Insert atUri before the closing delimiter
386386+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
387387+ if (frontmatterEndIndex === -1) {
388388+ throw new Error("Could not find frontmatter end");
389389+ }
210390211211- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
212212- const afterEnd = rawContent.slice(frontmatterEndIndex);
391391+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
392392+ const afterEnd = rawContent.slice(frontmatterEndIndex);
213393214214- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
394394+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
215395}
216396217397export function stripMarkdownForText(markdown: string): string {
218218- return markdown
219219- .replace(/#{1,6}\s/g, "") // Remove headers
220220- .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
221221- .replace(/\*([^*]+)\*/g, "$1") // Remove italic
222222- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
223223- .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
224224- .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
225225- .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
226226- .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
227227- .trim();
398398+ return markdown
399399+ .replace(/#{1,6}\s/g, "") // Remove headers
400400+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
401401+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
402402+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
403403+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
404404+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
405405+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
406406+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
407407+ .trim();
408408+}
409409+410410+export function getTextContent(
411411+ post: { content: string; rawFrontmatter?: Record<string, unknown> },
412412+ textContentField?: string,
413413+): string {
414414+ if (textContentField && post.rawFrontmatter?.[textContentField]) {
415415+ return String(post.rawFrontmatter[textContentField]);
416416+ }
417417+ return stripMarkdownForText(post.content);
228418}
+94
packages/cli/src/lib/oauth-client.ts
···11+import {
22+ NodeOAuthClient,
33+ type NodeOAuthClientOptions,
44+} from "@atproto/oauth-client-node";
55+import { sessionStore, stateStore } from "./oauth-store";
66+77+const CALLBACK_PORT = 4000;
88+const CALLBACK_HOST = "127.0.0.1";
99+const CALLBACK_URL = `http://${CALLBACK_HOST}:${CALLBACK_PORT}/oauth/callback`;
1010+1111+// OAuth scope for Sequoia CLI - includes atproto base scope plus our collections
1212+const OAUTH_SCOPE =
1313+ "atproto repo:site.standard.document repo:site.standard.publication repo:app.bsky.feed.post blob:*/*";
1414+1515+let oauthClient: NodeOAuthClient | null = null;
1616+1717+// Simple lock implementation for CLI (single process, no contention)
1818+// This prevents the "No lock mechanism provided" warning
1919+const locks = new Map<string, Promise<void>>();
2020+2121+async function requestLock<T>(
2222+ key: string,
2323+ fn: () => T | PromiseLike<T>,
2424+): Promise<T> {
2525+ // Wait for any existing lock on this key
2626+ while (locks.has(key)) {
2727+ await locks.get(key);
2828+ }
2929+3030+ // Create our lock
3131+ let resolve: () => void;
3232+ const lockPromise = new Promise<void>((r) => {
3333+ resolve = r;
3434+ });
3535+ locks.set(key, lockPromise);
3636+3737+ try {
3838+ return await fn();
3939+ } finally {
4040+ locks.delete(key);
4141+ resolve!();
4242+ }
4343+}
4444+4545+/**
4646+ * Get or create the OAuth client singleton
4747+ */
4848+export async function getOAuthClient(): Promise<NodeOAuthClient> {
4949+ if (oauthClient) {
5050+ return oauthClient;
5151+ }
5252+5353+ // Build client_id with required parameters
5454+ const clientIdParams = new URLSearchParams();
5555+ clientIdParams.append("redirect_uri", CALLBACK_URL);
5656+ clientIdParams.append("scope", OAUTH_SCOPE);
5757+5858+ const clientOptions: NodeOAuthClientOptions = {
5959+ clientMetadata: {
6060+ client_id: `http://localhost?${clientIdParams.toString()}`,
6161+ client_name: "Sequoia CLI",
6262+ client_uri: "https://github.com/stevedylandev/sequoia",
6363+ redirect_uris: [CALLBACK_URL],
6464+ grant_types: ["authorization_code", "refresh_token"],
6565+ response_types: ["code"],
6666+ token_endpoint_auth_method: "none",
6767+ application_type: "web",
6868+ scope: OAUTH_SCOPE,
6969+ dpop_bound_access_tokens: false,
7070+ },
7171+ stateStore,
7272+ sessionStore,
7373+ // Configure identity resolution
7474+ plcDirectoryUrl: "https://plc.directory",
7575+ // Provide lock mechanism to prevent warning
7676+ requestLock,
7777+ };
7878+7979+ oauthClient = new NodeOAuthClient(clientOptions);
8080+8181+ return oauthClient;
8282+}
8383+8484+export function getOAuthScope(): string {
8585+ return OAUTH_SCOPE;
8686+}
8787+8888+export function getCallbackUrl(): string {
8989+ return CALLBACK_URL;
9090+}
9191+9292+export function getCallbackPort(): number {
9393+ return CALLBACK_PORT;
9494+}
+161
packages/cli/src/lib/oauth-store.ts
···11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
44+import type {
55+ NodeSavedSession,
66+ NodeSavedSessionStore,
77+ NodeSavedState,
88+ NodeSavedStateStore,
99+} from "@atproto/oauth-client-node";
1010+1111+const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
1212+const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json");
1313+1414+interface OAuthStore {
1515+ states: Record<string, NodeSavedState>;
1616+ sessions: Record<string, NodeSavedSession>;
1717+ handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
1818+}
1919+2020+async function fileExists(filePath: string): Promise<boolean> {
2121+ try {
2222+ await fs.access(filePath);
2323+ return true;
2424+ } catch {
2525+ return false;
2626+ }
2727+}
2828+2929+async function loadOAuthStore(): Promise<OAuthStore> {
3030+ if (!(await fileExists(OAUTH_FILE))) {
3131+ return { states: {}, sessions: {} };
3232+ }
3333+3434+ try {
3535+ const content = await fs.readFile(OAUTH_FILE, "utf-8");
3636+ return JSON.parse(content) as OAuthStore;
3737+ } catch {
3838+ return { states: {}, sessions: {} };
3939+ }
4040+}
4141+4242+async function saveOAuthStore(store: OAuthStore): Promise<void> {
4343+ await fs.mkdir(CONFIG_DIR, { recursive: true });
4444+ await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2));
4545+ await fs.chmod(OAUTH_FILE, 0o600);
4646+}
4747+4848+/**
4949+ * State store for PKCE flow (temporary, used during auth)
5050+ */
5151+export const stateStore: NodeSavedStateStore = {
5252+ async set(key: string, state: NodeSavedState): Promise<void> {
5353+ const store = await loadOAuthStore();
5454+ store.states[key] = state;
5555+ await saveOAuthStore(store);
5656+ },
5757+5858+ async get(key: string): Promise<NodeSavedState | undefined> {
5959+ const store = await loadOAuthStore();
6060+ return store.states[key];
6161+ },
6262+6363+ async del(key: string): Promise<void> {
6464+ const store = await loadOAuthStore();
6565+ delete store.states[key];
6666+ await saveOAuthStore(store);
6767+ },
6868+};
6969+7070+/**
7171+ * Session store for OAuth tokens (persistent)
7272+ */
7373+export const sessionStore: NodeSavedSessionStore = {
7474+ async set(sub: string, session: NodeSavedSession): Promise<void> {
7575+ const store = await loadOAuthStore();
7676+ store.sessions[sub] = session;
7777+ await saveOAuthStore(store);
7878+ },
7979+8080+ async get(sub: string): Promise<NodeSavedSession | undefined> {
8181+ const store = await loadOAuthStore();
8282+ return store.sessions[sub];
8383+ },
8484+8585+ async del(sub: string): Promise<void> {
8686+ const store = await loadOAuthStore();
8787+ delete store.sessions[sub];
8888+ await saveOAuthStore(store);
8989+ },
9090+};
9191+9292+/**
9393+ * List all stored OAuth session DIDs
9494+ */
9595+export async function listOAuthSessions(): Promise<string[]> {
9696+ const store = await loadOAuthStore();
9797+ return Object.keys(store.sessions);
9898+}
9999+100100+/**
101101+ * Get an OAuth session by DID
102102+ */
103103+export async function getOAuthSession(
104104+ did: string,
105105+): Promise<NodeSavedSession | undefined> {
106106+ const store = await loadOAuthStore();
107107+ return store.sessions[did];
108108+}
109109+110110+/**
111111+ * Delete an OAuth session by DID
112112+ */
113113+export async function deleteOAuthSession(did: string): Promise<boolean> {
114114+ const store = await loadOAuthStore();
115115+ if (!store.sessions[did]) {
116116+ return false;
117117+ }
118118+ delete store.sessions[did];
119119+ await saveOAuthStore(store);
120120+ return true;
121121+}
122122+123123+export function getOAuthStorePath(): string {
124124+ return OAUTH_FILE;
125125+}
126126+127127+/**
128128+ * Store handle for an OAuth session (DID -> handle mapping)
129129+ */
130130+export async function setOAuthHandle(
131131+ did: string,
132132+ handle: string,
133133+): Promise<void> {
134134+ const store = await loadOAuthStore();
135135+ if (!store.handles) {
136136+ store.handles = {};
137137+ }
138138+ store.handles[did] = handle;
139139+ await saveOAuthStore(store);
140140+}
141141+142142+/**
143143+ * Get handle for an OAuth session by DID
144144+ */
145145+export async function getOAuthHandle(did: string): Promise<string | undefined> {
146146+ const store = await loadOAuthStore();
147147+ return store.handles?.[did];
148148+}
149149+150150+/**
151151+ * List all stored OAuth sessions with their handles
152152+ */
153153+export async function listOAuthSessionsWithHandles(): Promise<
154154+ Array<{ did: string; handle?: string }>
155155+> {
156156+ const store = await loadOAuthStore();
157157+ return Object.keys(store.sessions).map((did) => ({
158158+ did,
159159+ handle: store.handles?.[did],
160160+ }));
161161+}
+6-6
packages/cli/src/lib/prompts.ts
···11-import { isCancel, cancel } from "@clack/prompts";
11+import { cancel, isCancel } from "@clack/prompts";
2233export function exitOnCancel<T>(value: T | symbol): T {
44- if (isCancel(value)) {
55- cancel("Cancelled");
66- process.exit(0);
77- }
88- return value as T;
44+ if (isCancel(value)) {
55+ cancel("Cancelled");
66+ process.exit(0);
77+ }
88+ return value as T;
99}
+40-1
packages/cli/src/lib/types.ts
···55 coverImage?: string; // Field name for cover image (default: "ogImage")
66 tags?: string; // Field name for tags (default: "tags")
77 draft?: string; // Field name for draft status (default: "draft")
88+ slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
89}
9101011// Strong reference for Bluesky post (com.atproto.repo.strongRef)
···3132 identity?: string; // Which stored identity to use (matches identifier)
3233 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
3334 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
3535+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
3636+ stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false)
3737+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
3438 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
3539}
36403737-export interface Credentials {
4141+// Legacy credentials format (for backward compatibility during migration)
4242+export interface LegacyCredentials {
4343+ pdsUrl: string;
4444+ identifier: string;
4545+ password: string;
4646+}
4747+4848+// App password credentials (explicit type)
4949+export interface AppPasswordCredentials {
5050+ type: "app-password";
3851 pdsUrl: string;
3952 identifier: string;
4053 password: string;
4154}
42555656+// OAuth credentials (references stored OAuth session)
5757+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
5858+export interface OAuthCredentials {
5959+ type: "oauth";
6060+ did: string;
6161+ handle: string;
6262+}
6363+6464+// Union type for all credential types
6565+export type Credentials = AppPasswordCredentials | OAuthCredentials;
6666+6767+// Helper to check credential type
6868+export function isOAuthCredentials(
6969+ creds: Credentials,
7070+): creds is OAuthCredentials {
7171+ return creds.type === "oauth";
7272+}
7373+7474+export function isAppPasswordCredentials(
7575+ creds: Credentials,
7676+): creds is AppPasswordCredentials {
7777+ return creds.type === "app-password";
7878+}
7979+4380export interface PostFrontmatter {
4481 title: string;
4582 description?: string;
···5693 frontmatter: PostFrontmatter;
5794 content: string;
5895 rawContent: string;
9696+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
5997}
60986199export interface BlobRef {
···77115 contentHash: string;
78116 atUri?: string;
79117 lastPublished?: string;
118118+ slug?: string; // The generated slug for this post (used by inject command)
80119 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
81120}
82121