Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 70 additions & 21 deletions packages/opencode/src/cli/cmd/import.ts
Original file line number Diff line number Diff line change
@@ -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<string, Message>()
const partMap = new Map<string, Part[]>()

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 <file>",
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,
})
Expand All @@ -22,49 +78,42 @@ export const ImportCommand = cmd({
| {
info: Session.Info
messages: Array<{
info: any
parts: any[]
info: Message
parts: Part[]
}>
}
| undefined

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/<slug>`)
const slug = parseShareUrl(args.file)
if (!slug) {
const baseUrl = await ShareNext.url()
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
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}`)
process.stdout.write(EOL)
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(() => {})
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/share/share-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/test/cli/import.test.ts
Original file line number Diff line number Diff line change
@@ -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
})