promptmic 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/LICENSE +21 -0
- package/README.md +287 -0
- package/apps/server/dist/config-store.js +15 -0
- package/apps/server/dist/http/local-only-middleware.js +10 -0
- package/apps/server/dist/http/routes/config-routes.js +21 -0
- package/apps/server/dist/http/routes/filesystem-routes.js +28 -0
- package/apps/server/dist/http/routes/system-routes.js +16 -0
- package/apps/server/dist/http/static-assets.js +12 -0
- package/apps/server/dist/http/types.js +1 -0
- package/apps/server/dist/http-routes.js +15 -0
- package/apps/server/dist/index.js +54 -0
- package/apps/server/dist/main.js +8 -0
- package/apps/server/dist/pty-spawner.js +72 -0
- package/apps/server/dist/runtime-config.js +108 -0
- package/apps/server/dist/server-lifecycle.js +37 -0
- package/apps/server/dist/server-types.js +1 -0
- package/apps/server/dist/session-runtime.js +120 -0
- package/apps/web/dist/assets/DirectoryPickerDialog-D7IDk0pz.js +1 -0
- package/apps/web/dist/assets/SettingsDialog-uQLxj7UI.js +1 -0
- package/apps/web/dist/assets/index-BBNxXHaX.js +5 -0
- package/apps/web/dist/assets/index-G1_MX_Dd.css +1 -0
- package/apps/web/dist/assets/react-vendor-DQ3p2tNP.js +40 -0
- package/apps/web/dist/assets/scroll-area-C8DJNmaJ.js +1 -0
- package/apps/web/dist/assets/terminal-Beg8tuEN.css +32 -0
- package/apps/web/dist/assets/terminal-CnuCQtKf.js +9 -0
- package/apps/web/dist/assets/ui-vendor-Btc0UVaC.js +155 -0
- package/apps/web/dist/index.html +20 -0
- package/bin/termspeak-lib.mjs +189 -0
- package/bin/termspeak.mjs +9 -0
- package/config.example.json +21 -0
- package/package.json +70 -0
- package/packages/shared/dist/index.d.ts +2 -0
- package/packages/shared/dist/index.js +2 -0
- package/packages/shared/dist/provider.d.ts +85 -0
- package/packages/shared/dist/provider.js +13 -0
- package/packages/shared/dist/ws-messages.d.ts +219 -0
- package/packages/shared/dist/ws-messages.js +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucas Almeida
|
|
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,287 @@
|
|
|
1
|
+
# TermSpeak
|
|
2
|
+
|
|
3
|
+
Voice-first web UI for terminal-based AI coding assistants such as Claude Code, Codex CLI, and Aider, while keeping the real CLI session running through a PTY.
|
|
4
|
+
|
|
5
|
+
TermSpeak is built for developers who already work in the terminal and want a faster way to talk to coding assistants without giving up a real CLI session.
|
|
6
|
+
|
|
7
|
+
## Why TermSpeak
|
|
8
|
+
|
|
9
|
+
- Speak prompts instead of typing everything by hand
|
|
10
|
+
- Keep assistants running in a real interactive terminal session through PTY
|
|
11
|
+
- Switch between multiple providers such as Claude Code, Codex CLI, and Aider
|
|
12
|
+
- Choose the working directory before each session
|
|
13
|
+
- Use it alongside VS Code, Cursor, or any editor, as long as your assistant runs in the terminal
|
|
14
|
+
- Run everything locally on your own machine
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- Voice dictation with Web Speech API
|
|
19
|
+
- Text input with prompt history
|
|
20
|
+
- Built-in terminal view with `xterm.js`
|
|
21
|
+
- Multi-provider configuration
|
|
22
|
+
- Per-session working directory selection
|
|
23
|
+
- English and Portuguese (Brazil) UI support
|
|
24
|
+
|
|
25
|
+
The project now supports two interface languages out of the box:
|
|
26
|
+
|
|
27
|
+
- English
|
|
28
|
+
- Portuguese (Brazil)
|
|
29
|
+
|
|
30
|
+
Users can switch the UI language and the dictation language independently inside the app.
|
|
31
|
+
|
|
32
|
+
## Architecture
|
|
33
|
+
|
|
34
|
+
- `apps/server`: Node.js server with `node-pty`, Express, WebSocket, and dynamic config loading
|
|
35
|
+
- `apps/web`: React + Vite frontend with `xterm.js` and Web Speech API dictation
|
|
36
|
+
- `packages/shared`: shared types and schemas used by server and web
|
|
37
|
+
|
|
38
|
+
## Requirements
|
|
39
|
+
|
|
40
|
+
- Node.js 20+
|
|
41
|
+
- A Chromium-based browser with Web Speech API support, such as Chrome or Edge
|
|
42
|
+
- At least one assistant CLI available in your `PATH`, such as `claude`, `codex`, or `aider`
|
|
43
|
+
|
|
44
|
+
## Install and run
|
|
45
|
+
|
|
46
|
+
### From npm / npx
|
|
47
|
+
|
|
48
|
+
Package name on npm:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
promptmic
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Installed executable:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
promptmic
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
After the package is published, the default user flow will be:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx promptmic
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This starts TermSpeak locally and prints the URL in the terminal without opening the browser by default.
|
|
67
|
+
|
|
68
|
+
If you want the browser to open automatically:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx promptmic --open
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If you install it globally, the executable name is:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
promptmic
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
That means:
|
|
81
|
+
|
|
82
|
+
- use `npx promptmic` when running directly from the npm package name
|
|
83
|
+
- use `promptmic` after a global install
|
|
84
|
+
|
|
85
|
+
### Security note
|
|
86
|
+
|
|
87
|
+
TermSpeak is intended for local use on your own machine.
|
|
88
|
+
|
|
89
|
+
- by default it binds to `127.0.0.1`
|
|
90
|
+
- do not expose it on `0.0.0.0` or another non-local host unless you explicitly understand the risk
|
|
91
|
+
- non-local binding can expose terminal session control, file-system access, and assistant configuration to other machines
|
|
92
|
+
|
|
93
|
+
## Quick start
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone git@github.com:lucasaclima03/term-speak.git
|
|
97
|
+
cd term-speak
|
|
98
|
+
npm install
|
|
99
|
+
npm run dev
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then open `http://localhost:5173` in your browser.
|
|
103
|
+
|
|
104
|
+
The Vite dev server runs on `5173` and proxies API/WebSocket traffic to the local backend on `3001`.
|
|
105
|
+
|
|
106
|
+
If no providers are configured yet, the app opens the Settings dialog automatically on first launch.
|
|
107
|
+
|
|
108
|
+
## Run locally
|
|
109
|
+
|
|
110
|
+
### Development mode
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run dev
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- Open the app at `http://localhost:5173`
|
|
117
|
+
- The local API server runs at `http://localhost:3001`
|
|
118
|
+
- In repository development mode, the server will use `./config.json` if it exists
|
|
119
|
+
- On first launch, the Settings dialog opens automatically only if no providers are found in that config
|
|
120
|
+
|
|
121
|
+
### Production-style local run
|
|
122
|
+
|
|
123
|
+
This matches the published CLI behavior more closely:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm run build
|
|
127
|
+
npm run start
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- The backend serves the built frontend directly on `http://127.0.0.1:3001`
|
|
131
|
+
- By default, this mode uses `~/.term-speak/config.json`
|
|
132
|
+
- If that file does not exist yet, the first run starts with no providers and the onboarding flow begins in the UI
|
|
133
|
+
|
|
134
|
+
To run the production-style server while still using the repository config file:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm run start:repo
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Configuration
|
|
141
|
+
|
|
142
|
+
### Published CLI / npx usage
|
|
143
|
+
|
|
144
|
+
When you run TermSpeak from npm, it stores configuration in:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
~/.term-speak/config.json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
You do not need to create that file manually. On first run, if no providers are configured yet, the UI opens the Settings dialog and saves the configuration there.
|
|
151
|
+
|
|
152
|
+
### Repository / source usage
|
|
153
|
+
|
|
154
|
+
If you cloned the repository and want to use a repo-local config file, create:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
cp config.example.json config.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
In repository development mode, the local server can read `./config.json` and reloads it whenever the UI sends `POST /api/config`.
|
|
161
|
+
|
|
162
|
+
To actually start a session in any mode, you must have the configured assistant installed locally and available in your shell `PATH`.
|
|
163
|
+
|
|
164
|
+
If you want to use shell wrappers or functions such as `personal` or `work`, they must be available in your login shell.
|
|
165
|
+
|
|
166
|
+
### Config locations
|
|
167
|
+
|
|
168
|
+
- `npm run dev`: prefers `./config.json` in the repository
|
|
169
|
+
- if `./config.json` does not exist in `npm run dev`, the app starts with an empty config for onboarding instead of falling back to `~/.term-speak/config.json`
|
|
170
|
+
- `npm run start`: uses `~/.term-speak/config.json`
|
|
171
|
+
- `npm run start:repo`: forces `./config.json`
|
|
172
|
+
- `npx promptmic`: should behave like `npm run start`
|
|
173
|
+
|
|
174
|
+
### Provider fields
|
|
175
|
+
|
|
176
|
+
| Field | Type | Required | Description |
|
|
177
|
+
|---|---|---|---|
|
|
178
|
+
| `label` | `string` | Yes | Label shown in the UI |
|
|
179
|
+
| `executionMode` | `"direct" \| "shell"` | No | `direct` runs the executable directly. `shell` runs the command through the user's interactive shell. Defaults to `shell` for backward compatibility. |
|
|
180
|
+
| `command` | `string` | Yes | In `direct`, the executable name available in `PATH`. In `shell`, any shell command, alias, or function available in the user's shell. |
|
|
181
|
+
| `args` | `string[]` | No | Extra command arguments |
|
|
182
|
+
| `env` | `Record<string, string>` | No | Extra environment variables, with `~/` expansion |
|
|
183
|
+
|
|
184
|
+
### Execution modes
|
|
185
|
+
|
|
186
|
+
- `direct` is the recommended default for most providers because it runs the CLI directly and avoids shell startup noise
|
|
187
|
+
- `shell` is useful when you rely on shell wrappers, aliases, or functions such as `personal`, `work`, or custom environment bootstrap commands
|
|
188
|
+
- In `shell` mode, anything printed by your shell startup files can appear at the start of the session
|
|
189
|
+
|
|
190
|
+
### Example: multiple Claude accounts with direct execution
|
|
191
|
+
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"providers": {
|
|
195
|
+
"claude-personal": {
|
|
196
|
+
"label": "Claude (Personal)",
|
|
197
|
+
"executionMode": "direct",
|
|
198
|
+
"command": "claude",
|
|
199
|
+
"args": [],
|
|
200
|
+
"env": { "CLAUDE_CONFIG_DIR": "~/.claude-personal" }
|
|
201
|
+
},
|
|
202
|
+
"claude-work": {
|
|
203
|
+
"label": "Claude (Work)",
|
|
204
|
+
"executionMode": "direct",
|
|
205
|
+
"command": "claude",
|
|
206
|
+
"args": [],
|
|
207
|
+
"env": { "CLAUDE_CONFIG_DIR": "~/.claude-work" }
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
"defaultProvider": "claude-personal"
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Example: shell wrapper commands
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"providers": {
|
|
219
|
+
"claude-personal": {
|
|
220
|
+
"label": "Claude Personal",
|
|
221
|
+
"executionMode": "shell",
|
|
222
|
+
"command": "personal",
|
|
223
|
+
"args": [],
|
|
224
|
+
"env": {}
|
|
225
|
+
},
|
|
226
|
+
"claude-work": {
|
|
227
|
+
"label": "Claude Work",
|
|
228
|
+
"executionMode": "shell",
|
|
229
|
+
"command": "work",
|
|
230
|
+
"args": [],
|
|
231
|
+
"env": {}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Example: Aider + Codex
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"providers": {
|
|
242
|
+
"aider": {
|
|
243
|
+
"label": "Aider",
|
|
244
|
+
"executionMode": "direct",
|
|
245
|
+
"command": "aider",
|
|
246
|
+
"args": ["--no-auto-commits"],
|
|
247
|
+
"env": {}
|
|
248
|
+
},
|
|
249
|
+
"codex": {
|
|
250
|
+
"label": "Codex CLI",
|
|
251
|
+
"executionMode": "direct",
|
|
252
|
+
"command": "codex",
|
|
253
|
+
"args": [],
|
|
254
|
+
"env": {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Usage
|
|
261
|
+
|
|
262
|
+
1. Configure one or more assistants.
|
|
263
|
+
2. Choose the assistant in the dropdown.
|
|
264
|
+
3. Choose a working folder.
|
|
265
|
+
4. Start a session.
|
|
266
|
+
5. Speak or type prompts.
|
|
267
|
+
6. Stop the session when finished.
|
|
268
|
+
|
|
269
|
+
## Who this is for
|
|
270
|
+
|
|
271
|
+
TermSpeak is a good fit if you:
|
|
272
|
+
|
|
273
|
+
- use `claude`, `codex`, `aider`, or similar terminal-based assistants
|
|
274
|
+
- want voice input without leaving your local development workflow
|
|
275
|
+
- prefer editing in VS Code, Cursor, Neovim, or another editor while your assistant runs in the terminal
|
|
276
|
+
|
|
277
|
+
TermSpeak is not an IDE extension. It is a local voice interface for assistant CLIs.
|
|
278
|
+
|
|
279
|
+
## Development notes
|
|
280
|
+
|
|
281
|
+
- `config.json` is intentionally ignored and should stay local
|
|
282
|
+
- Do not commit `node_modules`, `dist`, or other generated local assets
|
|
283
|
+
- If you change user-facing copy, update both supported UI languages: English and Portuguese (Brazil)
|
|
284
|
+
|
|
285
|
+
## Contributing
|
|
286
|
+
|
|
287
|
+
Contribution guidelines live in [`CONTRIBUTING.md`](https://github.com/lucasaclima03/term-speak/blob/main/CONTRIBUTING.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from './runtime-config.js';
|
|
4
|
+
export function createConfigStore(configPath, initialConfig = loadConfig(configPath)) {
|
|
5
|
+
let currentConfig = initialConfig;
|
|
6
|
+
return {
|
|
7
|
+
configPath,
|
|
8
|
+
getConfig: () => currentConfig,
|
|
9
|
+
saveConfig: async (nextConfig) => {
|
|
10
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
11
|
+
await writeFile(configPath, JSON.stringify(nextConfig, null, 2), 'utf-8');
|
|
12
|
+
currentConfig = nextConfig;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { isAllowedHostHeader, isLoopbackAddress } from '../runtime-config.js';
|
|
2
|
+
export function registerLocalOnlyMiddleware(app) {
|
|
3
|
+
app.use((req, res, next) => {
|
|
4
|
+
if (!isLoopbackAddress(req.socket.remoteAddress) || !isAllowedHostHeader(req.headers.host)) {
|
|
5
|
+
res.status(403).json({ error: 'Local access only.' });
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
next();
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { configSchema } from '../../../../../packages/shared/dist/index.js';
|
|
2
|
+
export function registerConfigRoutes(app, configStore) {
|
|
3
|
+
app.get('/api/config', (_req, res) => {
|
|
4
|
+
res.json(configStore.getConfig());
|
|
5
|
+
});
|
|
6
|
+
app.post('/api/config', async (req, res) => {
|
|
7
|
+
const parsed = configSchema.safeParse(req.body);
|
|
8
|
+
if (!parsed.success) {
|
|
9
|
+
res.status(400).json({ error: parsed.error.issues });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await configStore.saveConfig(parsed.data);
|
|
14
|
+
res.json({ ok: true });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : 'Could not write config file.';
|
|
18
|
+
res.status(500).json({ error: message });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function registerFilesystemRoutes(app, defaultCwd) {
|
|
4
|
+
app.get('/api/fs/list', async (req, res) => {
|
|
5
|
+
try {
|
|
6
|
+
const requestedPath = typeof req.query.path === 'string' ? req.query.path : defaultCwd;
|
|
7
|
+
const resolvedPath = path.resolve(requestedPath);
|
|
8
|
+
const entries = await readdir(resolvedPath, { withFileTypes: true });
|
|
9
|
+
const directories = entries
|
|
10
|
+
.filter((entry) => entry.isDirectory())
|
|
11
|
+
.map((entry) => ({
|
|
12
|
+
name: entry.name,
|
|
13
|
+
path: path.join(resolvedPath, entry.name),
|
|
14
|
+
}))
|
|
15
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16
|
+
const parent = path.dirname(resolvedPath);
|
|
17
|
+
res.json({
|
|
18
|
+
path: resolvedPath,
|
|
19
|
+
parent: parent !== resolvedPath ? parent : null,
|
|
20
|
+
directories,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : 'Could not list directories.';
|
|
25
|
+
res.status(400).json({ error: message });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HOME_DIR, getDefaultProvider } from '../../runtime-config.js';
|
|
2
|
+
export function registerSystemRoutes(app, options) {
|
|
3
|
+
const { defaultCwd, configStore } = options;
|
|
4
|
+
app.get('/health', (_req, res) => {
|
|
5
|
+
res.json({ ok: true });
|
|
6
|
+
});
|
|
7
|
+
app.get('/api/info', (_req, res) => {
|
|
8
|
+
const config = configStore.getConfig();
|
|
9
|
+
res.json({
|
|
10
|
+
cwd: defaultCwd,
|
|
11
|
+
home: HOME_DIR,
|
|
12
|
+
providers: Object.entries(config.providers).map(([id, entry]) => ({ id, label: entry.label })),
|
|
13
|
+
defaultProvider: getDefaultProvider(config),
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function registerStaticAssets(app, staticDir) {
|
|
5
|
+
if (!existsSync(staticDir)) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
app.use(express.static(staticDir));
|
|
9
|
+
app.get('*', (_req, res) => {
|
|
10
|
+
res.sendFile(path.join(staticDir, 'index.html'));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { registerLocalOnlyMiddleware } from './http/local-only-middleware.js';
|
|
2
|
+
import { registerConfigRoutes } from './http/routes/config-routes.js';
|
|
3
|
+
import { registerFilesystemRoutes } from './http/routes/filesystem-routes.js';
|
|
4
|
+
import { registerSystemRoutes } from './http/routes/system-routes.js';
|
|
5
|
+
import { registerStaticAssets } from './http/static-assets.js';
|
|
6
|
+
export function registerHttpRoutes(options) {
|
|
7
|
+
const { app, defaultCwd, staticDir, localOnly, configStore } = options;
|
|
8
|
+
if (localOnly) {
|
|
9
|
+
registerLocalOnlyMiddleware(app);
|
|
10
|
+
}
|
|
11
|
+
registerSystemRoutes(app, { defaultCwd, configStore });
|
|
12
|
+
registerConfigRoutes(app, configStore);
|
|
13
|
+
registerFilesystemRoutes(app, defaultCwd);
|
|
14
|
+
registerStaticAssets(app, staticDir);
|
|
15
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import http from 'http';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { DEFAULT_HOST, DEFAULT_PORT, assertSafeHostBinding, displayHost, isAllowedOrigin, isLoopbackAddress, resolveConfigPath, resolveStaticDir, } from './runtime-config.js';
|
|
5
|
+
import { createConfigStore } from './config-store.js';
|
|
6
|
+
import { registerHttpRoutes } from './http-routes.js';
|
|
7
|
+
import { createPtySpawner } from './pty-spawner.js';
|
|
8
|
+
import { createSessionRuntime } from './session-runtime.js';
|
|
9
|
+
import { closeServerRuntime, getListeningPort, listenHttpServer } from './server-lifecycle.js';
|
|
10
|
+
export { assertSafeHostBinding, resolveConfigPath };
|
|
11
|
+
export { getDefaultConfigPath } from './runtime-config.js';
|
|
12
|
+
export async function startServer(options = {}) {
|
|
13
|
+
const host = options.host ?? process.env.HOST ?? DEFAULT_HOST;
|
|
14
|
+
const port = options.port ?? Number(process.env.PORT ?? DEFAULT_PORT);
|
|
15
|
+
const defaultCwd = options.workdir ?? process.env.WORKDIR ?? process.cwd();
|
|
16
|
+
const configPath = resolveConfigPath(options.configPath, options.preferUserConfig, options.preferRepoConfig ?? process.env.TERMSPEAK_PREFER_REPO_CONFIG === '1');
|
|
17
|
+
const staticDir = resolveStaticDir(options.staticDir);
|
|
18
|
+
const allowRemote = options.allowRemote ?? process.env.TERMSPEAK_ALLOW_REMOTE === '1';
|
|
19
|
+
const localOnly = options.localOnly ?? !allowRemote;
|
|
20
|
+
const configStore = createConfigStore(configPath);
|
|
21
|
+
assertSafeHostBinding(host, allowRemote);
|
|
22
|
+
const app = express();
|
|
23
|
+
app.use(express.json());
|
|
24
|
+
const server = http.createServer(app);
|
|
25
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
26
|
+
const spawnPty = createPtySpawner();
|
|
27
|
+
const sessionRuntime = createSessionRuntime({
|
|
28
|
+
defaultCwd,
|
|
29
|
+
localOnly,
|
|
30
|
+
getConfig: configStore.getConfig,
|
|
31
|
+
isAllowedOrigin,
|
|
32
|
+
isLoopbackAddress,
|
|
33
|
+
spawnPty,
|
|
34
|
+
});
|
|
35
|
+
registerHttpRoutes({
|
|
36
|
+
app,
|
|
37
|
+
configStore,
|
|
38
|
+
defaultCwd,
|
|
39
|
+
localOnly,
|
|
40
|
+
staticDir,
|
|
41
|
+
});
|
|
42
|
+
wss.on('connection', sessionRuntime.handleConnection);
|
|
43
|
+
await listenHttpServer(server, port, host);
|
|
44
|
+
const actualPort = getListeningPort(server, port);
|
|
45
|
+
const url = `http://${displayHost(host)}:${actualPort}`;
|
|
46
|
+
return {
|
|
47
|
+
host,
|
|
48
|
+
port: actualPort,
|
|
49
|
+
url,
|
|
50
|
+
configPath,
|
|
51
|
+
staticDir,
|
|
52
|
+
close: () => closeServerRuntime({ server, wss, beforeClose: sessionRuntime.closeAll }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { startServer } from './index.js';
|
|
2
|
+
void startServer().then(({ url }) => {
|
|
3
|
+
console.log(`TermSpeak server running at ${url}`);
|
|
4
|
+
}).catch((error) => {
|
|
5
|
+
const message = error instanceof Error ? error.message : 'Unknown server error.';
|
|
6
|
+
console.error(`[term-speak] ${message}`);
|
|
7
|
+
process.exitCode = 1;
|
|
8
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import pty from 'node-pty';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HOME_DIR } from './runtime-config.js';
|
|
4
|
+
export function expandTilde(value) {
|
|
5
|
+
return value.startsWith('~/') ? path.join(HOME_DIR, value.slice(2)) : value;
|
|
6
|
+
}
|
|
7
|
+
function quoteShellArg(value) {
|
|
8
|
+
if (value.length === 0)
|
|
9
|
+
return "''";
|
|
10
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
11
|
+
}
|
|
12
|
+
function formatSpawnError(entry, error) {
|
|
13
|
+
if (entry.executionMode === 'direct' &&
|
|
14
|
+
error &&
|
|
15
|
+
typeof error === 'object' &&
|
|
16
|
+
'code' in error &&
|
|
17
|
+
error.code === 'ENOENT') {
|
|
18
|
+
return new Error(`Could not start "${entry.label}". Command "${entry.command}" was not found in PATH. Install it or switch this provider to Shell command if it depends on a shell wrapper.`);
|
|
19
|
+
}
|
|
20
|
+
if (error instanceof Error) {
|
|
21
|
+
return new Error(`Could not start "${entry.label}": ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
return new Error(`Could not start "${entry.label}".`);
|
|
24
|
+
}
|
|
25
|
+
export function resolveProviderCommand(config, providerId, userShell = process.env.SHELL || '/bin/bash') {
|
|
26
|
+
const entry = config.providers[providerId];
|
|
27
|
+
if (!entry)
|
|
28
|
+
throw new Error(`Provider "${providerId}" not found`);
|
|
29
|
+
const envOverrides = {};
|
|
30
|
+
for (const [key, val] of Object.entries(entry.env)) {
|
|
31
|
+
envOverrides[key] = expandTilde(val);
|
|
32
|
+
}
|
|
33
|
+
if (entry.executionMode === 'direct') {
|
|
34
|
+
return {
|
|
35
|
+
executionMode: 'direct',
|
|
36
|
+
file: entry.command,
|
|
37
|
+
args: entry.args,
|
|
38
|
+
envOverrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const fullCmd = [entry.command, ...entry.args].map(quoteShellArg).join(' ');
|
|
42
|
+
return {
|
|
43
|
+
executionMode: 'shell',
|
|
44
|
+
file: userShell,
|
|
45
|
+
args: ['-ic', fullCmd],
|
|
46
|
+
envOverrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function createPtySpawner(options = {}) {
|
|
50
|
+
const baseEnv = Object.fromEntries(Object.entries(options.env ?? process.env).filter((entry) => typeof entry[1] === 'string'));
|
|
51
|
+
const spawn = options.spawn ?? pty.spawn;
|
|
52
|
+
const shell = options.shell ?? process.env.SHELL ?? '/bin/bash';
|
|
53
|
+
return ({ provider, cwd, config }) => {
|
|
54
|
+
const entry = config.providers[provider];
|
|
55
|
+
const command = resolveProviderCommand(config, provider, shell);
|
|
56
|
+
try {
|
|
57
|
+
return spawn(command.file, command.args, {
|
|
58
|
+
name: 'xterm-256color',
|
|
59
|
+
cols: 120,
|
|
60
|
+
rows: 30,
|
|
61
|
+
cwd,
|
|
62
|
+
env: {
|
|
63
|
+
...baseEnv,
|
|
64
|
+
...command.envOverrides,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw formatSpawnError(entry, error);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { configSchema } from '../../../packages/shared/dist/index.js';
|
|
6
|
+
const HOME_DIR = homedir();
|
|
7
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
8
|
+
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
9
|
+
const MODULE_DIR = path.dirname(MODULE_PATH);
|
|
10
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
11
|
+
const DEFAULT_PORT = 3001;
|
|
12
|
+
const DEFAULT_STATIC_DIR = path.resolve(MODULE_DIR, '../../web/dist');
|
|
13
|
+
const REPO_CONFIG_PATH = path.resolve(MODULE_DIR, '../../../config.json');
|
|
14
|
+
const USER_CONFIG_PATH = path.join(HOME_DIR, '.term-speak', 'config.json');
|
|
15
|
+
export { HOME_DIR, DEFAULT_HOST, DEFAULT_PORT };
|
|
16
|
+
export function isLoopbackAddress(address) {
|
|
17
|
+
if (!address)
|
|
18
|
+
return false;
|
|
19
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
20
|
+
}
|
|
21
|
+
export function isLocalHostValue(host) {
|
|
22
|
+
if (!host)
|
|
23
|
+
return false;
|
|
24
|
+
return LOCAL_HOSTS.has(host.toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
export function isAllowedHostHeader(hostHeader) {
|
|
27
|
+
if (!hostHeader)
|
|
28
|
+
return false;
|
|
29
|
+
const host = hostHeader.startsWith('[')
|
|
30
|
+
? hostHeader.slice(0, hostHeader.indexOf(']') + 1).toLowerCase()
|
|
31
|
+
: hostHeader.split(':')[0]?.toLowerCase();
|
|
32
|
+
if (!host)
|
|
33
|
+
return false;
|
|
34
|
+
return LOCAL_HOSTS.has(host);
|
|
35
|
+
}
|
|
36
|
+
export function isAllowedOrigin(origin) {
|
|
37
|
+
if (!origin)
|
|
38
|
+
return true;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(origin);
|
|
41
|
+
return LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function getDefaultConfigPath() {
|
|
48
|
+
return USER_CONFIG_PATH;
|
|
49
|
+
}
|
|
50
|
+
export function getRepoConfigPath() {
|
|
51
|
+
return REPO_CONFIG_PATH;
|
|
52
|
+
}
|
|
53
|
+
export function resolveConfigPath(customPath, preferUserConfig = false, preferRepoConfig = false) {
|
|
54
|
+
if (customPath)
|
|
55
|
+
return path.resolve(customPath);
|
|
56
|
+
if (process.env.TERMSPEAK_CONFIG_PATH)
|
|
57
|
+
return path.resolve(process.env.TERMSPEAK_CONFIG_PATH);
|
|
58
|
+
if (preferUserConfig)
|
|
59
|
+
return USER_CONFIG_PATH;
|
|
60
|
+
if (preferRepoConfig)
|
|
61
|
+
return REPO_CONFIG_PATH;
|
|
62
|
+
if (existsSync(REPO_CONFIG_PATH))
|
|
63
|
+
return REPO_CONFIG_PATH;
|
|
64
|
+
return USER_CONFIG_PATH;
|
|
65
|
+
}
|
|
66
|
+
export function resolveStaticDir(customPath) {
|
|
67
|
+
if (customPath)
|
|
68
|
+
return path.resolve(customPath);
|
|
69
|
+
if (process.env.TERMSPEAK_STATIC_DIR)
|
|
70
|
+
return path.resolve(process.env.TERMSPEAK_STATIC_DIR);
|
|
71
|
+
return DEFAULT_STATIC_DIR;
|
|
72
|
+
}
|
|
73
|
+
export function loadConfig(configPath) {
|
|
74
|
+
if (!existsSync(configPath)) {
|
|
75
|
+
return { providers: {} };
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const parsed = configSchema.safeParse(JSON.parse(readFileSync(configPath, 'utf-8')));
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
console.warn('[term-speak] invalid config file, ignoring it:', parsed.error.issues);
|
|
81
|
+
return { providers: {} };
|
|
82
|
+
}
|
|
83
|
+
return parsed.data;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.warn('[term-speak] could not read config file, ignoring it:', error);
|
|
87
|
+
return { providers: {} };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function getProviderIds(config) {
|
|
91
|
+
return Object.keys(config.providers);
|
|
92
|
+
}
|
|
93
|
+
export function getDefaultProvider(config) {
|
|
94
|
+
const ids = getProviderIds(config);
|
|
95
|
+
return config.defaultProvider && ids.includes(config.defaultProvider) ? config.defaultProvider : ids[0] ?? '';
|
|
96
|
+
}
|
|
97
|
+
export function displayHost(host) {
|
|
98
|
+
if (host === '0.0.0.0')
|
|
99
|
+
return '127.0.0.1';
|
|
100
|
+
if (host === '::')
|
|
101
|
+
return 'localhost';
|
|
102
|
+
return host;
|
|
103
|
+
}
|
|
104
|
+
export function assertSafeHostBinding(host, allowRemote = false) {
|
|
105
|
+
if (!isLocalHostValue(host) && !allowRemote) {
|
|
106
|
+
throw new Error(`Refusing to bind to non-local host "${host}" without explicit remote access opt-in. Use --allow-remote only if you understand the security risks.`);
|
|
107
|
+
}
|
|
108
|
+
}
|