@veris-ai/daytona-opencode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.opencode/plugin/daytona/core/logger.d.ts +15 -0
  2. package/.opencode/plugin/daytona/core/logger.js +65 -0
  3. package/.opencode/plugin/daytona/core/logger.js.map +1 -0
  4. package/.opencode/plugin/daytona/core/logger.ts +71 -0
  5. package/.opencode/plugin/daytona/core/project-data-storage.d.ts +63 -0
  6. package/.opencode/plugin/daytona/core/project-data-storage.js +213 -0
  7. package/.opencode/plugin/daytona/core/project-data-storage.js.map +1 -0
  8. package/.opencode/plugin/daytona/core/project-data-storage.ts +234 -0
  9. package/.opencode/plugin/daytona/core/session-manager.d.ts +74 -0
  10. package/.opencode/plugin/daytona/core/session-manager.js +441 -0
  11. package/.opencode/plugin/daytona/core/session-manager.js.map +1 -0
  12. package/.opencode/plugin/daytona/core/session-manager.ts +471 -0
  13. package/.opencode/plugin/daytona/core/toast.d.ts +47 -0
  14. package/.opencode/plugin/daytona/core/toast.js +70 -0
  15. package/.opencode/plugin/daytona/core/toast.js.map +1 -0
  16. package/.opencode/plugin/daytona/core/toast.ts +96 -0
  17. package/.opencode/plugin/daytona/core/types.d.ts +53 -0
  18. package/.opencode/plugin/daytona/core/types.js +12 -0
  19. package/.opencode/plugin/daytona/core/types.js.map +1 -0
  20. package/.opencode/plugin/daytona/core/types.ts +72 -0
  21. package/.opencode/plugin/daytona/git/host-git-manager.d.ts +58 -0
  22. package/.opencode/plugin/daytona/git/host-git-manager.js +342 -0
  23. package/.opencode/plugin/daytona/git/host-git-manager.js.map +1 -0
  24. package/.opencode/plugin/daytona/git/host-git-manager.ts +372 -0
  25. package/.opencode/plugin/daytona/git/index.d.ts +5 -0
  26. package/.opencode/plugin/daytona/git/index.js +6 -0
  27. package/.opencode/plugin/daytona/git/index.js.map +1 -0
  28. package/.opencode/plugin/daytona/git/index.ts +6 -0
  29. package/.opencode/plugin/daytona/git/sandbox-git-manager.d.ts +22 -0
  30. package/.opencode/plugin/daytona/git/sandbox-git-manager.js +80 -0
  31. package/.opencode/plugin/daytona/git/sandbox-git-manager.js.map +1 -0
  32. package/.opencode/plugin/daytona/git/sandbox-git-manager.ts +92 -0
  33. package/.opencode/plugin/daytona/git/session-git-manager.d.ts +54 -0
  34. package/.opencode/plugin/daytona/git/session-git-manager.js +184 -0
  35. package/.opencode/plugin/daytona/git/session-git-manager.js.map +1 -0
  36. package/.opencode/plugin/daytona/git/session-git-manager.ts +203 -0
  37. package/.opencode/plugin/daytona/index.d.ts +125 -0
  38. package/.opencode/plugin/daytona/index.js +60 -0
  39. package/.opencode/plugin/daytona/index.js.map +1 -0
  40. package/.opencode/plugin/daytona/index.ts +73 -0
  41. package/.opencode/plugin/daytona/plugins/custom-tools.d.ts +122 -0
  42. package/.opencode/plugin/daytona/plugins/custom-tools.js +18 -0
  43. package/.opencode/plugin/daytona/plugins/custom-tools.js.map +1 -0
  44. package/.opencode/plugin/daytona/plugins/custom-tools.ts +21 -0
  45. package/.opencode/plugin/daytona/plugins/session-events.d.ts +10 -0
  46. package/.opencode/plugin/daytona/plugins/session-events.js +63 -0
  47. package/.opencode/plugin/daytona/plugins/session-events.js.map +1 -0
  48. package/.opencode/plugin/daytona/plugins/session-events.ts +61 -0
  49. package/.opencode/plugin/daytona/plugins/system-transform.d.ts +10 -0
  50. package/.opencode/plugin/daytona/plugins/system-transform.js +38 -0
  51. package/.opencode/plugin/daytona/plugins/system-transform.js.map +1 -0
  52. package/.opencode/plugin/daytona/plugins/system-transform.ts +43 -0
  53. package/.opencode/plugin/daytona/tools/bash.d.ts +19 -0
  54. package/.opencode/plugin/daytona/tools/bash.js +42 -0
  55. package/.opencode/plugin/daytona/tools/bash.js.map +1 -0
  56. package/.opencode/plugin/daytona/tools/bash.ts +51 -0
  57. package/.opencode/plugin/daytona/tools/edit.d.ts +21 -0
  58. package/.opencode/plugin/daytona/tools/edit.js +33 -0
  59. package/.opencode/plugin/daytona/tools/edit.js.map +1 -0
  60. package/.opencode/plugin/daytona/tools/edit.ts +44 -0
  61. package/.opencode/plugin/daytona/tools/get-preview-url.d.ts +17 -0
  62. package/.opencode/plugin/daytona/tools/get-preview-url.js +17 -0
  63. package/.opencode/plugin/daytona/tools/get-preview-url.js.map +1 -0
  64. package/.opencode/plugin/daytona/tools/get-preview-url.ts +26 -0
  65. package/.opencode/plugin/daytona/tools/git-sync.d.ts +12 -0
  66. package/.opencode/plugin/daytona/tools/git-sync.js +26 -0
  67. package/.opencode/plugin/daytona/tools/git-sync.js.map +1 -0
  68. package/.opencode/plugin/daytona/tools/git-sync.ts +36 -0
  69. package/.opencode/plugin/daytona/tools/glob.d.ts +17 -0
  70. package/.opencode/plugin/daytona/tools/glob.js +21 -0
  71. package/.opencode/plugin/daytona/tools/glob.js.map +1 -0
  72. package/.opencode/plugin/daytona/tools/glob.ts +30 -0
  73. package/.opencode/plugin/daytona/tools/grep.d.ts +17 -0
  74. package/.opencode/plugin/daytona/tools/grep.js +29 -0
  75. package/.opencode/plugin/daytona/tools/grep.js.map +1 -0
  76. package/.opencode/plugin/daytona/tools/grep.ts +39 -0
  77. package/.opencode/plugin/daytona/tools/ls.d.ts +17 -0
  78. package/.opencode/plugin/daytona/tools/ls.js +22 -0
  79. package/.opencode/plugin/daytona/tools/ls.js.map +1 -0
  80. package/.opencode/plugin/daytona/tools/ls.ts +32 -0
  81. package/.opencode/plugin/daytona/tools/multiedit.d.ts +25 -0
  82. package/.opencode/plugin/daytona/tools/multiedit.js +37 -0
  83. package/.opencode/plugin/daytona/tools/multiedit.js.map +1 -0
  84. package/.opencode/plugin/daytona/tools/multiedit.ts +54 -0
  85. package/.opencode/plugin/daytona/tools/read.d.ts +17 -0
  86. package/.opencode/plugin/daytona/tools/read.js +18 -0
  87. package/.opencode/plugin/daytona/tools/read.js.map +1 -0
  88. package/.opencode/plugin/daytona/tools/read.ts +27 -0
  89. package/.opencode/plugin/daytona/tools/veris-receipt.d.ts +26 -0
  90. package/.opencode/plugin/daytona/tools/veris-receipt.js +76 -0
  91. package/.opencode/plugin/daytona/tools/veris-receipt.js.map +1 -0
  92. package/.opencode/plugin/daytona/tools/veris-receipt.ts +103 -0
  93. package/.opencode/plugin/daytona/tools/write.d.ts +19 -0
  94. package/.opencode/plugin/daytona/tools/write.js +18 -0
  95. package/.opencode/plugin/daytona/tools/write.js.map +1 -0
  96. package/.opencode/plugin/daytona/tools/write.ts +27 -0
  97. package/.opencode/plugin/daytona/tools.d.ts +119 -0
  98. package/.opencode/plugin/daytona/tools.js +35 -0
  99. package/.opencode/plugin/daytona/tools.js.map +1 -0
  100. package/.opencode/plugin/daytona/tools.ts +45 -0
  101. package/.opencode/plugin/index.d.ts +9 -0
  102. package/.opencode/plugin/index.js +10 -0
  103. package/.opencode/plugin/index.js.map +1 -0
  104. package/.opencode/plugin/index.ts +11 -0
  105. package/README.md +87 -0
  106. package/package.json +52 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { logger } from '../core/logger'
7
+ import { spawnSync } from 'child_process'
8
+ import { realpathSync } from 'fs'
9
+ import { isAbsolute, resolve as pathResolve } from 'path'
10
+
11
+ type ExecResult = {
12
+ ok: boolean
13
+ stdout: string
14
+ stderr: string
15
+ status: number | null
16
+ }
17
+
18
+ type ExecOptions = {
19
+ cwd?: string
20
+ env?: NodeJS.ProcessEnv
21
+ }
22
+
23
+ // Runs git with an explicit argument vector and no shell, so interpolated values
24
+ // (SSH URLs, refs, remote names) can never be interpreted as shell syntax.
25
+ function execGit(args: string[], options: ExecOptions = {}): ExecResult {
26
+ const res = spawnSync('git', args, {
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ encoding: 'utf8',
29
+ ...options,
30
+ })
31
+ if (res.error) {
32
+ return { ok: false, stdout: '', stderr: res.error.message, status: null }
33
+ }
34
+ const status = typeof res.status === 'number' ? res.status : null
35
+ return { ok: status === 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '', status }
36
+ }
37
+
38
+ // POSIX sh single-quoting (close, escape, reopen): GIT_SSH_COMMAND is parsed by the
39
+ // shell, so an unquoted known_hosts path containing spaces would split into ssh args.
40
+ function shellQuote(value: string): string {
41
+ return `'${value.replace(/'/g, `'\\''`)}'`
42
+ }
43
+
44
+ // When DAYTONA_SSH_KNOWN_HOSTS names a known_hosts file with the ssh.app.daytona.io
45
+ // host keys, pin host verification to it for sandbox git transfers. This lets the
46
+ // first noninteractive sync succeed without trust-on-first-use, and without changing
47
+ // SSH behavior for any other remote. Unset means inherited SSH behavior, as before.
48
+ //
49
+ // The value crosses two parsers. sh splits GIT_SSH_COMMAND into ssh's argv (outer
50
+ // single quotes), then OpenSSH splits the UserKnownHostsFile VALUE on whitespace as
51
+ // a file list (inner double quotes keep a spaced path as one file). OpenSSH's config
52
+ // grammar has no escape for a literal double quote inside a quoted value, so such
53
+ // paths are rejected instead of being silently mis-pinned.
54
+ function networkEnv(): NodeJS.ProcessEnv | undefined {
55
+ const knownHosts = process.env.DAYTONA_SSH_KNOWN_HOSTS?.trim()
56
+ if (!knownHosts) return undefined
57
+ if (knownHosts.includes('"')) {
58
+ throw new Error('DAYTONA_SSH_KNOWN_HOSTS must not contain a double quote (") character')
59
+ }
60
+ return {
61
+ ...process.env,
62
+ // GlobalKnownHostsFile=/dev/null: otherwise a matching entry in the system-wide
63
+ // /etc/ssh/ssh_known_hosts would also be accepted and verification would not be
64
+ // pinned to the configured file alone.
65
+ GIT_SSH_COMMAND: `ssh -o ${shellQuote(`UserKnownHostsFile="${knownHosts}"`)} -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes`,
66
+ }
67
+ }
68
+
69
+ export class HostGitManager {
70
+ // Per-repo serialization: one queue per git-common-dir. Linked worktrees (git worktree
71
+ // add) of the same repo share `.git/config` and refs, so they must share a queue to
72
+ // avoid racing on `remote add/remove` and `.git/config.lock`. Different repos still
73
+ // proceed in parallel.
74
+ private static operationQueues = new Map<string, Promise<void>>()
75
+
76
+ // cwd → queue-key cache. `git rev-parse --git-common-dir` shells out; caching avoids
77
+ // running it on every enqueue. Repo layout doesn't move at runtime, so this is stable.
78
+ private static queueKeyCache = new Map<string, string>()
79
+
80
+ /**
81
+ * Resolve the serialization key for a worktree: the CANONICAL absolute path of its
82
+ * shared git dir (common across linked worktrees of the same repo). Canonicalizing
83
+ * with realpath collapses symlinked aliases onto the same key, so two callers
84
+ * reaching the same repo via different symlink paths still share a queue and can't
85
+ * race on `.git/config`. Falls back to (canonical) cwd if git can't identify a repo.
86
+ */
87
+ private static queueKeyFor(cwd: string): string {
88
+ const cached = HostGitManager.queueKeyCache.get(cwd)
89
+ if (cached) return cached
90
+ const res = execGit(['rev-parse', '--git-common-dir'], { cwd })
91
+ const rawPath = !res.ok
92
+ ? cwd
93
+ : (() => {
94
+ const out = res.stdout.trim()
95
+ return isAbsolute(out) ? out : pathResolve(cwd, out)
96
+ })()
97
+ let key: string
98
+ try {
99
+ key = realpathSync(rawPath)
100
+ } catch {
101
+ // Path may have been deleted between git resolution and our stat; use as-is
102
+ key = rawPath
103
+ }
104
+ HostGitManager.queueKeyCache.set(cwd, key)
105
+ return key
106
+ }
107
+
108
+ /**
109
+ * Chain `fn` onto the queue for the repo containing `cwd`. The stored promise is
110
+ * always-resolves so a failure doesn't poison the chain; callers still see errors via
111
+ * `await` on the returned promise. Cleared from the map when the tail settles to avoid
112
+ * leaking entries for repos that are no longer active.
113
+ */
114
+ private static enqueue<T>(cwd: string, fn: () => Promise<T>): Promise<T> {
115
+ const queueKey = HostGitManager.queueKeyFor(cwd)
116
+ const prev = HostGitManager.operationQueues.get(queueKey) ?? Promise.resolve()
117
+ const operation = prev.then(fn)
118
+ const stored: Promise<void> = operation.then(
119
+ () => undefined,
120
+ () => undefined,
121
+ )
122
+ HostGitManager.operationQueues.set(queueKey, stored)
123
+ stored.then(() => {
124
+ if (HostGitManager.operationQueues.get(queueKey) === stored) {
125
+ HostGitManager.operationQueues.delete(queueKey)
126
+ }
127
+ })
128
+ return operation
129
+ }
130
+
131
+ /** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */
132
+ private emptyCommitOidCache = new Map<string, string>()
133
+
134
+ /**
135
+ * Checks if a git repository exists in the current directory
136
+ * @returns true if a git repo exists, false otherwise
137
+ */
138
+ hasRepo(cwd?: string): boolean {
139
+ return execGit(['rev-parse', '--is-inside-work-tree'], cwd ? { cwd } : {}).ok
140
+ }
141
+
142
+ /**
143
+ * Allocates the next available opencode/N branch number by scanning local refs and
144
+ * reserving the chosen number by creating the ref immediately.
145
+ *
146
+ * This avoids relying on OpenCode's project ID and works even in repos with no commits.
147
+ */
148
+ allocateAndReserveBranchNumber(cwd: string, prefix = 'opencode'): number {
149
+ const start = Date.now()
150
+ if (!this.hasRepo(cwd)) {
151
+ throw new Error('No local git repository found.')
152
+ }
153
+
154
+ const base = `refs/heads/${prefix}/`
155
+ const listRes = execGit(['for-each-ref', '--format=%(refname:strip=3)', base], { cwd })
156
+ if (!listRes.ok) throw new Error(listRes.stderr)
157
+ const list = listRes.stdout.trim()
158
+ const nums =
159
+ list.length === 0
160
+ ? []
161
+ : list
162
+ .split('\n')
163
+ .map((s) => s.trim())
164
+ .filter(Boolean)
165
+ .map((s) => Number.parseInt(s, 10))
166
+ .filter((n) => Number.isFinite(n) && n > 0)
167
+
168
+ let n = (nums.length ? Math.max(...nums) : 0) + 1
169
+ const maxAttempts = 50 // Circuit-breaker
170
+ let attempts = 0
171
+ while (n < 1_000_000 && attempts < maxAttempts) {
172
+ attempts++
173
+ const ref = `${base}${n}`
174
+ if (this.refExists(cwd, ref)) {
175
+ n++
176
+ continue
177
+ }
178
+ const oid = this.getOrCreateEmptyCommitOid(cwd)
179
+ const result = execGit(['update-ref', ref, oid], { cwd })
180
+ if (result.ok) {
181
+ logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`)
182
+ return n
183
+ } else {
184
+ // If we raced or hit an edge case, try the next number.
185
+ n++
186
+ }
187
+ }
188
+ const oid = this.getOrCreateEmptyCommitOid(cwd)
189
+ const last = execGit(['update-ref', `${base}${n}`, oid], { cwd })
190
+ if (last.ok) {
191
+ logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`)
192
+ return n
193
+ }
194
+ throw new Error(`Failed to allocate branch number after ${attempts} attempts. Last error: ${last.stderr}`)
195
+ }
196
+
197
+ private refExists(cwd: string, ref: string): boolean {
198
+ return execGit(['show-ref', '--verify', '--quiet', ref], { cwd }).ok
199
+ }
200
+
201
+ /** Commit OID a local ref points at, or '' if the ref does not exist. */
202
+ getRefOid(cwd: string, ref: string): string {
203
+ const res = execGit(['rev-parse', '--verify', '--quiet', ref], { cwd })
204
+ return res.ok ? res.stdout.trim() : ''
205
+ }
206
+
207
+ /**
208
+ * Returns a commit OID that branch refs can point at. Uses HEAD if the repo has commits,
209
+ * otherwise creates and caches an empty commit (empty tree + commit). Branch refs must
210
+ * point at commits, not blobs.
211
+ */
212
+ private getOrCreateEmptyCommitOid(cwd: string): string {
213
+ const cached = this.emptyCommitOidCache.get(cwd)
214
+ if (cached) return cached
215
+ const headRes = execGit(['rev-parse', 'HEAD'], { cwd })
216
+ const head = headRes.ok ? headRes.stdout.trim() : ''
217
+ if (head) {
218
+ this.emptyCommitOidCache.set(cwd, head)
219
+ return head
220
+ }
221
+
222
+ // Create an empty tree (idempotent) then a commit pointing at it.
223
+ const treeResult = spawnSync('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], {
224
+ input: '',
225
+ cwd,
226
+ encoding: 'utf8',
227
+ })
228
+ const treeOid = treeResult.stdout?.trim()
229
+ if (treeResult.status !== 0 || !treeOid) {
230
+ const errorMsg =
231
+ treeResult.stderr?.toString() || treeResult.error?.message || String(treeResult.error || 'unknown')
232
+ throw new Error(`Failed to create empty tree: ${errorMsg}`)
233
+ }
234
+
235
+ // Provide a default identity for reservation commits when repo has no user.name/user.email (e.g. CI).
236
+ const reservationCommitName = 'OpenCode Plugin'
237
+ const reservationCommitEmail = 'opencode@daytona.io'
238
+ const reservationCommitMessage = 'OpenCode reservation'
239
+ const commitEnv = {
240
+ ...process.env,
241
+ GIT_AUTHOR_NAME: reservationCommitName,
242
+ GIT_AUTHOR_EMAIL: reservationCommitEmail,
243
+ GIT_COMMITTER_NAME: reservationCommitName,
244
+ GIT_COMMITTER_EMAIL: reservationCommitEmail,
245
+ }
246
+
247
+ const commitRes = execGit(['commit-tree', treeOid, '-m', reservationCommitMessage], { cwd, env: commitEnv })
248
+ if (!commitRes.ok) throw new Error(`Failed to create empty commit: ${commitRes.stderr}`)
249
+ const commitOid = commitRes.stdout.trim()
250
+ this.emptyCommitOidCache.set(cwd, commitOid)
251
+ return commitOid
252
+ }
253
+
254
+ /**
255
+ * Pushes local changes to the sandbox remote.
256
+ * @param remoteName Numbered remote (e.g. sandbox-2) matching opencode/N.
257
+ * @param sshUrl The SSH URL of the sandbox remote.
258
+ * @param branch The branch to push to.
259
+ * @param cwd Worktree path to run git in.
260
+ * @returns true if push succeeded, false if no repo exists. Throws if the push fails.
261
+ */
262
+ async pushLocalToSandboxRemote(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise<boolean> {
263
+ if (!this.hasRepo(cwd)) {
264
+ logger.warn('No local git repository found. Skipping push to sandbox.')
265
+ return false
266
+ }
267
+ logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`)
268
+ await HostGitManager.enqueue(cwd, async () => {
269
+ const statusRes = execGit(['status', '--porcelain'], { cwd })
270
+ if (!statusRes.ok) {
271
+ throw new Error(statusRes.stderr)
272
+ }
273
+ if (statusRes.stdout.trim().length > 0) {
274
+ logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).')
275
+ }
276
+
277
+ this.setRemote(remoteName, sshUrl, cwd)
278
+ let attempts = 0
279
+ while (attempts < 3) {
280
+ const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: networkEnv() })
281
+ if (pushRes.ok) {
282
+ logger.info(`✓ Pushed local changes to ${remoteName}`)
283
+ return
284
+ }
285
+ attempts++
286
+ if (attempts >= 3) {
287
+ logger.error(`Error pushing to ${remoteName} after 3 attempts: ${pushRes.stderr}`)
288
+ throw new Error(pushRes.stderr)
289
+ }
290
+ logger.warn(`Push attempt ${attempts} failed, retrying...`)
291
+ }
292
+ })
293
+ return true
294
+ }
295
+
296
+ private setRemote(remoteName: string, sshUrl: string, cwd: string): void {
297
+ // Remote may not exist yet — ignore this result. `remote add` below is the check that matters.
298
+ execGit(['remote', 'remove', remoteName], { cwd })
299
+ const addRes = execGit(['remote', 'add', remoteName, sshUrl], { cwd })
300
+ if (!addRes.ok) {
301
+ throw new Error(`Failed to configure sandbox remote '${remoteName}': ${addRes.stderr}`)
302
+ }
303
+ }
304
+
305
+ async pull(remoteName: string, sshUrl: string, branch: string, cwd: string, localBranch?: string): Promise<void> {
306
+ await HostGitManager.enqueue(cwd, async () => {
307
+ this.setRemote(remoteName, sshUrl, cwd)
308
+ let attempts = 0
309
+ let lastError: unknown = undefined
310
+ // The first pull attempt sometimes fails. I'm not sure what the cause is.
311
+ while (attempts < 3) {
312
+ try {
313
+ if (localBranch) {
314
+ // Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit
315
+ // "refusing to fetch into branch checked out" when this branch is checked out.
316
+ const fetchRes = execGit(['fetch', remoteName, branch], { cwd, env: networkEnv() })
317
+ if (!fetchRes.ok) throw new Error(fetchRes.stderr)
318
+
319
+ const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd })
320
+ if (!updateRefRes.ok) throw new Error(updateRefRes.stderr)
321
+
322
+ // Only reset working directory if we're currently on this branch
323
+ const currentBranchRes = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
324
+ const currentBranch = currentBranchRes.ok ? currentBranchRes.stdout.trim() : ''
325
+ if (currentBranch === localBranch) {
326
+ const resetRes = execGit(['reset', '--hard', `refs/heads/${localBranch}`], { cwd })
327
+ if (!resetRes.ok) throw new Error(resetRes.stderr)
328
+ }
329
+
330
+ logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`)
331
+ } else {
332
+ const pullRes = execGit(['pull', remoteName, branch], { cwd, env: networkEnv() })
333
+ if (!pullRes.ok) throw new Error(pullRes.stderr)
334
+ logger.info('✓ Pulled latest changes from sandbox')
335
+ }
336
+ return
337
+ } catch (e) {
338
+ lastError = e
339
+ attempts++
340
+ if (attempts >= 3) {
341
+ logger.error(`Error pulling from sandbox after 3 attempts: ${e}`)
342
+ } else {
343
+ logger.warn(`Pull attempt ${attempts} failed, retrying...`)
344
+ }
345
+ }
346
+ }
347
+
348
+ // If we got here, all attempts failed.
349
+ throw lastError ?? new Error('Pull failed after 3 attempts')
350
+ })
351
+ }
352
+
353
+ async push(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise<void> {
354
+ await HostGitManager.enqueue(cwd, async () => {
355
+ this.setRemote(remoteName, sshUrl, cwd)
356
+ let attempts = 0
357
+ while (attempts < 3) {
358
+ const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd, env: networkEnv() })
359
+ if (pushRes.ok) {
360
+ logger.info('✓ Pushed changes to sandbox')
361
+ return
362
+ }
363
+ attempts++
364
+ if (attempts >= 3) {
365
+ logger.error(`Error pushing to sandbox after 3 attempts: ${pushRes.stderr}`)
366
+ throw new Error(pushRes.stderr)
367
+ }
368
+ logger.warn(`Push attempt ${attempts} failed, retrying...`)
369
+ }
370
+ })
371
+ }
372
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { SessionGitManager } from './session-git-manager';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ export { SessionGitManager } from './session-git-manager';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ export { SessionGitManager } from './session-git-manager'
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { Sandbox } from '@daytona/sdk';
6
+ export declare class DaytonaSandboxGitManager {
7
+ private readonly sandbox;
8
+ private readonly repoPath;
9
+ constructor(sandbox: Sandbox, repoPath: string);
10
+ ensureDirectory(): Promise<void>;
11
+ private runGitCommand;
12
+ ensureRepo(): Promise<void>;
13
+ autoCommit(): Promise<boolean>;
14
+ getCurrentBranch(): Promise<string>;
15
+ /**
16
+ * Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet).
17
+ * Any other git failure throws: callers use '' as "nothing to pull", and the delete
18
+ * path acts on it, so a masked error here could destroy unsynced commits.
19
+ */
20
+ getHeadOid(): Promise<string>;
21
+ resetToRemote(branch: string): Promise<void>;
22
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { logger } from '../core/logger';
6
+ export class DaytonaSandboxGitManager {
7
+ sandbox;
8
+ repoPath;
9
+ constructor(sandbox, repoPath) {
10
+ this.sandbox = sandbox;
11
+ this.repoPath = repoPath;
12
+ }
13
+ async ensureDirectory() {
14
+ await this.sandbox.fs.createFolder(this.repoPath, '755');
15
+ }
16
+ async runGitCommand(command) {
17
+ const result = await this.sandbox.process.executeCommand(command, this.repoPath);
18
+ const output = result.result.trim();
19
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
20
+ const installHint = output.includes('git: command not found')
21
+ ? '\nHint: git is not installed in this sandbox. Rebuild the sandbox image with git installed.'
22
+ : '';
23
+ throw new Error(`Git command failed: ${command}\nOutput: ${output || '(no output)'}${installHint}`);
24
+ }
25
+ return result.result;
26
+ }
27
+ async ensureRepo() {
28
+ await this.ensureDirectory();
29
+ const isGit = await this.runGitCommand('if [ -e .git ]; then git rev-parse --is-inside-work-tree; else echo false; fi');
30
+ if (isGit.trim() !== 'true') {
31
+ await this.runGitCommand('git init');
32
+ await this.runGitCommand('git config user.email "sandbox@example.com"');
33
+ await this.runGitCommand('git config user.name "Daytona Sandbox"');
34
+ logger.info(`Initialized git repo in sandbox at ${this.repoPath}`);
35
+ }
36
+ }
37
+ async autoCommit() {
38
+ // Check if there are any changes to commit
39
+ const status = await this.runGitCommand('git status --porcelain');
40
+ if (!status.trim()) {
41
+ logger.info(`No changes to commit in sandbox at ${this.repoPath}`);
42
+ return false;
43
+ }
44
+ await this.runGitCommand('git add .');
45
+ await this.runGitCommand('git commit -am "Auto-commit from Daytona plugin"');
46
+ logger.info(`Auto-committed changes in sandbox at ${this.repoPath}`);
47
+ return true;
48
+ }
49
+ async getCurrentBranch() {
50
+ const branch = await this.runGitCommand('git rev-parse --abbrev-ref HEAD');
51
+ return branch.trim();
52
+ }
53
+ /**
54
+ * Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet).
55
+ * Any other git failure throws: callers use '' as "nothing to pull", and the delete
56
+ * path acts on it, so a masked error here could destroy unsynced commits.
57
+ */
58
+ async getHeadOid() {
59
+ const out = await this.runGitCommand('if oid=$(git rev-parse --verify --quiet HEAD); then echo "$oid"; else git rev-parse --is-inside-work-tree > /dev/null && echo UNBORN; fi');
60
+ const trimmed = out.trim();
61
+ return trimmed === 'UNBORN' ? '' : trimmed;
62
+ }
63
+ async resetToRemote(branch) {
64
+ // Check out the branch the host just pushed. Using -f (not -B) checks out the
65
+ // pushed commit instead of resetting the branch ref to the sandbox's current HEAD.
66
+ const checkout = await this.runGitCommand(`git checkout -f ${branch}`);
67
+ logger.info(`Checked out branch '${branch}': ${checkout}`);
68
+ await this.runGitCommand('git reset --hard');
69
+ await this.runGitCommand('git clean -fd');
70
+ logger.info('Reset sandbox worktree to pushed state.');
71
+ const status = await this.runGitCommand('git status --porcelain');
72
+ if (status.trim()) {
73
+ logger.warn(`Sandbox has uncommitted changes after reset:\n${status}`);
74
+ }
75
+ else {
76
+ logger.info('No uncommitted changes in sandbox after reset.');
77
+ }
78
+ }
79
+ }
80
+ //# sourceMappingURL=sandbox-git-manager.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sandbox-git-manager.js","sourceRoot":"","sources":["sandbox-git-manager.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAEvC,MAAM,OAAO,wBAAwB;IAEhB;IACA;IAFnB,YACmB,OAAgB,EAChB,QAAgB;QADhB,YAAO,GAAP,OAAO,CAAS;QAChB,aAAQ,GAAR,QAAQ,CAAQ;IAChC,CAAC;IAEJ,KAAK,CAAC,eAAe;QACnB,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IAC1D,CAAC;IAEO,KAAK,CAAC,aAAa,CAAC,OAAe;QACzC,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC,OAAO,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QAChF,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAA;QAEnC,IAAI,MAAM,CAAC,QAAQ,KAAK,SAAS,IAAI,MAAM,CAAC,QAAQ,KAAK,CAAC,EAAE,CAAC;YAC3D,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAC;gBAC3D,CAAC,CAAC,6FAA6F;gBAC/F,CAAC,CAAC,EAAE,CAAA;YACN,MAAM,IAAI,KAAK,CAAC,uBAAuB,OAAO,aAAa,MAAM,IAAI,aAAa,GAAG,WAAW,EAAE,CAAC,CAAA;QACrG,CAAC;QAED,OAAO,MAAM,CAAC,MAAM,CAAA;IACtB,CAAC;IAED,KAAK,CAAC,UAAU;QACd,MAAM,IAAI,CAAC,eAAe,EAAE,CAAA;QAC5B,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,aAAa,CACpC,+EAA+E,CAChF,CAAA;QACD,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,MAAM,EAAE,CAAC;YAC5B,MAAM,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC,CAAA;YACpC,MAAM,IAAI,CAAC,aAAa,CAAC,6CAA6C,CAAC,CAAA;YACvE,MAAM,IAAI,CAAC,aAAa,CAAC,wCAAwC,CAAC,CAAA;YAClE,MAAM,CAAC,IAAI,CAAC,sCAAsC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED,KAAK,CAAC,UAAU;QACd,2CAA2C;QAC3C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,wBAAwB,CAAC,CAAA;QACjE,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;YACnB,MAAM,CAAC,IAAI,CAAC,sCAAsC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAA;YAClE,OAAO,KAAK,CAAA;QACd,CAAC;QACD,MAAM,IAAI,CAAC,aAAa,CAAC,WAAW,CAAC,CAAA;QACrC,MAAM,IAAI,CAAC,aAAa,CAAC,kDAAkD,CAAC,CAAA;QAC5E,MAAM,CAAC,IAAI,CAAC,wCAAwC,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAA;QACpE,OAAO,IAAI,CAAA;IACb,CAAC;IAED,KAAK,CAAC,gBAAgB;QACpB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,iCAAiC,CAAC,CAAA;QAC1E,OAAO,MAAM,CAAC,IAAI,EAAE,CAAA;IACtB,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,UAAU;QACd,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,aAAa,CAClC,0IAA0I,CAC3I,CAAA;QACD,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;QAC1B,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAA;IAC5C,CAAC;IAED,KAAK,CAAC,aAAa,CAAC,MAAc;QAChC,8EAA8E;QAC9E,mFAAmF;QACnF,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,mBAAmB,MAAM,EAAE,CAAC,CAAA;QACtE,MAAM,CAAC,IAAI,CAAC,uBAAuB,MAAM,MAAM,QAAQ,EAAE,CAAC,CAAA;QAC1D,MAAM,IAAI,CAAC,aAAa,CAAC,kBAAkB,CAAC,CAAA;QAC5C,MAAM,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,CAAA;QACzC,MAAM,CAAC,IAAI,CAAC,yCAAyC,CAAC,CAAA;QACtD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,wBAAwB,CAAC,CAAA;QACjE,IAAI,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;YAClB,MAAM,CAAC,IAAI,CAAC,iDAAiD,MAAM,EAAE,CAAC,CAAA;QACxE,CAAC;aAAM,CAAC;YACN,MAAM,CAAC,IAAI,CAAC,gDAAgD,CAAC,CAAA;QAC/D,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import type { Sandbox } from '@daytona/sdk'
7
+ import { logger } from '../core/logger'
8
+
9
+ export class DaytonaSandboxGitManager {
10
+ constructor(
11
+ private readonly sandbox: Sandbox,
12
+ private readonly repoPath: string,
13
+ ) {}
14
+
15
+ async ensureDirectory(): Promise<void> {
16
+ await this.sandbox.fs.createFolder(this.repoPath, '755')
17
+ }
18
+
19
+ private async runGitCommand(command: string): Promise<string> {
20
+ const result = await this.sandbox.process.executeCommand(command, this.repoPath)
21
+ const output = result.result.trim()
22
+
23
+ if (result.exitCode !== undefined && result.exitCode !== 0) {
24
+ const installHint = output.includes('git: command not found')
25
+ ? '\nHint: git is not installed in this sandbox. Rebuild the sandbox image with git installed.'
26
+ : ''
27
+ throw new Error(`Git command failed: ${command}\nOutput: ${output || '(no output)'}${installHint}`)
28
+ }
29
+
30
+ return result.result
31
+ }
32
+
33
+ async ensureRepo(): Promise<void> {
34
+ await this.ensureDirectory()
35
+ const isGit = await this.runGitCommand(
36
+ 'if [ -e .git ]; then git rev-parse --is-inside-work-tree; else echo false; fi',
37
+ )
38
+ if (isGit.trim() !== 'true') {
39
+ await this.runGitCommand('git init')
40
+ await this.runGitCommand('git config user.email "sandbox@example.com"')
41
+ await this.runGitCommand('git config user.name "Daytona Sandbox"')
42
+ logger.info(`Initialized git repo in sandbox at ${this.repoPath}`)
43
+ }
44
+ }
45
+
46
+ async autoCommit(): Promise<boolean> {
47
+ // Check if there are any changes to commit
48
+ const status = await this.runGitCommand('git status --porcelain')
49
+ if (!status.trim()) {
50
+ logger.info(`No changes to commit in sandbox at ${this.repoPath}`)
51
+ return false
52
+ }
53
+ await this.runGitCommand('git add .')
54
+ await this.runGitCommand('git commit -am "Auto-commit from Daytona plugin"')
55
+ logger.info(`Auto-committed changes in sandbox at ${this.repoPath}`)
56
+ return true
57
+ }
58
+
59
+ async getCurrentBranch(): Promise<string> {
60
+ const branch = await this.runGitCommand('git rev-parse --abbrev-ref HEAD')
61
+ return branch.trim()
62
+ }
63
+
64
+ /**
65
+ * Commit OID of the sandbox HEAD, or '' on an unborn branch (no commits yet).
66
+ * Any other git failure throws: callers use '' as "nothing to pull", and the delete
67
+ * path acts on it, so a masked error here could destroy unsynced commits.
68
+ */
69
+ async getHeadOid(): Promise<string> {
70
+ const out = await this.runGitCommand(
71
+ 'if oid=$(git rev-parse --verify --quiet HEAD); then echo "$oid"; else git rev-parse --is-inside-work-tree > /dev/null && echo UNBORN; fi',
72
+ )
73
+ const trimmed = out.trim()
74
+ return trimmed === 'UNBORN' ? '' : trimmed
75
+ }
76
+
77
+ async resetToRemote(branch: string): Promise<void> {
78
+ // Check out the branch the host just pushed. Using -f (not -B) checks out the
79
+ // pushed commit instead of resetting the branch ref to the sandbox's current HEAD.
80
+ const checkout = await this.runGitCommand(`git checkout -f ${branch}`)
81
+ logger.info(`Checked out branch '${branch}': ${checkout}`)
82
+ await this.runGitCommand('git reset --hard')
83
+ await this.runGitCommand('git clean -fd')
84
+ logger.info('Reset sandbox worktree to pushed state.')
85
+ const status = await this.runGitCommand('git status --porcelain')
86
+ if (status.trim()) {
87
+ logger.warn(`Sandbox has uncommitted changes after reset:\n${status}`)
88
+ } else {
89
+ logger.info('No uncommitted changes in sandbox after reset.')
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Copyright Daytona Platforms Inc.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import type { Sandbox } from '@daytona/sdk';
6
+ import type { PluginInput } from '@opencode-ai/plugin';
7
+ /**
8
+ * SessionGitManager: Combines DaytonaSandboxGitManager and HostGitManager for session lifecycle git operations.
9
+ */
10
+ export declare class SessionGitManager {
11
+ private readonly sandboxGit;
12
+ private readonly hostGit;
13
+ private readonly sandbox;
14
+ private readonly repoPath;
15
+ private readonly worktree;
16
+ private readonly branch;
17
+ private readonly localBranch;
18
+ /** Numbered remote (sandbox-2) matches localBranch (opencode/2) */
19
+ private readonly remoteName;
20
+ constructor(sandbox: Sandbox, repoPath: string, worktree: string, branchNumber: number);
21
+ /**
22
+ * Allocate and reserve the next opencode/N number in the local repo at `worktree`.
23
+ * This keeps all host-git concerns inside the git manager layer.
24
+ */
25
+ static allocateAndReserveBranchNumber(worktree: string, prefix?: string): number;
26
+ private static pendingSyncs;
27
+ /**
28
+ * Run `fn` after any in-flight sync for this session and track it until it settles.
29
+ * The caller of this invocation sees failures; waiters only observe completion.
30
+ */
31
+ static enqueueSessionSync<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
32
+ /** Resolves when the session has no in-flight sync. Never rejects. */
33
+ static waitForPendingSync(sessionId: string): Promise<void>;
34
+ /**
35
+ * Resolves when no session has an in-flight sync, or when `timeoutMs` elapses first
36
+ * (returns false in that case). Never rejects. The bound exists for shutdown, where a
37
+ * sync stalled on an unreachable sandbox must not wedge process exit; the delete path
38
+ * intentionally waits unbounded instead, because deleting mid-sync loses data.
39
+ */
40
+ static waitForAllPendingSyncs(timeoutMs?: number): Promise<boolean>;
41
+ private getSshUrl;
42
+ hasLocalRepo(): boolean;
43
+ /**
44
+ * Initialize git in the sandbox and sync with host
45
+ * Used when a new sandbox is created for a session
46
+ */
47
+ initializeAndSync(pluginCtx?: PluginInput): Promise<void>;
48
+ /**
49
+ * Auto-commit in the sandbox and pull latest from host
50
+ * Used on session idle
51
+ * Returns true if changes were synced, false if no changes or no local repo
52
+ */
53
+ autoCommitAndPull(pluginCtx?: PluginInput): Promise<boolean>;
54
+ }