···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
+16
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 |
1820| `bluesky` | `object` | No | - | Bluesky posting configuration |
1921| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
2022| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
···7981 }
8082}
8183```
8484+8585+### Slug Configuration
8686+8787+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
8888+8989+```json
9090+{
9191+ "frontmatter": {
9292+ "slugField": "url"
9393+ }
9494+}
9595+```
9696+9797+If the frontmatter field is not found, it falls back to the filepath.
82988399### Ignoring Files
84100
+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
···11-import { AtpAgent } from "@atproto/api";
22-import * as fs from "fs/promises";
33-import * as path from "path";
11+import { Agent, AtpAgent } from "@atproto/api";
42import * as mimeTypes from "mime-types";
55-import type { Credentials, BlogPost, BlobObject, PublisherConfig, StrongRef } from "./types";
33+import * as fs from "node:fs/promises";
44+import * as path from "node:path";
65import { stripMarkdownForText } from "./markdown";
66+import { getOAuthClient } from "./oauth-client";
77+import type {
88+ BlobObject,
99+ BlogPost,
1010+ Credentials,
1111+ PublisherConfig,
1212+ StrongRef,
1313+} from "./types";
1414+import { isAppPasswordCredentials, isOAuthCredentials } from "./types";
1515+1616+/**
1717+ * Type guard to check if a record value is a DocumentRecord
1818+ */
1919+function isDocumentRecord(value: unknown): value is DocumentRecord {
2020+ if (!value || typeof value !== "object") return false;
2121+ const v = value as Record<string, unknown>;
2222+ return (
2323+ v.$type === "site.standard.document" &&
2424+ typeof v.title === "string" &&
2525+ typeof v.site === "string" &&
2626+ typeof v.path === "string" &&
2727+ typeof v.textContent === "string" &&
2828+ typeof v.publishedAt === "string"
2929+ );
3030+}
731832async function fileExists(filePath: string): Promise<boolean> {
99- try {
1010- await fs.access(filePath);
1111- return true;
1212- } catch {
1313- return false;
1414- }
3333+ try {
3434+ await fs.access(filePath);
3535+ return true;
3636+ } catch {
3737+ return false;
3838+ }
3939+}
4040+4141+/**
4242+ * Resolve a handle to a DID
4343+ */
4444+export async function resolveHandleToDid(handle: string): Promise<string> {
4545+ if (handle.startsWith("did:")) {
4646+ return handle;
4747+ }
4848+4949+ // Try to resolve handle via Bluesky API
5050+ const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
5151+ const resolveResponse = await fetch(resolveUrl);
5252+ if (!resolveResponse.ok) {
5353+ throw new Error("Could not resolve handle");
5454+ }
5555+ const resolveData = (await resolveResponse.json()) as { did: string };
5656+ return resolveData.did;
1557}
16581759export async function resolveHandleToPDS(handle: string): Promise<string> {
1818- // First, resolve the handle to a DID
1919- let did: string;
6060+ // First, resolve the handle to a DID
6161+ const did = await resolveHandleToDid(handle);
20622121- if (handle.startsWith("did:")) {
2222- did = handle;
2323- } else {
2424- // Try to resolve handle via Bluesky API
2525- const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
2626- const resolveResponse = await fetch(resolveUrl);
2727- if (!resolveResponse.ok) {
2828- throw new Error("Could not resolve handle");
2929- }
3030- const resolveData = (await resolveResponse.json()) as { did: string };
3131- did = resolveData.did;
3232- }
6363+ // Now resolve the DID to get the PDS URL from the DID document
6464+ let pdsUrl: string | undefined;
33653434- // Now resolve the DID to get the PDS URL from the DID document
3535- let pdsUrl: string | undefined;
6666+ if (did.startsWith("did:plc:")) {
6767+ // Fetch DID document from plc.directory
6868+ const didDocUrl = `https://plc.directory/${did}`;
6969+ const didDocResponse = await fetch(didDocUrl);
7070+ if (!didDocResponse.ok) {
7171+ throw new Error("Could not fetch DID document");
7272+ }
7373+ const didDoc = (await didDocResponse.json()) as {
7474+ service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
7575+ };
36763737- if (did.startsWith("did:plc:")) {
3838- // Fetch DID document from plc.directory
3939- const didDocUrl = `https://plc.directory/${did}`;
4040- const didDocResponse = await fetch(didDocUrl);
4141- if (!didDocResponse.ok) {
4242- throw new Error("Could not fetch DID document");
4343- }
4444- const didDoc = (await didDocResponse.json()) as {
4545- service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
4646- };
7777+ // Find the PDS service endpoint
7878+ const pdsService = didDoc.service?.find(
7979+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
8080+ );
8181+ pdsUrl = pdsService?.serviceEndpoint;
8282+ } else if (did.startsWith("did:web:")) {
8383+ // For did:web, fetch the DID document from the domain
8484+ const domain = did.replace("did:web:", "");
8585+ const didDocUrl = `https://${domain}/.well-known/did.json`;
8686+ const didDocResponse = await fetch(didDocUrl);
8787+ if (!didDocResponse.ok) {
8888+ throw new Error("Could not fetch DID document");
8989+ }
9090+ const didDoc = (await didDocResponse.json()) as {
9191+ service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
9292+ };
47934848- // Find the PDS service endpoint
4949- const pdsService = didDoc.service?.find(
5050- (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
5151- );
5252- pdsUrl = pdsService?.serviceEndpoint;
5353- } else if (did.startsWith("did:web:")) {
5454- // For did:web, fetch the DID document from the domain
5555- const domain = did.replace("did:web:", "");
5656- const didDocUrl = `https://${domain}/.well-known/did.json`;
5757- const didDocResponse = await fetch(didDocUrl);
5858- if (!didDocResponse.ok) {
5959- throw new Error("Could not fetch DID document");
6060- }
6161- const didDoc = (await didDocResponse.json()) as {
6262- service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
6363- };
6464-6565- const pdsService = didDoc.service?.find(
6666- (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
6767- );
6868- pdsUrl = pdsService?.serviceEndpoint;
6969- }
9494+ const pdsService = didDoc.service?.find(
9595+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
9696+ );
9797+ pdsUrl = pdsService?.serviceEndpoint;
9898+ }
70997171- if (!pdsUrl) {
7272- throw new Error("Could not find PDS URL for user");
7373- }
100100+ if (!pdsUrl) {
101101+ throw new Error("Could not find PDS URL for user");
102102+ }
741037575- return pdsUrl;
104104+ return pdsUrl;
76105}
7710678107export interface CreatePublicationOptions {
7979- url: string;
8080- name: string;
8181- description?: string;
8282- iconPath?: string;
8383- showInDiscover?: boolean;
108108+ url: string;
109109+ name: string;
110110+ description?: string;
111111+ iconPath?: string;
112112+ showInDiscover?: boolean;
84113}
851148686-export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
8787- const agent = new AtpAgent({ service: credentials.pdsUrl });
115115+export async function createAgent(credentials: Credentials): Promise<Agent> {
116116+ if (isOAuthCredentials(credentials)) {
117117+ // OAuth flow - restore session from stored tokens
118118+ const client = await getOAuthClient();
119119+ try {
120120+ const oauthSession = await client.restore(credentials.did);
121121+ // Wrap the OAuth session in an Agent which provides the atproto API
122122+ return new Agent(oauthSession);
123123+ } catch (error) {
124124+ if (error instanceof Error) {
125125+ // Check for common OAuth errors
126126+ if (
127127+ error.message.includes("expired") ||
128128+ error.message.includes("revoked")
129129+ ) {
130130+ throw new Error(
131131+ `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`,
132132+ );
133133+ }
134134+ }
135135+ throw error;
136136+ }
137137+ }
881388989- await agent.login({
9090- identifier: credentials.identifier,
9191- password: credentials.password,
9292- });
139139+ // App password flow
140140+ if (!isAppPasswordCredentials(credentials)) {
141141+ throw new Error("Invalid credential type");
142142+ }
143143+ const agent = new AtpAgent({ service: credentials.pdsUrl });
931449494- return agent;
145145+ await agent.login({
146146+ identifier: credentials.identifier,
147147+ password: credentials.password,
148148+ });
149149+150150+ return agent;
95151}
9615297153export async function uploadImage(
9898- agent: AtpAgent,
9999- imagePath: string
154154+ agent: Agent,
155155+ imagePath: string,
100156): Promise<BlobObject | undefined> {
101101- if (!(await fileExists(imagePath))) {
102102- return undefined;
103103- }
157157+ if (!(await fileExists(imagePath))) {
158158+ return undefined;
159159+ }
104160105105- try {
106106- const imageBuffer = await fs.readFile(imagePath);
107107- const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
161161+ try {
162162+ const imageBuffer = await fs.readFile(imagePath);
163163+ const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream";
108164109109- const response = await agent.com.atproto.repo.uploadBlob(
110110- new Uint8Array(imageBuffer),
111111- {
112112- encoding: mimeType,
113113- }
114114- );
165165+ const response = await agent.com.atproto.repo.uploadBlob(
166166+ new Uint8Array(imageBuffer),
167167+ {
168168+ encoding: mimeType,
169169+ },
170170+ );
115171116116- return {
117117- $type: "blob",
118118- ref: {
119119- $link: response.data.blob.ref.toString(),
120120- },
121121- mimeType,
122122- size: imageBuffer.byteLength,
123123- };
124124- } catch (error) {
125125- console.error(`Error uploading image ${imagePath}:`, error);
126126- return undefined;
127127- }
172172+ return {
173173+ $type: "blob",
174174+ ref: {
175175+ $link: response.data.blob.ref.toString(),
176176+ },
177177+ mimeType,
178178+ size: imageBuffer.byteLength,
179179+ };
180180+ } catch (error) {
181181+ console.error(`Error uploading image ${imagePath}:`, error);
182182+ return undefined;
183183+ }
128184}
129185130186export async function resolveImagePath(
131131- ogImage: string,
132132- imagesDir: string | undefined,
133133- contentDir: string
187187+ ogImage: string,
188188+ imagesDir: string | undefined,
189189+ contentDir: string,
134190): Promise<string | null> {
135135- // Try multiple resolution strategies
136136- const filename = path.basename(ogImage);
191191+ // Try multiple resolution strategies
192192+ const filename = path.basename(ogImage);
137193138138- // 1. If imagesDir is specified, look there
139139- if (imagesDir) {
140140- const imagePath = path.join(imagesDir, filename);
141141- if (await fileExists(imagePath)) {
142142- const stat = await fs.stat(imagePath);
143143- if (stat.size > 0) {
144144- return imagePath;
145145- }
146146- }
147147- }
194194+ // 1. If imagesDir is specified, look there
195195+ if (imagesDir) {
196196+ const imagePath = path.join(imagesDir, filename);
197197+ if (await fileExists(imagePath)) {
198198+ const stat = await fs.stat(imagePath);
199199+ if (stat.size > 0) {
200200+ return imagePath;
201201+ }
202202+ }
203203+ }
148204149149- // 2. Try the ogImage path directly (if it's absolute)
150150- if (path.isAbsolute(ogImage)) {
151151- return ogImage;
152152- }
205205+ // 2. Try the ogImage path directly (if it's absolute)
206206+ if (path.isAbsolute(ogImage)) {
207207+ return ogImage;
208208+ }
153209154154- // 3. Try relative to content directory
155155- const contentRelative = path.join(contentDir, ogImage);
156156- if (await fileExists(contentRelative)) {
157157- const stat = await fs.stat(contentRelative);
158158- if (stat.size > 0) {
159159- return contentRelative;
160160- }
161161- }
210210+ // 3. Try relative to content directory
211211+ const contentRelative = path.join(contentDir, ogImage);
212212+ if (await fileExists(contentRelative)) {
213213+ const stat = await fs.stat(contentRelative);
214214+ if (stat.size > 0) {
215215+ return contentRelative;
216216+ }
217217+ }
162218163163- return null;
219219+ return null;
164220}
165221166222export async function createDocument(
167167- agent: AtpAgent,
168168- post: BlogPost,
169169- config: PublisherConfig,
170170- coverImage?: BlobObject
223223+ agent: Agent,
224224+ post: BlogPost,
225225+ config: PublisherConfig,
226226+ coverImage?: BlobObject,
171227): Promise<string> {
172172- const pathPrefix = config.pathPrefix || "/posts";
173173- const postPath = `${pathPrefix}/${post.slug}`;
174174- const textContent = stripMarkdownForText(post.content);
175175- const publishDate = new Date(post.frontmatter.publishDate);
228228+ const pathPrefix = config.pathPrefix || "/posts";
229229+ const postPath = `${pathPrefix}/${post.slug}`;
230230+ const publishDate = new Date(post.frontmatter.publishDate);
231231+232232+ // Determine textContent: use configured field from frontmatter, or fallback to markdown body
233233+ let textContent: string;
234234+ if (
235235+ config.textContentField &&
236236+ post.rawFrontmatter?.[config.textContentField]
237237+ ) {
238238+ textContent = String(post.rawFrontmatter[config.textContentField]);
239239+ } else {
240240+ textContent = stripMarkdownForText(post.content);
241241+ }
242242+243243+ const record: Record<string, unknown> = {
244244+ $type: "site.standard.document",
245245+ title: post.frontmatter.title,
246246+ site: config.publicationUri,
247247+ path: postPath,
248248+ textContent: textContent.slice(0, 10000),
249249+ publishedAt: publishDate.toISOString(),
250250+ canonicalUrl: `${config.siteUrl}${postPath}`,
251251+ };
176252177177- const record: Record<string, unknown> = {
178178- $type: "site.standard.document",
179179- title: post.frontmatter.title,
180180- site: config.publicationUri,
181181- path: postPath,
182182- textContent: textContent.slice(0, 10000),
183183- publishedAt: publishDate.toISOString(),
184184- canonicalUrl: `${config.siteUrl}${postPath}`,
185185- };
253253+ if (post.frontmatter.description) {
254254+ record.description = post.frontmatter.description;
255255+ }
186256187187- if (coverImage) {
188188- record.coverImage = coverImage;
189189- }
257257+ if (coverImage) {
258258+ record.coverImage = coverImage;
259259+ }
190260191191- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
192192- record.tags = post.frontmatter.tags;
193193- }
261261+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
262262+ record.tags = post.frontmatter.tags;
263263+ }
194264195195- const response = await agent.com.atproto.repo.createRecord({
196196- repo: agent.session!.did,
197197- collection: "site.standard.document",
198198- record,
199199- });
265265+ const response = await agent.com.atproto.repo.createRecord({
266266+ repo: agent.did!,
267267+ collection: "site.standard.document",
268268+ record,
269269+ });
200270201201- return response.data.uri;
271271+ return response.data.uri;
202272}
203273204274export async function updateDocument(
205205- agent: AtpAgent,
206206- post: BlogPost,
207207- atUri: string,
208208- config: PublisherConfig,
209209- coverImage?: BlobObject
275275+ agent: Agent,
276276+ post: BlogPost,
277277+ atUri: string,
278278+ config: PublisherConfig,
279279+ coverImage?: BlobObject,
210280): Promise<void> {
211211- // Parse the atUri to get the collection and rkey
212212- // Format: at://did:plc:xxx/collection/rkey
213213- const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
214214- if (!uriMatch) {
215215- throw new Error(`Invalid atUri format: ${atUri}`);
216216- }
281281+ // Parse the atUri to get the collection and rkey
282282+ // Format: at://did:plc:xxx/collection/rkey
283283+ const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
284284+ if (!uriMatch) {
285285+ throw new Error(`Invalid atUri format: ${atUri}`);
286286+ }
217287218218- const [, , collection, rkey] = uriMatch;
288288+ const [, , collection, rkey] = uriMatch;
219289220220- const pathPrefix = config.pathPrefix || "/posts";
221221- const postPath = `${pathPrefix}/${post.slug}`;
222222- const textContent = stripMarkdownForText(post.content);
223223- const publishDate = new Date(post.frontmatter.publishDate);
290290+ const pathPrefix = config.pathPrefix || "/posts";
291291+ const postPath = `${pathPrefix}/${post.slug}`;
292292+ const publishDate = new Date(post.frontmatter.publishDate);
224293225225- const record: Record<string, unknown> = {
226226- $type: "site.standard.document",
227227- title: post.frontmatter.title,
228228- site: config.publicationUri,
229229- path: postPath,
230230- textContent: textContent.slice(0, 10000),
231231- publishedAt: publishDate.toISOString(),
232232- canonicalUrl: `${config.siteUrl}${postPath}`,
233233- };
294294+ // Determine textContent: use configured field from frontmatter, or fallback to markdown body
295295+ let textContent: string;
296296+ if (
297297+ config.textContentField &&
298298+ post.rawFrontmatter?.[config.textContentField]
299299+ ) {
300300+ textContent = String(post.rawFrontmatter[config.textContentField]);
301301+ } else {
302302+ textContent = stripMarkdownForText(post.content);
303303+ }
234304235235- if (coverImage) {
236236- record.coverImage = coverImage;
237237- }
305305+ const record: Record<string, unknown> = {
306306+ $type: "site.standard.document",
307307+ title: post.frontmatter.title,
308308+ site: config.publicationUri,
309309+ path: postPath,
310310+ textContent: textContent.slice(0, 10000),
311311+ publishedAt: publishDate.toISOString(),
312312+ canonicalUrl: `${config.siteUrl}${postPath}`,
313313+ };
238314239239- if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
240240- record.tags = post.frontmatter.tags;
241241- }
315315+ if (post.frontmatter.description) {
316316+ record.description = post.frontmatter.description;
317317+ }
242318243243- await agent.com.atproto.repo.putRecord({
244244- repo: agent.session!.did,
245245- collection: collection!,
246246- rkey: rkey!,
247247- record,
248248- });
319319+ if (coverImage) {
320320+ record.coverImage = coverImage;
321321+ }
322322+323323+ if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
324324+ record.tags = post.frontmatter.tags;
325325+ }
326326+327327+ await agent.com.atproto.repo.putRecord({
328328+ repo: agent.did!,
329329+ collection: collection!,
330330+ rkey: rkey!,
331331+ record,
332332+ });
249333}
250334251251-export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
252252- const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
253253- if (!match) return null;
254254- return {
255255- did: match[1]!,
256256- collection: match[2]!,
257257- rkey: match[3]!,
258258- };
335335+export function parseAtUri(
336336+ atUri: string,
337337+): { did: string; collection: string; rkey: string } | null {
338338+ const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
339339+ if (!match) return null;
340340+ return {
341341+ did: match[1]!,
342342+ collection: match[2]!,
343343+ rkey: match[3]!,
344344+ };
259345}
260346261347export interface DocumentRecord {
262262- $type: "site.standard.document";
263263- title: string;
264264- site: string;
265265- path: string;
266266- textContent: string;
267267- publishedAt: string;
268268- canonicalUrl?: string;
269269- coverImage?: BlobObject;
270270- tags?: string[];
271271- location?: string;
348348+ $type: "site.standard.document";
349349+ title: string;
350350+ site: string;
351351+ path: string;
352352+ textContent: string;
353353+ publishedAt: string;
354354+ canonicalUrl?: string;
355355+ description?: string;
356356+ coverImage?: BlobObject;
357357+ tags?: string[];
358358+ location?: string;
272359}
273360274361export interface ListDocumentsResult {
275275- uri: string;
276276- cid: string;
277277- value: DocumentRecord;
362362+ uri: string;
363363+ cid: string;
364364+ value: DocumentRecord;
278365}
279366280367export async function listDocuments(
281281- agent: AtpAgent,
282282- publicationUri?: string
368368+ agent: Agent,
369369+ publicationUri?: string,
283370): Promise<ListDocumentsResult[]> {
284284- const documents: ListDocumentsResult[] = [];
285285- let cursor: string | undefined;
371371+ const documents: ListDocumentsResult[] = [];
372372+ let cursor: string | undefined;
286373287287- do {
288288- const response = await agent.com.atproto.repo.listRecords({
289289- repo: agent.session!.did,
290290- collection: "site.standard.document",
291291- limit: 100,
292292- cursor,
293293- });
374374+ do {
375375+ const response = await agent.com.atproto.repo.listRecords({
376376+ repo: agent.did!,
377377+ collection: "site.standard.document",
378378+ limit: 100,
379379+ cursor,
380380+ });
294381295295- for (const record of response.data.records) {
296296- const value = record.value as unknown as DocumentRecord;
382382+ for (const record of response.data.records) {
383383+ if (!isDocumentRecord(record.value)) {
384384+ continue;
385385+ }
297386298298- // If publicationUri is specified, only include documents from that publication
299299- if (publicationUri && value.site !== publicationUri) {
300300- continue;
301301- }
387387+ // If publicationUri is specified, only include documents from that publication
388388+ if (publicationUri && record.value.site !== publicationUri) {
389389+ continue;
390390+ }
302391303303- documents.push({
304304- uri: record.uri,
305305- cid: record.cid,
306306- value,
307307- });
308308- }
392392+ documents.push({
393393+ uri: record.uri,
394394+ cid: record.cid,
395395+ value: record.value,
396396+ });
397397+ }
309398310310- cursor = response.data.cursor;
311311- } while (cursor);
399399+ cursor = response.data.cursor;
400400+ } while (cursor);
312401313313- return documents;
402402+ return documents;
314403}
315404316405export async function createPublication(
317317- agent: AtpAgent,
318318- options: CreatePublicationOptions
406406+ agent: Agent,
407407+ options: CreatePublicationOptions,
319408): Promise<string> {
320320- let icon: BlobObject | undefined;
409409+ let icon: BlobObject | undefined;
321410322322- if (options.iconPath) {
323323- icon = await uploadImage(agent, options.iconPath);
324324- }
411411+ if (options.iconPath) {
412412+ icon = await uploadImage(agent, options.iconPath);
413413+ }
325414326326- const record: Record<string, unknown> = {
327327- $type: "site.standard.publication",
328328- url: options.url,
329329- name: options.name,
330330- createdAt: new Date().toISOString(),
331331- };
415415+ const record: Record<string, unknown> = {
416416+ $type: "site.standard.publication",
417417+ url: options.url,
418418+ name: options.name,
419419+ createdAt: new Date().toISOString(),
420420+ };
332421333333- if (options.description) {
334334- record.description = options.description;
335335- }
422422+ if (options.description) {
423423+ record.description = options.description;
424424+ }
336425337337- if (icon) {
338338- record.icon = icon;
339339- }
426426+ if (icon) {
427427+ record.icon = icon;
428428+ }
340429341341- if (options.showInDiscover !== undefined) {
342342- record.preferences = {
343343- showInDiscover: options.showInDiscover,
344344- };
345345- }
430430+ if (options.showInDiscover !== undefined) {
431431+ record.preferences = {
432432+ showInDiscover: options.showInDiscover,
433433+ };
434434+ }
346435347347- const response = await agent.com.atproto.repo.createRecord({
348348- repo: agent.session!.did,
349349- collection: "site.standard.publication",
350350- record,
351351- });
436436+ const response = await agent.com.atproto.repo.createRecord({
437437+ repo: agent.did!,
438438+ collection: "site.standard.publication",
439439+ record,
440440+ });
352441353353- return response.data.uri;
442442+ return response.data.uri;
354443}
355444356445// --- Bluesky Post Creation ---
357446358447export interface CreateBlueskyPostOptions {
359359- title: string;
360360- description?: string;
361361- canonicalUrl: string;
362362- coverImage?: BlobObject;
363363- publishedAt: string; // Used as createdAt for the post
448448+ title: string;
449449+ description?: string;
450450+ canonicalUrl: string;
451451+ coverImage?: BlobObject;
452452+ publishedAt: string; // Used as createdAt for the post
364453}
365454366455/**
367456 * Count graphemes in a string (for Bluesky's 300 grapheme limit)
368457 */
369458function countGraphemes(str: string): number {
370370- // Use Intl.Segmenter if available, otherwise fallback to spread operator
371371- if (typeof Intl !== "undefined" && Intl.Segmenter) {
372372- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
373373- return [...segmenter.segment(str)].length;
374374- }
375375- return [...str].length;
459459+ // Use Intl.Segmenter if available, otherwise fallback to spread operator
460460+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
461461+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
462462+ return [...segmenter.segment(str)].length;
463463+ }
464464+ return [...str].length;
376465}
377466378467/**
379468 * Truncate a string to a maximum number of graphemes
380469 */
381470function truncateToGraphemes(str: string, maxGraphemes: number): string {
382382- if (typeof Intl !== "undefined" && Intl.Segmenter) {
383383- const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
384384- const segments = [...segmenter.segment(str)];
385385- if (segments.length <= maxGraphemes) return str;
386386- return segments.slice(0, maxGraphemes - 3).map(s => s.segment).join("") + "...";
387387- }
388388- // Fallback
389389- const chars = [...str];
390390- if (chars.length <= maxGraphemes) return str;
391391- return chars.slice(0, maxGraphemes - 3).join("") + "...";
471471+ if (typeof Intl !== "undefined" && Intl.Segmenter) {
472472+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
473473+ const segments = [...segmenter.segment(str)];
474474+ if (segments.length <= maxGraphemes) return str;
475475+ return `${segments
476476+ .slice(0, maxGraphemes - 3)
477477+ .map((s) => s.segment)
478478+ .join("")}...`;
479479+ }
480480+ // Fallback
481481+ const chars = [...str];
482482+ if (chars.length <= maxGraphemes) return str;
483483+ return `${chars.slice(0, maxGraphemes - 3).join("")}...`;
392484}
393485394486/**
395487 * Create a Bluesky post with external link embed
396488 */
397489export async function createBlueskyPost(
398398- agent: AtpAgent,
399399- options: CreateBlueskyPostOptions
490490+ agent: Agent,
491491+ options: CreateBlueskyPostOptions,
400492): Promise<StrongRef> {
401401- const { title, description, canonicalUrl, coverImage, publishedAt } = options;
493493+ const { title, description, canonicalUrl, coverImage, publishedAt } = options;
402494403403- // Build post text: title + description + URL
404404- // Max 300 graphemes for Bluesky posts
405405- const MAX_GRAPHEMES = 300;
495495+ // Build post text: title + description + URL
496496+ // Max 300 graphemes for Bluesky posts
497497+ const MAX_GRAPHEMES = 300;
406498407407- let postText: string;
408408- const urlPart = `\n\n${canonicalUrl}`;
409409- const urlGraphemes = countGraphemes(urlPart);
499499+ let postText: string;
500500+ const urlPart = `\n\n${canonicalUrl}`;
501501+ const urlGraphemes = countGraphemes(urlPart);
410502411411- if (description) {
412412- // Try: title + description + URL
413413- const fullText = `${title}\n\n${description}${urlPart}`;
414414- if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
415415- postText = fullText;
416416- } else {
417417- // Truncate description to fit
418418- const availableForDesc = MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n") - urlGraphemes - countGraphemes("\n\n");
419419- if (availableForDesc > 10) {
420420- const truncatedDesc = truncateToGraphemes(description, availableForDesc);
421421- postText = `${title}\n\n${truncatedDesc}${urlPart}`;
422422- } else {
423423- // Just title + URL
424424- postText = `${title}${urlPart}`;
425425- }
426426- }
427427- } else {
428428- // Just title + URL
429429- postText = `${title}${urlPart}`;
430430- }
503503+ if (description) {
504504+ // Try: title + description + URL
505505+ const fullText = `${title}\n\n${description}${urlPart}`;
506506+ if (countGraphemes(fullText) <= MAX_GRAPHEMES) {
507507+ postText = fullText;
508508+ } else {
509509+ // Truncate description to fit
510510+ const availableForDesc =
511511+ MAX_GRAPHEMES -
512512+ countGraphemes(title) -
513513+ countGraphemes("\n\n") -
514514+ urlGraphemes -
515515+ countGraphemes("\n\n");
516516+ if (availableForDesc > 10) {
517517+ const truncatedDesc = truncateToGraphemes(
518518+ description,
519519+ availableForDesc,
520520+ );
521521+ postText = `${title}\n\n${truncatedDesc}${urlPart}`;
522522+ } else {
523523+ // Just title + URL
524524+ postText = `${title}${urlPart}`;
525525+ }
526526+ }
527527+ } else {
528528+ // Just title + URL
529529+ postText = `${title}${urlPart}`;
530530+ }
431531432432- // Final truncation if still too long (shouldn't happen but safety check)
433433- if (countGraphemes(postText) > MAX_GRAPHEMES) {
434434- postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
435435- }
532532+ // Final truncation if still too long (shouldn't happen but safety check)
533533+ if (countGraphemes(postText) > MAX_GRAPHEMES) {
534534+ postText = truncateToGraphemes(postText, MAX_GRAPHEMES);
535535+ }
436536437437- // Calculate byte indices for the URL facet
438438- const encoder = new TextEncoder();
439439- const urlStartInText = postText.lastIndexOf(canonicalUrl);
440440- const beforeUrl = postText.substring(0, urlStartInText);
441441- const byteStart = encoder.encode(beforeUrl).length;
442442- const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
537537+ // Calculate byte indices for the URL facet
538538+ const encoder = new TextEncoder();
539539+ const urlStartInText = postText.lastIndexOf(canonicalUrl);
540540+ const beforeUrl = postText.substring(0, urlStartInText);
541541+ const byteStart = encoder.encode(beforeUrl).length;
542542+ const byteEnd = byteStart + encoder.encode(canonicalUrl).length;
443543444444- // Build facets for the URL link
445445- const facets = [
446446- {
447447- index: {
448448- byteStart,
449449- byteEnd,
450450- },
451451- features: [
452452- {
453453- $type: "app.bsky.richtext.facet#link",
454454- uri: canonicalUrl,
455455- },
456456- ],
457457- },
458458- ];
544544+ // Build facets for the URL link
545545+ const facets = [
546546+ {
547547+ index: {
548548+ byteStart,
549549+ byteEnd,
550550+ },
551551+ features: [
552552+ {
553553+ $type: "app.bsky.richtext.facet#link",
554554+ uri: canonicalUrl,
555555+ },
556556+ ],
557557+ },
558558+ ];
459559460460- // Build external embed
461461- const embed: Record<string, unknown> = {
462462- $type: "app.bsky.embed.external",
463463- external: {
464464- uri: canonicalUrl,
465465- title: title.substring(0, 500), // Max 500 chars for title
466466- description: (description || "").substring(0, 1000), // Max 1000 chars for description
467467- },
468468- };
560560+ // Build external embed
561561+ const embed: Record<string, unknown> = {
562562+ $type: "app.bsky.embed.external",
563563+ external: {
564564+ uri: canonicalUrl,
565565+ title: title.substring(0, 500), // Max 500 chars for title
566566+ description: (description || "").substring(0, 1000), // Max 1000 chars for description
567567+ },
568568+ };
469569470470- // Add thumbnail if coverImage is available
471471- if (coverImage) {
472472- (embed.external as Record<string, unknown>).thumb = coverImage;
473473- }
570570+ // Add thumbnail if coverImage is available
571571+ if (coverImage) {
572572+ (embed.external as Record<string, unknown>).thumb = coverImage;
573573+ }
474574475475- // Create the post record
476476- const record: Record<string, unknown> = {
477477- $type: "app.bsky.feed.post",
478478- text: postText,
479479- facets,
480480- embed,
481481- createdAt: new Date(publishedAt).toISOString(),
482482- };
575575+ // Create the post record
576576+ const record: Record<string, unknown> = {
577577+ $type: "app.bsky.feed.post",
578578+ text: postText,
579579+ facets,
580580+ embed,
581581+ createdAt: new Date(publishedAt).toISOString(),
582582+ };
483583484484- const response = await agent.com.atproto.repo.createRecord({
485485- repo: agent.session!.did,
486486- collection: "app.bsky.feed.post",
487487- record,
488488- });
584584+ const response = await agent.com.atproto.repo.createRecord({
585585+ repo: agent.did!,
586586+ collection: "app.bsky.feed.post",
587587+ record,
588588+ });
489589490490- return {
491491- uri: response.data.uri,
492492- cid: response.data.cid,
493493- };
590590+ return {
591591+ uri: response.data.uri,
592592+ cid: response.data.cid,
593593+ };
494594}
495595496596/**
497597 * Add bskyPostRef to an existing document record
498598 */
499599export async function addBskyPostRefToDocument(
500500- agent: AtpAgent,
501501- documentAtUri: string,
502502- bskyPostRef: StrongRef
600600+ agent: Agent,
601601+ documentAtUri: string,
602602+ bskyPostRef: StrongRef,
503603): Promise<void> {
504504- const parsed = parseAtUri(documentAtUri);
505505- if (!parsed) {
506506- throw new Error(`Invalid document URI: ${documentAtUri}`);
507507- }
604604+ const parsed = parseAtUri(documentAtUri);
605605+ if (!parsed) {
606606+ throw new Error(`Invalid document URI: ${documentAtUri}`);
607607+ }
508608509509- // Fetch existing record
510510- const existingRecord = await agent.com.atproto.repo.getRecord({
511511- repo: parsed.did,
512512- collection: parsed.collection,
513513- rkey: parsed.rkey,
514514- });
609609+ // Fetch existing record
610610+ const existingRecord = await agent.com.atproto.repo.getRecord({
611611+ repo: parsed.did,
612612+ collection: parsed.collection,
613613+ rkey: parsed.rkey,
614614+ });
515615516516- // Add bskyPostRef to the record
517517- const updatedRecord = {
518518- ...(existingRecord.data.value as Record<string, unknown>),
519519- bskyPostRef,
520520- };
616616+ // Add bskyPostRef to the record
617617+ const updatedRecord = {
618618+ ...(existingRecord.data.value as Record<string, unknown>),
619619+ bskyPostRef,
620620+ };
521621522522- // Update the record
523523- await agent.com.atproto.repo.putRecord({
524524- repo: parsed.did,
525525- collection: parsed.collection,
526526- rkey: parsed.rkey,
527527- record: updatedRecord,
528528- });
622622+ // Update the record
623623+ await agent.com.atproto.repo.putRecord({
624624+ repo: parsed.did,
625625+ collection: parsed.collection,
626626+ rkey: parsed.rkey,
627627+ record: updatedRecord,
628628+ });
529629}
+17-3
packages/cli/src/lib/config.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
33-import type { PublisherConfig, PublisherState, FrontmatterMapping, BlueskyConfig } from "./types";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33+import type {
44+ PublisherConfig,
55+ PublisherState,
66+ FrontmatterMapping,
77+ BlueskyConfig,
88+} from "./types";
49510const CONFIG_FILENAME = "sequoia.json";
611const STATE_FILENAME = ".sequoia-state.json";
···7681 pdsUrl?: string;
7782 frontmatter?: FrontmatterMapping;
7883 ignore?: string[];
8484+ removeIndexFromSlug?: boolean;
8585+ textContentField?: string;
7986 bluesky?: BlueskyConfig;
8087}): string {
8188 const config: Record<string, unknown> = {
···113120 config.ignore = options.ignore;
114121 }
115122123123+ if (options.removeIndexFromSlug) {
124124+ config.removeIndexFromSlug = options.removeIndexFromSlug;
125125+ }
126126+127127+ if (options.textContentField) {
128128+ config.textContentField = options.textContentField;
129129+ }
116130 if (options.bluesky) {
117131 config.bluesky = options.bluesky;
118132 }
+204-102
packages/cli/src/lib/credentials.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
33-import * as os from "os";
44-import type { Credentials } from "./types";
11+import * as fs from "node:fs/promises";
22+import * as os from "node:os";
33+import * as path from "node:path";
44+import { getOAuthSession, listOAuthSessions } from "./oauth-store";
55+import type {
66+ AppPasswordCredentials,
77+ Credentials,
88+ LegacyCredentials,
99+ OAuthCredentials,
1010+} from "./types";
511612const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
713const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
81499-// Stored credentials keyed by identifier
1010-type CredentialsStore = Record<string, Credentials>;
1515+// Stored credentials keyed by identifier (can be legacy or typed)
1616+type CredentialsStore = Record<
1717+ string,
1818+ AppPasswordCredentials | LegacyCredentials
1919+>;
11201221async function fileExists(filePath: string): Promise<boolean> {
1313- try {
1414- await fs.access(filePath);
1515- return true;
1616- } catch {
1717- return false;
1818- }
2222+ try {
2323+ await fs.access(filePath);
2424+ return true;
2525+ } catch {
2626+ return false;
2727+ }
1928}
20292130/**
2222- * Load all stored credentials
3131+ * Normalize credentials to have explicit type
2332 */
3333+function normalizeCredentials(
3434+ creds: AppPasswordCredentials | LegacyCredentials,
3535+): AppPasswordCredentials {
3636+ // If it already has type, return as-is
3737+ if ("type" in creds && creds.type === "app-password") {
3838+ return creds;
3939+ }
4040+ // Migrate legacy format
4141+ return {
4242+ type: "app-password",
4343+ pdsUrl: creds.pdsUrl,
4444+ identifier: creds.identifier,
4545+ password: creds.password,
4646+ };
4747+}
4848+2449async function loadCredentialsStore(): Promise<CredentialsStore> {
2525- if (!(await fileExists(CREDENTIALS_FILE))) {
2626- return {};
2727- }
5050+ if (!(await fileExists(CREDENTIALS_FILE))) {
5151+ return {};
5252+ }
28532929- try {
3030- const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
3131- const parsed = JSON.parse(content);
5454+ try {
5555+ const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
5656+ const parsed = JSON.parse(content);
32573333- // Handle legacy single-credential format (migrate on read)
3434- if (parsed.identifier && parsed.password) {
3535- const legacy = parsed as Credentials;
3636- return { [legacy.identifier]: legacy };
3737- }
5858+ // Handle legacy single-credential format (migrate on read)
5959+ if (parsed.identifier && parsed.password) {
6060+ const legacy = parsed as LegacyCredentials;
6161+ return { [legacy.identifier]: legacy };
6262+ }
38633939- return parsed as CredentialsStore;
4040- } catch {
4141- return {};
4242- }
6464+ return parsed as CredentialsStore;
6565+ } catch {
6666+ return {};
6767+ }
4368}
44694570/**
4671 * Save the entire credentials store
4772 */
4873async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
4949- await fs.mkdir(CONFIG_DIR, { recursive: true });
5050- await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
5151- await fs.chmod(CREDENTIALS_FILE, 0o600);
7474+ await fs.mkdir(CONFIG_DIR, { recursive: true });
7575+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
7676+ await fs.chmod(CREDENTIALS_FILE, 0o600);
7777+}
7878+7979+/**
8080+ * Try to load OAuth credentials for a given profile (DID or handle)
8181+ */
8282+async function tryLoadOAuthCredentials(
8383+ profile: string,
8484+): Promise<OAuthCredentials | null> {
8585+ // If it looks like a DID, try to get the session directly
8686+ if (profile.startsWith("did:")) {
8787+ const session = await getOAuthSession(profile);
8888+ if (session) {
8989+ return {
9090+ type: "oauth",
9191+ did: profile,
9292+ handle: profile, // We don't have the handle stored, use DID
9393+ pdsUrl: "https://bsky.social", // Will be resolved from DID doc
9494+ };
9595+ }
9696+ }
9797+9898+ // Otherwise, we would need to check all OAuth sessions to find a matching handle,
9999+ // but handle matching isn't perfect without storing handles alongside sessions.
100100+ // For now, just return null if profile isn't a DID.
101101+ return null;
52102}
5310354104/**
···56106 *
57107 * Priority:
58108 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD)
5959- * 2. SEQUOIA_PROFILE env var - selects from stored credentials
109109+ * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID)
60110 * 3. projectIdentity parameter (from sequoia.json)
6161- * 4. If only one identity stored, use it
111111+ * 4. If only one identity stored (app-password or OAuth), use it
62112 * 5. Return null (caller should prompt user)
63113 */
64114export async function loadCredentials(
6565- projectIdentity?: string
115115+ projectIdentity?: string,
66116): Promise<Credentials | null> {
6767- // 1. Check environment variables first (full override)
6868- const envIdentifier = process.env.ATP_IDENTIFIER;
6969- const envPassword = process.env.ATP_APP_PASSWORD;
7070- const envPdsUrl = process.env.PDS_URL;
7171-7272- if (envIdentifier && envPassword) {
7373- return {
7474- identifier: envIdentifier,
7575- password: envPassword,
7676- pdsUrl: envPdsUrl || "https://bsky.social",
7777- };
7878- }
117117+ // 1. Check environment variables first (full override)
118118+ const envIdentifier = process.env.ATP_IDENTIFIER;
119119+ const envPassword = process.env.ATP_APP_PASSWORD;
120120+ const envPdsUrl = process.env.PDS_URL;
791218080- const store = await loadCredentialsStore();
8181- const identifiers = Object.keys(store);
122122+ if (envIdentifier && envPassword) {
123123+ return {
124124+ type: "app-password",
125125+ identifier: envIdentifier,
126126+ password: envPassword,
127127+ pdsUrl: envPdsUrl || "https://bsky.social",
128128+ };
129129+ }
821308383- if (identifiers.length === 0) {
8484- return null;
8585- }
131131+ const store = await loadCredentialsStore();
132132+ const appPasswordIds = Object.keys(store);
133133+ const oauthDids = await listOAuthSessions();
861348787- // 2. SEQUOIA_PROFILE env var
8888- const profileEnv = process.env.SEQUOIA_PROFILE;
8989- if (profileEnv && store[profileEnv]) {
9090- return store[profileEnv];
9191- }
135135+ // 2. SEQUOIA_PROFILE env var
136136+ const profileEnv = process.env.SEQUOIA_PROFILE;
137137+ if (profileEnv) {
138138+ // Try app-password credentials first
139139+ if (store[profileEnv]) {
140140+ return normalizeCredentials(store[profileEnv]);
141141+ }
142142+ // Try OAuth session (profile could be a DID)
143143+ const oauth = await tryLoadOAuthCredentials(profileEnv);
144144+ if (oauth) {
145145+ return oauth;
146146+ }
147147+ }
921489393- // 3. Project-specific identity (from sequoia.json)
9494- if (projectIdentity && store[projectIdentity]) {
9595- return store[projectIdentity];
9696- }
149149+ // 3. Project-specific identity (from sequoia.json)
150150+ if (projectIdentity) {
151151+ if (store[projectIdentity]) {
152152+ return normalizeCredentials(store[projectIdentity]);
153153+ }
154154+ const oauth = await tryLoadOAuthCredentials(projectIdentity);
155155+ if (oauth) {
156156+ return oauth;
157157+ }
158158+ }
971599898- // 4. If only one identity, use it
9999- if (identifiers.length === 1 && identifiers[0]) {
100100- return store[identifiers[0]] ?? null;
101101- }
160160+ // 4. If only one identity total, use it
161161+ const totalIdentities = appPasswordIds.length + oauthDids.length;
162162+ if (totalIdentities === 1) {
163163+ if (appPasswordIds.length === 1 && appPasswordIds[0]) {
164164+ return normalizeCredentials(store[appPasswordIds[0]]!);
165165+ }
166166+ if (oauthDids.length === 1 && oauthDids[0]) {
167167+ const session = await getOAuthSession(oauthDids[0]);
168168+ if (session) {
169169+ return {
170170+ type: "oauth",
171171+ did: oauthDids[0],
172172+ handle: oauthDids[0],
173173+ pdsUrl: "https://bsky.social",
174174+ };
175175+ }
176176+ }
177177+ }
102178103103- // Multiple identities exist but none selected
104104- return null;
179179+ // Multiple identities exist but none selected, or no identities
180180+ return null;
105181}
106182107183/**
108108- * Get a specific identity by identifier
184184+ * Get a specific identity by identifier (app-password only)
109185 */
110186export async function getCredentials(
111111- identifier: string
112112-): Promise<Credentials | null> {
113113- const store = await loadCredentialsStore();
114114- return store[identifier] || null;
187187+ identifier: string,
188188+): Promise<AppPasswordCredentials | null> {
189189+ const store = await loadCredentialsStore();
190190+ const creds = store[identifier];
191191+ if (!creds) return null;
192192+ return normalizeCredentials(creds);
115193}
116194117195/**
118118- * List all stored identities
196196+ * List all stored app-password identities
119197 */
120198export async function listCredentials(): Promise<string[]> {
121121- const store = await loadCredentialsStore();
122122- return Object.keys(store);
199199+ const store = await loadCredentialsStore();
200200+ return Object.keys(store);
201201+}
202202+203203+/**
204204+ * List all credentials (both app-password and OAuth)
205205+ */
206206+export async function listAllCredentials(): Promise<
207207+ Array<{ id: string; type: "app-password" | "oauth" }>
208208+> {
209209+ const store = await loadCredentialsStore();
210210+ const oauthDids = await listOAuthSessions();
211211+212212+ const result: Array<{ id: string; type: "app-password" | "oauth" }> = [];
213213+214214+ for (const id of Object.keys(store)) {
215215+ result.push({ id, type: "app-password" });
216216+ }
217217+218218+ for (const did of oauthDids) {
219219+ result.push({ id: did, type: "oauth" });
220220+ }
221221+222222+ return result;
123223}
124224125225/**
126126- * Save credentials for an identity (adds or updates)
226226+ * Save app-password credentials for an identity (adds or updates)
127227 */
128128-export async function saveCredentials(credentials: Credentials): Promise<void> {
129129- const store = await loadCredentialsStore();
130130- store[credentials.identifier] = credentials;
131131- await saveCredentialsStore(store);
228228+export async function saveCredentials(
229229+ credentials: AppPasswordCredentials,
230230+): Promise<void> {
231231+ const store = await loadCredentialsStore();
232232+ store[credentials.identifier] = credentials;
233233+ await saveCredentialsStore(store);
132234}
133235134236/**
135237 * Delete credentials for a specific identity
136238 */
137239export async function deleteCredentials(identifier?: string): Promise<boolean> {
138138- const store = await loadCredentialsStore();
139139- const identifiers = Object.keys(store);
240240+ const store = await loadCredentialsStore();
241241+ const identifiers = Object.keys(store);
140242141141- if (identifiers.length === 0) {
142142- return false;
143143- }
243243+ if (identifiers.length === 0) {
244244+ return false;
245245+ }
144246145145- // If identifier specified, delete just that one
146146- if (identifier) {
147147- if (!store[identifier]) {
148148- return false;
149149- }
150150- delete store[identifier];
151151- await saveCredentialsStore(store);
152152- return true;
153153- }
247247+ // If identifier specified, delete just that one
248248+ if (identifier) {
249249+ if (!store[identifier]) {
250250+ return false;
251251+ }
252252+ delete store[identifier];
253253+ await saveCredentialsStore(store);
254254+ return true;
255255+ }
154256155155- // If only one identity, delete it (backwards compat behavior)
156156- if (identifiers.length === 1 && identifiers[0]) {
157157- delete store[identifiers[0]];
158158- await saveCredentialsStore(store);
159159- return true;
160160- }
257257+ // If only one identity, delete it (backwards compat behavior)
258258+ if (identifiers.length === 1 && identifiers[0]) {
259259+ delete store[identifiers[0]];
260260+ await saveCredentialsStore(store);
261261+ return true;
262262+ }
161263162162- // Multiple identities but none specified
163163- return false;
264264+ // Multiple identities but none specified
265265+ return false;
164266}
165267166268export function getCredentialsPath(): string {
167167- return CREDENTIALS_FILE;
269269+ return CREDENTIALS_FILE;
168270}
+323-176
packages/cli/src/lib/markdown.ts
···11-import * as fs from "fs/promises";
22-import * as path from "path";
11+import * as fs from "node:fs/promises";
22+import * as path from "node:path";
33import { glob } from "glob";
44import { minimatch } from "minimatch";
55-import type { PostFrontmatter, BlogPost, FrontmatterMapping } from "./types";
55+import type { BlogPost, FrontmatterMapping, PostFrontmatter } from "./types";
6677-export function parseFrontmatter(content: string, mapping?: FrontmatterMapping): {
88- frontmatter: PostFrontmatter;
99- body: string;
77+export function parseFrontmatter(
88+ content: string,
99+ mapping?: FrontmatterMapping,
1010+): {
1111+ frontmatter: PostFrontmatter;
1212+ body: string;
1313+ rawFrontmatter: Record<string, unknown>;
1014} {
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);
1515+ // Support multiple frontmatter delimiters:
1616+ // --- (YAML) - Jekyll, Astro, most SSGs
1717+ // +++ (TOML) - Hugo
1818+ // *** - Alternative format
1919+ const frontmatterRegex = /^(---|\+\+\+|\*\*\*)\n([\s\S]*?)\n\1\n([\s\S]*)$/;
2020+ const match = content.match(frontmatterRegex);
17211818- if (!match) {
1919- throw new Error("Could not parse frontmatter");
2020- }
2222+ if (!match) {
2323+ throw new Error("Could not parse frontmatter");
2424+ }
21252222- const delimiter = match[1];
2323- const frontmatterStr = match[2] ?? "";
2424- const body = match[3] ?? "";
2626+ const delimiter = match[1];
2727+ const frontmatterStr = match[2] ?? "";
2828+ const body = match[3] ?? "";
25292626- // Determine format based on delimiter:
2727- // +++ uses TOML (key = value)
2828- // --- and *** use YAML (key: value)
2929- const isToml = delimiter === "+++";
3030- const separator = isToml ? "=" : ":";
3030+ // Determine format based on delimiter:
3131+ // +++ uses TOML (key = value)
3232+ // --- and *** use YAML (key: value)
3333+ const isToml = delimiter === "+++";
3434+ const separator = isToml ? "=" : ":";
31353232- // Parse frontmatter manually
3333- const raw: Record<string, unknown> = {};
3434- const lines = frontmatterStr.split("\n");
3636+ // Parse frontmatter manually
3737+ const raw: Record<string, unknown> = {};
3838+ const lines = frontmatterStr.split("\n");
35393636- for (const line of lines) {
3737- const sepIndex = line.indexOf(separator);
3838- if (sepIndex === -1) continue;
4040+ let i = 0;
4141+ while (i < lines.length) {
4242+ const line = lines[i];
4343+ if (line === undefined) {
4444+ i++;
4545+ continue;
4646+ }
4747+ const sepIndex = line.indexOf(separator);
4848+ if (sepIndex === -1) {
4949+ i++;
5050+ continue;
5151+ }
39524040- const key = line.slice(0, sepIndex).trim();
4141- let value = line.slice(sepIndex + 1).trim();
5353+ const key = line.slice(0, sepIndex).trim();
5454+ let value = line.slice(sepIndex + 1).trim();
42554343- // Handle quoted strings
4444- if (
4545- (value.startsWith('"') && value.endsWith('"')) ||
4646- (value.startsWith("'") && value.endsWith("'"))
4747- ) {
4848- value = value.slice(1, -1);
4949- }
5656+ // Handle quoted strings
5757+ if (
5858+ (value.startsWith('"') && value.endsWith('"')) ||
5959+ (value.startsWith("'") && value.endsWith("'"))
6060+ ) {
6161+ value = value.slice(1, -1);
6262+ }
50635151- // 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- }
6464+ // Handle inline arrays (simple case for tags)
6565+ if (value.startsWith("[") && value.endsWith("]")) {
6666+ const arrayContent = value.slice(1, -1);
6767+ raw[key] = arrayContent
6868+ .split(",")
6969+ .map((item) => item.trim().replace(/^["']|["']$/g, ""));
7070+ } else if (value === "" && !isToml) {
7171+ // Check for YAML-style multiline array (key with no value followed by - items)
7272+ const arrayItems: string[] = [];
7373+ let j = i + 1;
7474+ while (j < lines.length) {
7575+ const nextLine = lines[j];
7676+ if (nextLine === undefined) {
7777+ j++;
7878+ continue;
7979+ }
8080+ // Check if line is a list item (starts with whitespace and -)
8181+ const listMatch = nextLine.match(/^\s+-\s*(.*)$/);
8282+ if (listMatch && listMatch[1] !== undefined) {
8383+ let itemValue = listMatch[1].trim();
8484+ // Remove quotes if present
8585+ if (
8686+ (itemValue.startsWith('"') && itemValue.endsWith('"')) ||
8787+ (itemValue.startsWith("'") && itemValue.endsWith("'"))
8888+ ) {
8989+ itemValue = itemValue.slice(1, -1);
9090+ }
9191+ arrayItems.push(itemValue);
9292+ j++;
9393+ } else if (nextLine.trim() === "") {
9494+ // Skip empty lines within the array
9595+ j++;
9696+ } else {
9797+ // Hit a new key or non-list content
9898+ break;
9999+ }
100100+ }
101101+ if (arrayItems.length > 0) {
102102+ raw[key] = arrayItems;
103103+ i = j;
104104+ continue;
105105+ } else {
106106+ raw[key] = value;
107107+ }
108108+ } else if (value === "true") {
109109+ raw[key] = true;
110110+ } else if (value === "false") {
111111+ raw[key] = false;
112112+ } else {
113113+ raw[key] = value;
114114+ }
115115+ i++;
116116+ }
651176666- // Apply field mappings to normalize to standard PostFrontmatter fields
6767- const frontmatter: Record<string, unknown> = {};
118118+ // Apply field mappings to normalize to standard PostFrontmatter fields
119119+ const frontmatter: Record<string, unknown> = {};
681206969- // Title mapping
7070- const titleField = mapping?.title || "title";
7171- frontmatter.title = raw[titleField] || raw.title;
121121+ // Title mapping
122122+ const titleField = mapping?.title || "title";
123123+ frontmatter.title = raw[titleField] || raw.title;
721247373- // Description mapping
7474- const descField = mapping?.description || "description";
7575- frontmatter.description = raw[descField] || raw.description;
125125+ // Description mapping
126126+ const descField = mapping?.description || "description";
127127+ frontmatter.description = raw[descField] || raw.description;
761287777- // 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- }
129129+ // Publish date mapping - check custom field first, then fallbacks
130130+ const dateField = mapping?.publishDate;
131131+ if (dateField && raw[dateField]) {
132132+ frontmatter.publishDate = raw[dateField];
133133+ } else if (raw.publishDate) {
134134+ frontmatter.publishDate = raw.publishDate;
135135+ } else {
136136+ // Fallback to common date field names
137137+ const dateFields = ["pubDate", "date", "createdAt", "created_at"];
138138+ for (const field of dateFields) {
139139+ if (raw[field]) {
140140+ frontmatter.publishDate = raw[field];
141141+ break;
142142+ }
143143+ }
144144+ }
931459494- // Cover image mapping
9595- const coverField = mapping?.coverImage || "ogImage";
9696- frontmatter.ogImage = raw[coverField] || raw.ogImage;
146146+ // Cover image mapping
147147+ const coverField = mapping?.coverImage || "ogImage";
148148+ frontmatter.ogImage = raw[coverField] || raw.ogImage;
971499898- // Tags mapping
9999- const tagsField = mapping?.tags || "tags";
100100- frontmatter.tags = raw[tagsField] || raw.tags;
150150+ // Tags mapping
151151+ const tagsField = mapping?.tags || "tags";
152152+ frontmatter.tags = raw[tagsField] || raw.tags;
101153102102- // 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- }
154154+ // Draft mapping
155155+ const draftField = mapping?.draft || "draft";
156156+ const draftValue = raw[draftField] ?? raw.draft;
157157+ if (draftValue !== undefined) {
158158+ frontmatter.draft = draftValue === true || draftValue === "true";
159159+ }
108160109109- // Always preserve atUri (internal field)
110110- frontmatter.atUri = raw.atUri;
161161+ // Always preserve atUri (internal field)
162162+ frontmatter.atUri = raw.atUri;
111163112112- return { frontmatter: frontmatter as unknown as PostFrontmatter, body };
164164+ return {
165165+ frontmatter: frontmatter as unknown as PostFrontmatter,
166166+ body,
167167+ rawFrontmatter: raw,
168168+ };
113169}
114170115171export function getSlugFromFilename(filename: string): string {
116116- return filename
117117- .replace(/\.mdx?$/, "")
118118- .toLowerCase()
119119- .replace(/\s+/g, "-");
172172+ return filename
173173+ .replace(/\.mdx?$/, "")
174174+ .toLowerCase()
175175+ .replace(/\s+/g, "-");
176176+}
177177+178178+export interface SlugOptions {
179179+ slugField?: string;
180180+ removeIndexFromSlug?: boolean;
181181+}
182182+183183+export function getSlugFromOptions(
184184+ relativePath: string,
185185+ rawFrontmatter: Record<string, unknown>,
186186+ options: SlugOptions = {},
187187+): string {
188188+ const { slugField, removeIndexFromSlug = false } = options;
189189+190190+ let slug: string;
191191+192192+ // If slugField is set, try to get the value from frontmatter
193193+ if (slugField) {
194194+ const frontmatterValue = rawFrontmatter[slugField];
195195+ if (frontmatterValue && typeof frontmatterValue === "string") {
196196+ // Remove leading slash if present
197197+ slug = frontmatterValue
198198+ .replace(/^\//, "")
199199+ .toLowerCase()
200200+ .replace(/\s+/g, "-");
201201+ } else {
202202+ // Fallback to filepath if frontmatter field not found
203203+ slug = relativePath
204204+ .replace(/\.mdx?$/, "")
205205+ .toLowerCase()
206206+ .replace(/\s+/g, "-");
207207+ }
208208+ } else {
209209+ // Default: use filepath
210210+ slug = relativePath
211211+ .replace(/\.mdx?$/, "")
212212+ .toLowerCase()
213213+ .replace(/\s+/g, "-");
214214+ }
215215+216216+ // Remove /index or /_index suffix if configured
217217+ if (removeIndexFromSlug) {
218218+ slug = slug.replace(/\/_?index$/, "");
219219+ }
220220+221221+ return slug;
120222}
121223122224export 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("");
225225+ const encoder = new TextEncoder();
226226+ const data = encoder.encode(content);
227227+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
228228+ const hashArray = Array.from(new Uint8Array(hashBuffer));
229229+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
128230}
129231130232function 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;
233233+ for (const pattern of ignorePatterns) {
234234+ if (minimatch(relativePath, pattern)) {
235235+ return true;
236236+ }
237237+ }
238238+ return false;
239239+}
240240+241241+export interface ScanOptions {
242242+ frontmatterMapping?: FrontmatterMapping;
243243+ ignorePatterns?: string[];
244244+ slugField?: string;
245245+ removeIndexFromSlug?: boolean;
137246}
138247139248export async function scanContentDirectory(
140140- contentDir: string,
141141- frontmatterMapping?: FrontmatterMapping,
142142- ignorePatterns: string[] = []
249249+ contentDir: string,
250250+ frontmatterMappingOrOptions?: FrontmatterMapping | ScanOptions,
251251+ ignorePatterns: string[] = [],
143252): Promise<BlogPost[]> {
144144- const patterns = ["**/*.md", "**/*.mdx"];
145145- const posts: BlogPost[] = [];
253253+ // Handle both old signature (frontmatterMapping, ignorePatterns) and new signature (options)
254254+ let options: ScanOptions;
255255+ if (
256256+ frontmatterMappingOrOptions &&
257257+ ("frontmatterMapping" in frontmatterMappingOrOptions ||
258258+ "ignorePatterns" in frontmatterMappingOrOptions ||
259259+ "slugField" in frontmatterMappingOrOptions)
260260+ ) {
261261+ options = frontmatterMappingOrOptions as ScanOptions;
262262+ } else {
263263+ // Old signature: (contentDir, frontmatterMapping?, ignorePatterns?)
264264+ options = {
265265+ frontmatterMapping: frontmatterMappingOrOptions as
266266+ | FrontmatterMapping
267267+ | undefined,
268268+ ignorePatterns,
269269+ };
270270+ }
146271147147- for (const pattern of patterns) {
148148- const files = await glob(pattern, {
149149- cwd: contentDir,
150150- absolute: false,
151151- });
272272+ const {
273273+ frontmatterMapping,
274274+ ignorePatterns: ignore = [],
275275+ slugField,
276276+ removeIndexFromSlug,
277277+ } = options;
278278+279279+ const patterns = ["**/*.md", "**/*.mdx"];
280280+ const posts: BlogPost[] = [];
281281+282282+ for (const pattern of patterns) {
283283+ const files = await glob(pattern, {
284284+ cwd: contentDir,
285285+ absolute: false,
286286+ });
152287153153- for (const relativePath of files) {
154154- // Skip files matching ignore patterns
155155- if (shouldIgnore(relativePath, ignorePatterns)) {
156156- continue;
157157- }
288288+ for (const relativePath of files) {
289289+ // Skip files matching ignore patterns
290290+ if (shouldIgnore(relativePath, ignore)) {
291291+ continue;
292292+ }
158293159159- const filePath = path.join(contentDir, relativePath);
160160- const rawContent = await fs.readFile(filePath, "utf-8");
294294+ const filePath = path.join(contentDir, relativePath);
295295+ const rawContent = await fs.readFile(filePath, "utf-8");
161296162162- try {
163163- const { frontmatter, body } = parseFrontmatter(rawContent, frontmatterMapping);
164164- const filename = path.basename(relativePath);
165165- const slug = getSlugFromFilename(filename);
297297+ try {
298298+ const { frontmatter, body, rawFrontmatter } = parseFrontmatter(
299299+ rawContent,
300300+ frontmatterMapping,
301301+ );
302302+ const slug = getSlugFromOptions(relativePath, rawFrontmatter, {
303303+ slugField,
304304+ removeIndexFromSlug,
305305+ });
166306167167- 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- }
307307+ posts.push({
308308+ filePath,
309309+ slug,
310310+ frontmatter,
311311+ content: body,
312312+ rawContent,
313313+ rawFrontmatter,
314314+ });
315315+ } catch (error) {
316316+ console.error(`Error parsing ${relativePath}:`, error);
317317+ }
318318+ }
319319+ }
179320180180- // 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- });
321321+ // Sort by publish date (newest first)
322322+ posts.sort((a, b) => {
323323+ const dateA = new Date(a.frontmatter.publishDate);
324324+ const dateB = new Date(b.frontmatter.publishDate);
325325+ return dateB.getTime() - dateA.getTime();
326326+ });
186327187187- return posts;
328328+ return posts;
188329}
189330190190-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 === "+++";
331331+export function updateFrontmatterWithAtUri(
332332+ rawContent: string,
333333+ atUri: string,
334334+): string {
335335+ // Detect which delimiter is used (---, +++, or ***)
336336+ const delimiterMatch = rawContent.match(/^(---|\+\+\+|\*\*\*)/);
337337+ const delimiter = delimiterMatch?.[1] ?? "---";
338338+ const isToml = delimiter === "+++";
195339196196- // Format the atUri entry based on frontmatter type
197197- const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
340340+ // Format the atUri entry based on frontmatter type
341341+ const atUriEntry = isToml ? `atUri = "${atUri}"` : `atUri: "${atUri}"`;
198342199199- // 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- }
343343+ // Check if atUri already exists in frontmatter (handle both formats)
344344+ if (rawContent.includes("atUri:") || rawContent.includes("atUri =")) {
345345+ // Replace existing atUri (match both YAML and TOML formats)
346346+ return rawContent.replace(
347347+ /atUri\s*[=:]\s*["']?[^"'\n]+["']?\n?/,
348348+ `${atUriEntry}\n`,
349349+ );
350350+ }
204351205205- // 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- }
352352+ // Insert atUri before the closing delimiter
353353+ const frontmatterEndIndex = rawContent.indexOf(delimiter, 4);
354354+ if (frontmatterEndIndex === -1) {
355355+ throw new Error("Could not find frontmatter end");
356356+ }
210357211211- const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
212212- const afterEnd = rawContent.slice(frontmatterEndIndex);
358358+ const beforeEnd = rawContent.slice(0, frontmatterEndIndex);
359359+ const afterEnd = rawContent.slice(frontmatterEndIndex);
213360214214- return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
361361+ return `${beforeEnd}${atUriEntry}\n${afterEnd}`;
215362}
216363217364export 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();
365365+ return markdown
366366+ .replace(/#{1,6}\s/g, "") // Remove headers
367367+ .replace(/\*\*([^*]+)\*\*/g, "$1") // Remove bold
368368+ .replace(/\*([^*]+)\*/g, "$1") // Remove italic
369369+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Remove links, keep text
370370+ .replace(/`{3}[\s\S]*?`{3}/g, "") // Remove code blocks
371371+ .replace(/`([^`]+)`/g, "$1") // Remove inline code formatting
372372+ .replace(/!\[.*?\]\(.*?\)/g, "") // Remove images
373373+ .replace(/\n{3,}/g, "\n\n") // Normalize multiple newlines
374374+ .trim();
228375}
+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+}
+124
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+}
1818+1919+async function fileExists(filePath: string): Promise<boolean> {
2020+ try {
2121+ await fs.access(filePath);
2222+ return true;
2323+ } catch {
2424+ return false;
2525+ }
2626+}
2727+2828+async function loadOAuthStore(): Promise<OAuthStore> {
2929+ if (!(await fileExists(OAUTH_FILE))) {
3030+ return { states: {}, sessions: {} };
3131+ }
3232+3333+ try {
3434+ const content = await fs.readFile(OAUTH_FILE, "utf-8");
3535+ return JSON.parse(content) as OAuthStore;
3636+ } catch {
3737+ return { states: {}, sessions: {} };
3838+ }
3939+}
4040+4141+async function saveOAuthStore(store: OAuthStore): Promise<void> {
4242+ await fs.mkdir(CONFIG_DIR, { recursive: true });
4343+ await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2));
4444+ await fs.chmod(OAUTH_FILE, 0o600);
4545+}
4646+4747+/**
4848+ * State store for PKCE flow (temporary, used during auth)
4949+ */
5050+export const stateStore: NodeSavedStateStore = {
5151+ async set(key: string, state: NodeSavedState): Promise<void> {
5252+ const store = await loadOAuthStore();
5353+ store.states[key] = state;
5454+ await saveOAuthStore(store);
5555+ },
5656+5757+ async get(key: string): Promise<NodeSavedState | undefined> {
5858+ const store = await loadOAuthStore();
5959+ return store.states[key];
6060+ },
6161+6262+ async del(key: string): Promise<void> {
6363+ const store = await loadOAuthStore();
6464+ delete store.states[key];
6565+ await saveOAuthStore(store);
6666+ },
6767+};
6868+6969+/**
7070+ * Session store for OAuth tokens (persistent)
7171+ */
7272+export const sessionStore: NodeSavedSessionStore = {
7373+ async set(sub: string, session: NodeSavedSession): Promise<void> {
7474+ const store = await loadOAuthStore();
7575+ store.sessions[sub] = session;
7676+ await saveOAuthStore(store);
7777+ },
7878+7979+ async get(sub: string): Promise<NodeSavedSession | undefined> {
8080+ const store = await loadOAuthStore();
8181+ return store.sessions[sub];
8282+ },
8383+8484+ async del(sub: string): Promise<void> {
8585+ const store = await loadOAuthStore();
8686+ delete store.sessions[sub];
8787+ await saveOAuthStore(store);
8888+ },
8989+};
9090+9191+/**
9292+ * List all stored OAuth session DIDs
9393+ */
9494+export async function listOAuthSessions(): Promise<string[]> {
9595+ const store = await loadOAuthStore();
9696+ return Object.keys(store.sessions);
9797+}
9898+9999+/**
100100+ * Get an OAuth session by DID
101101+ */
102102+export async function getOAuthSession(
103103+ did: string,
104104+): Promise<NodeSavedSession | undefined> {
105105+ const store = await loadOAuthStore();
106106+ return store.sessions[did];
107107+}
108108+109109+/**
110110+ * Delete an OAuth session by DID
111111+ */
112112+export async function deleteOAuthSession(did: string): Promise<boolean> {
113113+ const store = await loadOAuthStore();
114114+ if (!store.sessions[did]) {
115115+ return false;
116116+ }
117117+ delete store.sessions[did];
118118+ await saveOAuthStore(store);
119119+ return true;
120120+}
121121+122122+export function getOAuthStorePath(): string {
123123+ return OAUTH_FILE;
124124+}
+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}
+39-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+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
3437 bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
3538}
36393737-export interface Credentials {
4040+// Legacy credentials format (for backward compatibility during migration)
4141+export interface LegacyCredentials {
3842 pdsUrl: string;
3943 identifier: string;
4044 password: string;
4145}
42464747+// App password credentials (explicit type)
4848+export interface AppPasswordCredentials {
4949+ type: "app-password";
5050+ pdsUrl: string;
5151+ identifier: string;
5252+ password: string;
5353+}
5454+5555+// OAuth credentials (references stored OAuth session)
5656+export interface OAuthCredentials {
5757+ type: "oauth";
5858+ did: string;
5959+ handle: string;
6060+ pdsUrl: string;
6161+}
6262+6363+// Union type for all credential types
6464+export type Credentials = AppPasswordCredentials | OAuthCredentials;
6565+6666+// Helper to check credential type
6767+export function isOAuthCredentials(
6868+ creds: Credentials,
6969+): creds is OAuthCredentials {
7070+ return creds.type === "oauth";
7171+}
7272+7373+export function isAppPasswordCredentials(
7474+ creds: Credentials,
7575+): creds is AppPasswordCredentials {
7676+ return creds.type === "app-password";
7777+}
7878+4379export interface PostFrontmatter {
4480 title: string;
4581 description?: string;
···5692 frontmatter: PostFrontmatter;
5793 content: string;
5894 rawContent: string;
9595+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
5996}
60976198export interface BlobRef {
···77114 contentHash: string;
78115 atUri?: string;
79116 lastPublished?: string;
117117+ slug?: string; // The generated slug for this post (used by inject command)
80118 bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
81119}
82120