daytona.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import type { Daytona, Sandbox } from "@daytonaio/sdk"
  2. import type { Plugin } from "@opencode-ai/plugin"
  3. import { join } from "node:path"
  4. import { fileURLToPath } from "node:url"
  5. import { tmpdir } from "node:os"
  6. import { access, copyFile, mkdir } from "node:fs/promises"
  7. let client: Promise<Daytona> | undefined
  8. let daytona = function daytona(): Promise<Daytona> {
  9. if (client == null) {
  10. client = import("@daytonaio/sdk").then(
  11. ({ Daytona }) =>
  12. new Daytona({
  13. apiKey: "dtn_d63c206564ef49d4104ec2cd755e561bb3665beed8fd7d7ab2c5f7a2186965f0",
  14. }),
  15. )
  16. }
  17. return client
  18. }
  19. const preview = new Map<string, { url: string; token: string }>()
  20. const repo = "/home/daytona/workspace/repo"
  21. const root = "/home/daytona/workspace"
  22. const localbin = "/home/daytona/opencode"
  23. const installbin = "/home/daytona/.opencode/bin/opencode"
  24. const health = "http://127.0.0.1:3096/global/health"
  25. const local = fileURLToPath(
  26. new URL("./packages/opencode/dist/opencode-linux-x64-baseline/bin/opencode", import.meta.url),
  27. )
  28. async function exists(file: string) {
  29. return access(file)
  30. .then(() => true)
  31. .catch(() => false)
  32. }
  33. function sh(value: string) {
  34. return `'${value.replace(/'/g, `"'"'"`)}'`
  35. }
  36. // Internally Daytona uses axios, which tries to overwrite stack
  37. // traces when a failure happens. That path fails in Bun, however, so
  38. // when something goes wrong you only see a very obscure error.
  39. async function withSandbox<T>(name: string, fn: (sandbox: Sandbox) => Promise<T>) {
  40. const stack = Error.captureStackTrace
  41. // @ts-expect-error temporary compatibility hack for Daytona's axios stack handling in Bun
  42. Error.captureStackTrace = undefined
  43. try {
  44. return await fn(await (await daytona()).get(name))
  45. } finally {
  46. Error.captureStackTrace = stack
  47. }
  48. }
  49. export const DaytonaWorkspacePlugin: Plugin = async ({ experimental_workspace, worktree, project }) => {
  50. experimental_workspace.register("daytona", {
  51. name: "Daytona",
  52. description: "Create a remote Daytona workspace",
  53. configure(config) {
  54. return config
  55. },
  56. async create(config, env) {
  57. const temp = join(tmpdir(), `opencode-daytona-${Date.now()}`)
  58. console.log("creating sandbox...")
  59. const sandbox = await (
  60. await daytona()
  61. ).create({
  62. name: config.name,
  63. snapshot: "daytona-large",
  64. envVars: env,
  65. })
  66. console.log("creating ssh...")
  67. const ssh = await withSandbox(config.name, (sandbox) => sandbox.createSshAccess())
  68. console.log("daytona:", ssh.sshCommand)
  69. const run = async (command: string) => {
  70. console.log("sandbox:", command)
  71. const result = await sandbox.process.executeCommand(command)
  72. if (result.result) process.stdout.write(result.result)
  73. if (result.exitCode === 0) return result
  74. throw new Error(result.result || `sandbox command failed: ${command}`)
  75. }
  76. const wait = async () => {
  77. for (let i = 0; i < 60; i++) {
  78. const result = await sandbox.process.executeCommand(`curl -fsS ${sh(health)}`)
  79. if (result.exitCode === 0) {
  80. if (result.result) process.stdout.write(result.result)
  81. return
  82. }
  83. console.log(`waiting for server (${i + 1}/60)`)
  84. await Bun.sleep(1000)
  85. }
  86. const log = await sandbox.process.executeCommand(`test -f /tmp/opencode.log && cat /tmp/opencode.log || true`)
  87. throw new Error(log.result || "daytona workspace server did not become ready in time")
  88. }
  89. const dir = join(temp, "repo")
  90. const tar = join(temp, "repo.tgz")
  91. const source = `file://${worktree}`
  92. await mkdir(temp, { recursive: true })
  93. const args = ["clone", "--depth", "1", "--no-local"]
  94. if (config.branch) args.push("--branch", config.branch)
  95. args.push(source, dir)
  96. console.log("git cloning...")
  97. const clone = Bun.spawn(["git", ...args], {
  98. cwd: tmpdir(),
  99. stdout: "pipe",
  100. stderr: "pipe",
  101. })
  102. const code = await clone.exited
  103. if (code !== 0) throw new Error(await new Response(clone.stderr).text())
  104. const configPackage = join(worktree, ".opencode", "package.json")
  105. // if (await exists(configPackage)) {
  106. // console.log("copying config package...")
  107. // await mkdir(join(dir, ".opencode"), { recursive: true })
  108. // await copyFile(configPackage, join(dir, ".opencode", "package.json"))
  109. // }
  110. console.log("tarring...")
  111. const packed = Bun.spawn(["tar", "-czf", tar, "-C", temp, "repo"], {
  112. stdout: "ignore",
  113. stderr: "pipe",
  114. })
  115. if ((await packed.exited) !== 0) throw new Error(await new Response(packed.stderr).text())
  116. console.log("uploading files...")
  117. await sandbox.fs.uploadFile(tar, "repo.tgz")
  118. const have = await exists(local)
  119. console.log("local", local)
  120. if (have) {
  121. console.log("uploading local binary...")
  122. await sandbox.fs.uploadFile(local, "opencode")
  123. }
  124. console.log("bootstrapping workspace...")
  125. await run(`rm -rf ${sh(repo)} && mkdir -p ${sh(root)} && tar -xzf \"$HOME/repo.tgz\" -C \"$HOME/workspace\"`)
  126. if (have) {
  127. await run(`chmod +x ${sh(localbin)}`)
  128. } else {
  129. await run(
  130. `mkdir -p \"$HOME/.opencode/bin\" && OPENCODE_INSTALL_DIR=\"$HOME/.opencode/bin\" curl -fsSL https://opencode.ai/install | bash`,
  131. )
  132. }
  133. await run(`printf \"%s\\n\" ${sh(project.id)} > ${sh(`${repo}/.git/opencode`)}`)
  134. console.log("starting server...")
  135. await run(
  136. `cd ${sh(repo)} && exe=${sh(localbin)} && if [ ! -x \"$exe\" ]; then exe=${sh(installbin)}; fi && nohup env \"$exe\" serve --hostname 0.0.0.0 --port 3096 >/tmp/opencode.log 2>&1 </dev/null &`,
  137. )
  138. console.log("waiting for server...")
  139. await wait()
  140. },
  141. async remove(config) {
  142. const sandbox = await (await daytona()).get(config.name).catch(() => undefined)
  143. if (!sandbox) return
  144. await (await daytona()).delete(sandbox)
  145. preview.delete(config.name)
  146. },
  147. async target(config) {
  148. let link = preview.get(config.name)
  149. if (!link) {
  150. link = await withSandbox(config.name, (sandbox) => sandbox.getPreviewLink(3096))
  151. preview.set(config.name, link)
  152. }
  153. return {
  154. type: "remote",
  155. url: link.url,
  156. headers: {
  157. "x-daytona-preview-token": link.token,
  158. "x-daytona-skip-preview-warning": "true",
  159. "x-opencode-directory": repo,
  160. },
  161. }
  162. },
  163. })
  164. return {}
  165. }
  166. export default DaytonaWorkspacePlugin