@gricha/perry 0.2.3 → 0.2.5

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.
@@ -0,0 +1,51 @@
1
+ export const opencodeSync = {
2
+ getRequiredDirs() {
3
+ return ['/home/workspace/.config/opencode'];
4
+ },
5
+ async getFilesToSync(_context) {
6
+ return [];
7
+ },
8
+ async getDirectoriesToSync(_context) {
9
+ return [];
10
+ },
11
+ async getGeneratedConfigs(context) {
12
+ const zenToken = context.agentConfig.agents?.opencode?.zen_token;
13
+ if (!zenToken) {
14
+ return [];
15
+ }
16
+ const hostConfigContent = await context.readHostFile('~/.config/opencode/opencode.json');
17
+ let mcpConfig = {};
18
+ if (hostConfigContent) {
19
+ try {
20
+ const parsed = JSON.parse(hostConfigContent);
21
+ if (parsed.mcp && typeof parsed.mcp === 'object') {
22
+ mcpConfig = parsed.mcp;
23
+ }
24
+ }
25
+ catch {
26
+ // Invalid JSON, ignore
27
+ }
28
+ }
29
+ const config = {
30
+ provider: {
31
+ opencode: {
32
+ options: {
33
+ apiKey: zenToken,
34
+ },
35
+ },
36
+ },
37
+ model: 'opencode/claude-sonnet-4',
38
+ };
39
+ if (Object.keys(mcpConfig).length > 0) {
40
+ config.mcp = mcpConfig;
41
+ }
42
+ return [
43
+ {
44
+ dest: '/home/workspace/.config/opencode/opencode.json',
45
+ content: JSON.stringify(config, null, 2),
46
+ permissions: '600',
47
+ category: 'credential',
48
+ },
49
+ ];
50
+ },
51
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -41,7 +41,7 @@ export class BaseChatWebSocketServer extends BaseWebSocketServer {
41
41
  };
42
42
  if (!connection.session) {
43
43
  if (isHostMode) {
44
- connection.session = this.createHostSession(message.sessionId, onMessage, message.model);
44
+ connection.session = this.createHostSession(message.sessionId, onMessage, message.model, message.projectPath);
45
45
  }
46
46
  else {
47
47
  const containerName = getContainerName(workspaceName);
@@ -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
  }
@@ -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
  }
@@ -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
+ }
package/dist/index.js CHANGED
@@ -177,6 +177,23 @@ program
177
177
  handleError(err);
178
178
  }
179
179
  });
180
+ program
181
+ .command('clone <source> <clone-name>')
182
+ .description('Clone an existing workspace')
183
+ .action(async (source, cloneName) => {
184
+ try {
185
+ const client = await getClient();
186
+ console.log(`Cloning workspace '${source}' to '${cloneName}'...`);
187
+ console.log('This may take a while for large workspaces.');
188
+ const workspace = await client.cloneWorkspace(source, cloneName);
189
+ console.log(`Workspace '${cloneName}' created.`);
190
+ console.log(` Status: ${workspace.status}`);
191
+ console.log(` SSH Port: ${workspace.ports.ssh}`);
192
+ }
193
+ catch (err) {
194
+ handleError(err);
195
+ }
196
+ });
180
197
  program
181
198
  .command('info [name]')
182
199
  .description('Show workspace or agent info')
@@ -288,15 +305,21 @@ program
288
305
  console.error(`Workspace '${name}' is not running (status: ${workspace.status})`);
289
306
  process.exit(1);
290
307
  }
308
+ let effectivePorts = ports;
291
309
  if (ports.length === 0) {
292
- console.log(`Workspace '${name}' is running.`);
293
- console.log('');
294
- console.log('Usage: perry proxy <name> <port> [<port>...]');
295
- console.log(' Examples:');
296
- console.log(' perry proxy alpha 3000 # Forward port 3000');
297
- console.log(' perry proxy alpha 8080:3000 # Forward local 8080 to remote 3000');
298
- console.log(' perry proxy alpha 3000 5173 # Forward multiple ports');
299
- return;
310
+ const configuredForwards = workspace.ports.forwards || [];
311
+ if (configuredForwards.length === 0) {
312
+ console.log(`No ports configured for workspace '${name}'.`);
313
+ console.log('');
314
+ console.log('Configure ports with: perry ports <name> <port> [<port>...]');
315
+ console.log(' Example: perry ports ' + name + ' 3000 5173');
316
+ console.log('');
317
+ console.log('Or specify ports directly: perry proxy <name> <port> [<port>...]');
318
+ console.log(' Example: perry proxy ' + name + ' 3000 5173');
319
+ return;
320
+ }
321
+ effectivePorts = configuredForwards.map((p) => String(p));
322
+ console.log(`Using configured ports: ${configuredForwards.join(', ')}`);
300
323
  }
301
324
  if (isLocalWorker(worker)) {
302
325
  const containerName = getContainerName(name);
@@ -305,7 +328,7 @@ program
305
328
  console.error(`Could not get IP for container '${containerName}'`);
306
329
  process.exit(1);
307
330
  }
308
- const forwards = ports.map(parseDockerPortForward);
331
+ const forwards = effectivePorts.map(parseDockerPortForward);
309
332
  console.log(`Forwarding ports: ${formatDockerPortForwards(forwards)}`);
310
333
  console.log(`Container IP: ${containerIp}`);
311
334
  console.log('Press Ctrl+C to stop.');
@@ -330,7 +353,7 @@ program
330
353
  await new Promise(() => { });
331
354
  }
332
355
  else {
333
- const forwards = ports.map(parsePortForward);
356
+ const forwards = effectivePorts.map(parsePortForward);
334
357
  console.log(`Forwarding ports: ${formatPortForwards(forwards)}`);
335
358
  console.log('Press Ctrl+C to stop.');
336
359
  console.log('');
@@ -354,6 +377,47 @@ program
354
377
  handleError(err);
355
378
  }
356
379
  });
380
+ program
381
+ .command('ports <name> [ports...]')
382
+ .description('Configure ports to forward for a workspace')
383
+ .action(async (name, ports) => {
384
+ try {
385
+ const client = await getClient();
386
+ const workspace = await client.getWorkspace(name);
387
+ if (!workspace) {
388
+ console.error(`Workspace '${name}' not found`);
389
+ process.exit(1);
390
+ }
391
+ if (ports.length === 0) {
392
+ const currentPorts = workspace.ports.forwards || [];
393
+ if (currentPorts.length === 0) {
394
+ console.log(`No ports configured for workspace '${name}'.`);
395
+ console.log('');
396
+ console.log('Usage: perry ports <name> <port> [<port>...]');
397
+ console.log(' Example: perry ports ' + name + ' 3000 5173 8080');
398
+ }
399
+ else {
400
+ console.log(`Ports configured for '${name}': ${currentPorts.join(', ')}`);
401
+ }
402
+ return;
403
+ }
404
+ const portNumbers = ports.map((p) => {
405
+ const num = parseInt(p, 10);
406
+ if (isNaN(num) || num < 1 || num > 65535) {
407
+ console.error(`Invalid port number: ${p}`);
408
+ process.exit(1);
409
+ }
410
+ return num;
411
+ });
412
+ await client.setPortForwards(name, portNumbers);
413
+ console.log(`Ports configured for '${name}': ${portNumbers.join(', ')}`);
414
+ console.log('');
415
+ console.log(`Run 'perry proxy ${name}' to start forwarding.`);
416
+ }
417
+ catch (err) {
418
+ handleError(err);
419
+ }
420
+ });
357
421
  const configCmd = program.command('config').description('Manage configuration');
358
422
  configCmd
359
423
  .command('show')
@@ -400,8 +464,15 @@ configCmd
400
464
  for (const [dest, src] of Object.entries(config.credentials.files)) {
401
465
  console.log(` - ${dest} <- ${src}`);
402
466
  }
403
- if (config.scripts.post_start) {
404
- console.log(` Post-start Script: ${config.scripts.post_start}`);
467
+ const scripts = config.scripts.post_start;
468
+ if (scripts && scripts.length > 0) {
469
+ console.log(` Post-start Scripts: ${scripts.length}`);
470
+ for (const script of scripts) {
471
+ console.log(` - ${script}`);
472
+ }
473
+ }
474
+ if (config.scripts.fail_on_error) {
475
+ console.log(` Scripts Fail on Error: enabled`);
405
476
  }
406
477
  });
407
478
  const sshCmd = program.command('ssh').description('Manage SSH keys for workspaces');
package/dist/perry-worker CHANGED
Binary file