shellbase 0.1.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.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # shellbase
2
+
3
+ 내 컴퓨터 터미널(특히 [Claude Code](https://claude.com/claude-code) 세션)을 폰 브라우저로
4
+ 실시간으로 보고 조작할 수 있게 해주는 데스크톱 에이전트입니다.
5
+
6
+ ## 설치
7
+
8
+ ```bash
9
+ npm install -g shellbase
10
+ ```
11
+
12
+ ## 사용법
13
+
14
+ ```bash
15
+ # 구글 계정으로 로그인 (처음이면 자동으로 가입도 됨)
16
+ shellbase login
17
+
18
+ # 지금 폴더에서 터미널 세션 시작
19
+ shellbase start
20
+
21
+ # 다른 폴더에서 시작하고 싶으면
22
+ shellbase start --dir ~/projects/my-app --name "내 노트북"
23
+ ```
24
+
25
+ `shellbase start` 를 실행한 뒤 [shellbase.web.connectbase.world](https://shellbase.web.connectbase.world)
26
+ 에 같은 계정으로 접속하면 내 데스크톱 목록에 뜹니다. 선택하면 데스크톱에 접속 승인 알림이 뜨고,
27
+ 승인하면 폰에서 그대로 타이핑할 수 있어요.
28
+
29
+ ## 명령어
30
+
31
+ | 명령어 | 설명 |
32
+ |---|---|
33
+ | `shellbase login` | 구글 계정으로 로그인 |
34
+ | `shellbase logout` | 로그아웃 |
35
+ | `shellbase whoami` | 현재 로그인된 계정 확인 |
36
+ | `shellbase start [--dir <path>] [--name <name>]` | 터미널 세션 시작 |
37
+
38
+ ## 참고
39
+
40
+ - `node-pty` 를 사용해서 설치 시 네이티브 모듈을 컴파일합니다. macOS는 Xcode Command Line Tools,
41
+ Linux는 `build-essential`(또는 배포판의 동급 패키지), Windows는 Visual Studio Build Tools가
42
+ 필요할 수 있어요.
43
+ - 터미널 내용은 기기별 키로 암호화돼서 전송되고, 모바일에서 접속을 시도할 때마다 데스크톱에서
44
+ 직접 승인해야 입력이 가능합니다.
package/dist/auth.js ADDED
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import crypto from 'node:crypto';
4
+ import { ConnectBase } from 'connectbase-client';
5
+ import { APP_ID, PUBLIC_KEY, CONFIG_DIR, CREDENTIALS_PATH, DEVICE_PATH } from './config.js';
6
+ import { generateFrameKey } from './crypto.js';
7
+ import { loginWithGoogle } from './google-auth.js';
8
+ function ensureConfigDir() {
9
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
10
+ }
11
+ function saveCredentials(creds) {
12
+ ensureConfigDir();
13
+ fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { mode: 0o600 });
14
+ }
15
+ function loadCredentials() {
16
+ try {
17
+ return JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf8'));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function loadOrCreateDevice() {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(DEVICE_PATH, 'utf8'));
26
+ }
27
+ catch {
28
+ // realtime userId 는 UUID 형태를 "인증된 멤버 전용"으로 취급해서 거부하므로,
29
+ // 기기 식별자는 접두사를 붙여 UUID 로 보이지 않게 만든다.
30
+ const identity = {
31
+ deviceId: `desktop-${crypto.randomUUID()}`,
32
+ deviceName: os.hostname(),
33
+ frameKey: generateFrameKey(),
34
+ };
35
+ ensureConfigDir();
36
+ fs.writeFileSync(DEVICE_PATH, JSON.stringify(identity, null, 2), { mode: 0o600 });
37
+ return identity;
38
+ }
39
+ }
40
+ function createClient(onTokens) {
41
+ return new ConnectBase({
42
+ appId: APP_ID,
43
+ publicKey: PUBLIC_KEY,
44
+ onTokenRefresh: (tokens) => {
45
+ onTokens?.({ access_token: tokens.accessToken, refresh_token: tokens.refreshToken });
46
+ },
47
+ });
48
+ }
49
+ export async function login() {
50
+ // 이 앱은 구글 로그인만 허용하도록 설정돼 있어서(콘솔에서 이메일/비밀번호 로그인 끔),
51
+ // CLI도 구글 로그인만 지원. 상세 흐름은 google-auth.ts 참고 (모바일 웹을 통한 중계 방식).
52
+ const { accessToken, refreshToken, memberId } = await loginWithGoogle();
53
+ const cb = createClient();
54
+ cb.setTokens(accessToken, refreshToken);
55
+ const me = await cb.auth.getMe();
56
+ saveCredentials({
57
+ member_id: memberId,
58
+ nickname: me.nickname,
59
+ access_token: accessToken,
60
+ refresh_token: refreshToken,
61
+ });
62
+ console.log(`로그인 완료! (${me.nickname})`);
63
+ }
64
+ export async function logout() {
65
+ try {
66
+ fs.rmSync(CREDENTIALS_PATH);
67
+ }
68
+ catch {
69
+ // 이미 로그아웃 상태
70
+ }
71
+ console.log('로그아웃했어요.');
72
+ }
73
+ export async function whoami() {
74
+ const { cb, memberId } = await requireClient();
75
+ const me = await cb.auth.getMe();
76
+ console.log(`${me.nickname}${me.email ? ` <${me.email}>` : ''} (member_id: ${memberId})`);
77
+ }
78
+ export async function requireClient() {
79
+ const creds = loadCredentials();
80
+ if (!creds) {
81
+ console.error('로그인이 필요해요. 먼저 `shellbase login` 을 실행하세요.');
82
+ process.exit(1);
83
+ }
84
+ const cb = createClient((tokens) => {
85
+ const current = loadCredentials();
86
+ if (current)
87
+ saveCredentials({ ...current, ...tokens });
88
+ });
89
+ cb.setTokens(creds.access_token, creds.refresh_token);
90
+ return { cb, memberId: creds.member_id, nickname: creds.nickname };
91
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { login, logout, whoami } from './auth.js';
4
+ import { startSession } from './session.js';
5
+ const program = new Command();
6
+ program.name('shellbase').description('내 컴퓨터 터미널을 폰에서 접속할 수 있게 해주는 에이전트');
7
+ program
8
+ .command('login')
9
+ .description('구글 계정으로 로그인 (처음이면 자동으로 가입도 돼요)')
10
+ .action(async () => {
11
+ await login();
12
+ });
13
+ program
14
+ .command('logout')
15
+ .description('로그아웃')
16
+ .action(async () => {
17
+ await logout();
18
+ });
19
+ program
20
+ .command('whoami')
21
+ .description('현재 로그인된 계정 확인')
22
+ .action(async () => {
23
+ await whoami();
24
+ });
25
+ program
26
+ .command('start')
27
+ .description('이 컴퓨터의 터미널을 열어서 폰 접속을 받을 수 있게 시작')
28
+ .option('--dir <path>', '이 폴더에서 터미널을 시작해요 (기본: 현재 폴더)')
29
+ .option('--name <name>', '폰에서 보여줄 이 기기의 이름 (기본: 컴퓨터 이름)')
30
+ .action(async (opts) => {
31
+ await startSession(opts);
32
+ });
33
+ program.parseAsync(process.argv).catch((err) => {
34
+ console.error('실행 중 문제가 생겼어요:', err instanceof Error ? err.message : err);
35
+ process.exit(1);
36
+ });
package/dist/config.js ADDED
@@ -0,0 +1,14 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ export const APP_ID = process.env.SHELLBASE_APP_ID ?? '01a00981-cc7b-79b1-ada4-7338d52a4466';
4
+ export const PUBLIC_KEY = process.env.SHELLBASE_PUBLIC_KEY ?? 'cb_pk_01a00981-cc9d-79c0-85f7-df1b6f1955ce';
5
+ // 실시간 프레임 채널. persist:false로 만들어서 ConnectBase 서버에 내용이 남지 않음.
6
+ export const TERMINAL_CATEGORY = 'shellbase-terminal';
7
+ // 이 앱의 모든 멤버가 같은 채널을 공유하므로, 프레임 자체는 기기별 키로 암호화해서 보낸다 (crypto.ts).
8
+ export const DEVICES_TABLE_ID = '01a009c7-a33d-79ca-9eeb-f9dc2d442c1b';
9
+ // CLI 구글 로그인 중계용 (google-auth.ts) — 짧게 살고 암호화된 채로만 저장됨.
10
+ export const CLI_AUTH_SESSIONS_TABLE_ID = '01a00aa0-802d-7730-88ba-b831b7405188';
11
+ export const WEB_LOGIN_URL = 'https://shellbase.web.connectbase.world/login';
12
+ export const CONFIG_DIR = path.join(os.homedir(), '.shellbase');
13
+ export const CREDENTIALS_PATH = path.join(CONFIG_DIR, 'credentials.json');
14
+ export const DEVICE_PATH = path.join(CONFIG_DIR, 'device.json');
package/dist/crypto.js ADDED
@@ -0,0 +1,29 @@
1
+ import crypto from 'node:crypto';
2
+ const ALGO = 'aes-256-gcm';
3
+ const IV_LEN = 12;
4
+ const TAG_LEN = 16;
5
+ // 실시간 채널은 이 ConnectBase 앱의 멤버라면 누구나 구독할 수 있는 공유 채널이라,
6
+ // 프레임 자체를 기기별 키로 암호화해서 다른 사용자가 봐도 알아볼 수 없게 만든다.
7
+ // 지갑 형식은 base64(iv(12) + ciphertext+authTag) — 브라우저 Web Crypto(AES-GCM)의
8
+ // "암호문 뒤에 태그를 붙여 반환" 관례와 그대로 맞춰서, 모바일(Web Crypto)과 바로 호환되게 함.
9
+ export function generateFrameKey() {
10
+ return crypto.randomBytes(32).toString('base64');
11
+ }
12
+ export function encryptFrame(plaintext, keyB64) {
13
+ const key = Buffer.from(keyB64, 'base64');
14
+ const iv = crypto.randomBytes(IV_LEN);
15
+ const cipher = crypto.createCipheriv(ALGO, key, iv);
16
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
17
+ const authTag = cipher.getAuthTag();
18
+ return Buffer.concat([iv, ciphertext, authTag]).toString('base64');
19
+ }
20
+ export function decryptFrame(payloadB64, keyB64) {
21
+ const key = Buffer.from(keyB64, 'base64');
22
+ const raw = Buffer.from(payloadB64, 'base64');
23
+ const iv = raw.subarray(0, IV_LEN);
24
+ const authTag = raw.subarray(raw.length - TAG_LEN);
25
+ const ciphertext = raw.subarray(IV_LEN, raw.length - TAG_LEN);
26
+ const decipher = crypto.createDecipheriv(ALGO, key, iv);
27
+ decipher.setAuthTag(authTag);
28
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
29
+ }
@@ -0,0 +1,34 @@
1
+ import { DEVICES_TABLE_ID } from './config.js';
2
+ export async function registerDevice(cb, params) {
3
+ const { data: rows } = await cb.database.queryData(DEVICES_TABLE_ID, {
4
+ where: { device_id: params.deviceId },
5
+ limit: 1,
6
+ });
7
+ const payload = {
8
+ owner_id: params.ownerId,
9
+ device_id: params.deviceId,
10
+ device_name: params.deviceName,
11
+ status: 'online',
12
+ cwd: params.cwd,
13
+ frame_key: params.frameKey,
14
+ favorite_dirs: params.favoriteDirs,
15
+ last_seen_at: new Date().toISOString(),
16
+ };
17
+ const existing = rows[0];
18
+ if (existing) {
19
+ await cb.database.updateData(DEVICES_TABLE_ID, existing.id, { data: payload });
20
+ return existing.id;
21
+ }
22
+ const created = await cb.database.createData(DEVICES_TABLE_ID, { data: payload });
23
+ return created.id;
24
+ }
25
+ export async function heartbeatDevice(cb, rowId) {
26
+ await cb.database.updateData(DEVICES_TABLE_ID, rowId, {
27
+ data: { status: 'online', last_seen_at: new Date().toISOString() },
28
+ });
29
+ }
30
+ export async function markDeviceOffline(cb, rowId) {
31
+ await cb.database.updateData(DEVICES_TABLE_ID, rowId, {
32
+ data: { status: 'offline', last_seen_at: new Date().toISOString() },
33
+ });
34
+ }
@@ -0,0 +1,64 @@
1
+ import crypto from 'node:crypto';
2
+ import { exec } from 'node:child_process';
3
+ import { ConnectBase } from 'connectbase-client';
4
+ import { APP_ID, PUBLIC_KEY, CLI_AUTH_SESSIONS_TABLE_ID, WEB_LOGIN_URL } from './config.js';
5
+ import { generateFrameKey, decryptFrame } from './crypto.js';
6
+ const API_BASE = 'https://api.connectbase.world';
7
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
8
+ const POLL_INTERVAL_MS = 1500;
9
+ const SESSION_TTL_MS = 10 * 60_000;
10
+ function openBrowser(url) {
11
+ const command = process.platform === 'darwin'
12
+ ? `open "${url}"`
13
+ : process.platform === 'win32'
14
+ ? `start "" "${url}"`
15
+ : `xdg-open "${url}"`;
16
+ exec(command, () => { });
17
+ }
18
+ // ConnectBase의 app_callback은 이 앱이 실제로 배포된 웹 주소만 허용해서(오픈 리다이렉트 방지),
19
+ // CLI가 임의의 localhost 포트를 콜백으로 쓸 수 없다. 대신: 모바일 웹(WEB_LOGIN_URL)이 구글 로그인을
20
+ // 대신 받고, 임시 DB row(cli_auth_sessions)를 통해 토큰을 CLI에 중계한다. row는 CLI만 아는 랜덤 키로
21
+ // 암호화돼서 저장되므로, 같은 앱의 다른 사용자가 테이블을 봐도 내용을 못 읽는다.
22
+ export async function loginWithGoogle() {
23
+ const sessionId = crypto.randomBytes(16).toString('hex');
24
+ const encKey = generateFrameKey();
25
+ const state = `cli-relay:${sessionId}:${encKey}`;
26
+ const cb = new ConnectBase({ appId: APP_ID, publicKey: PUBLIC_KEY });
27
+ const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
28
+ const created = await cb.database.createData(CLI_AUTH_SESSIONS_TABLE_ID, {
29
+ data: { session_id: sessionId, status: 'pending', expires_at: expiresAt },
30
+ });
31
+ const params = new URLSearchParams({ app_callback: WEB_LOGIN_URL, intent: 'signup', state });
32
+ const res = await fetch(`${API_BASE}/v1/public/oauth/google/authorize/central?${params}`, {
33
+ headers: { 'X-Public-Key': PUBLIC_KEY },
34
+ });
35
+ if (!res.ok) {
36
+ throw new Error(`구글 로그인 시작에 실패했어요 (HTTP ${res.status}).`);
37
+ }
38
+ const { authorization_url } = (await res.json());
39
+ console.log('브라우저에서 구글 로그인 창을 열게요. 완료하면 자동으로 이어져요...');
40
+ console.log(`(자동으로 안 열리면 이 주소를 직접 열어주세요: ${authorization_url})`);
41
+ openBrowser(authorization_url);
42
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
43
+ while (Date.now() < deadline) {
44
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
45
+ const { data: rows } = await cb.database.queryData(CLI_AUTH_SESSIONS_TABLE_ID, {
46
+ where: { session_id: sessionId },
47
+ limit: 1,
48
+ });
49
+ const row = rows[0];
50
+ if (!row)
51
+ continue;
52
+ if (row.data.status === 'completed' && row.data.encrypted_payload) {
53
+ cb.database.deleteData(CLI_AUTH_SESSIONS_TABLE_ID, row.id).catch(() => { });
54
+ const payload = JSON.parse(decryptFrame(row.data.encrypted_payload, encKey));
55
+ return {
56
+ accessToken: payload.access_token,
57
+ refreshToken: payload.refresh_token,
58
+ memberId: payload.member_id,
59
+ };
60
+ }
61
+ }
62
+ cb.database.deleteData(CLI_AUTH_SESSIONS_TABLE_ID, created.id).catch(() => { });
63
+ throw new Error('로그인 시간이 초과됐어요 (5분). 다시 시도해주세요.');
64
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,23 @@
1
+ import readline from 'node:readline';
2
+ // y/n 승인 프롬프트 — 정해진 시간 안에 응답 없으면 자동 거부(false)
3
+ export function promptApproval(question, timeoutMs) {
4
+ return new Promise((resolve) => {
5
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
6
+ let done = false;
7
+ const finish = (result) => {
8
+ if (done)
9
+ return;
10
+ done = true;
11
+ clearTimeout(timer);
12
+ rl.close();
13
+ resolve(result);
14
+ };
15
+ const timer = setTimeout(() => {
16
+ process.stdout.write('\n(시간 초과 — 자동으로 거부했어요)\n');
17
+ finish(false);
18
+ }, timeoutMs);
19
+ rl.question(question, (answer) => {
20
+ finish(answer.trim().toLowerCase().startsWith('y'));
21
+ });
22
+ });
23
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { CONFIG_DIR } from './config.js';
5
+ const RECENT_DIRS_PATH = path.join(CONFIG_DIR, 'recent-dirs.json');
6
+ const MAX_ENTRIES = 8;
7
+ function load() {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(RECENT_DIRS_PATH, 'utf8'));
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
15
+ // 지금 시작한 폴더를 맨 앞으로 올리고, 홈 디렉터리는 항상 후보에 포함시킴 (모바일 폴더 선택용)
16
+ export function touchRecentDir(cwd) {
17
+ const home = os.homedir();
18
+ const existing = load().filter((dir) => dir !== cwd && dir !== home);
19
+ const updated = [cwd, home, ...existing].slice(0, MAX_ENTRIES);
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
21
+ fs.writeFileSync(RECENT_DIRS_PATH, JSON.stringify(updated, null, 2), { mode: 0o600 });
22
+ return updated;
23
+ }
@@ -0,0 +1,148 @@
1
+ import path from 'node:path';
2
+ import * as pty from 'node-pty';
3
+ import { requireClient, loadOrCreateDevice } from './auth.js';
4
+ import { TERMINAL_CATEGORY } from './config.js';
5
+ import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
6
+ import { encryptFrame, decryptFrame } from './crypto.js';
7
+ import { registerDevice, heartbeatDevice, markDeviceOffline } from './devices.js';
8
+ import { promptApproval } from './prompt.js';
9
+ import { touchRecentDir } from './recent-dirs.js';
10
+ const HEARTBEAT_MS = 20_000;
11
+ const APPROVAL_TIMEOUT_MS = 30_000;
12
+ const SCROLLBACK_MAX = 16_000;
13
+ function resolveShell() {
14
+ if (process.platform === 'win32')
15
+ return process.env.COMSPEC ?? 'powershell.exe';
16
+ return process.env.SHELL ?? '/bin/bash';
17
+ }
18
+ export async function startSession(options) {
19
+ const { cb, memberId } = await requireClient();
20
+ const device = loadOrCreateDevice();
21
+ const deviceName = options.name ?? device.deviceName;
22
+ const cwd = options.dir ? path.resolve(options.dir) : process.cwd();
23
+ console.log(`"${deviceName}" 세션을 시작해요 (작업 폴더: ${cwd})`);
24
+ const deviceRowId = await registerDevice(cb, {
25
+ ownerId: memberId,
26
+ deviceId: device.deviceId,
27
+ deviceName,
28
+ cwd,
29
+ frameKey: device.frameKey,
30
+ favoriteDirs: touchRecentDir(cwd),
31
+ });
32
+ const heartbeat = setInterval(() => {
33
+ heartbeatDevice(cb, deviceRowId).catch((err) => console.error('하트비트 실패:', err.message));
34
+ }, HEARTBEAT_MS);
35
+ await cb.realtime.connect({ userId: device.deviceId });
36
+ const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
37
+ // 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 기기별 키로 암호화해서 주고받는다.
38
+ // 다른 사람이 같은 채널을 구독해도 to/kind 정도만 보이고 실제 터미널 내용은 못 읽음.
39
+ const shell = resolveShell();
40
+ const ptyProcess = pty.spawn(shell, [], {
41
+ name: 'xterm-256color',
42
+ cols: 80,
43
+ rows: 24,
44
+ cwd,
45
+ env: process.env,
46
+ });
47
+ // PTY 출력은 30ms 간격으로 모아서 전송 — 잦은 전송으로 메시지 수/요금이 불어나는 것 방지
48
+ let outBuffer = '';
49
+ const flush = async () => {
50
+ if (!outBuffer)
51
+ return;
52
+ const chunk = outBuffer;
53
+ outBuffer = '';
54
+ try {
55
+ await channel.send({
56
+ kind: 'output',
57
+ to: device.deviceId,
58
+ data: encryptFrame(chunk, device.frameKey),
59
+ }, { includeSelf: false });
60
+ }
61
+ catch (err) {
62
+ console.error('출력 전송 실패:', err.message);
63
+ }
64
+ };
65
+ const flushTimer = setInterval(() => void flush(), 30);
66
+ // 최근 출력을 조금 들고 있다가, 모바일이 (재)접속했을 때 놓친 화면 대신 다시 보여줌
67
+ let scrollback = '';
68
+ ptyProcess.onData((data) => {
69
+ outBuffer += data;
70
+ scrollback = (scrollback + data).slice(-SCROLLBACK_MAX);
71
+ });
72
+ // 모바일이 접속을 시도하면 데스크톱에서 승인해야만 입력이 먹힘 (읽기/출력은 항상 흐름 —
73
+ // 이미 같은 계정으로 로그인했다는 전제이므로, 승인은 "지금 이 순간 내가 타이핑을 넘겨줄지"
74
+ // 확인하는 용도). 승인 전에 온 input 프레임은 무시.
75
+ let inputApproved = false;
76
+ let approvalInFlight = false;
77
+ const stopMessages = channel.onMessage((msg) => {
78
+ const frame = msg.data;
79
+ if (!frame || frame.to !== device.deviceId)
80
+ return;
81
+ if (frame.kind === 'connect_request') {
82
+ // 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인 물어보기 전에 최근 화면부터 먼저 채워줌
83
+ // (끊겼다 재접속한 경우든, 처음 들어온 경우든 동일하게 "방금까지 뭐가 있었는지" 보여줌)
84
+ if (scrollback) {
85
+ channel
86
+ .send({ kind: 'output', to: device.deviceId, data: encryptFrame(scrollback, device.frameKey) }, { includeSelf: false })
87
+ .catch((err) => console.error('화면 복구 전송 실패:', err.message));
88
+ }
89
+ if (approvalInFlight)
90
+ return;
91
+ approvalInFlight = true;
92
+ promptApproval('\n📱 모바일에서 접속을 요청했어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): ', APPROVAL_TIMEOUT_MS)
93
+ .then((approved) => {
94
+ inputApproved = approved;
95
+ approvalInFlight = false;
96
+ return channel.send({ kind: approved ? 'connect_approved' : 'connect_denied', to: device.deviceId }, { includeSelf: false });
97
+ })
98
+ .catch((err) => console.error('접속 승인 처리 실패:', err.message));
99
+ return;
100
+ }
101
+ if (frame.kind === 'input' && typeof frame.data === 'string') {
102
+ if (!inputApproved)
103
+ return;
104
+ try {
105
+ ptyProcess.write(decryptFrame(frame.data, device.frameKey));
106
+ }
107
+ catch (err) {
108
+ console.error('입력 복호화 실패 (다른 기기 키로 보낸 프레임?):', err.message);
109
+ }
110
+ }
111
+ else if (frame.kind === 'resize' && frame.cols && frame.rows) {
112
+ ptyProcess.resize(frame.cols, frame.rows);
113
+ }
114
+ });
115
+ startSleepGuard();
116
+ let shuttingDown = false;
117
+ const shutdown = async () => {
118
+ if (shuttingDown)
119
+ return;
120
+ shuttingDown = true;
121
+ clearInterval(flushTimer);
122
+ clearInterval(heartbeat);
123
+ await flush();
124
+ stopMessages();
125
+ stopSleepGuard();
126
+ try {
127
+ await channel.unsubscribe();
128
+ }
129
+ catch {
130
+ // 이미 끊겼으면 무시
131
+ }
132
+ try {
133
+ await markDeviceOffline(cb, deviceRowId);
134
+ }
135
+ catch {
136
+ // 오프라인 표시 실패해도 종료는 계속 진행
137
+ }
138
+ cb.realtime.disconnect();
139
+ ptyProcess.kill();
140
+ process.exit(0);
141
+ };
142
+ process.on('SIGINT', () => void shutdown());
143
+ process.on('SIGTERM', () => void shutdown());
144
+ ptyProcess.onExit(() => {
145
+ console.log('셸이 종료돼서 세션도 함께 종료해요.');
146
+ void shutdown();
147
+ });
148
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn } from 'node:child_process';
2
+ let guard = null;
3
+ // 세션이 떠 있는 동안만 컴퓨터가 절전 모드로 들어가지 않게 막고,
4
+ // 세션이 끝나면(stopSleepGuard) 바로 풀어서 평소엔 정상적으로 절전되게 함.
5
+ export function startSleepGuard() {
6
+ if (guard)
7
+ return;
8
+ if (process.platform === 'darwin') {
9
+ // -w PID: 우리 프로세스가 죽으면 caffeinate 도 자동으로 함께 종료됨
10
+ guard = spawn('caffeinate', ['-i', '-w', String(process.pid)], { stdio: 'ignore' });
11
+ guard.on('error', () => {
12
+ console.warn('절전 방지(caffeinate) 실행에 실패했어요 — 세션 중 컴퓨터가 잠들 수 있어요.');
13
+ guard = null;
14
+ });
15
+ return;
16
+ }
17
+ if (process.platform === 'linux') {
18
+ guard = spawn('systemd-inhibit', ['--what=sleep', '--why=ShellBase session active', 'sleep', 'infinity'], { stdio: 'ignore' });
19
+ guard.on('error', () => {
20
+ console.warn('절전 방지(systemd-inhibit)를 못 찾았어요 — 세션 중 컴퓨터가 잠들 수 있어요. (systemd 없는 배포판인가요?)');
21
+ guard = null;
22
+ });
23
+ return;
24
+ }
25
+ if (process.platform === 'win32') {
26
+ // Win32 SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED)
27
+ // 를 PowerShell에서 P/Invoke로 호출 — 이 powershell 프로세스가 살아있는 동안 절전이 안 됨.
28
+ // caffeinate 의 "-w PID" 같은 부모 프로세스 자동 연동은 없어서, stopSleepGuard()에서 직접 kill.
29
+ const script = 'Add-Type -Name Win32 -Namespace ShellBase -MemberDefinition ' +
30
+ '\'[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);\'; ' +
31
+ '[ShellBase.Win32]::SetThreadExecutionState(0x80000003) | Out-Null; ' +
32
+ 'while ($true) { Start-Sleep -Seconds 60 }';
33
+ guard = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
34
+ stdio: 'ignore',
35
+ });
36
+ guard.on('error', () => {
37
+ console.warn('절전 방지(PowerShell) 실행에 실패했어요 — 세션 중 컴퓨터가 잠들 수 있어요.');
38
+ guard = null;
39
+ });
40
+ return;
41
+ }
42
+ console.warn('이 운영체제에서는 절전 방지를 자동으로 못 해요 — 세션 중에는 컴퓨터가 잠들지 않게 직접 설정해주세요.');
43
+ }
44
+ export function stopSleepGuard() {
45
+ guard?.kill();
46
+ guard = null;
47
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "shellbase",
3
+ "version": "0.1.0",
4
+ "description": "내 컴퓨터 터미널(특히 Claude Code 세션)을 폰 브라우저로 실시간 접속하게 해주는 데스크톱 에이전트",
5
+ "type": "module",
6
+ "bin": {
7
+ "shellbase": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "cli",
18
+ "terminal",
19
+ "remote",
20
+ "pty",
21
+ "claude-code",
22
+ "mobile"
23
+ ],
24
+ "scripts": {
25
+ "dev": "tsx src/cli.ts",
26
+ "build": "tsc",
27
+ "start": "node dist/cli.js"
28
+ },
29
+ "dependencies": {
30
+ "commander": "^15.0.0",
31
+ "connectbase-client": "^5.13.0",
32
+ "node-pty": "^1.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.2",
36
+ "tsx": "^4.23.12",
37
+ "typescript": "^6.0.2"
38
+ }
39
+ }