From 371edf70bb91f8598bfb5e94dfed2d940db7a0ec Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Sat, 15 Aug 2026 03:41:29 +0100 Subject: [PATCH] migrate: @atproto/api -> @bsky/sdk + @atproto/lex - Replace deprecated @atproto/api / BskyAgent with @bsky/sdk + @atproto/lex + @atproto/lex-password-session - Update package.json dependencies across 22 packages - Migrate source code in atproto, croft-click-core, malachite, jasper, tourmaline, opal, supporters, svelte-standard-site, and their web frontends - Pattern: Agent/AtpAgent -> Client; agent.com.atproto.repo.* -> client.call(com.atproto.repo.*.main as any, ...) - agent.getProfile -> client.call(app.bsky.actor.getProfile.main as any, ...) - response.data.* -> response.* - Add any casts for @atproto/lex v0.3.x type compatibility --- packages/atproto/package.json | 5 +- packages/atproto/src/agents.ts | 45 +- packages/atproto/src/fetch.ts | 177 ++--- .../atproto/src/pagination/fetchAllRecords.ts | 11 +- packages/atproto/src/posts.ts | 33 +- packages/bismuth-web/package.json | 114 +-- packages/bismuth-web/src/lib/core/oauth.ts | 6 +- packages/croft-click-core/package.json | 6 +- packages/croft-click-core/src/auth.ts | 25 +- packages/croft-click-core/src/car-fetch.ts | 32 +- packages/croft-click-core/src/polish.ts | 37 +- packages/croft-click-core/src/publisher.ts | 13 +- packages/croft-click-core/src/sync.ts | 50 +- packages/jasper-web/package.json | 122 +-- packages/jasper-web/src/lib/core/oauth.ts | 8 +- .../jasper-web/src/routes/import/+page.svelte | 25 +- packages/jasper-web/vite.config.ts | 2 +- packages/jasper/package.json | 4 +- packages/jasper/src/core/types.ts | 4 +- packages/jasper/src/index.ts | 5 +- packages/jasper/src/lib/auth.ts | 53 +- packages/jasper/src/lib/browser.ts | 75 +- packages/jasper/src/lib/oauth-login.ts | 10 +- packages/jasper/src/lib/publisher.ts | 84 +- .../jasper/src/lib/rate-limited-publisher.ts | 20 +- packages/jasper/src/lib/spark-publisher.ts | 47 +- .../jasper/src/lib/spark-story-publisher.ts | 59 +- .../jasper/src/lib/spark-video-publisher.ts | 43 +- packages/malachite-web/package.json | 8 +- .../src/lib/components/steps/AuthStep.svelte | 4 +- packages/malachite-web/src/lib/core/import.ts | 27 +- packages/malachite-web/src/lib/core/oauth.ts | 6 +- .../src/routes/import/+page.svelte | 6 +- packages/malachite-web/vite.config.ts | 4 +- packages/malachite/package.json | 6 +- packages/malachite/src/lib/auth.ts | 12 +- packages/malachite/src/lib/cli.ts | 54 +- packages/malachite/src/lib/oauth-login.ts | 10 +- packages/malachite/src/lib/polish.ts | 12 +- packages/malachite/src/lib/publisher.ts | 17 +- packages/malachite/src/lib/sync.ts | 35 +- packages/malachite/src/tests/polish.test.ts | 4 +- packages/malachite/src/types.ts | 7 +- packages/opal-web/package.json | 6 +- packages/opal-web/src/lib/core/import.ts | 30 +- packages/opal-web/src/lib/core/oauth.ts | 6 +- .../opal-web/src/routes/import/+page.svelte | 6 +- packages/opal-web/vite.config.ts | 2 +- packages/opal/package.json | 4 +- packages/opal/src/cli.ts | 22 +- packages/opal/src/publisher.ts | 12 +- packages/supporters/package.json | 6 +- packages/supporters/src/lib/events.ts | 13 +- packages/supporters/src/lib/github-store.ts | 48 +- packages/supporters/src/lib/store.ts | 46 +- packages/svelte-standard-site/package.json | 273 +++---- .../lib/components/document/RichText.svelte | 7 +- .../svelte-standard-site/src/lib/publisher.ts | 164 ++-- .../src/lib/utils/agents.ts | 18 +- .../src/lib/utils/comments.ts | 35 +- packages/tangled-sync/package.json | 4 +- packages/tangled-sync/src/check.ts | 63 +- packages/tangled-sync/src/index.ts | 101 ++- packages/tangled-sync/src/test-atproto.ts | 57 +- packages/tourmaline/package.json | 4 +- packages/tourmaline/src/lib/atproto/oauth.ts | 6 +- packages/tourmaline/src/lib/share/post.ts | 38 +- packages/tourmaline/src/lib/share/registry.ts | 8 +- .../tourmaline/src/routes/share/+page.svelte | 4 +- pnpm-lock.yaml | 716 +++++++++++++++--- 70 files changed, 1757 insertions(+), 1269 deletions(-) diff --git a/packages/atproto/package.json b/packages/atproto/package.json index 59ddfe9..0d64b83 100644 --- a/packages/atproto/package.json +++ b/packages/atproto/package.json @@ -42,10 +42,11 @@ "check": "tsc --noEmit" }, "peerDependencies": { - "@atproto/api": ">=0.13.0" + "@bsky/sdk": ">=0.1.0" }, "devDependencies": { - "@atproto/api": "^0.19.3", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", "typescript": "^5.9.3" } } diff --git a/packages/atproto/src/agents.ts b/packages/atproto/src/agents.ts index f9b6165..a8b1ab7 100644 --- a/packages/atproto/src/agents.ts +++ b/packages/atproto/src/agents.ts @@ -1,25 +1,11 @@ -/** - * Agent management for AT Protocol XRPC calls. - * - * Creates and caches ATP agents with fallback between public and PDS endpoints. - * All functions that previously read PUBLIC_ATPROTO_DID from the environment - * now accept `did: string` as their first argument. - */ - -import { AtpAgent } from '@atproto/api'; +import { Client } from '@atproto/lex'; +import { app } from '@bsky/sdk/lexicons'; import type { ResolvedIdentity } from './types.js'; import { cache } from './cache.js'; -/** Default timeout for individual AT Protocol XRPC calls (ms). */ const XRPC_TIMEOUT = 8_000; - -/** Default timeout for identity resolution (ms). */ const IDENTITY_TIMEOUT = 5_000; -/** - * Wraps a promise with a timeout. Rejects with a TimeoutError if the promise - * doesn't settle within `ms` milliseconds. - */ function withTimeout(promise: Promise, ms: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms); @@ -30,7 +16,7 @@ function withTimeout(promise: Promise, ms: number): Promise { }); } -export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { +export function createAgent(service: string, fetchFn?: typeof fetch): Client { const wrappedFetch = fetchFn ? async (url: URL | RequestInfo, init?: RequestInit) => { const urlStr = url instanceof URL ? url.toString() : url; @@ -47,17 +33,14 @@ export function createAgent(service: string, fetchFn?: typeof fetch): AtpAgent { } : undefined; - return new AtpAgent({ - service, - ...(wrappedFetch && { fetch: wrappedFetch }) - }); + return new Client({ service, fetch: wrappedFetch }); } export const constellationAgent = createAgent('https://constellation.microcosm.blue'); export const defaultAgent = createAgent('https://public.api.bsky.app'); -let resolvedAgent: AtpAgent | null = null; -let pdsAgent: AtpAgent | null = null; +let resolvedAgent: Client | null = null; +let pdsAgent: Client | null = null; export async function resolveIdentity( did: string, @@ -97,19 +80,17 @@ export async function resolveIdentity( return data; } -export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise { +export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promise { if (resolvedAgent) return resolvedAgent; try { try { - const response = await withTimeout( - constellationAgent.getProfile({ actor: did }), + await withTimeout( + constellationAgent.call(app.bsky.actor.getProfile.main as any, { actor: did }), XRPC_TIMEOUT ); - if (response.success) { - resolvedAgent = constellationAgent; - return resolvedAgent; - } + resolvedAgent = constellationAgent; + return resolvedAgent; } catch { // fall through } @@ -123,7 +104,7 @@ export async function getPublicAgent(did: string, fetchFn?: typeof fetch): Promi } } -export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise { +export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise { if (pdsAgent) return pdsAgent; const resolved = await resolveIdentity(did, fetchFn); pdsAgent = createAgent(resolved.pds, fetchFn); @@ -132,7 +113,7 @@ export async function getPDSAgent(did: string, fetchFn?: typeof fetch): Promise< export async function withFallback( did: string, - operation: (agent: AtpAgent) => Promise, + operation: (agent: Client) => Promise, usePDSFirst = false, fetchFn?: typeof fetch ): Promise { diff --git a/packages/atproto/src/fetch.ts b/packages/atproto/src/fetch.ts index 987ebea..bcf5c90 100644 --- a/packages/atproto/src/fetch.ts +++ b/packages/atproto/src/fetch.ts @@ -20,6 +20,7 @@ import { cache } from './cache.js'; import { withFallback, resolveIdentity } from './agents.js'; import { buildPdsBlobUrl } from './media.js'; import { findArtwork } from './musicbrainz.js'; +import { com, app } from '@bsky/sdk/lexicons'; import type { ProfileData, SiteInfoData, @@ -73,13 +74,13 @@ async function listLatestRecord( ): Promise<{ uri: string; value: any } | null> { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection, limit: 1 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn @@ -96,8 +97,8 @@ export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise const profile = await withFallback( did, - async (agent) => { - const response = await agent.getProfile({ actor: did }); + async (client) => { + const response = (await client.call(app.bsky.actor.getProfile.main as any, { actor: did })) as any; return response.data; }, false, @@ -108,12 +109,12 @@ export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise try { const recordResponse = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.getRecord({ + async (client) => { + const response = (await client.call(com.atproto.repo.getRecord.main as any, { repo: did, collection: 'app.bsky.actor.profile', rkey: 'self' - }); + })) as any; return response.data; }, false, @@ -152,13 +153,13 @@ export async function fetchSiteInfo( try { const result = await withFallback( did, - async (agent) => { + async (client) => { try { - const response = await agent.com.atproto.repo.getRecord({ + const response = (await client.call(com.atproto.repo.getRecord.main as any, { repo: did, collection: 'uk.ewancroft.site.info', rkey: 'self' - }); + })) as any; return response.data; } catch (err: any) { if (err.error === 'RecordNotFound') return null; @@ -189,13 +190,13 @@ export async function fetchLinks( try { const value = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.getRecord({ + async (client) => { + const response = (await client.call(com.atproto.repo.getRecord.main as any, { repo: did, collection: 'blue.linkat.board', rkey: 'self' - }); - return response.data.value; + })) as any; + return response.value; }, true, fetchFn @@ -327,13 +328,13 @@ export async function fetchKibunStatus( try { const statusRecords = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'social.kibun.status', limit: 1 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn @@ -370,13 +371,13 @@ export async function fetchRecentPopfeedReviews( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'social.popfeed.feed.review', limit - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn @@ -384,7 +385,7 @@ export async function fetchRecentPopfeedReviews( if (!records?.length) return []; - const data: PopfeedReview[] = records.map((record) => { + const data: PopfeedReview[] = records.map((record: any) => { const value = record.value as any; const rkey = record.uri.split('/').pop() ?? record.uri; return { @@ -423,19 +424,19 @@ export async function fetchSifaPositions( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.position', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaPosition[] = records.map((record) => { + const data: SifaPosition[] = records.map((record: any) => { const value = record.value as any; return { company: value.company, @@ -477,19 +478,19 @@ export async function fetchSifaEducation( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.education', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaEducation[] = records.map((record) => { + const data: SifaEducation[] = records.map((record: any) => { const value = record.value as any; return { institution: value.institution, @@ -530,19 +531,19 @@ export async function fetchSifaVolunteering( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.volunteering', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaVolunteering[] = records.map((record) => { + const data: SifaVolunteering[] = records.map((record: any) => { const value = record.value as any; return { organization: value.organization, @@ -574,19 +575,19 @@ export async function fetchSifaHonors( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.honor', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaHonor[] = records.map((record) => { + const data: SifaHonor[] = records.map((record: any) => { const value = record.value as any; return { title: value.title, @@ -622,19 +623,19 @@ export async function fetchSifaCourses( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.course', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaCourse[] = records.map((record) => { + const data: SifaCourse[] = records.map((record: any) => { const value = record.value as any; return { name: value.name, @@ -663,19 +664,19 @@ export async function fetchSifaPublications( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.publication', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaPublication[] = records.map((record) => { + const data: SifaPublication[] = records.map((record: any) => { const value = record.value as any; return { title: value.title, @@ -712,13 +713,13 @@ export async function fetchTangledRepos( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'sh.tangled.repo', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn @@ -726,7 +727,7 @@ export async function fetchTangledRepos( if (!records.length) return null; - const repos: TangledRepo[] = records.map((record) => { + const repos: TangledRepo[] = records.map((record: any) => { const value = record.value as any; return { uri: record.uri, @@ -762,12 +763,12 @@ export async function fetchSifaProfile( try { const result = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.getRecord({ + async (client) => { + const response = (await client.call(com.atproto.repo.getRecord.main as any, { repo: did, collection: 'id.sifa.profile.self', rkey: 'self' - }); + })) as any; return response.data; }, true, @@ -804,19 +805,19 @@ export async function fetchSifaSkills( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.skill', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaSkill[] = records.map((record) => { + const data: SifaSkill[] = records.map((record: any) => { const value = record.value as any; return { name: value.name, @@ -843,19 +844,19 @@ export async function fetchSifaProjects( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.project', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaProject[] = records.map((record) => { + const data: SifaProject[] = records.map((record: any) => { const value = record.value as any; return { name: value.name, @@ -890,19 +891,19 @@ export async function fetchSifaLanguages( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.language', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaLanguage[] = records.map((record) => { + const data: SifaLanguage[] = records.map((record: any) => { const value = record.value as any; return { name: value.name, @@ -929,19 +930,19 @@ export async function fetchSifaCertifications( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.certification', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaCertification[] = records.map((record) => { + const data: SifaCertification[] = records.map((record: any) => { const value = record.value as any; return { name: value.name, @@ -975,19 +976,19 @@ export async function fetchSifaExternalAccounts( try { const records = await withFallback( did, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo: did, collection: 'id.sifa.profile.externalAccount', limit: 100 - }); - return response.data.records; + })) as any; + return response.records; }, true, fetchFn ); - const data: SifaExternalAccount[] = records.map((record) => { + const data: SifaExternalAccount[] = records.map((record: any) => { const value = record.value as any; return { platform: value.platform, diff --git a/packages/atproto/src/pagination/fetchAllRecords.ts b/packages/atproto/src/pagination/fetchAllRecords.ts index 30820eb..c6f2299 100644 --- a/packages/atproto/src/pagination/fetchAllRecords.ts +++ b/packages/atproto/src/pagination/fetchAllRecords.ts @@ -6,6 +6,7 @@ */ import { withFallback } from '../agents.js'; +import { com } from '@bsky/sdk/lexicons'; export interface FetchRecordsConfig { repo: string; @@ -31,15 +32,15 @@ export async function fetchAllRecords( do { const records = await withFallback( repo, - async (agent) => { - const response = await agent.com.atproto.repo.listRecords({ + async (client) => { + const response = (await client.call(com.atproto.repo.listRecords.main as any, { repo, collection, limit, cursor - }); - cursor = response.data.cursor; - return response.data.records; + })) as any; + cursor = response.cursor; + return response.records; }, true, fetchFn diff --git a/packages/atproto/src/posts.ts b/packages/atproto/src/posts.ts index 601f6ac..b77814a 100644 --- a/packages/atproto/src/posts.ts +++ b/packages/atproto/src/posts.ts @@ -8,6 +8,7 @@ import { cache } from './cache.js'; import { withFallback } from './agents.js'; import type { BlueskyPost, PostAuthor, ExternalLink } from './types.js'; +import { app } from '@bsky/sdk/lexicons'; export async function fetchLatestBlueskyPost( did: string, @@ -18,14 +19,14 @@ export async function fetchLatestBlueskyPost( if (cached) return cached; try { - const feedResponse = await withFallback( - did, - async (agent) => agent.getAuthorFeed({ actor: did, limit: 5 }), - false, - fetchFn - ); - - const feed = feedResponse.data.feed; + const feedResponse = (await withFallback( + did, + async (client) => client.call(app.bsky.feed.getAuthorFeed.main as any, { actor: did, limit: 5 }), + false, + fetchFn + )) as any; + + const feed = feedResponse.feed; if (!feed.length) return null; const latestFeedItem = feed[0]; @@ -72,16 +73,16 @@ export async function fetchPostFromUri( if (depth >= 3) return null; try { - const threadResponse = await withFallback( - did, - async (agent) => agent.getPostThread({ uri, depth: 0 }), - false, - fetchFn - ); + const threadResponse = (await withFallback( + did, + async (client) => client.call(app.bsky.feed.getPostThread.main as any, { uri, depth: 0 }), + false, + fetchFn + )) as any; - if (!threadResponse.data.thread || !('post' in threadResponse.data.thread)) return null; + if (!threadResponse.thread || !('post' in threadResponse.thread)) return null; - const postData = threadResponse.data.thread.post; + const postData = threadResponse.thread.post; const value = postData.record as any; const embed = (postData as any).embed ?? null; diff --git a/packages/bismuth-web/package.json b/packages/bismuth-web/package.json index cb63bb5..504eee7 100644 --- a/packages/bismuth-web/package.json +++ b/packages/bismuth-web/package.json @@ -1,58 +1,60 @@ { - "name": "@ewanc26/bismuth-web", - "version": "0.2.3", - "description": "Web frontend for Bismuth — convert ATProto richtext-block documents to Markdown in your browser", - "author": "Ewan Croft", - "license": "AGPL-3.0-only", - "private": true, - "type": "module", - "keywords": [ - "atproto", - "leaflet", - "standard.site", - "markdown", - "svelte", - "sveltekit" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/ewanc26/pkgs.git", - "directory": "packages/bismuth-web" - }, - "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/bismuth-web", - "bugs": { - "url": "https://github.com/ewanc26/pkgs/issues", - "email": "contact@ewancroft.uk" - }, - "scripts": { - "dev": "vite dev", - "build": "pnpm --filter @ewanc26/bismuth build && pnpm --filter @ewanc26/landing-ui build && vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "lint": "prettier --check .", - "format": "prettier --write ." - }, - "dependencies": { - "@atproto/api": "0.20.5", - "@atproto/oauth-client-browser": "0.4.0", - "@ewanc26/bismuth": "workspace:*", - "@ewanc26/landing-ui": "workspace:*", - "@lucide/svelte": "1.17.0" - }, - "devDependencies": { - "@sveltejs/adapter-vercel": "6.3.3", - "@sveltejs/kit": "2.57.0", - "@sveltejs/vite-plugin-svelte": "7.0.0", - "@tailwindcss/vite": "4.2.2", - "prettier": "3.8.3", - "prettier-plugin-svelte": "3.5.1", - "prettier-plugin-tailwindcss": "0.7.2", - "svelte": "5.55.2", - "svelte-check": "4.4.6", - "tailwindcss": "4.2.2", - "typescript": "6.0.2", - "vercel": "51.4.0", - "vite": "8.0.7" - } + "name": "@ewanc26/bismuth-web", + "version": "0.2.3", + "description": "Web frontend for Bismuth \u2014 convert ATProto richtext-block documents to Markdown in your browser", + "author": "Ewan Croft", + "license": "AGPL-3.0-only", + "private": true, + "type": "module", + "keywords": [ + "atproto", + "leaflet", + "standard.site", + "markdown", + "svelte", + "sveltekit" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ewanc26/pkgs.git", + "directory": "packages/bismuth-web" + }, + "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/bismuth-web", + "bugs": { + "url": "https://github.com/ewanc26/pkgs/issues", + "email": "contact@ewancroft.uk" + }, + "scripts": { + "dev": "vite dev", + "build": "pnpm --filter @ewanc26/bismuth build && pnpm --filter @ewanc26/landing-ui build && vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "dependencies": { + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", + "@atproto/oauth-client-browser": "0.4.0", + "@ewanc26/bismuth": "workspace:*", + "@ewanc26/landing-ui": "workspace:*", + "@lucide/svelte": "1.17.0" + }, + "devDependencies": { + "@sveltejs/adapter-vercel": "6.3.3", + "@sveltejs/kit": "2.57.0", + "@sveltejs/vite-plugin-svelte": "7.0.0", + "@tailwindcss/vite": "4.2.2", + "prettier": "3.8.3", + "prettier-plugin-svelte": "3.5.1", + "prettier-plugin-tailwindcss": "0.7.2", + "svelte": "5.55.2", + "svelte-check": "4.4.6", + "tailwindcss": "4.2.2", + "typescript": "6.0.2", + "vercel": "51.4.0", + "vite": "8.0.7" + } } diff --git a/packages/bismuth-web/src/lib/core/oauth.ts b/packages/bismuth-web/src/lib/core/oauth.ts index b8ff7a7..6ebc9ff 100644 --- a/packages/bismuth-web/src/lib/core/oauth.ts +++ b/packages/bismuth-web/src/lib/core/oauth.ts @@ -4,7 +4,7 @@ */ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; -import { Agent } from '@atproto/api'; +import { Client } from '@atproto/lex' const SCOPE = 'atproto repo:click.croft.toolkit.use'; @@ -31,11 +31,11 @@ function getClient(): Promise { /** * Call once on mount on the /convert page. */ -export async function initOAuth(): Promise { +export async function initOAuth(): Promise { const client = await getClient(); const result = await client.init(); if (!result) return null; - return new Agent(result.session); + return new Client(result.session); } /** diff --git a/packages/croft-click-core/package.json b/packages/croft-click-core/package.json index c689bf5..fbcbb51 100644 --- a/packages/croft-click-core/package.json +++ b/packages/croft-click-core/package.json @@ -1,7 +1,7 @@ { "name": "@ewanc26/croft-click-core", "version": "0.2.3", - "description": "Shared core library for croft.click ATProto tools — publishing, rate limiting, sync, and data conversion", + "description": "Shared core library for croft.click ATProto tools \u2014 publishing, rate limiting, sync, and data conversion", "author": "Ewan Croft", "license": "AGPL-3.0-only", "type": "module", @@ -40,7 +40,9 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@atproto/api": "^0.19.16", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@ewanc26/tid": "workspace:*", "@ipld/car": "^5.3.2", "@ipld/dag-cbor": "^9.2.2", diff --git a/packages/croft-click-core/src/auth.ts b/packages/croft-click-core/src/auth.ts index 957f3a8..cec59cb 100644 --- a/packages/croft-click-core/src/auth.ts +++ b/packages/croft-click-core/src/auth.ts @@ -3,8 +3,10 @@ * No CLI prompts; credentials come from the caller. */ -import { Agent, AtpAgent } from '@atproto/api'; -import { SLINGSHOT_RESOLVER } from './config.js'; +import { Client } from '@atproto/lex' +import { PasswordSession } from '@atproto/lex-password-session' +import { api } from '@bsky/sdk' +import { SLINGSHOT_RESOLVER } from './config.js' export interface ResolvedIdentity { did: string; @@ -86,15 +88,12 @@ export async function login( identifier: string, password: string, pdsOverride?: string -): Promise { - if (pdsOverride) { - const agent = new AtpAgent({ service: pdsOverride }); - await agent.login({ identifier, password }); - return agent; - } - - const identity = await resolveIdentity(identifier); - const agent = new AtpAgent({ service: identity.pds }); - await agent.login({ identifier: identity.did, password }); - return agent; +): Promise { + const service = pdsOverride || (await resolveIdentity(identifier)).pds + const session = await PasswordSession.login({ + service, + identifier, + password, + }) + return new Client(session, { service: api.app.service }) } diff --git a/packages/croft-click-core/src/car-fetch.ts b/packages/croft-click-core/src/car-fetch.ts index 65597c4..aee8a0e 100644 --- a/packages/croft-click-core/src/car-fetch.ts +++ b/packages/croft-click-core/src/car-fetch.ts @@ -164,8 +164,8 @@ export async function fetchRepoViaCAR( } /** - * Extract the PDS base URL from an @atproto/api Agent or AtpAgent. - * Handles both password-auth agents and OAuth session-manager agents. + * Extract the PDS base URL from an @atproto/lex Client or legacy @atproto/api Agent. + * Handles both password-auth clients and OAuth session-manager clients. */ export function getPdsUrlFromAgent(agent: unknown): string { const a = agent as Record; @@ -174,8 +174,12 @@ export function getPdsUrlFromAgent(agent: unknown): string { const issuer = (a['sessionManager'] as any)?.serverMetadata?.issuer; if (issuer) return issuer.toString(); - // AtpAgent / password-auth agent: direct URL fields. - for (const field of ['dispatchUrl', 'pdsUrl', 'serviceUrl', 'service']) { + // @atproto/lex Client: direct service URL. + const service = a['service']; + if (service && typeof service === 'string') return service; + + // Legacy AtpAgent / password-auth agent: direct URL fields. + for (const field of ['dispatchUrl', 'pdsUrl', 'serviceUrl']) { const v = a[field] ?? (a['sessionManager'] as any)?.[field]; if (v) return v.toString(); } @@ -184,27 +188,27 @@ export function getPdsUrlFromAgent(agent: unknown): string { } /** - * Extract a Bearer token from an agent for authenticated CAR fetches. + * Extract a Bearer token from a client for authenticated CAR fetches. * * Some PDS instances return 401 on com.atproto.sync.getRepo without auth, * even though the spec marks it public. This helper covers both auth shapes: * - * - Password / AtpAgent: agent.session.accessJwt - * - OAuth (browser): agent.sessionManager.getTokens() → accessToken - * - * Returns undefined if the token can't be obtained non-destructively - * (e.g. an expired OAuth session that would need a refresh — callers should - * let the normal agent.* API methods handle that path instead). + * - @atproto/lex Client backed by PasswordSession: session.accessJwt + * - Legacy @atproto/api Agent: agent.session.accessJwt + * - OAuth (browser): agent.sessionManager.getTokens() → accessToken */ export async function getAgentToken(agent: unknown): Promise { const a = agent as Record; - // Password-auth CredentialSession (AtpAgent): - // session.accessJwt holds the current JWT. It may be expired — callers - // should handle CARFetchUnauthorizedError and retry after refreshing. + // @atproto/lex Client with PasswordSession: + // session.accessJwt holds the current JWT. const jwt = (a['session'] as any)?.accessJwt; if (jwt) return jwt as string; + // Legacy @atproto/api Agent / AtpAgent: + const legacyJwt = (a['session'] as any)?.accessJwt; + if (legacyJwt) return legacyJwt as string; + // OAuth agent: session manager exposes getTokens() (non-mutating read). const sm = (a['sessionManager'] as any); if (typeof sm?.getTokens === 'function') { diff --git a/packages/croft-click-core/src/polish.ts b/packages/croft-click-core/src/polish.ts index bde4e48..9a6c856 100644 --- a/packages/croft-click-core/src/polish.ts +++ b/packages/croft-click-core/src/polish.ts @@ -7,13 +7,14 @@ * in malachite) and by malachite-web. */ -import type { Agent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import { RECORD_TYPE, LEGACY_RECORD_TYPE } from './config.js'; import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken } from './car-fetch.js'; import { retryWithBackoff } from './retry-helper.js'; import { RateLimiter } from './rate-limiter.js'; import { ProactiveRatePacer } from './proactive-rate-pacer.js'; import { isRateLimitError, normalizeHeaders } from './rate-limit-headers.js'; +import { com } from '@bsky/sdk/lexicons' export interface PolishRecord { rkey: string; @@ -50,9 +51,9 @@ export interface PolishMigrateOptions { const POINTS_PER_RECORD = 3; const MAX_WRITES_PER_BATCH = 200; -/** Extract DID from any agent shape (credential session or OAuth session manager). */ -function getDid(agent: Agent): string | undefined { - return agent.did ?? (agent as any).sessionManager?.did; +/** Extract DID from a Client. */ +function getDid(client: Client): string { + return client.assertDid } function collectionFromUri(uri: string): string { @@ -120,14 +121,14 @@ export function buildPolishPlan( * Fetch both collections via CAR and build a migration plan. * Read-only — performs no writes. */ -export async function analyzeLegacyRecords(agent: Agent, signal?: AbortSignal): Promise { - const did = getDid(agent); +export async function analyzeLegacyRecords(client: Client, signal?: AbortSignal): Promise { + const did = getDid(client); if (!did) throw new Error('No authenticated session'); signal?.throwIfAborted(); - const pdsUrl = getPdsUrlFromAgent(agent); - const token = await getAgentToken(agent); + const pdsUrl = getPdsUrlFromAgent(client); + const token = await getAgentToken(client); const [legacy, production] = await Promise.all([ fetchRepoViaCAR(pdsUrl, did, LEGACY_RECORD_TYPE, signal, token), @@ -149,11 +150,11 @@ export async function analyzeLegacyRecords(agent: Agent, signal?: AbortSignal): * data is ever lost — re-running polish will finish the job. */ export async function migrateLegacyRecords( - agent: Agent, + client: Client, plan: PolishPlan, opts: PolishMigrateOptions = {} ): Promise { - const did = getDid(agent); + const did = getDid(client); if (!did) throw new Error('No authenticated session'); const { dryRun = false, onProgress, signal } = opts; @@ -191,9 +192,9 @@ export async function migrateLegacyRecords( })); try { - const response = await retryWithBackoff( + const response = (await retryWithBackoff( async () => - await agent.com.atproto.repo.applyWrites( + await client.call(com.atproto.repo.applyWrites.main as any, { repo: did, writes: writes as any, @@ -218,7 +219,7 @@ export async function migrateLegacyRecords( '504', ], } - ); + )) as any; try { const respHeaders = (response as any)?.headers as Record | undefined; @@ -229,7 +230,7 @@ export async function migrateLegacyRecords( // ignore header parse errors } - const results = (response.data.results ?? []) as any[]; + const results = (response.results ?? []) as any[]; for (let j = 0; j < batch.length; j++) { const result = results[j]; if (result && !('error' in result)) { @@ -296,9 +297,9 @@ export async function migrateLegacyRecords( })); try { - const response = await retryWithBackoff( + const response = (await retryWithBackoff( async () => - await agent.com.atproto.repo.applyWrites( + await client.call(com.atproto.repo.applyWrites.main as any, { repo: did, writes: writes as any, @@ -323,7 +324,7 @@ export async function migrateLegacyRecords( '504', ], } - ); + )) as any; try { const respHeaders = (response as any)?.headers as Record | undefined; @@ -334,7 +335,7 @@ export async function migrateLegacyRecords( // ignore header parse errors } - const results = (response.data.results ?? []) as any[]; + const results = (response.results ?? []) as any[]; for (let j = 0; j < batch.length; j++) { const result = results[j]; if (result && !('error' in result)) { diff --git a/packages/croft-click-core/src/publisher.ts b/packages/croft-click-core/src/publisher.ts index 647369f..c8c7287 100644 --- a/packages/croft-click-core/src/publisher.ts +++ b/packages/croft-click-core/src/publisher.ts @@ -8,8 +8,9 @@ * The CLI wrapper in src/lib/publisher.ts adapts this to terminal UI. */ -import type { Agent } from '@atproto/api'; -import type { PlayRecord } from './types.js'; +import type { Client } from '@atproto/lex' +import type { PlayRecord } from './types.js' +import { com } from '@bsky/sdk/lexicons' import { RECORD_TYPE, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from './config.js'; import { RateLimiter } from './rate-limiter.js'; import { ProactiveRatePacer } from './proactive-rate-pacer.js'; @@ -60,7 +61,7 @@ function extractHeaders(response: unknown): Record { } export async function publishRecords( - agent: Agent, + client: Client, records: PlayRecord[], dryRun: boolean, callbacks: PublisherCallbacks, @@ -185,8 +186,8 @@ export async function publishRecords( try { const response = await retryWithBackoff( - () => agent.com.atproto.repo.applyWrites( - { repo: agent.did ?? (agent as any).sessionManager?.did ?? '', writes: writes as any }, + () => client.call(com.atproto.repo.applyWrites.main as any, + { repo: client.assertDid, writes: writes as any }, { signal: ac.signal } ), { @@ -213,7 +214,7 @@ export async function publishRecords( ); // Success! - const batchSuccessCount = (response.data as any).results?.length ?? batch.length; + const batchSuccessCount = (response as any).results?.length ?? batch.length; successCount += batchSuccessCount; const batchDuration = Date.now() - batchStartTime; diff --git a/packages/croft-click-core/src/sync.ts b/packages/croft-click-core/src/sync.ts index 8da34b8..936ee8a 100644 --- a/packages/croft-click-core/src/sync.ts +++ b/packages/croft-click-core/src/sync.ts @@ -4,10 +4,11 @@ * No CLI UI or caching; those are added by the CLI wrapper in src/lib/sync.ts. */ -import type { Agent } from '@atproto/api'; -import type { PlayRecord } from './types.js'; -import { RECORD_TYPES } from './config.js'; -import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken, CARFetchUnauthorizedError } from './car-fetch.js'; +import type { Client } from '@atproto/lex' +import type { PlayRecord } from './types.js' +import { RECORD_TYPES } from './config.js' +import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken, CARFetchUnauthorizedError } from './car-fetch.js' +import { com } from '@bsky/sdk/lexicons' export interface ExistingRecord { uri: string; @@ -44,18 +45,18 @@ function collectionFromUri(uri: string): string { return uri.split('/').slice(3, -1).join('/'); } -/** Extract DID from any agent shape (credential session or OAuth session manager). */ -function getDid(agent: Agent): string | undefined { - return agent.did ?? (agent as any).sessionManager?.did; +/** Extract DID from a Client. */ +function getDid(client: Client): string { + return client.assertDid } export async function fetchExistingRecords( - agent: Agent, + client: Client, onProgress?: (fetched: number) => void, forceRefresh = false, signal?: AbortSignal ): Promise> { - const did = getDid(agent); + const did = getDid(client); if (!did) throw new Error('No authenticated session'); if (!forceRefresh && sessionCache.has(did)) { @@ -64,23 +65,19 @@ export async function fetchExistingRecords( signal?.throwIfAborted(); - const pdsUrl = getPdsUrlFromAgent(agent); - let token = await getAgentToken(agent); + const pdsUrl = getPdsUrlFromAgent(client); + let token = await getAgentToken(client); let carRecords: Awaited>; try { carRecords = await fetchPlayRecords(pdsUrl, did, signal, token); } catch (err) { if (err instanceof CARFetchUnauthorizedError) { - // The token we sent was invalid or expired. Try to silently refresh the - // session (works for both CredentialSession / AtpAgent and OAuth agents - // that expose a refreshSession method on their session manager) then - // retry the CAR fetch exactly once before giving up. - const sm = (agent as any)?.sessionManager; + const sm = (client as any)?.sessionManager; let retried = false; if (typeof sm?.refreshSession === 'function') { try { await sm.refreshSession(); - const freshToken = await getAgentToken(agent); + const freshToken = await getAgentToken(client); if (freshToken && freshToken !== token) { carRecords = await fetchPlayRecords(pdsUrl, did, signal, freshToken); token = freshToken; @@ -91,7 +88,6 @@ export async function fetchExistingRecords( } } if (!retried) { - // Clear the stale session cache so the next call starts clean. sessionCache.delete(did); throw err; } @@ -119,28 +115,28 @@ export function filterNewRecords( } export async function fetchAllRecordsForDedup( - agent: Agent, + client: Client, onProgress?: (fetched: number) => void, signal?: AbortSignal ): Promise { - const did = getDid(agent); + const did = getDid(client); if (!did) throw new Error('No authenticated session'); signal?.throwIfAborted(); - const pdsUrl = getPdsUrlFromAgent(agent); - let token = await getAgentToken(agent); + const pdsUrl = getPdsUrlFromAgent(client); + let token = await getAgentToken(client); let carRecords: Awaited>; try { carRecords = await fetchPlayRecords(pdsUrl, did, signal, token); } catch (err) { if (err instanceof CARFetchUnauthorizedError) { - const sm = (agent as any)?.sessionManager; + const sm = (client as any)?.sessionManager; let retried = false; if (typeof sm?.refreshSession === 'function') { try { await sm.refreshSession(); - const freshToken = await getAgentToken(agent); + const freshToken = await getAgentToken(client); if (freshToken && freshToken !== token) { carRecords = await fetchPlayRecords(pdsUrl, did, signal, freshToken); token = freshToken; @@ -184,7 +180,7 @@ export function findDuplicateGroups(records: ExistingRecord[]): DedupGroup[] { } export async function removeDuplicateRecords( - agent: Agent, + client: Client, groups: DedupGroup[], onProgress?: (removed: number) => void, signal?: AbortSignal @@ -194,8 +190,8 @@ export async function removeDuplicateRecords( for (const rec of group.records.slice(1)) { signal?.throwIfAborted(); try { - await agent.com.atproto.repo.deleteRecord( - { repo: getDid(agent) ?? '', collection: collectionFromUri(rec.uri), rkey: rec.uri.split('/').pop()! }, + await client.call(com.atproto.repo.deleteRecord.main as any, + { repo: getDid(client)!, collection: collectionFromUri(rec.uri), rkey: rec.uri.split('/').pop()! }, { signal } ); removed++; diff --git a/packages/jasper-web/package.json b/packages/jasper-web/package.json index 747cdba..2b3061c 100644 --- a/packages/jasper-web/package.json +++ b/packages/jasper-web/package.json @@ -1,62 +1,64 @@ { - "name": "@ewanc26/jasper-web", - "version": "0.4.0", - "description": "Web frontend for Jasper — import Instagram photos, stories, and videos to Grain or Spark", - "author": "Ewan Croft", - "license": "AGPL-3.0-only", - "private": true, - "type": "module", - "keywords": [ - "instagram", - "grain", - "atproto", - "bluesky", - "svelte", - "sveltekit" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/ewanc26/pkgs.git", - "directory": "packages/jasper-web" - }, - "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/jasper-web", - "bugs": { - "url": "https://github.com/ewanc26/pkgs/issues", - "email": "contact@ewancroft.uk" - }, - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check .", - "format": "prettier --write ." - }, - "dependencies": { - "@atproto/api": "^0.19.3", - "@atproto/common-web": "^0.4.12", - "@atproto/oauth-client-browser": "^0.3.41", - "@ewanc26/jasper": "workspace:*", - "@zip.js/zip.js": "^2.7.57", - "@ewanc26/landing-ui": "workspace:*", - "@lucide/svelte": "^0.575.0" - }, - "devDependencies": { - "@sveltejs/adapter-vercel": "^6.3.1", - "@sveltejs/kit": "^2.50.2", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^25.6.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.4.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.51.0", - "svelte-check": "^4.3.6", - "tailwindcss": "^4.1.18", - "typescript": "^5.9.3", - "vercel": "^50.44.0", - "vite": "^7.3.1" - } + "name": "@ewanc26/jasper-web", + "version": "0.4.0", + "description": "Web frontend for Jasper \u2014 import Instagram photos, stories, and videos to Grain or Spark", + "author": "Ewan Croft", + "license": "AGPL-3.0-only", + "private": true, + "type": "module", + "keywords": [ + "instagram", + "grain", + "atproto", + "bluesky", + "svelte", + "sveltekit" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ewanc26/pkgs.git", + "directory": "packages/jasper-web" + }, + "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/jasper-web", + "bugs": { + "url": "https://github.com/ewanc26/pkgs/issues", + "email": "contact@ewancroft.uk" + }, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write ." + }, + "dependencies": { + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", + "@atproto/common-web": "^0.4.12", + "@atproto/oauth-client-browser": "^0.3.41", + "@ewanc26/jasper": "workspace:*", + "@zip.js/zip.js": "^2.7.57", + "@ewanc26/landing-ui": "workspace:*", + "@lucide/svelte": "^0.575.0" + }, + "devDependencies": { + "@sveltejs/adapter-vercel": "^6.3.1", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.6.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.51.0", + "svelte-check": "^4.3.6", + "tailwindcss": "^4.1.18", + "typescript": "^5.9.3", + "vercel": "^50.44.0", + "vite": "^7.3.1" + } } diff --git a/packages/jasper-web/src/lib/core/oauth.ts b/packages/jasper-web/src/lib/core/oauth.ts index 6b3ce26..987d25d 100644 --- a/packages/jasper-web/src/lib/core/oauth.ts +++ b/packages/jasper-web/src/lib/core/oauth.ts @@ -4,7 +4,7 @@ */ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; -import { Agent } from '@atproto/api'; +import { Client } from '@atproto/lex'; // The loopback redirect_uri must use 127.0.0.1, not localhost — RFC 8252 // explicitly disallows the localhost hostname in loopback redirect URIs. @@ -43,13 +43,13 @@ function getClient(): Promise { /** * Call once on mount on the /import page. * Processes any OAuth callback params in the URL and restores stored sessions. - * Returns an Agent if a session is active, or null if the user still needs to sign in. + * Returns a Client if a session is active, or null if the user still needs to sign in. */ -export async function initOAuth(): Promise { +export async function initOAuth(): Promise { const client = await getClient(); const result = await client.init(); if (!result) return null; - return new Agent(result.session); + return new Client(result.session); } /** diff --git a/packages/jasper-web/src/routes/import/+page.svelte b/packages/jasper-web/src/routes/import/+page.svelte index 7b64652..9ca79f9 100644 --- a/packages/jasper-web/src/routes/import/+page.svelte +++ b/packages/jasper-web/src/routes/import/+page.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { fly } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; - import type { Agent } from '@atproto/api'; + import type { Client } from '@atproto/lex'; + import { com } from '@bsky/sdk/lexicons'; import { initOAuth, signInWithOAuth } from '$lib/core/oauth'; import { runImport, @@ -40,7 +41,7 @@ let step = $state(0); let prevStep = $state(0); - let agent = $state(null); + let agent = $state(null); let loading = $state(true); let error = $state(null); let profile = $state<{ displayName?: string; description?: string; avatar?: string } | null>( @@ -344,15 +345,13 @@ agent = await initOAuth(); if (agent) { // Fetch profile record from PDS - const profileResult = await agent.com.atproto.repo - .getRecord({ - repo: agent.did ?? '', - collection: 'app.bsky.actor.profile', - rkey: 'self' - }) - .catch(() => null); - - const profileRecord = profileResult?.data?.value as + const profileResult = (await agent.call(com.atproto.repo.getRecord.main as any, { + repo: agent.assertDid, + collection: 'app.bsky.actor.profile', + rkey: 'self' + })) as any; + + const profileRecord = profileResult?.value as | { displayName?: string; description?: string; @@ -365,8 +364,8 @@ let avatarUrl = undefined; const cid = (profileRecord?.avatar as { ref?: { $link?: string } } | undefined)?.ref?.$link; - if (cid && agent.did) { - avatarUrl = `https://cdn.bsky.app/img/avatar/plain/${agent.did}/${cid}@jpeg`; + if (cid && agent.assertDid) { + avatarUrl = `https://cdn.bsky.app/img/avatar/plain/${agent.assertDid}/${cid}@jpeg`; } profile = { diff --git a/packages/jasper-web/vite.config.ts b/packages/jasper-web/vite.config.ts index 53aedab..7fd9e8d 100644 --- a/packages/jasper-web/vite.config.ts +++ b/packages/jasper-web/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, optimizeDeps: { - include: ['@atproto/api', '@atproto/common-web'] + include: ['@atproto/common-web'] }, build: { diff --git a/packages/jasper/package.json b/packages/jasper/package.json index 6286cf5..932aaf3 100644 --- a/packages/jasper/package.json +++ b/packages/jasper/package.json @@ -53,7 +53,9 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@atproto/api": "^0.19.3", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@atproto/oauth-client-node": "^0.3.16", "@ewanc26/croft-click-core": "workspace:*", "@ewanc26/tid": "workspace:*", diff --git a/packages/jasper/src/core/types.ts b/packages/jasper/src/core/types.ts index 6bb016a..16b3eb7 100644 --- a/packages/jasper/src/core/types.ts +++ b/packages/jasper/src/core/types.ts @@ -2,7 +2,7 @@ * Core type definitions for Jasper */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; // ============================================ // Grain Types @@ -333,4 +333,4 @@ export interface Config { MIN_UPLOAD_DELAY: number; } -export type { Agent }; +export type { Client }; diff --git a/packages/jasper/src/index.ts b/packages/jasper/src/index.ts index 10dfabf..acd9e8a 100644 --- a/packages/jasper/src/index.ts +++ b/packages/jasper/src/index.ts @@ -63,6 +63,7 @@ import { } from "./lib/import-state.js"; import path from "path"; import fs from "fs"; +import { com } from '@bsky/sdk/lexicons' /** * Run interactive mode @@ -832,8 +833,8 @@ async function runImport(options: { if (!options.dryRun && imported > 0) { try { - await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + await agent.call(com.atproto.repo.createRecord, { + repo: agent.assertDid!, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', diff --git a/packages/jasper/src/lib/auth.ts b/packages/jasper/src/lib/auth.ts index 73c6ffe..26ddb34 100644 --- a/packages/jasper/src/lib/auth.ts +++ b/packages/jasper/src/lib/auth.ts @@ -2,7 +2,9 @@ * Authentication wrapper for Jasper * Supports OAuth (recommended) and app password fallback */ -import { AtpAgent, Agent } from "@atproto/api"; +import { Client } from '@atproto/lex' +import { PasswordSession } from '@atproto/lex-password-session' +import { api } from '@bsky/sdk' import { prompt, isNonInteractive } from "../utils/input.js"; import * as ui from "../utils/ui.js"; import { log } from "../utils/logger.js"; @@ -107,7 +109,7 @@ export async function resolveIdentity( export async function loginWithPassword( identifier: string, password: string, -): Promise { +): Promise { ui.header("Jasper Login"); ui.startSpinner("Resolving identity..."); @@ -115,33 +117,18 @@ export async function loginWithPassword( ui.succeedSpinner(`Resolved to ${did}`); ui.startSpinner("Authenticating..."); - const agent = new AtpAgent({ service: pds }); - - try { - await agent.login({ identifier: did, password }); - ui.succeedSpinner("Logged in successfully!"); - ui.keyValue("DID", agent.session?.did || "unknown"); - ui.keyValue("Handle", agent.session?.handle || "unknown"); - - return agent; - } catch (error) { - ui.failSpinner("Login failed"); - const err = error as Error; - - if (err.message.includes("AuthFactorTokenRequired")) { - throw new Error( - "Two-factor authentication required. Please use your app password.", - ); - } else if (err.message.includes("AccountTakedown")) { - throw new Error("Account is suspended or has been taken down."); - } else if (err.message.includes("InvalidCredentials")) { - throw new Error( - "Invalid credentials. Please check your handle and app password.", - ); - } else { - throw new Error(`Login failed: ${err.message}`); - } - } + const session = await PasswordSession.login({ + service: pds, + identifier: did, + password, + }); + const client = new Client(session, { service: api.app.service }); + + ui.succeedSpinner("Logged in successfully!"); + ui.keyValue("DID", client.assertDid); + ui.keyValue("Handle", (session as any).handle || "unknown"); + + return client; } /** @@ -170,17 +157,17 @@ export async function authenticate( handle?: string, password?: string, useOAuth = true, -): Promise { +): Promise { // Try to restore existing OAuth session if (useOAuth) { const sessions = await listOAuthSessions(); if (sessions.length > 0) { const did = handle?.startsWith("did:") ? handle : sessions[0]!; log.info(`Attempting to restore OAuth session for ${did}...`); - const agent = await restoreOAuthSession(did); - if (agent) { + const client = await restoreOAuthSession(did); + if (client) { log.info("Restored OAuth session successfully."); - return agent; + return client; } log.warn("Could not restore OAuth session."); } diff --git a/packages/jasper/src/lib/browser.ts b/packages/jasper/src/lib/browser.ts index c1826c3..e43d9f0 100644 --- a/packages/jasper/src/lib/browser.ts +++ b/packages/jasper/src/lib/browser.ts @@ -38,7 +38,8 @@ import { uploadSparkVideo, publishSparkVideoPost, } from "./spark-video-publisher.js"; -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; +import { com } from '@bsky/sdk/lexicons'; import type { Target, SparkAspectRatio } from "../core/types.js"; import { GRAIN_GALLERY_ITEM_COLLECTION } from "../core/config.js"; import { @@ -311,7 +312,7 @@ function parsePost(rawPost: InstagramExportPost, isStory = false): ParsedPost | * Browser-compatible publishPhoto that accepts Blob */ export async function publishPhotoFromBlob( - agent: Agent, + client: Client, imageBlob: Blob, aspectRatio: { width: number; height: number }, createdAt: string, @@ -322,7 +323,7 @@ export async function publishPhotoFromBlob( const arrayBuffer = await imageBlob.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); - return publishPhoto(agent, uint8Array, aspectRatio, createdAt, alt, dryRun); + return publishPhoto(client, uint8Array, aspectRatio, createdAt, alt, dryRun); } /** @@ -344,7 +345,7 @@ interface FlushGrainBatchResult { * gallery items that should be created for successful photos. */ async function flushGrainPhotoBatch( - agent: Agent, + client: Client, photoBatch: PhotoInput[], galleryUri: string | null, dryRun: boolean, @@ -353,7 +354,7 @@ async function flushGrainPhotoBatch( return { successes: [], uris: [], galleryItemInputs: [] }; } - const results = await publishPhotos(agent, photoBatch, dryRun); + const results = await publishPhotos(client, photoBatch, dryRun); const successes: boolean[] = []; const uris: string[] = []; @@ -382,8 +383,8 @@ async function flushGrainPhotoBatch( /** * Fetch user's existing galleries */ -export async function fetchUserGalleries(agent: Agent): Promise { - const galleries = await getExistingGalleries(agent); +export async function fetchUserGalleries(client: Client): Promise { + const galleries = await getExistingGalleries(client); return galleries.map((g) => ({ uri: g.uri, title: g.title, @@ -395,12 +396,12 @@ export async function fetchUserGalleries(agent: Agent): Promise { * Create a new gallery */ export async function createNewGallery( - agent: Agent, + client: Client, title: string, description?: string, dryRun = false, ): Promise<{ success: boolean; uri?: string; error?: string }> { - const result = await createGallery(agent, title, description, dryRun); + const result = await createGallery(client, title, description, dryRun); return { success: result.success, uri: result.uri, error: result.error }; } @@ -408,7 +409,7 @@ export async function createNewGallery( * Find orphan photos (photos not in any gallery) * These would be from imports before the gallery fix */ -export async function fetchOrphanPhotos(agent: Agent): Promise { +export async function fetchOrphanPhotos(client: Client): Promise { const orphans: OrphanPhoto[] = []; try { @@ -416,8 +417,8 @@ export async function fetchOrphanPhotos(agent: Agent): Promise { const galleryItemUris = new Set(); let cursor: string | undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result: any = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: GRAIN_GALLERY_ITEM_COLLECTION, limit: 100, cursor, @@ -436,8 +437,8 @@ export async function fetchOrphanPhotos(agent: Agent): Promise { // Find photos not in any gallery cursor = undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result: any = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: "social.grain.photo", limit: 100, cursor, @@ -468,7 +469,7 @@ export async function fetchOrphanPhotos(agent: Agent): Promise { * Add orphan photos to a gallery */ export async function organizeOrphanPhotos( - agent: Agent, + client: Client, galleryUri: string, orphanUris: string[], dryRun = false, @@ -497,7 +498,7 @@ export async function organizeOrphanPhotos( try { logger.info(`Adding photo to gallery...`); const result = await createGalleryItem( - agent, + client, galleryUri, photoUri, i, @@ -532,11 +533,11 @@ async function getImageDimensionsFromBlob( } /** - * Wrap an ATProto Agent to capture rate-limit headers from all XRPC responses. - * The proxy intercepts method calls on the agent (uploadBlob, com.atproto.repo.*, etc.) + * Wrap an ATProto Client to capture rate-limit headers from all XRPC responses. + * The proxy intercepts method calls on the client (uploadBlob, com.atproto.repo.*, etc.) * and feeds response headers into a RateLimiter for server-driven burst protection. */ -function withRateLimitCapture(agent: Agent, rateLimiter: RateLimiter): Agent { +function withRateLimitCapture(client: Client, rateLimiter: RateLimiter): Client { const wrapPromise = (p: Promise): Promise => p.then( (res) => { @@ -580,7 +581,7 @@ function withRateLimitCapture(agent: Agent, rateLimiter: RateLimiter): Agent { }, }; - return new Proxy(agent, handler) as Agent; + return new Proxy(client, handler) as Client; } /** @@ -588,7 +589,7 @@ function withRateLimitCapture(agent: Agent, rateLimiter: RateLimiter): Agent { * Supports gallery selection, batch limiting, state persistence, and alt text override */ export async function runImport( - agent: Agent, + client: Client, file: File, dryRun: boolean, galleryUri: string | null, @@ -618,7 +619,7 @@ export async function runImport( : log; const rateLimiter = new RateLimiter(); - agent = withRateLimitCapture(agent, rateLimiter); + client = withRateLimitCapture(client, rateLimiter); try { logger.info("Parsing Instagram export..."); @@ -633,11 +634,11 @@ export async function runImport( ); const existingPhotos = target === "spark" - ? await getExistingSparkPosts(agent) - : await getExistingPhotos(agent); + ? await getExistingSparkPosts(client) + : await getExistingPhotos(client); const existingStories = target === "spark" - ? await getExistingSparkStories(agent) + ? await getExistingSparkStories(client) : new Set(); logger.info( `Found ${existingPhotos.size} existing ${target === "spark" ? "posts" : "photos"}${existingStories.size > 0 ? `, ${existingStories.size} stories` : ""}`, @@ -679,7 +680,7 @@ export async function runImport( ); } else if (galleryUri) { // Try to get gallery title - const galleries = await getExistingGalleries(agent); + const galleries = await getExistingGalleries(client); const gallery = galleries.find((g) => g.uri === galleryUri); galleryTitle = gallery?.title || "Unknown Gallery"; @@ -798,7 +799,7 @@ export async function runImport( // Flush any pending Grain batch so batchCount is accurate for the daily limit check if (target === "grain" && grainPhotoBatch.length > 0) { const flushResult = await flushGrainPhotoBatch( - agent, grainPhotoBatch, galleryUri, dryRun, + client, grainPhotoBatch, galleryUri, dryRun, ); // Assign gallery item positions @@ -810,7 +811,7 @@ export async function runImport( // Batch-create gallery items if (flushResult.galleryItemInputs.length > 0) { const itemResults = await createGalleryItems( - agent, flushResult.galleryItemInputs, dryRun, + client, flushResult.galleryItemInputs, dryRun, ); for (let gi = 0; gi < itemResults.length; gi++) { if (itemResults[gi].success) { @@ -878,7 +879,7 @@ export async function runImport( await videoMedia.data!.arrayBuffer(), ); const uploadResult = await uploadSparkVideo( - agent, + client, videoData, videoMedia.data!.type || "video/mp4", ); @@ -903,7 +904,7 @@ export async function runImport( const { publishSparkVideoStory } = await import("./spark-story-publisher.js"); const result = await publishSparkVideoStory( - agent, + client, uploadResult.blob, getAltText(post.caption), aspectRatio, @@ -924,7 +925,7 @@ export async function runImport( } } else { const result = await publishSparkVideoPost( - agent, + client, uploadResult.blob, getAltText(post.caption), aspectRatio, @@ -989,14 +990,14 @@ export async function runImport( let result; if (isStory) { result = await publishSparkStory( - agent, + client, imageItems.slice(0, 12), timestamp, dryRun, ); } else { result = await publishSparkPost( - agent, + client, imageItems.slice(0, 12), timestamp, post.caption, @@ -1054,7 +1055,7 @@ export async function runImport( // Flush remaining Grain batch (if any) if (target === "grain" && grainPhotoBatch.length > 0) { const flushResult = await flushGrainPhotoBatch( - agent, grainPhotoBatch, galleryUri, dryRun, + client, grainPhotoBatch, galleryUri, dryRun, ); // Assign gallery item positions using the accumulated timestamp order @@ -1067,7 +1068,7 @@ export async function runImport( let galleryItemSuccessCount = 0; if (flushResult.galleryItemInputs.length > 0) { const itemResults = await createGalleryItems( - agent, flushResult.galleryItemInputs, dryRun, + client, flushResult.galleryItemInputs, dryRun, ); for (let gi = 0; gi < itemResults.length; gi++) { if (itemResults[gi].success) { @@ -1107,8 +1108,8 @@ export async function runImport( if (!dryRun && photosImported > 0) { try { - await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid!, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', diff --git a/packages/jasper/src/lib/oauth-login.ts b/packages/jasper/src/lib/oauth-login.ts index 1fc015c..65f7ff3 100644 --- a/packages/jasper/src/lib/oauth-login.ts +++ b/packages/jasper/src/lib/oauth-login.ts @@ -7,7 +7,7 @@ */ import http from "node:http"; -import { Agent } from "@atproto/api"; +import { Client } from '@atproto/lex' import * as ui from "../utils/ui.js"; import { prompt } from "../utils/input.js"; import { @@ -111,7 +111,7 @@ function waitForCallback(): Promise { * Resolves the identity, opens the browser, waits for the callback, * exchanges the code for a session, and saves it to disk. */ -export async function loginWithOAuth(handle?: string): Promise { +export async function loginWithOAuth(handle?: string): Promise { ui.header("Jasper OAuth Login"); if (!handle) { @@ -165,18 +165,18 @@ export async function loginWithOAuth(handle?: string): Promise { ui.info("Your session will refresh automatically when needed."); console.log(""); - return new Agent(session); + return new Client(session); } /** * Restore a stored OAuth session for the given DID. * Returns null if no session is stored or if the refresh fails. */ -export async function restoreOAuthSession(did: string): Promise { +export async function restoreOAuthSession(did: string): Promise { try { const client = await getOAuthClient(); const session = await client.restore(did); - return new Agent(session); + return new Client(session); } catch { return null; } diff --git a/packages/jasper/src/lib/publisher.ts b/packages/jasper/src/lib/publisher.ts index 584b4ed..d5ba875 100644 --- a/packages/jasper/src/lib/publisher.ts +++ b/packages/jasper/src/lib/publisher.ts @@ -5,7 +5,7 @@ * Supports batch record creation via com.atproto.repo.applyWrites * for efficient bulk imports (avoids per-record rate limit costs). */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; import { generateTID } from "@ewanc26/tid"; import type { ParsedPost } from "../core/types.js"; import { config, GRAIN_GALLERY_COLLECTION, GRAIN_GALLERY_ITEM_COLLECTION } from "../core/config.js"; @@ -13,6 +13,7 @@ import { log } from "../utils/logger.js"; import { processImageBrowser, } from "./browser-image-utils.js"; +import { com } from '@bsky/sdk/lexicons' /** * Result of publishing a single photo @@ -68,25 +69,28 @@ export interface GalleryItemInput { */ const APPLY_WRITES_MAX = 200; -/** - * Upload an image as a blob - */ async function uploadBlob( - agent: Agent, + client: Client, imageData: Uint8Array, mimeType: string, ): Promise<{ cid: string; mimeType: string }> { - const uploadResult = await agent.uploadBlob(imageData, { - encoding: mimeType, + const form = new FormData(); + form.append('file', new Blob([imageData as any], { type: mimeType })); + + const res = await fetch(`${(client as any).service || 'https://bsky.social'}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: (client as any).session?.accessJwt ? { Authorization: `Bearer ${(client as any).session.accessJwt}` } : undefined, + body: form, }); - if (!uploadResult.data.blob) { - throw new Error("No blob returned from upload"); + if (!res.ok) { + throw new Error(`Blob upload failed: ${res.status}`); } + const data = await res.json() as { blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } }; return { - cid: uploadResult.data.blob.ref.toString(), - mimeType: uploadResult.data.blob.mimeType, + cid: data.blob.ref.toString(), + mimeType: data.blob.mimeType, }; } @@ -96,14 +100,14 @@ async function uploadBlob( * This is a thin wrapper around publishPhotos for backward compatibility. */ export async function publishPhoto( - agent: Agent, + client: Client, imageData: Buffer | Uint8Array, aspectRatio: { width: number; height: number }, createdAt: string, alt?: string, dryRun = false, ): Promise { - const results = await publishPhotos(agent, [{ + const results = await publishPhotos(client, [{ imageData: imageData instanceof Buffer ? new Uint8Array(imageData) : imageData, aspectRatio, createdAt, @@ -124,7 +128,7 @@ export async function publishPhoto( * reducing per-record overhead and rate-limit consumption. */ export async function publishPhotos( - agent: Agent, + client: Client, photos: PhotoInput[], dryRun = false, ): Promise { @@ -157,7 +161,7 @@ export async function publishPhotos( // Upload blob const blob = await uploadBlob( - agent, + client, processed.processed, processed.mimeType, ); @@ -193,12 +197,12 @@ export async function publishPhotos( for (let chunkStart = 0; chunkStart < writes.length; chunkStart += APPLY_WRITES_MAX) { const chunk = writes.slice(chunkStart, chunkStart + APPLY_WRITES_MAX); try { - const response = await agent.com.atproto.repo.applyWrites({ - repo: agent.did!, + const response = await client.call(com.atproto.repo.applyWrites, { + repo: client.assertDid, writes: chunk as any, }); - const respResults = (response.data as any)?.results as Array<{ uri: string; cid: string }> | undefined; + const respResults = (response as any)?.results as Array<{ uri: string; cid: string }> | undefined; if (respResults) { // Map results back to the correct positions @@ -236,20 +240,20 @@ export async function publishPhotos( /** * Check for existing photos to avoid duplicates */ -export async function getExistingPhotos(agent: Agent): Promise> { +export async function getExistingPhotos(client: Client): Promise> { const existing = new Set(); try { let cursor: string | undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: config.GRAIN_PHOTO_COLLECTION, limit: 100, cursor, }); - for (const record of result.data.records) { + for (const record of result.records) { // Extract createdAt from the record const value = record.value as { createdAt?: string }; if (value.createdAt) { @@ -257,7 +261,7 @@ export async function getExistingPhotos(agent: Agent): Promise> { } } - cursor = result.data.cursor; + cursor = result.cursor; } while (cursor); } catch (error) { log.warn( @@ -303,7 +307,7 @@ export async function loadPostMedia( * Create a new gallery */ export async function createGallery( - agent: Agent, + client: Client, title: string, description?: string, dryRun = false, @@ -322,8 +326,8 @@ export async function createGallery( createdAt: now, }; - const result = await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid!, collection: GRAIN_GALLERY_COLLECTION, rkey: generateTID(now), record, @@ -331,8 +335,8 @@ export async function createGallery( return { success: true, - uri: result.data.uri, - cid: result.data.cid, + uri: result.uri, + cid: result.cid, }; } catch (error) { return { @@ -348,14 +352,14 @@ export async function createGallery( * This is a thin wrapper around createGalleryItems for backward compatibility. */ export async function createGalleryItem( - agent: Agent, + client: Client, galleryUri: string, photoUri: string, position: number, createdAt: string, dryRun = false, ): Promise { - const results = await createGalleryItems(agent, [{ + const results = await createGalleryItems(client, [{ galleryUri, photoUri, position, @@ -372,7 +376,7 @@ export async function createGalleryItem( * record creation operation. */ export async function createGalleryItems( - agent: Agent, + client: Client, items: GalleryItemInput[], dryRun = false, ): Promise { @@ -404,12 +408,12 @@ export async function createGalleryItems( })); try { - const response = await agent.com.atproto.repo.applyWrites({ - repo: agent.did!, + const response = await client.call(com.atproto.repo.applyWrites, { + repo: client.assertDid!, writes: writes as any, }); - const respResults = (response.data as any)?.results as Array<{ uri: string; cid: string }> | undefined; + const respResults = (response as any)?.results as Array<{ uri: string; cid: string }> | undefined; if (respResults) { for (let j = 0; j < respResults.length; j++) { @@ -426,7 +430,7 @@ export async function createGalleryItems( const idx = chunkStart + j; results[idx] = { success: true, - uri: `at://${agent.did}/${GRAIN_GALLERY_ITEM_COLLECTION}/${generateTID(chunk[j].createdAt)}`, + uri: `at://${client.assertDid}/${GRAIN_GALLERY_ITEM_COLLECTION}/${generateTID(chunk[j].createdAt)}`, cid: '', }; } @@ -448,20 +452,20 @@ export async function createGalleryItems( /** * Get existing galleries for the user */ -export async function getExistingGalleries(agent: Agent): Promise { +export async function getExistingGalleries(client: Client): Promise { const galleries: GalleryInfo[] = []; try { let cursor: string | undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: GRAIN_GALLERY_COLLECTION, limit: 100, cursor, }); - for (const record of result.data.records) { + for (const record of result.records) { const value = record.value as { title?: string; createdAt?: string }; galleries.push({ uri: record.uri, @@ -470,7 +474,7 @@ export async function getExistingGalleries(agent: Agent): Promise }); } - cursor = result.data.cursor; + cursor = result.cursor; } while (cursor); } catch (error) { log.warn("Could not fetch existing galleries"); diff --git a/packages/jasper/src/lib/rate-limited-publisher.ts b/packages/jasper/src/lib/rate-limited-publisher.ts index 1ad71ff..991ee18 100644 --- a/packages/jasper/src/lib/rate-limited-publisher.ts +++ b/packages/jasper/src/lib/rate-limited-publisher.ts @@ -2,7 +2,7 @@ * Rate-limited publisher wrapper * Integrates Malachite's RateLimiter with Jasper's publishing */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; import { RateLimiter, isRateLimitError } from "@ewanc26/croft-click-core"; import { publishPhoto, @@ -42,12 +42,12 @@ export const OPERATION_POINTS = { */ export class RateLimitedPublisher { private rateLimiter: RateLimiter; - private agent: Agent; + private client: Client; private dryRun: boolean; private cancelled = false; - constructor(agent: Agent, dryRun = false, headroom = 0.15) { - this.agent = agent; + constructor(client: Client, dryRun = false, headroom = 0.15) { + this.client = client; this.dryRun = dryRun; this.rateLimiter = new RateLimiter({ headroom }); } @@ -105,7 +105,7 @@ export class RateLimitedPublisher { try { const result = await publishPhoto( - this.agent, + this.client, imageData, aspectRatio, createdAt, @@ -121,7 +121,7 @@ export class RateLimitedPublisher { // Wait and retry once await this.waitForQuota(OPERATION_POINTS.PHOTO); return publishPhoto( - this.agent, + this.client, imageData, aspectRatio, createdAt, @@ -143,13 +143,13 @@ export class RateLimitedPublisher { await this.waitForQuota(OPERATION_POINTS.GALLERY); try { - return await createGallery(this.agent, title, description, this.dryRun); + return await createGallery(this.client, title, description, this.dryRun); } catch (error) { if (isRateLimitError(error)) { log.warn("Rate limit hit, waiting for reset..."); this.rateLimiter.handleRateLimitHit(); await this.waitForQuota(OPERATION_POINTS.GALLERY); - return createGallery(this.agent, title, description, this.dryRun); + return createGallery(this.client, title, description, this.dryRun); } throw error; } @@ -168,7 +168,7 @@ export class RateLimitedPublisher { try { return await createGalleryItem( - this.agent, + this.client, galleryUri, photoUri, position, @@ -181,7 +181,7 @@ export class RateLimitedPublisher { this.rateLimiter.handleRateLimitHit(); await this.waitForQuota(OPERATION_POINTS.GALLERY_ITEM); return createGalleryItem( - this.agent, + this.client, galleryUri, photoUri, position, diff --git a/packages/jasper/src/lib/spark-publisher.ts b/packages/jasper/src/lib/spark-publisher.ts index 96f6719..5bad14b 100644 --- a/packages/jasper/src/lib/spark-publisher.ts +++ b/packages/jasper/src/lib/spark-publisher.ts @@ -5,7 +5,7 @@ * Supports batch record creation via com.atproto.repo.applyWrites * for efficient bulk imports. */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; import { generateTID } from "@ewanc26/tid"; import type { SparkMediaImage, @@ -14,6 +14,7 @@ import type { } from "../core/types.js"; import { SPARK_POST_COLLECTION, SPARK_MEDIA_IMAGES } from "../core/config.js"; import { log } from "../utils/logger.js"; +import { com } from '@bsky/sdk/lexicons' /** * Result of publishing a Spark post @@ -49,21 +50,27 @@ const APPLY_WRITES_MAX = 200; * Upload an image as a blob */ async function uploadBlob( - agent: Agent, + client: Client, imageData: Uint8Array, mimeType: string, ): Promise<{ cid: string; mimeType: string }> { - const uploadResult = await agent.uploadBlob(imageData, { - encoding: mimeType, + const form = new FormData(); + form.append('file', new Blob([imageData as any], { type: mimeType })); + + const res = await fetch(`${(client as any).service || 'https://bsky.social'}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: (client as any).session?.accessJwt ? { Authorization: `Bearer ${(client as any).session.accessJwt}` } : undefined, + body: form, }); - if (!uploadResult.data.blob) { - throw new Error("No blob returned from upload"); + if (!res.ok) { + throw new Error(`Blob upload failed: ${res.status}`); } + const data = await res.json() as { blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } }; return { - cid: uploadResult.data.blob.ref.toString(), - mimeType: uploadResult.data.blob.mimeType, + cid: data.blob.ref.toString(), + mimeType: data.blob.mimeType, }; } @@ -98,7 +105,7 @@ function buildMediaImage( * This is a thin wrapper around publishSparkPosts for backward compatibility. */ export async function publishSparkPost( - agent: Agent, + client: Client, images: Array<{ data: Uint8Array; mimeType: string; @@ -110,7 +117,7 @@ export async function publishSparkPost( caption?: string, dryRun = false, ): Promise { - const results = await publishSparkPosts(agent, [{ + const results = await publishSparkPosts(client, [{ images, createdAt, caption, @@ -128,7 +135,7 @@ export async function publishSparkPost( * but all record creations are batched into one applyWrites call. */ export async function publishSparkPosts( - agent: Agent, + client: Client, posts: SparkPostInput[], dryRun = false, ): Promise { @@ -156,7 +163,7 @@ export async function publishSparkPosts( const mediaImages: SparkMediaImage[] = []; for (const img of post.images.slice(0, 12)) { - const blob = await uploadBlob(agent, img.data, img.mimeType); + const blob = await uploadBlob(client, img.data, img.mimeType); mediaImages.push( buildMediaImage(blob, img.size, img.alt, img.aspectRatio), ); @@ -196,12 +203,12 @@ export async function publishSparkPosts( for (let chunkStart = 0; chunkStart < writes.length; chunkStart += APPLY_WRITES_MAX) { const chunk = writes.slice(chunkStart, chunkStart + APPLY_WRITES_MAX); try { - const response = await agent.com.atproto.repo.applyWrites({ - repo: agent.did!, + const response = await client.call(com.atproto.repo.applyWrites, { + repo: client.assertDid!, writes: chunk as any, }); - const respResults = (response.data as any)?.results as Array<{ uri: string; cid: string }> | undefined; + const respResults = (response as any)?.results as Array<{ uri: string; cid: string }> | undefined; if (respResults) { for (let j = 0; j < respResults.length; j++) { @@ -236,28 +243,28 @@ export async function publishSparkPosts( * Check for existing Spark posts to avoid duplicates */ export async function getExistingSparkPosts( - agent: Agent, + client: Client, ): Promise> { const existing = new Set(); try { let cursor: string | undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: SPARK_POST_COLLECTION, limit: 100, cursor, }); - for (const record of result.data.records) { + for (const record of result.records) { const value = record.value as { createdAt?: string }; if (value.createdAt) { existing.add(value.createdAt); } } - cursor = result.data.cursor; + cursor = result.cursor; } while (cursor); } catch (error) { log.warn( diff --git a/packages/jasper/src/lib/spark-story-publisher.ts b/packages/jasper/src/lib/spark-story-publisher.ts index 65abb61..9c3aa34 100644 --- a/packages/jasper/src/lib/spark-story-publisher.ts +++ b/packages/jasper/src/lib/spark-story-publisher.ts @@ -2,7 +2,7 @@ * Publisher for Spark stories * Handles blob upload and so.sprk.story.post record creation */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; import { generateTID } from "@ewanc26/tid"; import type { SparkMediaImage, @@ -18,6 +18,7 @@ import { SPARK_MEDIA_VIDEO, } from "../core/config.js"; import { log } from "../utils/logger.js"; +import { com } from '@bsky/sdk/lexicons' /** * Result of publishing a Spark story @@ -33,21 +34,27 @@ export interface SparkStoryPublishResult { * Upload an image as a blob */ async function uploadBlob( - agent: Agent, + client: Client, imageData: Uint8Array, mimeType: string, ): Promise<{ cid: string; mimeType: string }> { - const uploadResult = await agent.uploadBlob(imageData, { - encoding: mimeType, + const form = new FormData(); + form.append('file', new Blob([imageData as any], { type: mimeType })); + + const res = await fetch(`${(client as any).service || 'https://bsky.social'}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: (client as any).session?.accessJwt ? { Authorization: `Bearer ${(client as any).session.accessJwt}` } : undefined, + body: form, }); - if (!uploadResult.data.blob) { - throw new Error("No blob returned from upload"); + if (!res.ok) { + throw new Error(`Blob upload failed: ${res.status}`); } + const data = await res.json() as { blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } }; return { - cid: uploadResult.data.blob.ref.toString(), - mimeType: uploadResult.data.blob.mimeType, + cid: data.blob.ref.toString(), + mimeType: data.blob.mimeType, }; } @@ -80,7 +87,7 @@ function buildMediaImage( * of so.sprk.media.images or so.sprk.media.video. */ export async function publishSparkStory( - agent: Agent, + client: Client, images: Array<{ data: Uint8Array; mimeType: string; @@ -103,7 +110,7 @@ export async function publishSparkStory( const mediaImages: SparkMediaImage[] = []; for (const img of images) { - const blob = await uploadBlob(agent, img.data, img.mimeType); + const blob = await uploadBlob(client, img.data, img.mimeType); mediaImages.push( buildMediaImage(blob, img.size, img.alt, img.aspectRatio), ); @@ -122,17 +129,17 @@ export async function publishSparkStory( media, }; - const result = await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid!, collection: SPARK_STORY_COLLECTION, rkey: generateTID(createdAt), - record, + record: record as any, }); return { success: true, - uri: result.data.uri, - cid: result.data.cid, + uri: result.uri, + cid: result.cid, }; } catch (error) { const err = error as Error; @@ -147,7 +154,7 @@ export async function publishSparkStory( * Publish a Spark story with a video */ export async function publishSparkVideoStory( - agent: Agent, + client: Client, videoBlob: { cid: string; mimeType: string; @@ -182,17 +189,17 @@ export async function publishSparkVideoStory( media, }; - const result = await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid!, collection: SPARK_STORY_COLLECTION, rkey: generateTID(createdAt), - record, + record: record as any, }); return { success: true, - uri: result.data.uri, - cid: result.data.cid, + uri: result.uri, + cid: result.cid, }; } catch (error) { const err = error as Error; @@ -207,28 +214,28 @@ export async function publishSparkVideoStory( * Check for existing Spark stories to avoid duplicates */ export async function getExistingSparkStories( - agent: Agent, + client: Client, ): Promise> { const existing = new Set(); try { let cursor: string | undefined; do { - const result = await agent.com.atproto.repo.listRecords({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.listRecords, { + repo: client.assertDid!, collection: SPARK_STORY_COLLECTION, limit: 100, cursor, }); - for (const record of result.data.records) { + for (const record of result.records) { const value = record.value as { createdAt?: string }; if (value.createdAt) { existing.add(value.createdAt); } } - cursor = result.data.cursor; + cursor = result.cursor; } while (cursor); } catch (error) { log.warn( diff --git a/packages/jasper/src/lib/spark-video-publisher.ts b/packages/jasper/src/lib/spark-video-publisher.ts index 5f1a1b6..eddc94d 100644 --- a/packages/jasper/src/lib/spark-video-publisher.ts +++ b/packages/jasper/src/lib/spark-video-publisher.ts @@ -3,7 +3,7 @@ * Handles video upload via so.sprk.video.uploadVideo, job polling, * and so.sprk.feed.post record creation with so.sprk.media.video */ -import type { Agent } from "@atproto/api"; +import type { Client } from '@atproto/lex'; import { generateTID } from "@ewanc26/tid"; import type { SparkMediaVideo, @@ -12,6 +12,7 @@ import type { } from "../core/types.js"; import { SPARK_POST_COLLECTION, SPARK_MEDIA_VIDEO } from "../core/config.js"; import { log } from "../utils/logger.js"; +import { com } from '@bsky/sdk/lexicons' /** Maximum time to wait for video processing (5 minutes) */ const VIDEO_PROCESSING_TIMEOUT_MS = 5 * 60 * 1000; @@ -48,28 +49,26 @@ export interface SparkVideoPostResult { * 3. Use the returned blob in the record */ export async function uploadSparkVideo( - agent: Agent, + client: Client, videoData: Uint8Array, mimeType: string, ): Promise { try { - // Step 1: Upload the video - const uploadResult = await agent.com.atproto.repo.uploadBlob(videoData, { - encoding: mimeType, + const form = new FormData(); + form.append('file', new Blob([videoData as any], { type: mimeType })); + + const res = await fetch(`${(client as any).service || 'https://bsky.social'}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: (client as any).session?.accessJwt ? { Authorization: `Bearer ${(client as any).session.accessJwt}` } : undefined, + body: form, }); - if (!uploadResult.data.blob) { - throw new Error("No blob returned from video upload"); + if (!res.ok) { + throw new Error(`Video upload failed: ${res.status}`); } - // Note: Spark's video upload flow (so.sprk.video.uploadVideo) is a - // separate XRPC procedure that processes the video server-side. - // For now, we use the standard blob upload as a fallback, since - // the Spark video processing service may not be available on all PDSs. - // When the Spark AppView is available, this should be updated to use - // so.sprk.video.uploadVideo and poll for job completion. - - const blob = uploadResult.data.blob; + const data = await res.json() as { blob: { $type: 'blob'; ref: { $link: string }; mimeType: string; size: number } }; + const blob = data.blob; return { success: true, blob: { @@ -95,7 +94,7 @@ export async function uploadSparkVideo( * requires a custom agent method since so.sprk.* isn't in the default @atproto/api types. */ export async function waitForVideoJob( - _agent: Agent, + _client: Client, _jobId: string, timeoutMs: number = VIDEO_PROCESSING_TIMEOUT_MS, ): Promise { @@ -118,7 +117,7 @@ export async function waitForVideoJob( * Creates a so.sprk.feed.post with so.sprk.media.video as the media union. */ export async function publishSparkVideoPost( - agent: Agent, + client: Client, videoBlob: { cid: string; mimeType: string; @@ -158,17 +157,17 @@ export async function publishSparkVideoPost( record.caption = { text: caption }; } - const result = await agent.com.atproto.repo.createRecord({ - repo: agent.did!, + const result = await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid!, collection: SPARK_POST_COLLECTION, rkey: generateTID(createdAt), - record, + record: record as any, }); return { success: true, - uri: result.data.uri, - cid: result.data.cid, + uri: result.uri, + cid: result.cid, }; } catch (error) { const err = error as Error; diff --git a/packages/malachite-web/package.json b/packages/malachite-web/package.json index ace0ada..d2d4ad7 100644 --- a/packages/malachite-web/package.json +++ b/packages/malachite-web/package.json @@ -1,7 +1,7 @@ { "name": "@ewanc26/malachite-web", "version": "0.7.2", - "description": "Web frontend for Malachite — import Last.fm and Spotify listening history to ATProto", + "description": "Web frontend for Malachite \u2014 import Last.fm and Spotify listening history to ATProto", "author": "Ewan Croft", "license": "AGPL-3.0-only", "private": true, @@ -34,11 +34,13 @@ "format": "prettier --write ." }, "dependencies": { - "@atproto/api": "^0.19.3", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@atproto/common-web": "^0.4.12", "@atproto/oauth-client-browser": "^0.3.41", - "@ewanc26/landing-ui": "workspace:*", "@ewanc26/croft-click-core": "workspace:*", + "@ewanc26/landing-ui": "workspace:*", "@lucide/svelte": "^0.575.0" }, "devDependencies": { diff --git a/packages/malachite-web/src/lib/components/steps/AuthStep.svelte b/packages/malachite-web/src/lib/components/steps/AuthStep.svelte index 7469592..1a7f66e 100644 --- a/packages/malachite-web/src/lib/components/steps/AuthStep.svelte +++ b/packages/malachite-web/src/lib/components/steps/AuthStep.svelte @@ -2,7 +2,7 @@ import { ArrowLeft, ArrowRight, Eye, EyeOff } from '@lucide/svelte'; import { login } from '$lib/core/auth.js'; import { signInWithOAuth } from '$lib/core/oauth.js'; - import type { Agent } from '@atproto/api'; + import type { Client } from '@atproto/lex'; import { saveCredentials, loadCredentials, @@ -13,7 +13,7 @@ onauth, onback, }: { - onauth: (agent: Agent) => void; + onauth: (client: Client) => void; onback: () => void; } = $props(); diff --git a/packages/malachite-web/src/lib/core/import.ts b/packages/malachite-web/src/lib/core/import.ts index a55d8f4..ca35f29 100644 --- a/packages/malachite-web/src/lib/core/import.ts +++ b/packages/malachite-web/src/lib/core/import.ts @@ -7,7 +7,7 @@ * are web-specific. */ -import type { Agent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import type { ImportMode, LogEntry, PlayRecord } from '$lib/types.js'; import { CLIENT_AGENT } from '../config.js'; import { parseLastFmFile, convertToPlayRecord } from './csv.js'; @@ -26,6 +26,7 @@ import { } from '@ewanc26/croft-click-core'; import { publishRecords, type PublishProgress } from '@ewanc26/croft-click-core'; import { analyzeLegacyRecords, migrateLegacyRecords } from '@ewanc26/croft-click-core'; +import { com } from '@bsky/sdk/lexicons' export type { PublishProgress }; @@ -50,7 +51,7 @@ export interface ImportCallbacks { } export async function runImport( - agent: Agent, + client: Client, mode: ImportMode, lastfmFiles: File[], spotifyFiles: File[], @@ -73,7 +74,7 @@ export async function runImport( onLog('section', '── Deduplication ──────────────────────────────────'); onLog('info', 'Fetching existing records from Teal…'); const all = await fetchAllRecordsForDedup( - agent, + client, (n) => onLog('progress', ` Fetched ${n.toLocaleString()} records…`), sig, ); @@ -95,7 +96,7 @@ export async function runImport( onLog('info', 'Removing duplicates…'); const removed = await removeDuplicateRecords( - agent, + client, groups, (n) => onLog('progress', ` Removed ${n}/${totalDups}…`), sig, @@ -108,7 +109,7 @@ export async function runImport( if (mode === 'polish') { onLog('section', '── Polish ───────────────────────────────────────────'); onLog('info', 'Fetching legacy fm.teal.alpha.feed.play records…'); - const plan = await analyzeLegacyRecords(agent, sig); + const plan = await analyzeLegacyRecords(client, sig); if (plan.legacyTotal === 0) { onLog('success', 'No legacy scrobbles found — nothing to migrate.'); @@ -125,7 +126,7 @@ export async function runImport( } onLog('warn', 'Migrating — legacy copies are only removed after a successful backfill.'); - const polishRes = await migrateLegacyRecords(agent, plan, { + const polishRes = await migrateLegacyRecords(client, plan, { onProgress: (phase, done, total) => onLog('progress', ` ${phase === 'backfill' ? 'Backfilled' : 'Removed'} ${done}/${total}…`), signal: sig, @@ -138,8 +139,8 @@ export async function runImport( } try { - await agent.com.atproto.repo.createRecord({ - repo: agent.did ?? '', + await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', @@ -237,7 +238,7 @@ export async function runImport( let carSyncOk = true; // Check web cache (sessionStorage with 24h TTL) before fetching. - const did = agent.did ?? (agent as any)?.sessionManager?.did; + const did = client.assertDid ?? (client as any)?.sessionManager?.did; let fromCache = false; if (!fresh && did) { const cached = loadRecordsCache(did); @@ -252,7 +253,7 @@ export async function runImport( if (!fromCache) { try { existing = await fetchExistingRecords( - agent, + client, (n) => onLog('progress', ` Fetched ${n.toLocaleString()} existing records…`), fresh, sig, @@ -299,7 +300,7 @@ export async function runImport( // ── Publish ────────────────────────────────────────────────────────────── onLog('section', '── Publishing ───────────────────────────────────────'); onLog('warn', 'Do not close this tab while publishing.'); - const res = await publishRecords(agent, records, dryRun, { + const res = await publishRecords(client, records, dryRun, { onProgress, onLog: (level, msg) => onLog(level as LogEntry['level'], msg), isCancelled, @@ -307,8 +308,8 @@ export async function runImport( if (!dryRun && !res.cancelled && res.successCount > 0) { try { - await agent.com.atproto.repo.createRecord({ - repo: agent.did ?? '', + await client.call(com.atproto.repo.createRecord, { + repo: client.assertDid, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', diff --git a/packages/malachite-web/src/lib/core/oauth.ts b/packages/malachite-web/src/lib/core/oauth.ts index b89754b..0745b07 100644 --- a/packages/malachite-web/src/lib/core/oauth.ts +++ b/packages/malachite-web/src/lib/core/oauth.ts @@ -4,7 +4,7 @@ */ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; -import { Agent } from '@atproto/api'; +import { Client } from '@atproto/lex' // The loopback redirect_uri must use 127.0.0.1, not localhost — RFC 8252 // explicitly disallows the localhost hostname in loopback redirect URIs. @@ -44,11 +44,11 @@ function getClient(): Promise { * Processes any OAuth callback params in the URL and restores stored sessions. * Returns an Agent if a session is active, or null if the user still needs to sign in. */ -export async function initOAuth(): Promise { +export async function initOAuth(): Promise { const client = await getClient(); const result = await client.init(); if (!result) return null; - return new Agent(result.session); + return new Client(result.session); } /** diff --git a/packages/malachite-web/src/routes/import/+page.svelte b/packages/malachite-web/src/routes/import/+page.svelte index 55829df..c1b9a57 100644 --- a/packages/malachite-web/src/routes/import/+page.svelte +++ b/packages/malachite-web/src/routes/import/+page.svelte @@ -3,7 +3,7 @@ import { onMount } from 'svelte'; import { fly } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; - import type { Agent } from '@atproto/api'; + import type { Client } from '@atproto/lex'; import { initOAuth } from '$lib/core/oauth.js'; import { modeNeeds } from '$lib/modes.js'; @@ -39,7 +39,7 @@ let prevStep = $state(_initStep); let mode = $state(_initMode); - let agent = $state(null); + let agent = $state(null); let lastfmFiles = $state([]); let spotifyFiles = $state([]); let appleFiles = $state([]); @@ -98,7 +98,7 @@ goTo(Math.max(0, step - 1)); } - function handleAuth(a: Agent) { + function handleAuth(a: Client) { agent = a; goTo(needs.files ? 2 : 3); } diff --git a/packages/malachite-web/vite.config.ts b/packages/malachite-web/vite.config.ts index da322ef..9e72afc 100644 --- a/packages/malachite-web/vite.config.ts +++ b/packages/malachite-web/vite.config.ts @@ -22,12 +22,12 @@ export default defineConfig({ }, optimizeDeps: { - include: ['@atproto/api', '@atproto/common-web'] + include: ['@atproto/lex', '@atproto/lex-password-session'] }, build: { target: 'es2022', - // The /import page chunk is large because it bundles @atproto/api, the OAuth + // The /import page chunk is large because it bundles @atproto/lex, the OAuth // client, and the IPLD/CAR parser — all unavoidable for an ATProto import tool. // The page is client-only (ssr=false, prerender=false) so it's never on the // critical path; gzipped it's ~350 kB which is acceptable. diff --git a/packages/malachite/package.json b/packages/malachite/package.json index cc8e33b..920c93e 100644 --- a/packages/malachite/package.json +++ b/packages/malachite/package.json @@ -67,10 +67,12 @@ "check-limits": "node scripts/rate-limit-monitor.js" }, "dependencies": { - "@ewanc26/croft-click-core": "workspace:*", - "@atproto/api": "^0.19.16", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@atproto/oauth-client-node": "^0.3.16", "@atproto/common-web": "^0.4.12", + "@ewanc26/croft-click-core": "workspace:*", "@ewanc26/tid": "workspace:*", "@ipld/car": "^5.3.2", "@ipld/dag-cbor": "^9.2.2", diff --git a/packages/malachite/src/lib/auth.ts b/packages/malachite/src/lib/auth.ts index 89f2e40..c841e91 100644 --- a/packages/malachite/src/lib/auth.ts +++ b/packages/malachite/src/lib/auth.ts @@ -3,7 +3,7 @@ * Adds terminal prompts and credential persistence on top of the core login. */ -import type { Agent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import { login as coreLogin, resolveIdentity } from '@ewanc26/croft-click-core'; import { prompt, isNonInteractive } from '../utils/input.js'; import * as ui from '../utils/ui.js'; @@ -19,7 +19,7 @@ export async function login( identifier: string | undefined, password: string | undefined, resolverOrPds?: string -): Promise { +): Promise { ui.header('ATProto Login'); if ((!identifier || !password) && isNonInteractive()) { @@ -49,11 +49,11 @@ export async function login( try { ui.startSpinner(pdsOverride ? `Using provided PDS: ${pdsOverride}` : 'Resolving identity…'); - const agent = await coreLogin(identifier!, password!, pdsOverride); + const client = await coreLogin(identifier!, password!, pdsOverride); ui.succeedSpinner('Logged in successfully!'); - ui.keyValue('DID', (agent as any).session?.did || (agent as any).did || 'unknown'); - ui.keyValue('Handle', (agent as any).session?.handle || 'unknown'); + ui.keyValue('DID', client.assertDid); + ui.keyValue('Handle', (client as any).session?.handle || 'unknown'); try { saveCredentials(identifier!, password!); @@ -63,7 +63,7 @@ export async function login( } console.log(''); - return agent; + return client; } catch (error) { const err = error as Error; ui.failSpinner('Login failed'); diff --git a/packages/malachite/src/lib/cli.ts b/packages/malachite/src/lib/cli.ts index e0ddf0f..8235a69 100644 --- a/packages/malachite/src/lib/cli.ts +++ b/packages/malachite/src/lib/cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { parseArgs } from 'node:util'; -import { AtpAgent } from '@atproto/api'; +import { Client } from '@atproto/lex' +import { com } from '@bsky/sdk/lexicons' import type { PlayRecord, Config, CommandLineArgs, PublishResult } from '../types.js'; import { login } from './auth.js'; import { @@ -573,7 +574,7 @@ export async function runCLI(): Promise { } const cfg = config as Config; - let agent: AtpAgent | null = null; + let client: Client | null = null; // Development mode enables verbose logging and file logging const isDev = args.dev ?? false; @@ -667,8 +668,8 @@ export async function runCLI(): Promise { } log.section('Clear Cache'); log.info('Authenticating to identify cache...'); - agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; - const did = agent.session?.did; + client = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER); + const did = client.assertDid; if (!did) { throw new Error('Failed to get DID from session'); } @@ -701,10 +702,10 @@ export async function runCLI(): Promise { log.info(`Using OAuth session for ${handle ?? did}`); const oauthAgent = await restoreOAuthSession(did); if (oauthAgent) { - agent = oauthAgent as unknown as AtpAgent; + client = oauthAgent as Client; } } - if (!agent) { + if (!client) { if (!args.handle || !args.password) { const creds = loadCredentials(); if (creds) { @@ -715,10 +716,10 @@ export async function runCLI(): Promise { throw new Error('Deduplicate mode requires authentication. Run --oauth-login or pass --handle and --password.'); } } - agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; + client = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER); } log.section('Remove Duplicate Records'); - const result = await removeDuplicates(agent, cfg, dryRun); + const result = await removeDuplicates(client, cfg, dryRun); if (result.totalDuplicates === 0) { return; } @@ -734,7 +735,7 @@ export async function runCLI(): Promise { log.info('Duplicate removal cancelled by user.'); process.exit(0); } - await removeDuplicates(agent, cfg, false); + await removeDuplicates(client, cfg, false); log.success('Duplicate removal complete!'); } else if (dryRun) { log.info('DRY RUN: No records were actually removed.'); @@ -752,10 +753,10 @@ export async function runCLI(): Promise { log.info(`Using OAuth session for ${handle ?? did}`); const oauthAgent = await restoreOAuthSession(did); if (oauthAgent) { - agent = oauthAgent as unknown as AtpAgent; + client = oauthAgent as Client; } } - if (!agent) { + if (!client) { if (!args.handle || !args.password) { const creds = loadCredentials(); if (creds) { @@ -766,7 +767,7 @@ export async function runCLI(): Promise { throw new Error('Polish mode requires authentication. Run --oauth-login or pass --handle and --password.'); } } - agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; + client = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER); } log.section('Polish Legacy Scrobbles'); @@ -774,7 +775,7 @@ export async function runCLI(): Promise { log.info('(No, this is not a Polish-language version of Malachite — it polishes your scrobble history.)'); log.blank(); - const plan = await analyzeLegacyRecords(agent); + const plan = await analyzeLegacyRecords(client); displayPolishPlan(plan, dryRun); if (plan.legacyTotal === 0) { @@ -802,12 +803,12 @@ export async function runCLI(): Promise { log.blank(); } - await migrateLegacyRecords(agent, plan, false); + await migrateLegacyRecords(client, plan, false); log.success('Migration complete!'); try { - await agent.com.atproto.repo.createRecord({ - repo: agent.session?.did ?? agent.did ?? '', + await client.call(com.atproto.repo.createRecord.main as any, { + repo: client.assertDid, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', @@ -833,12 +834,12 @@ export async function runCLI(): Promise { log.info(`Using OAuth session for ${handle ?? did}`); const oauthAgent = await restoreOAuthSession(did); if (oauthAgent) { - agent = oauthAgent as unknown as AtpAgent; + client = oauthAgent as Client; } else { log.warn('OAuth session could not be restored — falling back to app-password credentials.'); } } - if (!agent) { + if (!client) { if (!args.handle || !args.password) { const creds = loadCredentials(); if (creds) { @@ -850,7 +851,7 @@ export async function runCLI(): Promise { } } log.debug('Authenticating...'); - agent = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER) as AtpAgent; + client = await login(args.handle, args.password, args.pds ?? cfg.SLINGSHOT_RESOLVER); } log.debug('Authentication successful'); @@ -908,13 +909,13 @@ export async function runCLI(): Promise { } log.blank(); - if (agent) { + if (client) { const originalRecords = [...records]; let carSyncOk = true; let existingMap: Awaited>; try { - existingMap = await fetchExistingRecords(agent, cfg, args.fresh ?? false); + existingMap = await fetchExistingRecords(client, cfg, args.fresh ?? false); } catch (carErr) { carSyncOk = false; const msg = (carErr as Error)?.message ?? String(carErr); @@ -1024,7 +1025,7 @@ export async function runCLI(): Promise { log.section('Publishing Records'); const result: PublishResult = await publishRecordsWithApplyWrites( - agent, + client, records, batchSize, batchDelay, @@ -1051,9 +1052,14 @@ export async function runCLI(): Promise { } } + if (!client) { + log.info('No authenticated client — skipping publish'); + return; + } + try { - await agent.com.atproto.repo.createRecord({ - repo: agent.session?.did ?? agent.did ?? '', + await client.call(com.atproto.repo.createRecord.main as any, { + repo: client.assertDid, collection: 'click.croft.toolkit.use', record: { $type: 'click.croft.toolkit.use', diff --git a/packages/malachite/src/lib/oauth-login.ts b/packages/malachite/src/lib/oauth-login.ts index 04197d1..3facea0 100644 --- a/packages/malachite/src/lib/oauth-login.ts +++ b/packages/malachite/src/lib/oauth-login.ts @@ -7,7 +7,7 @@ */ import http from 'node:http'; -import { Agent } from '@atproto/api'; +import { Client } from '@atproto/lex' import { resolveIdentity } from '@ewanc26/croft-click-core'; import * as ui from '../utils/ui.js'; import { prompt } from '../utils/input.js'; @@ -102,7 +102,7 @@ function waitForCallback(): Promise { * Resolves the identity, opens the browser, waits for the callback, * exchanges the code for a session, and saves it to disk. */ -export async function loginWithOAuth(handle?: string): Promise { +export async function loginWithOAuth(handle?: string): Promise { ui.header('ATProto OAuth Login'); if (!handle) { @@ -152,18 +152,18 @@ export async function loginWithOAuth(handle?: string): Promise { ui.info('Your session will refresh automatically when needed.'); console.log(''); - return new Agent(session); + return new Client(session); } /** * Restore a stored OAuth session for the given DID. * Returns null if no session is stored or if the refresh fails. */ -export async function restoreOAuthSession(did: string): Promise { +export async function restoreOAuthSession(did: string): Promise { try { const client = await getOAuthClient(); const session = await client.restore(did); - return new Agent(session); + return new Client(session); } catch { return null; } diff --git a/packages/malachite/src/lib/polish.ts b/packages/malachite/src/lib/polish.ts index 814ca76..5a3725f 100644 --- a/packages/malachite/src/lib/polish.ts +++ b/packages/malachite/src/lib/polish.ts @@ -1,4 +1,4 @@ -import type { AtpAgent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import type { SingleBar } from 'cli-progress'; import { RECORD_TYPE, LEGACY_RECORD_TYPE } from '../config.js'; import { formatDate } from '../utils/helpers.js'; @@ -30,14 +30,14 @@ export { buildPolishPlan } from '@ewanc26/croft-click-core'; * Fetch both collections via CAR and build a migration plan. * Read-only — performs no writes. */ -export async function analyzeLegacyRecords(agent: AtpAgent): Promise { +export async function analyzeLegacyRecords(client: Client): Promise { log.section('Analyzing Legacy Records'); const start = Date.now(); ui.startSpinner('📦 Fetching repo via CAR export...'); let plan: PolishPlan; try { - plan = await coreAnalyze(agent); + plan = await coreAnalyze(client); } catch (err) { ui.failSpinner('Failed to fetch repo via CAR export'); throw err; @@ -100,7 +100,7 @@ function formatPlay(value: Record): string { * data is ever lost — re-running polish will finish the job. */ export async function migrateLegacyRecords( - agent: AtpAgent, + client: Client, plan: PolishPlan, dryRun = false ): Promise { @@ -130,9 +130,9 @@ export async function migrateLegacyRecords( log.info(`${label}: ${done.toLocaleString()}/${total.toLocaleString()} (${pct}%) — ${elapsed}s elapsed`); }; - const result = await coreMigrate(agent, plan, { + const result = await coreMigrate(client, plan, { dryRun, - onProgress: (phase, done, total) => { + onProgress: (phase, done, total: number) => { if (phase === 'backfill') { bars.backfill?.update(done, {}); } else { diff --git a/packages/malachite/src/lib/publisher.ts b/packages/malachite/src/lib/publisher.ts index 695689f..5ac381a 100644 --- a/packages/malachite/src/lib/publisher.ts +++ b/packages/malachite/src/lib/publisher.ts @@ -1,4 +1,4 @@ -import type { AtpAgent } from '@atproto/api'; +import type { Client } from '@atproto/lex' import { formatDuration, formatDate } from '../utils/helpers.js'; import { isImportCancelled } from '../utils/killswitch.js'; import { RateLimiter } from '../utils/rate-limiter.js'; @@ -14,6 +14,7 @@ import { completeImport, getResumeStartIndex, } from '../utils/import-state.js'; +import { com } from '@bsky/sdk/lexicons' /** * Publish records using PROACTIVE rate limiting - never hits rate limits @@ -38,7 +39,7 @@ import { * Never hits 750-point headroom threshold! */ export async function publishRecordsWithApplyWrites( - agent: AtpAgent | null, + client: Client | null, records: PlayRecord[], _initialBatchSize: number, // Ignored - kept for backwards compatibility _batchDelay: number, // Ignored - kept for backwards compatibility @@ -54,8 +55,8 @@ export async function publishRecordsWithApplyWrites( return handleDryRun(records, config, syncMode); } - if (!agent) { - throw new Error('Agent is required for publishing'); + if (!client) { + throw new Error('Client is required for publishing'); } // Initialize systems @@ -195,8 +196,8 @@ export async function publishRecordsWithApplyWrites( // Send batch with retry logic for transient failures const response = await retryWithBackoff( async () => { - return await agent.com.atproto.repo.applyWrites({ - repo: agent.session?.did || '', + return await client.call(com.atproto.repo.applyWrites.main as any, { + repo: client.assertDid, writes: writes as any, }); }, @@ -217,7 +218,7 @@ export async function publishRecordsWithApplyWrites( '502', '504', ], - onRetry: (attempt, maxAttempts, delay, error) => { + onRetry: (attempt: number, maxAttempts: number, delay: number, error: Error) => { log.warn(`⚠️ Batch ${batchCounter} failed (attempt ${attempt}/${maxAttempts}): ${error.message}`); log.info(`⏳ Retrying in ${(delay / 1000).toFixed(1)}s...`); }, @@ -225,7 +226,7 @@ export async function publishRecordsWithApplyWrites( ); // Success! - const batchSuccessCount = response.data.results?.length || batch.length; + const batchSuccessCount = (response as any).results?.length || batch.length; successCount += batchSuccessCount; const batchDuration = Date.now() - batchStartTime; diff --git a/packages/malachite/src/lib/sync.ts b/packages/malachite/src/lib/sync.ts index 63dca72..55464f8 100644 --- a/packages/malachite/src/lib/sync.ts +++ b/packages/malachite/src/lib/sync.ts @@ -1,4 +1,4 @@ -import type { AtpAgent } from '@atproto/api'; +import type { Client } from '@atproto/lex' import type { PlayRecord, Config } from '../types.js'; import { fetchRepoViaCAR, getPdsUrlFromAgent, getAgentToken } from '../utils/car-fetch.js'; import { formatDate, formatDateRange } from '../utils/helpers.js'; @@ -6,6 +6,7 @@ import * as ui from '../utils/ui.js'; import { log } from '../utils/logger.js'; import { isCacheValid, loadCache, saveCache, getCacheInfo } from '../utils/teal-cache.js'; import { RECORD_TYPES } from '../config.js'; +import { com } from '@bsky/sdk/lexicons' interface ExistingRecord { uri: string; @@ -40,12 +41,12 @@ function collectionFromUri(uri: string): string { * write-quota points. */ export async function fetchExistingRecords( - agent: AtpAgent, + client: Client, _config: Config, forceRefresh: boolean = false ): Promise> { log.section('Checking Existing Records'); - const did = agent.session?.did; + const did = client.assertDid; if (!did) { throw new Error('No authenticated session found'); @@ -74,8 +75,8 @@ export async function fetchExistingRecords( log.info('📦 Fetching repo via CAR export (no rate-limit points consumed)...'); } - const pdsUrl = getPdsUrlFromAgent(agent); - const token = await getAgentToken(agent); + const pdsUrl = getPdsUrlFromAgent(client); + const token = await getAgentToken(client); const carStart = Date.now(); const carRecords = await fetchPlayRecords(pdsUrl, did, token); const carElapsed = ((Date.now() - carStart) / 1000).toFixed(1); @@ -101,10 +102,10 @@ export async function fetchExistingRecords( * Used by the deduplicate flow. */ export async function fetchAllRecords( - agent: AtpAgent, + client: Client, _config: Config ): Promise { - const did = agent.session?.did; + const did = client.assertDid; if (!did) { throw new Error('No authenticated session found'); @@ -112,10 +113,10 @@ export async function fetchAllRecords( ui.startSpinner('📦 Fetching repo via CAR export...'); - const pdsUrl = getPdsUrlFromAgent(agent); - const token = await getAgentToken(agent); + const pdsUrl = getPdsUrlFromAgent(client); + const token = await getAgentToken(client); const carRecords = await fetchPlayRecords(pdsUrl, did, token); - const allRecords: ExistingRecord[] = carRecords.map((rec) => ({ + const allRecords: ExistingRecord[] = carRecords.map((rec: { uri: string; cid: string; value: any }) => ({ uri: rec.uri, cid: rec.cid, value: rec.value as PlayRecord, @@ -274,13 +275,13 @@ export function findDuplicates(allRecords: ExistingRecord[], fuzzy = true): Dupl * Remove duplicate records from Teal, keeping only the first occurrence. */ export async function removeDuplicates( - agent: AtpAgent, + client: Client, config: Config, dryRun: boolean = false ): Promise<{ totalDuplicates: number; recordsRemoved: number }> { ui.header('Checking for Duplicate Records'); - const allRecords = await fetchAllRecords(agent, config); + const allRecords = await fetchAllRecords(client, config); ui.startSpinner('Analyzing records for duplicates...'); const duplicateGroups = findDuplicates(allRecords); @@ -323,11 +324,11 @@ export async function removeDuplicates( for (const group of duplicateGroups) { for (const record of group.records.slice(1)) { try { - await agent.com.atproto.repo.deleteRecord({ - repo: agent.session?.did || '', - collection: collectionFromUri(record.uri), - rkey: record.uri.split('/').pop()!, - }); + await client.call(com.atproto.repo.deleteRecord.main as any, { + repo: client.assertDid, + collection: collectionFromUri(record.uri), + rkey: record.uri.split('/').pop()!, + }); recordsRemoved++; const elapsed = (Date.now() - startTime) / 1000; progressBar.update(recordsRemoved, { speed: recordsRemoved / Math.max(elapsed, 0.1) }); diff --git a/packages/malachite/src/tests/polish.test.ts b/packages/malachite/src/tests/polish.test.ts index 17505fe..df89f6c 100644 --- a/packages/malachite/src/tests/polish.test.ts +++ b/packages/malachite/src/tests/polish.test.ts @@ -11,7 +11,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import type { AtpAgent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import { buildPolishPlan, migrateLegacyRecords } from '../lib/polish.js'; import type { PolishPlan } from '../lib/polish.js'; @@ -55,7 +55,7 @@ function makeFakeAgent( }, }, }; - return { agent: agent as unknown as AtpAgent, created, deleted }; + return { agent: agent as unknown as Client, created, deleted }; } describe('buildPolishPlan', () => { diff --git a/packages/malachite/src/types.ts b/packages/malachite/src/types.ts index 6091c92..f240167 100644 --- a/packages/malachite/src/types.ts +++ b/packages/malachite/src/types.ts @@ -1,9 +1,6 @@ -import { AtpAgent as Agent } from '@atproto/api'; +import { Client } from '@atproto/lex' -/** - * Type alias for the ATProto Agent, used for clarity in the project. - */ -export type AtpAgent = Agent; +export type AtpAgent = Client; export interface LastFmCsvRecord { uts: string; diff --git a/packages/opal-web/package.json b/packages/opal-web/package.json index ff8a367..7a04f4e 100644 --- a/packages/opal-web/package.json +++ b/packages/opal-web/package.json @@ -1,7 +1,7 @@ { "name": "@ewanc26/opal-web", "version": "0.1.3", - "description": "Web frontend for Opal — convert microblog posts from Twitter, Mastodon, Threads, and Nostr to AT Protocol", + "description": "Web frontend for Opal \u2014 convert microblog posts from Twitter, Mastodon, Threads, and Nostr to AT Protocol", "author": "Ewan Croft", "license": "AGPL-3.0-only", "private": true, @@ -36,7 +36,9 @@ "format": "prettier --write ." }, "dependencies": { - "@atproto/api": "^0.19.3", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@atproto/common-web": "^0.4.12", "@atproto/oauth-client-browser": "^0.3.41", "@ewanc26/landing-ui": "workspace:*", diff --git a/packages/opal-web/src/lib/core/import.ts b/packages/opal-web/src/lib/core/import.ts index 1ad9f17..e61f7b9 100644 --- a/packages/opal-web/src/lib/core/import.ts +++ b/packages/opal-web/src/lib/core/import.ts @@ -3,9 +3,10 @@ * Handles the conversion + publishing flow with progress + cancellation callbacks. */ -import type { Agent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import type { Platform, MicroblogPost, ConvertResult } from '@ewanc26/opal'; import { convertData, parseTwitterArchive, publishRecords } from '@ewanc26/opal'; +import { com } from '@bsky/sdk/lexicons'; export interface ImportResult { success: number; @@ -39,7 +40,7 @@ export async function parseExport( * Full import flow: parse → publish. */ export async function runImport( - agent: Agent, + agent: Client, posts: MicroblogPost[], dryRun: boolean, { onLog, onProgress, isCancelled }: ImportCallbacks, @@ -54,18 +55,21 @@ export async function runImport( if (!dryRun && !res.cancelled && res.successCount > 0) { try { - await agent.com.atproto.repo.createRecord({ - repo: agent.did ?? '', - collection: 'click.croft.toolkit.use', - record: { - $type: 'click.croft.toolkit.use', - tool: { - $type: 'click.croft.tools.opal', - postsImported: res.successCount, - }, - createdAt: new Date().toISOString() + await agent.call( + com.atproto.repo.createRecord, + { + repo: agent.assertDid ?? '', + collection: 'click.croft.toolkit.use', + record: { + $type: 'click.croft.toolkit.use', + tool: { + $type: 'click.croft.tools.opal', + postsImported: res.successCount, + }, + createdAt: new Date().toISOString() + } } - }); + ); } catch (err) { onLog('warn', `Failed to log toolkit usage: ${(err as Error).message}`); } diff --git a/packages/opal-web/src/lib/core/oauth.ts b/packages/opal-web/src/lib/core/oauth.ts index 53ee9e2..dee540b 100644 --- a/packages/opal-web/src/lib/core/oauth.ts +++ b/packages/opal-web/src/lib/core/oauth.ts @@ -4,7 +4,7 @@ */ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; -import { Agent } from '@atproto/api'; +import { Client } from '@atproto/lex'; const SCOPE = 'atproto repo:app.bsky.feed.post repo:click.croft.toolkit.use'; @@ -32,11 +32,11 @@ function getClient(): Promise { * Processes any OAuth callback params in the URL and restores stored sessions. * Returns an Agent if a session is active, or null if the user still needs to sign in. */ -export async function initOAuth(): Promise { +export async function initOAuth(): Promise { const client = await getClient(); const result = await client.init(); if (!result) return null; - return new Agent(result.session); + return new Client(result.session); } /** diff --git a/packages/opal-web/src/routes/import/+page.svelte b/packages/opal-web/src/routes/import/+page.svelte index d3e933e..d59f974 100644 --- a/packages/opal-web/src/routes/import/+page.svelte +++ b/packages/opal-web/src/routes/import/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { fly } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; - import type { Agent } from '@atproto/api'; + import type { Client } from '@atproto/lex'; import type { Platform, MicroblogPost, ConvertResult } from '@ewanc26/opal'; import { initOAuth, signInWithOAuth } from '$lib/core/oauth.js'; import { parseExport, runImport } from '$lib/core/import.js'; @@ -22,7 +22,7 @@ let prevStep = $state(_initStep); let platform = $state(_initPlatform); - let agent = $state(null); + let agent = $state(null); let handle = $state(''); let convertResult = $state(null); let selectedPosts = $state>(new Set()); @@ -77,7 +77,7 @@ } } - function handleAuth(a: Agent) { + function handleAuth(a: Client) { agent = a; goTo(2); } diff --git a/packages/opal-web/vite.config.ts b/packages/opal-web/vite.config.ts index 30e7555..a243987 100644 --- a/packages/opal-web/vite.config.ts +++ b/packages/opal-web/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, optimizeDeps: { - include: ['@atproto/api', '@atproto/common-web'], + include: ['@atproto/common-web'], }, build: { diff --git a/packages/opal/package.json b/packages/opal/package.json index ab0ec68..f7d72aa 100644 --- a/packages/opal/package.json +++ b/packages/opal/package.json @@ -54,7 +54,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@atproto/api": "^0.19.3", + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", "@atproto/oauth-client-node": "^0.3.16", "@ewanc26/croft-click-core": "workspace:*", "@ewanc26/tid": "workspace:*", diff --git a/packages/opal/src/cli.ts b/packages/opal/src/cli.ts index f083237..f7b50ca 100644 --- a/packages/opal/src/cli.ts +++ b/packages/opal/src/cli.ts @@ -80,16 +80,22 @@ Examples: } async function login(handle: string, password: string) { - // Dynamic import so @atproto/api is only loaded when publishing - const { AtpAgent } = await import('@atproto/api'); - const agent = new AtpAgent({ service: 'https://bsky.social' }); + // Dynamic import so @atproto/lex is only loaded when publishing + const { Client } = await import('@atproto/lex'); + const { PasswordSession } = await import('@atproto/lex-password-session'); console.log(`Logging in as ${handle}…`); - await agent.login({ identifier: handle, password }); + const session = await PasswordSession.login({ + service: 'https://bsky.social', + identifier: handle, + password, + }); - const did = agent.did ?? 'unknown'; + const client = new Client(session, { service: 'https://bsky.social' as any }); + + const did = client.assertDid ?? 'unknown'; console.log(` DID: ${did}`); - return agent; + return client; } export async function runCLI(): Promise { @@ -174,10 +180,10 @@ export async function runCLI(): Promise { process.once('SIGINT', onSigInt); try { - const agent = await login(args.handle!, args.password!); + const client = await login(args.handle!, args.password!); const pubResult = await publishRecords( - agent, + client, result.posts, opts.dryRun ?? false, { diff --git a/packages/opal/src/publisher.ts b/packages/opal/src/publisher.ts index 2421b9c..c8a5cfd 100644 --- a/packages/opal/src/publisher.ts +++ b/packages/opal/src/publisher.ts @@ -8,8 +8,9 @@ * parent's AT URI + CID is known when the child record is constructed. */ -import type { Agent } from '@atproto/api'; +import type { Client } from '@atproto/lex'; import type { MicroblogPost, Facet } from './types.js'; +import { com } from '@bsky/sdk/lexicons'; import { generateTID } from '@ewanc26/tid'; import { RateLimiter, @@ -166,7 +167,7 @@ function dependenciesMet( } export async function publishRecords( - agent: Agent, + agent: Client, posts: MicroblogPost[], dryRun: boolean, callbacks: PublisherCallbacks, @@ -262,9 +263,10 @@ export async function publishRecords( try { const response = await retryWithBackoff( - () => agent.com.atproto.repo.applyWrites( + () => agent.call( + com.atproto.repo.applyWrites, { - repo: agent.did ?? (agent as any).sessionManager?.did ?? '', + repo: agent.assertDid ?? '', writes: writes as any, }, { signal: ac.signal }, @@ -293,7 +295,7 @@ export async function publishRecords( ); // Extract results and build publishedMap for thread reference resolution - const results = (response.data as any)?.results ?? []; + const results = (response as any)?.results ?? []; for (let j = 0; j < Math.min(results.length, batch.length); j++) { const result = results[j] as { uri?: string; cid?: string } | undefined; if (result?.uri && result?.cid) { diff --git a/packages/supporters/package.json b/packages/supporters/package.json index 1039798..b14e68f 100644 --- a/packages/supporters/package.json +++ b/packages/supporters/package.json @@ -59,11 +59,13 @@ "@lucide/svelte": "^0.575.0" }, "peerDependencies": { - "@atproto/api": ">=0.13.0", + "@bsky/sdk": ">=0.1.0", "svelte": "^5.0.0" }, "devDependencies": { - "@atproto/api": "^0.19.3", + "@atproto/lex": "^0.3.0", + "@atproto/lex-password-session": "^0.2.0", + "@bsky/sdk": "latest", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-vercel": "^6.3.3", "@sveltejs/kit": "^2.55.0", diff --git a/packages/supporters/src/lib/events.ts b/packages/supporters/src/lib/events.ts index f05907f..708cd8e 100644 --- a/packages/supporters/src/lib/events.ts +++ b/packages/supporters/src/lib/events.ts @@ -9,9 +9,10 @@ * @param did - The ATProto DID to read records from. */ -import { AtpAgent } from '@atproto/api'; +import { Client } from '@atproto/lex' import { decodeTid } from '@ewanc26/tid'; import type { KofiEventType } from './types.js'; +import { com } from '@bsky/sdk/lexicons' export type { KofiEventType }; @@ -37,20 +38,20 @@ async function resolvePdsUrl(did: string): Promise { export async function fetchEvents(did: string): Promise { const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); + const client = new Client(pdsUrl); const events: KofiSupportEvent[] = []; let cursor: string | undefined; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, + const res = await client.call(com.atproto.repo.listRecords, { + repo: did as any, collection: COLLECTION, limit: 100, cursor }); - for (const record of res.data.records) { + for (const record of res.records) { const value = record.value as { name: string; type: KofiEventType; tier?: string }; const rkey = record.uri.split('/').pop() ?? ''; let date: Date; @@ -62,7 +63,7 @@ export async function fetchEvents(did: string): Promise { events.push({ rkey, name: value.name, type: value.type, tier: value.tier, date }); } - cursor = res.data.cursor; + cursor = res.cursor; } while (cursor); return events.sort((a, b) => b.date.getTime() - a.date.getTime()); diff --git a/packages/supporters/src/lib/github-store.ts b/packages/supporters/src/lib/github-store.ts index 142c417..a66532d 100644 --- a/packages/supporters/src/lib/github-store.ts +++ b/packages/supporters/src/lib/github-store.ts @@ -15,9 +15,11 @@ * ATPROTO_APP_PASSWORD — an app password from your PDS settings */ -import { AtpAgent } from '@atproto/api'; +import { Client } from '@atproto/lex' +import { PasswordSession } from '@atproto/lex-password-session' import { generateTID, decodeTid } from "@ewanc26/tid"; import type { GitHubSponsor, GitHubSponsorshipAction } from './github-types.js'; +import { com } from '@bsky/sdk/lexicons' const COLLECTION = 'uk.ewancroft.support.github'; @@ -60,13 +62,17 @@ async function resolvePdsUrl(did: string): Promise { return data.pds; } -async function authedAgent(): Promise<{ agent: AtpAgent; did: string }> { +async function authedClient(): Promise<{ client: Client; did: string }> { const did = requireEnv('ATPROTO_DID'); const password = requireEnv('ATPROTO_APP_PASSWORD'); const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); - await agent.login({ identifier: did, password }); - return { agent, did }; + const session = await PasswordSession.login({ + service: pdsUrl, + identifier: did, + password, + }); + const client = new Client(session, { service: pdsUrl as any }); + return { client, did }; } /** @@ -77,21 +83,21 @@ async function authedAgent(): Promise<{ agent: AtpAgent; did: string }> { */ export async function fetchSponsorEvents(did: string): Promise { const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); + const client = new Client(pdsUrl); const events: GitHubSponsorEvent[] = []; let cursor: string | undefined; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, + const res = await client.call(com.atproto.repo.listRecords, { + repo: did as any, collection: COLLECTION, limit: 100, cursor }); - for (const record of res.data.records) { - const value = record.value as GitHubSponsorEventRecord; + for (const record of res.records) { + const value = record.value as any; const rkey = record.uri.split('/').pop() ?? ''; let date: Date; try { @@ -110,7 +116,7 @@ export async function fetchSponsorEvents(did: string): Promise b.date.getTime() - a.date.getTime()); @@ -124,25 +130,25 @@ export async function fetchSponsorEvents(did: string): Promise { const did = requireEnv('ATPROTO_DID'); const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); + const client = new Client(pdsUrl); const events: Array<{ rkey: string; record: GitHubSponsorEventRecord }> = []; let cursor: string | undefined; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, + const res = await client.call(com.atproto.repo.listRecords, { + repo: did as any, collection: COLLECTION, limit: 100, cursor }); - for (const record of res.data.records) { + for (const record of res.records) { const rkey = record.uri.split('/').pop() ?? ''; - events.push({ rkey, record: record.value as unknown as GitHubSponsorEventRecord }); + events.push({ rkey, record: record.value as any }); } - cursor = res.data.cursor; + cursor = res.cursor; } while (cursor); events.sort((a, b) => (a.rkey < b.rkey ? -1 : 1)); @@ -185,7 +191,7 @@ export async function appendSponsorEvent( monthlyUsd: number, timestamp: string ): Promise { - const { agent, did } = await authedAgent(); + const { client, did } = await authedClient(); const record: GitHubSponsorEventRecord = { login, @@ -197,10 +203,10 @@ export async function appendSponsorEvent( const ts = timestamp.endsWith('Z') ? timestamp : timestamp + 'Z'; - await agent.com.atproto.repo.putRecord({ - repo: did, + await client.call(com.atproto.repo.putRecord.main as any, { + repo: did as any, collection: COLLECTION, rkey: generateTID(ts), - record: record as unknown as { [x: string]: unknown } + record: record as any }); } diff --git a/packages/supporters/src/lib/store.ts b/packages/supporters/src/lib/store.ts index 2918261..5df4104 100644 --- a/packages/supporters/src/lib/store.ts +++ b/packages/supporters/src/lib/store.ts @@ -16,9 +16,11 @@ * The PDS URL is resolved automatically from the DID via Slingshot. */ -import { AtpAgent } from '@atproto/api'; +import { Client } from '@atproto/lex' +import { PasswordSession } from '@atproto/lex-password-session' import { generateTID } from "@ewanc26/tid"; import type { KofiSupporter, KofiEventType } from './types.js'; +import { com } from '@bsky/sdk/lexicons' const COLLECTION = 'uk.ewancroft.support.kofi'; @@ -53,15 +55,19 @@ async function resolvePdsUrl(did: string): Promise { return data.pds; } -/** Authenticated agent for write operations. */ -async function authedAgent(): Promise<{ agent: AtpAgent; did: string }> { - const did = requireEnv('ATPROTO_DID'); - const password = requireEnv('ATPROTO_APP_PASSWORD'); - - const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); - await agent.login({ identifier: did, password }); - return { agent, did }; +/** Authenticated client for write operations. */ +async function authedClient(): Promise<{ client: Client; did: string }> { + const did = requireEnv('ATPROTO_DID'); + const password = requireEnv('ATPROTO_APP_PASSWORD'); + + const pdsUrl = await resolvePdsUrl(did); + const session = await PasswordSession.login({ + service: pdsUrl, + identifier: did, + password, + }); + const client = new Client(session, { service: pdsUrl as any }); + return { client, did }; } /** @@ -72,24 +78,24 @@ export async function readStore(): Promise { const did = requireEnv('ATPROTO_DID'); const pdsUrl = await resolvePdsUrl(did); - const agent = new AtpAgent({ service: pdsUrl }); + const client = new Client(pdsUrl); const events: KofiEventRecord[] = []; let cursor: string | undefined; do { - const res = await agent.com.atproto.repo.listRecords({ - repo: did, + const res = await client.call(com.atproto.repo.listRecords, { + repo: did as any, collection: COLLECTION, limit: 100, cursor }); - for (const record of res.data.records) { - events.push(record.value as unknown as KofiEventRecord); + for (const record of res.records) { + events.push(record.value as any); } - cursor = res.data.cursor; + cursor = res.cursor; } while (cursor); return aggregateEvents(events); @@ -129,7 +135,7 @@ export async function appendEvent( shopItems?: string[]; } ): Promise { - const { agent, did } = await authedAgent(); + const { client, did } = await authedClient(); // Ko-fi timestamps have no timezone; normalise to UTC const ts = timestamp.endsWith('Z') ? timestamp : timestamp + 'Z'; @@ -143,10 +149,10 @@ export async function appendEvent( ...(opts?.shopItems?.length ? { shopItems: opts.shopItems } : {}) }; - await agent.com.atproto.repo.putRecord({ - repo: did, + await client.call(com.atproto.repo.putRecord.main as any, { + repo: did as any, collection: COLLECTION, rkey: generateTID(ts), - record: record as unknown as { [x: string]: unknown } + record: record as any }); } diff --git a/packages/svelte-standard-site/package.json b/packages/svelte-standard-site/package.json index c364cc9..259d81b 100644 --- a/packages/svelte-standard-site/package.json +++ b/packages/svelte-standard-site/package.json @@ -1,138 +1,139 @@ { - "name": "@ewanc26/svelte-standard-site", - "version": "0.2.4", - "description": "SvelteKit library for reading and writing AT Protocol longform content via site.standard.* records — with a complete design system, federated comments, publishing tools, and content verification.", - "author": "Ewan Croft", - "license": "AGPL-3.0-only", - "type": "module", - "keywords": [ - "svelte", - "sveltekit", - "atproto", - "at-protocol", - "bluesky", - "site-standard", - "blog", - "cms", - "design-system", - "components", - "dark-mode", - "light-mode", - "theme", - "publishing", - "federation", - "comments" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/ewanc26/pkgs.git", - "directory": "packages/svelte-standard-site" - }, - "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/svelte-standard-site", - "bugs": { - "url": "https://github.com/ewanc26/pkgs/issues" - }, - "publishConfig": { - "access": "public" - }, - "files": [ - "dist", - "src/lib", - "README.md" - ], - "sideEffects": [ - "**/*.css" - ], - "svelte": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "svelte": "./dist/index.js", - "default": "./dist/index.js" - }, - "./publisher": { - "types": "./dist/publisher.d.ts", - "default": "./dist/publisher.js" - }, - "./content": { - "types": "./dist/utils/content.d.ts", - "default": "./dist/utils/content.js" - }, - "./comments": { - "types": "./dist/utils/native-comments.d.ts", - "default": "./dist/utils/native-comments.js" - }, - "./verification": { - "types": "./dist/utils/verification.d.ts", - "default": "./dist/utils/verification.js" - }, - "./schemas": { - "types": "./dist/schemas.d.ts", - "default": "./dist/schemas.js" - }, - "./config/env": { - "types": "./dist/config/env.d.ts", - "default": "./dist/config/env.js" - }, - "./styles/base.css": { - "default": "./dist/styles/base.css" - }, - "./styles/themes.css": { - "default": "./dist/styles/themes.css" - } - }, - "scripts": { - "build": "svelte-kit sync && svelte-package -i src/lib -o dist && publint", - "dev": "svelte-kit sync && svelte-package -i src/lib -o dist --watch", - "dev:app": "vite dev", - "preview": "vite preview", - "prepare": "svelte-kit sync && svelte-package -i src/lib -o dist || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "test": "vitest run", - "test:watch": "vitest", - "format": "prettier --write .", - "lint": "prettier --check ." - }, - "dependencies": { - "@atproto/api": "^0.19.3", - "@ewanc26/atproto": "workspace:*", - "@ewanc26/tid": "workspace:*", - "@ewanc26/utils": "workspace:*", - "@lucide/svelte": "^0.577.0", - "katex": "^0.16.38", - "rehype-slug": "^6.0.0", - "rehype-stringify": "^10.0.1", - "remark-gfm": "^4.0.1", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.1.2", - "shiki": "^4.3.0", - "unified": "^11.0.5", - "zod": "^3.24.0" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.0.0", - "svelte": "^5.0.0" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/kit": "^2.55.0", - "@sveltejs/package": "^2.5.7", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.1", - "@types/node": "^25.5.0", - "jsdom": "^27.0.0", - "prettier": "^3.7.4", - "prettier-plugin-svelte": "^3.5.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "publint": "^0.3.18", - "svelte": "^5.53.11", - "svelte-check": "^4.4.5", - "tailwindcss": "^4.2.1", - "typescript": "^5.9.3", - "vite": "^7.2.6", - "vitest": "^4.1.0" - } + "name": "@ewanc26/svelte-standard-site", + "version": "0.2.4", + "description": "SvelteKit library for reading and writing AT Protocol longform content via site.standard.* records \u2014 with a complete design system, federated comments, publishing tools, and content verification.", + "author": "Ewan Croft", + "license": "AGPL-3.0-only", + "type": "module", + "keywords": [ + "svelte", + "sveltekit", + "atproto", + "at-protocol", + "bluesky", + "site-standard", + "blog", + "cms", + "design-system", + "components", + "dark-mode", + "light-mode", + "theme", + "publishing", + "federation", + "comments" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ewanc26/pkgs.git", + "directory": "packages/svelte-standard-site" + }, + "homepage": "https://github.com/ewanc26/pkgs/tree/main/packages/svelte-standard-site", + "bugs": { + "url": "https://github.com/ewanc26/pkgs/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "src/lib", + "README.md" + ], + "sideEffects": [ + "**/*.css" + ], + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./publisher": { + "types": "./dist/publisher.d.ts", + "default": "./dist/publisher.js" + }, + "./content": { + "types": "./dist/utils/content.d.ts", + "default": "./dist/utils/content.js" + }, + "./comments": { + "types": "./dist/utils/native-comments.d.ts", + "default": "./dist/utils/native-comments.js" + }, + "./verification": { + "types": "./dist/utils/verification.d.ts", + "default": "./dist/utils/verification.js" + }, + "./schemas": { + "types": "./dist/schemas.d.ts", + "default": "./dist/schemas.js" + }, + "./config/env": { + "types": "./dist/config/env.d.ts", + "default": "./dist/config/env.js" + }, + "./styles/base.css": { + "default": "./dist/styles/base.css" + }, + "./styles/themes.css": { + "default": "./dist/styles/themes.css" + } + }, + "scripts": { + "build": "svelte-kit sync && svelte-package -i src/lib -o dist && publint", + "dev": "svelte-kit sync && svelte-package -i src/lib -o dist --watch", + "dev:app": "vite dev", + "preview": "vite preview", + "prepare": "svelte-kit sync && svelte-package -i src/lib -o dist || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", + "test:watch": "vitest", + "format": "prettier --write .", + "lint": "prettier --check ." + }, + "dependencies": { + "@bsky/sdk": "latest", + "@atproto/lex": "^0.3.0", + "@ewanc26/atproto": "workspace:*", + "@ewanc26/tid": "workspace:*", + "@ewanc26/utils": "workspace:*", + "@lucide/svelte": "^0.577.0", + "katex": "^0.16.38", + "rehype-slug": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "shiki": "^4.3.0", + "unified": "^11.0.5", + "zod": "^3.24.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0", + "svelte": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/package": "^2.5.7", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^25.5.0", + "jsdom": "^27.0.0", + "prettier": "^3.7.4", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "publint": "^0.3.18", + "svelte": "^5.53.11", + "svelte-check": "^4.4.5", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.2.6", + "vitest": "^4.1.0" + } } diff --git a/packages/svelte-standard-site/src/lib/components/document/RichText.svelte b/packages/svelte-standard-site/src/lib/components/document/RichText.svelte index 544fd8e..3e41548 100644 --- a/packages/svelte-standard-site/src/lib/components/document/RichText.svelte +++ b/packages/svelte-standard-site/src/lib/components/document/RichText.svelte @@ -1,6 +1,5 @@