deckide 3.0.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/LICENSE +21 -0
- package/README.md +192 -0
- package/apps/server/dist/config.js +77 -0
- package/apps/server/dist/index.js +5 -0
- package/apps/server/dist/middleware/auth.js +78 -0
- package/apps/server/dist/middleware/cors.js +26 -0
- package/apps/server/dist/middleware/security.js +16 -0
- package/apps/server/dist/pty-client.js +177 -0
- package/apps/server/dist/pty-daemon.js +246 -0
- package/apps/server/dist/routes/decks.js +95 -0
- package/apps/server/dist/routes/files.js +221 -0
- package/apps/server/dist/routes/git.js +775 -0
- package/apps/server/dist/routes/settings.js +95 -0
- package/apps/server/dist/routes/terminals.js +239 -0
- package/apps/server/dist/routes/workspaces.js +83 -0
- package/apps/server/dist/server.js +257 -0
- package/apps/server/dist/types.js +1 -0
- package/apps/server/dist/utils/database.js +136 -0
- package/apps/server/dist/utils/error.js +28 -0
- package/apps/server/dist/utils/path.js +98 -0
- package/apps/server/dist/utils/shell.js +4 -0
- package/apps/server/dist/websocket.js +207 -0
- package/apps/server/package.json +26 -0
- package/apps/web/dist/assets/index-C-usl0y0.css +32 -0
- package/apps/web/dist/assets/index-CibzlLP5.js +129 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/deckide.js +79 -0
- package/package.json +77 -0
- package/packages/shared/dist/types.d.ts +124 -0
- package/packages/shared/dist/types.d.ts.map +1 -0
- package/packages/shared/dist/types.js +3 -0
- package/packages/shared/dist/types.js.map +1 -0
- package/packages/shared/dist/utils-node.d.ts +22 -0
- package/packages/shared/dist/utils-node.d.ts.map +1 -0
- package/packages/shared/dist/utils-node.js +35 -0
- package/packages/shared/dist/utils-node.js.map +1 -0
- package/packages/shared/dist/utils.d.ts +90 -0
- package/packages/shared/dist/utils.d.ts.map +1 -0
- package/packages/shared/dist/utils.js +186 -0
- package/packages/shared/dist/utils.js.map +1 -0
- package/packages/shared/package.json +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 tako0614
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Deck IDE
|
|
2
|
+
|
|
3
|
+
複数のターミナルを並列で管理できる軽量なWeb IDE。AIエージェント(Claude Code、Codex CLI等)を永続的に動かすことに最適化されています。
|
|
4
|
+
|
|
5
|
+
## 特徴
|
|
6
|
+
|
|
7
|
+
- **マルチターミナル** - 複数のターミナルを同時に起動・管理
|
|
8
|
+
- **Monaco Editor** - VS Codeと同じエディタエンジンによるコード編集
|
|
9
|
+
- **Git統合** - ステージング、コミット、プッシュ、プル、ブランチ管理
|
|
10
|
+
- **マルチリポジトリ対応** - ワークスペース内の複数Gitリポジトリを自動検出
|
|
11
|
+
- **ファイルエクスプローラー** - ファイル・フォルダの作成、削除、名前変更
|
|
12
|
+
- **Diffビューア** - 変更ファイルの差分表示
|
|
13
|
+
- **Windowsデスクトップアプリ** - Electronベースのネイティブアプリ(自動アップデート対応)
|
|
14
|
+
|
|
15
|
+
## スクリーンショット
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
19
|
+
│ [エクスプローラー] [Git] [設定] Deck IDE │
|
|
20
|
+
├─────────┬───────────────────────────────────────────────────┤
|
|
21
|
+
│ ファイル │ Monaco Editor │
|
|
22
|
+
│ ツリー │ │
|
|
23
|
+
│ │ │
|
|
24
|
+
│ ├───────────────────────────────────────────────────┤
|
|
25
|
+
│ │ Terminal 1 │ Terminal 2 │
|
|
26
|
+
│ │ $ claude │ $ codex │
|
|
27
|
+
│ │ ... │ ... │
|
|
28
|
+
└─────────┴───────────────────────────────────────────────────┘
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## インストール
|
|
32
|
+
|
|
33
|
+
### Windowsデスクトップアプリ(推奨)
|
|
34
|
+
|
|
35
|
+
[Releases](https://github.com/tako0614/ide/releases)から最新の`Deck-IDE-Setup-x.x.x.exe`をダウンロードしてインストール。
|
|
36
|
+
|
|
37
|
+
### ソースから実行
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# リポジトリをクローン
|
|
41
|
+
git clone https://github.com/tako0614/ide.git
|
|
42
|
+
cd ide
|
|
43
|
+
|
|
44
|
+
# 依存関係をインストール
|
|
45
|
+
npm install
|
|
46
|
+
|
|
47
|
+
# 開発モードで起動(Web + Server)
|
|
48
|
+
npm run dev:server # ターミナル1
|
|
49
|
+
npm run dev:web # ターミナル2
|
|
50
|
+
|
|
51
|
+
# またはビルドして起動
|
|
52
|
+
npm run serve
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
ブラウザで http://localhost:3210 を開く。
|
|
56
|
+
|
|
57
|
+
## プロジェクト構成
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
ide/
|
|
61
|
+
├── apps/
|
|
62
|
+
│ ├── web/ # フロントエンド (React + Vite)
|
|
63
|
+
│ ├── server/ # バックエンド (Hono + node-pty)
|
|
64
|
+
│ └── desktop/ # Electronアプリ
|
|
65
|
+
├── packages/
|
|
66
|
+
│ └── shared/ # 共有型定義
|
|
67
|
+
└── docs/ # ドキュメント
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 技術スタック
|
|
71
|
+
|
|
72
|
+
### フロントエンド
|
|
73
|
+
- React 19
|
|
74
|
+
- Vite
|
|
75
|
+
- Monaco Editor
|
|
76
|
+
- xterm.js (WebGL対応)
|
|
77
|
+
- TypeScript
|
|
78
|
+
|
|
79
|
+
### バックエンド
|
|
80
|
+
- Hono (高速HTTPフレームワーク)
|
|
81
|
+
- node-pty (疑似ターミナル)
|
|
82
|
+
- simple-git (Git操作)
|
|
83
|
+
- WebSocket (ターミナル通信)
|
|
84
|
+
- SQLite (データ永続化)
|
|
85
|
+
|
|
86
|
+
### デスクトップ
|
|
87
|
+
- Electron 40
|
|
88
|
+
- electron-builder
|
|
89
|
+
- electron-updater (自動アップデート)
|
|
90
|
+
|
|
91
|
+
## 使い方
|
|
92
|
+
|
|
93
|
+
### ワークスペースの追加
|
|
94
|
+
|
|
95
|
+
1. 左サイドバーの「+」ボタンをクリック
|
|
96
|
+
2. ディレクトリパスを入力(例: `C:\Projects\myapp`)
|
|
97
|
+
3. ワークスペースが追加される
|
|
98
|
+
|
|
99
|
+
### デッキの作成
|
|
100
|
+
|
|
101
|
+
1. ワークスペースを選択
|
|
102
|
+
2. 「デッキ作成」ボタンをクリック
|
|
103
|
+
3. デッキ名を入力
|
|
104
|
+
|
|
105
|
+
### ターミナルの使用
|
|
106
|
+
|
|
107
|
+
- 「ターミナル追加」: 新しいターミナルを開く
|
|
108
|
+
- 「Claude」: `claude` コマンドを実行するターミナルを開く
|
|
109
|
+
- 「Codex」: `codex` コマンドを実行するターミナルを開く
|
|
110
|
+
|
|
111
|
+
### Git操作
|
|
112
|
+
|
|
113
|
+
1. サイドナビでGitアイコンをクリック
|
|
114
|
+
2. 変更ファイルを確認
|
|
115
|
+
3. 「+」でステージング、「−」でアンステージ
|
|
116
|
+
4. コミットメッセージを入力してコミット
|
|
117
|
+
5. プッシュ/プル/フェッチボタンで同期
|
|
118
|
+
|
|
119
|
+
## 開発
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# 依存関係のインストール
|
|
123
|
+
npm install
|
|
124
|
+
|
|
125
|
+
# 開発サーバー起動
|
|
126
|
+
npm run dev:server # サーバー (port 3210)
|
|
127
|
+
npm run dev:web # Web (port 5173)
|
|
128
|
+
|
|
129
|
+
# ビルド
|
|
130
|
+
npm run build:web # Webのみ
|
|
131
|
+
npm run build:server # サーバーのみ
|
|
132
|
+
npm run build:desktop # デスクトップアプリ
|
|
133
|
+
|
|
134
|
+
# デスクトップアプリの開発
|
|
135
|
+
npm run dev:desktop
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## 環境変数
|
|
139
|
+
|
|
140
|
+
サーバーは以下の環境変数をサポートします:
|
|
141
|
+
|
|
142
|
+
| 変数名 | 説明 | デフォルト |
|
|
143
|
+
|--------|------|-----------|
|
|
144
|
+
| `PORT` | サーバーポート | 3210 |
|
|
145
|
+
| `HOST` | バインドアドレス | localhost |
|
|
146
|
+
| `BASIC_AUTH_USER` | Basic認証ユーザー名 | (なし) |
|
|
147
|
+
| `BASIC_AUTH_PASSWORD` | Basic認証パスワード | (なし) |
|
|
148
|
+
| `CORS_ORIGIN` | CORS許可オリジン | * (開発時) |
|
|
149
|
+
| `DEFAULT_ROOT` | デフォルトルートパス | (なし) |
|
|
150
|
+
|
|
151
|
+
## API
|
|
152
|
+
|
|
153
|
+
### ワークスペース
|
|
154
|
+
- `GET /api/workspaces` - ワークスペース一覧
|
|
155
|
+
- `POST /api/workspaces` - ワークスペース作成
|
|
156
|
+
|
|
157
|
+
### デッキ
|
|
158
|
+
- `GET /api/decks` - デッキ一覧
|
|
159
|
+
- `POST /api/decks` - デッキ作成
|
|
160
|
+
|
|
161
|
+
### ファイル
|
|
162
|
+
- `GET /api/files` - ファイル一覧
|
|
163
|
+
- `GET /api/file` - ファイル読み込み
|
|
164
|
+
- `PUT /api/file` - ファイル保存
|
|
165
|
+
- `POST /api/file` - ファイル作成
|
|
166
|
+
- `DELETE /api/file` - ファイル削除
|
|
167
|
+
|
|
168
|
+
### ターミナル
|
|
169
|
+
- `GET /api/terminals` - ターミナル一覧
|
|
170
|
+
- `POST /api/terminals` - ターミナル作成
|
|
171
|
+
- `DELETE /api/terminals/:id` - ターミナル削除
|
|
172
|
+
- `WS /api/terminals/:id` - ターミナル接続
|
|
173
|
+
|
|
174
|
+
### Git
|
|
175
|
+
- `GET /api/git/status` - Gitステータス
|
|
176
|
+
- `GET /api/git/repos` - リポジトリ一覧
|
|
177
|
+
- `POST /api/git/stage` - ステージング
|
|
178
|
+
- `POST /api/git/unstage` - アンステージ
|
|
179
|
+
- `POST /api/git/commit` - コミット
|
|
180
|
+
- `POST /api/git/push` - プッシュ
|
|
181
|
+
- `POST /api/git/pull` - プル
|
|
182
|
+
- `GET /api/git/diff` - 差分取得
|
|
183
|
+
- `GET /api/git/branches` - ブランチ一覧
|
|
184
|
+
- `POST /api/git/checkout` - ブランチ切り替え
|
|
185
|
+
|
|
186
|
+
## ライセンス
|
|
187
|
+
|
|
188
|
+
MIT
|
|
189
|
+
|
|
190
|
+
## 作者
|
|
191
|
+
|
|
192
|
+
[tako0614](https://github.com/tako0614)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
// When running as a global CLI (`deckide`), DECKIDE_DATA_DIR is set to ~/.deckide/
|
|
7
|
+
const globalDataDir = process.env.DECKIDE_DATA_DIR;
|
|
8
|
+
export const SETTINGS_FILE = globalDataDir
|
|
9
|
+
? path.join(globalDataDir, 'settings.json')
|
|
10
|
+
: path.join(__dirname, '..', '..', 'settings.json');
|
|
11
|
+
let fileSettings = {};
|
|
12
|
+
try {
|
|
13
|
+
const settingsData = fsSync.readFileSync(SETTINGS_FILE, 'utf-8');
|
|
14
|
+
fileSettings = JSON.parse(settingsData);
|
|
15
|
+
console.log('[CONFIG] Loaded settings from file');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// No settings file, use environment variables
|
|
19
|
+
}
|
|
20
|
+
function parseIntEnv(val, defaultVal) {
|
|
21
|
+
if (typeof val === 'number')
|
|
22
|
+
return Number.isFinite(val) ? Math.floor(val) : defaultVal;
|
|
23
|
+
const n = parseInt(val ?? '', 10);
|
|
24
|
+
return Number.isFinite(n) ? n : defaultVal;
|
|
25
|
+
}
|
|
26
|
+
export const DEFAULT_ROOT = process.env.DEFAULT_ROOT || os.homedir();
|
|
27
|
+
export const PORT = parseIntEnv(process.env.PORT ?? fileSettings.port, 8787);
|
|
28
|
+
export const HOST = process.env.HOST || '0.0.0.0';
|
|
29
|
+
export const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || (fileSettings.basicAuthEnabled ? fileSettings.basicAuthUser : undefined);
|
|
30
|
+
export const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || (fileSettings.basicAuthEnabled ? fileSettings.basicAuthPassword : undefined);
|
|
31
|
+
export const CORS_ORIGIN = process.env.CORS_ORIGIN;
|
|
32
|
+
export const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
33
|
+
export const MAX_FILE_SIZE = parseIntEnv(process.env.MAX_FILE_SIZE, 10 * 1024 * 1024);
|
|
34
|
+
export const TERMINAL_BUFFER_LIMIT = parseIntEnv(process.env.TERMINAL_BUFFER_LIMIT, 500_000);
|
|
35
|
+
export const MAX_REQUEST_BODY_SIZE = parseIntEnv(process.env.MAX_REQUEST_BODY_SIZE, 1024 * 1024); // 1MB default
|
|
36
|
+
export const TRUST_PROXY = process.env.TRUST_PROXY === 'true'; // Only trust proxy headers if explicitly enabled
|
|
37
|
+
// In packaged app: server is at app.asar.unpacked/server/, web is at app.asar.unpacked/web/dist/
|
|
38
|
+
// In development: server is at apps/server/dist/, web is at apps/web/dist/
|
|
39
|
+
const packagedDistDir = path.resolve(__dirname, '..', 'web', 'dist');
|
|
40
|
+
const devDistDir = path.resolve(__dirname, '..', '..', 'web', 'dist');
|
|
41
|
+
export const distDir = fsSync.existsSync(packagedDistDir) ? packagedDistDir : devDistDir;
|
|
42
|
+
export const hasStatic = fsSync.existsSync(distDir);
|
|
43
|
+
const packagedDataDir = path.resolve(__dirname, '..', 'data');
|
|
44
|
+
const devDataDir = path.resolve(__dirname, '..', '..', 'data');
|
|
45
|
+
export const dataDir = globalDataDir
|
|
46
|
+
? globalDataDir
|
|
47
|
+
: fsSync.existsSync(path.dirname(packagedDataDir)) && !fsSync.existsSync(devDataDir) ? packagedDataDir : devDataDir;
|
|
48
|
+
export const dbPath = process.env.DB_PATH || path.join(dataDir, 'deck-ide.db');
|
|
49
|
+
// Validate critical configuration
|
|
50
|
+
if (NODE_ENV === 'production') {
|
|
51
|
+
if (!CORS_ORIGIN) {
|
|
52
|
+
console.error('CRITICAL: CORS_ORIGIN must be set in production!');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
// Validate password strength in production
|
|
56
|
+
if (BASIC_AUTH_PASSWORD && BASIC_AUTH_PASSWORD.length < 12) {
|
|
57
|
+
console.error('CRITICAL: BASIC_AUTH_PASSWORD must be at least 12 characters in production!');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
// Warn if no authentication is configured
|
|
61
|
+
if (!BASIC_AUTH_USER || !BASIC_AUTH_PASSWORD) {
|
|
62
|
+
console.warn('WARNING: No authentication configured! API is publicly accessible.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Validate numeric configuration values
|
|
66
|
+
if (!Number.isFinite(PORT) || PORT < 1 || PORT > 65535) {
|
|
67
|
+
console.error('CRITICAL: Invalid PORT value');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isFinite(MAX_FILE_SIZE) || MAX_FILE_SIZE < 1024) {
|
|
71
|
+
console.error('CRITICAL: Invalid MAX_FILE_SIZE value');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
// Ensure data directory exists
|
|
75
|
+
fsSync.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
76
|
+
// PTY daemon info file - written by daemon on startup so server can find its port
|
|
77
|
+
export const daemonInfoPath = path.join(path.dirname(dbPath), 'pty-daemon.json');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { basicAuth } from 'hono/basic-auth';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { BASIC_AUTH_USER, BASIC_AUTH_PASSWORD } from '../config.js';
|
|
4
|
+
export const basicAuthMiddleware = BASIC_AUTH_USER && BASIC_AUTH_PASSWORD
|
|
5
|
+
? basicAuth({ username: BASIC_AUTH_USER, password: BASIC_AUTH_PASSWORD })
|
|
6
|
+
: undefined;
|
|
7
|
+
// WebSocket token management
|
|
8
|
+
const WS_TOKEN_TTL_MS = 30 * 1000; // 30 seconds
|
|
9
|
+
const wsTokens = new Map(); // token -> expiry timestamp
|
|
10
|
+
// Cleanup expired tokens periodically
|
|
11
|
+
setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [token, expiry] of wsTokens.entries()) {
|
|
14
|
+
if (now > expiry) {
|
|
15
|
+
wsTokens.delete(token);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}, 10000).unref();
|
|
19
|
+
/**
|
|
20
|
+
* Generate a one-time WebSocket token
|
|
21
|
+
*/
|
|
22
|
+
export function generateWsToken() {
|
|
23
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
24
|
+
wsTokens.set(token, Date.now() + WS_TOKEN_TTL_MS);
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Validate and consume a WebSocket token
|
|
29
|
+
*/
|
|
30
|
+
export function validateWsToken(token) {
|
|
31
|
+
const expiry = wsTokens.get(token);
|
|
32
|
+
if (!expiry) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
wsTokens.delete(token); // One-time use
|
|
36
|
+
return Date.now() <= expiry;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Check if Basic Auth is enabled
|
|
40
|
+
*/
|
|
41
|
+
export function isBasicAuthEnabled() {
|
|
42
|
+
return Boolean(BASIC_AUTH_USER && BASIC_AUTH_PASSWORD);
|
|
43
|
+
}
|
|
44
|
+
function timingSafeEqual(a, b) {
|
|
45
|
+
const aBuf = Buffer.from(a);
|
|
46
|
+
const bBuf = Buffer.from(b);
|
|
47
|
+
if (aBuf.length !== bBuf.length) {
|
|
48
|
+
// Dummy comparison to prevent length-based timing leak
|
|
49
|
+
crypto.timingSafeEqual(aBuf, aBuf);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
53
|
+
}
|
|
54
|
+
export function verifyWebSocketAuth(req) {
|
|
55
|
+
if (!BASIC_AUTH_USER || !BASIC_AUTH_PASSWORD) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
// Check for token in query string first
|
|
59
|
+
const url = new URL(req.url || '', 'http://localhost');
|
|
60
|
+
const token = url.searchParams.get('token');
|
|
61
|
+
if (token && validateWsToken(token)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
// Fall back to Basic Auth header
|
|
65
|
+
const authHeader = req.headers.authorization;
|
|
66
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const base64Credentials = authHeader.slice('Basic '.length);
|
|
70
|
+
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf8');
|
|
71
|
+
const colonIndex = credentials.indexOf(':');
|
|
72
|
+
if (colonIndex === -1) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const username = credentials.substring(0, colonIndex);
|
|
76
|
+
const password = credentials.substring(colonIndex + 1);
|
|
77
|
+
return timingSafeEqual(username, BASIC_AUTH_USER) && timingSafeEqual(password, BASIC_AUTH_PASSWORD);
|
|
78
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CORS_ORIGIN, NODE_ENV, PORT } from '../config.js';
|
|
2
|
+
const DEV_ALLOWED_ORIGINS = [
|
|
3
|
+
'http://localhost:5173',
|
|
4
|
+
'http://localhost:3000',
|
|
5
|
+
`http://localhost:${PORT}`,
|
|
6
|
+
];
|
|
7
|
+
export const corsMiddleware = async (c, next) => {
|
|
8
|
+
if (CORS_ORIGIN) {
|
|
9
|
+
c.header('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
10
|
+
}
|
|
11
|
+
else if (NODE_ENV === 'development') {
|
|
12
|
+
const origin = c.req.header('origin');
|
|
13
|
+
if (origin && DEV_ALLOWED_ORIGINS.includes(origin)) {
|
|
14
|
+
c.header('Access-Control-Allow-Origin', origin);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
c.header('Access-Control-Allow-Origin', `http://localhost:${PORT}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
c.header('Access-Control-Allow-Methods', 'GET,POST,PUT,OPTIONS');
|
|
21
|
+
c.header('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
|
22
|
+
if (c.req.method === 'OPTIONS') {
|
|
23
|
+
return c.body(null, 204);
|
|
24
|
+
}
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Security event logging
|
|
2
|
+
export function logSecurityEvent(event, details) {
|
|
3
|
+
const timestamp = new Date().toISOString();
|
|
4
|
+
console.warn(`[SECURITY] ${timestamp} ${event}:`, JSON.stringify(details));
|
|
5
|
+
}
|
|
6
|
+
export const securityHeaders = async (c, next) => {
|
|
7
|
+
c.header('X-Frame-Options', 'DENY');
|
|
8
|
+
c.header('X-Content-Type-Options', 'nosniff');
|
|
9
|
+
c.header('X-XSS-Protection', '1; mode=block');
|
|
10
|
+
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
11
|
+
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
12
|
+
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
13
|
+
// Allow Monaco Editor from CDN, Google Fonts, and blob: for workers
|
|
14
|
+
c.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net blob:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ws: wss:; worker-src 'self' blob:;");
|
|
15
|
+
await next();
|
|
16
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PtyClient - IPC client for the main server to communicate with the PTY daemon.
|
|
3
|
+
* Uses newline-delimited JSON over a local TCP connection.
|
|
4
|
+
*/
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
export class PtyClient extends EventEmitter {
|
|
8
|
+
socket = null;
|
|
9
|
+
lineBuf = '';
|
|
10
|
+
_connected = false;
|
|
11
|
+
listCallback = null;
|
|
12
|
+
shutdownCallback = null;
|
|
13
|
+
createCallbacks = new Map();
|
|
14
|
+
get connected() {
|
|
15
|
+
return this._connected;
|
|
16
|
+
}
|
|
17
|
+
connect(port) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const socket = net.createConnection(port, '127.0.0.1');
|
|
20
|
+
socket.once('connect', () => {
|
|
21
|
+
this.socket = socket;
|
|
22
|
+
this._connected = true;
|
|
23
|
+
resolve();
|
|
24
|
+
});
|
|
25
|
+
socket.once('error', (err) => {
|
|
26
|
+
if (!this._connected) {
|
|
27
|
+
reject(err);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
this.emit('error', err);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
socket.on('data', (chunk) => {
|
|
34
|
+
this.lineBuf += chunk.toString('utf8');
|
|
35
|
+
const lines = this.lineBuf.split('\n');
|
|
36
|
+
this.lineBuf = lines.pop();
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
if (!line.trim())
|
|
39
|
+
continue;
|
|
40
|
+
try {
|
|
41
|
+
this.handleMessage(JSON.parse(line));
|
|
42
|
+
}
|
|
43
|
+
catch { /* malformed message, ignore */ }
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
socket.on('close', () => {
|
|
47
|
+
this._connected = false;
|
|
48
|
+
this.socket = null;
|
|
49
|
+
if (this.shutdownCallback) {
|
|
50
|
+
this.shutdownCallback(false);
|
|
51
|
+
this.shutdownCallback = null;
|
|
52
|
+
}
|
|
53
|
+
this.emit('disconnect');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
handleMessage(msg) {
|
|
58
|
+
switch (msg.type) {
|
|
59
|
+
case 'data':
|
|
60
|
+
this.emit('data', msg.id, msg.data);
|
|
61
|
+
break;
|
|
62
|
+
case 'exit':
|
|
63
|
+
this.emit('exit', msg.id, msg.code);
|
|
64
|
+
break;
|
|
65
|
+
case 'list_result': {
|
|
66
|
+
const cb = this.listCallback;
|
|
67
|
+
this.listCallback = null;
|
|
68
|
+
cb?.(msg.terminals);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'shutdown_ack': {
|
|
72
|
+
const cb = this.shutdownCallback;
|
|
73
|
+
this.shutdownCallback = null;
|
|
74
|
+
cb?.(true);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case 'created': {
|
|
78
|
+
const cb = this.createCallbacks.get(msg.id);
|
|
79
|
+
if (cb) {
|
|
80
|
+
this.createCallbacks.delete(msg.id);
|
|
81
|
+
cb();
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case 'error': {
|
|
86
|
+
if (msg.id) {
|
|
87
|
+
const cb = this.createCallbacks.get(msg.id);
|
|
88
|
+
if (cb) {
|
|
89
|
+
this.createCallbacks.delete(msg.id);
|
|
90
|
+
cb(msg.message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
send(msg) {
|
|
98
|
+
if (this.socket && !this.socket.destroyed) {
|
|
99
|
+
try {
|
|
100
|
+
this.socket.write(JSON.stringify(msg) + '\n');
|
|
101
|
+
}
|
|
102
|
+
catch { /* socket may be closing */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Spawn a new PTY in the daemon. Resolves when daemon confirms creation. */
|
|
106
|
+
create(params) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
this.createCallbacks.delete(params.id);
|
|
110
|
+
reject(new Error(`Timeout creating terminal ${params.id}`));
|
|
111
|
+
}, 10_000);
|
|
112
|
+
this.createCallbacks.set(params.id, (err) => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (err)
|
|
115
|
+
reject(new Error(err));
|
|
116
|
+
else
|
|
117
|
+
resolve();
|
|
118
|
+
});
|
|
119
|
+
this.send({ type: 'create', ...params });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Send keyboard input to a terminal. */
|
|
123
|
+
input(id, data) {
|
|
124
|
+
this.send({ type: 'input', id, data });
|
|
125
|
+
}
|
|
126
|
+
/** Resize a terminal. */
|
|
127
|
+
resize(id, cols, rows) {
|
|
128
|
+
this.send({ type: 'resize', id, cols, rows });
|
|
129
|
+
}
|
|
130
|
+
/** Kill a terminal and remove it from the daemon. */
|
|
131
|
+
kill(id) {
|
|
132
|
+
this.send({ type: 'kill', id });
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Subscribe to live data from a terminal.
|
|
136
|
+
* The daemon will first send any buffered output since `bufferOffset`,
|
|
137
|
+
* then stream all subsequent output.
|
|
138
|
+
*/
|
|
139
|
+
attach(id, bufferOffset) {
|
|
140
|
+
this.send({ type: 'attach', id, bufferOffset });
|
|
141
|
+
}
|
|
142
|
+
/** Get a list of all terminals currently alive in the daemon. */
|
|
143
|
+
list() {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
this.listCallback = null;
|
|
147
|
+
resolve([]);
|
|
148
|
+
}, 5_000);
|
|
149
|
+
this.listCallback = (terminals) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve(terminals);
|
|
152
|
+
};
|
|
153
|
+
this.send({ type: 'list' });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** Ask the daemon to terminate itself and all child PTYs. */
|
|
157
|
+
shutdown(timeoutMs = 2_000) {
|
|
158
|
+
if (!this.connected) {
|
|
159
|
+
return Promise.resolve(false);
|
|
160
|
+
}
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
this.shutdownCallback = null;
|
|
164
|
+
resolve(false);
|
|
165
|
+
}, timeoutMs);
|
|
166
|
+
this.shutdownCallback = (ok) => {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
this.shutdownCallback = null;
|
|
169
|
+
resolve(ok);
|
|
170
|
+
};
|
|
171
|
+
this.send({ type: 'shutdown' });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
destroy() {
|
|
175
|
+
this.socket?.destroy();
|
|
176
|
+
}
|
|
177
|
+
}
|