@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 CHANGED
@@ -5,6 +5,7 @@
5
5
  <h1 align="center">Perry</h1>
6
6
 
7
7
  <p align="center">
8
+ <a href="https://gricha.github.io/perry/"><img src="https://img.shields.io/badge/docs-docusaurus-blue" alt="Documentation"></a>
8
9
  <a href="https://github.com/gricha/perry/actions/workflows/test.yml"><img src="https://github.com/gricha/perry/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
9
10
  <a href="https://github.com/gricha/perry/releases"><img src="https://img.shields.io/github/v/release/gricha/perry" alt="Release"></a>
10
11
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
@@ -28,6 +29,8 @@ It can be connected directly to your host, or it can create docker containers so
28
29
 
29
30
  Continue your sessions on the go!
30
31
 
32
+ **[Get Started →](https://gricha.github.io/perry/docs/getting-started)**
33
+
31
34
  ## Features
32
35
 
33
36
  - **AI Coding Agents** - Claude Code, OpenCode, Codex CLI pre-installed
@@ -1,12 +1,8 @@
1
1
  import { watch } from 'fs';
2
2
  import { access } from 'fs/promises';
3
3
  import { expandPath } from '../config/loader';
4
- const STANDARD_CREDENTIAL_FILES = [
5
- '~/.gitconfig',
6
- '~/.claude/.credentials.json',
7
- '~/.codex/auth.json',
8
- '~/.codex/config.toml',
9
- ];
4
+ import { getCredentialFilePaths } from '../agents';
5
+ const STANDARD_CREDENTIAL_FILES = ['~/.gitconfig', ...getCredentialFilePaths()];
10
6
  export class FileWatcher {
11
7
  watchers = new Map();
12
8
  config;
@@ -16,6 +16,7 @@ const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']
16
16
  const WorkspacePortsSchema = z.object({
17
17
  ssh: z.number(),
18
18
  http: z.number().optional(),
19
+ forwards: z.array(z.number()).optional(),
19
20
  });
20
21
  const WorkspaceInfoSchema = z.object({
21
22
  name: z.string(),
@@ -31,7 +32,8 @@ const CredentialsSchema = z.object({
31
32
  files: z.record(z.string(), z.string()),
32
33
  });
33
34
  const ScriptsSchema = z.object({
34
- post_start: z.string().optional(),
35
+ post_start: z.array(z.string()).optional(),
36
+ fail_on_error: z.boolean().optional(),
35
37
  });
36
38
  const CodingAgentsSchema = z.object({
37
39
  opencode: z
@@ -193,6 +195,43 @@ export function createRouter(ctx) {
193
195
  }
194
196
  return workspace;
195
197
  });
198
+ const getPortForwards = os
199
+ .input(z.object({ name: z.string() }))
200
+ .output(z.object({ forwards: z.array(z.number()) }))
201
+ .handler(async ({ input }) => {
202
+ try {
203
+ const forwards = await ctx.workspaces.getPortForwards(input.name);
204
+ return { forwards };
205
+ }
206
+ catch (err) {
207
+ mapErrorToORPC(err, 'Failed to get port forwards');
208
+ }
209
+ });
210
+ const setPortForwards = os
211
+ .input(z.object({ name: z.string(), forwards: z.array(z.number().int().min(1).max(65535)) }))
212
+ .output(WorkspaceInfoSchema)
213
+ .handler(async ({ input }) => {
214
+ try {
215
+ return await ctx.workspaces.setPortForwards(input.name, input.forwards);
216
+ }
217
+ catch (err) {
218
+ mapErrorToORPC(err, 'Failed to set port forwards');
219
+ }
220
+ });
221
+ const cloneWorkspace = os
222
+ .input(z.object({
223
+ sourceName: z.string(),
224
+ cloneName: z.string(),
225
+ }))
226
+ .output(WorkspaceInfoSchema)
227
+ .handler(async ({ input }) => {
228
+ try {
229
+ return await ctx.workspaces.clone(input.sourceName, input.cloneName);
230
+ }
231
+ catch (err) {
232
+ mapErrorToORPC(err, 'Failed to clone workspace');
233
+ }
234
+ });
196
235
  const getInfo = os.handler(async () => {
197
236
  let dockerVersion = 'unknown';
198
237
  try {
@@ -275,6 +314,76 @@ export function createRouter(ctx) {
275
314
  const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
276
315
  return discoverSSHKeys();
277
316
  });
317
+ const GitHubRepoSchema = z.object({
318
+ name: z.string(),
319
+ fullName: z.string(),
320
+ cloneUrl: z.string(),
321
+ sshUrl: z.string(),
322
+ private: z.boolean(),
323
+ description: z.string().nullable(),
324
+ updatedAt: z.string(),
325
+ });
326
+ const listGitHubRepos = os
327
+ .input(z.object({
328
+ search: z.string().optional(),
329
+ perPage: z.number().optional().default(30),
330
+ page: z.number().optional().default(1),
331
+ }))
332
+ .output(z.object({
333
+ configured: z.boolean(),
334
+ repos: z.array(GitHubRepoSchema),
335
+ hasMore: z.boolean(),
336
+ }))
337
+ .handler(async ({ input }) => {
338
+ const config = ctx.config.get();
339
+ const token = config.agents?.github?.token;
340
+ if (!token) {
341
+ return { configured: false, repos: [], hasMore: false };
342
+ }
343
+ try {
344
+ const params = new URLSearchParams({
345
+ per_page: String(input.perPage),
346
+ page: String(input.page),
347
+ sort: 'updated',
348
+ direction: 'desc',
349
+ });
350
+ const url = input.search
351
+ ? `https://api.github.com/search/repositories?q=${encodeURIComponent(input.search)}+user:@me&${params}`
352
+ : `https://api.github.com/user/repos?${params}`;
353
+ const response = await fetch(url, {
354
+ headers: {
355
+ Authorization: `Bearer ${token}`,
356
+ Accept: 'application/vnd.github+json',
357
+ 'X-GitHub-Api-Version': '2022-11-28',
358
+ },
359
+ });
360
+ if (!response.ok) {
361
+ if (response.status === 401) {
362
+ return { configured: false, repos: [], hasMore: false };
363
+ }
364
+ throw new Error(`GitHub API error: ${response.status}`);
365
+ }
366
+ const data = await response.json();
367
+ const items = input.search ? data.items : data;
368
+ const repos = items.map((repo) => ({
369
+ name: repo.name,
370
+ fullName: repo.full_name,
371
+ cloneUrl: repo.clone_url,
372
+ sshUrl: repo.ssh_url,
373
+ private: repo.private,
374
+ description: repo.description,
375
+ updatedAt: repo.updated_at,
376
+ }));
377
+ const linkHeader = response.headers.get('Link');
378
+ const hasMore = linkHeader?.includes('rel="next"') ?? false;
379
+ return { configured: true, repos, hasMore };
380
+ }
381
+ catch (err) {
382
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
383
+ message: `Failed to fetch GitHub repos: ${err.message}`,
384
+ });
385
+ }
386
+ });
278
387
  async function listHostSessions(input) {
279
388
  const limit = input.limit ?? 50;
280
389
  const offset = input.offset ?? 0;
@@ -777,20 +886,6 @@ export function createRouter(ctx) {
777
886
  homeDir: os_module.homedir(),
778
887
  };
779
888
  });
780
- const updateHostAccess = os
781
- .input(z.object({ enabled: z.boolean() }))
782
- .handler(async ({ input }) => {
783
- const currentConfig = ctx.config.get();
784
- const newConfig = { ...currentConfig, allowHostAccess: input.enabled };
785
- ctx.config.set(newConfig);
786
- await saveAgentConfig(newConfig, ctx.configDir);
787
- return {
788
- enabled: input.enabled,
789
- hostname: os_module.hostname(),
790
- username: os_module.userInfo().username,
791
- homeDir: os_module.homedir(),
792
- };
793
- });
794
889
  const listModels = os
795
890
  .input(z.object({
796
891
  agentType: z.enum(['claude-code', 'opencode']),
@@ -850,6 +945,7 @@ export function createRouter(ctx) {
850
945
  list: listWorkspaces,
851
946
  get: getWorkspace,
852
947
  create: createWorkspace,
948
+ clone: cloneWorkspace,
853
949
  delete: deleteWorkspace,
854
950
  start: startWorkspace,
855
951
  stop: stopWorkspace,
@@ -857,6 +953,8 @@ export function createRouter(ctx) {
857
953
  sync: syncWorkspace,
858
954
  syncAll: syncAllWorkspaces,
859
955
  touch: touchWorkspace,
956
+ getPortForwards: getPortForwards,
957
+ setPortForwards: setPortForwards,
860
958
  },
861
959
  sessions: {
862
960
  list: listSessions,
@@ -871,9 +969,11 @@ export function createRouter(ctx) {
871
969
  models: {
872
970
  list: listModels,
873
971
  },
972
+ github: {
973
+ listRepos: listGitHubRepos,
974
+ },
874
975
  host: {
875
976
  info: getHostInfo,
876
- updateAccess: updateHostAccess,
877
977
  },
878
978
  info: getInfo,
879
979
  config: {
package/dist/agent/run.js CHANGED
@@ -5,7 +5,7 @@ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
5
5
  import { DEFAULT_AGENT_PORT } from '../shared/constants';
6
6
  import { WorkspaceManager } from '../workspace/manager';
7
7
  import { containerRunning, getContainerName } from '../docker';
8
- import { startEagerImagePull } from '../docker/eager-pull';
8
+ import { startEagerImagePull, stopEagerImagePull } from '../docker/eager-pull';
9
9
  import { TerminalWebSocketServer } from '../terminal/websocket';
10
10
  import { ChatWebSocketServer } from '../chat/websocket';
11
11
  import { OpencodeWebSocketServer } from '../chat/opencode-websocket';
@@ -245,8 +245,20 @@ export async function startAgent(options = {}) {
245
245
  console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
246
246
  startEagerImagePull();
247
247
  });
248
+ let isShuttingDown = false;
248
249
  const shutdown = async () => {
250
+ if (isShuttingDown) {
251
+ console.log('[agent] Force exit');
252
+ process.exit(0);
253
+ }
254
+ isShuttingDown = true;
249
255
  console.log('[agent] Shutting down...');
256
+ const forceExitTimeout = setTimeout(() => {
257
+ console.log('[agent] Force exit after timeout');
258
+ process.exit(0);
259
+ }, 3000);
260
+ forceExitTimeout.unref();
261
+ stopEagerImagePull();
250
262
  fileWatcher.stop();
251
263
  if (tailscaleServeActive) {
252
264
  console.log('[agent] Stopping Tailscale Serve...');
@@ -255,7 +267,9 @@ export async function startAgent(options = {}) {
255
267
  chatServer.close();
256
268
  opencodeServer.close();
257
269
  terminalServer.close();
270
+ server.closeAllConnections();
258
271
  server.close(() => {
272
+ clearTimeout(forceExitTimeout);
259
273
  console.log('[agent] Server closed');
260
274
  process.exit(0);
261
275
  });