@gricha/perry 0.1.8 → 0.2.1

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.
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Perry</title>
8
- <script type="module" crossorigin src="/assets/index-DQmM39Em.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CaFOQOgc.css">
8
+ <script type="module" crossorigin src="/assets/index-CwCl9DVw.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CGJDysKS.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -66,9 +66,9 @@ export class ApiClient {
66
66
  throw this.wrapError(err);
67
67
  }
68
68
  }
69
- async startWorkspace(name) {
69
+ async startWorkspace(name, options) {
70
70
  try {
71
- return await this.client.workspaces.start({ name });
71
+ return await this.client.workspaces.start({ name, clone: options?.clone, env: options?.env });
72
72
  }
73
73
  catch (err) {
74
74
  throw this.wrapError(err);
@@ -0,0 +1,65 @@
1
+ import { imageExists, tryPullImage, getDockerVersion } from './index';
2
+ import { WORKSPACE_IMAGE_REGISTRY } from '../shared/constants';
3
+ import pkg from '../../package.json';
4
+ const RETRY_INTERVAL_MS = 20000;
5
+ const MAX_RETRIES = 10;
6
+ let pullInProgress = false;
7
+ let pullComplete = false;
8
+ async function isDockerAvailable() {
9
+ try {
10
+ await getDockerVersion();
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function pullWorkspaceImage() {
18
+ const registryImage = `${WORKSPACE_IMAGE_REGISTRY}:${pkg.version}`;
19
+ const exists = await imageExists(registryImage);
20
+ if (exists) {
21
+ console.log(`[agent] Workspace image ${registryImage} already available`);
22
+ return true;
23
+ }
24
+ console.log(`[agent] Pulling workspace image ${registryImage}...`);
25
+ const pulled = await tryPullImage(registryImage);
26
+ if (pulled) {
27
+ console.log('[agent] Workspace image pulled successfully');
28
+ return true;
29
+ }
30
+ console.log('[agent] Failed to pull image - will retry later');
31
+ return false;
32
+ }
33
+ export async function startEagerImagePull() {
34
+ if (pullInProgress || pullComplete) {
35
+ return;
36
+ }
37
+ pullInProgress = true;
38
+ const attemptPull = async (attempt) => {
39
+ if (attempt > MAX_RETRIES) {
40
+ console.log('[agent] Max retries reached for image pull - giving up background pull');
41
+ pullInProgress = false;
42
+ return;
43
+ }
44
+ const dockerAvailable = await isDockerAvailable();
45
+ if (!dockerAvailable) {
46
+ if (attempt === 1) {
47
+ console.log('[agent] Docker not available - will retry in background');
48
+ }
49
+ setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
50
+ return;
51
+ }
52
+ const success = await pullWorkspaceImage();
53
+ if (success) {
54
+ pullComplete = true;
55
+ pullInProgress = false;
56
+ }
57
+ else {
58
+ setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
59
+ }
60
+ };
61
+ attemptPull(1);
62
+ }
63
+ export function isImagePullComplete() {
64
+ return pullComplete;
65
+ }
package/dist/index.js CHANGED
@@ -131,36 +131,18 @@ program
131
131
  handleError(err);
132
132
  }
133
133
  });
134
- program
135
- .command('create <name>')
136
- .description('Create a new workspace')
137
- .option('--clone <url>', 'Git repository URL to clone')
138
- .action(async (name, options) => {
139
- try {
140
- const client = await getClient();
141
- console.log(`Creating workspace '${name}'...`);
142
- const workspace = await client.createWorkspace({
143
- name,
144
- clone: options.clone,
145
- });
146
- console.log(`Workspace '${workspace.name}' created.`);
147
- console.log(` Status: ${workspace.status}`);
148
- console.log(` SSH Port: ${workspace.ports.ssh}`);
149
- }
150
- catch (err) {
151
- handleError(err);
152
- }
153
- });
154
134
  program
155
135
  .command('start <name>')
156
- .description('Start a stopped workspace')
157
- .action(async (name) => {
136
+ .description('Start a workspace (creates it if it does not exist)')
137
+ .option('--clone <url>', 'Git repository URL to clone (when creating)')
138
+ .action(async (name, options) => {
158
139
  try {
159
140
  const client = await getClient();
160
141
  console.log(`Starting workspace '${name}'...`);
161
- const workspace = await client.startWorkspace(name);
142
+ const workspace = await client.startWorkspace(name, { clone: options.clone });
162
143
  console.log(`Workspace '${workspace.name}' started.`);
163
144
  console.log(` Status: ${workspace.status}`);
145
+ console.log(` SSH Port: ${workspace.ports.ssh}`);
164
146
  }
165
147
  catch (err) {
166
148
  handleError(err);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'os';
2
2
  import { join } from 'path';
3
3
  import { mkdir, readFile, writeFile } from 'fs/promises';
4
- const PACKAGE_NAME = '@gricha/perry';
4
+ const GITHUB_REPO = 'gricha/perry';
5
5
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
6
6
  async function getCacheDir() {
7
7
  const dir = join(homedir(), '.config', 'perry');
@@ -29,13 +29,18 @@ async function writeCache(cache) {
29
29
  }
30
30
  async function fetchLatestVersion() {
31
31
  try {
32
- const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
32
+ const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
33
33
  signal: AbortSignal.timeout(3000),
34
+ headers: {
35
+ Accept: 'application/vnd.github.v3+json',
36
+ 'User-Agent': 'perry-update-checker',
37
+ },
34
38
  });
35
39
  if (!response.ok)
36
40
  return null;
37
41
  const data = (await response.json());
38
- return data.version || null;
42
+ const tag = data.tag_name || null;
43
+ return tag ? tag.replace(/^v/, '') : null;
39
44
  }
40
45
  catch {
41
46
  return null;
@@ -68,10 +73,10 @@ export async function checkForUpdates(currentVersion) {
68
73
  }
69
74
  if (latestVersion && compareVersions(currentVersion, latestVersion) > 0) {
70
75
  console.log('');
71
- console.log(`\x1b[33m╭─────────────────────────────────────────────────────────╮\x1b[0m`);
72
- console.log(`\x1b[33m│\x1b[0m Update available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33m│\x1b[0m`);
73
- console.log(`\x1b[33m│\x1b[0m Run \x1b[36mnpm install -g ${PACKAGE_NAME}\x1b[0m to update \x1b[33m│\x1b[0m`);
74
- console.log(`\x1b[33m╰─────────────────────────────────────────────────────────╯\x1b[0m`);
76
+ console.log(`\x1b[33m╭──────────────────────────────────────────────────────────────────────────────────╮\x1b[0m`);
77
+ console.log(`\x1b[33m│\x1b[0m Update available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33m│\x1b[0m`);
78
+ console.log(`\x1b[33m│\x1b[0m Run: \x1b[36mcurl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/main/install.sh | bash\x1b[0m \x1b[33m│\x1b[0m`);
79
+ console.log(`\x1b[33m╰──────────────────────────────────────────────────────────────────────────────────╯\x1b[0m`);
75
80
  console.log('');
76
81
  }
77
82
  }
@@ -312,6 +312,9 @@ export class WorkspaceManager {
312
312
  });
313
313
  }
314
314
  async syncWorkspaceStatus(workspace) {
315
+ if (workspace.status === 'creating') {
316
+ return;
317
+ }
315
318
  const containerName = getContainerName(workspace.name);
316
319
  const exists = await docker.containerExists(containerName);
317
320
  if (!exists) {
@@ -323,7 +326,7 @@ export class WorkspaceManager {
323
326
  }
324
327
  const running = await docker.containerRunning(containerName);
325
328
  const newStatus = running ? 'running' : 'stopped';
326
- if (workspace.status !== newStatus && workspace.status !== 'creating') {
329
+ if (workspace.status !== newStatus) {
327
330
  workspace.status = newStatus;
328
331
  await this.state.setWorkspace(workspace);
329
332
  }
@@ -416,67 +419,77 @@ export class WorkspaceManager {
416
419
  throw err;
417
420
  }
418
421
  }
419
- async start(name) {
422
+ async start(name, options) {
420
423
  const workspace = await this.state.getWorkspace(name);
421
424
  if (!workspace) {
422
- throw new Error(`Workspace '${name}' not found`);
425
+ return this.create({ name, clone: options?.clone, env: options?.env });
423
426
  }
424
- const containerName = getContainerName(name);
425
- const volumeName = `${VOLUME_PREFIX}${name}`;
426
- const exists = await docker.containerExists(containerName);
427
- if (!exists) {
428
- const volumeExists = await docker.volumeExists(volumeName);
429
- if (!volumeExists) {
430
- throw new Error(`Container and volume for workspace '${name}' were deleted. ` +
431
- `Please delete this workspace and create a new one.`);
432
- }
433
- const workspaceImage = await ensureWorkspaceImage();
434
- const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
435
- const containerEnv = {
436
- ...this.config.credentials.env,
437
- };
438
- if (this.config.agents?.github?.token) {
439
- containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
440
- }
441
- if (this.config.agents?.claude_code?.oauth_token) {
442
- containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
427
+ const previousStatus = workspace.status;
428
+ workspace.status = 'creating';
429
+ await this.state.setWorkspace(workspace);
430
+ try {
431
+ const containerName = getContainerName(name);
432
+ const volumeName = `${VOLUME_PREFIX}${name}`;
433
+ const exists = await docker.containerExists(containerName);
434
+ if (!exists) {
435
+ const volumeExists = await docker.volumeExists(volumeName);
436
+ if (!volumeExists) {
437
+ throw new Error(`Container and volume for workspace '${name}' were deleted. ` +
438
+ `Please delete this workspace and create a new one.`);
439
+ }
440
+ const workspaceImage = await ensureWorkspaceImage();
441
+ const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
442
+ const containerEnv = {
443
+ ...this.config.credentials.env,
444
+ };
445
+ if (this.config.agents?.github?.token) {
446
+ containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
447
+ }
448
+ if (this.config.agents?.claude_code?.oauth_token) {
449
+ containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
450
+ }
451
+ if (workspace.repo) {
452
+ containerEnv.WORKSPACE_REPO_URL = workspace.repo;
453
+ }
454
+ const containerId = await docker.createContainer({
455
+ name: containerName,
456
+ image: workspaceImage,
457
+ hostname: name,
458
+ privileged: true,
459
+ restartPolicy: 'unless-stopped',
460
+ env: containerEnv,
461
+ volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
462
+ ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
463
+ labels: {
464
+ 'workspace.name': name,
465
+ 'workspace.managed': 'true',
466
+ },
467
+ });
468
+ workspace.containerId = containerId;
469
+ workspace.ports.ssh = sshPort;
470
+ await this.state.setWorkspace(workspace);
443
471
  }
444
- if (workspace.repo) {
445
- containerEnv.WORKSPACE_REPO_URL = workspace.repo;
472
+ const running = await docker.containerRunning(containerName);
473
+ if (running) {
474
+ workspace.status = 'running';
475
+ workspace.lastUsed = new Date().toISOString();
476
+ await this.state.setWorkspace(workspace);
477
+ return workspace;
446
478
  }
447
- const containerId = await docker.createContainer({
448
- name: containerName,
449
- image: workspaceImage,
450
- hostname: name,
451
- privileged: true,
452
- restartPolicy: 'unless-stopped',
453
- env: containerEnv,
454
- volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
455
- ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
456
- labels: {
457
- 'workspace.name': name,
458
- 'workspace.managed': 'true',
459
- },
460
- });
461
- workspace.containerId = containerId;
462
- workspace.ports.ssh = sshPort;
463
- await this.state.setWorkspace(workspace);
464
- }
465
- const running = await docker.containerRunning(containerName);
466
- if (running) {
479
+ await docker.startContainer(containerName);
480
+ await docker.waitForContainerReady(containerName);
481
+ await this.setupWorkspaceCredentials(containerName, name);
467
482
  workspace.status = 'running';
468
483
  workspace.lastUsed = new Date().toISOString();
469
484
  await this.state.setWorkspace(workspace);
485
+ await this.runPostStartScript(containerName);
470
486
  return workspace;
471
487
  }
472
- await docker.startContainer(containerName);
473
- await docker.waitForContainerReady(containerName);
474
- await this.setupWorkspaceCredentials(containerName, name);
475
- workspace.status = 'running';
476
- workspace.lastUsed = new Date().toISOString();
477
- await this.state.setWorkspace(workspace);
478
- await this.runPostStartScript(containerName);
479
- return workspace;
488
+ catch (err) {
489
+ workspace.status = previousStatus === 'error' ? 'error' : 'stopped';
490
+ await this.state.setWorkspace(workspace);
491
+ throw err;
492
+ }
480
493
  }
481
494
  async stop(name) {
482
495
  const workspace = await this.state.getWorkspace(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
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": {