shellbase 0.1.10 → 0.2.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/dist/agent.js +375 -0
- package/dist/cli.js +4 -3
- package/dist/devices.js +19 -0
- package/dist/open-sessions.js +27 -0
- package/package.json +1 -1
- package/dist/session.js +0 -252
- package/dist/spawner.js +0 -49
package/dist/agent.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
import { requireClient } from './auth.js';
|
|
7
|
+
import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
|
|
8
|
+
import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
|
|
9
|
+
import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
|
|
10
|
+
import { registerDevice, heartbeatDevice, unregisterDevice, listOnlineDirs } from './devices.js';
|
|
11
|
+
import { promptApproval } from './prompt.js';
|
|
12
|
+
import { touchRecentDir } from './recent-dirs.js';
|
|
13
|
+
import { listDirs } from './browse.js';
|
|
14
|
+
import { loadOpenSessions, saveOpenSessions } from './open-sessions.js';
|
|
15
|
+
const HEARTBEAT_MS = 20_000;
|
|
16
|
+
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
17
|
+
const SCROLLBACK_MAX = 16_000;
|
|
18
|
+
// 서버가 전송 속도를 최소 100ms 간격으로 제한해서(RATE_LIMITED), 그보다 짧은 간격으로 보내면 출력이
|
|
19
|
+
// 많은 순간에 거부될 수 있음. 그렇다고 매번 고정 간격을 기다리면 한두 글자 출력도 불필요하게 지연되니,
|
|
20
|
+
// "마지막 전송 후 이 간격이 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서 보낸다"로 처리한다.
|
|
21
|
+
const MIN_SEND_INTERVAL_MS = 120;
|
|
22
|
+
// 실수나 버그로 세션이 무한정 늘어나는 것을 막는 상한 (한 컴퓨터에서 이 이상 필요한 경우는 사실상 없음)
|
|
23
|
+
const MAX_SESSIONS = 20;
|
|
24
|
+
function resolveShell() {
|
|
25
|
+
if (process.platform === 'win32')
|
|
26
|
+
return process.env.COMSPEC ?? 'powershell.exe';
|
|
27
|
+
return process.env.SHELL ?? '/bin/bash';
|
|
28
|
+
}
|
|
29
|
+
// 폰이 보내는 제어 프레임(끄기 / 새 세션 / 폴더 목록)은 그 세션의 키로 풀려야만 받아들인다.
|
|
30
|
+
// 키는 devices 테이블의 내 row 에만 있고 RLS 로 주인만 읽을 수 있으니, 복호화에 성공했다는 것 자체가
|
|
31
|
+
// "주인이 보낸 요청"이라는 증거가 된다 (입력 승인과는 별개 — 승인은 타이핑 권한용).
|
|
32
|
+
function readControl(data, key) {
|
|
33
|
+
if (typeof data !== 'string')
|
|
34
|
+
return null;
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(decryptFrame(data, key));
|
|
37
|
+
if (!parsed || parsed.token !== CONTROL_TOKEN)
|
|
38
|
+
return null;
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function runAgent(options) {
|
|
46
|
+
const { cb, memberId } = await requireClient();
|
|
47
|
+
const me = await cb.auth.getMe();
|
|
48
|
+
const accountAutoApprove = me.custom_data?.auto_approve === true;
|
|
49
|
+
const autoApprove = options.autoApprove === true || accountAutoApprove;
|
|
50
|
+
const primaryCwd = options.dir ? path.resolve(options.dir) : process.cwd();
|
|
51
|
+
// 이름 앞부분(컴퓨터를 가리키는 부분) — 이 프로세스가 나중에 만드는 세션들이 물려받는다.
|
|
52
|
+
// 도커 안에서는 os.hostname() 이 컨테이너 ID라서, --name 을 준 경우 그걸 쓰는 게 알아보기 좋음.
|
|
53
|
+
const hostLabel = options.name ?? os.hostname();
|
|
54
|
+
const shell = resolveShell();
|
|
55
|
+
const sessions = new Map();
|
|
56
|
+
function defaultName(cwd) {
|
|
57
|
+
return cwd === os.homedir() ? hostLabel : `${hostLabel} · ${path.basename(cwd)}`;
|
|
58
|
+
}
|
|
59
|
+
function persistOpenSessions() {
|
|
60
|
+
saveOpenSessions([...sessions.values()]
|
|
61
|
+
.filter((session) => !session.closing)
|
|
62
|
+
.map((session) => ({ dir: session.cwd, name: session.name })));
|
|
63
|
+
}
|
|
64
|
+
const send = async (frame) => {
|
|
65
|
+
try {
|
|
66
|
+
await channel.send(frame, { includeSelf: false });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error('전송 실패:', err.message);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const flush = async (session) => {
|
|
73
|
+
session.pendingFlush = null;
|
|
74
|
+
if (!session.outBuffer)
|
|
75
|
+
return;
|
|
76
|
+
const chunk = session.outBuffer;
|
|
77
|
+
session.outBuffer = '';
|
|
78
|
+
session.lastSentAt = Date.now();
|
|
79
|
+
await send({
|
|
80
|
+
kind: 'output',
|
|
81
|
+
to: session.deviceId,
|
|
82
|
+
data: encryptFrame(chunk, session.frameKey),
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
const scheduleFlush = (session) => {
|
|
86
|
+
if (session.pendingFlush !== null)
|
|
87
|
+
return;
|
|
88
|
+
const wait = Math.max(0, MIN_SEND_INTERVAL_MS - (Date.now() - session.lastSentAt));
|
|
89
|
+
session.pendingFlush = setTimeout(() => void flush(session), wait);
|
|
90
|
+
};
|
|
91
|
+
// stdin 은 프로세스에 하나뿐이라, 세션이 여러 개일 때 승인 프롬프트가 겹치지 않게 한 번에 하나씩 묻는다
|
|
92
|
+
let approvalChain = Promise.resolve();
|
|
93
|
+
function askApproval(session) {
|
|
94
|
+
session.approvalInFlight = true;
|
|
95
|
+
approvalChain = approvalChain
|
|
96
|
+
.then(async () => {
|
|
97
|
+
if (session.closing)
|
|
98
|
+
return;
|
|
99
|
+
const approved = autoApprove
|
|
100
|
+
? true
|
|
101
|
+
: await promptApproval(`\n📱 "${session.name}" 세션에 모바일 접속 요청이 왔어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): `, APPROVAL_TIMEOUT_MS);
|
|
102
|
+
session.inputApproved = approved;
|
|
103
|
+
session.approvalInFlight = false;
|
|
104
|
+
await send({
|
|
105
|
+
kind: approved ? 'connect_approved' : 'connect_denied',
|
|
106
|
+
to: session.deviceId,
|
|
107
|
+
});
|
|
108
|
+
})
|
|
109
|
+
.catch((err) => {
|
|
110
|
+
session.approvalInFlight = false;
|
|
111
|
+
console.error('접속 승인 처리 실패:', err.message);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async function createSession(dir, explicitName) {
|
|
115
|
+
const cwd = path.resolve(dir);
|
|
116
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
117
|
+
throw new Error(`세션은 한 번에 최대 ${MAX_SESSIONS}개까지 열 수 있어요.`);
|
|
118
|
+
}
|
|
119
|
+
let stat;
|
|
120
|
+
try {
|
|
121
|
+
stat = fs.statSync(cwd);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error(`그런 폴더가 없어요: ${cwd}`);
|
|
125
|
+
}
|
|
126
|
+
if (!stat.isDirectory())
|
|
127
|
+
throw new Error(`폴더가 아니라 파일이에요: ${cwd}`);
|
|
128
|
+
const session = {
|
|
129
|
+
deviceId: `desktop-${crypto.randomUUID()}`,
|
|
130
|
+
frameKey: generateFrameKey(),
|
|
131
|
+
name: explicitName ?? defaultName(cwd),
|
|
132
|
+
cwd,
|
|
133
|
+
rowId: '',
|
|
134
|
+
pty: pty.spawn(shell, [], {
|
|
135
|
+
name: 'xterm-256color',
|
|
136
|
+
cols: 80,
|
|
137
|
+
rows: 24,
|
|
138
|
+
cwd,
|
|
139
|
+
env: process.env,
|
|
140
|
+
}),
|
|
141
|
+
scrollback: '',
|
|
142
|
+
inputApproved: false,
|
|
143
|
+
approvalInFlight: false,
|
|
144
|
+
outBuffer: '',
|
|
145
|
+
lastSentAt: 0,
|
|
146
|
+
pendingFlush: null,
|
|
147
|
+
closing: false,
|
|
148
|
+
};
|
|
149
|
+
session.pty.onData((data) => {
|
|
150
|
+
session.outBuffer += data;
|
|
151
|
+
// 최근 출력을 조금 들고 있다가, 모바일이 (재)접속했을 때 놓친 화면 대신 다시 보여줌
|
|
152
|
+
session.scrollback = (session.scrollback + data).slice(-SCROLLBACK_MAX);
|
|
153
|
+
scheduleFlush(session);
|
|
154
|
+
});
|
|
155
|
+
session.pty.onExit(() => {
|
|
156
|
+
console.log(`"${session.name}" 세션의 셸이 종료돼서 세션도 닫아요.`);
|
|
157
|
+
void closeSession(session, { deliberate: true });
|
|
158
|
+
});
|
|
159
|
+
// 프레임 배분표에 먼저 올린 뒤 목록에 등록한다 — 폰 목록에 뜬 세션은 항상 요청을 받을 수 있는 상태
|
|
160
|
+
// (실시간 프레임은 저장되지 않아서, 등록이 먼저면 그 사이 요청이 그대로 사라짐 — §8.20)
|
|
161
|
+
sessions.set(session.deviceId, session);
|
|
162
|
+
try {
|
|
163
|
+
session.rowId = await registerDevice(cb, {
|
|
164
|
+
ownerId: memberId,
|
|
165
|
+
deviceId: session.deviceId,
|
|
166
|
+
deviceName: session.name,
|
|
167
|
+
cwd,
|
|
168
|
+
frameKey: session.frameKey,
|
|
169
|
+
favoriteDirs: touchRecentDir(cwd),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
sessions.delete(session.deviceId);
|
|
174
|
+
session.pty.kill();
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
persistOpenSessions();
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
// deliberate=true: 사용자가 일부러 닫은 것 → 재시작 복구 목록에서도 빠진다
|
|
181
|
+
async function closeSession(session, opts) {
|
|
182
|
+
if (session.closing)
|
|
183
|
+
return;
|
|
184
|
+
session.closing = true;
|
|
185
|
+
if (session.pendingFlush !== null)
|
|
186
|
+
clearTimeout(session.pendingFlush);
|
|
187
|
+
await flush(session);
|
|
188
|
+
sessions.delete(session.deviceId);
|
|
189
|
+
if (session.rowId) {
|
|
190
|
+
try {
|
|
191
|
+
await unregisterDevice(cb, session.rowId);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// 삭제 실패해도 계속 진행 — 폰 목록에 남으면 거기서 지울 수 있음
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
session.pty.kill();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// 이미 죽었으면 무시
|
|
202
|
+
}
|
|
203
|
+
if (opts.deliberate)
|
|
204
|
+
persistOpenSessions();
|
|
205
|
+
console.log(`"${session.name}" 세션을 닫았어요. (남은 세션 ${sessions.size}개)`);
|
|
206
|
+
if (sessions.size === 0) {
|
|
207
|
+
console.log('열린 세션이 없어서 에이전트도 종료해요.');
|
|
208
|
+
await shutdownProcess();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
let shuttingDown = false;
|
|
212
|
+
// 프로세스 종료(Ctrl+C, SIGTERM, 마지막 세션 닫힘) — 열린 세션 목록 파일은 그대로 남겨서
|
|
213
|
+
// 컴퓨터·컨테이너가 다시 뜰 때 복구할 수 있게 한다 (일부러 닫은 세션은 이미 목록에서 빠져 있음)
|
|
214
|
+
async function shutdownProcess() {
|
|
215
|
+
if (shuttingDown)
|
|
216
|
+
return;
|
|
217
|
+
shuttingDown = true;
|
|
218
|
+
clearInterval(heartbeat);
|
|
219
|
+
for (const session of [...sessions.values()]) {
|
|
220
|
+
session.closing = true;
|
|
221
|
+
if (session.pendingFlush !== null)
|
|
222
|
+
clearTimeout(session.pendingFlush);
|
|
223
|
+
await flush(session);
|
|
224
|
+
if (session.rowId) {
|
|
225
|
+
try {
|
|
226
|
+
await unregisterDevice(cb, session.rowId);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// 무시 — 폰 목록에서 수동으로 지울 수 있음
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
session.pty.kill();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// 무시
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
sessions.clear();
|
|
240
|
+
stopMessages();
|
|
241
|
+
stopSleepGuard();
|
|
242
|
+
try {
|
|
243
|
+
await channel.unsubscribe();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// 이미 끊겼으면 무시
|
|
247
|
+
}
|
|
248
|
+
cb.realtime.disconnect();
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
// 실시간 연결은 프로세스에 하나만 — userId 는 UUID 형태를 "인증된 멤버 전용"으로 취급해서 거부하므로
|
|
252
|
+
// 접두사를 붙여 UUID 로 보이지 않게 만든다. 프레임 배분은 userId 가 아니라 frame.to 로 한다.
|
|
253
|
+
await cb.realtime.connect({ userId: `agent-${crypto.randomUUID()}` });
|
|
254
|
+
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
255
|
+
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 세션별 키로 암호화해서 주고받는다.
|
|
256
|
+
const stopMessages = channel.onMessage((msg) => {
|
|
257
|
+
const frame = msg.data;
|
|
258
|
+
if (!frame)
|
|
259
|
+
return;
|
|
260
|
+
const session = sessions.get(frame.to);
|
|
261
|
+
if (!session || session.closing)
|
|
262
|
+
return;
|
|
263
|
+
if (frame.kind === 'connect_request') {
|
|
264
|
+
// 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인을 묻기 전에 최근 화면부터 먼저 채워줌
|
|
265
|
+
if (session.scrollback) {
|
|
266
|
+
void send({
|
|
267
|
+
kind: 'output',
|
|
268
|
+
to: session.deviceId,
|
|
269
|
+
data: encryptFrame(session.scrollback, session.frameKey),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (session.approvalInFlight)
|
|
273
|
+
return;
|
|
274
|
+
askApproval(session);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (frame.kind === 'kill') {
|
|
278
|
+
if (!readControl(frame.data, session.frameKey))
|
|
279
|
+
return;
|
|
280
|
+
console.log(`\n📱 폰에서 "${session.name}" 세션을 끄라고 요청했어요 — 정리할게요.`);
|
|
281
|
+
void closeSession(session, { deliberate: true });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (frame.kind === 'list_dirs') {
|
|
285
|
+
const control = readControl(frame.data, session.frameKey);
|
|
286
|
+
if (!control)
|
|
287
|
+
return;
|
|
288
|
+
const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : session.cwd);
|
|
289
|
+
void send({
|
|
290
|
+
kind: 'dirs',
|
|
291
|
+
to: session.deviceId,
|
|
292
|
+
data: encryptFrame(JSON.stringify(listing), session.frameKey),
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (frame.kind === 'new_session') {
|
|
297
|
+
const control = readControl(frame.data, session.frameKey);
|
|
298
|
+
if (!control || typeof control.dir !== 'string')
|
|
299
|
+
return;
|
|
300
|
+
const dir = control.dir;
|
|
301
|
+
void (async () => {
|
|
302
|
+
try {
|
|
303
|
+
const created = await createSession(dir);
|
|
304
|
+
console.log(`\n📱 폰 요청으로 새 세션을 열었어요: "${created.name}" (${created.cwd})`);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
const reason = err.message;
|
|
308
|
+
console.error(`\n📱 폰이 요청한 새 세션을 못 열었어요: ${reason}`);
|
|
309
|
+
await send({
|
|
310
|
+
kind: 'new_session_failed',
|
|
311
|
+
to: session.deviceId,
|
|
312
|
+
data: encryptFrame(reason, session.frameKey),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (frame.kind === 'input' && typeof frame.data === 'string') {
|
|
319
|
+
if (!session.inputApproved)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
session.pty.write(decryptFrame(frame.data, session.frameKey));
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
console.error('입력 복호화 실패 (다른 세션 키로 온 프레임?):', err.message);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (frame.kind === 'resize' && frame.cols && frame.rows) {
|
|
330
|
+
session.pty.resize(frame.cols, frame.rows);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const heartbeat = setInterval(() => {
|
|
334
|
+
for (const session of sessions.values()) {
|
|
335
|
+
if (session.closing || !session.rowId)
|
|
336
|
+
continue;
|
|
337
|
+
heartbeatDevice(cb, session.rowId).catch((err) => console.error(`하트비트 실패(${session.name}):`, err.message));
|
|
338
|
+
}
|
|
339
|
+
}, HEARTBEAT_MS);
|
|
340
|
+
startSleepGuard();
|
|
341
|
+
process.on('SIGINT', () => void shutdownProcess());
|
|
342
|
+
process.on('SIGTERM', () => void shutdownProcess());
|
|
343
|
+
// 복구 목록은 첫 세션을 만들기 전에 읽어둔다 — createSession 이 목록 파일을 현재 상태로 덮어쓰므로,
|
|
344
|
+
// 나중에 읽으면 방금 만든 첫 세션 하나만 남아 이전 세션들이 사라진다 (실제 테스트에서 잡힌 버그)
|
|
345
|
+
const savedRecords = options.restore === false ? [] : loadOpenSessions();
|
|
346
|
+
// ── 첫 세션(사용자가 실행한 그 폴더)
|
|
347
|
+
const primary = await createSession(primaryCwd, options.name ?? defaultName(primaryCwd));
|
|
348
|
+
console.log(`"${primary.name}" 세션을 시작했어요 (작업 폴더: ${primary.cwd})`);
|
|
349
|
+
if (autoApprove) {
|
|
350
|
+
console.log(`⚠️ 자동 승인이 켜져 있어요${accountAutoApprove ? ' (계정 설정)' : ' (--auto-approve)'} — 접속 요청을 묻지 않고 자동 승인해요. 내 계정에 들어올 수 있는 사람은 누구나 곧바로 입력할 수 있어요.`);
|
|
351
|
+
}
|
|
352
|
+
// ── 지난번에 열려 있던 세션 복구 (컴퓨터·컨테이너 재시작 후 자동으로 원래 상태로)
|
|
353
|
+
if (options.restore === false) {
|
|
354
|
+
console.log('이전 세션 복구는 건너뛰어요 (--no-restore)');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const saved = savedRecords.filter((entry) => entry.dir !== primary.cwd);
|
|
358
|
+
if (saved.length === 0)
|
|
359
|
+
return;
|
|
360
|
+
// 같은 컴퓨터에서 `shellbase start` 를 또 실행한 경우, 이미 살아있는 세션을 중복으로 띄우지 않는다
|
|
361
|
+
const liveDirs = await listOnlineDirs(cb).catch(() => new Set());
|
|
362
|
+
for (const entry of saved) {
|
|
363
|
+
if (liveDirs.has(entry.dir)) {
|
|
364
|
+
console.log(` 건너뜀 (이미 켜져 있어요): ${entry.dir}`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const restored = await createSession(entry.dir, entry.name);
|
|
369
|
+
console.log(` 복구: "${restored.name}" (${restored.cwd})`);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
console.log(` 건너뜀 (${err.message})`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { login, logout, whoami } from './auth.js';
|
|
4
|
-
import {
|
|
4
|
+
import { runAgent } from './agent.js';
|
|
5
5
|
// 실시간 기능(realtime.subscribe)이 Node 의 네이티브 WebSocket(Node 22+)에 의존해서,
|
|
6
6
|
// Node 20 에서는 조용히 SSE 모드로 떨어져 "네트워크가 막혔다"는 헷갈리는 에러만 남기고 죽는다.
|
|
7
7
|
// 미리 버전을 확인해서 원인을 바로 알려준다.
|
|
@@ -33,12 +33,13 @@ program
|
|
|
33
33
|
});
|
|
34
34
|
program
|
|
35
35
|
.command('start')
|
|
36
|
-
.description('이 컴퓨터의 터미널을 열어서 폰 접속을 받을 수 있게 시작')
|
|
36
|
+
.description('이 컴퓨터의 터미널을 열어서 폰 접속을 받을 수 있게 시작 (한 프로세스가 여러 세션을 관리해요)')
|
|
37
37
|
.option('--dir <path>', '이 폴더에서 터미널을 시작해요 (기본: 현재 폴더)')
|
|
38
38
|
.option('--name <name>', '폰에서 보여줄 이 기기의 이름 (기본: 컴퓨터 이름)')
|
|
39
39
|
.option('--auto-approve', '폰에서 접속할 때마다 묻지 않고 자동으로 승인해요 (편하지만, 내 계정에 접속하는 누구나 곧바로 입력할 수 있게 되니 주의)')
|
|
40
|
+
.option('--no-restore', '지난번에 열려 있던 세션들을 자동으로 다시 띄우지 않아요')
|
|
40
41
|
.action(async (opts) => {
|
|
41
|
-
await
|
|
42
|
+
await runAgent(opts);
|
|
42
43
|
});
|
|
43
44
|
program.parseAsync(process.argv).catch((err) => {
|
|
44
45
|
console.error('실행 중 문제가 생겼어요:', err instanceof Error ? err.message : err);
|
package/dist/devices.js
CHANGED
|
@@ -27,3 +27,22 @@ export async function heartbeatDevice(cb, rowId) {
|
|
|
27
27
|
export async function unregisterDevice(cb, rowId) {
|
|
28
28
|
await cb.database.deleteData(DEVICES_TABLE_ID, rowId);
|
|
29
29
|
}
|
|
30
|
+
// 복구할 때 "이미 다른 프로세스가 띄워둔 세션"을 또 띄우지 않도록, 지금 살아있는 세션들의 작업 폴더를 모은다.
|
|
31
|
+
// (같은 컴퓨터에서 `shellbase start` 를 두 번 실행하는 경우까지 안전하게 처리하기 위함)
|
|
32
|
+
const ONLINE_WINDOW_MS = 30_000;
|
|
33
|
+
export async function listOnlineDirs(cb) {
|
|
34
|
+
const dirs = new Set();
|
|
35
|
+
const { data } = await cb.database.getData(DEVICES_TABLE_ID, {
|
|
36
|
+
orderBy: 'last_seen_at',
|
|
37
|
+
orderDirection: 'desc',
|
|
38
|
+
limit: 100,
|
|
39
|
+
});
|
|
40
|
+
for (const row of data) {
|
|
41
|
+
if (row.data.status !== 'online')
|
|
42
|
+
continue;
|
|
43
|
+
if (Date.now() - new Date(row.data.last_seen_at).getTime() > ONLINE_WINDOW_MS)
|
|
44
|
+
continue;
|
|
45
|
+
dirs.add(row.data.cwd);
|
|
46
|
+
}
|
|
47
|
+
return dirs;
|
|
48
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_DIR } from './config.js';
|
|
4
|
+
// 지금 열려 있는 세션 목록을 파일로 남겨둬서, 컴퓨터·컨테이너가 재시작돼도 다시 띄울 수 있게 한다.
|
|
5
|
+
// (devices 테이블 row 는 정상 종료 시 지워지도록 설계돼 있어서 복구 근거로 쓸 수 없음 — §8.8)
|
|
6
|
+
const OPEN_SESSIONS_PATH = path.join(CONFIG_DIR, 'open-sessions.json');
|
|
7
|
+
export function loadOpenSessions() {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(fs.readFileSync(OPEN_SESSIONS_PATH, 'utf8'));
|
|
10
|
+
if (!Array.isArray(parsed))
|
|
11
|
+
return [];
|
|
12
|
+
return parsed.filter((entry) => entry && typeof entry.dir === 'string' && typeof entry.name === 'string');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function saveOpenSessions(records) {
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
fs.writeFileSync(OPEN_SESSIONS_PATH, JSON.stringify(records, null, 2), { mode: 0o600 });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
// 저장에 실패해도 세션 자체는 정상 동작해야 함 (복구만 못 하게 됨)
|
|
25
|
+
console.error('열린 세션 목록 저장 실패:', err.message);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
CHANGED
package/dist/session.js
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import * as pty from 'node-pty';
|
|
5
|
-
import { requireClient } from './auth.js';
|
|
6
|
-
import { TERMINAL_CATEGORY, CONTROL_TOKEN } from './config.js';
|
|
7
|
-
import { startSleepGuard, stopSleepGuard } from './sleep-guard.js';
|
|
8
|
-
import { encryptFrame, decryptFrame, generateFrameKey } from './crypto.js';
|
|
9
|
-
import { registerDevice, heartbeatDevice, unregisterDevice } from './devices.js';
|
|
10
|
-
import { promptApproval } from './prompt.js';
|
|
11
|
-
import { touchRecentDir } from './recent-dirs.js';
|
|
12
|
-
import { spawnSession } from './spawner.js';
|
|
13
|
-
import { listDirs } from './browse.js';
|
|
14
|
-
const HEARTBEAT_MS = 20_000;
|
|
15
|
-
const APPROVAL_TIMEOUT_MS = 30_000;
|
|
16
|
-
const SCROLLBACK_MAX = 16_000;
|
|
17
|
-
function resolveShell() {
|
|
18
|
-
if (process.platform === 'win32')
|
|
19
|
-
return process.env.COMSPEC ?? 'powershell.exe';
|
|
20
|
-
return process.env.SHELL ?? '/bin/bash';
|
|
21
|
-
}
|
|
22
|
-
// 폰이 보내는 제어 프레임(끄기 / 새 세션)은 이 세션의 암호화 키로 풀려야만 받아들인다.
|
|
23
|
-
// 키는 devices 테이블의 내 row 에만 있고 RLS 로 주인만 읽을 수 있으니, 복호화에 성공했다는 것
|
|
24
|
-
// 자체가 "주인이 보낸 요청"이라는 증거가 된다 (입력 승인과 별개 — 승인은 타이핑 권한용).
|
|
25
|
-
function readControl(data, key) {
|
|
26
|
-
if (typeof data !== 'string')
|
|
27
|
-
return null;
|
|
28
|
-
try {
|
|
29
|
-
const parsed = JSON.parse(decryptFrame(data, key));
|
|
30
|
-
if (!parsed || parsed.token !== CONTROL_TOKEN)
|
|
31
|
-
return null;
|
|
32
|
-
return parsed;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
export async function startSession(options) {
|
|
39
|
-
const { cb, memberId } = await requireClient();
|
|
40
|
-
const cwd = options.dir ? path.resolve(options.dir) : process.cwd();
|
|
41
|
-
// 폴더 이름을 기본 이름에 넣어서, 같은 컴퓨터에서 여러 개 띄워도 폰 목록에서 구분되게 한다.
|
|
42
|
-
const defaultName = cwd === os.homedir() ? os.hostname() : `${os.hostname()} · ${path.basename(cwd)}`;
|
|
43
|
-
const deviceName = options.name ?? defaultName;
|
|
44
|
-
// realtime userId 는 UUID 형태를 "인증된 멤버 전용"으로 취급해서 거부하므로, 세션 식별자는
|
|
45
|
-
// 접두사를 붙여 UUID 로 보이지 않게 만든다. `shellbase start` 를 실행할 때마다 새로 발급 —
|
|
46
|
-
// 예전엔 컴퓨터당 하나로 고정돼있어서, 같은 컴퓨터에서 두 개를 동시에 띄우면 프레임이 서로
|
|
47
|
-
// 섞였음(둘 다 같은 deviceId 를 썼기 때문). 이제 세션(프로세스)마다 독립된 식별자를 가짐.
|
|
48
|
-
const device = {
|
|
49
|
-
deviceId: `desktop-${crypto.randomUUID()}`,
|
|
50
|
-
deviceName,
|
|
51
|
-
frameKey: generateFrameKey(),
|
|
52
|
-
};
|
|
53
|
-
// 계정 설정(웹의 "새 세션 자동 승인" 토글, custom_data.auto_approve)이 켜져 있으면
|
|
54
|
-
// --auto-approve 를 안 줘도 자동 승인. 계정 단위라 이 계정으로 로그인한 모든 컴퓨터에 적용됨.
|
|
55
|
-
const me = await cb.auth.getMe();
|
|
56
|
-
const accountAutoApprove = me.custom_data?.auto_approve === true;
|
|
57
|
-
const autoApprove = options.autoApprove === true || accountAutoApprove;
|
|
58
|
-
console.log(`"${deviceName}" 세션을 시작해요 (작업 폴더: ${cwd})`);
|
|
59
|
-
if (autoApprove) {
|
|
60
|
-
console.log(`⚠️ 자동 승인이 켜져 있어요${accountAutoApprove ? ' (계정 설정)' : ' (--auto-approve)'} — 접속 요청을 묻지 않고 자동 승인해요. 내 계정에 들어올 수 있는 사람은 누구나 곧바로 입력할 수 있어요.`);
|
|
61
|
-
}
|
|
62
|
-
await cb.realtime.connect({ userId: device.deviceId });
|
|
63
|
-
const channel = await cb.realtime.subscribe(TERMINAL_CATEGORY);
|
|
64
|
-
// 이 채널은 앱의 모든 멤버가 공유하므로, 프레임 내용(data)은 기기별 키로 암호화해서 주고받는다.
|
|
65
|
-
// 다른 사람이 같은 채널을 구독해도 to/kind 정도만 보이고 실제 터미널 내용은 못 읽음.
|
|
66
|
-
const shell = resolveShell();
|
|
67
|
-
const ptyProcess = pty.spawn(shell, [], {
|
|
68
|
-
name: 'xterm-256color',
|
|
69
|
-
cols: 80,
|
|
70
|
-
rows: 24,
|
|
71
|
-
cwd,
|
|
72
|
-
env: process.env,
|
|
73
|
-
});
|
|
74
|
-
// PTY 출력은 모아서 전송 — 서버가 전송 속도를 최소 100ms 간격으로 제한해서(RATE_LIMITED), 그보다
|
|
75
|
-
// 짧은 간격으로 보내면 출력이 많은 순간(로그 스크롤 등)에 전송이 거부될 수 있음. 그렇다고 매번
|
|
76
|
-
// 고정 간격을 기다리면 한두 글자짜리 출력도 불필요하게 지연되니, "마지막 전송 후 이 간격이
|
|
77
|
-
// 지났으면 바로 보내고, 아니면 그만큼만 기다렸다 모아서 보낸다" 방식으로 평소 지연은 최소화하고
|
|
78
|
-
// 출력이 몰릴 때만 자동으로 묶이게 한다.
|
|
79
|
-
const MIN_SEND_INTERVAL_MS = 120;
|
|
80
|
-
let outBuffer = '';
|
|
81
|
-
let lastSentAt = 0;
|
|
82
|
-
let pendingFlush = null;
|
|
83
|
-
const flush = async () => {
|
|
84
|
-
pendingFlush = null;
|
|
85
|
-
if (!outBuffer)
|
|
86
|
-
return;
|
|
87
|
-
const chunk = outBuffer;
|
|
88
|
-
outBuffer = '';
|
|
89
|
-
lastSentAt = Date.now();
|
|
90
|
-
try {
|
|
91
|
-
await channel.send({
|
|
92
|
-
kind: 'output',
|
|
93
|
-
to: device.deviceId,
|
|
94
|
-
data: encryptFrame(chunk, device.frameKey),
|
|
95
|
-
}, { includeSelf: false });
|
|
96
|
-
}
|
|
97
|
-
catch (err) {
|
|
98
|
-
console.error('출력 전송 실패:', err.message);
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
const scheduleFlush = () => {
|
|
102
|
-
if (pendingFlush !== null)
|
|
103
|
-
return;
|
|
104
|
-
const wait = Math.max(0, MIN_SEND_INTERVAL_MS - (Date.now() - lastSentAt));
|
|
105
|
-
pendingFlush = setTimeout(() => void flush(), wait);
|
|
106
|
-
};
|
|
107
|
-
// 최근 출력을 조금 들고 있다가, 모바일이 (재)접속했을 때 놓친 화면 대신 다시 보여줌
|
|
108
|
-
let scrollback = '';
|
|
109
|
-
ptyProcess.onData((data) => {
|
|
110
|
-
outBuffer += data;
|
|
111
|
-
scrollback = (scrollback + data).slice(-SCROLLBACK_MAX);
|
|
112
|
-
scheduleFlush();
|
|
113
|
-
});
|
|
114
|
-
// 모바일이 접속을 시도하면 데스크톱에서 승인해야만 입력이 먹힘 (읽기/출력은 항상 흐름 —
|
|
115
|
-
// 이미 같은 계정으로 로그인했다는 전제이므로, 승인은 "지금 이 순간 내가 타이핑을 넘겨줄지"
|
|
116
|
-
// 확인하는 용도). 승인 전에 온 input 프레임은 무시.
|
|
117
|
-
let inputApproved = false;
|
|
118
|
-
let approvalInFlight = false;
|
|
119
|
-
const stopMessages = channel.onMessage((msg) => {
|
|
120
|
-
const frame = msg.data;
|
|
121
|
-
if (!frame || frame.to !== device.deviceId)
|
|
122
|
-
return;
|
|
123
|
-
if (frame.kind === 'connect_request') {
|
|
124
|
-
// 화면 보기는 승인 여부와 무관하게 항상 되므로, 승인 물어보기 전에 최근 화면부터 먼저 채워줌
|
|
125
|
-
// (끊겼다 재접속한 경우든, 처음 들어온 경우든 동일하게 "방금까지 뭐가 있었는지" 보여줌)
|
|
126
|
-
if (scrollback) {
|
|
127
|
-
channel
|
|
128
|
-
.send({ kind: 'output', to: device.deviceId, data: encryptFrame(scrollback, device.frameKey) }, { includeSelf: false })
|
|
129
|
-
.catch((err) => console.error('화면 복구 전송 실패:', err.message));
|
|
130
|
-
}
|
|
131
|
-
if (approvalInFlight)
|
|
132
|
-
return;
|
|
133
|
-
approvalInFlight = true;
|
|
134
|
-
const approval = autoApprove
|
|
135
|
-
? Promise.resolve(true)
|
|
136
|
-
: promptApproval('\n📱 모바일에서 접속을 요청했어요. 입력을 허용할까요? (y/N, 30초 내 미응답 시 자동 거부): ', APPROVAL_TIMEOUT_MS);
|
|
137
|
-
approval
|
|
138
|
-
.then((approved) => {
|
|
139
|
-
inputApproved = approved;
|
|
140
|
-
approvalInFlight = false;
|
|
141
|
-
return channel.send({ kind: approved ? 'connect_approved' : 'connect_denied', to: device.deviceId }, { includeSelf: false });
|
|
142
|
-
})
|
|
143
|
-
.catch((err) => console.error('접속 승인 처리 실패:', err.message));
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// 폰에서 "이 세션 끄기" — 정상 종료 경로(shutdown)를 그대로 타서 목록에서도 스스로 지워진다.
|
|
147
|
-
if (frame.kind === 'kill') {
|
|
148
|
-
if (!readControl(frame.data, device.frameKey))
|
|
149
|
-
return;
|
|
150
|
-
console.log('\n📱 폰에서 이 세션을 끄라고 요청했어요 — 정리하고 종료할게요.');
|
|
151
|
-
void shutdown();
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
// 폰의 폴더 탐색기 — 요청받은 경로의 하위 폴더 목록을 암호화해서 돌려준다 (세션 열 곳 고르기용)
|
|
155
|
-
if (frame.kind === 'list_dirs') {
|
|
156
|
-
const control = readControl(frame.data, device.frameKey);
|
|
157
|
-
if (!control)
|
|
158
|
-
return;
|
|
159
|
-
const listing = listDirs(typeof control.dir === 'string' && control.dir ? control.dir : cwd);
|
|
160
|
-
channel
|
|
161
|
-
.send({
|
|
162
|
-
kind: 'dirs',
|
|
163
|
-
to: device.deviceId,
|
|
164
|
-
data: encryptFrame(JSON.stringify(listing), device.frameKey),
|
|
165
|
-
}, { includeSelf: false })
|
|
166
|
-
.catch((err) => console.error('폴더 목록 전송 실패:', err.message));
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
// 폰에서 "새 세션 열기" — 이 세션이 CLI 를 한 번 더 띄워주고, 새 세션은 폰 목록에 따로 나타난다.
|
|
170
|
-
if (frame.kind === 'new_session') {
|
|
171
|
-
const control = readControl(frame.data, device.frameKey);
|
|
172
|
-
if (!control || typeof control.dir !== 'string')
|
|
173
|
-
return;
|
|
174
|
-
// 이름 앞부분(컴퓨터를 가리키는 부분)만 물려줘서 "GB10 (Docker) · shellbase" 처럼 나오게 함
|
|
175
|
-
const result = spawnSession(control.dir, deviceName.split(' · ')[0]);
|
|
176
|
-
if (result.ok) {
|
|
177
|
-
console.log(`\n📱 폰 요청으로 새 세션을 열었어요 (${control.dir})`);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
console.error(`\n📱 폰이 요청한 새 세션을 못 열었어요: ${result.reason}`);
|
|
181
|
-
channel
|
|
182
|
-
.send({
|
|
183
|
-
kind: 'new_session_failed',
|
|
184
|
-
to: device.deviceId,
|
|
185
|
-
data: encryptFrame(result.reason, device.frameKey),
|
|
186
|
-
}, { includeSelf: false })
|
|
187
|
-
.catch((err) => console.error('실패 알림 전송 실패:', err.message));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (frame.kind === 'input' && typeof frame.data === 'string') {
|
|
191
|
-
if (!inputApproved)
|
|
192
|
-
return;
|
|
193
|
-
try {
|
|
194
|
-
ptyProcess.write(decryptFrame(frame.data, device.frameKey));
|
|
195
|
-
}
|
|
196
|
-
catch (err) {
|
|
197
|
-
console.error('입력 복호화 실패 (다른 기기 키로 보낸 프레임?):', err.message);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
else if (frame.kind === 'resize' && frame.cols && frame.rows) {
|
|
201
|
-
ptyProcess.resize(frame.cols, frame.rows);
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
// 목록 등록은 "이제 프레임을 받을 수 있다"가 확실해진 지금(구독 + 메시지 핸들러 준비 완료) 한다.
|
|
205
|
-
// 예전엔 실시간 연결보다 먼저 등록해서, 폰이 목록에 뜨자마자 보낸 요청(특히 "세션 끄기")이
|
|
206
|
-
// 아직 구독 전인 세션에 닿지 못하고 그대로 사라지는 구간이 있었음 (실시간 프레임은 저장되지 않음).
|
|
207
|
-
const deviceRowId = await registerDevice(cb, {
|
|
208
|
-
ownerId: memberId,
|
|
209
|
-
deviceId: device.deviceId,
|
|
210
|
-
deviceName,
|
|
211
|
-
cwd,
|
|
212
|
-
frameKey: device.frameKey,
|
|
213
|
-
favoriteDirs: touchRecentDir(cwd),
|
|
214
|
-
});
|
|
215
|
-
const heartbeat = setInterval(() => {
|
|
216
|
-
heartbeatDevice(cb, deviceRowId).catch((err) => console.error('하트비트 실패:', err.message));
|
|
217
|
-
}, HEARTBEAT_MS);
|
|
218
|
-
startSleepGuard();
|
|
219
|
-
let shuttingDown = false;
|
|
220
|
-
const shutdown = async () => {
|
|
221
|
-
if (shuttingDown)
|
|
222
|
-
return;
|
|
223
|
-
shuttingDown = true;
|
|
224
|
-
if (pendingFlush !== null)
|
|
225
|
-
clearTimeout(pendingFlush);
|
|
226
|
-
clearInterval(heartbeat);
|
|
227
|
-
await flush();
|
|
228
|
-
stopMessages();
|
|
229
|
-
stopSleepGuard();
|
|
230
|
-
try {
|
|
231
|
-
await channel.unsubscribe();
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
// 이미 끊겼으면 무시
|
|
235
|
-
}
|
|
236
|
-
try {
|
|
237
|
-
await unregisterDevice(cb, deviceRowId);
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
// 삭제 실패해도 종료는 계속 진행 — 폰 목록에 남아있으면 나중에 수동으로 지울 수 있음
|
|
241
|
-
}
|
|
242
|
-
cb.realtime.disconnect();
|
|
243
|
-
ptyProcess.kill();
|
|
244
|
-
process.exit(0);
|
|
245
|
-
};
|
|
246
|
-
process.on('SIGINT', () => void shutdown());
|
|
247
|
-
process.on('SIGTERM', () => void shutdown());
|
|
248
|
-
ptyProcess.onExit(() => {
|
|
249
|
-
console.log('셸이 종료돼서 세션도 함께 종료해요.');
|
|
250
|
-
void shutdown();
|
|
251
|
-
});
|
|
252
|
-
}
|
package/dist/spawner.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
// nameLabel: 폰 목록에서 부모 세션과 같은 컴퓨터임을 알아보게 물려받는 이름 앞부분.
|
|
15
|
-
// 도커 컨테이너 안에서는 os.hostname() 이 컨테이너 ID(예: 7a54a499a8f3)로 나와서 기본 이름이
|
|
16
|
-
// "7a54a499a8f3 · shellbase" 처럼 알아보기 어려워짐 — 그래서 부모 이름을 물려준다.
|
|
17
|
-
export function spawnSession(dir, nameLabel) {
|
|
18
|
-
const target = path.resolve(dir);
|
|
19
|
-
let stat;
|
|
20
|
-
try {
|
|
21
|
-
stat = fs.statSync(target);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return { ok: false, reason: `그런 폴더가 없어요: ${target}` };
|
|
25
|
-
}
|
|
26
|
-
if (!stat.isDirectory())
|
|
27
|
-
return { ok: false, reason: `폴더가 아니라 파일이에요: ${target}` };
|
|
28
|
-
try {
|
|
29
|
-
// 폰에서 띄우는 세션은 승인 물음에 대답할 터미널이 없다(stdio: 'ignore') — 그대로 두면 30초 뒤
|
|
30
|
-
// 자동 거부돼서 화면만 보이고 타이핑이 안 되는 세션이 된다. 요청을 보낸 쪽은 이미 이 세션의
|
|
31
|
-
// 암호화 키(주인만 읽을 수 있는 devices row)를 가지고 있음이 확인된 상태라 자동 승인으로 띄운다.
|
|
32
|
-
const args = [resolveEntry(), 'start', '--dir', target, '--auto-approve'];
|
|
33
|
-
if (nameLabel)
|
|
34
|
-
args.push('--name', `${nameLabel} · ${path.basename(target)}`);
|
|
35
|
-
const child = spawn(process.execPath, args, {
|
|
36
|
-
cwd: target,
|
|
37
|
-
env: process.env,
|
|
38
|
-
// 부모 세션을 Ctrl+C 로 껐을 때 폰에서 따로 만든 세션까지 같이 죽지 않도록 독립 프로세스로 띄움
|
|
39
|
-
// (각 세션은 폰 목록에서 개별적으로 끌 수 있음). 도커 컨테이너 안이라면 컨테이너가 멈출 때 함께 정리됨.
|
|
40
|
-
detached: true,
|
|
41
|
-
stdio: 'ignore',
|
|
42
|
-
});
|
|
43
|
-
child.unref();
|
|
44
|
-
return { ok: true };
|
|
45
|
-
}
|
|
46
|
-
catch (err) {
|
|
47
|
-
return { ok: false, reason: err.message };
|
|
48
|
-
}
|
|
49
|
-
}
|