diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 9d7e8c56171..37419f4e235 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,73 @@ import type { Argv } from "yargs" +import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "../../session" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { Storage } from "../../storage/storage" import { Instance } from "../../project/instance" +import { ShareNext } from "../../share/share-next" import { EOL } from "os" +/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */ +export type ShareData = + | { type: "session"; data: SDKSession } + | { type: "message"; data: Message } + | { type: "part"; data: Part } + | { type: "session_diff"; data: unknown } + | { type: "model"; data: unknown } + +/** Extract share ID from a share URL like https://opncd.ai/share/abc123 */ +export function parseShareUrl(url: string): string | null { + const match = url.match(/^https?:\/\/[^/]+\/share\/([a-zA-Z0-9_-]+)$/) + return match ? match[1] : null +} + +/** + * Transform ShareNext API response (flat array) into the nested structure for local file storage. + * + * The API returns a flat array: [session, message, message, part, part, ...] + * Local storage expects: { info: session, messages: [{ info: message, parts: [part, ...] }, ...] } + * + * This groups parts by their messageID to reconstruct the hierarchy before writing to disk. + */ +export function transformShareData(shareData: ShareData[]): { + info: SDKSession + messages: Array<{ info: Message; parts: Part[] }> +} | null { + const sessionItem = shareData.find((d) => d.type === "session") + if (!sessionItem) return null + + const messageMap = new Map() + const partMap = new Map() + + for (const item of shareData) { + if (item.type === "message") { + messageMap.set(item.data.id, item.data) + } else if (item.type === "part") { + if (!partMap.has(item.data.messageID)) { + partMap.set(item.data.messageID, []) + } + partMap.get(item.data.messageID)!.push(item.data) + } + } + + if (messageMap.size === 0) return null + + return { + info: sessionItem.data, + messages: Array.from(messageMap.values()).map((msg) => ({ + info: msg, + parts: partMap.get(msg.id) ?? [], + })), + } +} + export const ImportCommand = cmd({ command: "import ", describe: "import session data from JSON file or URL", builder: (yargs: Argv) => { return yargs.positional("file", { - describe: "path to JSON file or opencode.ai share URL", + describe: "path to JSON file or share URL", type: "string", demandOption: true, }) @@ -22,8 +78,8 @@ export const ImportCommand = cmd({ | { info: Session.Info messages: Array<{ - info: any - parts: any[] + info: Message + parts: Part[] }> } | undefined @@ -31,15 +87,16 @@ export const ImportCommand = cmd({ const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") if (isUrl) { - const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/) - if (!urlMatch) { - process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/`) + const slug = parseShareUrl(args.file) + if (!slug) { + const baseUrl = await ShareNext.url() + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) process.stdout.write(EOL) return } - const slug = urlMatch[1] - const response = await fetch(`https://opncd.ai/api/share/${slug}`) + const baseUrl = await ShareNext.url() + const response = await fetch(`${baseUrl}/api/share/${slug}/data`) if (!response.ok) { process.stdout.write(`Failed to fetch share data: ${response.statusText}`) @@ -47,24 +104,16 @@ export const ImportCommand = cmd({ return } - const data = await response.json() + const shareData: ShareData[] = await response.json() + const transformed = transformShareData(shareData) - if (!data.info || !data.messages || Object.keys(data.messages).length === 0) { - process.stdout.write(`Share not found: ${slug}`) + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) process.stdout.write(EOL) return } - exportData = { - info: data.info, - messages: Object.values(data.messages).map((msg: any) => { - const { parts, ...info } = msg - return { - info, - parts, - } - }), - } + exportData = transformed } else { const file = Bun.file(args.file) exportData = await file.json().catch(() => {}) diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 95271f8c827..b3e8e354fdd 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -11,7 +11,7 @@ import type * as SDK from "@opencode-ai/sdk/v2" export namespace ShareNext { const log = Log.create({ service: "share-next" }) - async function url() { + export async function url() { return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") } diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts new file mode 100644 index 00000000000..a1a69dc0941 --- /dev/null +++ b/packages/opencode/test/cli/import.test.ts @@ -0,0 +1,38 @@ +import { test, expect } from "bun:test" +import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import" + +// parseShareUrl tests +test("parses valid share URLs", () => { + expect(parseShareUrl("https://opncd.ai/share/Jsj3hNIW")).toBe("Jsj3hNIW") + expect(parseShareUrl("https://custom.example.com/share/abc123")).toBe("abc123") + expect(parseShareUrl("http://localhost:3000/share/test_id-123")).toBe("test_id-123") +}) + +test("rejects invalid URLs", () => { + expect(parseShareUrl("https://opncd.ai/s/Jsj3hNIW")).toBeNull() // legacy format + expect(parseShareUrl("https://opncd.ai/share/")).toBeNull() + expect(parseShareUrl("https://opncd.ai/share/id/extra")).toBeNull() + expect(parseShareUrl("not-a-url")).toBeNull() +}) + +// transformShareData tests +test("transforms share data to storage format", () => { + const data: ShareData[] = [ + { type: "session", data: { id: "sess-1", title: "Test" } as any }, + { type: "message", data: { id: "msg-1", sessionID: "sess-1" } as any }, + { type: "part", data: { id: "part-1", messageID: "msg-1" } as any }, + { type: "part", data: { id: "part-2", messageID: "msg-1" } as any }, + ] + + const result = transformShareData(data)! + + expect(result.info.id).toBe("sess-1") + expect(result.messages).toHaveLength(1) + expect(result.messages[0].parts).toHaveLength(2) +}) + +test("returns null for invalid share data", () => { + expect(transformShareData([])).toBeNull() + expect(transformShareData([{ type: "message", data: {} as any }])).toBeNull() + expect(transformShareData([{ type: "session", data: { id: "s" } as any }])).toBeNull() // no messages +})