@gricha/perry 0.2.2 → 0.2.4

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 (41) hide show
  1. package/README.md +11 -0
  2. package/dist/agent/file-watcher.js +134 -0
  3. package/dist/agent/router.js +48 -2
  4. package/dist/agent/run.js +44 -4
  5. package/dist/agent/web/assets/index-BmFYrCoX.css +1 -0
  6. package/dist/agent/web/assets/index-IavvQP8G.js +104 -0
  7. package/dist/agent/web/index.html +2 -2
  8. package/dist/agents/__tests__/claude-code.test.js +125 -0
  9. package/dist/agents/__tests__/codex.test.js +64 -0
  10. package/dist/agents/__tests__/opencode.test.js +130 -0
  11. package/dist/agents/__tests__/sync.test.js +272 -0
  12. package/dist/agents/index.js +177 -0
  13. package/dist/agents/sync/claude-code.js +84 -0
  14. package/dist/agents/sync/codex.js +29 -0
  15. package/dist/agents/sync/copier.js +89 -0
  16. package/dist/agents/sync/opencode.js +51 -0
  17. package/dist/agents/sync/types.js +1 -0
  18. package/dist/agents/types.js +1 -0
  19. package/dist/chat/base-chat-websocket.js +2 -2
  20. package/dist/chat/base-claude-session.js +169 -0
  21. package/dist/chat/base-opencode-session.js +181 -0
  22. package/dist/chat/handler.js +14 -157
  23. package/dist/chat/host-handler.js +13 -142
  24. package/dist/chat/host-opencode-handler.js +28 -187
  25. package/dist/chat/opencode-handler.js +38 -197
  26. package/dist/chat/opencode-websocket.js +1 -1
  27. package/dist/chat/types.js +1 -0
  28. package/dist/chat/websocket.js +2 -2
  29. package/dist/client/api.js +25 -0
  30. package/dist/config/loader.js +20 -2
  31. package/dist/docker/eager-pull.js +19 -3
  32. package/dist/docker/index.js +28 -1
  33. package/dist/index.js +83 -12
  34. package/dist/perry-worker +0 -0
  35. package/dist/shared/constants.js +1 -0
  36. package/dist/shared/types.js +0 -1
  37. package/dist/terminal/websocket.js +1 -1
  38. package/dist/workspace/manager.js +178 -115
  39. package/package.json +4 -3
  40. package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
  41. package/dist/agent/web/assets/index-DN_QW9sL.js +0 -104
@@ -1,21 +1,37 @@
1
1
  import { homedir } from 'os';
2
2
  import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
- export class HostOpencodeSession {
5
- process = null;
4
+ import { BaseOpencodeSession } from './base-opencode-session';
5
+ export class HostOpencodeSession extends BaseOpencodeSession {
6
6
  workDir;
7
- sessionId;
8
- model;
9
- sessionModel;
10
- onMessage;
11
- buffer = '';
12
- historyLoaded = false;
13
7
  constructor(options, onMessage) {
8
+ super(options.sessionId, options.model, onMessage);
14
9
  this.workDir = options.workDir || homedir();
15
- this.sessionId = options.sessionId;
16
- this.model = options.model;
17
- this.sessionModel = options.model;
18
- this.onMessage = onMessage;
10
+ }
11
+ getLogPrefix() {
12
+ return 'host-opencode';
13
+ }
14
+ getNoOutputErrorMessage() {
15
+ return 'No response from OpenCode. Check if OpenCode is installed and configured.';
16
+ }
17
+ getSpawnConfig(userMessage) {
18
+ const args = ['stdbuf', '-oL', 'opencode', 'run', '--format', 'json'];
19
+ if (this.sessionId) {
20
+ args.push('--session', this.sessionId);
21
+ }
22
+ if (this.model) {
23
+ args.push('--model', this.model);
24
+ }
25
+ args.push(userMessage);
26
+ return {
27
+ command: args,
28
+ options: {
29
+ cwd: this.workDir,
30
+ env: {
31
+ ...process.env,
32
+ },
33
+ },
34
+ };
19
35
  }
20
36
  async loadHistory() {
21
37
  if (this.historyLoaded || !this.sessionId) {
@@ -122,181 +138,6 @@ export class HostOpencodeSession {
122
138
  console.error('[host-opencode] Failed to load history:', err);
123
139
  }
124
140
  }
125
- async sendMessage(userMessage) {
126
- if (this.sessionId && !this.historyLoaded) {
127
- await this.loadHistory();
128
- }
129
- const args = ['-oL', 'opencode', 'run', '--format', 'json'];
130
- if (this.sessionId) {
131
- args.push('--session', this.sessionId);
132
- }
133
- if (this.model) {
134
- args.push('--model', this.model);
135
- }
136
- args.push(userMessage);
137
- console.log('[host-opencode] Running: stdbuf', args.join(' '));
138
- this.onMessage({
139
- type: 'system',
140
- content: 'Processing your message...',
141
- timestamp: new Date().toISOString(),
142
- });
143
- try {
144
- const proc = Bun.spawn(['stdbuf', ...args], {
145
- cwd: this.workDir,
146
- stdin: 'ignore',
147
- stdout: 'pipe',
148
- stderr: 'pipe',
149
- env: {
150
- ...process.env,
151
- },
152
- });
153
- this.process = proc;
154
- if (!proc.stdout || !proc.stderr) {
155
- throw new Error('Failed to get process streams');
156
- }
157
- console.log('[host-opencode] Process spawned, waiting for output...');
158
- const stderrPromise = new Response(proc.stderr).text();
159
- const decoder = new TextDecoder();
160
- let receivedAnyOutput = false;
161
- for await (const chunk of proc.stdout) {
162
- const text = decoder.decode(chunk);
163
- console.log('[host-opencode] Received chunk:', text.length, 'bytes');
164
- receivedAnyOutput = true;
165
- this.buffer += text;
166
- this.processBuffer();
167
- }
168
- const exitCode = await proc.exited;
169
- console.log('[host-opencode] Process exited with code:', exitCode, 'receivedOutput:', receivedAnyOutput);
170
- const stderrText = await stderrPromise;
171
- if (stderrText) {
172
- console.error('[host-opencode] stderr:', stderrText);
173
- }
174
- if (exitCode !== 0) {
175
- this.onMessage({
176
- type: 'error',
177
- content: stderrText || `OpenCode exited with code ${exitCode}`,
178
- timestamp: new Date().toISOString(),
179
- });
180
- return;
181
- }
182
- if (!receivedAnyOutput) {
183
- this.onMessage({
184
- type: 'error',
185
- content: 'No response from OpenCode. Check if OpenCode is installed and configured.',
186
- timestamp: new Date().toISOString(),
187
- });
188
- return;
189
- }
190
- this.onMessage({
191
- type: 'done',
192
- content: 'Response complete',
193
- timestamp: new Date().toISOString(),
194
- });
195
- }
196
- catch (err) {
197
- console.error('[host-opencode] Error:', err);
198
- this.onMessage({
199
- type: 'error',
200
- content: err.message,
201
- timestamp: new Date().toISOString(),
202
- });
203
- }
204
- finally {
205
- this.process = null;
206
- }
207
- }
208
- processBuffer() {
209
- const lines = this.buffer.split('\n');
210
- this.buffer = lines.pop() || '';
211
- for (const line of lines) {
212
- if (!line.trim())
213
- continue;
214
- try {
215
- const event = JSON.parse(line);
216
- this.handleStreamEvent(event);
217
- }
218
- catch {
219
- console.error('[host-opencode] Failed to parse:', line);
220
- }
221
- }
222
- }
223
- handleStreamEvent(event) {
224
- const timestamp = new Date().toISOString();
225
- if (event.type === 'step_start' && event.sessionID) {
226
- if (!this.sessionId) {
227
- this.sessionId = event.sessionID;
228
- this.sessionModel = this.model;
229
- this.historyLoaded = true;
230
- this.onMessage({
231
- type: 'system',
232
- content: `Session started ${this.sessionId}`,
233
- timestamp,
234
- });
235
- }
236
- return;
237
- }
238
- if (event.type === 'text' && event.part?.text) {
239
- this.onMessage({
240
- type: 'assistant',
241
- content: event.part.text,
242
- timestamp,
243
- });
244
- return;
245
- }
246
- if (event.type === 'tool_use' && event.part) {
247
- const toolName = event.part.tool || 'unknown';
248
- const toolId = event.part.callID || event.part.id;
249
- const input = event.part.state?.input;
250
- const output = event.part.state?.output;
251
- const title = event.part.state?.title || input?.description || toolName;
252
- console.log('[host-opencode] Tool use:', toolName, title);
253
- this.onMessage({
254
- type: 'tool_use',
255
- content: JSON.stringify(input, null, 2),
256
- toolName: title || toolName,
257
- toolId,
258
- timestamp,
259
- });
260
- if (output) {
261
- this.onMessage({
262
- type: 'tool_result',
263
- content: output,
264
- toolName,
265
- toolId,
266
- timestamp,
267
- });
268
- }
269
- return;
270
- }
271
- }
272
- async interrupt() {
273
- if (this.process) {
274
- this.process.kill();
275
- this.process = null;
276
- this.onMessage({
277
- type: 'system',
278
- content: 'Chat interrupted',
279
- timestamp: new Date().toISOString(),
280
- });
281
- }
282
- }
283
- setModel(model) {
284
- if (this.model !== model) {
285
- this.model = model;
286
- if (this.sessionModel !== model) {
287
- this.sessionId = undefined;
288
- this.historyLoaded = false;
289
- this.onMessage({
290
- type: 'system',
291
- content: `Switching to model: ${model}`,
292
- timestamp: new Date().toISOString(),
293
- });
294
- }
295
- }
296
- }
297
- getSessionId() {
298
- return this.sessionId;
299
- }
300
141
  }
301
142
  export function createHostOpencodeSession(options, onMessage) {
302
143
  return new HostOpencodeSession(options, onMessage);
@@ -1,21 +1,47 @@
1
+ import { BaseOpencodeSession } from './base-opencode-session';
1
2
  import { opencodeProvider } from '../sessions/agents/opencode';
2
- export class OpencodeSession {
3
- process = null;
3
+ export class OpencodeSession extends BaseOpencodeSession {
4
4
  containerName;
5
5
  workDir;
6
- sessionId;
7
- model;
8
- sessionModel;
9
- onMessage;
10
- buffer = '';
11
- historyLoaded = false;
12
6
  constructor(options, onMessage) {
7
+ super(options.sessionId, options.model, onMessage);
13
8
  this.containerName = options.containerName;
14
9
  this.workDir = options.workDir || '/home/workspace';
15
- this.sessionId = options.sessionId;
16
- this.model = options.model;
17
- this.sessionModel = options.model;
18
- this.onMessage = onMessage;
10
+ }
11
+ getLogPrefix() {
12
+ return 'opencode';
13
+ }
14
+ getNoOutputErrorMessage() {
15
+ return 'No response from OpenCode. Check if OpenCode is configured in the workspace.';
16
+ }
17
+ getSpawnConfig(userMessage) {
18
+ const args = [
19
+ 'docker',
20
+ 'exec',
21
+ '-i',
22
+ '-u',
23
+ 'workspace',
24
+ '-w',
25
+ this.workDir,
26
+ this.containerName,
27
+ 'stdbuf',
28
+ '-oL',
29
+ 'opencode',
30
+ 'run',
31
+ '--format',
32
+ 'json',
33
+ ];
34
+ if (this.sessionId) {
35
+ args.push('--session', this.sessionId);
36
+ }
37
+ if (this.model) {
38
+ args.push('--model', this.model);
39
+ }
40
+ args.push(userMessage);
41
+ return {
42
+ command: args,
43
+ options: {},
44
+ };
19
45
  }
20
46
  async loadHistory() {
21
47
  if (this.historyLoaded || !this.sessionId) {
@@ -68,191 +94,6 @@ export class OpencodeSession {
68
94
  console.error('[opencode] Failed to load history:', err);
69
95
  }
70
96
  }
71
- async sendMessage(userMessage) {
72
- if (this.sessionId && !this.historyLoaded) {
73
- await this.loadHistory();
74
- }
75
- const args = [
76
- 'exec',
77
- '-i',
78
- '-u',
79
- 'workspace',
80
- '-w',
81
- this.workDir,
82
- this.containerName,
83
- 'stdbuf',
84
- '-oL',
85
- 'opencode',
86
- 'run',
87
- '--format',
88
- 'json',
89
- ];
90
- if (this.sessionId) {
91
- args.push('--session', this.sessionId);
92
- }
93
- if (this.model) {
94
- args.push('--model', this.model);
95
- }
96
- args.push(userMessage);
97
- console.log('[opencode] Running:', 'docker', args.join(' '));
98
- this.onMessage({
99
- type: 'system',
100
- content: 'Processing your message...',
101
- timestamp: new Date().toISOString(),
102
- });
103
- try {
104
- const proc = Bun.spawn(['docker', ...args], {
105
- stdin: 'ignore',
106
- stdout: 'pipe',
107
- stderr: 'pipe',
108
- });
109
- this.process = proc;
110
- if (!proc.stdout || !proc.stderr) {
111
- throw new Error('Failed to get process streams');
112
- }
113
- console.log('[opencode] Process spawned, waiting for output...');
114
- const stderrPromise = new Response(proc.stderr).text();
115
- const decoder = new TextDecoder();
116
- let receivedAnyOutput = false;
117
- for await (const chunk of proc.stdout) {
118
- const text = decoder.decode(chunk);
119
- console.log('[opencode] Received chunk:', text.length, 'bytes');
120
- receivedAnyOutput = true;
121
- this.buffer += text;
122
- this.processBuffer();
123
- }
124
- const exitCode = await proc.exited;
125
- console.log('[opencode] Process exited with code:', exitCode, 'receivedOutput:', receivedAnyOutput);
126
- const stderrText = await stderrPromise;
127
- if (stderrText) {
128
- console.error('[opencode] stderr:', stderrText);
129
- }
130
- if (exitCode !== 0) {
131
- this.onMessage({
132
- type: 'error',
133
- content: stderrText || `OpenCode exited with code ${exitCode}`,
134
- timestamp: new Date().toISOString(),
135
- });
136
- return;
137
- }
138
- if (!receivedAnyOutput) {
139
- this.onMessage({
140
- type: 'error',
141
- content: 'No response from OpenCode. Check if OpenCode is configured in the workspace.',
142
- timestamp: new Date().toISOString(),
143
- });
144
- return;
145
- }
146
- this.onMessage({
147
- type: 'done',
148
- content: 'Response complete',
149
- timestamp: new Date().toISOString(),
150
- });
151
- }
152
- catch (err) {
153
- console.error('[opencode] Error:', err);
154
- this.onMessage({
155
- type: 'error',
156
- content: err.message,
157
- timestamp: new Date().toISOString(),
158
- });
159
- }
160
- finally {
161
- this.process = null;
162
- }
163
- }
164
- processBuffer() {
165
- const lines = this.buffer.split('\n');
166
- this.buffer = lines.pop() || '';
167
- for (const line of lines) {
168
- if (!line.trim())
169
- continue;
170
- try {
171
- const event = JSON.parse(line);
172
- this.handleStreamEvent(event);
173
- }
174
- catch {
175
- console.error('[opencode] Failed to parse:', line);
176
- }
177
- }
178
- }
179
- handleStreamEvent(event) {
180
- const timestamp = new Date().toISOString();
181
- if (event.type === 'step_start' && event.sessionID) {
182
- if (!this.sessionId) {
183
- this.sessionId = event.sessionID;
184
- this.sessionModel = this.model;
185
- this.historyLoaded = true;
186
- this.onMessage({
187
- type: 'system',
188
- content: `Session started ${this.sessionId}`,
189
- timestamp,
190
- });
191
- }
192
- return;
193
- }
194
- if (event.type === 'text' && event.part?.text) {
195
- this.onMessage({
196
- type: 'assistant',
197
- content: event.part.text,
198
- timestamp,
199
- });
200
- return;
201
- }
202
- if (event.type === 'tool_use' && event.part) {
203
- const toolName = event.part.tool || 'unknown';
204
- const toolId = event.part.callID || event.part.id;
205
- const input = event.part.state?.input;
206
- const output = event.part.state?.output;
207
- const title = event.part.state?.title || input?.description || toolName;
208
- console.log('[opencode] Tool use:', toolName, title);
209
- this.onMessage({
210
- type: 'tool_use',
211
- content: JSON.stringify(input, null, 2),
212
- toolName: title || toolName,
213
- toolId,
214
- timestamp,
215
- });
216
- if (output) {
217
- this.onMessage({
218
- type: 'tool_result',
219
- content: output,
220
- toolName,
221
- toolId,
222
- timestamp,
223
- });
224
- }
225
- return;
226
- }
227
- }
228
- async interrupt() {
229
- if (this.process) {
230
- this.process.kill();
231
- this.process = null;
232
- this.onMessage({
233
- type: 'system',
234
- content: 'Chat interrupted',
235
- timestamp: new Date().toISOString(),
236
- });
237
- }
238
- }
239
- setModel(model) {
240
- if (this.model !== model) {
241
- this.model = model;
242
- if (this.sessionModel !== model) {
243
- this.sessionId = undefined;
244
- this.historyLoaded = false;
245
- this.onMessage({
246
- type: 'system',
247
- content: `Switching to model: ${model}`,
248
- timestamp: new Date().toISOString(),
249
- });
250
- }
251
- }
252
- }
253
- getSessionId() {
254
- return this.sessionId;
255
- }
256
97
  }
257
98
  export function createOpencodeSession(options, onMessage) {
258
99
  return new OpencodeSession(options, onMessage);
@@ -15,7 +15,7 @@ export class OpencodeWebSocketServer extends BaseChatWebSocketServer {
15
15
  workspaceName,
16
16
  };
17
17
  }
18
- createHostSession(sessionId, onMessage, messageModel) {
18
+ createHostSession(sessionId, onMessage, messageModel, _projectPath) {
19
19
  const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
20
20
  return createHostOpencodeSession({ sessionId, model }, onMessage);
21
21
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -15,10 +15,10 @@ export class ChatWebSocketServer extends BaseChatWebSocketServer {
15
15
  workspaceName,
16
16
  };
17
17
  }
18
- createHostSession(sessionId, onMessage, messageModel) {
18
+ createHostSession(sessionId, onMessage, messageModel, projectPath) {
19
19
  const config = this.getConfig();
20
20
  const model = messageModel || config.agents?.claude_code?.model;
21
- return createHostChatSession({ sessionId, model }, onMessage);
21
+ return createHostChatSession({ sessionId, model, workDir: projectPath }, onMessage);
22
22
  }
23
23
  createContainerSession(containerName, sessionId, onMessage, messageModel) {
24
24
  const config = this.getConfig();
@@ -98,6 +98,31 @@ export class ApiClient {
98
98
  throw this.wrapError(err);
99
99
  }
100
100
  }
101
+ async getPortForwards(name) {
102
+ try {
103
+ const result = await this.client.workspaces.getPortForwards({ name });
104
+ return result.forwards;
105
+ }
106
+ catch (err) {
107
+ throw this.wrapError(err);
108
+ }
109
+ }
110
+ async setPortForwards(name, forwards) {
111
+ try {
112
+ return await this.client.workspaces.setPortForwards({ name, forwards });
113
+ }
114
+ catch (err) {
115
+ throw this.wrapError(err);
116
+ }
117
+ }
118
+ async cloneWorkspace(sourceName, cloneName) {
119
+ try {
120
+ return await this.client.workspaces.clone({ sourceName, cloneName });
121
+ }
122
+ catch (err) {
123
+ throw this.wrapError(err);
124
+ }
125
+ }
101
126
  getTerminalUrl(name) {
102
127
  const wsUrl = this.baseUrl.replace(/^http/, 'ws');
103
128
  return `${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`;
@@ -16,7 +16,10 @@ export function createDefaultAgentConfig() {
16
16
  env: {},
17
17
  files: {},
18
18
  },
19
- scripts: {},
19
+ scripts: {
20
+ post_start: ['~/.perry/userscripts'],
21
+ fail_on_error: false,
22
+ },
20
23
  agents: {},
21
24
  allowHostAccess: true,
22
25
  ssh: {
@@ -29,6 +32,18 @@ export function createDefaultAgentConfig() {
29
32
  },
30
33
  };
31
34
  }
35
+ function migratePostStart(value) {
36
+ if (!value) {
37
+ return ['~/.perry/userscripts'];
38
+ }
39
+ if (typeof value === 'string') {
40
+ return [value, '~/.perry/userscripts'];
41
+ }
42
+ if (Array.isArray(value)) {
43
+ return value.length > 0 ? value : ['~/.perry/userscripts'];
44
+ }
45
+ return ['~/.perry/userscripts'];
46
+ }
32
47
  export async function loadAgentConfig(configDir) {
33
48
  const dir = getConfigDir(configDir);
34
49
  const configPath = path.join(dir, CONFIG_FILE);
@@ -41,7 +56,10 @@ export async function loadAgentConfig(configDir) {
41
56
  env: config.credentials?.env || {},
42
57
  files: config.credentials?.files || {},
43
58
  },
44
- scripts: config.scripts || {},
59
+ scripts: {
60
+ post_start: migratePostStart(config.scripts?.post_start),
61
+ fail_on_error: config.scripts?.fail_on_error ?? false,
62
+ },
45
63
  agents: config.agents || {},
46
64
  allowHostAccess: config.allowHostAccess ?? true,
47
65
  ssh: {
@@ -5,6 +5,7 @@ const RETRY_INTERVAL_MS = 20000;
5
5
  const MAX_RETRIES = 10;
6
6
  let pullInProgress = false;
7
7
  let pullComplete = false;
8
+ let abortController = null;
8
9
  async function isDockerAvailable() {
9
10
  try {
10
11
  await getDockerVersion();
@@ -35,7 +36,13 @@ export async function startEagerImagePull() {
35
36
  return;
36
37
  }
37
38
  pullInProgress = true;
39
+ abortController = new AbortController();
40
+ const signal = abortController.signal;
38
41
  const attemptPull = async (attempt) => {
42
+ if (signal.aborted) {
43
+ pullInProgress = false;
44
+ return;
45
+ }
39
46
  if (attempt > MAX_RETRIES) {
40
47
  console.log('[agent] Max retries reached for image pull - giving up background pull');
41
48
  pullInProgress = false;
@@ -46,7 +53,8 @@ export async function startEagerImagePull() {
46
53
  if (attempt === 1) {
47
54
  console.log('[agent] Docker not available - will retry in background');
48
55
  }
49
- setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
56
+ const timer = setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
57
+ timer.unref();
50
58
  return;
51
59
  }
52
60
  const success = await pullWorkspaceImage();
@@ -54,12 +62,20 @@ export async function startEagerImagePull() {
54
62
  pullComplete = true;
55
63
  pullInProgress = false;
56
64
  }
57
- else {
58
- setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
65
+ else if (!signal.aborted) {
66
+ const timer = setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
67
+ timer.unref();
59
68
  }
60
69
  };
61
70
  attemptPull(1);
62
71
  }
72
+ export function stopEagerImagePull() {
73
+ if (abortController) {
74
+ abortController.abort();
75
+ abortController = null;
76
+ }
77
+ pullInProgress = false;
78
+ }
63
79
  export function isImagePullComplete() {
64
80
  return pullComplete;
65
81
  }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
+ import { CONTAINER_PREFIX } from '../shared/constants';
2
3
  export * from './types';
3
- const CONTAINER_PREFIX = 'workspace-';
4
4
  export function getContainerName(name) {
5
5
  return `${CONTAINER_PREFIX}${name}`;
6
6
  }
@@ -410,3 +410,30 @@ export async function getLogs(containerName, options = {}) {
410
410
  const { stdout, stderr } = await docker(args);
411
411
  return stdout + stderr;
412
412
  }
413
+ export async function cloneVolume(sourceVolume, destVolume) {
414
+ if (!(await volumeExists(sourceVolume))) {
415
+ throw new Error(`Source volume '${sourceVolume}' does not exist`);
416
+ }
417
+ if (await volumeExists(destVolume)) {
418
+ throw new Error(`Volume '${destVolume}' already exists`);
419
+ }
420
+ await createVolume(destVolume);
421
+ try {
422
+ await docker([
423
+ 'run',
424
+ '--rm',
425
+ '-v',
426
+ `${sourceVolume}:/source:ro`,
427
+ '-v',
428
+ `${destVolume}:/dest`,
429
+ 'alpine',
430
+ 'sh',
431
+ '-c',
432
+ 'cp -a /source/. /dest/',
433
+ ]);
434
+ }
435
+ catch (err) {
436
+ await removeVolume(destVolume, true).catch(() => { });
437
+ throw new Error(`Failed to clone volume: ${err.message}`);
438
+ }
439
+ }