@gricha/perry 0.2.5 → 0.3.0

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.
@@ -0,0 +1,151 @@
1
+ import { createTerminalSession } from './handler';
2
+ import { createHostTerminalSession } from './host-handler';
3
+ import { isControlMessage } from './types';
4
+ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
5
+ export class TerminalHandler {
6
+ connections = new Map();
7
+ getContainerName;
8
+ isHostAccessAllowed;
9
+ getPreferredShell;
10
+ constructor(options) {
11
+ this.getContainerName = options.getContainerName;
12
+ this.isHostAccessAllowed = options.isHostAccessAllowed || (() => false);
13
+ this.getPreferredShell = options.getPreferredShell || (() => undefined);
14
+ }
15
+ handleOpen(ws, workspaceName) {
16
+ const isHostMode = workspaceName === HOST_WORKSPACE_NAME;
17
+ if (isHostMode && !this.isHostAccessAllowed()) {
18
+ ws.close(4003, 'Host access is disabled');
19
+ return;
20
+ }
21
+ const connection = {
22
+ ws,
23
+ session: null,
24
+ workspaceName,
25
+ started: false,
26
+ };
27
+ this.connections.set(ws, connection);
28
+ }
29
+ handleMessage(ws, data) {
30
+ const connection = this.connections.get(ws);
31
+ if (!connection)
32
+ return;
33
+ if (data.startsWith('{')) {
34
+ try {
35
+ const message = JSON.parse(data);
36
+ if (isControlMessage(message)) {
37
+ if (!connection.started) {
38
+ this.startSession(connection, message.cols, message.rows);
39
+ }
40
+ else if (connection.session) {
41
+ connection.session.resize({ cols: message.cols, rows: message.rows });
42
+ }
43
+ return;
44
+ }
45
+ }
46
+ catch {
47
+ // Not valid JSON control message, pass through as input
48
+ }
49
+ }
50
+ if (connection.session) {
51
+ connection.session.write(data);
52
+ }
53
+ }
54
+ handleClose(ws, _code, _reason) {
55
+ const connection = this.connections.get(ws);
56
+ if (connection?.session) {
57
+ connection.session.kill();
58
+ }
59
+ this.connections.delete(ws);
60
+ }
61
+ handleError(ws, error) {
62
+ console.error('WebSocket error:', error);
63
+ const connection = this.connections.get(ws);
64
+ if (connection?.session) {
65
+ connection.session.kill();
66
+ }
67
+ this.connections.delete(ws);
68
+ }
69
+ startSession(connection, cols, rows) {
70
+ if (connection.started)
71
+ return;
72
+ connection.started = true;
73
+ const { ws, workspaceName } = connection;
74
+ const isHostMode = workspaceName === HOST_WORKSPACE_NAME;
75
+ const preferredShell = this.getPreferredShell();
76
+ let session;
77
+ if (isHostMode) {
78
+ session = createHostTerminalSession({
79
+ size: { cols, rows },
80
+ shell: preferredShell,
81
+ });
82
+ }
83
+ else {
84
+ const containerName = this.getContainerName(workspaceName);
85
+ session = createTerminalSession({
86
+ containerName,
87
+ user: 'workspace',
88
+ size: { cols, rows },
89
+ shell: preferredShell,
90
+ });
91
+ }
92
+ connection.session = session;
93
+ session.setOnData((data) => {
94
+ try {
95
+ ws.send(data);
96
+ }
97
+ catch {
98
+ // WebSocket might be closed
99
+ }
100
+ });
101
+ session.setOnExit((code) => {
102
+ try {
103
+ ws.close(1000, `Process exited with code ${code}`);
104
+ }
105
+ catch {
106
+ // WebSocket might be closed
107
+ }
108
+ this.connections.delete(ws);
109
+ });
110
+ try {
111
+ session.start();
112
+ }
113
+ catch (err) {
114
+ console.error('Failed to start terminal session:', err);
115
+ ws.close(1011, 'Failed to start terminal');
116
+ this.connections.delete(ws);
117
+ }
118
+ }
119
+ getConnectionCount() {
120
+ return this.connections.size;
121
+ }
122
+ getConnectionsForWorkspace(workspaceName) {
123
+ let count = 0;
124
+ for (const conn of this.connections.values()) {
125
+ if (conn.workspaceName === workspaceName) {
126
+ count++;
127
+ }
128
+ }
129
+ return count;
130
+ }
131
+ closeConnectionsForWorkspace(workspaceName) {
132
+ for (const [ws, conn] of this.connections.entries()) {
133
+ if (conn.workspaceName === workspaceName) {
134
+ if (conn.session) {
135
+ conn.session.kill();
136
+ }
137
+ ws.close(1001, 'Workspace stopped');
138
+ this.connections.delete(ws);
139
+ }
140
+ }
141
+ }
142
+ close() {
143
+ for (const [ws, conn] of this.connections.entries()) {
144
+ if (conn.session) {
145
+ conn.session.kill();
146
+ }
147
+ ws.close(1001, 'Server shutting down');
148
+ }
149
+ this.connections.clear();
150
+ }
151
+ }
@@ -1,9 +1,25 @@
1
+ import { spawnSync } from 'child_process';
1
2
  import { BaseTerminalSession } from './base-handler';
3
+ function shellExistsInContainer(containerName, shell) {
4
+ const result = spawnSync('docker', ['exec', containerName, 'test', '-x', shell], {
5
+ timeout: 5000,
6
+ });
7
+ return result.status === 0;
8
+ }
9
+ function resolveShell(containerName, preferred) {
10
+ const fallback = '/bin/bash';
11
+ if (!preferred)
12
+ return fallback;
13
+ if (shellExistsInContainer(containerName, preferred))
14
+ return preferred;
15
+ return fallback;
16
+ }
2
17
  export class TerminalSession extends BaseTerminalSession {
3
18
  containerName;
4
19
  user;
5
20
  constructor(options) {
6
- super(options.shell || '/bin/bash', options.size);
21
+ const shell = resolveShell(options.containerName, options.shell);
22
+ super(shell, options.size);
7
23
  this.containerName = options.containerName;
8
24
  this.user = options.user || 'workspace';
9
25
  }
@@ -1,9 +1,27 @@
1
+ import { accessSync, constants } from 'fs';
1
2
  import { BaseTerminalSession } from './base-handler';
2
3
  import { homedir } from 'os';
4
+ function shellExistsOnHost(shell) {
5
+ try {
6
+ accessSync(shell, constants.X_OK);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ function resolveHostShell(preferred) {
14
+ const fallback = '/bin/bash';
15
+ if (!preferred)
16
+ return process.env.SHELL || fallback;
17
+ if (shellExistsOnHost(preferred))
18
+ return preferred;
19
+ return process.env.SHELL || fallback;
20
+ }
3
21
  export class HostTerminalSession extends BaseTerminalSession {
4
22
  workDir;
5
23
  constructor(options = {}) {
6
- super(options.shell || process.env.SHELL || '/bin/bash', options.size);
24
+ super(resolveHostShell(options.shell), options.size);
7
25
  this.workDir = options.workDir || homedir();
8
26
  }
9
27
  getSpawnConfig() {
@@ -7,10 +7,12 @@ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
7
7
  export class TerminalWebSocketServer extends BaseWebSocketServer {
8
8
  getContainerName;
9
9
  isHostAccessAllowed;
10
+ getPreferredShell;
10
11
  constructor(options) {
11
12
  super({ isWorkspaceRunning: options.isWorkspaceRunning });
12
13
  this.getContainerName = options.getContainerName;
13
14
  this.isHostAccessAllowed = options.isHostAccessAllowed || (() => false);
15
+ this.getPreferredShell = options.getPreferredShell || (() => undefined);
14
16
  }
15
17
  handleConnection(ws, workspaceName) {
16
18
  const isHostMode = workspaceName === HOST_WORKSPACE_NAME;
@@ -24,9 +26,11 @@ export class TerminalWebSocketServer extends BaseWebSocketServer {
24
26
  if (started)
25
27
  return;
26
28
  started = true;
29
+ const preferredShell = this.getPreferredShell();
27
30
  if (isHostMode) {
28
31
  session = createHostTerminalSession({
29
32
  size: { cols, rows },
33
+ shell: preferredShell,
30
34
  });
31
35
  }
32
36
  else {
@@ -35,6 +39,7 @@ export class TerminalWebSocketServer extends BaseWebSocketServer {
35
39
  containerName,
36
40
  user: 'workspace',
37
41
  size: { cols, rows },
42
+ shell: preferredShell,
38
43
  });
39
44
  }
40
45
  const connection = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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": {
@@ -18,8 +18,8 @@
18
18
  "test:web": "playwright test",
19
19
  "test:tui": "vitest run --config vitest.tui.config.js",
20
20
  "test:watch": "vitest",
21
- "lint": "oxlint --deny-warnings src/",
22
- "lint:fix": "oxlint --fix src/",
21
+ "lint": "oxlint --deny-warnings src/ mobile/src/",
22
+ "lint:fix": "oxlint --fix src/ mobile/src/",
23
23
  "format": "oxfmt --write src/ test/",
24
24
  "format:check": "oxfmt --check src/ test/",
25
25
  "check": "bun run lint && bun run format:check && bun x tsc --noEmit",