···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
+29
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 |
0018| `bluesky` | `object` | No | - | Bluesky posting configuration |
19| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
20| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
···79 }
80}
81```
000000000000000000000000008283### Ignoring Files
84
···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 |
···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
+9-7
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 authorize 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
···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
···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}
+40-1
packages/cli/src/lib/types.ts
···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")
08}
910// Strong reference for Bluesky post (com.atproto.repo.strongRef)
···31 identity?: string; // Which stored identity to use (matches identifier)
32 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
33 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
00034 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
35}
3637-export interface Credentials {
00000000038 pdsUrl: string;
39 identifier: string;
40 password: string;
41}
4200000000000000000000000043export interface PostFrontmatter {
44 title: string;
45 description?: string;
···56 frontmatter: PostFrontmatter;
57 content: string;
58 rawContent: string;
059}
6061export interface BlobRef {
···77 contentHash: string;
78 atUri?: string;
79 lastPublished?: string;
080 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
81}
82
···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}
1011// Strong reference for Bluesky post (com.atproto.repo.strongRef)
···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+// 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+80export interface PostFrontmatter {
81 title: string;
82 description?: string;
···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}
121