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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +287 -0
  3. package/apps/server/dist/config-store.js +15 -0
  4. package/apps/server/dist/http/local-only-middleware.js +10 -0
  5. package/apps/server/dist/http/routes/config-routes.js +21 -0
  6. package/apps/server/dist/http/routes/filesystem-routes.js +28 -0
  7. package/apps/server/dist/http/routes/system-routes.js +16 -0
  8. package/apps/server/dist/http/static-assets.js +12 -0
  9. package/apps/server/dist/http/types.js +1 -0
  10. package/apps/server/dist/http-routes.js +15 -0
  11. package/apps/server/dist/index.js +54 -0
  12. package/apps/server/dist/main.js +8 -0
  13. package/apps/server/dist/pty-spawner.js +72 -0
  14. package/apps/server/dist/runtime-config.js +108 -0
  15. package/apps/server/dist/server-lifecycle.js +37 -0
  16. package/apps/server/dist/server-types.js +1 -0
  17. package/apps/server/dist/session-runtime.js +120 -0
  18. package/apps/web/dist/assets/DirectoryPickerDialog-D7IDk0pz.js +1 -0
  19. package/apps/web/dist/assets/SettingsDialog-uQLxj7UI.js +1 -0
  20. package/apps/web/dist/assets/index-BBNxXHaX.js +5 -0
  21. package/apps/web/dist/assets/index-G1_MX_Dd.css +1 -0
  22. package/apps/web/dist/assets/react-vendor-DQ3p2tNP.js +40 -0
  23. package/apps/web/dist/assets/scroll-area-C8DJNmaJ.js +1 -0
  24. package/apps/web/dist/assets/terminal-Beg8tuEN.css +32 -0
  25. package/apps/web/dist/assets/terminal-CnuCQtKf.js +9 -0
  26. package/apps/web/dist/assets/ui-vendor-Btc0UVaC.js +155 -0
  27. package/apps/web/dist/index.html +20 -0
  28. package/bin/termspeak-lib.mjs +189 -0
  29. package/bin/termspeak.mjs +9 -0
  30. package/config.example.json +21 -0
  31. package/package.json +70 -0
  32. package/packages/shared/dist/index.d.ts +2 -0
  33. package/packages/shared/dist/index.js +2 -0
  34. package/packages/shared/dist/provider.d.ts +85 -0
  35. package/packages/shared/dist/provider.js +13 -0
  36. package/packages/shared/dist/ws-messages.d.ts +219 -0
  37. 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
+ }