@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.
- package/README.md +2 -2
- package/dist/agent/router.js +9 -2
- package/dist/agent/run.js +2 -0
- package/dist/agent/static.js +17 -8
- package/dist/agent/web/assets/{index-CaFOQOgc.css → index-CGJDysKS.css} +1 -1
- package/dist/agent/web/assets/index-CwCl9DVw.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/client/api.js +2 -2
- package/dist/docker/eager-pull.js +65 -0
- package/dist/index.js +5 -23
- package/dist/update-checker.js +12 -7
- package/dist/workspace/manager.js +65 -52
- package/package.json +1 -1
- package/dist/agent/web/assets/index-DQmM39Em.js +0 -104
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/dist/client/api.js
CHANGED
|
@@ -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
|
|
157
|
-
.
|
|
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);
|
package/dist/update-checker.js
CHANGED
|
@@ -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
|
|
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://
|
|
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
|
-
|
|
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
|
|
72
|
-
console.log(`\x1b[33m│\x1b[0m Update available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m
|
|
73
|
-
console.log(`\x1b[33m│\x1b[0m Run \x1b[
|
|
74
|
-
console.log(`\x1b[33m
|
|
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
|
|
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
|
-
|
|
425
|
+
return this.create({ name, clone: options?.clone, env: options?.env });
|
|
423
426
|
}
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
containerEnv
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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);
|