import { ok } from "@atcute/client"; import type { Datetime, Did } from "@atcute/lexicons/syntax"; import { nsidOf, recordKeyOf, type Cid } from "$lib/api/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitIndexedRecord } from "$lib/api/awaitRecord"; import { hostForKnot, serviceUrlFor } from "$lib/api/_request"; import { createBobbinClient } from "$lib/api/client"; import { mainSchema as createRepoSchema } from "$lib/api/lexicons/types/org/tangled/repo/createRepo"; import type { Main as DeclarationRecord } from "$lib/api/lexicons/types/org/tangled/repo/declaration"; import { mainSchema as deleteRepoSchema } from "$lib/api/lexicons/types/sh/tangled/repo/delete"; import { createRecord, deleteRecord, readRecord, type WrittenRecord } from "$lib/api/write"; import { pingIndexNow } from "$lib/api/indexnow"; import { serviceDidForHost } from "$lib/auth/agent"; const DECLARATION_COLLECTION = nsidOf("org.tangled.repo.declaration"); export interface RepoCreationInput { ownerDid: Did; ownerHandle: string; name: string; description?: string; defaultBranch?: string; knot: string; spindle?: string; forkOf?: Did; } export interface CreatedRepo { ownerHandle: string; name: string; repoDid: Did; } export const validateRepoName = (rawName: string): string => { if (rawName.length === 0) throw new Error("Repository name cannot be empty."); if (rawName.length > 100) throw new Error("Repository name must be 100 characters or fewer."); if (rawName.includes("/") || rawName.includes("\\")) { throw new Error("Repository name contains invalid path characters."); } if (rawName.startsWith(".") || rawName.endsWith(".")) { throw new Error("Repository name contains an invalid path sequence."); } if (!/^[a-zA-Z0-9._-]+$/.test(rawName)) { throw new Error( "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores." ); } if (rawName.includes("..")) throw new Error("Repository name cannot contain sequential dots."); if (rawName.toLowerCase() === "self") throw new Error(`Repository name ${JSON.stringify(rawName)} is reserved.`); return /\.git$/i.test(rawName) ? rawName.slice(0, -4) : rawName; }; const messageOf = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); const sleep = (milliseconds: number): Promise => { const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, milliseconds); return promise; }; const deleteDeclaration = async ( agent: OAuthUserAgent, rkey: string, cid: Cid ): Promise => { try { await deleteRecord(agent, DECLARATION_COLLECTION, recordKeyOf(rkey), cid); return null; } catch (cause) { return `the repository declaration could not be reverted: ${messageOf(cause)}`; } }; const deleteKnotRepo = async ( agent: OAuthUserAgent, input: RepoCreationInput, rkey: string, repoDid: Did ): Promise => { const knotHost = hostForKnot(input.knot); const ctx = createBobbinClient({ serviceUrl: serviceUrlFor(knotHost), agent }); let lastError = "unknown error"; for (const delay of [0, 2_000, 5_000]) { if (delay > 0) await sleep(delay); try { await ok( ctx.xrpc.call(deleteRepoSchema, { input: { repo: repoDid, did: input.ownerDid, name: rkey, rkey: recordKeyOf(rkey) } }) ); return null; } catch (cause) { lastError = messageOf(cause); } } return `the repository on ${knotHost} could not be reverted: ${lastError}`; }; export const createRepo = async ( agent: OAuthUserAgent, bobbinUrl: string, input: RepoCreationInput ): Promise => { const name = validateRepoName(input.name); const rkey = name.toLowerCase(); const description = input.description?.trim() || undefined; const spindle = input.spindle?.trim() || undefined; if ([...(description ?? "")].length > 140) { throw new Error("Description must be 140 characters or fewer."); } if (!input.knot) throw new Error("Select a knot."); if (agent.sub !== input.ownerDid) throw new Error("The active account changed. Try again."); if (await readRecord(agent, DECLARATION_COLLECTION, recordKeyOf(rkey))) throw new Error( `A repository named "${name}" already exists in your account. Choose another name.` ); const createdAt = new Date().toISOString() as Datetime; const knot = createBobbinClient({ serviceUrl: serviceUrlFor(hostForKnot(input.knot)), agent }); const created = await ok( knot.xrpc.call(createRepoSchema, { input: { name: recordKeyOf(name), createdAt, ...(input.defaultBranch ? { defaultBranch: input.defaultBranch } : {}), ...(description ? { description } : {}), ...(spindle ? { spindle: serviceDidForHost(spindle) as Did } : {}), ...(input.forkOf ? { source: { $type: "org.tangled.repo.defs#sourceTangled", did: input.forkOf } } : {}) } }) ); const repoDid = created.did; if (!repoDid) { throw new Error("Knot failed to mint a repo DID. The knot may need to be upgraded."); } let written: WrittenRecord | undefined; try { const declaration: DeclarationRecord = { $type: "org.tangled.repo.declaration", repo: repoDid, createdAt }; written = await createRecord(agent, DECLARATION_COLLECTION, declaration, recordKeyOf(rkey)); await awaitIndexedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), written); } catch (cause) { const recordRollback = written ? await deleteDeclaration(agent, rkey, written.cid) : null; const knotRollback = recordRollback ? null : await deleteKnotRepo(agent, input, rkey, repoDid); const rollback = [recordRollback, knotRollback].filter(Boolean).join("; "); throw new Error( `Failed to create repository: ${messageOf(cause)}${rollback ? `. Also, ${rollback}.` : ""}`, { cause } ); } pingIndexNow(`https://tangled.org/${input.ownerHandle}/${name}`); return { ownerHandle: input.ownerHandle, name, repoDid }; };