From 6a0ddf859a81cd2a9ea4cace0ff32c3f5483954e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 15:33:40 -0400 Subject: [PATCH 1/2] refactor(cli): convert import command to effectCmd Drop bootstrap()/Instance.provide ceremony in favor of effectCmd's auto- provided InstanceRef. ShareNext.Service is yielded directly instead of through AppRuntime.runPromise wrappers. Body extracted to runImport so the legacy bootstrap-finally disposal can be matched via Effect.ensuring without re-indenting the entire body. Behavior preserved: dispose still runs on success / typed failure / defect / interruption (Effect.ensuring is a strict superset of the old try/finally). --- packages/opencode/src/cli/cmd/import.ts | 245 ++++++++++++------------ 1 file changed, 121 insertions(+), 124 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index d55aba091aa6..40e4b818c52f 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,15 @@ -import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "@/effect/app-runtime" -import { Schema } from "effect" +import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) const decodePart = Schema.decodeUnknownSync(MessageV2.Part) @@ -78,135 +76,134 @@ export function transformShareData(shareData: ShareData[]): { } } -export const ImportCommand = cmd({ +type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } + +export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", - builder: (yargs: Argv) => { - return yargs.positional("file", { + builder: (yargs) => + yargs.positional("file", { describe: "path to JSON file or share URL", type: "string", demandOption: true, - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let exportData: - | { - info: SDKSession - messages: Array<{ - info: Message - parts: Part[] - }> - } - | undefined - - const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") - - if (isUrl) { - const slug = parseShareUrl(args.file) - if (!slug) { - const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } - - const parsed = new URL(args.file) - const baseUrl = parsed.origin - const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) - const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} - - const dataPath = req.api.data(slug) - let response = await fetch(`${baseUrl}${dataPath}`, { - headers, - }) + }), + handler: Effect.fn("Cli.import")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + // Match legacy bootstrap() finally — dispose runs disposers + emits + // server.instance.disposed even on early-return / error paths. + return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers, - }) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData: ShareData[] = await response.json() - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = await Filesystem.readJson>(args.file).catch(() => undefined) - if (!exportData) { - process.stdout.write(`File not found: ${args.file}`) - process.stdout.write(EOL) - return - } - } +const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { + const share = yield* ShareNext.Service - if (!exportData) { - process.stdout.write(`Failed to read session data`) - process.stdout.write(EOL) - return - } + let exportData: ExportData | undefined + + const isUrl = file.startsWith("http://") || file.startsWith("https://") + + if (isUrl) { + const slug = parseShareUrl(file) + if (!slug) { + const baseUrl = yield* Effect.orDie(share.url()) + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) + process.stdout.write(EOL) + return + } + + const baseUrl = new URL(file).origin + const req = yield* Effect.orDie(share.request()) + const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + + const dataPath = req.api.data(slug) + let response = yield* Effect.promise(() => fetch(`${baseUrl}${dataPath}`, { headers })) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = yield* Effect.promise(() => fetch(`${baseUrl}/api/share/${slug}/data`, { headers })) + } + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const shareData = (yield* Effect.promise(() => response.json())) as ShareData[] + const transformed = transformShareData(shareData) - const info = Schema.decodeUnknownSync(Session.Info)({ - ...exportData.info, - projectID: Instance.project.id, - }) as Session.Info - const row = Session.toRow(info) + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) + process.stdout.write(EOL) + return + } + + exportData = transformed + } else { + exportData = yield* Effect.promise(() => + Filesystem.readJson>(file).catch(() => undefined), + ) + if (!exportData) { + process.stdout.write(`File not found: ${file}`) + process.stdout.write(EOL) + return + } + } + + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } + + const info = Schema.decodeUnknownSync(Session.Info)({ + ...exportData.info, + projectID, + }) as Session.Info + const row = Session.toRow(info) + Database.use((db) => + db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .run(), + ) + + for (const msg of exportData.messages) { + const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const { id, sessionID: _, ...msgData } = msgInfo + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData, + }) + .onConflictDoNothing() + .run(), + ) + + for (const part of msg.parts) { + const partInfo = decodePart(part) as MessageV2.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() .run(), ) + } + } - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info - const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part - const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) - } - } - - process.stdout.write(`Imported session: ${exportData.info.id}`) - process.stdout.write(EOL) - }) - }, + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) }) From e5483d7c136bd48452229187dfe69f3ae99ac12f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:48:02 -0400 Subject: [PATCH 2/2] refactor(cli): friendly fetch/JSON errors + tighten invariants - Use Effect.tryPromise + CliError for fetch and response.json so network/parse failures surface as clean one-liners instead of FiberFailure stack traces. - Make the InstanceRef invariant explicit: Effect.die instead of a silent early return when (impossibly) absent. - Reword the legacy-bootstrap comment to describe Effect.ensuring's exit-path coverage directly. --- packages/opencode/src/cli/cmd/import.ts | 27 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 40e4b818c52f..8d19376662a0 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,7 +1,7 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { effectCmd } from "../effect-cmd" +import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { InstanceRef } from "@/effect/instance-ref" @@ -88,11 +88,12 @@ export const ImportCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.import")(function* (args) { + // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. const ctx = yield* InstanceRef - if (!ctx) return + if (!ctx) return yield* Effect.die("InstanceRef not provided") const store = yield* InstanceStore.Service - // Match legacy bootstrap() finally — dispose runs disposers + emits - // server.instance.disposed even on early-return / error paths. + // Ensure store.dispose runs disposers and emits server.instance.disposed + // on every exit path: success, early return, typed failure, defect, interrupt. return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) }), }) @@ -117,11 +118,20 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI const req = yield* Effect.orDie(share.request()) const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + const tryFetch = (url: string) => + Effect.tryPromise({ + try: () => fetch(url, { headers }), + catch: (e) => + new CliError({ + message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, + }), + }) + const dataPath = req.api.data(slug) - let response = yield* Effect.promise(() => fetch(`${baseUrl}${dataPath}`, { headers })) + let response = yield* tryFetch(`${baseUrl}${dataPath}`) if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = yield* Effect.promise(() => fetch(`${baseUrl}/api/share/${slug}/data`, { headers })) + response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) } if (!response.ok) { @@ -130,7 +140,10 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectI return } - const shareData = (yield* Effect.promise(() => response.json())) as ShareData[] + const shareData = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CliError({ message: "Share data was not valid JSON" }), + }) const transformed = transformShareData(shareData) if (!transformed) {