shellbase 0.1.6 → 0.1.8

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
@@ -45,8 +45,33 @@ shellbase start --dir ~/projects/my-app --name "내 노트북"
45
45
 
46
46
  터미널을 새로 열어서 다른 폴더로 `shellbase start` 를 한 번 더 실행하면, 별개의 세션으로 등록돼서
47
47
  폰 목록에 폴더별로 따로 떠요(예: `내PC · project-a`, `내PC · project-b`). 프로젝트마다 세션을 하나씩
48
- 켜두고 폰에서 왔다갔다 접속할 수 있어요. `Ctrl+C` 로 정상 종료하면 그 세션은 목록에서 자동으로
49
- 지워지고, 강제 종료 등으로 남아있는 세션은 폰 목록에서 휴지통 아이콘으로 직접 지울 수 있어요.
48
+ 켜두고 폰에서 왔다갔다 접속할 수 있어요.
49
+
50
+ ### 폰에서 세션 열기 · 전환 · 끄기 (컴퓨터에 손 안 대고)
51
+
52
+ 세션이 **하나라도 켜져 있으면**, 그다음부터는 폰에서 전부 할 수 있어요.
53
+
54
+ | 하고 싶은 것 | 폰에서 하는 법 |
55
+ |---|---|
56
+ | 세션 하나 더 열기 | 목록 화면의 **`+ 새 세션`** → 폴더 고르고 열기 (켜져 있는 세션이 그 컴퓨터에서 하나 더 띄워줘요) |
57
+ | 세션 갈아타기 | 터미널 화면 위쪽의 **기기 이름(▾)** 탭 → 원하는 세션 선택 |
58
+ | 세션 끄기 | 목록의 **전원 아이콘**, 또는 터미널 화면의 기기 이름(▾) → **이 세션 끄기** |
59
+ | 화면만 닫기 | `← 목록` 으로 나가기 — 컴퓨터의 세션은 계속 살아있어요 |
60
+
61
+ - **끄기는 진짜로 꺼요.** 컴퓨터에서 돌아가던 그 터미널이 종료되고 목록에서도 자동으로 사라져요
62
+ (`Ctrl+C` 를 누른 것과 같아요). 프로세스가 죽었는데 목록에만 남아있는 항목은 휴지통 아이콘으로
63
+ 지울 수 있어요 — 그건 목록만 정리하는 버튼이에요.
64
+ - **폰에서 연 세션은 자동 승인으로 열려요.** 컴퓨터 앞에 `y` 를 눌러줄 사람이 없기 때문이에요.
65
+ (요청을 보낸 쪽이 그 세션의 암호화 키를 가지고 있는지 먼저 확인해요 — 그 키는 내 계정만 읽을 수
66
+ 있어서, 다른 사람은 남의 세션을 열거나 끌 수 없어요.)
67
+ - **폰에서 연 세션은 부모 세션과 독립적이에요.** 처음 켠 세션을 `Ctrl+C` 로 꺼도 폰에서 만든 세션은
68
+ 계속 살아있어요 (각각 폰에서 따로 끄면 돼요).
69
+
70
+ > ⚠️ **도커로 띄운 세션을 폰에서 끄면 도커가 다시 살려요.** `--restart unless-stopped` 는 정상 종료
71
+ > (exit 0)에도 컨테이너를 자동 재시작하거든요(실제로 확인한 동작이에요). 그래서 도커로 돌리는 그
72
+ > 세션은 폰에서 끄면 **사실상 재시작**이 되고, 새 이름의 세션으로 목록에 다시 나타나요. 완전히
73
+ > 내리려면 컴퓨터에서 `docker stop shellbase-agent` 를 쓰세요. 폰에서 `+ 새 세션` 으로 만든 세션들은
74
+ > 도커와 무관한 일반 프로세스라 폰에서 끄면 그대로 꺼져요.
50
75
 
51
76
  ## 참고
52
77
 
package/dist/config.js CHANGED
@@ -11,3 +11,7 @@ export const CLI_AUTH_SESSIONS_TABLE_ID = '01a00aa0-802d-7730-88ba-b831b7405188'
11
11
  export const WEB_LOGIN_URL = 'https://shellbase.web.connectbase.world/login';
12
12
  export const CONFIG_DIR = path.join(os.homedir(), '.shellbase');
13
13
  export const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
14
+ // 폰이 보내는 제어 프레임(세션 끄기 / 새 세션 열기)에 함께 실어 보내는 확인 문자열.
15
+ // 프레임 전체가 세션별 키로 암호화돼 있어서, 이 문자열이 제대로 풀린다는 건 보낸 쪽이 그 키
16
+ // (= devices 테이블의 내 row, RLS 로 주인만 읽을 수 있음)를 가지고 있었다는 증거가 된다.
17
+ export const CONTROL_TOKEN = 'shellbase-control-v1';
package/dist/session.js CHANGED
@@ -3,12 +3,13 @@ import os from 'node:os';
3
3
  import crypto from 'node:crypto';
4
4
  import * as pty from 'node-pty';
5
5
  import { requireClient } from './auth.js';
6
- import { TERMINAL_CATEGORY } from './config.js';
6
+ import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
7
7
  import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
8
8
  import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
9
9
  import { registerDevice, heartbeatDevice, unregisterDevice } from './devices.js';
10
10
  import { promptApproval } from './prompt.js';
11
11
  import { touchRecentDir } from './recent-dirs.js';
12
+ import { spawnSession } from './spawner.js';
12
13
  const HEARTBEAT_MS = 20_000;
13
14
  const APPROVAL_TIMEOUT_MS = 30_000;
14
15
  const SCROLLBACK_MAX = 16_000;
@@ -17,6 +18,22 @@ function resolveShell() {
17
18
  return process.env.COMSPEC ?? 'powershell.exe';
18
19
  return process.env.SHELL ?? '/bin/bash';
19
20
  }
21
+ // 폰이 보내는 제어 프레임(끄기 / 새 세션)은 이 세션의 암호화 키로 풀려야만 받아들인다.
22
+ // 키는 devices 테이블의 내 row 에만 있고 RLS 로 주인만 읽을 수 있으니, 복호화에 성공했다는 것
23
+ // 자체가 "주인이 보낸 요청"이라는 증거가 된다 (입력 승인과 별개 — 승인은 타이핑 권한용).
24
+ function readControl(data, key) {
25
+ if (typeof data !== 'string')
26
+ return null;
27
+ try {
28
+ const parsed = JSON.parse(decryptFrame(data, key));
29
+ if (!parsed || parsed.token !== CONTROL_TOKEN)
30
+ return null;
31
+ return parsed;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
20
37
  export async function startSession(options) {
21
38
  const { cb, memberId } = await requireClient();
22
39
  const cwd = options.dir ? path.resolve(options.dir) : process.cwd();
@@ -41,17 +58,6 @@ export async function startSession(options) {
41
58
  if (autoApprove) {
42
59
  console.log(`⚠️ 자동 승인이 켜져 있어요${accountAutoApprove ? ' (계정 설정)' : ' (--auto-approve)'} — 접속 요청을 묻지 않고 자동 승인해요. 내 계정에 들어올 수 있는 사람은 누구나 곧바로 입력할 수 있어요.`);
43
60
  }
44
- const deviceRowId = await registerDevice(cb, {
45
- ownerId: memberId,
46
- deviceId: device.deviceId,
47
- deviceName,
48
- cwd,
49
- frameKey: device.frameKey,
50
- favoriteDirs: touchRecentDir(cwd),
51
- });
52
- const heartbeat = setInterval(() => {
53
- heartbeatDevice(cb, deviceRowId).catch((err) => console.error('하트비트 실패:', err.message));
54
- }, HEARTBEAT_MS);
55
61
  await cb.realtime.connect({ userId: device.deviceId });
56
62
  const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
57
63
  // 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 기기별 키로 암호화해서 주고받는다.
@@ -65,14 +71,21 @@ export async function startSession(options) {
65
71
  env: process.env,
66
72
  });
67
73
  // PTY 출력은 모아서 전송 — 서버가 전송 속도를 최소 100ms 간격으로 제한해서(RATE_LIMITED), 그보다
68
- // 짧은 간격으로 보내면 출력이 많은 순간(로그 스크롤 등)에 전송이 거부될 수 있음. 메시지 수/요금
69
- // 절감 목적도 있지만, 이제는 제한을 지키는 우선.
74
+ // 짧은 간격으로 보내면 출력이 많은 순간(로그 스크롤 등)에 전송이 거부될 수 있음. 그렇다고 매번
75
+ // 고정 간격을 기다리면 한두 글자짜리 출력도 불필요하게 지연되니, "마지막 전송 후 이 간격이
76
+ // 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서 보낸다" 방식으로 평소 지연은 최소화하고
77
+ // 출력이 몰릴 때만 자동으로 묶이게 한다.
78
+ const MIN_SEND_INTERVAL_MS = 120;
70
79
  let outBuffer = '';
80
+ let lastSentAt = 0;
81
+ let pendingFlush = null;
71
82
  const flush = async () => {
83
+ pendingFlush = null;
72
84
  if (!outBuffer)
73
85
  return;
74
86
  const chunk = outBuffer;
75
87
  outBuffer = '';
88
+ lastSentAt = Date.now();
76
89
  try {
77
90
  await channel.send({
78
91
  kind: 'output',
@@ -84,12 +97,18 @@ export async function startSession(options) {
84
97
  console.error('출력 전송 실패:', err.message);
85
98
  }
86
99
  };
87
- const flushTimer = setInterval(() => void flush(), 120);
100
+ const scheduleFlush = () => {
101
+ if (pendingFlush !== null)
102
+ return;
103
+ const wait = Math.max(0, MIN_SEND_INTERVAL_MS - (Date.now() - lastSentAt));
104
+ pendingFlush = setTimeout(() => void flush(), wait);
105
+ };
88
106
  // 최근 출력을 조금 들고 있다가, 모바일이 (재)접속했을 때 놓친 화면 대신 다시 보여줌
89
107
  let scrollback = '';
90
108
  ptyProcess.onData((data) => {
91
109
  outBuffer += data;
92
110
  scrollback = (scrollback + data).slice(-SCROLLBACK_MAX);
111
+ scheduleFlush();
93
112
  });
94
113
  // 모바일이 접속을 시도하면 데스크톱에서 승인해야만 입력이 먹힘 (읽기/출력은 항상 흐름 —
95
114
  // 이미 같은 계정으로 로그인했다는 전제이므로, 승인은 "지금 이 순간 내가 타이핑을 넘겨줄지"
@@ -123,6 +142,34 @@ export async function startSession(options) {
123
142
  .catch((err) => console.error('접속 승인 처리 실패:', err.message));
124
143
  return;
125
144
  }
145
+ // 폰에서 "이 세션 끄기" — 정상 종료 경로(shutdown)를 그대로 타서 목록에서도 스스로 지워진다.
146
+ if (frame.kind === 'kill') {
147
+ if (!readControl(frame.data, device.frameKey))
148
+ return;
149
+ console.log('\n📱 폰에서 이 세션을 끄라고 요청했어요 — 정리하고 종료할게요.');
150
+ void shutdown();
151
+ return;
152
+ }
153
+ // 폰에서 "새 세션 열기" — 이 세션이 CLI 를 한 번 더 띄워주고, 새 세션은 폰 목록에 따로 나타난다.
154
+ if (frame.kind === 'new_session') {
155
+ const control = readControl(frame.data, device.frameKey);
156
+ if (!control || typeof control.dir !== 'string')
157
+ return;
158
+ const result = spawnSession(control.dir);
159
+ if (result.ok) {
160
+ console.log(`\n📱 폰 요청으로 새 세션을 열었어요 (${control.dir})`);
161
+ return;
162
+ }
163
+ console.error(`\n📱 폰이 요청한 새 세션을 못 열었어요: ${result.reason}`);
164
+ channel
165
+ .send({
166
+ kind: 'new_session_failed',
167
+ to: device.deviceId,
168
+ data: encryptFrame(result.reason, device.frameKey),
169
+ }, { includeSelf: false })
170
+ .catch((err) => console.error('실패 알림 전송 실패:', err.message));
171
+ return;
172
+ }
126
173
  if (frame.kind === 'input' && typeof frame.data === 'string') {
127
174
  if (!inputApproved)
128
175
  return;
@@ -137,13 +184,28 @@ export async function startSession(options) {
137
184
  ptyProcess.resize(frame.cols, frame.rows);
138
185
  }
139
186
  });
187
+ // 목록 등록은 "이제 프레임을 받을 수 있다"가 확실해진 지금(구독 + 메시지 핸들러 준비 완료) 한다.
188
+ // 예전엔 실시간 연결보다 먼저 등록해서, 폰이 목록에 뜨자마자 보낸 요청(특히 "세션 끄기")이
189
+ // 아직 구독 전인 세션에 닿지 못하고 그대로 사라지는 구간이 있었음 (실시간 프레임은 저장되지 않음).
190
+ const deviceRowId = await registerDevice(cb, {
191
+ ownerId: memberId,
192
+ deviceId: device.deviceId,
193
+ deviceName,
194
+ cwd,
195
+ frameKey: device.frameKey,
196
+ favoriteDirs: touchRecentDir(cwd),
197
+ });
198
+ const heartbeat = setInterval(() => {
199
+ heartbeatDevice(cb, deviceRowId).catch((err) => console.error('하트비트 실패:', err.message));
200
+ }, HEARTBEAT_MS);
140
201
  startSleepGuard();
141
202
  let shuttingDown = false;
142
203
  const shutdown = async () => {
143
204
  if (shuttingDown)
144
205
  return;
145
206
  shuttingDown = true;
146
- clearInterval(flushTimer);
207
+ if (pendingFlush !== null)
208
+ clearTimeout(pendingFlush);
147
209
  clearInterval(heartbeat);
148
210
  await flush();
149
211
  stopMessages();
@@ -0,0 +1,43 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ // `shellbase` 는 bin 심볼릭 링크(예: /usr/local/bin/shellbase)로 실행되기도 하고 dist/cli.js 로
6
+ // 직접 실행되기도 해서, 지금 돌아가는 진입점(process.argv[1])을 그대로 다시 쓰는 게 가장 확실하다.
7
+ // 그게 비어있는 예외적인 경우에만 이 파일 옆의 cli.js 로 되돌아간다.
8
+ function resolveEntry() {
9
+ const current = process.argv[1];
10
+ if (current && fs.existsSync(current))
11
+ return current;
12
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), 'cli.js');
13
+ }
14
+ export function spawnSession(dir) {
15
+ const target = path.resolve(dir);
16
+ let stat;
17
+ try {
18
+ stat = fs.statSync(target);
19
+ }
20
+ catch {
21
+ return { ok: false, reason: `그런 폴더가 없어요: ${target}` };
22
+ }
23
+ if (!stat.isDirectory())
24
+ return { ok: false, reason: `폴더가 아니라 파일이에요: ${target}` };
25
+ try {
26
+ // 폰에서 띄우는 세션은 승인 물음에 대답할 터미널이 없다(stdio: 'ignore') — 그대로 두면 30초 뒤
27
+ // 자동 거부돼서 화면만 보이고 타이핑이 안 되는 세션이 된다. 요청을 보낸 쪽은 이미 이 세션의
28
+ // 암호화 키(주인만 읽을 수 있는 devices row)를 가지고 있음이 확인된 상태라 자동 승인으로 띄운다.
29
+ const child = spawn(process.execPath, [resolveEntry(), 'start', '--dir', target, '--auto-approve'], {
30
+ cwd: target,
31
+ env: process.env,
32
+ // 부모 세션을 Ctrl+C 로 껐을 때 폰에서 따로 만든 세션까지 같이 죽지 않도록 독립 프로세스로 띄움
33
+ // (각 세션은 폰 목록에서 개별적으로 끌 수 있음). 도커 컨테이너 안이라면 컨테이너가 멈출 때 함께 정리됨.
34
+ detached: true,
35
+ stdio: 'ignore',
36
+ });
37
+ child.unref();
38
+ return { ok: true };
39
+ }
40
+ catch (err) {
41
+ return { ok: false, reason: err.message };
42
+ }
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellbase",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {