@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.
- package/README.md +3 -0
- package/dist/agent/file-watcher.js +2 -6
- package/dist/agent/router.js +116 -16
- package/dist/agent/run.js +15 -1
- package/dist/agent/web/assets/index-0UMxrAK_.js +104 -0
- package/dist/agent/web/assets/index-BwItLEFi.css +1 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/agents/__tests__/claude-code.test.js +125 -0
- package/dist/agents/__tests__/codex.test.js +64 -0
- package/dist/agents/__tests__/opencode.test.js +130 -0
- package/dist/agents/__tests__/sync.test.js +272 -0
- package/dist/agents/index.js +177 -0
- package/dist/agents/sync/claude-code.js +84 -0
- package/dist/agents/sync/codex.js +29 -0
- package/dist/agents/sync/copier.js +89 -0
- package/dist/agents/sync/opencode.js +51 -0
- package/dist/agents/sync/types.js +1 -0
- package/dist/agents/types.js +1 -0
- package/dist/chat/base-chat-websocket.js +1 -1
- package/dist/chat/opencode-websocket.js +1 -1
- package/dist/chat/websocket.js +2 -2
- package/dist/client/api.js +25 -0
- package/dist/config/loader.js +20 -2
- package/dist/docker/eager-pull.js +19 -3
- package/dist/docker/index.js +27 -0
- package/dist/index.js +83 -12
- package/dist/perry-worker +0 -0
- package/dist/workspace/manager.js +178 -115
- package/package.json +1 -1
- package/dist/agent/web/assets/index-BF-4SpMu.js +0 -104
- package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
|
@@ -9,6 +9,7 @@ import * as docker from '../docker';
|
|
|
9
9
|
import { getContainerName } from '../docker';
|
|
10
10
|
import { VOLUME_PREFIX, WORKSPACE_IMAGE_LOCAL, WORKSPACE_IMAGE_REGISTRY, SSH_PORT_RANGE_START, SSH_PORT_RANGE_END, } from '../shared/constants';
|
|
11
11
|
import { collectAuthorizedKeys, collectCopyKeys } from '../ssh/sync';
|
|
12
|
+
import { syncAllAgents } from '../agents';
|
|
12
13
|
async function findAvailablePort(start, end) {
|
|
13
14
|
for (let port = start; port <= end; port++) {
|
|
14
15
|
const available = await new Promise((resolve) => {
|
|
@@ -127,97 +128,6 @@ export class WorkspaceManager {
|
|
|
127
128
|
});
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
|
-
async setupClaudeCodeConfig(containerName) {
|
|
131
|
-
const localClaudeCredentials = expandPath('~/.claude/.credentials.json');
|
|
132
|
-
const configContent = JSON.stringify({ hasCompletedOnboarding: true });
|
|
133
|
-
const tempFile = path.join(os.tmpdir(), `ws-claude-config-${Date.now()}.json`);
|
|
134
|
-
try {
|
|
135
|
-
await fs.writeFile(tempFile, configContent);
|
|
136
|
-
await docker.copyToContainer(containerName, tempFile, '/home/workspace/.claude.json');
|
|
137
|
-
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', '/home/workspace/.claude.json'], { user: 'root' });
|
|
138
|
-
await docker.execInContainer(containerName, ['chmod', '644', '/home/workspace/.claude.json'], { user: 'workspace' });
|
|
139
|
-
}
|
|
140
|
-
finally {
|
|
141
|
-
await fs.unlink(tempFile).catch(() => { });
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
await fs.access(localClaudeCredentials);
|
|
145
|
-
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.claude'], {
|
|
146
|
-
user: 'workspace',
|
|
147
|
-
});
|
|
148
|
-
await copyCredentialToContainer({
|
|
149
|
-
source: '~/.claude/.credentials.json',
|
|
150
|
-
dest: '/home/workspace/.claude/.credentials.json',
|
|
151
|
-
containerName,
|
|
152
|
-
filePermissions: '600',
|
|
153
|
-
tempPrefix: 'ws-claude-creds',
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
// No credentials file - that's OK, user may use oauth_token env var instead
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
async copyCodexCredentials(containerName) {
|
|
161
|
-
const codexDir = expandPath('~/.codex');
|
|
162
|
-
try {
|
|
163
|
-
await fs.access(codexDir);
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.codex'], {
|
|
169
|
-
user: 'workspace',
|
|
170
|
-
});
|
|
171
|
-
await copyCredentialToContainer({
|
|
172
|
-
source: '~/.codex/auth.json',
|
|
173
|
-
dest: '/home/workspace/.codex/auth.json',
|
|
174
|
-
containerName,
|
|
175
|
-
filePermissions: '600',
|
|
176
|
-
tempPrefix: 'ws-codex-auth',
|
|
177
|
-
});
|
|
178
|
-
await copyCredentialToContainer({
|
|
179
|
-
source: '~/.codex/config.toml',
|
|
180
|
-
dest: '/home/workspace/.codex/config.toml',
|
|
181
|
-
containerName,
|
|
182
|
-
filePermissions: '600',
|
|
183
|
-
tempPrefix: 'ws-codex-config',
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
async setupOpencodeConfig(containerName) {
|
|
187
|
-
const zenToken = this.config.agents?.opencode?.zen_token;
|
|
188
|
-
if (!zenToken) {
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const config = {
|
|
192
|
-
provider: {
|
|
193
|
-
opencode: {
|
|
194
|
-
options: {
|
|
195
|
-
apiKey: zenToken,
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
model: 'opencode/claude-sonnet-4',
|
|
200
|
-
};
|
|
201
|
-
const configJson = JSON.stringify(config, null, 2);
|
|
202
|
-
const tempFile = `/tmp/ws-opencode-config-${Date.now()}.json`;
|
|
203
|
-
await fs.writeFile(tempFile, configJson, 'utf-8');
|
|
204
|
-
try {
|
|
205
|
-
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.config/opencode'], {
|
|
206
|
-
user: 'workspace',
|
|
207
|
-
});
|
|
208
|
-
await docker.copyToContainer(containerName, tempFile, '/home/workspace/.config/opencode/opencode.json');
|
|
209
|
-
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', '/home/workspace/.config/opencode/opencode.json'], { user: 'root' });
|
|
210
|
-
await docker.execInContainer(containerName, ['chmod', '600', '/home/workspace/.config/opencode/opencode.json'], { user: 'workspace' });
|
|
211
|
-
}
|
|
212
|
-
finally {
|
|
213
|
-
try {
|
|
214
|
-
await fs.unlink(tempFile);
|
|
215
|
-
}
|
|
216
|
-
catch {
|
|
217
|
-
// Ignore cleanup errors
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
131
|
async copyGitConfig(containerName) {
|
|
222
132
|
await copyCredentialToContainer({
|
|
223
133
|
source: '~/.gitconfig',
|
|
@@ -279,9 +189,7 @@ export class WorkspaceManager {
|
|
|
279
189
|
async setupWorkspaceCredentials(containerName, workspaceName) {
|
|
280
190
|
await this.copyGitConfig(containerName);
|
|
281
191
|
await this.copyCredentialFiles(containerName);
|
|
282
|
-
await this.
|
|
283
|
-
await this.copyCodexCredentials(containerName);
|
|
284
|
-
await this.setupOpencodeConfig(containerName);
|
|
192
|
+
await syncAllAgents(containerName, this.config);
|
|
285
193
|
await this.copyPerryWorker(containerName);
|
|
286
194
|
if (workspaceName) {
|
|
287
195
|
await this.setupSSHKeys(containerName, workspaceName);
|
|
@@ -306,30 +214,70 @@ export class WorkspaceManager {
|
|
|
306
214
|
user: 'root',
|
|
307
215
|
});
|
|
308
216
|
}
|
|
309
|
-
async
|
|
310
|
-
const
|
|
311
|
-
if (!
|
|
217
|
+
async runUserScripts(containerName) {
|
|
218
|
+
const scriptPaths = this.config.scripts.post_start;
|
|
219
|
+
if (!scriptPaths || scriptPaths.length === 0) {
|
|
312
220
|
return;
|
|
313
221
|
}
|
|
314
|
-
const
|
|
222
|
+
const failOnError = this.config.scripts.fail_on_error ?? false;
|
|
223
|
+
for (const scriptPath of scriptPaths) {
|
|
224
|
+
const expandedPath = expandPath(scriptPath);
|
|
225
|
+
try {
|
|
226
|
+
const stat = await fs.stat(expandedPath);
|
|
227
|
+
if (stat.isDirectory()) {
|
|
228
|
+
await this.runScriptsFromDirectory(containerName, expandedPath, failOnError);
|
|
229
|
+
}
|
|
230
|
+
else if (stat.isFile()) {
|
|
231
|
+
await this.runSingleScript(containerName, expandedPath, failOnError);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (err.code === 'ENOENT') {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
console.warn(`Error accessing script path ${expandedPath}:`, err);
|
|
239
|
+
if (failOnError) {
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async runScriptsFromDirectory(containerName, dirPath, failOnError) {
|
|
246
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
247
|
+
const scripts = entries
|
|
248
|
+
.filter((e) => e.isFile() && e.name.endsWith('.sh'))
|
|
249
|
+
.map((e) => e.name)
|
|
250
|
+
.sort();
|
|
251
|
+
for (const scriptName of scripts) {
|
|
252
|
+
const scriptPath = path.join(dirPath, scriptName);
|
|
253
|
+
await this.runSingleScript(containerName, scriptPath, failOnError);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async runSingleScript(containerName, scriptPath, failOnError) {
|
|
257
|
+
const scriptName = path.basename(scriptPath);
|
|
258
|
+
const destPath = `/workspace/.perry-script-${scriptName}`;
|
|
315
259
|
try {
|
|
316
|
-
await
|
|
260
|
+
await docker.copyToContainer(containerName, scriptPath, destPath);
|
|
261
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', destPath], {
|
|
262
|
+
user: 'root',
|
|
263
|
+
});
|
|
264
|
+
await docker.execInContainer(containerName, ['chmod', '+x', destPath], {
|
|
265
|
+
user: 'workspace',
|
|
266
|
+
});
|
|
267
|
+
console.log(`[scripts] Running: ${scriptPath}`);
|
|
268
|
+
await docker.execInContainer(containerName, ['bash', destPath], {
|
|
269
|
+
user: 'workspace',
|
|
270
|
+
});
|
|
271
|
+
await docker.execInContainer(containerName, ['rm', '-f', destPath], {
|
|
272
|
+
user: 'workspace',
|
|
273
|
+
});
|
|
317
274
|
}
|
|
318
|
-
catch {
|
|
319
|
-
console.warn(`
|
|
320
|
-
|
|
275
|
+
catch (err) {
|
|
276
|
+
console.warn(`[scripts] Error running ${scriptPath}:`, err);
|
|
277
|
+
if (failOnError) {
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
321
280
|
}
|
|
322
|
-
const destPath = '/workspace/post-start.sh';
|
|
323
|
-
await docker.copyToContainer(containerName, expandedPath, destPath);
|
|
324
|
-
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', destPath], {
|
|
325
|
-
user: 'root',
|
|
326
|
-
});
|
|
327
|
-
await docker.execInContainer(containerName, ['chmod', '+x', destPath], {
|
|
328
|
-
user: 'workspace',
|
|
329
|
-
});
|
|
330
|
-
await docker.execInContainer(containerName, ['bash', destPath], {
|
|
331
|
-
user: 'workspace',
|
|
332
|
-
});
|
|
333
281
|
}
|
|
334
282
|
async syncWorkspaceStatus(workspace) {
|
|
335
283
|
if (workspace.status === 'creating') {
|
|
@@ -437,7 +385,7 @@ export class WorkspaceManager {
|
|
|
437
385
|
await this.setupWorkspaceCredentials(containerName, name);
|
|
438
386
|
workspace.status = 'running';
|
|
439
387
|
await this.state.setWorkspace(workspace);
|
|
440
|
-
await this.
|
|
388
|
+
await this.runUserScripts(containerName);
|
|
441
389
|
return workspace;
|
|
442
390
|
}
|
|
443
391
|
catch (err) {
|
|
@@ -516,7 +464,7 @@ export class WorkspaceManager {
|
|
|
516
464
|
workspace.status = 'running';
|
|
517
465
|
workspace.lastUsed = new Date().toISOString();
|
|
518
466
|
await this.state.setWorkspace(workspace);
|
|
519
|
-
await this.
|
|
467
|
+
await this.runUserScripts(containerName);
|
|
520
468
|
return workspace;
|
|
521
469
|
}
|
|
522
470
|
catch (err) {
|
|
@@ -593,4 +541,119 @@ export class WorkspaceManager {
|
|
|
593
541
|
}
|
|
594
542
|
await this.setupWorkspaceCredentials(containerName, name);
|
|
595
543
|
}
|
|
544
|
+
async setPortForwards(name, forwards) {
|
|
545
|
+
const workspace = await this.state.getWorkspace(name);
|
|
546
|
+
if (!workspace) {
|
|
547
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
548
|
+
}
|
|
549
|
+
workspace.ports.forwards = forwards;
|
|
550
|
+
await this.state.setWorkspace(workspace);
|
|
551
|
+
return workspace;
|
|
552
|
+
}
|
|
553
|
+
async getPortForwards(name) {
|
|
554
|
+
const workspace = await this.state.getWorkspace(name);
|
|
555
|
+
if (!workspace) {
|
|
556
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
557
|
+
}
|
|
558
|
+
return workspace.ports.forwards || [];
|
|
559
|
+
}
|
|
560
|
+
async clone(sourceName, cloneName) {
|
|
561
|
+
const source = await this.state.getWorkspace(sourceName);
|
|
562
|
+
if (!source) {
|
|
563
|
+
throw new Error(`Workspace '${sourceName}' not found`);
|
|
564
|
+
}
|
|
565
|
+
const existing = await this.state.getWorkspace(cloneName);
|
|
566
|
+
if (existing) {
|
|
567
|
+
throw new Error(`Workspace '${cloneName}' already exists`);
|
|
568
|
+
}
|
|
569
|
+
const sourceContainerName = getContainerName(sourceName);
|
|
570
|
+
const cloneContainerName = getContainerName(cloneName);
|
|
571
|
+
const sourceVolumeName = `${VOLUME_PREFIX}${sourceName}`;
|
|
572
|
+
const sourceDockerVolume = `${VOLUME_PREFIX}${sourceName}-docker`;
|
|
573
|
+
const cloneVolumeName = `${VOLUME_PREFIX}${cloneName}`;
|
|
574
|
+
const cloneDockerVolume = `${VOLUME_PREFIX}${cloneName}-docker`;
|
|
575
|
+
const workspace = {
|
|
576
|
+
name: cloneName,
|
|
577
|
+
status: 'creating',
|
|
578
|
+
containerId: '',
|
|
579
|
+
created: new Date().toISOString(),
|
|
580
|
+
repo: source.repo,
|
|
581
|
+
ports: {
|
|
582
|
+
ssh: 0,
|
|
583
|
+
forwards: source.ports.forwards ? [...source.ports.forwards] : undefined,
|
|
584
|
+
},
|
|
585
|
+
lastUsed: new Date().toISOString(),
|
|
586
|
+
};
|
|
587
|
+
await this.state.setWorkspace(workspace);
|
|
588
|
+
const wasRunning = await docker.containerRunning(sourceContainerName);
|
|
589
|
+
try {
|
|
590
|
+
if (wasRunning) {
|
|
591
|
+
await docker.stopContainer(sourceContainerName);
|
|
592
|
+
}
|
|
593
|
+
await docker.cloneVolume(sourceVolumeName, cloneVolumeName);
|
|
594
|
+
await docker.cloneVolume(sourceDockerVolume, cloneDockerVolume);
|
|
595
|
+
if (wasRunning) {
|
|
596
|
+
await docker.startContainer(sourceContainerName);
|
|
597
|
+
}
|
|
598
|
+
const workspaceImage = await ensureWorkspaceImage();
|
|
599
|
+
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
|
|
600
|
+
const containerEnv = {
|
|
601
|
+
...this.config.credentials.env,
|
|
602
|
+
};
|
|
603
|
+
if (this.config.agents?.github?.token) {
|
|
604
|
+
containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
|
|
605
|
+
}
|
|
606
|
+
if (this.config.agents?.claude_code?.oauth_token) {
|
|
607
|
+
containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
|
|
608
|
+
}
|
|
609
|
+
if (workspace.repo) {
|
|
610
|
+
containerEnv.WORKSPACE_REPO_URL = workspace.repo;
|
|
611
|
+
}
|
|
612
|
+
const containerId = await docker.createContainer({
|
|
613
|
+
name: cloneContainerName,
|
|
614
|
+
image: workspaceImage,
|
|
615
|
+
hostname: cloneName,
|
|
616
|
+
privileged: true,
|
|
617
|
+
restartPolicy: 'unless-stopped',
|
|
618
|
+
env: containerEnv,
|
|
619
|
+
volumes: [
|
|
620
|
+
{ source: cloneVolumeName, target: '/home/workspace', readonly: false },
|
|
621
|
+
{ source: cloneDockerVolume, target: '/var/lib/docker', readonly: false },
|
|
622
|
+
],
|
|
623
|
+
ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
|
|
624
|
+
labels: {
|
|
625
|
+
'workspace.name': cloneName,
|
|
626
|
+
'workspace.managed': 'true',
|
|
627
|
+
},
|
|
628
|
+
});
|
|
629
|
+
workspace.containerId = containerId;
|
|
630
|
+
workspace.ports.ssh = sshPort;
|
|
631
|
+
await this.state.setWorkspace(workspace);
|
|
632
|
+
await docker.startContainer(cloneContainerName);
|
|
633
|
+
await docker.waitForContainerReady(cloneContainerName);
|
|
634
|
+
await this.setupWorkspaceCredentials(cloneContainerName, cloneName);
|
|
635
|
+
workspace.status = 'running';
|
|
636
|
+
await this.state.setWorkspace(workspace);
|
|
637
|
+
await this.runUserScripts(cloneContainerName);
|
|
638
|
+
return workspace;
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
workspace.status = 'error';
|
|
642
|
+
await this.state.setWorkspace(workspace);
|
|
643
|
+
if (await docker.containerExists(cloneContainerName)) {
|
|
644
|
+
await docker.removeContainer(cloneContainerName, true).catch(() => { });
|
|
645
|
+
}
|
|
646
|
+
if (await docker.volumeExists(cloneVolumeName)) {
|
|
647
|
+
await docker.removeVolume(cloneVolumeName, true).catch(() => { });
|
|
648
|
+
}
|
|
649
|
+
if (await docker.volumeExists(cloneDockerVolume)) {
|
|
650
|
+
await docker.removeVolume(cloneDockerVolume, true).catch(() => { });
|
|
651
|
+
}
|
|
652
|
+
await this.state.deleteWorkspace(cloneName).catch(() => { });
|
|
653
|
+
if (wasRunning && !(await docker.containerRunning(sourceContainerName))) {
|
|
654
|
+
await docker.startContainer(sourceContainerName).catch(() => { });
|
|
655
|
+
}
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
596
659
|
}
|