shellbase 0.1.7 → 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)은 기기별 키로 암호화해서 주고받는다.
@@ -136,6 +142,34 @@ export async function startSession(options) {
136
142
  .catch((err) => console.error('접속 승인 처리 실패:', err.message));
137
143
  return;
138
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
+ }
139
173
  if (frame.kind === 'input' && typeof frame.data === 'string') {
140
174
  if (!inputApproved)
141
175
  return;
@@ -150,6 +184,20 @@ export async function startSession(options) {
150
184
  ptyProcess.resize(frame.cols, frame.rows);
151
185
  }
152
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);
153
201
  startSleepGuard();
154
202
  let shuttingDown = false;
155
203
  const shutdown = async () => {
@@ -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.7",
3
+ "version": "0.1.8",
4
4
  "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
5
  "type": "module",
6
6
  "bin": {