···1+---
2+layout: minimal
3+title: "Introducing Sequoia: Publishing for the Open Web"
4+date: 2026-01-30
5+atUri: "at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v"
6+---
7+8+# Introducing Sequoia: Publishing for the Open Web
9+10+
11+12+Today I'm excited to release a new tool for the [AT Protocol](https://atproto.com): Sequoia. This is a CLI tool that can take your existing self-hosted blog and publish it to the ATmosphere using [Standard.site](https://standard.site) lexicons.
13+14+If you haven't explored ATProto you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/), but in short, it's a new way to publish content to the web that puts ownership and control back in the hands of users. Blogs in some ways have already been doing this, but they've been missing a key piece: distribution. One of the unique features of ATProto is [lexicons](), which are schemas that apps build to create folders of content on a user's personal data server. The domain verified nature lets them be indexed and aggregated with ease. Outside of apps, lexicons can be extended by community members to build a common standard. That's exactly how [Standard.site](https://standard.site) was brought about, pushing a new way for standardizing publications and documents on ATProto.
15+16+The founders and platforms behind the standard, [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), all serve to make creating and sharing blogs easy. If you are not a technical person and don't have a blog already, I would highly recommend checking all of them out! However, for those of us who already have blogs, there was a need for a tool that could make it easy to publish existing and new content with this new standard. Thus Sequoia was born.
17+18+Sequoia is a relatively simple CLI that can do the following:
19+- Authenticate with your ATProto handle
20+- Configure your blog through an interactive setup process
21+- Create publication and document records on your PDS
22+- Add necessary verification pieces to your site
23+- Sync with existing records on your PDS
24+25+It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action:
26+27+<iframe
28+ class="w-full"
29+ style={{aspectRatio: "16/9"}}
30+ src="https://www.youtube.com/embed/sxursUHq5kw"
31+ title="YouTube video player"
32+ frameborder="0"
33+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
34+ referrerpolicy="strict-origin-when-cross-origin"
35+ allowfullscreen
36+></iframe>
37+38+ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons.
39+40+Install Sequoia today and check out the [quickstart guide](/quickstart) to publish your content into the ATmosphere ๐ณ
41+42+:::code-group
43+```bash [npm]
44+npm i -g sequoia-cli
45+```
46+47+```bash [pnpm]
48+pnpm i -g sequoia-cli
49+```
50+51+```bash [bun]
52+bun i -g sequoia-cli
53+```
54+:::
+34-1
docs/docs/pages/cli-reference.mdx
···1# CLI Reference
200000000000000003## `auth`
45```bash [Terminal]
6sequoia auth
7-> Authenticate with your ATProto PDS
89OPTIONS:
10 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···13 --list - List all stored identities [optional]
14 --help, -h - show help [optional]
15```
001617## `init`
18···61 --dry-run, -n - Preview what would be synced without making changes [optional]
62 --help, -h - show help [optional]
63```
000000000000000
···1# CLI Reference
23+## `login`
4+5+```bash [Terminal]
6+sequoia login
7+> Login with OAuth (browser-based authentication)
8+9+OPTIONS:
10+ --logout <str> - Remove OAuth session for a specific DID [optional]
11+12+FLAGS:
13+ --list - List all stored OAuth sessions [optional]
14+ --help, -h - show help [optional]
15+```
16+17+OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically.
18+19## `auth`
2021```bash [Terminal]
22sequoia auth
23+> Authenticate with your ATProto PDS using an app password
2425OPTIONS:
26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···29 --list - List all stored identities [optional]
30 --help, -h - show help [optional]
31```
32+33+Use this as an alternative to `login` when OAuth isn't available or for CI environments.
3435## `init`
36···79 --dry-run, -n - Preview what would be synced without making changes [optional]
80 --help, -h - show help [optional]
81```
82+83+## `update`
84+85+```bash [Terminal]
86+sequoia update
87+> Update local config or ATProto publication record
88+89+FLAGS:
90+ --help, -h - show help [optional]
91+```
92+93+Interactive command to modify your existing configuration. Choose between:
94+95+- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
96+- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+41-2
docs/docs/pages/config.mdx
···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
017| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
000001819### Example
20···31 "frontmatter": {
32 "publishDate": "date"
33 },
34- "ignore": ["_index.md"]
000035}
36```
37···44| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
45| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
46| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
04748### Example
49···54publishDate: 2024-01-15
55ogImage: cover.jpg
56tags: [welcome, intro]
057---
58```
59···65{
66 "frontmatter": {
67 "publishDate": "date",
68- "coverImage": "thumbnail"
000000000000069 }
70}
71```
000000000000007273### Ignoring Files
74
···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
17+| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
18| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
19+| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
20+| `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) |
21+| `bluesky` | `object` | No | - | Bluesky posting configuration |
22+| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
23+| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
2425### Example
26···37 "frontmatter": {
38 "publishDate": "date"
39 },
40+ "ignore": ["_index.md"],
41+ "bluesky": {
42+ "enabled": true,
43+ "maxAgeDays": 30
44+ }
45}
46```
47···54| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
55| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
56| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
57+| `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish |
5859### Example
60···65publishDate: 2024-01-15
66ogImage: cover.jpg
67tags: [welcome, intro]
68+draft: false
69---
70```
71···77{
78 "frontmatter": {
79 "publishDate": "date",
80+ "coverImage": "thumbnail",
81+ "draft": "private"
82+ }
83+}
84+```
85+86+### Slug Configuration
87+88+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
89+90+```json
91+{
92+ "frontmatter": {
93+ "slugField": "url"
94 }
95}
96```
97+98+If the frontmatter field is not found, it falls back to the filepath.
99+100+### Jekyll-Style Date Prefixes
101+102+Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs:
103+104+```json
105+{
106+ "stripDatePrefix": true
107+}
108+```
109+110+This transforms `2024-01-15-my-post.md` into the slug `my-post`.
111112### Ignoring Files
113
+40-2
docs/docs/pages/publishing.mdx
···10sequoia publish --dry-run
11```
1213-This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it!
1415```bash [Terminal]
16sequoia publish
···23If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command.
2425```bash [Terminal]
26-seuqoia sync
27```
2829Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config.
000000000000000000000000000000000000003031## Troubleshooting
32
···10sequoia publish --dry-run
11```
1213+This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it!
1415```bash [Terminal]
16sequoia publish
···23If you happen to loose the state file or if you want to pull down records you already have published, you can use the `sync` command.
2425```bash [Terminal]
26+sequoia sync
27```
2829Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config.
30+31+## Bluesky Posting
32+33+Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config:
34+35+```json
36+{
37+ "bluesky": {
38+ "enabled": true,
39+ "maxAgeDays": 30
40+ }
41+}
42+```
43+44+When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters.
45+46+The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky.
47+48+## Draft Posts
49+50+Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it.
51+52+```yaml
53+---
54+title: Work in Progress
55+draft: true
56+---
57+```
58+59+If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`:
60+61+```json
62+{
63+ "frontmatter": {
64+ "draft": "private"
65+ }
66+}
67+```
6869## Troubleshooting
70
+10-8
docs/docs/pages/quickstart.mdx
···31sequoia
32```
3334-### Authorize
35-36-In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password.
3738-:::tip
39-You can create an app password [here](https://bsky.app/settings/app-passwords)
40-:::
4142```bash [Terminal]
43-sequoia auth
44```
0000004546### Initialize
47···59- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
60- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
61- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
62-- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
63- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
64 - **Publication name** - The name of your blog
65 - **Publication description** - A description for your blog
···31sequoia
32```
3334+### Login
003536+In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account.
003738```bash [Terminal]
39+sequoia login
40```
41+42+This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI.
43+44+:::tip
45+Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth.
46+:::
4748### Initialize
49···61- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
62- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
63- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
64+- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
65- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
66 - **Publication name** - The name of your blog
67 - **Publication description** - A description for your blog
+2-2
docs/docs/pages/setup.mdx
···2829## Authorize
3031-In order for Sequoia to publish or update records on your PDS, you need to authoize it with your ATProto handle and an app password.
3233:::tip
34You can create an app password [here](https://bsky.app/settings/app-passwords)
···56- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
57- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
58- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
59-- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with infomation like `title`, `description`, and `publishedDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
60- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
61 - **Publication name** - The name of your blog
62 - **Publication description** - A description for your blog
···2829## Authorize
3031+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.
3233:::tip
34You can create an app password [here](https://bsky.app/settings/app-passwords)
···56- **Public/static directory** - The path for the folder where your public items go, e.g. `./public`. Generally used for opengraph images or icons, but in this case we need it to store a `.well-known` verification for your blog, [read more here](/verifying).
57- **Build output directory** - Where you published html css and js lives, e.g. `./dist`
58- **URL path prefix for posts** - The path that goes before a post slug, e.g. the prefix for `https://sequoia.pub/blog/hello` would be `/blog`.
59+- **Configure your frontmatter field mappings** - In your markdown posts there is usually frontmatter with information like `title`, `description`, and `publishDate`. Follow the prompts and enter the names for your frontmatter fields so Sequoia can use them for creating standard.site documents.
60- **Publication setup** - Here you can choose to `Create a new publication` which will create a `site.standard.publication` record on your PDS, or you can `Use an existing publication AT URI`. If you haven't done this before, select `Create a new publication`.
61 - **Publication name** - The name of your blog
62 - **Publication description** - A description for your blog
+2-2
docs/docs/pages/verifying.mdx
···3In order for your posts to show up on indexers you need to make sure your publication and your documents are verified.
45:::tip
6-You an learn more about Standard.site verification [here](https://standard.site/)
7:::
89## Publication Verification
···2223### pds.ls
2425-Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as perscribed on their website.
2627### Standard.site Validator
28
···3In order for your posts to show up on indexers you need to make sure your publication and your documents are verified.
45:::tip
6+You can learn more about Standard.site verification [here](https://standard.site/)
7:::
89## Publication Verification
···2223### pds.ls
2425+Visit [pds.ls](https://pds.ls) and in the search bar paste in a `arUri` for either your publication or document, click the info tab, and then click the "info" tab. This will have a schema verification that will make sure the fields are accurate, however this will not cover Standard.site verification as prescribed on their website.
2627### Standard.site Validator
28
+2-2
docs/docs/pages/what-is-sequoia.mdx
···3Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down.
45- [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/).
6-- [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. The unique property to lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more!
7-- [Standard.site](https://standard.site) - Standard.site is a set of lexicons specailly designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard.
89The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too.
10
···3Sequoia is a simple CLI that can be used to publish Standard.site lexicons to the AT Protocol. Yeah that's a mouthful; let's break it down.
45- [AT Protocol](https://atproto.com) - As the site says, "The AT Protocol is an open, decentralized network for building social applications." In reality it's a bit more than that. It's a new way to publish content to the web that puts control back in the hands of users without sacrificing distrubtion. There's a lot to unpack, but you can find a primer [here](https://stevedylan.dev/posts/atproto-starter/).
6+- [Lexicons](https://atproto.com/guides/lexicon) - Lexicons are schemas used inside the AT Protocol. If you were to "like" a post, what would that consist of? Probably _who_ liked it, _what_ post was liked, and the _author_ of the post. A unique property of lexicons is that anyone can publish them and have them verified under a domain. Then these lexicons can be used to build apps by pulling a users records, aggregating them using an indexer, and a whole lot more!
7+- [Standard.site](https://standard.site) - Standard.site is a set of lexicons specially designed for publishing content. It was started by the founders of [leaflet.pub](https://leaflet.pub), [pckt.blog](https://pckt.blog), and [offprint.app](https://offprint.app), with the mission of finding a schema that can be used for blog posts and blog sites themselves (if you don't have a self-hosted blog, definitely check those platforms out!). So far it has proven to be the lexicon of choice for publishing content to ATProto with multiple tools and lexicons revolving around the standard.
89The goal of Sequoia is to make it easier for those with existing self-hosted blogs to publish their content to the ATmosphere, no matter what SSG or framework you might be using. As of right now the focus will be static sites, but if there is enough traction there might be a future package that can be used for SSR frameworks too.
10
···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";
23export function exitOnCancel<T>(value: T | symbol): T {
4- if (isCancel(value)) {
5- cancel("Cancelled");
6- process.exit(0);
7- }
8- return value as T;
9}
···1+import { cancel, isCancel } from "@clack/prompts";
23export function exitOnCancel<T>(value: T | symbol): T {
4+ if (isCancel(value)) {
5+ cancel("Cancelled");
6+ process.exit(0);
7+ }
8+ return value as T;
9}
+56-1
packages/cli/src/lib/types.ts
···4 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
000000000000007}
89export interface PublisherConfig {
···18 identity?: string; // Which stored identity to use (matches identifier)
19 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
20 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
000021}
2223-export interface Credentials {
00000000024 pdsUrl: string;
25 identifier: string;
26 password: string;
27}
2800000000000000000000000029export interface PostFrontmatter {
30 title: string;
31 description?: string;
···33 tags?: string[];
34 ogImage?: string;
35 atUri?: string;
036}
3738export interface BlogPost {
···41 frontmatter: PostFrontmatter;
42 content: string;
43 rawContent: string;
044}
4546export interface BlobRef {
···62 contentHash: string;
63 atUri?: string;
64 lastPublished?: string;
0065}
6667export interface PublicationRecord {
···4 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
7+ draft?: string; // Field name for draft status (default: "draft")
8+ slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
9+}
10+11+// Strong reference for Bluesky post (com.atproto.repo.strongRef)
12+export interface StrongRef {
13+ uri: string; // at:// URI format
14+ cid: string; // Content ID
15+}
16+17+// Bluesky posting configuration
18+export interface BlueskyConfig {
19+ enabled: boolean;
20+ maxAgeDays?: number; // Only post if published within N days (default: 7)
21}
2223export interface PublisherConfig {
···32 identity?: string; // Which stored identity to use (matches identifier)
33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
35+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
36+ stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false)
37+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
38+ bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
39}
4041+// Legacy credentials format (for backward compatibility during migration)
42+export interface LegacyCredentials {
43+ pdsUrl: string;
44+ identifier: string;
45+ password: string;
46+}
47+48+// App password credentials (explicit type)
49+export interface AppPasswordCredentials {
50+ type: "app-password";
51 pdsUrl: string;
52 identifier: string;
53 password: string;
54}
5556+// OAuth credentials (references stored OAuth session)
57+export interface OAuthCredentials {
58+ type: "oauth";
59+ did: string;
60+ handle: string;
61+ pdsUrl: 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+80export interface PostFrontmatter {
81 title: string;
82 description?: string;
···84 tags?: string[];
85 ogImage?: string;
86 atUri?: string;
87+ draft?: boolean;
88}
8990export interface BlogPost {
···93 frontmatter: PostFrontmatter;
94 content: string;
95 rawContent: string;
96+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
97}
9899export interface BlobRef {
···115 contentHash: string;
116 atUri?: string;
117 lastPublished?: string;
118+ slug?: string; // The generated slug for this post (used by inject command)
119+ bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
120}
121122export interface PublicationRecord {