shellbase 0.2.0 → 0.3.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/README.md +32 -12
- package/dist/agent.js +52 -18
- package/dist/cli.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,11 +41,30 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
|
|
|
41
41
|
`--auto-approve` 를 쓰면 폰에서 접속할 때마다 물어보지 않고 바로 승인해요. 편하지만, 내 계정에
|
|
42
42
|
로그인할 수 있는 사람은 누구나 바로 입력할 수 있게 되니 신뢰할 수 있는 환경에서만 쓰세요.
|
|
43
43
|
|
|
44
|
-
###
|
|
44
|
+
### 여러 세션을 한 프로세스가 관리해요
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
`shellbase start` 를 한 번 실행하면 프로세스 하나가 뜨고, 그 안에서 **터미널(세션)을 여러 개** 관리해요.
|
|
47
|
+
폰에서 `+ 새 세션` 으로 늘려도 프로세스는 그대로 하나입니다.
|
|
48
|
+
|
|
49
|
+
- 세션 2개 기준 메모리 **약 85MB** (예전엔 세션마다 프로세스를 띄워서 200MB였어요)
|
|
50
|
+
- 실시간 연결도 세션 수와 무관하게 **1개**만 사용해요
|
|
51
|
+
- 프로젝트마다 세션을 하나씩 켜두고 폰 상단 칩으로 왔다갔다 하면 됩니다
|
|
52
|
+
- `Ctrl+C` 를 누르면 그 프로세스의 **모든 세션**이 함께 정상 종료돼요 (열려 있던 목록은 기억해둬요 — 아래 참고)
|
|
53
|
+
- 터미널을 새로 열어 `shellbase start` 를 또 실행해도 됩니다 (프로세스가 하나 더 생기고, 이미 켜져 있는
|
|
54
|
+
세션을 중복으로 복구하지는 않아요)
|
|
55
|
+
|
|
56
|
+
### 재시작하면 세션이 자동으로 돌아와요
|
|
57
|
+
|
|
58
|
+
열려 있던 세션 목록은 `~/.shellbase/open-sessions.json` 에 기록돼요. 컴퓨터·컨테이너가 재시작되거나
|
|
59
|
+
`Ctrl+C` 로 껐다가 `shellbase start` 를 다시 실행하면, **그때 열려 있던 세션들을 자동으로 다시 띄워요.**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
shellbase start # 이전 세션들도 함께 복구
|
|
63
|
+
shellbase start --no-restore # 복구하지 않고 이 폴더 세션 하나만
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- 폰에서 **일부러 끈 세션은 복구 대상에서 빠져요** (다시 열리지 않아요)
|
|
67
|
+
- 폴더가 사라졌거나, 이미 켜져 있는 세션은 조용히 건너뜁니다
|
|
49
68
|
|
|
50
69
|
### 폰에서 세션 열기 · 전환 · 끄기 (컴퓨터에 손 안 대고)
|
|
51
70
|
|
|
@@ -64,14 +83,15 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
|
|
|
64
83
|
- **폰에서 연 세션은 자동 승인으로 열려요.** 컴퓨터 앞에 `y` 를 눌러줄 사람이 없기 때문이에요.
|
|
65
84
|
(요청을 보낸 쪽이 그 세션의 암호화 키를 가지고 있는지 먼저 확인해요 — 그 키는 내 계정만 읽을 수
|
|
66
85
|
있어서, 다른 사람은 남의 세션을 열거나 끌 수 없어요.)
|
|
67
|
-
- **폰에서 연
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
>
|
|
72
|
-
>
|
|
73
|
-
>
|
|
74
|
-
>
|
|
86
|
+
- **폰에서 연 세션도 같은 프로세스가 관리해요.** 그래서 컴퓨터에서 `Ctrl+C` 를 누르면 모든 세션이 함께
|
|
87
|
+
종료돼요(그 대신 다음 실행 때 자동 복구됩니다). 세션을 하나만 끄고 싶으면 폰에서 그 세션의 `✕` 를
|
|
88
|
+
쓰세요 — 나머지 세션은 그대로 유지돼요.
|
|
89
|
+
|
|
90
|
+
> ⚠️ **도커로 돌릴 때, 마지막 세션을 폰에서 끄면 도커가 다시 띄워요.** 세션이 0개가 되면 프로세스가
|
|
91
|
+
> 종료되는데, `--restart unless-stopped` 는 정상 종료(exit 0)에도 컨테이너를 자동 재시작하기 때문이에요
|
|
92
|
+
> (실제로 확인한 동작). 즉 마지막 세션 끄기는 **사실상 재시작**이 됩니다. 세션이 여러 개일 때 하나만
|
|
93
|
+
> 끄는 건 프로세스가 계속 살아있으니 그대로 꺼져요. 컨테이너를 완전히 내리려면 컴퓨터에서
|
|
94
|
+
> `docker stop shellbase-agent` 를 쓰세요.
|
|
75
95
|
|
|
76
96
|
## 참고
|
|
77
97
|
|
package/dist/agent.js
CHANGED
|
@@ -15,12 +15,27 @@ import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
|
15
15
|
const HEARTBEAT_MS = 20_000;
|
|
16
16
|
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
17
17
|
const SCROLLBACK_MAX = 16_000;
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
18
|
+
// 출력 전송 최소 간격. "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서
|
|
19
|
+
// 보낸다"(leading-edge 스로틀). 예전엔 문서의 "최소 100ms" 를 보수적으로 잡아 120ms 로 뒀는데, 실측해보니
|
|
20
|
+
// 서버가 40회 연속 전송도 거부 없이 받아줬고 이 값이 타이핑 체감 지연의 주범이었음(입력·출력 양쪽에서
|
|
21
|
+
// 각각 최대 120ms). 30ms 로 낮춰 체감을 줄이고, 출력이 몰릴 때는 여전히 초당 33회로 묶인다.
|
|
22
|
+
const MIN_SEND_INTERVAL_MS = 30;
|
|
23
|
+
// 그래도 서버가 거부하면(RATE_LIMITED 등) 잠시 간격을 넉넉히 벌려서 재시도한다
|
|
24
|
+
const SEND_PENALTY_MS = 500;
|
|
25
|
+
const PENALTY_INTERVAL_MS = 250;
|
|
22
26
|
// 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
|
|
23
27
|
const MAX_SESSIONS = 20;
|
|
28
|
+
// 폰의 폴더 탐색기를 열 때 "지금 셸이 있는 폴더"에서 시작하려면, 세션을 시작한 폴더가 아니라
|
|
29
|
+
// 셸 프로세스의 실제 작업 폴더를 봐야 한다(사용자가 cd 로 옮겨 다니므로). 리눅스는 /proc 로 바로 알 수 있고,
|
|
30
|
+
// 없는 OS(맥·윈도)에서는 세션 시작 폴더로 되돌아간다.
|
|
31
|
+
function liveCwd(session) {
|
|
32
|
+
try {
|
|
33
|
+
return fs.readlinkSync(`/proc/${session.pty.pid}/cwd`);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return session.cwd;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
24
39
|
function resolveShell() {
|
|
25
40
|
if (process.platform === 'win32')
|
|
26
41
|
return process.env.COMSPEC ?? 'powershell.exe';
|
|
@@ -44,12 +59,19 @@ function readControl(data, key) {
|
|
|
44
59
|
}
|
|
45
60
|
export async function runAgent(options) {
|
|
46
61
|
const { cb, memberId } = await requireClient();
|
|
62
|
+
// 여기서 한 번 호출해서 토큰이 살아있는지 먼저 확인 (만료됐으면 세션을 띄우기 전에 알려주는 게 낫다)
|
|
47
63
|
const me = await cb.auth.getMe();
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
// 입력 승인 정책: 이 도구의 보안 경계는 "내 ConnectBase 계정(구글 로그인)" 이다. 화면 출력은 원래부터
|
|
65
|
+
// 계정 주인만 볼 수 있었고(세션 키가 RLS 로 보호되는 내 row 에만 있음), 실제로도 화면만 봐도 민감한
|
|
66
|
+
// 내용은 다 보인다. 그래서 기본값은 "내 계정으로 들어왔으면 타이핑도 허용" — 화면 앞에 사람이 없는
|
|
67
|
+
// 서버·도커에서는 애초에 승인할 방법도 없었다. 노트북처럼 한 단계 더 두고 싶은 경우에만
|
|
68
|
+
// --require-approval 로 예전처럼 매번 물어보게 할 수 있다.
|
|
69
|
+
const requireApproval = options.requireApproval === true;
|
|
50
70
|
const primaryCwd = options.dir ? path.resolve(options.dir) : process.cwd();
|
|
51
71
|
// 이름 앞부분(컴퓨터를 가리키는 부분) — 이 프로세스가 나중에 만드는 세션들이 물려받는다.
|
|
52
72
|
// 도커 안에서는 os.hostname() 이 컨테이너 ID라서, --name 을 준 경우 그걸 쓰는 게 알아보기 좋음.
|
|
73
|
+
// 폰 목록은 이 이름의 앞부분("컴퓨터 · 폴더" 의 앞쪽)을 기준으로 세션들을 한 컴퓨터로 묶는다.
|
|
74
|
+
// (devices 테이블에 host_id 같은 필드를 새로 넣으려면 테이블 스키마 변경이 필요해서, 지금은 이름 기준)
|
|
53
75
|
const hostLabel = options.name ?? os.hostname();
|
|
54
76
|
const shell = resolveShell();
|
|
55
77
|
const sessions = new Map();
|
|
@@ -76,16 +98,27 @@ export async function runAgent(options) {
|
|
|
76
98
|
const chunk = session.outBuffer;
|
|
77
99
|
session.outBuffer = '';
|
|
78
100
|
session.lastSentAt = Date.now();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
try {
|
|
102
|
+
await channel.send({
|
|
103
|
+
kind: 'output',
|
|
104
|
+
to: session.deviceId,
|
|
105
|
+
data: encryptFrame(chunk, session.frameKey),
|
|
106
|
+
}, { includeSelf: false });
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
// 실패한 출력을 그냥 버리면 폰 화면이 깨진 채로 남으니, 다음 전송 앞에 다시 붙여 순서대로 재시도한다
|
|
110
|
+
session.outBuffer = chunk + session.outBuffer;
|
|
111
|
+
session.sendPenaltyUntil = Date.now() + SEND_PENALTY_MS;
|
|
112
|
+
console.error('출력 전송 실패, 다시 시도해요:', err.message);
|
|
113
|
+
if (!session.closing)
|
|
114
|
+
scheduleFlush(session);
|
|
115
|
+
}
|
|
84
116
|
};
|
|
85
117
|
const scheduleFlush = (session) => {
|
|
86
118
|
if (session.pendingFlush !== null)
|
|
87
119
|
return;
|
|
88
|
-
const
|
|
120
|
+
const interval = Date.now() < session.sendPenaltyUntil ? PENALTY_INTERVAL_MS : MIN_SEND_INTERVAL_MS;
|
|
121
|
+
const wait = Math.max(0, interval - (Date.now() - session.lastSentAt));
|
|
89
122
|
session.pendingFlush = setTimeout(() => void flush(session), wait);
|
|
90
123
|
};
|
|
91
124
|
// stdin 은 프로세스에 하나뿐이라, 세션이 여러 개일 때 승인 프롬프트가 겹치지 않게 한 번에 하나씩 묻는다
|
|
@@ -96,7 +129,7 @@ export async function runAgent(options) {
|
|
|
96
129
|
.then(async () => {
|
|
97
130
|
if (session.closing)
|
|
98
131
|
return;
|
|
99
|
-
const approved =
|
|
132
|
+
const approved = !requireApproval
|
|
100
133
|
? true
|
|
101
134
|
: await promptApproval(`\n📱 "${session.name}" 세션에 모바일 접속 요청이 왔어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): `, APPROVAL_TIMEOUT_MS);
|
|
102
135
|
session.inputApproved = approved;
|
|
@@ -143,6 +176,7 @@ export async function runAgent(options) {
|
|
|
143
176
|
approvalInFlight: false,
|
|
144
177
|
outBuffer: '',
|
|
145
178
|
lastSentAt: 0,
|
|
179
|
+
sendPenaltyUntil: 0,
|
|
146
180
|
pendingFlush: null,
|
|
147
181
|
closing: false,
|
|
148
182
|
};
|
|
@@ -285,7 +319,7 @@ export async function runAgent(options) {
|
|
|
285
319
|
const control = readControl(frame.data, session.frameKey);
|
|
286
320
|
if (!control)
|
|
287
321
|
return;
|
|
288
|
-
const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : session
|
|
322
|
+
const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : liveCwd(session));
|
|
289
323
|
void send({
|
|
290
324
|
kind: 'dirs',
|
|
291
325
|
to: session.deviceId,
|
|
@@ -345,10 +379,10 @@ export async function runAgent(options) {
|
|
|
345
379
|
const savedRecords = options.restore === false ? [] : loadOpenSessions();
|
|
346
380
|
// ── 첫 세션(사용자가 실행한 그 폴더)
|
|
347
381
|
const primary = await createSession(primaryCwd, options.name ?? defaultName(primaryCwd));
|
|
348
|
-
console.log(`"${primary.name}" 세션을 시작했어요 (작업 폴더: ${primary.cwd})`);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
382
|
+
console.log(`"${primary.name}" 세션을 시작했어요 (작업 폴더: ${primary.cwd}, 계정: ${me.nickname})`);
|
|
383
|
+
console.log(requireApproval
|
|
384
|
+
? '접속할 때마다 이 화면에서 승인(y)을 물어봐요. (--require-approval)'
|
|
385
|
+
: '내 계정으로 로그인한 폰에서는 바로 입력할 수 있어요. 매번 확인받고 싶으면 --require-approval 로 실행하세요.');
|
|
352
386
|
// ── 지난번에 열려 있던 세션 복구 (컴퓨터·컨테이너 재시작 후 자동으로 원래 상태로)
|
|
353
387
|
if (options.restore === false) {
|
|
354
388
|
console.log('이전 세션 복구는 건너뛰어요 (--no-restore)');
|
package/dist/cli.js
CHANGED
|
@@ -36,7 +36,8 @@ program
|
|
|
36
36
|
.description('이 컴퓨터의 터미널을 열어서 폰 접속을 받을 수 있게 시작 (한 프로세스가 여러 세션을 관리해요)')
|
|
37
37
|
.option('--dir <path>', '이 폴더에서 터미널을 시작해요 (기본: 현재 폴더)')
|
|
38
38
|
.option('--name <name>', '폰에서 보여줄 이 기기의 이름 (기본: 컴퓨터 이름)')
|
|
39
|
-
.option('--
|
|
39
|
+
.option('--require-approval', '폰에서 접속할 때마다 이 컴퓨터에서 y 로 승인해야 입력이 가능해요 (기본: 내 계정이면 바로 허용)')
|
|
40
|
+
.option('--auto-approve', '(옛 옵션 — 이제 기본 동작이라 넣지 않아도 같아요)')
|
|
40
41
|
.option('--no-restore', '지난번에 열려 있던 세션들을 자동으로 다시 띄우지 않아요')
|
|
41
42
|
.action(async (opts) => {
|
|
42
43
|
await runAgent(opts);
|