This repository has no description
0

Configure Feed

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

feat(ptyfile): support [sessions.X.env] for per-session env vars

Adds an optional `env` table to each pty.toml session entry. The parser
collects it into PtySessionDef.env, and a new commandWithEnvExports()
helper builds an `export K='V'; <command>` prefix that's prepended when
the supervisor wraps the command in `/bin/sh -c`. Single-quote escaping
keeps values with `'` and shell metacharacters safe.

Both the initial spawn path (cmdUp) and the restart re-read path
(supervisor.evaluateSession) call the helper, so env survives session
restarts and pty.toml edits.

Tests: tests/ptyfile.test.ts (parsing + export-string shape, including
single-quote and metacharacter escapes) and a new integration case in
tests/up-down.test.ts that spawns a session, redirects $MY_VAR to a probe
file, and asserts the value landed inside the session.

Nathan Herald (Jun 24, 2026, 10:00 AM +0200) 42e3f29b b0801e6e

+165 -5
+2 -2
src/cli.ts
··· 33 33 readRecentEvents, formatEvent, 34 34 emitUserEvent, 35 35 } from "./events.ts"; 36 - import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 36 + import { readPtyFile, commandWithEnvExports, type PtySessionDef } from "./ptyfile.ts"; 37 37 import { getSupervisorDir } from "./supervisor.ts"; 38 38 import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; 39 39 import { parseDuration, formatDuration } from "./duration.ts"; ··· 3171 3171 await spawnDaemon({ 3172 3172 name: sess.name, 3173 3173 command: "/bin/sh", 3174 - args: ["-c", sess.command], 3174 + args: ["-c", commandWithEnvExports(sess)], 3175 3175 displayCommand: sess.command, 3176 3176 cwd: ptyFile.dir, 3177 3177 tags: tomlTags,
+27 -1
src/ptyfile.ts
··· 9 9 shortName: string; // name as written in the toml (for filtering) 10 10 command: string; 11 11 tags?: Record<string, string>; 12 + env?: Record<string, string>; 12 13 } 13 14 14 15 export interface PtyFile { ··· 61 62 } 62 63 } 63 64 64 - sessions.push({ name, shortName: rawName, command: d.command, tags }); 65 + let env: Record<string, string> | undefined; 66 + if (d.env !== undefined) { 67 + if (!d.env || typeof d.env !== "object" || Array.isArray(d.env)) { 68 + throw new Error(`Session "${name}" in ${filePath}: "env" must be a table of string values`); 69 + } 70 + env = {}; 71 + for (const [k, v] of Object.entries(d.env as Record<string, unknown>)) { 72 + if (typeof v !== "string") { 73 + throw new Error(`Session "${name}" in ${filePath}: env.${k} must be a string`); 74 + } 75 + env[k] = v; 76 + } 77 + } 78 + 79 + sessions.push({ name, shortName: rawName, command: d.command, tags, env }); 65 80 } 66 81 } 67 82 ··· 71 86 72 87 return { dir: resolvedDir, prefix, sessions }; 73 88 } 89 + 90 + /** Build the `/bin/sh -c` payload for a session: an optional `export K='V'; ...` 91 + * prefix derived from `sess.env`, followed by the session's own command. */ 92 + export function commandWithEnvExports(sess: PtySessionDef): string { 93 + const entries = sess.env ? Object.entries(sess.env) : []; 94 + if (entries.length === 0) return sess.command; 95 + const prefix = entries 96 + .map(([k, v]) => `export ${k}='${v.replace(/'/g, `'\\''`)}'`) 97 + .join("; "); 98 + return `${prefix}; ${sess.command}`; 99 + }
+2 -2
src/supervisor.ts
··· 7 7 type SessionMetadata, 8 8 } from "./sessions.ts"; 9 9 import { spawnDaemon } from "./spawn.ts"; 10 - import { readPtyFile } from "./ptyfile.ts"; 10 + import { readPtyFile, commandWithEnvExports } from "./ptyfile.ts"; 11 11 import { EventWriter, EventType, type EventRecord } from "./events.ts"; 12 12 13 13 /** Supervisor state lives in its own subdirectory to avoid polluting the session dir. */ ··· 386 386 const sessDef = ptyFile.sessions.find((s) => s.shortName === ptyfileSession); 387 387 if (sessDef) { 388 388 command = "/bin/sh"; 389 - args = ["-c", sessDef.command]; 389 + args = ["-c", commandWithEnvExports(sessDef)]; 390 390 displayCommand = sessDef.command; 391 391 cwd = ptyFile.dir; 392 392 tags = { ...sessDef.tags, ptyfile: ptyfilePath, "ptyfile.session": ptyfileSession };
+108
tests/ptyfile.test.ts
··· 1 + import { describe, it, expect, 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 { readPtyFile, commandWithEnvExports } from "../src/ptyfile.ts"; 6 + 7 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-ptyfile-")); 8 + afterAll(() => { 9 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 10 + }); 11 + 12 + function makeDir(name: string, content: string): string { 13 + const dir = fs.mkdtempSync(path.join(testRoot, `${name}-`)); 14 + fs.writeFileSync(path.join(dir, "pty.toml"), content); 15 + return dir; 16 + } 17 + 18 + describe("readPtyFile env", () => { 19 + it("parses [sessions.X.env] as a string map", () => { 20 + const dir = makeDir("envok", ` 21 + [sessions.worker] 22 + command = "cat" 23 + 24 + [sessions.worker.env] 25 + FOO = "bar" 26 + SECOND = "two" 27 + `); 28 + const file = readPtyFile(dir); 29 + expect(file.sessions).toHaveLength(1); 30 + expect(file.sessions[0].env).toEqual({ FOO: "bar", SECOND: "two" }); 31 + }); 32 + 33 + it("omits env when not present", () => { 34 + const dir = makeDir("noenv", ` 35 + [sessions.plain] 36 + command = "cat" 37 + `); 38 + const file = readPtyFile(dir); 39 + expect(file.sessions[0].env).toBeUndefined(); 40 + }); 41 + 42 + it("accepts an empty env table", () => { 43 + const dir = makeDir("emptyenv", ` 44 + [sessions.empty] 45 + command = "cat" 46 + 47 + [sessions.empty.env] 48 + `); 49 + const file = readPtyFile(dir); 50 + expect(file.sessions[0].env).toEqual({}); 51 + }); 52 + 53 + it("rejects non-string env values", () => { 54 + const dir = makeDir("badenv", ` 55 + [sessions.bad] 56 + command = "cat" 57 + 58 + [sessions.bad.env] 59 + NOT_A_STRING = 42 60 + `); 61 + expect(() => readPtyFile(dir)).toThrow(/env\.NOT_A_STRING must be a string/); 62 + }); 63 + }); 64 + 65 + describe("commandWithEnvExports", () => { 66 + it("returns the bare command when env is absent", () => { 67 + expect(commandWithEnvExports({ 68 + name: "x", shortName: "x", command: "echo hi", 69 + })).toBe("echo hi"); 70 + }); 71 + 72 + it("returns the bare command when env is empty", () => { 73 + expect(commandWithEnvExports({ 74 + name: "x", shortName: "x", command: "echo hi", env: {}, 75 + })).toBe("echo hi"); 76 + }); 77 + 78 + it("prepends export statements", () => { 79 + expect(commandWithEnvExports({ 80 + name: "x", shortName: "x", command: "echo $FOO", 81 + env: { FOO: "bar" }, 82 + })).toBe("export FOO='bar'; echo $FOO"); 83 + }); 84 + 85 + it("emits one export per env entry", () => { 86 + const out = commandWithEnvExports({ 87 + name: "x", shortName: "x", command: "do-thing", 88 + env: { A: "1", B: "two" }, 89 + }); 90 + expect(out).toContain("export A='1'"); 91 + expect(out).toContain("export B='two'"); 92 + expect(out).toMatch(/; do-thing$/); 93 + }); 94 + 95 + it("escapes single quotes in values", () => { 96 + expect(commandWithEnvExports({ 97 + name: "x", shortName: "x", command: "echo $MSG", 98 + env: { MSG: "it's a value" }, 99 + })).toBe(`export MSG='it'\\''s a value'; echo $MSG`); 100 + }); 101 + 102 + it("handles values with shell metacharacters safely", () => { 103 + expect(commandWithEnvExports({ 104 + name: "x", shortName: "x", command: "go", 105 + env: { PATH_LIKE: "$HOME/bin:/usr/bin; echo pwned" }, 106 + })).toBe(`export PATH_LIKE='$HOME/bin:/usr/bin; echo pwned'; go`); 107 + }); 108 + });
+26
tests/up-down.test.ts
··· 121 121 expect(running.map((s: any) => s.name).sort()).toEqual(["db", "web"]); 122 122 }, 15000); 123 123 124 + it("propagates env from pty.toml into the spawned session", async () => { 125 + const projDir = makeProjectDir(); 126 + const sessDir = makeSessionDir(); 127 + const outFile = path.join(projDir, "envcheck.out"); 128 + writePtyToml(projDir, ` 129 + [sessions.envprobe] 130 + command = "echo \\"$MY_VAR|$ANOTHER\\" > '${outFile}'; cat" 131 + 132 + [sessions.envprobe.env] 133 + MY_VAR = "hello" 134 + ANOTHER = "world" 135 + `); 136 + 137 + const result = runCli(sessDir, "up", projDir); 138 + expect(result.status).toBe(0); 139 + expect(result.stdout).toContain("envprobe (started)"); 140 + 141 + // `cat` keeps the session alive after the redirect; wait for the file. 142 + const deadline = Date.now() + 3000; 143 + while (Date.now() < deadline && !fs.existsSync(outFile)) { 144 + await new Promise((r) => setTimeout(r, 50)); 145 + } 146 + expect(fs.existsSync(outFile)).toBe(true); 147 + expect(fs.readFileSync(outFile, "utf-8").trim()).toBe("hello|world"); 148 + }, 15000); 149 + 124 150 it("skips already running sessions", () => { 125 151 const projDir = makeProjectDir(); 126 152 const sessDir = makeSessionDir();