Skip to content

Commit 79b6ce5

Browse files
authored
refactor(cli): convert import command to effectCmd (#25467)
1 parent 0c816eb commit 79b6ce5

1 file changed

Lines changed: 133 additions & 123 deletions

File tree

Lines changed: 133 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import type { Argv } from "yargs"
21
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
32
import { Session } from "@/session/session"
43
import { MessageV2 } from "../../session/message-v2"
5-
import { cmd } from "./cmd"
6-
import { bootstrap } from "../bootstrap"
4+
import { CliError, effectCmd } from "../effect-cmd"
75
import { Database } from "@/storage/db"
86
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
9-
import { Instance } from "../../project/instance"
7+
import { InstanceRef } from "@/effect/instance-ref"
8+
import { InstanceStore } from "@/project/instance-store"
109
import { ShareNext } from "@/share/share-next"
1110
import { EOL } from "os"
1211
import { Filesystem } from "@/util/filesystem"
13-
import { AppRuntime } from "@/effect/app-runtime"
14-
import { Schema } from "effect"
12+
import { Effect, Schema } from "effect"
1513

1614
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
1715
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
@@ -78,135 +76,147 @@ export function transformShareData(shareData: ShareData[]): {
7876
}
7977
}
8078

81-
export const ImportCommand = cmd({
79+
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }
80+
81+
export const ImportCommand = effectCmd({
8282
command: "import <file>",
8383
describe: "import session data from JSON file or URL",
84-
builder: (yargs: Argv) => {
85-
return yargs.positional("file", {
84+
builder: (yargs) =>
85+
yargs.positional("file", {
8686
describe: "path to JSON file or share URL",
8787
type: "string",
8888
demandOption: true,
89+
}),
90+
handler: Effect.fn("Cli.import")(function* (args) {
91+
// effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant.
92+
const ctx = yield* InstanceRef
93+
if (!ctx) return yield* Effect.die("InstanceRef not provided")
94+
const store = yield* InstanceStore.Service
95+
// Ensure store.dispose runs disposers and emits server.instance.disposed
96+
// on every exit path: success, early return, typed failure, defect, interrupt.
97+
return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx)))
98+
}),
99+
})
100+
101+
const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) {
102+
const share = yield* ShareNext.Service
103+
104+
let exportData: ExportData | undefined
105+
106+
const isUrl = file.startsWith("http://") || file.startsWith("https://")
107+
108+
if (isUrl) {
109+
const slug = parseShareUrl(file)
110+
if (!slug) {
111+
const baseUrl = yield* Effect.orDie(share.url())
112+
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
113+
process.stdout.write(EOL)
114+
return
115+
}
116+
117+
const baseUrl = new URL(file).origin
118+
const req = yield* Effect.orDie(share.request())
119+
const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {}
120+
121+
const tryFetch = (url: string) =>
122+
Effect.tryPromise({
123+
try: () => fetch(url, { headers }),
124+
catch: (e) =>
125+
new CliError({
126+
message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`,
127+
}),
128+
})
129+
130+
const dataPath = req.api.data(slug)
131+
let response = yield* tryFetch(`${baseUrl}${dataPath}`)
132+
133+
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
134+
response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`)
135+
}
136+
137+
if (!response.ok) {
138+
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
139+
process.stdout.write(EOL)
140+
return
141+
}
142+
143+
const shareData = yield* Effect.tryPromise({
144+
try: () => response.json() as Promise<ShareData[]>,
145+
catch: () => new CliError({ message: "Share data was not valid JSON" }),
89146
})
90-
},
91-
handler: async (args) => {
92-
await bootstrap(process.cwd(), async () => {
93-
let exportData:
94-
| {
95-
info: SDKSession
96-
messages: Array<{
97-
info: Message
98-
parts: Part[]
99-
}>
100-
}
101-
| undefined
102-
103-
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
104-
105-
if (isUrl) {
106-
const slug = parseShareUrl(args.file)
107-
if (!slug) {
108-
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
109-
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
110-
process.stdout.write(EOL)
111-
return
112-
}
113-
114-
const parsed = new URL(args.file)
115-
const baseUrl = parsed.origin
116-
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
117-
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
118-
119-
const dataPath = req.api.data(slug)
120-
let response = await fetch(`${baseUrl}${dataPath}`, {
121-
headers,
122-
})
147+
const transformed = transformShareData(shareData)
123148

124-
if (!response.ok && dataPath !== `/api/share/${slug}/data`) {
125-
response = await fetch(`${baseUrl}/api/share/${slug}/data`, {
126-
headers,
127-
})
128-
}
129-
130-
if (!response.ok) {
131-
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
132-
process.stdout.write(EOL)
133-
return
134-
}
135-
136-
const shareData: ShareData[] = await response.json()
137-
const transformed = transformShareData(shareData)
138-
139-
if (!transformed) {
140-
process.stdout.write(`Share not found or empty: ${slug}`)
141-
process.stdout.write(EOL)
142-
return
143-
}
144-
145-
exportData = transformed
146-
} else {
147-
exportData = await Filesystem.readJson<NonNullable<typeof exportData>>(args.file).catch(() => undefined)
148-
if (!exportData) {
149-
process.stdout.write(`File not found: ${args.file}`)
150-
process.stdout.write(EOL)
151-
return
152-
}
153-
}
149+
if (!transformed) {
150+
process.stdout.write(`Share not found or empty: ${slug}`)
151+
process.stdout.write(EOL)
152+
return
153+
}
154154

155-
if (!exportData) {
156-
process.stdout.write(`Failed to read session data`)
157-
process.stdout.write(EOL)
158-
return
159-
}
155+
exportData = transformed
156+
} else {
157+
exportData = yield* Effect.promise(() =>
158+
Filesystem.readJson<NonNullable<typeof exportData>>(file).catch(() => undefined),
159+
)
160+
if (!exportData) {
161+
process.stdout.write(`File not found: ${file}`)
162+
process.stdout.write(EOL)
163+
return
164+
}
165+
}
166+
167+
if (!exportData) {
168+
process.stdout.write(`Failed to read session data`)
169+
process.stdout.write(EOL)
170+
return
171+
}
160172

161-
const info = Schema.decodeUnknownSync(Session.Info)({
162-
...exportData.info,
163-
projectID: Instance.project.id,
164-
}) as Session.Info
165-
const row = Session.toRow(info)
173+
const info = Schema.decodeUnknownSync(Session.Info)({
174+
...exportData.info,
175+
projectID,
176+
}) as Session.Info
177+
const row = Session.toRow(info)
178+
Database.use((db) =>
179+
db
180+
.insert(SessionTable)
181+
.values(row)
182+
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
183+
.run(),
184+
)
185+
186+
for (const msg of exportData.messages) {
187+
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
188+
const { id, sessionID: _, ...msgData } = msgInfo
189+
Database.use((db) =>
190+
db
191+
.insert(MessageTable)
192+
.values({
193+
id,
194+
session_id: row.id,
195+
time_created: msgInfo.time?.created ?? Date.now(),
196+
data: msgData,
197+
})
198+
.onConflictDoNothing()
199+
.run(),
200+
)
201+
202+
for (const part of msg.parts) {
203+
const partInfo = decodePart(part) as MessageV2.Part
204+
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
166205
Database.use((db) =>
167206
db
168-
.insert(SessionTable)
169-
.values(row)
170-
.onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } })
207+
.insert(PartTable)
208+
.values({
209+
id: partId,
210+
message_id: messageID,
211+
session_id: row.id,
212+
data: partData,
213+
})
214+
.onConflictDoNothing()
171215
.run(),
172216
)
217+
}
218+
}
173219

174-
for (const msg of exportData.messages) {
175-
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
176-
const { id, sessionID: _, ...msgData } = msgInfo
177-
Database.use((db) =>
178-
db
179-
.insert(MessageTable)
180-
.values({
181-
id,
182-
session_id: row.id,
183-
time_created: msgInfo.time?.created ?? Date.now(),
184-
data: msgData,
185-
})
186-
.onConflictDoNothing()
187-
.run(),
188-
)
189-
190-
for (const part of msg.parts) {
191-
const partInfo = decodePart(part) as MessageV2.Part
192-
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
193-
Database.use((db) =>
194-
db
195-
.insert(PartTable)
196-
.values({
197-
id: partId,
198-
message_id: messageID,
199-
session_id: row.id,
200-
data: partData,
201-
})
202-
.onConflictDoNothing()
203-
.run(),
204-
)
205-
}
206-
}
207-
208-
process.stdout.write(`Imported session: ${exportData.info.id}`)
209-
process.stdout.write(EOL)
210-
})
211-
},
220+
process.stdout.write(`Imported session: ${exportData.info.id}`)
221+
process.stdout.write(EOL)
212222
})

0 commit comments

Comments
 (0)