@gricha/perry 0.2.2 → 0.2.4
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 +11 -0
- package/dist/agent/file-watcher.js +134 -0
- package/dist/agent/router.js +48 -2
- package/dist/agent/run.js +44 -4
- package/dist/agent/web/assets/index-BmFYrCoX.css +1 -0
- package/dist/agent/web/assets/index-IavvQP8G.js +104 -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 +2 -2
- package/dist/chat/base-claude-session.js +169 -0
- package/dist/chat/base-opencode-session.js +181 -0
- package/dist/chat/handler.js +14 -157
- package/dist/chat/host-handler.js +13 -142
- package/dist/chat/host-opencode-handler.js +28 -187
- package/dist/chat/opencode-handler.js +38 -197
- package/dist/chat/opencode-websocket.js +1 -1
- package/dist/chat/types.js +1 -0
- 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 +28 -1
- package/dist/index.js +83 -12
- package/dist/perry-worker +0 -0
- package/dist/shared/constants.js +1 -0
- package/dist/shared/types.js +0 -1
- package/dist/terminal/websocket.js +1 -1
- package/dist/workspace/manager.js +178 -115
- package/package.json +4 -3
- package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
- package/dist/agent/web/assets/index-DN_QW9sL.js +0 -104
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
404
|
-
|
|
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
|
package/dist/shared/constants.js
CHANGED
package/dist/shared/types.js
CHANGED
|
@@ -3,7 +3,7 @@ import { BaseWebSocketServer, safeSend } from '../shared/base-websocket';
|
|
|
3
3
|
import { createTerminalSession } from './handler';
|
|
4
4
|
import { createHostTerminalSession } from './host-handler';
|
|
5
5
|
import { isControlMessage } from './types';
|
|
6
|
-
import { HOST_WORKSPACE_NAME } from '../shared/types';
|
|
6
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
7
7
|
export class TerminalWebSocketServer extends BaseWebSocketServer {
|
|
8
8
|
getContainerName;
|
|
9
9
|
isHostAccessAllowed;
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gricha/perry",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "rm -rf ./dist && bun run build:ts && bun run build:worker && bun run build:web && bun link",
|
|
14
14
|
"build:ts": "tsc && chmod +x dist/index.js",
|
|
15
15
|
"build:worker": "bun build src/index.ts --compile --outfile dist/perry-worker --target=bun",
|
|
16
|
-
"build:web": "
|
|
16
|
+
"build:web": "cd web && bun run build && cp -r dist ../dist/agent/web",
|
|
17
17
|
"test": "vitest run",
|
|
18
18
|
"test:web": "playwright test",
|
|
19
19
|
"test:tui": "vitest run --config vitest.tui.config.js",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"format:check": "oxfmt --check src/ test/",
|
|
25
25
|
"check": "bun run lint && bun run format:check && bun x tsc --noEmit",
|
|
26
26
|
"lint:web": "cd web && bun run lint",
|
|
27
|
-
"validate": "bun run check && bun run build && bun run test && bun run lint:web && bun run test:web"
|
|
27
|
+
"validate": "bun run check && bun run build && bun run test && bun run lint:web && bun run test:web",
|
|
28
|
+
"build:binaries": "bun run scripts/build-binaries.ts"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"bun": ">=1.3.5"
|