@gricha/perry 0.0.1 → 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 (37) hide show
  1. package/README.md +25 -33
  2. package/dist/agent/router.js +39 -404
  3. package/dist/agent/web/assets/index-BGbqUzMS.js +104 -0
  4. package/dist/agent/web/assets/index-CHEQQv1U.css +1 -0
  5. package/dist/agent/web/favicon.ico +0 -0
  6. package/dist/agent/web/index.html +3 -3
  7. package/dist/agent/web/logo-192.png +0 -0
  8. package/dist/agent/web/logo-512.png +0 -0
  9. package/dist/agent/web/logo.png +0 -0
  10. package/dist/agent/web/logo.webp +0 -0
  11. package/dist/chat/base-chat-websocket.js +83 -0
  12. package/dist/chat/host-opencode-handler.js +115 -3
  13. package/dist/chat/opencode-handler.js +60 -0
  14. package/dist/chat/opencode-server.js +252 -0
  15. package/dist/chat/opencode-websocket.js +15 -87
  16. package/dist/chat/websocket.js +19 -86
  17. package/dist/client/ws-shell.js +15 -5
  18. package/dist/docker/index.js +41 -1
  19. package/dist/index.js +3 -3
  20. package/dist/sessions/agents/claude.js +86 -0
  21. package/dist/sessions/agents/codex.js +110 -0
  22. package/dist/sessions/agents/index.js +44 -0
  23. package/dist/sessions/agents/opencode.js +168 -0
  24. package/dist/sessions/agents/types.js +1 -0
  25. package/dist/sessions/agents/utils.js +31 -0
  26. package/dist/shared/base-websocket.js +13 -1
  27. package/dist/shared/constants.js +2 -1
  28. package/dist/terminal/base-handler.js +68 -0
  29. package/dist/terminal/handler.js +18 -75
  30. package/dist/terminal/host-handler.js +7 -61
  31. package/dist/terminal/websocket.js +2 -4
  32. package/dist/workspace/manager.js +33 -22
  33. package/dist/workspace/state.js +33 -2
  34. package/package.json +1 -1
  35. package/dist/agent/web/assets/index-9t2sFIJM.js +0 -101
  36. package/dist/agent/web/assets/index-CCFpTruF.css +0 -1
  37. package/dist/agent/web/vite.svg +0 -1
@@ -2,11 +2,12 @@ import { createServer } from 'net';
2
2
  import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
+ import pkg from '../../package.json';
5
6
  import { StateManager } from './state';
6
7
  import { expandPath } from '../config/loader';
7
8
  import * as docker from '../docker';
8
9
  import { getContainerName } from '../docker';
9
- import { VOLUME_PREFIX, WORKSPACE_IMAGE, SSH_PORT_RANGE_START, SSH_PORT_RANGE_END, } from '../shared/constants';
10
+ import { VOLUME_PREFIX, WORKSPACE_IMAGE_LOCAL, WORKSPACE_IMAGE_REGISTRY, SSH_PORT_RANGE_START, SSH_PORT_RANGE_END, } from '../shared/constants';
10
11
  async function findAvailablePort(start, end) {
11
12
  for (let port = start; port <= end; port++) {
12
13
  const available = await new Promise((resolve) => {
@@ -23,6 +24,21 @@ async function findAvailablePort(start, end) {
23
24
  }
24
25
  throw new Error(`No available port in range ${start}-${end}`);
25
26
  }
27
+ async function ensureWorkspaceImage() {
28
+ const registryImage = `${WORKSPACE_IMAGE_REGISTRY}:${pkg.version}`;
29
+ const localExists = await docker.imageExists(WORKSPACE_IMAGE_LOCAL);
30
+ if (localExists) {
31
+ return WORKSPACE_IMAGE_LOCAL;
32
+ }
33
+ console.log(`Pulling workspace image ${registryImage}...`);
34
+ const pulled = await docker.tryPullImage(registryImage);
35
+ if (pulled) {
36
+ return registryImage;
37
+ }
38
+ throw new Error(`Workspace image not found. Either:\n` +
39
+ ` 1. Run 'perry build' to build locally, or\n` +
40
+ ` 2. Check your network connection to pull from registry`);
41
+ }
26
42
  async function copyCredentialToContainer(options) {
27
43
  const { source, dest, containerName, dirPermissions = '700', filePermissions = '600', tempPrefix = 'ws-cred', } = options;
28
44
  const expandedSource = expandPath(source);
@@ -210,6 +226,13 @@ export class WorkspaceManager {
210
226
  tempPrefix: 'ws-gitconfig',
211
227
  });
212
228
  }
229
+ async setupWorkspaceCredentials(containerName) {
230
+ await this.copyGitConfig(containerName);
231
+ await this.copyCredentialFiles(containerName);
232
+ await this.setupClaudeCodeConfig(containerName);
233
+ await this.copyCodexCredentials(containerName);
234
+ await this.setupOpencodeConfig(containerName);
235
+ }
213
236
  async runPostStartScript(containerName) {
214
237
  const scriptPath = this.config.scripts.post_start;
215
238
  if (!scriptPath) {
@@ -287,10 +310,7 @@ export class WorkspaceManager {
287
310
  };
288
311
  await this.state.setWorkspace(workspace);
289
312
  try {
290
- const imageReady = await docker.imageExists(WORKSPACE_IMAGE);
291
- if (!imageReady) {
292
- throw new Error(`Workspace image '${WORKSPACE_IMAGE}' not found. Run 'workspace build' first.`);
293
- }
313
+ const workspaceImage = await ensureWorkspaceImage();
294
314
  if (!(await docker.volumeExists(volumeName))) {
295
315
  await docker.createVolume(volumeName);
296
316
  }
@@ -310,7 +330,7 @@ export class WorkspaceManager {
310
330
  }
311
331
  const containerId = await docker.createContainer({
312
332
  name: containerName,
313
- image: WORKSPACE_IMAGE,
333
+ image: workspaceImage,
314
334
  hostname: name,
315
335
  privileged: true,
316
336
  restartPolicy: 'unless-stopped',
@@ -326,11 +346,8 @@ export class WorkspaceManager {
326
346
  workspace.ports.ssh = sshPort;
327
347
  await this.state.setWorkspace(workspace);
328
348
  await docker.startContainer(containerName);
329
- await this.copyGitConfig(containerName);
330
- await this.copyCredentialFiles(containerName);
331
- await this.setupClaudeCodeConfig(containerName);
332
- await this.copyCodexCredentials(containerName);
333
- await this.setupOpencodeConfig(containerName);
349
+ await docker.waitForContainerReady(containerName);
350
+ await this.setupWorkspaceCredentials(containerName);
334
351
  workspace.status = 'running';
335
352
  await this.state.setWorkspace(workspace);
336
353
  await this.runPostStartScript(containerName);
@@ -356,6 +373,7 @@ export class WorkspaceManager {
356
373
  throw new Error(`Container and volume for workspace '${name}' were deleted. ` +
357
374
  `Please delete this workspace and create a new one.`);
358
375
  }
376
+ const workspaceImage = await ensureWorkspaceImage();
359
377
  const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
360
378
  const containerEnv = {
361
379
  ...this.config.credentials.env,
@@ -371,7 +389,7 @@ export class WorkspaceManager {
371
389
  }
372
390
  const containerId = await docker.createContainer({
373
391
  name: containerName,
374
- image: WORKSPACE_IMAGE,
392
+ image: workspaceImage,
375
393
  hostname: name,
376
394
  privileged: true,
377
395
  restartPolicy: 'unless-stopped',
@@ -394,11 +412,8 @@ export class WorkspaceManager {
394
412
  return workspace;
395
413
  }
396
414
  await docker.startContainer(containerName);
397
- await this.copyGitConfig(containerName);
398
- await this.copyCredentialFiles(containerName);
399
- await this.setupClaudeCodeConfig(containerName);
400
- await this.copyCodexCredentials(containerName);
401
- await this.setupOpencodeConfig(containerName);
415
+ await docker.waitForContainerReady(containerName);
416
+ await this.setupWorkspaceCredentials(containerName);
402
417
  workspace.status = 'running';
403
418
  await this.state.setWorkspace(workspace);
404
419
  await this.runPostStartScript(containerName);
@@ -466,10 +481,6 @@ export class WorkspaceManager {
466
481
  if (!running) {
467
482
  throw new Error(`Workspace '${name}' is not running`);
468
483
  }
469
- await this.copyGitConfig(containerName);
470
- await this.copyCredentialFiles(containerName);
471
- await this.setupClaudeCodeConfig(containerName);
472
- await this.copyCodexCredentials(containerName);
473
- await this.setupOpencodeConfig(containerName);
484
+ await this.setupWorkspaceCredentials(containerName);
474
485
  }
475
486
  }
@@ -1,11 +1,40 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import path from 'path';
3
+ import lockfile from 'proper-lockfile';
3
4
  import { STATE_FILE } from '../shared/types';
4
5
  export class StateManager {
5
6
  statePath;
6
7
  state = null;
8
+ lockfilePath;
7
9
  constructor(configDir) {
8
10
  this.statePath = path.join(configDir, STATE_FILE);
11
+ this.lockfilePath = path.join(configDir, '.state.lock');
12
+ }
13
+ async ensureLockfile() {
14
+ try {
15
+ await fs.mkdir(path.dirname(this.lockfilePath), { recursive: true });
16
+ await fs.writeFile(this.lockfilePath, '', { flag: 'wx' });
17
+ }
18
+ catch (err) {
19
+ if (err.code !== 'EEXIST') {
20
+ throw err;
21
+ }
22
+ }
23
+ }
24
+ async withLock(fn) {
25
+ await this.ensureLockfile();
26
+ let release;
27
+ try {
28
+ release = await lockfile.lock(this.lockfilePath, {
29
+ retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
30
+ });
31
+ return await fn();
32
+ }
33
+ finally {
34
+ if (release) {
35
+ await release();
36
+ }
37
+ }
9
38
  }
10
39
  async load() {
11
40
  if (this.state) {
@@ -29,8 +58,10 @@ export class StateManager {
29
58
  if (!this.state) {
30
59
  return;
31
60
  }
32
- await fs.mkdir(path.dirname(this.statePath), { recursive: true });
33
- await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
61
+ await this.withLock(async () => {
62
+ await fs.mkdir(path.dirname(this.statePath), { recursive: true });
63
+ await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
64
+ });
34
65
  }
35
66
  async getWorkspace(name) {
36
67
  const state = await this.load();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.",
5
5
  "type": "module",
6
6
  "bin": {