@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/dist/agent/index.js +6 -0
  4. package/dist/agent/router.js +1017 -0
  5. package/dist/agent/run.js +182 -0
  6. package/dist/agent/static.js +58 -0
  7. package/dist/agent/systemd.js +229 -0
  8. package/dist/agent/web/assets/index-9t2sFIJM.js +101 -0
  9. package/dist/agent/web/assets/index-CCFpTruF.css +1 -0
  10. package/dist/agent/web/index.html +14 -0
  11. package/dist/agent/web/vite.svg +1 -0
  12. package/dist/chat/handler.js +174 -0
  13. package/dist/chat/host-handler.js +170 -0
  14. package/dist/chat/host-opencode-handler.js +169 -0
  15. package/dist/chat/index.js +2 -0
  16. package/dist/chat/opencode-handler.js +177 -0
  17. package/dist/chat/opencode-websocket.js +95 -0
  18. package/dist/chat/websocket.js +100 -0
  19. package/dist/client/api.js +138 -0
  20. package/dist/client/config.js +34 -0
  21. package/dist/client/docker-proxy.js +103 -0
  22. package/dist/client/index.js +4 -0
  23. package/dist/client/proxy.js +96 -0
  24. package/dist/client/shell.js +71 -0
  25. package/dist/client/ws-shell.js +120 -0
  26. package/dist/config/loader.js +59 -0
  27. package/dist/docker/index.js +372 -0
  28. package/dist/docker/types.js +1 -0
  29. package/dist/index.js +475 -0
  30. package/dist/sessions/index.js +2 -0
  31. package/dist/sessions/metadata.js +55 -0
  32. package/dist/sessions/parser.js +553 -0
  33. package/dist/sessions/types.js +1 -0
  34. package/dist/shared/base-websocket.js +51 -0
  35. package/dist/shared/client-types.js +1 -0
  36. package/dist/shared/constants.js +11 -0
  37. package/dist/shared/types.js +5 -0
  38. package/dist/terminal/handler.js +86 -0
  39. package/dist/terminal/host-handler.js +76 -0
  40. package/dist/terminal/index.js +3 -0
  41. package/dist/terminal/types.js +8 -0
  42. package/dist/terminal/websocket.js +115 -0
  43. package/dist/workspace/index.js +3 -0
  44. package/dist/workspace/manager.js +475 -0
  45. package/dist/workspace/state.js +66 -0
  46. package/dist/workspace/types.js +1 -0
  47. package/package.json +68 -0
package/dist/index.js ADDED
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from 'commander';
3
+ import pkg from '../package.json';
4
+ import { startAgent } from './agent/run';
5
+ import { installService, uninstallService, showStatus } from './agent/systemd';
6
+ import { createApiClient, ApiClientError } from './client/api';
7
+ import { loadClientConfig, getWorker, setWorker } from './client/config';
8
+ import { openWSShell, openDockerExec, getTerminalWSUrl, isLocalWorker } from './client/ws-shell';
9
+ import { getContainerName, getContainerIp } from './docker';
10
+ import { startProxy, parsePortForward, formatPortForwards } from './client/proxy';
11
+ import { startDockerProxy, parsePortForward as parseDockerPortForward, formatPortForwards as formatDockerPortForwards, } from './client/docker-proxy';
12
+ import { loadAgentConfig, getConfigDir, ensureConfigDir } from './config/loader';
13
+ import { buildImage } from './docker';
14
+ import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE } from './shared/constants';
15
+ const program = new Command();
16
+ program
17
+ .name('perry')
18
+ .description('Distributed development environment orchestrator')
19
+ .version(pkg.version)
20
+ .action(() => {
21
+ program.outputHelp();
22
+ });
23
+ const agentCmd = program.command('agent').description('Manage the workspace agent daemon');
24
+ agentCmd
25
+ .command('run')
26
+ .description('Start the agent daemon')
27
+ .option('-p, --port <port>', 'Port to listen on', parseInt)
28
+ .option('-c, --config-dir <dir>', 'Configuration directory')
29
+ .option('--no-host-access', 'Disable direct host machine access')
30
+ .action(async (options) => {
31
+ await startAgent({
32
+ port: options.port,
33
+ configDir: options.configDir,
34
+ noHostAccess: options.hostAccess === false,
35
+ });
36
+ });
37
+ agentCmd
38
+ .command('install')
39
+ .description('Install agent as systemd user service')
40
+ .option('-p, --port <port>', 'Port to listen on', parseInt)
41
+ .option('-c, --config-dir <dir>', 'Configuration directory')
42
+ .option('--no-host-access', 'Disable direct host machine access')
43
+ .action(async (options) => {
44
+ await installService({
45
+ port: options.port,
46
+ configDir: options.configDir,
47
+ noHostAccess: options.hostAccess === false,
48
+ });
49
+ });
50
+ agentCmd
51
+ .command('uninstall')
52
+ .description('Uninstall agent systemd service')
53
+ .action(async () => {
54
+ await uninstallService();
55
+ });
56
+ agentCmd
57
+ .command('status')
58
+ .description('Show agent service status')
59
+ .action(async () => {
60
+ await showStatus();
61
+ });
62
+ agentCmd
63
+ .command('logs')
64
+ .description('View agent service logs')
65
+ .option('-f, --follow', 'Follow log output')
66
+ .option('-n, --lines <lines>', 'Number of lines to show', '50')
67
+ .action(async (options) => {
68
+ const { showLogs } = await import('./agent/systemd');
69
+ await showLogs({
70
+ follow: options.follow,
71
+ lines: parseInt(options.lines, 10),
72
+ });
73
+ });
74
+ async function checkLocalAgent() {
75
+ try {
76
+ const response = await fetch(`http://localhost:${DEFAULT_AGENT_PORT}/health`, {
77
+ signal: AbortSignal.timeout(1000),
78
+ });
79
+ return response.ok;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ async function getClient() {
86
+ let worker = await getWorker();
87
+ if (!worker) {
88
+ const localRunning = await checkLocalAgent();
89
+ if (localRunning) {
90
+ worker = `localhost:${DEFAULT_AGENT_PORT}`;
91
+ }
92
+ else {
93
+ console.error('No worker configured. Run: perry config worker <hostname>');
94
+ process.exit(1);
95
+ }
96
+ }
97
+ return createApiClient(worker);
98
+ }
99
+ program
100
+ .command('list')
101
+ .alias('ls')
102
+ .description('List all workspaces')
103
+ .action(async () => {
104
+ try {
105
+ const client = await getClient();
106
+ const workspaces = await client.listWorkspaces();
107
+ if (workspaces.length === 0) {
108
+ console.log('No workspaces found.');
109
+ return;
110
+ }
111
+ console.log('');
112
+ for (const ws of workspaces) {
113
+ const status = ws.status === 'running' ? '●' : '○';
114
+ console.log(` ${status} ${ws.name}`);
115
+ console.log(` Status: ${ws.status}`);
116
+ if (ws.repo) {
117
+ console.log(` Repo: ${ws.repo}`);
118
+ }
119
+ console.log(` SSH Port: ${ws.ports.ssh}`);
120
+ console.log(` Created: ${new Date(ws.created).toLocaleString()}`);
121
+ console.log('');
122
+ }
123
+ }
124
+ catch (err) {
125
+ handleError(err);
126
+ }
127
+ });
128
+ program
129
+ .command('create <name>')
130
+ .description('Create a new workspace')
131
+ .option('--clone <url>', 'Git repository URL to clone')
132
+ .action(async (name, options) => {
133
+ try {
134
+ const client = await getClient();
135
+ console.log(`Creating workspace '${name}'...`);
136
+ const workspace = await client.createWorkspace({
137
+ name,
138
+ clone: options.clone,
139
+ });
140
+ console.log(`Workspace '${workspace.name}' created.`);
141
+ console.log(` Status: ${workspace.status}`);
142
+ console.log(` SSH Port: ${workspace.ports.ssh}`);
143
+ }
144
+ catch (err) {
145
+ handleError(err);
146
+ }
147
+ });
148
+ program
149
+ .command('start <name>')
150
+ .description('Start a stopped workspace')
151
+ .action(async (name) => {
152
+ try {
153
+ const client = await getClient();
154
+ console.log(`Starting workspace '${name}'...`);
155
+ const workspace = await client.startWorkspace(name);
156
+ console.log(`Workspace '${workspace.name}' started.`);
157
+ console.log(` Status: ${workspace.status}`);
158
+ }
159
+ catch (err) {
160
+ handleError(err);
161
+ }
162
+ });
163
+ program
164
+ .command('stop <name>')
165
+ .description('Stop a running workspace')
166
+ .action(async (name) => {
167
+ try {
168
+ const client = await getClient();
169
+ console.log(`Stopping workspace '${name}'...`);
170
+ const workspace = await client.stopWorkspace(name);
171
+ console.log(`Workspace '${workspace.name}' stopped.`);
172
+ }
173
+ catch (err) {
174
+ handleError(err);
175
+ }
176
+ });
177
+ program
178
+ .command('delete <name>')
179
+ .alias('rm')
180
+ .description('Delete a workspace')
181
+ .action(async (name) => {
182
+ try {
183
+ const client = await getClient();
184
+ console.log(`Deleting workspace '${name}'...`);
185
+ await client.deleteWorkspace(name);
186
+ console.log(`Workspace '${name}' deleted.`);
187
+ }
188
+ catch (err) {
189
+ handleError(err);
190
+ }
191
+ });
192
+ program
193
+ .command('info [name]')
194
+ .description('Show workspace or agent info')
195
+ .action(async (name) => {
196
+ try {
197
+ const client = await getClient();
198
+ if (name) {
199
+ const workspace = await client.getWorkspace(name);
200
+ console.log(`Workspace: ${workspace.name}`);
201
+ console.log(` Status: ${workspace.status}`);
202
+ console.log(` Container ID: ${workspace.containerId.slice(0, 12)}`);
203
+ if (workspace.repo) {
204
+ console.log(` Repo: ${workspace.repo}`);
205
+ }
206
+ console.log(` SSH Port: ${workspace.ports.ssh}`);
207
+ console.log(` Created: ${new Date(workspace.created).toLocaleString()}`);
208
+ }
209
+ else {
210
+ const info = await client.info();
211
+ console.log(`Agent Info:`);
212
+ console.log(` Hostname: ${info.hostname}`);
213
+ console.log(` Uptime: ${formatUptime(info.uptime)}`);
214
+ console.log(` Workspaces: ${info.workspacesCount}`);
215
+ console.log(` Docker: ${info.dockerVersion}`);
216
+ }
217
+ }
218
+ catch (err) {
219
+ handleError(err);
220
+ }
221
+ });
222
+ program
223
+ .command('logs <name>')
224
+ .description('Show workspace logs')
225
+ .option('-n, --tail <lines>', 'Number of lines to show', '100')
226
+ .action(async (name, options) => {
227
+ try {
228
+ const client = await getClient();
229
+ const logs = await client.getLogs(name, parseInt(options.tail, 10));
230
+ console.log(logs);
231
+ }
232
+ catch (err) {
233
+ handleError(err);
234
+ }
235
+ });
236
+ program
237
+ .command('sync <name>')
238
+ .description('Sync credentials and files to a running workspace')
239
+ .action(async (name) => {
240
+ try {
241
+ const client = await getClient();
242
+ console.log(`Syncing credentials to workspace '${name}'...`);
243
+ await client.syncWorkspace(name);
244
+ console.log(`Workspace '${name}' synced.`);
245
+ }
246
+ catch (err) {
247
+ handleError(err);
248
+ }
249
+ });
250
+ program
251
+ .command('shell <name>')
252
+ .description('Open interactive terminal to workspace')
253
+ .action(async (name) => {
254
+ try {
255
+ const worker = await getWorkerWithFallback();
256
+ const client = await getClient();
257
+ const workspace = await client.getWorkspace(name);
258
+ if (workspace.status !== 'running') {
259
+ console.error(`Workspace '${name}' is not running (status: ${workspace.status})`);
260
+ process.exit(1);
261
+ }
262
+ if (isLocalWorker(worker)) {
263
+ const containerName = getContainerName(name);
264
+ await openDockerExec({
265
+ containerName,
266
+ onError: (err) => {
267
+ console.error(`\nConnection error: ${err.message}`);
268
+ },
269
+ });
270
+ }
271
+ else {
272
+ const wsUrl = getTerminalWSUrl(worker, name);
273
+ await openWSShell({
274
+ url: wsUrl,
275
+ onError: (err) => {
276
+ console.error(`\nConnection error: ${err.message}`);
277
+ },
278
+ });
279
+ }
280
+ }
281
+ catch (err) {
282
+ handleError(err);
283
+ }
284
+ });
285
+ async function getWorkerWithFallback() {
286
+ let worker = await getWorker();
287
+ if (!worker) {
288
+ const localRunning = await checkLocalAgent();
289
+ if (localRunning) {
290
+ worker = `localhost:${DEFAULT_AGENT_PORT}`;
291
+ }
292
+ else {
293
+ console.error('No worker configured. Run: perry config worker <hostname>');
294
+ process.exit(1);
295
+ }
296
+ }
297
+ return worker;
298
+ }
299
+ program
300
+ .command('proxy <name> [ports...]')
301
+ .description('Forward ports from workspace to local machine')
302
+ .action(async (name, ports) => {
303
+ try {
304
+ const worker = await getWorkerWithFallback();
305
+ const client = await getClient();
306
+ const workspace = await client.getWorkspace(name);
307
+ if (workspace.status !== 'running') {
308
+ console.error(`Workspace '${name}' is not running (status: ${workspace.status})`);
309
+ process.exit(1);
310
+ }
311
+ if (ports.length === 0) {
312
+ console.log(`Workspace '${name}' is running.`);
313
+ console.log('');
314
+ console.log('Usage: perry proxy <name> <port> [<port>...]');
315
+ console.log(' Examples:');
316
+ console.log(' perry proxy alpha 3000 # Forward port 3000');
317
+ console.log(' perry proxy alpha 8080:3000 # Forward local 8080 to remote 3000');
318
+ console.log(' perry proxy alpha 3000 5173 # Forward multiple ports');
319
+ return;
320
+ }
321
+ if (isLocalWorker(worker)) {
322
+ const containerName = getContainerName(name);
323
+ const containerIp = await getContainerIp(containerName);
324
+ if (!containerIp) {
325
+ console.error(`Could not get IP for container '${containerName}'`);
326
+ process.exit(1);
327
+ }
328
+ const forwards = ports.map(parseDockerPortForward);
329
+ console.log(`Forwarding ports: ${formatDockerPortForwards(forwards)}`);
330
+ console.log(`Container IP: ${containerIp}`);
331
+ console.log('Press Ctrl+C to stop.');
332
+ console.log('');
333
+ const cleanup = await startDockerProxy({
334
+ containerIp,
335
+ forwards,
336
+ onConnect: (port) => {
337
+ console.log(`Listening on 0.0.0.0:${port}`);
338
+ },
339
+ onError: (err) => {
340
+ console.error(`Proxy error: ${err.message}`);
341
+ },
342
+ });
343
+ const handleSignal = () => {
344
+ console.log('\nStopping proxy...');
345
+ cleanup();
346
+ process.exit(0);
347
+ };
348
+ process.on('SIGINT', handleSignal);
349
+ process.on('SIGTERM', handleSignal);
350
+ await new Promise(() => { });
351
+ }
352
+ else {
353
+ const forwards = ports.map(parsePortForward);
354
+ console.log(`Forwarding ports: ${formatPortForwards(forwards)}`);
355
+ console.log('Press Ctrl+C to stop.');
356
+ console.log('');
357
+ await startProxy({
358
+ worker,
359
+ sshPort: workspace.ports.ssh,
360
+ forwards,
361
+ onConnect: () => {
362
+ console.log('Connected. Ports are now forwarded.');
363
+ },
364
+ onDisconnect: (code) => {
365
+ console.log(`\nDisconnected (exit code: ${code})`);
366
+ },
367
+ onError: (err) => {
368
+ console.error(`\nConnection error: ${err.message}`);
369
+ },
370
+ });
371
+ }
372
+ }
373
+ catch (err) {
374
+ handleError(err);
375
+ }
376
+ });
377
+ const configCmd = program.command('config').description('Manage configuration');
378
+ configCmd
379
+ .command('show')
380
+ .description('Show current configuration')
381
+ .action(async () => {
382
+ const clientConfig = await loadClientConfig();
383
+ const configDir = getConfigDir();
384
+ console.log('Client Configuration:');
385
+ console.log(` Config Dir: ${configDir}`);
386
+ console.log(` Worker: ${clientConfig?.worker || '(not set)'}`);
387
+ });
388
+ configCmd
389
+ .command('worker [hostname]')
390
+ .description('Get or set the worker hostname')
391
+ .action(async (hostname) => {
392
+ if (hostname) {
393
+ await setWorker(hostname);
394
+ console.log(`Worker set to: ${hostname}`);
395
+ }
396
+ else {
397
+ const worker = await getWorker();
398
+ if (worker) {
399
+ console.log(worker);
400
+ }
401
+ else {
402
+ console.log('No worker configured.');
403
+ }
404
+ }
405
+ });
406
+ configCmd
407
+ .command('agent')
408
+ .description('Show agent configuration')
409
+ .action(async () => {
410
+ const configDir = getConfigDir();
411
+ await ensureConfigDir(configDir);
412
+ const config = await loadAgentConfig(configDir);
413
+ console.log('Agent Configuration:');
414
+ console.log(` Port: ${config.port}`);
415
+ console.log(` Environment Variables: ${Object.keys(config.credentials.env).length}`);
416
+ for (const key of Object.keys(config.credentials.env)) {
417
+ console.log(` - ${key}`);
418
+ }
419
+ console.log(` Credential Files: ${Object.keys(config.credentials.files).length}`);
420
+ for (const [dest, src] of Object.entries(config.credentials.files)) {
421
+ console.log(` - ${dest} <- ${src}`);
422
+ }
423
+ if (config.scripts.post_start) {
424
+ console.log(` Post-start Script: ${config.scripts.post_start}`);
425
+ }
426
+ });
427
+ program
428
+ .command('build')
429
+ .description('Build the workspace Docker image')
430
+ .option('--no-cache', 'Build without cache')
431
+ .action(async (options) => {
432
+ const buildContext = './perry';
433
+ console.log(`Building workspace image ${WORKSPACE_IMAGE}...`);
434
+ try {
435
+ await buildImage(WORKSPACE_IMAGE, buildContext, {
436
+ noCache: options.noCache === false ? false : !options.cache,
437
+ });
438
+ console.log('Build complete.');
439
+ }
440
+ catch (err) {
441
+ console.error('Build failed:', err instanceof Error ? err.message : err);
442
+ process.exit(1);
443
+ }
444
+ });
445
+ function handleError(err) {
446
+ if (err instanceof ApiClientError) {
447
+ console.error(`Error: ${err.message}`);
448
+ if (err.code === 'CONNECTION_FAILED') {
449
+ console.error('Make sure the agent is running and accessible.');
450
+ }
451
+ }
452
+ else if (err instanceof Error) {
453
+ console.error(`Error: ${err.message}`);
454
+ }
455
+ else {
456
+ console.error('An unknown error occurred');
457
+ }
458
+ process.exit(1);
459
+ }
460
+ function formatUptime(seconds) {
461
+ const days = Math.floor(seconds / 86400);
462
+ const hours = Math.floor((seconds % 86400) / 3600);
463
+ const minutes = Math.floor((seconds % 3600) / 60);
464
+ const parts = [];
465
+ if (days > 0)
466
+ parts.push(`${days}d`);
467
+ if (hours > 0)
468
+ parts.push(`${hours}h`);
469
+ if (minutes > 0)
470
+ parts.push(`${minutes}m`);
471
+ if (parts.length === 0)
472
+ parts.push(`${seconds}s`);
473
+ return parts.join(' ');
474
+ }
475
+ program.parse();
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './parser';
@@ -0,0 +1,55 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ function getStorePath(stateDir) {
4
+ return join(stateDir, 'session-names.json');
5
+ }
6
+ function makeKey(workspaceName, sessionId) {
7
+ return `${workspaceName}:${sessionId}`;
8
+ }
9
+ async function loadStore(stateDir) {
10
+ const storePath = getStorePath(stateDir);
11
+ try {
12
+ const content = await readFile(storePath, 'utf-8');
13
+ return JSON.parse(content);
14
+ }
15
+ catch {
16
+ return { version: 1, names: {} };
17
+ }
18
+ }
19
+ async function saveStore(stateDir, store) {
20
+ const storePath = getStorePath(stateDir);
21
+ await mkdir(dirname(storePath), { recursive: true });
22
+ await writeFile(storePath, JSON.stringify(store, null, 2));
23
+ }
24
+ export async function getSessionName(stateDir, workspaceName, sessionId) {
25
+ const store = await loadStore(stateDir);
26
+ const key = makeKey(workspaceName, sessionId);
27
+ return store.names[key]?.customName ?? null;
28
+ }
29
+ export async function setSessionName(stateDir, workspaceName, sessionId, customName) {
30
+ const store = await loadStore(stateDir);
31
+ const key = makeKey(workspaceName, sessionId);
32
+ store.names[key] = {
33
+ workspaceName,
34
+ sessionId,
35
+ customName,
36
+ updatedAt: new Date().toISOString(),
37
+ };
38
+ await saveStore(stateDir, store);
39
+ }
40
+ export async function deleteSessionName(stateDir, workspaceName, sessionId) {
41
+ const store = await loadStore(stateDir);
42
+ const key = makeKey(workspaceName, sessionId);
43
+ delete store.names[key];
44
+ await saveStore(stateDir, store);
45
+ }
46
+ export async function getSessionNamesForWorkspace(stateDir, workspaceName) {
47
+ const store = await loadStore(stateDir);
48
+ const result = {};
49
+ for (const [_key, record] of Object.entries(store.names)) {
50
+ if (record.workspaceName === workspaceName) {
51
+ result[record.sessionId] = record.customName;
52
+ }
53
+ }
54
+ return result;
55
+ }