This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add pty up/down, pty.toml, --tags, --cwd, and colorized list output

Nathan Herald (Apr 13, 2026, 3:41 PM +0200) 9f1fd2dc 80ac3c49

+647 -10
+5
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + - Add `pty up` / `pty down` commands to start and stop sessions defined in a `pty.toml` project file 6 + - `pty.toml` supports named sessions with commands and optional tags 7 + - `pty up` accepts a directory argument (`pty up ./backend`) and session name filtering (`pty up dev serve`) 8 + - Add `--tags` flag to `pty list` to display tags as `#key=value` hashtags 9 + - Colorize `pty list` output: bold cyan session names, dimmed commands 5 10 - Add `--cwd` flag to `pty run` to start a session in a specific directory without needing to `cd` first 6 11 7 12 ## 0.6.0
+27
README.md
··· 56 56 pty run --tag owner=forge -- node srv.js # tag a session with metadata 57 57 58 58 pty list # show active sessions 59 + pty list --tags # show sessions with tags (#key=value) 59 60 pty list --json # show as JSON 60 61 61 62 pty attach myserver # reconnect to a session ··· 82 83 pty kill myserver # terminate a running session 83 84 pty rm myserver # remove an exited session's metadata 84 85 pty gc # remove all exited sessions 86 + 87 + pty up # start all sessions from ./pty.toml 88 + pty up ./backend # start sessions from ./backend/pty.toml 89 + pty up claude dev # start specific sessions from ./pty.toml 90 + pty down # stop all sessions from ./pty.toml 91 + pty down claude # stop specific sessions 85 92 86 93 pty wrap claude # auto-wrap claude in pty sessions 87 94 pty unwrap claude # remove the wrapper ··· 125 132 ``` 126 133 127 134 Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL. 135 + 136 + ### Project Files 137 + 138 + A project can include a `pty.toml` to declare its sessions: 139 + 140 + ```toml 141 + [sessions.claude] 142 + command = "claude --dangerously-skip-permissions" 143 + tags = { role = "agent" } 144 + 145 + [sessions.dev] 146 + command = "deno task dev" 147 + tags = { role = "build" } 148 + 149 + [sessions.serve] 150 + command = "bin/serve" 151 + tags = { role = "server" } 152 + ``` 153 + 154 + Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`. 128 155 129 156 ### Plugins 130 157
+2 -2
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach a peek send events list ls stats restart kill rm remove gc wrap unwrap test help" 9 + commands="run attach a peek send events list ls stats restart kill rm remove gc up down wrap unwrap test help" 10 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then ··· 37 37 ;; 38 38 list|ls) 39 39 if [[ "${cur}" == -* ]]; then 40 - COMPREPLY=($(compgen -W "--json" -- "${cur}")) 40 + COMPREPLY=($(compgen -W "--json --tags" -- "${cur}")) 41 41 fi 42 42 ;; 43 43 gc)
+4
completions/pty.fish
··· 58 58 complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session' 59 59 complete -c pty -n __pty_needs_command -a remove -d 'Remove an exited session' 60 60 complete -c pty -n __pty_needs_command -a gc -d 'Remove all exited sessions' 61 + complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml' 62 + complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml' 61 63 complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions' 62 64 complete -c pty -n __pty_needs_command -a unwrap -d 'Remove a wrapper' 63 65 complete -c pty -n __pty_needs_command -a test -d 'Run tests (vitest)' ··· 96 98 97 99 # list: flags 98 100 complete -c pty -n '__pty_using_command list' -l json -d 'Output as JSON' 101 + complete -c pty -n '__pty_using_command list' -l tags -d 'Show tags as #key=value' 99 102 complete -c pty -n '__pty_using_command ls' -l json -d 'Output as JSON' 103 + complete -c pty -n '__pty_using_command ls' -l tags -d 'Show tags as #key=value' 100 104 101 105 # stats: session names and flags 102 106 complete -c pty -n '__pty_using_command stats' -a '(__pty_sessions)' -d 'Session'
+4 -1
completions/pty.zsh
··· 38 38 'rm:Remove an exited session' 39 39 'remove:Remove an exited session' 40 40 'gc:Remove all exited sessions' 41 + 'up:Start sessions from pty.toml' 42 + 'down:Stop sessions from pty.toml' 41 43 'wrap:Auto-wrap a command in pty sessions' 42 44 'unwrap:Remove a wrapper' 43 45 'test:Run tests (vitest)' ··· 94 96 ;; 95 97 list|ls) 96 98 _arguments \ 97 - '--json[Output as JSON]' 99 + '--json[Output as JSON]' \ 100 + '--tags[Show tags as #key=value]' 98 101 ;; 99 102 gc) 100 103 # No arguments
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-G+w8aIHp1GsNJb1tZwuyY638L6F5Hlp0neN8UwBMTLs="; 32 + npmDepsHash = "sha256-a7g3tIDb+6JKQxCTqnL1i3gps4LjTVjt8wf+TrDtPzY="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
+14 -1
package-lock.json
··· 13 13 "@preact/signals-core": "^1.14.0", 14 14 "@xterm/addon-serialize": "^0.14.0", 15 15 "@xterm/headless": "^6.0.0", 16 - "node-pty": "^1.0.0" 16 + "node-pty": "^1.0.0", 17 + "smol-toml": "^1.6.1" 17 18 }, 18 19 "bin": { 19 20 "pty": "bin/pty" ··· 1572 1573 "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 1573 1574 "dev": true, 1574 1575 "license": "ISC" 1576 + }, 1577 + "node_modules/smol-toml": { 1578 + "version": "1.6.1", 1579 + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", 1580 + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", 1581 + "license": "BSD-3-Clause", 1582 + "engines": { 1583 + "node": ">= 18" 1584 + }, 1585 + "funding": { 1586 + "url": "https://github.com/sponsors/cyyynthia" 1587 + } 1575 1588 }, 1576 1589 "node_modules/source-map-js": { 1577 1590 "version": "1.2.1",
+2 -1
package.json
··· 70 70 "@preact/signals-core": "^1.14.0", 71 71 "@xterm/addon-serialize": "^0.14.0", 72 72 "@xterm/headless": "^6.0.0", 73 - "node-pty": "^1.0.0" 73 + "node-pty": "^1.0.0", 74 + "smol-toml": "^1.6.1" 74 75 }, 75 76 "devDependencies": { 76 77 "@preact/signals-core": "^1.14.0",
+183 -4
src/cli.ts
··· 18 18 } from "./sessions.ts"; 19 19 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 20 20 import { EventFollower, readRecentEvents, formatEvent } from "./events.ts"; 21 + import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 21 22 22 23 // Lazy-load the interactive TUI so non-interactive commands don't crash when 23 24 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). ··· 50 51 pty events --recent <name> Show recent events and exit 51 52 pty events --json <name> Output raw JSONL 52 53 pty list List active sessions 54 + pty list --tags List sessions with tags (#key=value) 53 55 pty list --json List sessions as JSON 56 + pty up Start all sessions from pty.toml 57 + pty up <dir> Start sessions from <dir>/pty.toml 58 + pty up <name> [<name>...] Start specific sessions from pty.toml 59 + pty down Stop all sessions from pty.toml 60 + pty down <dir> Stop sessions from <dir>/pty.toml 61 + pty down <name> [<name>...] Stop specific sessions from pty.toml 54 62 pty kill <name> Kill or remove a session 55 63 pty gc Remove all exited sessions 56 64 pty wrap <command> Auto-wrap a command in pty sessions ··· 350 358 case "list": 351 359 case "ls": { 352 360 const jsonFlag = args.includes("--json"); 353 - await cmdList(jsonFlag); 361 + const tagsFlag = args.includes("--tags"); 362 + await cmdList(jsonFlag, tagsFlag); 354 363 break; 355 364 } 356 365 ··· 398 407 break; 399 408 } 400 409 410 + case "up": { 411 + if (args[1] === "-h" || args[1] === "--help") { 412 + console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml."); 413 + break; 414 + } 415 + // pty up [dir] [name...] 416 + const upArgs = args.slice(1); 417 + let dir: string | undefined; 418 + const names: string[] = []; 419 + 420 + for (const arg of upArgs) { 421 + if (arg.startsWith("-")) break; 422 + // First arg: directory if pty.toml exists there, otherwise session name 423 + if (!dir && names.length === 0 && hasPtyFile(arg)) { 424 + dir = arg; 425 + } else { 426 + names.push(arg); 427 + } 428 + } 429 + 430 + await cmdUp(dir, names); 431 + break; 432 + } 433 + 434 + case "down": { 435 + if (args[1] === "-h" || args[1] === "--help") { 436 + console.log("Usage: pty down [dir] [name...]\n\nStop sessions defined in pty.toml."); 437 + break; 438 + } 439 + // pty down [dir] [name...] 440 + const downArgs = args.slice(1); 441 + let dir: string | undefined; 442 + const names: string[] = []; 443 + 444 + for (const arg of downArgs) { 445 + if (arg.startsWith("-")) break; 446 + if (!dir && names.length === 0 && hasPtyFile(arg)) { 447 + dir = arg; 448 + } else { 449 + names.push(arg); 450 + } 451 + } 452 + 453 + await cmdDown(dir, names); 454 + break; 455 + } 456 + 401 457 case "rm": 402 458 case "remove": { 403 459 if (args.length < 2) { ··· 606 662 }); 607 663 } 608 664 609 - async function cmdList(json = false): Promise<void> { 665 + async function cmdList(json = false, showTags = false): Promise<void> { 610 666 const sessions = await listSessions(); 611 667 612 668 if (json) { ··· 644 700 const cwd = session.metadata?.cwd 645 701 ? shortPath(session.metadata.cwd) 646 702 : ""; 647 - console.log(` ${session.name} (pid: ${session.pid}) — ${cwd} — ${cmd}`); 703 + const tagStr = showTags && session.metadata?.tags 704 + ? " " + Object.entries(session.metadata.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 705 + : ""; 706 + console.log(` \x1b[1;36m${session.name}\x1b[0m${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 648 707 } 649 708 } 650 709 ··· 659 718 const cmd = meta 660 719 ? [meta.displayCommand, ...meta.args].join(" ") 661 720 : ""; 662 - console.log(` ${session.name} (exited with code ${code}, ${ago}) — ${cwd} — ${cmd}`); 721 + const tagStr = showTags && meta?.tags 722 + ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ") 723 + : ""; 724 + console.log(` \x1b[1m${session.name}\x1b[0m${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 663 725 } 664 726 } 665 727 } ··· 869 931 console.log(`Removed: ${name}`); 870 932 } 871 933 console.log(`Cleaned up ${removed.length} exited session${removed.length === 1 ? "" : "s"}.`); 934 + } 935 + 936 + function hasPtyFile(dir: string): boolean { 937 + try { 938 + return fs.statSync(path.join(path.resolve(dir), "pty.toml")).isFile(); 939 + } catch { 940 + return false; 941 + } 942 + } 943 + 944 + async function cmdUp(dir: string | undefined, names: string[]): Promise<void> { 945 + let ptyFile; 946 + try { 947 + ptyFile = readPtyFile(dir); 948 + } catch (e: any) { 949 + console.error(e.message); 950 + process.exit(1); 951 + } 952 + 953 + let sessions = ptyFile.sessions; 954 + if (names.length > 0) { 955 + const nameSet = new Set(names); 956 + const matchesName = (s: PtySessionDef) => nameSet.has(s.name) || nameSet.has(s.shortName); 957 + const unknown = names.filter((n) => !sessions.some((s) => s.name === n || s.shortName === n)); 958 + if (unknown.length > 0) { 959 + console.error(`Unknown session${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`); 960 + console.error(`Available: ${sessions.map((s) => s.shortName).join(", ")}`); 961 + process.exit(1); 962 + } 963 + sessions = sessions.filter(matchesName); 964 + } 965 + 966 + const existing = await listSessions(); 967 + const runningNames = new Set(existing.filter((s) => s.status === "running").map((s) => s.name)); 968 + 969 + let started = 0; 970 + let skipped = 0; 971 + 972 + for (const sess of sessions) { 973 + if (runningNames.has(sess.name)) { 974 + console.log(` ● ${sess.name} (already running)`); 975 + skipped++; 976 + continue; 977 + } 978 + 979 + // Clean up exited session with the same name 980 + const existingSession = existing.find((s) => s.name === sess.name); 981 + if (existingSession?.status === "exited") { 982 + cleanupAll(sess.name); 983 + } 984 + 985 + try { 986 + await spawnDaemon({ 987 + name: sess.name, 988 + command: "/bin/sh", 989 + args: ["-c", sess.command], 990 + displayCommand: sess.command, 991 + cwd: ptyFile.dir, 992 + tags: sess.tags, 993 + }); 994 + console.log(` ● ${sess.name} (started)`); 995 + started++; 996 + } catch (e: any) { 997 + console.error(` ✗ ${sess.name}: ${e.message}`); 998 + } 999 + } 1000 + 1001 + if (started === 0 && skipped === sessions.length) { 1002 + console.log("All sessions already running."); 1003 + } else if (started > 0) { 1004 + console.log(`Started ${started} session${started === 1 ? "" : "s"}.`); 1005 + } 1006 + } 1007 + 1008 + async function cmdDown(dir: string | undefined, names: string[]): Promise<void> { 1009 + let ptyFile; 1010 + try { 1011 + ptyFile = readPtyFile(dir); 1012 + } catch (e: any) { 1013 + console.error(e.message); 1014 + process.exit(1); 1015 + } 1016 + 1017 + let sessions = ptyFile.sessions; 1018 + if (names.length > 0) { 1019 + const nameSet = new Set(names); 1020 + sessions = sessions.filter((s) => nameSet.has(s.name) || nameSet.has(s.shortName)); 1021 + } 1022 + 1023 + const existing = await listSessions(); 1024 + let stopped = 0; 1025 + 1026 + for (const sess of sessions) { 1027 + const existingSession = existing.find((s) => s.name === sess.name); 1028 + if (!existingSession) continue; 1029 + 1030 + if (existingSession.status === "running" && existingSession.pid) { 1031 + try { 1032 + process.kill(existingSession.pid, "SIGTERM"); 1033 + console.log(` ○ ${sess.name} (stopped)`); 1034 + stopped++; 1035 + } catch { 1036 + console.error(` ✗ ${sess.name}: failed to stop`); 1037 + } 1038 + cleanupSocket(sess.name); 1039 + } else if (existingSession.status === "exited") { 1040 + cleanupAll(sess.name); 1041 + console.log(` ○ ${sess.name} (cleaned up)`); 1042 + stopped++; 1043 + } 1044 + } 1045 + 1046 + if (stopped === 0) { 1047 + console.log("No sessions to stop."); 1048 + } else { 1049 + console.log(`Stopped ${stopped} session${stopped === 1 ? "" : "s"}.`); 1050 + } 872 1051 } 873 1052 874 1053 async function cmdRestart(name: string, force = false): Promise<void> {
+3
src/client-api.ts
··· 36 36 type FollowerOptions, 37 37 } from "./events.ts"; 38 38 39 + // Project files 40 + export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts"; 41 + 39 42 // Keys 40 43 export { resolveKey, parseSeqValue } from "./keys.ts"; 41 44
+73
src/ptyfile.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 3 + import { parse as parseToml } from "smol-toml"; 4 + 5 + const PTY_TOML = "pty.toml"; 6 + 7 + export interface PtySessionDef { 8 + name: string; // full session name (with prefix if set) 9 + shortName: string; // name as written in the toml (for filtering) 10 + command: string; 11 + tags?: Record<string, string>; 12 + } 13 + 14 + export interface PtyFile { 15 + dir: string; 16 + prefix: string | null; 17 + sessions: PtySessionDef[]; 18 + } 19 + 20 + /** Find and parse a pty.toml file. Searches the given directory (or cwd). */ 21 + export function readPtyFile(dir?: string): PtyFile { 22 + const resolvedDir = dir ? path.resolve(dir) : process.cwd(); 23 + const filePath = path.join(resolvedDir, PTY_TOML); 24 + 25 + let content: string; 26 + try { 27 + content = fs.readFileSync(filePath, "utf-8"); 28 + } catch (err: any) { 29 + if (err?.code === "ENOENT") { 30 + throw new Error(`No ${PTY_TOML} found in ${resolvedDir}`); 31 + } 32 + throw err; 33 + } 34 + 35 + let parsed: any; 36 + try { 37 + parsed = parseToml(content); 38 + } catch (err: any) { 39 + throw new Error(`Invalid ${PTY_TOML} in ${resolvedDir}: ${err?.message ?? String(err)}`); 40 + } 41 + 42 + const prefix = typeof parsed.prefix === "string" && parsed.prefix.length > 0 ? parsed.prefix : null; 43 + const sessions: PtySessionDef[] = []; 44 + 45 + if (parsed.sessions && typeof parsed.sessions === "object") { 46 + for (const [rawName, def] of Object.entries(parsed.sessions)) { 47 + const name = prefix ? `${prefix}-${rawName}` : rawName; 48 + if (!def || typeof def !== "object") { 49 + throw new Error(`Invalid session "${name}" in ${filePath}: expected a table`); 50 + } 51 + const d = def as Record<string, unknown>; 52 + if (typeof d.command !== "string" || d.command.length === 0) { 53 + throw new Error(`Session "${name}" in ${filePath} is missing a "command" field`); 54 + } 55 + 56 + let tags: Record<string, string> | undefined; 57 + if (d.tags && typeof d.tags === "object") { 58 + tags = {}; 59 + for (const [k, v] of Object.entries(d.tags as Record<string, unknown>)) { 60 + tags[k] = String(v); 61 + } 62 + } 63 + 64 + sessions.push({ name, shortName: rawName, command: d.command, tags }); 65 + } 66 + } 67 + 68 + if (sessions.length === 0) { 69 + throw new Error(`No sessions defined in ${filePath}`); 70 + } 71 + 72 + return { dir: resolvedDir, prefix, sessions }; 73 + }
+329
tests/up-down.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + 12 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-updown-")); 13 + afterAll(() => { 14 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 + }); 16 + 17 + let sessionDirs: string[] = []; 18 + 19 + function makeProjectDir(): string { 20 + const dir = fs.mkdtempSync(path.join(testRoot, "proj-")); 21 + sessionDirs.push(dir); 22 + return dir; 23 + } 24 + 25 + function makeSessionDir(): string { 26 + const dir = fs.mkdtempSync(path.join(testRoot, "sd-")); 27 + sessionDirs.push(dir); 28 + return dir; 29 + } 30 + 31 + function writePtyToml(dir: string, content: string): void { 32 + fs.writeFileSync(path.join(dir, "pty.toml"), content); 33 + } 34 + 35 + function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { 36 + const result = spawnSync(nodeBin, [cliPath, ...args], { 37 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 38 + encoding: "utf-8", 39 + timeout: 15000, 40 + }); 41 + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; 42 + } 43 + 44 + function listJson(sessionDir: string): any[] { 45 + const result = runCli(sessionDir, "list", "--json"); 46 + return JSON.parse(result.stdout); 47 + } 48 + 49 + afterEach(() => { 50 + // Kill any sessions we may have started 51 + for (const dir of sessionDirs) { 52 + if (!fs.existsSync(dir)) continue; 53 + try { 54 + const entries = fs.readdirSync(dir); 55 + for (const e of entries) { 56 + if (e.endsWith(".pid")) { 57 + const pidStr = fs.readFileSync(path.join(dir, e), "utf-8").trim(); 58 + const pid = parseInt(pidStr, 10); 59 + if (!isNaN(pid)) { 60 + try { process.kill(pid, "SIGTERM"); } catch {} 61 + } 62 + } 63 + } 64 + // Clean up files 65 + for (const e of entries) { 66 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 67 + } 68 + } catch {} 69 + } 70 + sessionDirs = []; 71 + }); 72 + 73 + describe("pty up", () => { 74 + it("starts all sessions from pty.toml", () => { 75 + const projDir = makeProjectDir(); 76 + const sessDir = makeSessionDir(); 77 + writePtyToml(projDir, ` 78 + [sessions.one] 79 + command = "cat" 80 + 81 + [sessions.two] 82 + command = "cat" 83 + `); 84 + 85 + const result = runCli(sessDir, "up", projDir); 86 + 87 + expect(result.status).toBe(0); 88 + expect(result.stdout).toContain("one (started)"); 89 + expect(result.stdout).toContain("two (started)"); 90 + expect(result.stdout).toContain("Started 2 sessions"); 91 + 92 + const sessions = listJson(sessDir); 93 + expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); 94 + }, 15000); 95 + 96 + it("starts only named sessions", () => { 97 + const projDir = makeProjectDir(); 98 + const sessDir = makeSessionDir(); 99 + writePtyToml(projDir, ` 100 + [sessions.web] 101 + command = "cat" 102 + 103 + [sessions.worker] 104 + command = "cat" 105 + 106 + [sessions.db] 107 + command = "cat" 108 + `); 109 + 110 + const result = runCli(sessDir, "up", projDir, "web", "db"); 111 + 112 + expect(result.status).toBe(0); 113 + expect(result.stdout).toContain("web (started)"); 114 + expect(result.stdout).toContain("db (started)"); 115 + expect(result.stdout).not.toContain("worker"); 116 + 117 + const sessions = listJson(sessDir); 118 + const running = sessions.filter((s: any) => s.status === "running"); 119 + expect(running).toHaveLength(2); 120 + expect(running.map((s: any) => s.name).sort()).toEqual(["db", "web"]); 121 + }, 15000); 122 + 123 + it("skips already running sessions", () => { 124 + const projDir = makeProjectDir(); 125 + const sessDir = makeSessionDir(); 126 + writePtyToml(projDir, ` 127 + [sessions.mycat] 128 + command = "cat" 129 + `); 130 + 131 + // Start once 132 + runCli(sessDir, "up", projDir); 133 + 134 + // Start again 135 + const result = runCli(sessDir, "up", projDir); 136 + expect(result.stdout).toContain("mycat (already running)"); 137 + expect(result.stdout).toContain("All sessions already running"); 138 + }, 15000); 139 + 140 + it("propagates tags from pty.toml", () => { 141 + const projDir = makeProjectDir(); 142 + const sessDir = makeSessionDir(); 143 + writePtyToml(projDir, ` 144 + [sessions.tagged] 145 + command = "cat" 146 + tags = { role = "server", env = "dev" } 147 + `); 148 + 149 + runCli(sessDir, "up", projDir); 150 + 151 + const sessions = listJson(sessDir); 152 + const session = sessions.find((s: any) => s.name === "tagged"); 153 + expect(session).toBeDefined(); 154 + expect(session.tags).toEqual({ role: "server", env: "dev" }); 155 + }, 15000); 156 + 157 + it("sets cwd to the project directory", () => { 158 + const projDir = makeProjectDir(); 159 + const sessDir = makeSessionDir(); 160 + writePtyToml(projDir, ` 161 + [sessions.checkdir] 162 + command = "cat" 163 + `); 164 + 165 + runCli(sessDir, "up", projDir); 166 + 167 + const sessions = listJson(sessDir); 168 + const session = sessions.find((s: any) => s.name === "checkdir"); 169 + expect(session).toBeDefined(); 170 + expect(session.cwd).toBe(projDir); 171 + }, 15000); 172 + 173 + it("uses prefix for session names", () => { 174 + const projDir = makeProjectDir(); 175 + const sessDir = makeSessionDir(); 176 + writePtyToml(projDir, ` 177 + prefix = "myapp" 178 + 179 + [sessions.web] 180 + command = "cat" 181 + 182 + [sessions.worker] 183 + command = "cat" 184 + `); 185 + 186 + const result = runCli(sessDir, "up", projDir); 187 + 188 + expect(result.status).toBe(0); 189 + expect(result.stdout).toContain("myapp-web (started)"); 190 + expect(result.stdout).toContain("myapp-worker (started)"); 191 + 192 + const sessions = listJson(sessDir); 193 + const names = sessions.filter((s: any) => s.status === "running").map((s: any) => s.name); 194 + expect(names.sort()).toEqual(["myapp-web", "myapp-worker"]); 195 + }, 15000); 196 + 197 + it("filters by short name when prefix is set", () => { 198 + const projDir = makeProjectDir(); 199 + const sessDir = makeSessionDir(); 200 + writePtyToml(projDir, ` 201 + prefix = "myapp" 202 + 203 + [sessions.web] 204 + command = "cat" 205 + 206 + [sessions.worker] 207 + command = "cat" 208 + `); 209 + 210 + const result = runCli(sessDir, "up", projDir, "web"); 211 + 212 + expect(result.status).toBe(0); 213 + expect(result.stdout).toContain("myapp-web (started)"); 214 + expect(result.stdout).not.toContain("worker"); 215 + 216 + const sessions = listJson(sessDir); 217 + const running = sessions.filter((s: any) => s.status === "running"); 218 + expect(running).toHaveLength(1); 219 + expect(running[0].name).toBe("myapp-web"); 220 + }, 15000); 221 + 222 + it("errors on unknown session name", () => { 223 + const projDir = makeProjectDir(); 224 + const sessDir = makeSessionDir(); 225 + writePtyToml(projDir, ` 226 + [sessions.real] 227 + command = "cat" 228 + `); 229 + 230 + const result = runCli(sessDir, "up", projDir, "fake"); 231 + expect(result.status).not.toBe(0); 232 + expect(result.stderr).toContain("Unknown session: fake"); 233 + expect(result.stderr).toContain("Available: real"); 234 + }, 15000); 235 + 236 + it("errors when no pty.toml exists", () => { 237 + const projDir = makeProjectDir(); 238 + const sessDir = makeSessionDir(); 239 + 240 + const result = runCli(sessDir, "up", projDir); 241 + expect(result.status).not.toBe(0); 242 + expect(result.stderr).toContain("No pty.toml found"); 243 + }, 15000); 244 + 245 + it("errors on pty.toml with no sessions", () => { 246 + const projDir = makeProjectDir(); 247 + const sessDir = makeSessionDir(); 248 + writePtyToml(projDir, `# empty config\n`); 249 + 250 + const result = runCli(sessDir, "up", projDir); 251 + expect(result.status).not.toBe(0); 252 + expect(result.stderr).toContain("No sessions defined"); 253 + }, 15000); 254 + 255 + it("errors on session without a command", () => { 256 + const projDir = makeProjectDir(); 257 + const sessDir = makeSessionDir(); 258 + writePtyToml(projDir, ` 259 + [sessions.bad] 260 + tags = { foo = "bar" } 261 + `); 262 + 263 + const result = runCli(sessDir, "up", projDir); 264 + expect(result.status).not.toBe(0); 265 + expect(result.stderr).toContain('missing a "command" field'); 266 + }, 15000); 267 + }); 268 + 269 + describe("pty down", () => { 270 + it("stops all running sessions from pty.toml", () => { 271 + const projDir = makeProjectDir(); 272 + const sessDir = makeSessionDir(); 273 + writePtyToml(projDir, ` 274 + [sessions.alpha] 275 + command = "cat" 276 + 277 + [sessions.beta] 278 + command = "cat" 279 + `); 280 + 281 + // Start them 282 + runCli(sessDir, "up", projDir); 283 + let sessions = listJson(sessDir); 284 + expect(sessions.filter((s: any) => s.status === "running")).toHaveLength(2); 285 + 286 + // Stop them 287 + const result = runCli(sessDir, "down", projDir); 288 + expect(result.status).toBe(0); 289 + expect(result.stdout).toContain("alpha (stopped)"); 290 + expect(result.stdout).toContain("beta (stopped)"); 291 + expect(result.stdout).toContain("Stopped 2 sessions"); 292 + }, 15000); 293 + 294 + it("stops only named sessions", () => { 295 + const projDir = makeProjectDir(); 296 + const sessDir = makeSessionDir(); 297 + writePtyToml(projDir, ` 298 + [sessions.keep] 299 + command = "cat" 300 + 301 + [sessions.stop] 302 + command = "cat" 303 + `); 304 + 305 + runCli(sessDir, "up", projDir); 306 + 307 + const result = runCli(sessDir, "down", projDir, "stop"); 308 + expect(result.stdout).toContain("stop (stopped)"); 309 + expect(result.stdout).not.toContain("keep"); 310 + 311 + // "keep" should still be running 312 + const sessions = listJson(sessDir); 313 + const running = sessions.filter((s: any) => s.status === "running"); 314 + expect(running).toHaveLength(1); 315 + expect(running[0].name).toBe("keep"); 316 + }, 15000); 317 + 318 + it("reports when nothing to stop", () => { 319 + const projDir = makeProjectDir(); 320 + const sessDir = makeSessionDir(); 321 + writePtyToml(projDir, ` 322 + [sessions.ghost] 323 + command = "cat" 324 + `); 325 + 326 + const result = runCli(sessDir, "down", projDir); 327 + expect(result.stdout).toContain("No sessions to stop"); 328 + }, 15000); 329 + });