packages/cli/src/lib/atproto.ts 8.9 K raw
1
import { AtpAgent } from "@atproto/api";
2
import * as path from "path";
3
import type { Credentials, BlogPost, BlobObject, PublisherConfig } from "./types";
4
import { stripMarkdownForText } from "./markdown";
5
6
export async function resolveHandleToPDS(handle: string): Promise<string> {
7
  // First, resolve the handle to a DID
8
  let did: string;
9
10
  if (handle.startsWith("did:")) {
11
    did = handle;
12
  } else {
13
    // Try to resolve handle via Bluesky API
14
    const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
15
    const resolveResponse = await fetch(resolveUrl);
16
    if (!resolveResponse.ok) {
17
      throw new Error("Could not resolve handle");
18
    }
19
    const resolveData = (await resolveResponse.json()) as { did: string };
20
    did = resolveData.did;
21
  }
22
23
  // Now resolve the DID to get the PDS URL from the DID document
24
  let pdsUrl: string | undefined;
25
26
  if (did.startsWith("did:plc:")) {
27
    // Fetch DID document from plc.directory
28
    const didDocUrl = `https://plc.directory/${did}`;
29
    const didDocResponse = await fetch(didDocUrl);
30
    if (!didDocResponse.ok) {
31
      throw new Error("Could not fetch DID document");
32
    }
33
    const didDoc = (await didDocResponse.json()) as {
34
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
35
    };
36
37
    // Find the PDS service endpoint
38
    const pdsService = didDoc.service?.find(
39
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
40
    );
41
    pdsUrl = pdsService?.serviceEndpoint;
42
  } else if (did.startsWith("did:web:")) {
43
    // For did:web, fetch the DID document from the domain
44
    const domain = did.replace("did:web:", "");
45
    const didDocUrl = `https://${domain}/.well-known/did.json`;
46
    const didDocResponse = await fetch(didDocUrl);
47
    if (!didDocResponse.ok) {
48
      throw new Error("Could not fetch DID document");
49
    }
50
    const didDoc = (await didDocResponse.json()) as {
51
      service?: Array<{ id: string; type: string; serviceEndpoint: string }>;
52
    };
53
54
    const pdsService = didDoc.service?.find(
55
      (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
56
    );
57
    pdsUrl = pdsService?.serviceEndpoint;
58
  }
59
60
  if (!pdsUrl) {
61
    throw new Error("Could not find PDS URL for user");
62
  }
63
64
  return pdsUrl;
65
}
66
67
export interface CreatePublicationOptions {
68
  url: string;
69
  name: string;
70
  description?: string;
71
  iconPath?: string;
72
  showInDiscover?: boolean;
73
}
74
75
export async function createAgent(credentials: Credentials): Promise<AtpAgent> {
76
  const agent = new AtpAgent({ service: credentials.pdsUrl });
77
78
  await agent.login({
79
    identifier: credentials.identifier,
80
    password: credentials.password,
81
  });
82
83
  return agent;
84
}
85
86
export async function uploadImage(
87
  agent: AtpAgent,
88
  imagePath: string
89
): Promise<BlobObject | undefined> {
90
  const file = Bun.file(imagePath);
91
92
  if (!(await file.exists())) {
93
    return undefined;
94
  }
95
96
  try {
97
    const imageBuffer = await file.arrayBuffer();
98
    const mimeType = file.type || "application/octet-stream";
99
100
    const response = await agent.com.atproto.repo.uploadBlob(
101
      new Uint8Array(imageBuffer),
102
      {
103
        encoding: mimeType,
104
      }
105
    );
106
107
    return {
108
      $type: "blob",
109
      ref: {
110
        $link: response.data.blob.ref.toString(),
111
      },
112
      mimeType,
113
      size: imageBuffer.byteLength,
114
    };
115
  } catch (error) {
116
    console.error(`Error uploading image ${imagePath}:`, error);
117
    return undefined;
118
  }
119
}
120
121
export function resolveImagePath(
122
  ogImage: string,
123
  imagesDir: string | undefined,
124
  contentDir: string
125
): string | null {
126
  // Try multiple resolution strategies
127
  const filename = path.basename(ogImage);
128
129
  // 1. If imagesDir is specified, look there
130
  if (imagesDir) {
131
    const imagePath = path.join(imagesDir, filename);
132
    try {
133
      const stat = Bun.file(imagePath);
134
      if (stat.size > 0) {
135
        return imagePath;
136
      }
137
    } catch {
138
      // File doesn't exist, continue
139
    }
140
  }
141
142
  // 2. Try the ogImage path directly (if it's absolute)
143
  if (path.isAbsolute(ogImage)) {
144
    return ogImage;
145
  }
146
147
  // 3. Try relative to content directory
148
  const contentRelative = path.join(contentDir, ogImage);
149
  try {
150
    const stat = Bun.file(contentRelative);
151
    if (stat.size > 0) {
152
      return contentRelative;
153
    }
154
  } catch {
155
    // File doesn't exist
156
  }
157
158
  return null;
159
}
160
161
export async function createDocument(
162
  agent: AtpAgent,
163
  post: BlogPost,
164
  config: PublisherConfig,
165
  coverImage?: BlobObject
166
): Promise<string> {
167
  const pathPrefix = config.pathPrefix || "/posts";
168
  const postPath = `${pathPrefix}/${post.slug}`;
169
  const textContent = stripMarkdownForText(post.content);
170
  const publishDate = new Date(post.frontmatter.publishDate);
171
172
  const record: Record<string, unknown> = {
173
    $type: "site.standard.document",
174
    title: post.frontmatter.title,
175
    site: config.publicationUri,
176
    path: postPath,
177
    textContent: textContent.slice(0, 10000),
178
    publishedAt: publishDate.toISOString(),
179
    canonicalUrl: `${config.siteUrl}${postPath}`,
180
  };
181
182
  if (coverImage) {
183
    record.coverImage = coverImage;
184
  }
185
186
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
187
    record.tags = post.frontmatter.tags;
188
  }
189
190
  const response = await agent.com.atproto.repo.createRecord({
191
    repo: agent.session!.did,
192
    collection: "site.standard.document",
193
    record,
194
  });
195
196
  return response.data.uri;
197
}
198
199
export async function updateDocument(
200
  agent: AtpAgent,
201
  post: BlogPost,
202
  atUri: string,
203
  config: PublisherConfig,
204
  coverImage?: BlobObject
205
): Promise<void> {
206
  // Parse the atUri to get the collection and rkey
207
  // Format: at://did:plc:xxx/collection/rkey
208
  const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
209
  if (!uriMatch) {
210
    throw new Error(`Invalid atUri format: ${atUri}`);
211
  }
212
213
  const [, , collection, rkey] = uriMatch;
214
215
  const pathPrefix = config.pathPrefix || "/posts";
216
  const postPath = `${pathPrefix}/${post.slug}`;
217
  const textContent = stripMarkdownForText(post.content);
218
  const publishDate = new Date(post.frontmatter.publishDate);
219
220
  const record: Record<string, unknown> = {
221
    $type: "site.standard.document",
222
    title: post.frontmatter.title,
223
    site: config.publicationUri,
224
    path: postPath,
225
    textContent: textContent.slice(0, 10000),
226
    publishedAt: publishDate.toISOString(),
227
    canonicalUrl: `${config.siteUrl}${postPath}`,
228
  };
229
230
  if (coverImage) {
231
    record.coverImage = coverImage;
232
  }
233
234
  if (post.frontmatter.tags && post.frontmatter.tags.length > 0) {
235
    record.tags = post.frontmatter.tags;
236
  }
237
238
  await agent.com.atproto.repo.putRecord({
239
    repo: agent.session!.did,
240
    collection: collection!,
241
    rkey: rkey!,
242
    record,
243
  });
244
}
245
246
export function parseAtUri(atUri: string): { did: string; collection: string; rkey: string } | null {
247
  const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
248
  if (!match) return null;
249
  return {
250
    did: match[1]!,
251
    collection: match[2]!,
252
    rkey: match[3]!,
253
  };
254
}
255
256
export interface DocumentRecord {
257
  $type: "site.standard.document";
258
  title: string;
259
  site: string;
260
  path: string;
261
  textContent: string;
262
  publishedAt: string;
263
  canonicalUrl?: string;
264
  coverImage?: BlobObject;
265
  tags?: string[];
266
  location?: string;
267
}
268
269
export interface ListDocumentsResult {
270
  uri: string;
271
  cid: string;
272
  value: DocumentRecord;
273
}
274
275
export async function listDocuments(
276
  agent: AtpAgent,
277
  publicationUri?: string
278
): Promise<ListDocumentsResult[]> {
279
  const documents: ListDocumentsResult[] = [];
280
  let cursor: string | undefined;
281
282
  do {
283
    const response = await agent.com.atproto.repo.listRecords({
284
      repo: agent.session!.did,
285
      collection: "site.standard.document",
286
      limit: 100,
287
      cursor,
288
    });
289
290
    for (const record of response.data.records) {
291
      const value = record.value as unknown as DocumentRecord;
292
293
      // If publicationUri is specified, only include documents from that publication
294
      if (publicationUri && value.site !== publicationUri) {
295
        continue;
296
      }
297
298
      documents.push({
299
        uri: record.uri,
300
        cid: record.cid,
301
        value,
302
      });
303
    }
304
305
    cursor = response.data.cursor;
306
  } while (cursor);
307
308
  return documents;
309
}
310
311
export async function createPublication(
312
  agent: AtpAgent,
313
  options: CreatePublicationOptions
314
): Promise<string> {
315
  let icon: BlobObject | undefined;
316
317
  if (options.iconPath) {
318
    icon = await uploadImage(agent, options.iconPath);
319
  }
320
321
  const record: Record<string, unknown> = {
322
    $type: "site.standard.publication",
323
    url: options.url,
324
    name: options.name,
325
    createdAt: new Date().toISOString(),
326
  };
327
328
  if (options.description) {
329
    record.description = options.description;
330
  }
331
332
  if (icon) {
333
    record.icon = icon;
334
  }
335
336
  if (options.showInDiscover !== undefined) {
337
    record.preferences = {
338
      showInDiscover: options.showInDiscover,
339
    };
340
  }
341
342
  const response = await agent.com.atproto.repo.createRecord({
343
    repo: agent.session!.did,
344
    collection: "site.standard.publication",
345
    record,
346
  });
347
348
  return response.data.uri;
349
}