@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.
@@ -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.setupClaudeCodeConfig(containerName);
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 runPostStartScript(containerName) {
310
- const scriptPath = this.config.scripts.post_start;
311
- if (!scriptPath) {
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 expandedPath = expandPath(scriptPath);
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 fs.access(expandedPath);
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(`Post-start script not found, skipping: ${expandedPath}`);
320
- return;
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.runPostStartScript(containerName);
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.runPostStartScript(containerName);
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",
3
+ "version": "0.2.5",
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": {