webtty 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
+ <img src="assets/social-preview.png" width="600">
2
+
1
3
  # webtty
2
4
 
3
- A web TTY for running CLI/TUI applications in a browser tab, across platforms.
5
+ Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms.
6
+
7
+ ```sh
8
+ npx webtty # start server + open a terminal in the browser
9
+ npx webtty ls # list sessions
10
+ npx webtty help # show all commands
11
+ ```
4
12
 
5
13
  ## Debugging
6
14
 
package/dist/cli/index.js CHANGED
@@ -2156,38 +2156,161 @@ var {
2156
2156
  Help
2157
2157
  } = import__.default;
2158
2158
 
2159
- // src/cli/http.ts
2160
- import { spawn } from "node:child_process";
2159
+ // src/cli/commands.ts
2160
+ import * as childProcess2 from "node:child_process";
2161
+ import fs3 from "node:fs";
2162
+ import path3 from "node:path";
2163
+
2164
+ // src/config.ts
2161
2165
  import fs from "node:fs";
2166
+ import os from "node:os";
2162
2167
  import path from "node:path";
2168
+ function configDir() {
2169
+ return path.join(process.env.HOME ?? os.homedir(), ".config", "webtty");
2170
+ }
2171
+ function getConfigPath() {
2172
+ return path.join(configDir(), "config.json");
2173
+ }
2174
+ var DEFAULT_THEME = {
2175
+ background: "#000000",
2176
+ foreground: "#CCCCCC",
2177
+ cursor: "#FFFFFF",
2178
+ selection: "#FFFFFF",
2179
+ black: "#0C0C0C",
2180
+ red: "#C50F1F",
2181
+ green: "#13A10E",
2182
+ yellow: "#C19C00",
2183
+ blue: "#0037DA",
2184
+ purple: "#881798",
2185
+ cyan: "#3A96DD",
2186
+ white: "#CCCCCC",
2187
+ brightBlack: "#767676",
2188
+ brightRed: "#E74856",
2189
+ brightGreen: "#16C60C",
2190
+ brightYellow: "#F9F1A5",
2191
+ brightBlue: "#3B78FF",
2192
+ brightPurple: "#B4009E",
2193
+ brightCyan: "#61D6D6",
2194
+ brightWhite: "#F2F2F2"
2195
+ };
2196
+ var DEFAULT_CONFIG = {
2197
+ port: 2346,
2198
+ host: "127.0.0.1",
2199
+ shell: process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "/bin/bash",
2200
+ term: process.env.TERM ?? "xterm-256color",
2201
+ colorTerm: "truecolor",
2202
+ scrollback: 256 * 1024,
2203
+ cols: 80,
2204
+ rows: 24,
2205
+ fontSize: 13,
2206
+ fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace",
2207
+ cursorBlink: true,
2208
+ copyOnSelect: true,
2209
+ rightClickBehavior: "default",
2210
+ logs: false,
2211
+ theme: DEFAULT_THEME
2212
+ };
2213
+ function loadConfig() {
2214
+ if (!fs.existsSync(getConfigPath())) {
2215
+ try {
2216
+ saveConfig(DEFAULT_CONFIG);
2217
+ } catch (err) {
2218
+ console.warn(`webtty: failed to write default config to ${getConfigPath()}: ${err.message}`);
2219
+ return { ...DEFAULT_CONFIG };
2220
+ }
2221
+ }
2222
+ let raw;
2223
+ try {
2224
+ raw = fs.readFileSync(getConfigPath(), "utf8");
2225
+ } catch (err) {
2226
+ throw new Error(`webtty: failed to read config at ${getConfigPath()}: ${err.message}`);
2227
+ }
2228
+ let parsed;
2229
+ try {
2230
+ parsed = JSON.parse(raw);
2231
+ } catch {
2232
+ throw new Error(`webtty: invalid JSON in config file ${getConfigPath()}`);
2233
+ }
2234
+ const p = parsed;
2235
+ return {
2236
+ ...DEFAULT_CONFIG,
2237
+ ...typeof p.port === "number" && { port: p.port },
2238
+ ...typeof p.host === "string" && { host: p.host },
2239
+ ...typeof p.shell === "string" && { shell: p.shell },
2240
+ ...typeof p.term === "string" && { term: p.term },
2241
+ ...typeof p.colorTerm === "string" && { colorTerm: p.colorTerm },
2242
+ ...typeof p.scrollback === "number" && { scrollback: p.scrollback },
2243
+ ...typeof p.cols === "number" && { cols: p.cols },
2244
+ ...typeof p.rows === "number" && { rows: p.rows },
2245
+ ...typeof p.fontSize === "number" && { fontSize: p.fontSize },
2246
+ ...typeof p.fontFamily === "string" && { fontFamily: p.fontFamily },
2247
+ ...typeof p.cursorBlink === "boolean" && { cursorBlink: p.cursorBlink },
2248
+ ...typeof p.copyOnSelect === "boolean" && { copyOnSelect: p.copyOnSelect },
2249
+ ...typeof p.rightClickBehavior === "string" && {
2250
+ rightClickBehavior: p.rightClickBehavior === "copyPaste" ? "copyPaste" : "default"
2251
+ },
2252
+ ...typeof p.logs === "boolean" && { logs: p.logs },
2253
+ ...p.theme && typeof p.theme === "object" && { theme: { ...DEFAULT_THEME, ...p.theme } }
2254
+ };
2255
+ }
2256
+ function saveConfig(_config) {
2257
+ fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true });
2258
+ const content = JSON.stringify({
2259
+ port: DEFAULT_CONFIG.port,
2260
+ host: DEFAULT_CONFIG.host
2261
+ }, null, 2);
2262
+ fs.writeFileSync(getConfigPath(), content, "utf8");
2263
+ }
2264
+
2265
+ // src/cli/http.ts
2266
+ import * as childProcess from "node:child_process";
2267
+ import fs2 from "node:fs";
2268
+ import path2 from "node:path";
2163
2269
  import { fileURLToPath } from "node:url";
2164
2270
  var __filename2 = fileURLToPath(import.meta.url);
2165
- var __dirname2 = path.dirname(__filename2);
2271
+ var __dirname2 = path2.dirname(__filename2);
2166
2272
  var PORT = Number(process.env.PORT) || 2346;
2167
2273
  var BASE_URL = `http://127.0.0.1:${PORT}`;
2274
+ function logPath() {
2275
+ return path2.join(configDir(), "server.log");
2276
+ }
2168
2277
  async function isServerRunning() {
2169
2278
  try {
2170
- await fetch(`${BASE_URL}/api/sessions`);
2171
- return true;
2279
+ const res = await fetch(`${BASE_URL}/api/sessions`);
2280
+ if (!res.ok)
2281
+ return false;
2282
+ const body = await res.json();
2283
+ return Array.isArray(body);
2172
2284
  } catch {
2173
2285
  return false;
2174
2286
  }
2175
2287
  }
2176
- async function startServer() {
2288
+ async function startServer(timeoutMs = 1e4, _spawn = childProcess.spawn) {
2177
2289
  const isBun = typeof globalThis.Bun !== "undefined";
2178
2290
  const isTs = isBun && __filename2.endsWith(".ts");
2179
- const serverEntry = path.resolve(__dirname2, isTs ? "../server/index.ts" : "../server/index.js");
2180
- if (!fs.existsSync(serverEntry)) {
2291
+ const serverEntry = path2.resolve(__dirname2, isTs ? "../server/index.ts" : "../server/index.js");
2292
+ if (!fs2.existsSync(serverEntry)) {
2181
2293
  console.error(`webtty: server entry not found at ${serverEntry}`);
2182
2294
  process.exit(1);
2183
2295
  }
2184
- const child = spawn(process.execPath, [serverEntry], {
2296
+ const config = loadConfig();
2297
+ let stdio = "ignore";
2298
+ let logFd;
2299
+ if (config.logs) {
2300
+ const log = logPath();
2301
+ fs2.mkdirSync(path2.dirname(log), { recursive: true });
2302
+ logFd = fs2.openSync(log, "a");
2303
+ stdio = ["ignore", logFd, logFd];
2304
+ }
2305
+ const child = _spawn(process.execPath, [serverEntry], {
2185
2306
  detached: true,
2186
- stdio: "ignore",
2307
+ stdio,
2187
2308
  env: { ...process.env, PORT: String(PORT) }
2188
2309
  });
2189
2310
  child.unref();
2190
- const deadline = Date.now() + 1e4;
2311
+ if (logFd !== undefined)
2312
+ fs2.closeSync(logFd);
2313
+ const deadline = Date.now() + timeoutMs;
2191
2314
  while (Date.now() < deadline) {
2192
2315
  if (await isServerRunning())
2193
2316
  return;
@@ -2196,60 +2319,38 @@ async function startServer() {
2196
2319
  console.error("webtty: server did not start in time");
2197
2320
  process.exit(1);
2198
2321
  }
2199
- function openBrowser(url) {
2322
+ async function stopServer(baseUrl = BASE_URL, timeoutMs = 5000) {
2323
+ try {
2324
+ const res = await fetch(`${baseUrl}/api/server/stop`, { method: "POST" });
2325
+ if (!res.ok)
2326
+ return false;
2327
+ const deadline = Date.now() + timeoutMs;
2328
+ while (Date.now() < deadline) {
2329
+ if (!await isServerRunning())
2330
+ return true;
2331
+ await new Promise((r) => setTimeout(r, 100));
2332
+ }
2333
+ return false;
2334
+ } catch {
2335
+ return false;
2336
+ }
2337
+ }
2338
+ function openBrowser(url, _spawn = childProcess.spawn) {
2200
2339
  if (process.env.WEBTTY_NO_OPEN === "1")
2201
2340
  return;
2341
+ if (false)
2342
+ ;
2202
2343
  if (process.platform === "win32") {
2203
- spawn("cmd.exe", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
2344
+ _spawn("cmd.exe", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
2204
2345
  } else {
2205
2346
  const cmd = process.platform === "darwin" ? "open" : "xdg-open";
2206
- spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
2347
+ _spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
2207
2348
  }
2208
2349
  }
2209
2350
 
2210
2351
  // src/cli/commands.ts
2211
2352
  function registerCommands(program2) {
2212
- program2.command("start").description("Start the webtty server").action(async () => {
2213
- if (await isServerRunning()) {
2214
- console.log("webtty is already running");
2215
- return;
2216
- }
2217
- await startServer();
2218
- console.log("webtty started");
2219
- });
2220
- program2.command("stop").description("Stop the webtty server").action(async () => {
2221
- try {
2222
- const res = await fetch(`${BASE_URL}/api/server/stop`, { method: "POST" });
2223
- if (res.ok) {
2224
- console.log("webtty stopped");
2225
- } else {
2226
- console.error(`webtty stop failed (status: ${res.status})`);
2227
- process.exit(1);
2228
- }
2229
- } catch {
2230
- console.log("webtty is not running");
2231
- }
2232
- });
2233
- program2.command("ls").description("List all sessions").action(async () => {
2234
- let res;
2235
- try {
2236
- res = await fetch(`${BASE_URL}/api/sessions`);
2237
- } catch {
2238
- console.log("webtty is not running");
2239
- process.exit(1);
2240
- }
2241
- const sessions = await res.json();
2242
- if (sessions.length === 0) {
2243
- console.log("no sessions");
2244
- return;
2245
- }
2246
- console.log("id\t\t\tconnected\tcreated");
2247
- for (const s of sessions) {
2248
- const created = new Date(s.createdAt).toLocaleString();
2249
- console.log(`${s.id} ${s.connected} ${created}`);
2250
- }
2251
- });
2252
- program2.command("run [id]").description("Create or reuse a session and open it in the browser").action(async (id) => {
2353
+ program2.command("at [id]").alias("a").alias("attach").description("Attach to a new or existing session and open it").action(async (id) => {
2253
2354
  if (!await isServerRunning()) {
2254
2355
  await startServer();
2255
2356
  }
@@ -2290,7 +2391,31 @@ function registerCommands(program2) {
2290
2391
  console.log(url);
2291
2392
  openBrowser(url);
2292
2393
  });
2293
- program2.command("rm <id>").description("Kill a session and its PTY").action(async (id) => {
2394
+ program2.command("ls [id]").alias("list").description("List all sessions, or filter by id substring").action(async (filter) => {
2395
+ let res;
2396
+ try {
2397
+ res = await fetch(`${BASE_URL}/api/sessions`);
2398
+ } catch {
2399
+ console.log("webtty is not running");
2400
+ process.exit(1);
2401
+ }
2402
+ const all = await res.json();
2403
+ const sessions = filter ? all.filter((s) => s.id.includes(filter)) : all;
2404
+ if (sessions.length === 0) {
2405
+ console.log("no sessions");
2406
+ return;
2407
+ }
2408
+ console.log("id\t\t\tconnected\tcreated");
2409
+ for (const s of sessions) {
2410
+ const created = new Date(s.createdAt).toLocaleString();
2411
+ console.log(`${s.id} ${s.connected} ${created}`);
2412
+ }
2413
+ });
2414
+ program2.command("rm [id]").alias("remove").description("Destroy a session").action(async (id) => {
2415
+ if (!id) {
2416
+ console.error("webtty: rm requires a session id");
2417
+ process.exit(1);
2418
+ }
2294
2419
  let res;
2295
2420
  try {
2296
2421
  res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, {
@@ -2302,6 +2427,10 @@ function registerCommands(program2) {
2302
2427
  }
2303
2428
  if (res.status === 204) {
2304
2429
  console.log(`removed ${id}`);
2430
+ if (res.headers.get("x-sessions-remaining") === "0") {
2431
+ await stopServer();
2432
+ console.log("no sessions remaining — webtty stopped");
2433
+ }
2305
2434
  } else if (res.status === 404) {
2306
2435
  console.error(`session ${id} not found`);
2307
2436
  process.exit(1);
@@ -2310,7 +2439,11 @@ function registerCommands(program2) {
2310
2439
  process.exit(1);
2311
2440
  }
2312
2441
  });
2313
- program2.command("rename <id> <new-id>").description("Rename a session").action(async (id, newId) => {
2442
+ program2.command("mv [id] [new-id]").alias("move").alias("rename").description("Rename a session").action(async (id, newId) => {
2443
+ if (!id || !newId) {
2444
+ console.error("webtty: rename requires two arguments: [id] [new-id]");
2445
+ process.exit(1);
2446
+ }
2314
2447
  let res;
2315
2448
  try {
2316
2449
  res = await fetch(`${BASE_URL}/api/sessions/${encodeURIComponent(id)}`, {
@@ -2333,12 +2466,84 @@ function registerCommands(program2) {
2333
2466
  process.exit(1);
2334
2467
  }
2335
2468
  });
2469
+ program2.command("stop").description("Stop the webtty server").action(async () => {
2470
+ if (!await isServerRunning()) {
2471
+ console.log("webtty is not running");
2472
+ return;
2473
+ }
2474
+ const ok = await stopServer();
2475
+ if (ok) {
2476
+ console.log("webtty stopped");
2477
+ } else {
2478
+ console.error("webtty stop failed");
2479
+ process.exit(1);
2480
+ }
2481
+ });
2482
+ program2.command("start").description("Start the webtty server").action(async () => {
2483
+ if (await isServerRunning()) {
2484
+ console.log("webtty is already running");
2485
+ return;
2486
+ }
2487
+ await startServer();
2488
+ console.log("webtty started");
2489
+ });
2490
+ program2.command("config").description("Open the config file in $EDITOR").action(() => {
2491
+ const dir = configDir();
2492
+ const configPath = path3.join(dir, "config.json");
2493
+ fs3.mkdirSync(dir, { recursive: true });
2494
+ if (!fs3.existsSync(configPath)) {
2495
+ fs3.writeFileSync(configPath, `{}
2496
+ `, "utf8");
2497
+ }
2498
+ const editor = process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === "win32" ? "notepad" : "vi");
2499
+ childProcess2.spawnSync(editor, [configPath], { stdio: "inherit" });
2500
+ });
2501
+ program2.command("help").description("Show help — all commands and options").action(() => {
2502
+ program2.outputHelp();
2503
+ });
2336
2504
  }
2337
2505
 
2338
2506
  // src/cli/index.ts
2507
+ var CMDS_WITH_ARGS = new Set(["at", "rm", "ls", "mv"]);
2508
+ var CMD_NAME_WIDTH = "mv".length;
2339
2509
  var program2 = new Command;
2340
- program2.name("webtty").description("Web TTY — run terminal sessions in a browser tab");
2510
+ program2.name("webtty").description("Launch Terminal UI in the browser.").configureHelp({
2511
+ styleTitle(str) {
2512
+ return str.replace(/:$/, "").toUpperCase();
2513
+ },
2514
+ subcommandTerm(cmd) {
2515
+ const args = cmd.registeredArguments.map((arg) => arg.required ? `<${arg.name()}>` : `[${arg.name()}]`).join(" ");
2516
+ const name = CMDS_WITH_ARGS.has(cmd.name()) ? cmd.name().padEnd(CMD_NAME_WIDTH) : cmd.name();
2517
+ return args ? `${name} ${args}` : name;
2518
+ },
2519
+ formatHelp(cmd, helper) {
2520
+ const helpWidth = helper.helpWidth ?? 80;
2521
+ const termWidth = helper.padWidth(cmd, helper);
2522
+ const callFormatItem = (term, description2) => helper.formatItem(term, termWidth, description2, helper);
2523
+ const description = helper.commandDescription(cmd);
2524
+ const descriptionBlock = description.length > 0 ? ["", helper.boxWrap(helper.styleCommandDescription(description), helpWidth), ""] : [];
2525
+ const indent = " ";
2526
+ const usageWidth = "webtty [command]".length;
2527
+ const usageBlock = [
2528
+ helper.styleTitle("Usage:"),
2529
+ `${indent}${helper.styleUsage("webtty".padEnd(usageWidth))} ${helper.styleCommandDescription("Attach to main session and open it")}`,
2530
+ `${indent}${helper.styleUsage("webtty [command]".padEnd(usageWidth))} ${helper.styleCommandDescription("Execute a specific command")}`,
2531
+ ""
2532
+ ];
2533
+ const commandGroups = helper.groupItems(cmd.commands, helper.visibleCommands(cmd), (sub) => sub.helpGroup?.() || "Commands:");
2534
+ const commandsBlock = [];
2535
+ commandGroups.forEach((commands, group) => {
2536
+ const commandList = commands.map((sub) => callFormatItem(helper.styleSubcommandTerm(helper.subcommandTerm(sub)), helper.styleSubcommandDescription(helper.subcommandDescription(sub))));
2537
+ commandsBlock.push(...helper.formatItemList(group, commandList, helper));
2538
+ });
2539
+ return [...descriptionBlock, ...usageBlock, ...commandsBlock].join(`
2540
+ `);
2541
+ }
2542
+ });
2341
2543
  registerCommands(program2);
2342
- program2.parse(process.argv);
2544
+ program2.action(async () => {
2545
+ await program2.parseAsync(["at", "main"], { from: "user" });
2546
+ });
2547
+ program2.parseAsync(process.argv);
2343
2548
 
2344
- //# debugId=1C9197F72FC17E7564756E2164756E21
2549
+ //# debugId=1ED78924E352A12364756E2164756E21