@gricha/perry 0.0.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/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/agent/index.js +6 -0
- package/dist/agent/router.js +1017 -0
- package/dist/agent/run.js +182 -0
- package/dist/agent/static.js +58 -0
- package/dist/agent/systemd.js +229 -0
- package/dist/agent/web/assets/index-9t2sFIJM.js +101 -0
- package/dist/agent/web/assets/index-CCFpTruF.css +1 -0
- package/dist/agent/web/index.html +14 -0
- package/dist/agent/web/vite.svg +1 -0
- package/dist/chat/handler.js +174 -0
- package/dist/chat/host-handler.js +170 -0
- package/dist/chat/host-opencode-handler.js +169 -0
- package/dist/chat/index.js +2 -0
- package/dist/chat/opencode-handler.js +177 -0
- package/dist/chat/opencode-websocket.js +95 -0
- package/dist/chat/websocket.js +100 -0
- package/dist/client/api.js +138 -0
- package/dist/client/config.js +34 -0
- package/dist/client/docker-proxy.js +103 -0
- package/dist/client/index.js +4 -0
- package/dist/client/proxy.js +96 -0
- package/dist/client/shell.js +71 -0
- package/dist/client/ws-shell.js +120 -0
- package/dist/config/loader.js +59 -0
- package/dist/docker/index.js +372 -0
- package/dist/docker/types.js +1 -0
- package/dist/index.js +475 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/metadata.js +55 -0
- package/dist/sessions/parser.js +553 -0
- package/dist/sessions/types.js +1 -0
- package/dist/shared/base-websocket.js +51 -0
- package/dist/shared/client-types.js +1 -0
- package/dist/shared/constants.js +11 -0
- package/dist/shared/types.js +5 -0
- package/dist/terminal/handler.js +86 -0
- package/dist/terminal/host-handler.js +76 -0
- package/dist/terminal/index.js +3 -0
- package/dist/terminal/types.js +8 -0
- package/dist/terminal/websocket.js +115 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/manager.js +475 -0
- package/dist/workspace/state.js +66 -0
- package/dist/workspace/types.js +1 -0
- package/package.json +68 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { createServer } from 'net';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import { StateManager } from './state';
|
|
6
|
+
import { expandPath } from '../config/loader';
|
|
7
|
+
import * as docker from '../docker';
|
|
8
|
+
import { getContainerName } from '../docker';
|
|
9
|
+
import { VOLUME_PREFIX, WORKSPACE_IMAGE, SSH_PORT_RANGE_START, SSH_PORT_RANGE_END, } from '../shared/constants';
|
|
10
|
+
async function findAvailablePort(start, end) {
|
|
11
|
+
for (let port = start; port <= end; port++) {
|
|
12
|
+
const available = await new Promise((resolve) => {
|
|
13
|
+
const server = createServer();
|
|
14
|
+
server.listen(port, '127.0.0.1', () => {
|
|
15
|
+
const addr = server.address();
|
|
16
|
+
server.close(() => resolve(addr.port === port));
|
|
17
|
+
});
|
|
18
|
+
server.on('error', () => resolve(false));
|
|
19
|
+
});
|
|
20
|
+
if (available) {
|
|
21
|
+
return port;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`No available port in range ${start}-${end}`);
|
|
25
|
+
}
|
|
26
|
+
async function copyCredentialToContainer(options) {
|
|
27
|
+
const { source, dest, containerName, dirPermissions = '700', filePermissions = '600', tempPrefix = 'ws-cred', } = options;
|
|
28
|
+
const expandedSource = expandPath(source);
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(expandedSource);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const stat = await fs.stat(expandedSource);
|
|
36
|
+
if (stat.isDirectory()) {
|
|
37
|
+
const tempTar = path.join(os.tmpdir(), `${tempPrefix}-${Date.now()}.tar`);
|
|
38
|
+
try {
|
|
39
|
+
const { execSync } = await import('child_process');
|
|
40
|
+
execSync(`tar -cf "${tempTar}" -C "${expandedSource}" .`, { stdio: 'pipe' });
|
|
41
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', dest], {
|
|
42
|
+
user: 'workspace',
|
|
43
|
+
});
|
|
44
|
+
await docker.copyToContainer(containerName, tempTar, '/tmp/creds.tar');
|
|
45
|
+
await docker.execInContainer(containerName, ['tar', '-xf', '/tmp/creds.tar', '-C', dest], {
|
|
46
|
+
user: 'workspace',
|
|
47
|
+
});
|
|
48
|
+
await docker.execInContainer(containerName, ['rm', '/tmp/creds.tar'], {
|
|
49
|
+
user: 'workspace',
|
|
50
|
+
});
|
|
51
|
+
await docker.execInContainer(containerName, ['chmod', '-R', filePermissions, dest], {
|
|
52
|
+
user: 'workspace',
|
|
53
|
+
});
|
|
54
|
+
await docker.execInContainer(containerName, ['chmod', dirPermissions, dest], {
|
|
55
|
+
user: 'workspace',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await fs.unlink(tempTar).catch((err) => {
|
|
60
|
+
console.warn(`[workspace] Failed to clean up temp file ${tempTar}:`, err);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const destDir = path.dirname(dest);
|
|
66
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', destDir], {
|
|
67
|
+
user: 'workspace',
|
|
68
|
+
});
|
|
69
|
+
await docker.copyToContainer(containerName, expandedSource, dest);
|
|
70
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', dest], {
|
|
71
|
+
user: 'root',
|
|
72
|
+
});
|
|
73
|
+
await docker.execInContainer(containerName, ['chmod', filePermissions, dest], {
|
|
74
|
+
user: 'workspace',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class WorkspaceManager {
|
|
79
|
+
state;
|
|
80
|
+
config;
|
|
81
|
+
configDir;
|
|
82
|
+
constructor(configDir, config) {
|
|
83
|
+
this.state = new StateManager(configDir);
|
|
84
|
+
this.config = config;
|
|
85
|
+
this.configDir = configDir;
|
|
86
|
+
}
|
|
87
|
+
updateConfig(config) {
|
|
88
|
+
this.config = config;
|
|
89
|
+
}
|
|
90
|
+
async copyCredentialFiles(containerName) {
|
|
91
|
+
const files = this.config.credentials.files;
|
|
92
|
+
if (!files || Object.keys(files).length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const [destPath, sourcePath] of Object.entries(files)) {
|
|
96
|
+
const expandedDest = destPath.startsWith('~/')
|
|
97
|
+
? `/home/workspace/${destPath.slice(2)}`
|
|
98
|
+
: destPath;
|
|
99
|
+
const isPrivateKey = expandedDest.includes('.ssh') &&
|
|
100
|
+
!expandedDest.endsWith('.pub') &&
|
|
101
|
+
!expandedDest.endsWith('config') &&
|
|
102
|
+
!expandedDest.endsWith('known_hosts');
|
|
103
|
+
const filePermissions = isPrivateKey ? '600' : '644';
|
|
104
|
+
await copyCredentialToContainer({
|
|
105
|
+
source: sourcePath,
|
|
106
|
+
dest: expandedDest,
|
|
107
|
+
containerName,
|
|
108
|
+
filePermissions,
|
|
109
|
+
tempPrefix: 'ws-cred',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async setupClaudeCodeConfig(containerName) {
|
|
114
|
+
const localClaudeCredentials = expandPath('~/.claude/.credentials.json');
|
|
115
|
+
const configContent = JSON.stringify({ hasCompletedOnboarding: true });
|
|
116
|
+
const tempFile = path.join(os.tmpdir(), `ws-claude-config-${Date.now()}.json`);
|
|
117
|
+
try {
|
|
118
|
+
await fs.writeFile(tempFile, configContent);
|
|
119
|
+
await docker.copyToContainer(containerName, tempFile, '/home/workspace/.claude.json');
|
|
120
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', '/home/workspace/.claude.json'], { user: 'root' });
|
|
121
|
+
await docker.execInContainer(containerName, ['chmod', '644', '/home/workspace/.claude.json'], { user: 'workspace' });
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await fs.unlink(tempFile).catch(() => { });
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(localClaudeCredentials);
|
|
128
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.claude'], {
|
|
129
|
+
user: 'workspace',
|
|
130
|
+
});
|
|
131
|
+
await copyCredentialToContainer({
|
|
132
|
+
source: '~/.claude/.credentials.json',
|
|
133
|
+
dest: '/home/workspace/.claude/.credentials.json',
|
|
134
|
+
containerName,
|
|
135
|
+
filePermissions: '600',
|
|
136
|
+
tempPrefix: 'ws-claude-creds',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// No credentials file - that's OK, user may use oauth_token env var instead
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async copyCodexCredentials(containerName) {
|
|
144
|
+
const codexDir = expandPath('~/.codex');
|
|
145
|
+
try {
|
|
146
|
+
await fs.access(codexDir);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.codex'], {
|
|
152
|
+
user: 'workspace',
|
|
153
|
+
});
|
|
154
|
+
await copyCredentialToContainer({
|
|
155
|
+
source: '~/.codex/auth.json',
|
|
156
|
+
dest: '/home/workspace/.codex/auth.json',
|
|
157
|
+
containerName,
|
|
158
|
+
filePermissions: '600',
|
|
159
|
+
tempPrefix: 'ws-codex-auth',
|
|
160
|
+
});
|
|
161
|
+
await copyCredentialToContainer({
|
|
162
|
+
source: '~/.codex/config.toml',
|
|
163
|
+
dest: '/home/workspace/.codex/config.toml',
|
|
164
|
+
containerName,
|
|
165
|
+
filePermissions: '600',
|
|
166
|
+
tempPrefix: 'ws-codex-config',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async setupOpencodeConfig(containerName) {
|
|
170
|
+
const zenToken = this.config.agents?.opencode?.zen_token;
|
|
171
|
+
if (!zenToken) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const config = {
|
|
175
|
+
provider: {
|
|
176
|
+
opencode: {
|
|
177
|
+
options: {
|
|
178
|
+
apiKey: zenToken,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
model: 'opencode/claude-sonnet-4',
|
|
183
|
+
};
|
|
184
|
+
const configJson = JSON.stringify(config, null, 2);
|
|
185
|
+
const tempFile = `/tmp/ws-opencode-config-${Date.now()}.json`;
|
|
186
|
+
await fs.writeFile(tempFile, configJson, 'utf-8');
|
|
187
|
+
try {
|
|
188
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', '/home/workspace/.config/opencode'], {
|
|
189
|
+
user: 'workspace',
|
|
190
|
+
});
|
|
191
|
+
await docker.copyToContainer(containerName, tempFile, '/home/workspace/.config/opencode/opencode.json');
|
|
192
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', '/home/workspace/.config/opencode/opencode.json'], { user: 'root' });
|
|
193
|
+
await docker.execInContainer(containerName, ['chmod', '600', '/home/workspace/.config/opencode/opencode.json'], { user: 'workspace' });
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
try {
|
|
197
|
+
await fs.unlink(tempFile);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Ignore cleanup errors
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async copyGitConfig(containerName) {
|
|
205
|
+
await copyCredentialToContainer({
|
|
206
|
+
source: '~/.gitconfig',
|
|
207
|
+
dest: '/home/workspace/.gitconfig',
|
|
208
|
+
containerName,
|
|
209
|
+
filePermissions: '644',
|
|
210
|
+
tempPrefix: 'ws-gitconfig',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async runPostStartScript(containerName) {
|
|
214
|
+
const scriptPath = this.config.scripts.post_start;
|
|
215
|
+
if (!scriptPath) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const expandedPath = expandPath(scriptPath);
|
|
219
|
+
try {
|
|
220
|
+
await fs.access(expandedPath);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
console.warn(`Post-start script not found, skipping: ${expandedPath}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const destPath = '/workspace/post-start.sh';
|
|
227
|
+
await docker.copyToContainer(containerName, expandedPath, destPath);
|
|
228
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', destPath], {
|
|
229
|
+
user: 'root',
|
|
230
|
+
});
|
|
231
|
+
await docker.execInContainer(containerName, ['chmod', '+x', destPath], {
|
|
232
|
+
user: 'workspace',
|
|
233
|
+
});
|
|
234
|
+
await docker.execInContainer(containerName, ['bash', destPath], {
|
|
235
|
+
user: 'workspace',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async syncWorkspaceStatus(workspace) {
|
|
239
|
+
const containerName = getContainerName(workspace.name);
|
|
240
|
+
const exists = await docker.containerExists(containerName);
|
|
241
|
+
if (!exists) {
|
|
242
|
+
if (workspace.status !== 'error') {
|
|
243
|
+
workspace.status = 'error';
|
|
244
|
+
await this.state.setWorkspace(workspace);
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const running = await docker.containerRunning(containerName);
|
|
249
|
+
const newStatus = running ? 'running' : 'stopped';
|
|
250
|
+
if (workspace.status !== newStatus && workspace.status !== 'creating') {
|
|
251
|
+
workspace.status = newStatus;
|
|
252
|
+
await this.state.setWorkspace(workspace);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async list() {
|
|
256
|
+
const workspaces = await this.state.getAllWorkspaces();
|
|
257
|
+
for (const ws of workspaces) {
|
|
258
|
+
await this.syncWorkspaceStatus(ws);
|
|
259
|
+
}
|
|
260
|
+
return workspaces;
|
|
261
|
+
}
|
|
262
|
+
async get(name) {
|
|
263
|
+
const workspace = await this.state.getWorkspace(name);
|
|
264
|
+
if (!workspace) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
await this.syncWorkspaceStatus(workspace);
|
|
268
|
+
return workspace;
|
|
269
|
+
}
|
|
270
|
+
async create(options) {
|
|
271
|
+
const { name, clone, env } = options;
|
|
272
|
+
const containerName = getContainerName(name);
|
|
273
|
+
const volumeName = `${VOLUME_PREFIX}${name}`;
|
|
274
|
+
const existing = await this.state.getWorkspace(name);
|
|
275
|
+
if (existing) {
|
|
276
|
+
throw new Error(`Workspace '${name}' already exists`);
|
|
277
|
+
}
|
|
278
|
+
const workspace = {
|
|
279
|
+
name,
|
|
280
|
+
status: 'creating',
|
|
281
|
+
containerId: '',
|
|
282
|
+
created: new Date().toISOString(),
|
|
283
|
+
repo: clone,
|
|
284
|
+
ports: {
|
|
285
|
+
ssh: 0,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
await this.state.setWorkspace(workspace);
|
|
289
|
+
try {
|
|
290
|
+
const imageReady = await docker.imageExists(WORKSPACE_IMAGE);
|
|
291
|
+
if (!imageReady) {
|
|
292
|
+
throw new Error(`Workspace image '${WORKSPACE_IMAGE}' not found. Run 'workspace build' first.`);
|
|
293
|
+
}
|
|
294
|
+
if (!(await docker.volumeExists(volumeName))) {
|
|
295
|
+
await docker.createVolume(volumeName);
|
|
296
|
+
}
|
|
297
|
+
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
|
|
298
|
+
const containerEnv = {
|
|
299
|
+
...this.config.credentials.env,
|
|
300
|
+
...env,
|
|
301
|
+
};
|
|
302
|
+
if (this.config.agents?.github?.token) {
|
|
303
|
+
containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
|
|
304
|
+
}
|
|
305
|
+
if (this.config.agents?.claude_code?.oauth_token) {
|
|
306
|
+
containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
|
|
307
|
+
}
|
|
308
|
+
if (clone) {
|
|
309
|
+
containerEnv.WORKSPACE_REPO_URL = clone;
|
|
310
|
+
}
|
|
311
|
+
const containerId = await docker.createContainer({
|
|
312
|
+
name: containerName,
|
|
313
|
+
image: WORKSPACE_IMAGE,
|
|
314
|
+
hostname: name,
|
|
315
|
+
privileged: true,
|
|
316
|
+
restartPolicy: 'unless-stopped',
|
|
317
|
+
env: containerEnv,
|
|
318
|
+
volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
|
|
319
|
+
ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
|
|
320
|
+
labels: {
|
|
321
|
+
'workspace.name': name,
|
|
322
|
+
'workspace.managed': 'true',
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
workspace.containerId = containerId;
|
|
326
|
+
workspace.ports.ssh = sshPort;
|
|
327
|
+
await this.state.setWorkspace(workspace);
|
|
328
|
+
await docker.startContainer(containerName);
|
|
329
|
+
await this.copyGitConfig(containerName);
|
|
330
|
+
await this.copyCredentialFiles(containerName);
|
|
331
|
+
await this.setupClaudeCodeConfig(containerName);
|
|
332
|
+
await this.copyCodexCredentials(containerName);
|
|
333
|
+
await this.setupOpencodeConfig(containerName);
|
|
334
|
+
workspace.status = 'running';
|
|
335
|
+
await this.state.setWorkspace(workspace);
|
|
336
|
+
await this.runPostStartScript(containerName);
|
|
337
|
+
return workspace;
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
workspace.status = 'error';
|
|
341
|
+
await this.state.setWorkspace(workspace);
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async start(name) {
|
|
346
|
+
const workspace = await this.state.getWorkspace(name);
|
|
347
|
+
if (!workspace) {
|
|
348
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
349
|
+
}
|
|
350
|
+
const containerName = getContainerName(name);
|
|
351
|
+
const volumeName = `${VOLUME_PREFIX}${name}`;
|
|
352
|
+
const exists = await docker.containerExists(containerName);
|
|
353
|
+
if (!exists) {
|
|
354
|
+
const volumeExists = await docker.volumeExists(volumeName);
|
|
355
|
+
if (!volumeExists) {
|
|
356
|
+
throw new Error(`Container and volume for workspace '${name}' were deleted. ` +
|
|
357
|
+
`Please delete this workspace and create a new one.`);
|
|
358
|
+
}
|
|
359
|
+
const sshPort = await findAvailablePort(SSH_PORT_RANGE_START, SSH_PORT_RANGE_END);
|
|
360
|
+
const containerEnv = {
|
|
361
|
+
...this.config.credentials.env,
|
|
362
|
+
};
|
|
363
|
+
if (this.config.agents?.github?.token) {
|
|
364
|
+
containerEnv.GITHUB_TOKEN = this.config.agents.github.token;
|
|
365
|
+
}
|
|
366
|
+
if (this.config.agents?.claude_code?.oauth_token) {
|
|
367
|
+
containerEnv.CLAUDE_CODE_OAUTH_TOKEN = this.config.agents.claude_code.oauth_token;
|
|
368
|
+
}
|
|
369
|
+
if (workspace.repo) {
|
|
370
|
+
containerEnv.WORKSPACE_REPO_URL = workspace.repo;
|
|
371
|
+
}
|
|
372
|
+
const containerId = await docker.createContainer({
|
|
373
|
+
name: containerName,
|
|
374
|
+
image: WORKSPACE_IMAGE,
|
|
375
|
+
hostname: name,
|
|
376
|
+
privileged: true,
|
|
377
|
+
restartPolicy: 'unless-stopped',
|
|
378
|
+
env: containerEnv,
|
|
379
|
+
volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
|
|
380
|
+
ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
|
|
381
|
+
labels: {
|
|
382
|
+
'workspace.name': name,
|
|
383
|
+
'workspace.managed': 'true',
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
workspace.containerId = containerId;
|
|
387
|
+
workspace.ports.ssh = sshPort;
|
|
388
|
+
await this.state.setWorkspace(workspace);
|
|
389
|
+
}
|
|
390
|
+
const running = await docker.containerRunning(containerName);
|
|
391
|
+
if (running) {
|
|
392
|
+
workspace.status = 'running';
|
|
393
|
+
await this.state.setWorkspace(workspace);
|
|
394
|
+
return workspace;
|
|
395
|
+
}
|
|
396
|
+
await docker.startContainer(containerName);
|
|
397
|
+
await this.copyGitConfig(containerName);
|
|
398
|
+
await this.copyCredentialFiles(containerName);
|
|
399
|
+
await this.setupClaudeCodeConfig(containerName);
|
|
400
|
+
await this.copyCodexCredentials(containerName);
|
|
401
|
+
await this.setupOpencodeConfig(containerName);
|
|
402
|
+
workspace.status = 'running';
|
|
403
|
+
await this.state.setWorkspace(workspace);
|
|
404
|
+
await this.runPostStartScript(containerName);
|
|
405
|
+
return workspace;
|
|
406
|
+
}
|
|
407
|
+
async stop(name) {
|
|
408
|
+
const workspace = await this.state.getWorkspace(name);
|
|
409
|
+
if (!workspace) {
|
|
410
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
411
|
+
}
|
|
412
|
+
const containerName = getContainerName(name);
|
|
413
|
+
const running = await docker.containerRunning(containerName);
|
|
414
|
+
if (!running) {
|
|
415
|
+
workspace.status = 'stopped';
|
|
416
|
+
await this.state.setWorkspace(workspace);
|
|
417
|
+
return workspace;
|
|
418
|
+
}
|
|
419
|
+
await docker.stopContainer(containerName);
|
|
420
|
+
workspace.status = 'stopped';
|
|
421
|
+
await this.state.setWorkspace(workspace);
|
|
422
|
+
return workspace;
|
|
423
|
+
}
|
|
424
|
+
async delete(name) {
|
|
425
|
+
const workspace = await this.state.getWorkspace(name);
|
|
426
|
+
if (!workspace) {
|
|
427
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
428
|
+
}
|
|
429
|
+
const containerName = getContainerName(name);
|
|
430
|
+
const volumeName = `${VOLUME_PREFIX}${name}`;
|
|
431
|
+
if (await docker.containerExists(containerName)) {
|
|
432
|
+
await docker.removeContainer(containerName, true);
|
|
433
|
+
}
|
|
434
|
+
if (await docker.volumeExists(volumeName)) {
|
|
435
|
+
await docker.removeVolume(volumeName, true);
|
|
436
|
+
}
|
|
437
|
+
await this.state.deleteWorkspace(name);
|
|
438
|
+
}
|
|
439
|
+
async exec(name, command) {
|
|
440
|
+
const workspace = await this.state.getWorkspace(name);
|
|
441
|
+
if (!workspace) {
|
|
442
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
443
|
+
}
|
|
444
|
+
const containerName = getContainerName(name);
|
|
445
|
+
const running = await docker.containerRunning(containerName);
|
|
446
|
+
if (!running) {
|
|
447
|
+
throw new Error(`Workspace '${name}' is not running`);
|
|
448
|
+
}
|
|
449
|
+
return docker.execInContainer(containerName, command, { user: 'workspace' });
|
|
450
|
+
}
|
|
451
|
+
async getLogs(name, tail = 100) {
|
|
452
|
+
const workspace = await this.state.getWorkspace(name);
|
|
453
|
+
if (!workspace) {
|
|
454
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
455
|
+
}
|
|
456
|
+
const containerName = getContainerName(name);
|
|
457
|
+
return docker.getLogs(containerName, { tail });
|
|
458
|
+
}
|
|
459
|
+
async sync(name) {
|
|
460
|
+
const workspace = await this.state.getWorkspace(name);
|
|
461
|
+
if (!workspace) {
|
|
462
|
+
throw new Error(`Workspace '${name}' not found`);
|
|
463
|
+
}
|
|
464
|
+
const containerName = getContainerName(name);
|
|
465
|
+
const running = await docker.containerRunning(containerName);
|
|
466
|
+
if (!running) {
|
|
467
|
+
throw new Error(`Workspace '${name}' is not running`);
|
|
468
|
+
}
|
|
469
|
+
await this.copyGitConfig(containerName);
|
|
470
|
+
await this.copyCredentialFiles(containerName);
|
|
471
|
+
await this.setupClaudeCodeConfig(containerName);
|
|
472
|
+
await this.copyCodexCredentials(containerName);
|
|
473
|
+
await this.setupOpencodeConfig(containerName);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { STATE_FILE } from '../shared/types';
|
|
4
|
+
export class StateManager {
|
|
5
|
+
statePath;
|
|
6
|
+
state = null;
|
|
7
|
+
constructor(configDir) {
|
|
8
|
+
this.statePath = path.join(configDir, STATE_FILE);
|
|
9
|
+
}
|
|
10
|
+
async load() {
|
|
11
|
+
if (this.state) {
|
|
12
|
+
return this.state;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const content = await fs.readFile(this.statePath, 'utf-8');
|
|
16
|
+
this.state = JSON.parse(content);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.code === 'ENOENT') {
|
|
20
|
+
this.state = { workspaces: {} };
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return this.state;
|
|
27
|
+
}
|
|
28
|
+
async save() {
|
|
29
|
+
if (!this.state) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await fs.mkdir(path.dirname(this.statePath), { recursive: true });
|
|
33
|
+
await fs.writeFile(this.statePath, JSON.stringify(this.state, null, 2), 'utf-8');
|
|
34
|
+
}
|
|
35
|
+
async getWorkspace(name) {
|
|
36
|
+
const state = await this.load();
|
|
37
|
+
return state.workspaces[name] || null;
|
|
38
|
+
}
|
|
39
|
+
async getAllWorkspaces() {
|
|
40
|
+
const state = await this.load();
|
|
41
|
+
return Object.values(state.workspaces);
|
|
42
|
+
}
|
|
43
|
+
async setWorkspace(workspace) {
|
|
44
|
+
const state = await this.load();
|
|
45
|
+
state.workspaces[workspace.name] = workspace;
|
|
46
|
+
await this.save();
|
|
47
|
+
}
|
|
48
|
+
async deleteWorkspace(name) {
|
|
49
|
+
const state = await this.load();
|
|
50
|
+
if (state.workspaces[name]) {
|
|
51
|
+
delete state.workspaces[name];
|
|
52
|
+
await this.save();
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
async updateWorkspaceStatus(name, status) {
|
|
58
|
+
const workspace = await this.getWorkspace(name);
|
|
59
|
+
if (!workspace) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
workspace.status = status;
|
|
63
|
+
await this.setWorkspace(workspace);
|
|
64
|
+
return workspace;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gricha/perry",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"perry": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "rm -rf ./dist && bun run build:ts && bun run build:web && bun link",
|
|
14
|
+
"build:ts": "tsc && chmod +x dist/index.js",
|
|
15
|
+
"build:web": "cp src/shared/client-types.ts web/src/lib/types.ts && cd web && bun run build && cp -r dist ../dist/agent/web",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:web": "playwright test",
|
|
18
|
+
"test:tui": "vitest run --config vitest.tui.config.js",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"lint": "oxlint --deny-warnings src/",
|
|
21
|
+
"lint:fix": "oxlint --fix src/",
|
|
22
|
+
"format": "oxfmt --write src/ test/",
|
|
23
|
+
"format:check": "oxfmt --check src/ test/",
|
|
24
|
+
"check": "bun run lint && bun run format:check && bun x tsc --noEmit",
|
|
25
|
+
"validate": "bun run check && bun run build && bun run test"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.3.5"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@orpc/server": "^1.13.2",
|
|
32
|
+
"commander": "^11.1.0",
|
|
33
|
+
"fs-extra": "^11.2.0",
|
|
34
|
+
"proper-lockfile": "^4.1.2",
|
|
35
|
+
"ws": "^8.18.3",
|
|
36
|
+
"yaml": "^2.3.4",
|
|
37
|
+
"zod": "^4.3.4"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@playwright/test": "^1.57.0",
|
|
41
|
+
"@types/bun": "^1.3.5",
|
|
42
|
+
"@types/fs-extra": "^11.0.4",
|
|
43
|
+
"@types/node": "^22.8.1",
|
|
44
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
45
|
+
"@types/ws": "^8.18.1",
|
|
46
|
+
"oxfmt": "^0.21.0",
|
|
47
|
+
"oxlint": "^1.36.0",
|
|
48
|
+
"typescript": "^5.6.3",
|
|
49
|
+
"vitest": "^4.0.6"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "https://github.com/gricha/perry.git"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"docker",
|
|
57
|
+
"development",
|
|
58
|
+
"perry",
|
|
59
|
+
"cli",
|
|
60
|
+
"docker-in-docker",
|
|
61
|
+
"ssh"
|
|
62
|
+
],
|
|
63
|
+
"author": "gricha",
|
|
64
|
+
"license": "MIT",
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|