This repository has no description
0

Configure Feed

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

feat(name): decouple on-disk identifier from display label (#45)

* feat(name): decouple on-disk identifier from display label

Sessions now have a short random on-disk identifier (sock + json filename)
that is independent of the user-visible display label. This unblocks long
descriptive labels — both via `pty.toml` prefixes and via `pty run --name`
— which previously hit the macOS `sockaddr_un.sun_path` 104-byte limit.

Breaking changes
- `pty run --name <X>` now sets the displayName (any length, any printable
chars). The on-disk id is set by `--id <X>`. Both omitted → random short
id + auto-generated displayName. Both can be combined.
- `pty up` (pty.toml) gives every session a random short on-disk id. The
toml-derived `<prefix>-<sessionKey>` becomes the displayName, not the
filename. `pty up` re-run detection now matches by `(ptyfile,
ptyfile.session)` tag pair instead of by name.
- `pty.toml` gains two optional per-session fields: `id = "..."` to pin the
on-disk id and `display_name = "..."` to override the default label.
- `pty rename` validation: displayName uses the new permissive
`validateDisplayName` (≤ 500 chars, no slashes / null / newlines /
control bytes) instead of the strict `validateName` (sock-filename
charset, sock-path length). Strict validation is reserved for ids.
- `SessionMetadata.displayName` is now preserved through exit (previously
`saveExitMetadata` dropped it).
- `PtySessionDef.name` replaced by `displayName` + optional `id`;
`shortName` unchanged. External callers (`pty kill`, `peek`, `send`,
`attach`) are unaffected — they already resolved by either field via
`resolveRef`.
- `spawnDaemon`'s bundled-context fallback now passes `--id` (instead of
`--name`) to the CLI delegation path and explicitly threads
`displayName` / `--no-display-name`.

Tests
- New `tests/up-name-decouple.test.ts` covers random id, long-prefix
support, `(ptyfile, ptyfile.session)` re-run lookup, pty.toml `id` and
`display_name` overrides, and operations resolving by displayName.
- Existing tests migrated from `--name <id>` to `--id <id>` for tests
whose intent was pinning the on-disk identifier. `display-name.test.ts`
rewritten to test the new `--id` / `--name` semantics including
long-label, kernel-limit rejection, and id/name collision rejection.

Full suite green (1168 passed / 22 skipped / 1190 total).

* fix(cli): drop validateName from session-resolution paths

Long displayNames could be created via `pty run --name <long>` but
operating on them (`pty peek|send|kill|tag|events|attach|restart|rm`)
failed with the `validateName` sock-path kernel-limit error — the
strict validator was running on the user-supplied ref BEFORE
resolveRef did the lookup. The strict validator's job is to gate
on-disk id creation, not session-reference resolution.

Fix per schickling-assistant's PR #45 review:
- Drop the validateName(ref) block from 7 sites in cli.ts: attach,
peek, send, events, tag, kill, rm, cmdRestart, and tag-multi by-name
selector.
- Add resolveRef at the supervisor forget/reset dispatch sites so
cmdSupervisorForget/Reset receive a resolved name (their bodies
drop their own redundant validateName as a consequence).
- Drop the redundant validateName inside cmdRestart — its dispatcher
already resolved the ref.

validateName remains in place where it belongs: on-disk id creation
in `pty run --id`, pty.toml session id, and `pty rename` displayName
candidate paths.

Tests: 6 new regression tests in tests/display-name.test.ts covering
a 110-char displayName roundtripped through peek/send/tag/events/kill
plus the create case. Full suite: 1174 passed / 22 skipped / 0 failures.

authored by

Nathan and committed by
GitHub
(Jun 26, 2026, 9:02 PM +0200) 5cde2272 ca5a96d9

+668 -234
+14
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Breaking: on-disk name decoupled from display label 6 + - **The session `name` (sock + json filename) is now always a short identifier, separate from the user-visible display label.** Before: `pty run --name X -- cmd` made `X` the on-disk identifier (sock file = `X.sock`); pty.toml sessions used `<prefix>-<sessionKey>` as the on-disk name. The result: long pretty labels hit macOS's 104-byte `sockaddr_un.sun_path` limit (~68 chars of usable name with the default session dir), so Nathan's friend trying to give sessions long descriptive names ran straight into `EINVAL`/`ENAMETOOLONG` in spawn. Now: the on-disk name is always a short Crockford-base32 id (8 chars) unless explicitly pinned, and the display label is a separate `displayName` field with permissive validation (≤ 500 chars, any printable text except `/ \ \0 \n \r \t` and control bytes). 7 + - **`pty run` flag rename: `--name <X>` now sets the displayName; the on-disk id is set by `--id <X>`.** Old `pty run --name long-pretty-label -- cmd` silently broke on long values; new `pty run --name "My Long Pretty Label" -- cmd` works. To pin an explicit on-disk id (for deterministic scripting / reproducible automation), use `pty run --id <id>`. Both omitted → random id + auto-generated displayName. Both can be combined: `pty run --id svc --name "My Service" -- cmd`. The id is validated up front (charset, sock-path length, uniqueness) so automation fails loudly rather than producing a cryptic `EINVAL` deep in spawn. `--no-display-name` still works for "id only, no label." 8 + - **`pty up` (pty.toml sessions) gives every session a random short id.** The previous behavior of using `<prefix>-<sessionKey>` as the on-disk name is gone; that string is now the `displayName`. Long `prefix` values that used to hit the kernel limit (`prefix = "my-very-long-project-name"`, `[sessions.frontend-dev-server]` → 41-char name + path overhead) now succeed because the sock filename is just the 8-char id. 9 + - **`pty up` re-run detection: matches existing sessions by the `(ptyfile, ptyfile.session)` tag pair, not by name.** Same external behavior — `pty up` on a running toml session prints `(already running)` — but the matching key changed underneath. Manual `pty rename`s on a toml session no longer confuse `pty up`. 10 + - **pty.toml gets two optional fields per session table:** 11 + - `id = "..."` — pin the on-disk identifier (validated like `--id`). 12 + - `display_name = "..."` — override the default `<prefix>-<sessionKey>` label. 13 + Both omitted → random id + default label. Existing tomls keep working with no edits. 14 + - **`pty rename` now validates the new display name with `validateDisplayName`** (permissive: ≤ 500 chars, no slashes / backslashes / null / newlines / control bytes) instead of the strict `validateName` (sock-filename charset, sock-path length). The strict path is reserved for ids. 15 + - **`SessionMetadata.displayName` is now preserved through exit.** Previously `saveExitMetadata` rebuilt the metadata blob from `this.options` and didn't carry `displayName` forward, so a session that exited dropped its display label. Now it's read from existing metadata and re-stamped. 16 + - **Internal: `PtySessionDef.name` is gone — replaced by `displayName` + optional `id`.** Consumers of `readPtyFile()` need to update field references. `shortName` (the raw toml key) is unchanged. **External CLI/API surface unchanged for sessions resolved by reference** — `pty kill`, `pty peek`, `pty send`, `pty attach`, etc. already resolved by either name or displayName via `resolveRef → getSession`, and continue to. 17 + - **Migration**: existing sessions created under the old name model keep working as-is; their files don't move. New sessions created by `pty run` or `pty up` after the upgrade use the new shape. If you have shell aliases or scripts that hand-glob `~/.local/state/pty/<name>.sock` by a known long name, they will stop finding new sessions — query via `pty list --json` and resolve by `displayName`. 18 + 5 19 ### Spawn lifecycle 6 20 - **`spawnDaemon({ bindToSpawnerLifetime: true })` ties the daemon's lifetime to the spawner.** Off by default, so existing callers see no change. When enabled, `spawn.ts` sets `PTY_SPAWNER_PID=<spawner pid>` in the daemon's env; `server.ts` polls that PID every 5 s and calls `cleanShutdown(0)` once it's gone (and exits immediately if the spawner is already dead at startup). Fixes the orphan-daemon class of bug where a short-lived spawner (script, test harness, scoped Effect resource) exits without calling `disconnect()` / `kill()` and leaves the daemon reparented to init forever — observed in the wild as hundreds of `@myobie/pty/dist/server.js` processes accumulating multi-GiB RSS over days. Long-lived supervisors that want the daemon to outlive them simply omit the option. Implemented via env var rather than a runtime API so the daemon (a separate process) needs no IPC. See [overengineeringstudio/effect-utils#677](https://github.com/overengineeringstudio/effect-utils/issues/677) for the full write-up. 7 21
+21 -8
README.md
··· 50 50 pty # interactive session manager 51 51 pty --preselect-new # open the TUI with "Create new session..." selected 52 52 pty --filter-tag layout=work # TUI filtered by tag; new sessions inherit the tag 53 - pty run -- node server.js # start a session (random name + auto displayName) 54 - pty run --name myserver -- node server.js # start with an explicit name (still gets a displayName) 55 - pty run --no-display-name -- bash # random name, no friendly label (good for throwaway shells) 56 - pty run -d -- node server.js # start in the background 57 - pty run -a -- node server.js # create or attach if already running 58 - pty run -e -- npm test # ephemeral: auto-remove on exit 59 - pty run --tag owner=forge -- node srv.js # tag a session with metadata 60 - pty run --cwd /path -- node server.js # run in a specific directory 53 + pty run -- node server.js # random id + auto display label 54 + pty run --id myserver -- node server.js # pin an explicit on-disk id 55 + pty run --name "My API Server" -- node server.js # set an explicit display label (any length) 56 + pty run --id api --name "My API" -- node server.js # pin both id and display label 57 + pty run --no-display-name -- bash # random id, no friendly label 58 + pty run -d -- node server.js # start in the background 59 + pty run -a -- node server.js # create or attach if already running 60 + pty run -e -- npm test # ephemeral: auto-remove on exit 61 + pty run --tag owner=forge -- node srv.js # tag a session with metadata 62 + pty run --cwd /path -- node server.js # run in a specific directory 61 63 62 64 pty rename my-label # inside a session: add/change its displayName 63 65 pty rename <ref> my-label # outside: set displayName on <ref> ··· 197 199 ``` 198 200 199 201 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`. 202 + 203 + Each session also supports two optional fields: 204 + 205 + ```toml 206 + [sessions.serve] 207 + command = "bin/serve" 208 + id = "srv" # pin the on-disk id (sock + json filename) 209 + display_name = "My Web Server" # override the default `<prefix>-<sessionKey>` label 210 + ``` 211 + 212 + `id` is validated like a `pty run --id` value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. `display_name` is permissive (≤ 500 chars, any printable text); omitted → defaults to `<prefix>-<sessionKey>` (or just `<sessionKey>` if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past `sockaddr_un.sun_path` (~104 bytes) now work because the actual sock filename is just the short id. 200 213 201 214 Sessions can also declare per-session environment variables: 202 215
+188 -160
src/cli.ts
··· 15 15 cleanupAll, 16 16 cleanupSocket, 17 17 validateName, 18 + validateDisplayName, 18 19 acquireLock, 19 20 releaseLock, 20 21 updateTags, ··· 66 67 pty Interactive session manager 67 68 pty --preselect-new Open the TUI with "Create new session..." pre-selected 68 69 pty --filter-tag key=value Filter TUI to sessions with a tag; auto-applied to new sessions 69 - pty run -- <command> [args...] Create a session and attach (auto-named) 70 - pty run --name <n> -- <command> [args...] Create a named session and attach 71 - pty run -d -- <command> [args...] Create in the background 72 - pty run -a -- <command> [args...] Create or attach if already running 73 - pty run --tag key=value -- <command> Tag a session with metadata 74 - pty run --cwd /path -- <command> Run in a specific directory 75 - pty run --isolate-env -- <command> Scrub env down to a safe allow-list (for remote-reachable sessions) 76 - pty run --no-display-name -- <command> Skip the friendly cwd+command label (just a random id) 70 + pty run -- <command> [args...] Create a session and attach (random id + auto label) 71 + pty run --id <id> -- <command> Pin the on-disk id (sock + json filename) 72 + pty run --name <dn> -- <command> Set the display label (arbitrary length / chars) 73 + pty run --id <id> --name <dn> -- <cmd> Pin both id and display label 74 + pty run -d -- <command> [args...] Create in the background 75 + pty run -a -- <command> [args...] Create or attach if already running 76 + pty run --tag key=value -- <command> Tag a session with metadata 77 + pty run --cwd /path -- <command> Run in a specific directory 78 + pty run --isolate-env -- <command> Scrub env down to a safe allow-list (for remote-reachable sessions) 79 + pty run --no-display-name -- <command> Skip the friendly cwd+command label (just an id) 77 80 pty rename <new> Inside a session, set its displayName 78 81 pty rename <ref> <new> Outside, set displayName on <ref> 79 82 pty rename --show <ref> Show current displayName for <ref> ··· 244 247 } 245 248 246 249 case "run": { 247 - // Parse flags before the -- separator 250 + // Parse flags before the -- separator. The flag model: 251 + // --id <id> explicit on-disk id (sock/json filename). Validated: 252 + // charset, sock-path length, no existing-ref collision. 253 + // --name <dn> explicit display label (arbitrary length / chars, 254 + // within the permissive validateDisplayName rules). 255 + // Replaces the auto-generated cwd+cmd label. 256 + // --no-display-name skip displayName entirely. 257 + // Both omitted → random short id + auto-generated displayName. 248 258 let detach = false; 249 259 let attachExisting = false; 250 260 let ephemeral = false; 251 261 let isolateEnv = false; 252 262 let noDisplayName = false; 253 263 let force = false; 254 - let name: string | null = null; 264 + let explicitId: string | null = null; 265 + let explicitDisplayName: string | null = null; 255 266 let cwd: string | null = null; 256 267 const tags: Record<string, string> = {}; 257 268 let i = 1; ··· 262 273 else if (args[i] === "--isolate-env") { isolateEnv = true; i++; } 263 274 else if (args[i] === "--no-display-name") { noDisplayName = true; i++; } 264 275 else if (args[i] === "--force") { force = true; i++; } 265 - else if (args[i] === "--name" && i + 1 < args.length) { name = args[i + 1]; i += 2; } 276 + else if (args[i] === "--id" && i + 1 < args.length) { explicitId = args[i + 1]; i += 2; } 277 + else if (args[i] === "--name" && i + 1 < args.length) { explicitDisplayName = args[i + 1]; i += 2; } 266 278 else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; } 267 279 else if (args[i] === "--tag" && i + 1 < args.length) { 268 280 const eq = args[i + 1].indexOf("="); ··· 283 295 let cmdArgs: string[]; 284 296 285 297 if (dashDash !== -1) { 286 - // Anything between flags and -- that isn't a flag is a legacy positional name 298 + // Anything between flags and -- that isn't a flag is a legacy 299 + // positional that's now interpreted as the display name. The 300 + // previous semantics (positional = on-disk id) is gone — use --id. 287 301 const between = args.slice(i, dashDash); 288 - if (between.length > 0 && !name) { 289 - // Backward compat: pty run myserver -- node server.js 290 - name = between[0]; 291 - console.error(`Hint: use --name instead: pty run --name ${name} -- ...`); 302 + if (between.length > 0 && !explicitDisplayName) { 303 + explicitDisplayName = between[0]; 304 + console.error(`Hint: use --name instead: pty run --name ${between[0]} -- ...`); 292 305 } 293 306 cmd = args[dashDash + 1]; 294 307 cmdArgs = args.slice(dashDash + 2); 295 308 } else { 296 309 // No -- separator: legacy positional format 297 - // pty run myserver node server.js 310 + // pty run mydisplayname node server.js 298 311 const rest = args.slice(i); 299 - if (!name && rest.length >= 2) { 300 - name = rest[0]; 312 + if (!explicitDisplayName && rest.length >= 2) { 313 + explicitDisplayName = rest[0]; 301 314 cmd = rest[1]; 302 315 cmdArgs = rest.slice(2); 303 - console.error(`Hint: use --name instead: pty run --name ${name} -- ${cmd} ${cmdArgs.join(" ")}`.trimEnd()); 316 + console.error(`Hint: use --name instead: pty run --name ${rest[0]} -- ${cmd} ${cmdArgs.join(" ")}`.trimEnd()); 304 317 } else { 305 318 cmd = rest[0]; 306 319 cmdArgs = rest.slice(1); ··· 308 321 } 309 322 310 323 if (!cmd) { 311 - console.error("Usage: pty run [--name <name>] [-d] [-a] -- <command> [args...]"); 324 + console.error("Usage: pty run [--id <id>] [--name <displayName>] [-d] [-a] -- <command> [args...]"); 312 325 process.exit(1); 313 326 } 314 327 ··· 328 341 // running, the original "exec directly" behavior still makes sense 329 342 // (there's no session to attach to anyway). 330 343 if (process.env.PTY_SESSION && !detach) { 331 - if (attachExisting && name && !force) { 332 - const existing = await getSession(name); 344 + // Nested-attach check: target by either the explicit id or display name 345 + const lookupRef = explicitId ?? explicitDisplayName; 346 + if (attachExisting && lookupRef && !force) { 347 + const existing = await getSession(lookupRef); 333 348 if (existing && existing.status === "running") { 334 349 ensureNotNested("run -a", { 335 350 force: false, 336 351 hint: 337 - ` Target session "${name}" is already running; attaching would nest a client inside the current session.\n` + 352 + ` Target session "${lookupRef}" is already running; attaching would nest a client inside the current session.\n` + 338 353 " Pass --force to attach anyway, or detach first (Ctrl+\\) and re-run from outside.", 339 354 }); 340 355 } ··· 351 366 352 367 const existingRefs = await allRefs(); 353 368 354 - // Resolve `name` (stable id). If the user didn't pass --name, generate 355 - // a short random id (6 chars of Crockford-ish base32). Collisions are 356 - // astronomically unlikely but we retry if one shows up. 357 - if (!name) { 369 + // Resolve `name` (the on-disk id). If --id was passed, validate and use 370 + // it verbatim; otherwise generate a short random id. Charset, length, 371 + // and uniqueness checks are all done up front so automation fails 372 + // loudly rather than hitting EINVAL/ENAMETOOLONG deep in spawn. 373 + // 374 + // Uniqueness exception: under `-a` (attach-or-create), a collision 375 + // with an existing session is the *expected* path — cmdRun attaches 376 + // a running session or recreates an exited one. Defer to cmdRun. 377 + let name: string; 378 + if (explicitId) { 379 + try { 380 + validateName(explicitId); 381 + } catch (e: any) { 382 + console.error(e.message); 383 + process.exit(1); 384 + } 385 + if (existingRefs.has(explicitId) && !attachExisting) { 386 + console.error(`Session id "${explicitId}" is already in use (as a name or displayName).`); 387 + process.exit(1); 388 + } 389 + name = explicitId; 390 + } else { 391 + let candidate: string | null = null; 358 392 for (let attempt = 0; attempt < 8; attempt++) { 359 - const candidate = randomSessionName(); 360 - if (!existingRefs.has(candidate)) { name = candidate; break; } 393 + const c = randomSessionName(); 394 + if (!existingRefs.has(c)) { candidate = c; break; } 361 395 } 362 - if (!name) { 396 + if (!candidate) { 363 397 console.error("Could not generate a unique session id after 8 attempts."); 364 398 process.exit(1); 365 399 } 366 - } 367 - 368 - try { 369 - validateName(name); 370 - } catch (e: any) { 371 - console.error(e.message); 372 - process.exit(1); 400 + name = candidate; 373 401 } 374 402 375 - // Resolve `displayName`. Skip if --no-display-name. Otherwise 376 - // auto-generate the old human-friendly cwd+command label and dedup 377 - // against existing names/displayNames. 403 + // Resolve `displayName`. Precedence: 404 + // 1. --no-display-name → null 405 + // 2. --name <x> → x (validated permissively, deduped only 406 + // against existing refs) 407 + // 3. otherwise → auto cwd+cmd label (sanitized + deduped) 378 408 let displayName: string | null = null; 379 409 if (!noDisplayName) { 380 - let candidate = autoName(autoNameCmd, cmdArgs); 381 - candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 382 - if (existingRefs.has(candidate)) { 383 - for (let n = 2; ; n++) { 384 - const c = `${candidate}-${n}`; 385 - if (!existingRefs.has(c)) { candidate = c; break; } 410 + if (explicitDisplayName) { 411 + try { 412 + validateDisplayName(explicitDisplayName); 413 + } catch (e: any) { 414 + console.error(`Invalid displayName: ${e.message}`); 415 + process.exit(1); 416 + } 417 + if (explicitDisplayName === name) { 418 + console.error(`displayName cannot equal the session's id ("${name}").`); 419 + process.exit(1); 420 + } 421 + if (existingRefs.has(explicitDisplayName)) { 422 + console.error(`"${explicitDisplayName}" is already in use by another session (as a name or displayName).`); 423 + process.exit(1); 424 + } 425 + displayName = explicitDisplayName; 426 + } else { 427 + let candidate = autoName(autoNameCmd, cmdArgs); 428 + candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 429 + if (existingRefs.has(candidate) || candidate === name) { 430 + for (let n = 2; ; n++) { 431 + const c = `${candidate}-${n}`; 432 + if (!existingRefs.has(c) && c !== name) { candidate = c; break; } 433 + } 386 434 } 435 + displayName = candidate; 387 436 } 388 - displayName = candidate; 389 437 } 390 438 391 439 await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName); ··· 422 470 " Detach first (Ctrl+\\) or, from inside pty-layout, use ^]n to pick a session.\n" + 423 471 " Pass --force to attach anyway (nested clients are usually a mistake).", 424 472 }); 425 - try { 426 - validateName(attachName); 427 - } catch (e: any) { 428 - console.error(e.message); 429 - process.exit(1); 430 - } 431 473 const resolvedAttachName = await resolveRef(attachName); 432 474 await cmdAttach(resolvedAttachName, autoRestart, force); 433 475 break; ··· 466 508 console.error("Usage: pty peek [-f] [--plain] [--full] [--wait <pattern>] [-t <seconds>] <name>"); 467 509 process.exit(1); 468 510 } 469 - try { 470 - validateName(peekName); 471 - } catch (e: any) { 472 - console.error(e.message); 473 - process.exit(1); 474 - } 475 511 const resolvedPeekName = await resolveRef(peekName); 476 512 if (waitPatterns.length > 0) { 477 513 await cmdPeekWait(resolvedPeekName, waitPatterns, timeoutSec, plain); ··· 485 521 const sendName = args[1]; 486 522 if (!sendName) { 487 523 console.error('Usage: pty send <name> "text" or pty send <name> --seq "text" --seq key:return'); 488 - process.exit(1); 489 - } 490 - try { 491 - validateName(sendName); 492 - } catch (e: any) { 493 - console.error(e.message); 494 524 process.exit(1); 495 525 } 496 526 ··· 596 626 597 627 let resolvedEventsName: string | null = null; 598 628 if (eventsName) { 599 - try { 600 - validateName(eventsName); 601 - } catch (e: any) { 602 - console.error(e.message); 603 - process.exit(1); 604 - } 605 629 resolvedEventsName = await resolveRef(eventsName); 606 630 } 607 631 ··· 705 729 case "kill": { 706 730 if (args.length < 2) { 707 731 console.error("Usage: pty kill <name>"); 708 - process.exit(1); 709 - } 710 - try { 711 - validateName(args[1]); 712 - } catch (e: any) { 713 - console.error(e.message); 714 732 process.exit(1); 715 733 } 716 734 const resolvedKillName = await resolveRef(args[1]); ··· 730 748 console.error("Usage: pty tag <name> [key=value...] [--rm key...]"); 731 749 process.exit(1); 732 750 } 733 - try { 734 - validateName(tagName); 735 - } catch (e: any) { 736 - console.error(e.message); 737 - process.exit(1); 738 - } 739 751 const resolvedTagName = await resolveRef(tagName); 740 752 741 753 // Bulk-friendly parsing. Multiple `key=value` and `--rm <key>` may ··· 888 900 console.error("Usage: pty rm <name>"); 889 901 process.exit(1); 890 902 } 891 - try { 892 - validateName(args[1]); 893 - } catch (e: any) { 894 - console.error(e.message); 895 - process.exit(1); 896 - } 897 903 const resolvedRmName = await resolveRef(args[1]); 898 904 await cmdRm(resolvedRmName); 899 905 break; ··· 932 938 console.error("Usage: pty supervisor forget <name>"); 933 939 process.exit(1); 934 940 } 935 - await cmdSupervisorForget(forgetName); 941 + const resolvedForgetName = await resolveRef(forgetName); 942 + await cmdSupervisorForget(resolvedForgetName); 936 943 break; 937 944 } 938 945 case "reset": { ··· 941 948 console.error("Usage: pty supervisor reset <name>"); 942 949 process.exit(1); 943 950 } 944 - await cmdSupervisorReset(resetName); 951 + const resolvedResetName = await resolveRef(resetName); 952 + await cmdSupervisorReset(resolvedResetName); 945 953 break; 946 954 } 947 955 case "launchd": { ··· 1938 1946 process.exit(1); 1939 1947 } 1940 1948 1941 - // Validate the new display like a name — same charset rules keep things 1942 - // predictable in URLs, file names, and CLI args. 1949 + // Display names can be arbitrary printable text (spaces, punctuation, any 1950 + // length up to 500 chars). The on-disk id (`name`) carries the strict 1951 + // charset and the sock-path-length constraint; display names don't. 1943 1952 try { 1944 - validateName(newDisplay); 1953 + validateDisplayName(newDisplay); 1945 1954 } catch (e: any) { 1946 1955 console.error(`Invalid displayName: ${e.message}`); 1947 1956 process.exit(1); ··· 2145 2154 if (parsed.selector.kind === "names") { 2146 2155 targets = []; 2147 2156 for (const ref of parsed.selector.names) { 2148 - try { 2149 - validateName(ref); 2150 - } catch (e: any) { 2151 - console.error(`pty tag-multi: ${e.message}`); 2152 - process.exit(1); 2153 - } 2154 2157 const sess = await getSession(ref); 2155 2158 if (!sess) { 2156 2159 console.error(`pty tag-multi: session "${ref}" not found.`); ··· 2300 2303 process.exit(1); 2301 2304 } 2302 2305 2303 - try { 2304 - validateName(ref); 2305 - } catch (e: any) { 2306 - console.error(e.message); 2307 - process.exit(1); 2308 - } 2309 2306 const resolvedName = await resolveRef(ref); 2310 2307 2311 2308 let data: unknown; ··· 2580 2577 } 2581 2578 2582 2579 async function cmdSupervisorForget(name: string): Promise<void> { 2583 - try { 2584 - validateName(name); 2585 - } catch (e: any) { 2586 - console.error(e.message); 2587 - process.exit(1); 2588 - } 2589 - 2590 2580 const meta = readMetadata(name); 2591 2581 if (!meta) { 2592 2582 console.error(`Session "${name}" not found.`); ··· 2613 2603 } 2614 2604 2615 2605 async function cmdSupervisorReset(name: string): Promise<void> { 2616 - try { 2617 - validateName(name); 2618 - } catch (e: any) { 2619 - console.error(e.message); 2620 - process.exit(1); 2621 - } 2622 - 2623 2606 const meta = readMetadata(name); 2624 2607 if (!meta) { 2625 2608 console.error(`Session "${name}" not found.`); ··· 3090 3073 let sessions = ptyFile.sessions; 3091 3074 if (names.length > 0) { 3092 3075 const nameSet = new Set(names); 3093 - const matchesName = (s: PtySessionDef) => nameSet.has(s.name) || nameSet.has(s.shortName); 3094 - const unknown = names.filter((n) => !sessions.some((s) => s.name === n || s.shortName === n)); 3076 + const matchesName = (s: PtySessionDef) => nameSet.has(s.displayName) || nameSet.has(s.shortName); 3077 + const unknown = names.filter((n) => !sessions.some((s) => s.displayName === n || s.shortName === n)); 3095 3078 if (unknown.length > 0) { 3096 3079 console.error(`Unknown session${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`); 3097 3080 console.error(`Available: ${sessions.map((s) => s.shortName).join(", ")}`); ··· 3100 3083 sessions = sessions.filter(matchesName); 3101 3084 } 3102 3085 3086 + const tomlPath = path.join(ptyFile.dir, "pty.toml"); 3103 3087 const existing = await listSessions(); 3104 - const runningNames = new Set(existing.filter((s) => s.status === "running").map((s) => s.name)); 3088 + /** Find an existing session that came from this same (ptyfile, ptyfile.session) 3089 + * pair. The toml-derived displayName is just a label; identity is the tag 3090 + * pair, so renaming a session or hitting a long-name collision doesn't lose 3091 + * the binding. */ 3092 + const findByTags = (shortName: string) => existing.find((s) => 3093 + s.metadata?.tags?.ptyfile === tomlPath && 3094 + s.metadata?.tags?.["ptyfile.session"] === shortName 3095 + ); 3096 + const allRefSet = await allRefs(); 3105 3097 3106 3098 let started = 0; 3107 3099 let skipped = 0; 3108 3100 3109 3101 for (const sess of sessions) { 3110 - const tomlPath = path.join(ptyFile.dir, "pty.toml"); 3111 3102 const userTomlKeys = Object.keys(sess.tags ?? {}).sort(); 3112 3103 const ptyfileTagsValue = userTomlKeys.join(","); 3113 3104 const tomlTags: Record<string, string> = { ··· 3117 3108 "ptyfile.tags": ptyfileTagsValue, 3118 3109 }; 3119 3110 3120 - if (runningNames.has(sess.name)) { 3111 + const bound = findByTags(sess.shortName); 3112 + 3113 + if (bound && bound.status === "running") { 3121 3114 // Sync tags from toml to the running session (including ptyfile metadata). 3122 3115 // Track which tag keys came from the toml via "ptyfile.tags" so that 3123 3116 // removing a tag from the toml causes it to be removed here (but 3124 3117 // manually-added tags — those not in "ptyfile.tags" — are preserved). 3125 - const currentMeta = readMetadata(sess.name); 3126 - const currentTags = currentMeta?.tags ?? {}; 3118 + const currentTags = bound.metadata?.tags ?? {}; 3127 3119 3128 3120 const updates: Record<string, string> = {}; 3129 3121 for (const [k, v] of Object.entries(tomlTags)) { ··· 3137 3129 const newKeySet = new Set(userTomlKeys); 3138 3130 const removals = prevTomlKeys.filter((k) => !newKeySet.has(k)); 3139 3131 3132 + const label = bound.metadata?.displayName ?? bound.name; 3140 3133 if (Object.keys(updates).length > 0 || removals.length > 0) { 3141 3134 try { 3142 - updateTags(sess.name, updates, removals); 3135 + updateTags(bound.name, updates, removals); 3143 3136 const changedTagUpdates = Object.entries(updates) 3144 3137 .filter(([k]) => k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags") 3145 3138 .map(([k, v]) => `${k}=${v}`); 3146 3139 const changedRemovals = removals.map((k) => `-${k}`); 3147 3140 const changed = [...changedTagUpdates, ...changedRemovals].join(", "); 3148 3141 if (changed) { 3149 - console.log(` ● ${sess.name} (already running, updated tags: ${changed})`); 3142 + console.log(` ● ${label} (already running, updated tags: ${changed})`); 3150 3143 } else { 3151 - console.log(` ● ${sess.name} (already running)`); 3144 + console.log(` ● ${label} (already running)`); 3152 3145 } 3153 3146 } catch { 3154 - console.log(` ● ${sess.name} (already running)`); 3147 + console.log(` ● ${label} (already running)`); 3155 3148 } 3156 3149 } else { 3157 - console.log(` ● ${sess.name} (already running)`); 3150 + console.log(` ● ${label} (already running)`); 3158 3151 } 3159 3152 skipped++; 3160 3153 continue; 3161 3154 } 3162 3155 3163 - // Clean up any stale session (exited or vanished) with the same name 3164 - // so the respawn can reuse the slot. 3165 - const existingSession = existing.find((s) => s.name === sess.name); 3166 - if (existingSession && isGone(existingSession.status)) { 3167 - cleanupAll(sess.name); 3156 + // Clean up an exited bound session so its slot can be reused. 3157 + if (bound && isGone(bound.status)) { 3158 + cleanupAll(bound.name); 3159 + } 3160 + 3161 + // Pick the on-disk id: honor the pty.toml's `id = "..."` if set, 3162 + // otherwise generate a random one. Either way validate before spawn so 3163 + // long pinned ids fail with a clear up-front error. 3164 + let name: string; 3165 + if (sess.id) { 3166 + try { 3167 + validateName(sess.id); 3168 + } catch (e: any) { 3169 + console.error(` ✗ ${sess.displayName}: ${e.message}`); 3170 + continue; 3171 + } 3172 + if (allRefSet.has(sess.id)) { 3173 + console.error(` ✗ ${sess.displayName}: id "${sess.id}" is already in use (as a name or displayName).`); 3174 + continue; 3175 + } 3176 + name = sess.id; 3177 + allRefSet.add(sess.id); 3178 + } else { 3179 + let candidate: string | null = null; 3180 + for (let attempt = 0; attempt < 8; attempt++) { 3181 + const c = randomSessionName(); 3182 + if (!allRefSet.has(c)) { candidate = c; allRefSet.add(c); break; } 3183 + } 3184 + if (!candidate) { 3185 + console.error(` ✗ ${sess.displayName}: could not generate a unique session id after 8 attempts.`); 3186 + continue; 3187 + } 3188 + name = candidate; 3189 + } 3190 + 3191 + // Validate the toml-derived displayName once. Default `<prefix>-<short>` 3192 + // is always safe; an explicit `display_name` field could be anything. 3193 + try { 3194 + validateDisplayName(sess.displayName); 3195 + } catch (e: any) { 3196 + console.error(` ✗ ${sess.displayName}: ${e.message}`); 3197 + continue; 3168 3198 } 3169 3199 3170 3200 try { 3171 3201 await spawnDaemon({ 3172 - name: sess.name, 3202 + name, 3173 3203 command: "/bin/sh", 3174 3204 args: ["-c", commandWithEnvExports(sess)], 3175 3205 displayCommand: sess.command, 3176 3206 cwd: ptyFile.dir, 3177 3207 tags: tomlTags, 3208 + displayName: sess.displayName, 3178 3209 }); 3179 - console.log(` ● ${sess.name} (started)`); 3210 + console.log(` ● ${sess.displayName} (started)`); 3180 3211 started++; 3181 3212 } catch (e: any) { 3182 - console.error(` ✗ ${sess.name}: ${e.message}`); 3213 + console.error(` ✗ ${sess.displayName}: ${e.message}`); 3183 3214 } 3184 3215 } 3185 3216 ··· 3202 3233 let sessions = ptyFile.sessions; 3203 3234 if (names.length > 0) { 3204 3235 const nameSet = new Set(names); 3205 - sessions = sessions.filter((s) => nameSet.has(s.name) || nameSet.has(s.shortName)); 3236 + sessions = sessions.filter((s) => nameSet.has(s.displayName) || nameSet.has(s.shortName)); 3206 3237 } 3207 3238 3239 + const tomlPath = path.join(ptyFile.dir, "pty.toml"); 3208 3240 const existing = await listSessions(); 3241 + const findByTags = (shortName: string) => existing.find((s) => 3242 + s.metadata?.tags?.ptyfile === tomlPath && 3243 + s.metadata?.tags?.["ptyfile.session"] === shortName 3244 + ); 3209 3245 let stopped = 0; 3210 3246 3211 3247 for (const sess of sessions) { 3212 - const existingSession = existing.find((s) => s.name === sess.name); 3248 + const existingSession = findByTags(sess.shortName); 3213 3249 if (!existingSession) continue; 3214 3250 3251 + const label = existingSession.metadata?.displayName ?? existingSession.name; 3252 + 3215 3253 // Remove supervision tags so the supervisor doesn't restart it 3216 3254 const wasSupervised = existingSession.metadata?.tags?.strategy === "permanent" || existingSession.metadata?.tags?.strategy === "temporary"; 3217 3255 if (wasSupervised) { 3218 3256 try { 3219 3257 const removals = ["strategy"]; 3220 3258 if (existingSession.metadata?.tags?.["supervisor.status"]) removals.push("supervisor.status"); 3221 - updateTags(sess.name, {}, removals); 3259 + updateTags(existingSession.name, {}, removals); 3222 3260 } catch {} 3223 3261 } 3224 3262 3225 3263 if (existingSession.status === "running" && existingSession.pid) { 3226 3264 try { 3227 3265 process.kill(existingSession.pid, "SIGTERM"); 3228 - console.log(` ○ ${sess.name} (stopped${wasSupervised ? ", removed from supervision" : ""})`); 3266 + console.log(` ○ ${label} (stopped${wasSupervised ? ", removed from supervision" : ""})`); 3229 3267 stopped++; 3230 3268 } catch { 3231 - console.error(` ✗ ${sess.name}: failed to stop`); 3269 + console.error(` ✗ ${label}: failed to stop`); 3232 3270 } 3233 - cleanupSocket(sess.name); 3271 + cleanupSocket(existingSession.name); 3234 3272 } else if (isGone(existingSession.status)) { 3235 - cleanupAll(sess.name); 3236 - console.log(` ○ ${sess.name} (cleaned up)`); 3273 + cleanupAll(existingSession.name); 3274 + console.log(` ○ ${label} (cleaned up)`); 3237 3275 stopped++; 3238 3276 } 3239 3277 } ··· 3245 3283 } 3246 3284 3247 3285 // Warn if any stopped sessions are toml-managed 3248 - const anyTomlManaged = sessions.some((sess) => { 3249 - const existingSession = existing.find((s) => s.name === sess.name); 3250 - return existingSession?.metadata?.tags?.ptyfile; 3251 - }); 3286 + const anyTomlManaged = sessions.some((sess) => findByTags(sess.shortName)?.metadata?.tags?.ptyfile); 3252 3287 if (anyTomlManaged && stopped > 0) { 3253 3288 console.error("\nNote: strategy tags will be restored on the next 'pty up'."); 3254 3289 } ··· 3259 3294 yes = false, 3260 3295 forceNested = false, 3261 3296 ): Promise<void> { 3262 - try { 3263 - validateName(name); 3264 - } catch (e: any) { 3265 - console.error(e.message); 3266 - process.exit(1); 3267 - } 3268 - 3269 3297 const session = await getSession(name); 3270 3298 3271 3299 if (!session) {
+36 -8
src/ptyfile.ts
··· 5 5 const PTY_TOML = "pty.toml"; 6 6 7 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) 8 + /** Human-friendly label rendered by `pty ls` / events. By default: 9 + * `<prefix>-<shortName>` if `prefix` is set, else `<shortName>`. The 10 + * toml's optional `display_name = "..."` field overrides this. NOT the 11 + * on-disk identifier — that's a separate short random id (or the toml's 12 + * optional `id` field) generated at spawn time, so long display labels 13 + * don't blow past the macOS `sockaddr_un.sun_path` limit. */ 14 + displayName: string; 15 + /** Name as written in the toml (for `pty up <name>` filtering). */ 16 + shortName: string; 17 + /** Explicit on-disk id from `id = "..."` in the toml. When null, `pty up` 18 + * generates a random short id at spawn time. */ 19 + id: string | null; 10 20 command: string; 11 21 tags?: Record<string, string>; 12 22 env?: Record<string, string>; ··· 45 55 46 56 if (parsed.sessions && typeof parsed.sessions === "object") { 47 57 for (const [rawName, def] of Object.entries(parsed.sessions)) { 48 - const name = prefix ? `${prefix}-${rawName}` : rawName; 58 + const defaultDisplayName = prefix ? `${prefix}-${rawName}` : rawName; 49 59 if (!def || typeof def !== "object") { 50 - throw new Error(`Invalid session "${name}" in ${filePath}: expected a table`); 60 + throw new Error(`Invalid session "${defaultDisplayName}" in ${filePath}: expected a table`); 51 61 } 52 62 const d = def as Record<string, unknown>; 53 63 if (typeof d.command !== "string" || d.command.length === 0) { 54 - throw new Error(`Session "${name}" in ${filePath} is missing a "command" field`); 64 + throw new Error(`Session "${defaultDisplayName}" in ${filePath} is missing a "command" field`); 65 + } 66 + 67 + // Optional `display_name` override. 68 + let displayName = defaultDisplayName; 69 + if (d.display_name !== undefined) { 70 + if (typeof d.display_name !== "string" || d.display_name.length === 0) { 71 + throw new Error(`Session "${defaultDisplayName}" in ${filePath}: "display_name" must be a non-empty string`); 72 + } 73 + displayName = d.display_name; 74 + } 75 + 76 + // Optional `id` override (pinned on-disk identifier). 77 + let id: string | null = null; 78 + if (d.id !== undefined) { 79 + if (typeof d.id !== "string" || d.id.length === 0) { 80 + throw new Error(`Session "${defaultDisplayName}" in ${filePath}: "id" must be a non-empty string`); 81 + } 82 + id = d.id; 55 83 } 56 84 57 85 let tags: Record<string, string> | undefined; ··· 65 93 let env: Record<string, string> | undefined; 66 94 if (d.env !== undefined) { 67 95 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`); 96 + throw new Error(`Session "${defaultDisplayName}" in ${filePath}: "env" must be a table of string values`); 69 97 } 70 98 env = {}; 71 99 for (const [k, v] of Object.entries(d.env as Record<string, unknown>)) { 72 100 if (typeof v !== "string") { 73 - throw new Error(`Session "${name}" in ${filePath}: env.${k} must be a string`); 101 + throw new Error(`Session "${defaultDisplayName}" in ${filePath}: env.${k} must be a string`); 74 102 } 75 103 env[k] = v; 76 104 } 77 105 } 78 106 79 - sessions.push({ name, shortName: rawName, command: d.command, tags, env }); 107 + sessions.push({ displayName, shortName: rawName, id, command: d.command, tags, env }); 80 108 } 81 109 } 82 110
+1
src/server.ts
··· 859 859 exitedAt: new Date().toISOString(), 860 860 lastLines: this.getLastLines(), 861 861 ...(existing?.tags ? { tags: existing.tags } : {}), 862 + ...(existing?.displayName ? { displayName: existing.displayName } : {}), 862 863 }); 863 864 } 864 865
+19
src/sessions.ts
··· 46 46 } 47 47 } 48 48 49 + /** Permissive validator for display labels. Allowed: anything printable 50 + * including spaces and punctuation, ≤ 500 chars. Rejected: empty, NUL, 51 + * slashes (would confuse path-shaped UIs), backslashes, newlines, other 52 + * control characters. Length cap is a sanity limit, not a kernel limit — 53 + * display labels live in metadata.json, not in the socket path. */ 54 + export function validateDisplayName(name: string): void { 55 + if (!name || name.length === 0) { 56 + throw new Error("Display name cannot be empty."); 57 + } 58 + if (name.length > 500) { 59 + throw new Error("Display name too long (max 500 characters)."); 60 + } 61 + if (/[\0\/\\\n\r\t\x00-\x1f\x7f]/.test(name)) { 62 + throw new Error( 63 + `Invalid display name. Slashes, backslashes, newlines, and control characters are not allowed.` 64 + ); 65 + } 66 + } 67 + 49 68 export function getSessionDir(): string { 50 69 return process.env.PTY_SESSION_DIR ?? DEFAULT_SESSION_DIR; 51 70 }
+11 -3
src/spawn.ts
··· 196 196 * needs surface. 197 197 * 198 198 * `isolateEnv` maps to `--isolate-env`. `cwd` to `--cwd`. `tags` to 199 - * repeated `--tag k=v`. `name` to `--name`. The session command is 200 - * positional after `--`. 199 + * repeated `--tag k=v`. `name` to `--id` (the on-disk identifier under the 200 + * decoupled name/displayName model). `displayName` to `--name` if present. 201 + * The session command is positional after `--`. 201 202 */ 202 203 function spawnViaCli(options: SpawnDaemonOptions): Promise<void> { 203 - const cliArgs: string[] = ["run", "-d", "--name", options.name]; 204 + const cliArgs: string[] = ["run", "-d", "--id", options.name]; 205 + if (options.displayName) { 206 + cliArgs.push("--name", options.displayName); 207 + } else { 208 + // No display label requested — match spawnDaemon's behavior of leaving 209 + // displayName unset (rather than CLI's default of auto-generating one). 210 + cliArgs.push("--no-display-name"); 211 + } 204 212 if (options.cwd) cliArgs.push("--cwd", options.cwd); 205 213 if (options.isolateEnv) cliArgs.push("--isolate-env"); 206 214 if (options.tags) {
+6 -1
src/supervisor.ts
··· 401 401 // wipes the metadata file can still recover on the next attempt. 402 402 tracked.spawnConfig = { command, args, displayCommand, cwd, tags }; 403 403 404 + // Preserve the existing displayName across the respawn so the user-visible 405 + // label survives. (`name` is the immutable on-disk id; displayName is the 406 + // pretty label that pty.toml sets.) 407 + const displayName = metadata.displayName; 408 + 404 409 // Clean up the dead session 405 410 cleanupAll(name); 406 411 407 412 // Respawn 408 - await spawnDaemon({ name, command, args, displayCommand, cwd, tags }); 413 + await spawnDaemon({ name, command, args, displayCommand, cwd, tags, ...(displayName ? { displayName } : {}) }); 409 414 410 415 tracked.restartCount++; 411 416 tracked.lastRestartAt = Date.now();
+142 -15
tests/display-name.test.ts
··· 94 94 }); 95 95 }); 96 96 97 - describe("--name: explicit name + auto displayName (unless --no-display-name)", () => { 98 - it("honors --name and generates a displayName", () => { 97 + describe("--id: explicit on-disk id", () => { 98 + it("honors --id (pinned on-disk id) and auto-generates a displayName", () => { 99 99 const dir = makeSessionDir(); 100 - const r = runCli(dir, {}, "run", "-d", "--name", "mysvc", "--", "cat"); 100 + const r = runCli(dir, {}, "run", "-d", "--id", "mysvc", "--", "cat"); 101 101 expect(r.status).toBe(0); 102 102 103 103 const sessions = listJson(dir); ··· 107 107 collectPid(dir, "mysvc"); 108 108 }); 109 109 110 - it("--name combined with --no-display-name skips displayName", () => { 110 + it("--id combined with --no-display-name skips displayName", () => { 111 111 const dir = makeSessionDir(); 112 - const r = runCli(dir, {}, "run", "-d", "--name", "raw", "--no-display-name", "--", "cat"); 112 + const r = runCli(dir, {}, "run", "-d", "--id", "raw", "--no-display-name", "--", "cat"); 113 113 expect(r.status).toBe(0); 114 114 115 115 const sessions = listJson(dir); ··· 117 117 expect(s.displayName).toBeUndefined(); 118 118 collectPid(dir, "raw"); 119 119 }); 120 + 121 + it("--id combined with explicit --name pins both id and display label", () => { 122 + const dir = makeSessionDir(); 123 + const r = runCli(dir, {}, "run", "-d", "--id", "svc", "--name", "My Pretty Service", "--", "cat"); 124 + expect(r.status).toBe(0); 125 + 126 + const sessions = listJson(dir); 127 + const s = sessions.find((s: any) => s.name === "svc")!; 128 + expect(s).toBeDefined(); 129 + expect(s.displayName).toBe("My Pretty Service"); 130 + collectPid(dir, "svc"); 131 + }); 132 + 133 + it("rejects an --id whose sock path would exceed the kernel limit", () => { 134 + const dir = makeSessionDir(); 135 + const longId = "x".repeat(120); 136 + const r = runCli(dir, {}, "run", "-d", "--id", longId, "--", "cat"); 137 + expect(r.status).not.toBe(0); 138 + expect(r.stderr).toMatch(/exceeds the.*byte kernel limit|too long/i); 139 + }); 140 + 141 + it("rejects an --id that collides with an existing session", () => { 142 + const dir = makeSessionDir(); 143 + runCli(dir, {}, "run", "-d", "--id", "dup", "--", "cat"); 144 + const r = runCli(dir, {}, "run", "-d", "--id", "dup", "--", "cat"); 145 + expect(r.status).not.toBe(0); 146 + expect(r.stderr).toContain("already in use"); 147 + collectPid(dir, "dup"); 148 + }); 149 + }); 150 + 151 + describe("--name: explicit display label (any length / chars)", () => { 152 + it("accepts a long display label that would not be a valid id", () => { 153 + const dir = makeSessionDir(); 154 + const longLabel = "My Very Long Display Label With Spaces and Punctuation"; 155 + const r = runCli(dir, {}, "run", "-d", "--name", longLabel, "--", "cat"); 156 + expect(r.status).toBe(0); 157 + 158 + const sessions = listJson(dir); 159 + expect(sessions).toHaveLength(1); 160 + expect(sessions[0].displayName).toBe(longLabel); 161 + // The on-disk name is a random short id, not the display label. 162 + expect(sessions[0].name).toMatch(/^[a-z0-9]{6,12}$/); 163 + expect(sessions[0].name).not.toBe(longLabel); 164 + collectPid(dir, sessions[0].name); 165 + }); 166 + 167 + it("rejects an --id equal to the explicit --name", () => { 168 + const dir = makeSessionDir(); 169 + const r = runCli(dir, {}, "run", "-d", "--id", "same", "--name", "same", "--", "cat"); 170 + expect(r.status).not.toBe(0); 171 + expect(r.stderr).toMatch(/cannot equal/i); 172 + }); 173 + 174 + it("rejects an --name that collides with another session", () => { 175 + const dir = makeSessionDir(); 176 + runCli(dir, {}, "run", "-d", "--id", "a1", "--name", "shared", "--", "cat"); 177 + const r = runCli(dir, {}, "run", "-d", "--id", "a2", "--name", "shared", "--", "cat"); 178 + expect(r.status).not.toBe(0); 179 + expect(r.stderr).toContain("already in use"); 180 + collectPid(dir, "a1"); 181 + }); 120 182 }); 121 183 122 184 describe("pty rename (outside a session)", () => { 123 185 it("pty rename <ref> <new> sets displayName", () => { 124 186 const dir = makeSessionDir(); 125 - runCli(dir, {}, "run", "-d", "--name", "webapp", "--no-display-name", "--", "cat"); 187 + runCli(dir, {}, "run", "-d", "--id", "webapp", "--no-display-name", "--", "cat"); 126 188 127 189 const r = runCli(dir, {}, "rename", "webapp", "my-label"); 128 190 expect(r.status).toBe(0); ··· 135 197 136 198 it("pty rename --show <ref> prints current displayName", () => { 137 199 const dir = makeSessionDir(); 138 - runCli(dir, {}, "run", "-d", "--name", "api", "--no-display-name", "--", "cat"); 200 + runCli(dir, {}, "run", "-d", "--id", "api", "--no-display-name", "--", "cat"); 139 201 runCli(dir, {}, "rename", "api", "friendly-api"); 140 202 141 203 const r = runCli(dir, {}, "rename", "--show", "api"); ··· 146 208 147 209 it("pty rename --show <ref> without displayName prints a hint", () => { 148 210 const dir = makeSessionDir(); 149 - runCli(dir, {}, "run", "-d", "--name", "bare", "--no-display-name", "--", "cat"); 211 + runCli(dir, {}, "run", "-d", "--id", "bare", "--no-display-name", "--", "cat"); 150 212 151 213 const r = runCli(dir, {}, "rename", "--show", "bare"); 152 214 expect(r.status).toBe(0); ··· 156 218 157 219 it("pty rename --clear <ref> removes displayName", () => { 158 220 const dir = makeSessionDir(); 159 - runCli(dir, {}, "run", "-d", "--name", "svc", "--no-display-name", "--", "cat"); 221 + runCli(dir, {}, "run", "-d", "--id", "svc", "--no-display-name", "--", "cat"); 160 222 runCli(dir, {}, "rename", "svc", "pretty"); 161 223 expect(listJson(dir).find((s: any) => s.name === "svc")!.displayName).toBe("pretty"); 162 224 ··· 175 237 176 238 it("rejects displayName that collides with another session's name", () => { 177 239 const dir = makeSessionDir(); 178 - runCli(dir, {}, "run", "-d", "--name", "aaa", "--no-display-name", "--", "cat"); 179 - runCli(dir, {}, "run", "-d", "--name", "bbb", "--no-display-name", "--", "cat"); 240 + runCli(dir, {}, "run", "-d", "--id", "aaa", "--no-display-name", "--", "cat"); 241 + runCli(dir, {}, "run", "-d", "--id", "bbb", "--no-display-name", "--", "cat"); 180 242 181 243 const r = runCli(dir, {}, "rename", "aaa", "bbb"); 182 244 expect(r.status).not.toBe(0); ··· 187 249 188 250 it("rejects displayName equal to the session's own name", () => { 189 251 const dir = makeSessionDir(); 190 - runCli(dir, {}, "run", "-d", "--name", "same", "--no-display-name", "--", "cat"); 252 + runCli(dir, {}, "run", "-d", "--id", "same", "--no-display-name", "--", "cat"); 191 253 const r = runCli(dir, {}, "rename", "same", "same"); 192 254 expect(r.status).not.toBe(0); 193 255 expect(r.stderr).toContain("cannot equal"); ··· 198 260 describe("pty rename (inside a session)", () => { 199 261 it("pty rename <new> with PTY_SESSION set renames the current session", () => { 200 262 const dir = makeSessionDir(); 201 - runCli(dir, {}, "run", "-d", "--name", "insider", "--no-display-name", "--", "cat"); 263 + runCli(dir, {}, "run", "-d", "--id", "insider", "--no-display-name", "--", "cat"); 202 264 203 265 const r = runCli(dir, { PTY_SESSION: "insider" }, "rename", "from-inside"); 204 266 expect(r.status).toBe(0); ··· 209 271 210 272 it("pty rename --clear with PTY_SESSION clears the current session's displayName", () => { 211 273 const dir = makeSessionDir(); 212 - runCli(dir, {}, "run", "-d", "--name", "i2", "--no-display-name", "--", "cat"); 274 + runCli(dir, {}, "run", "-d", "--id", "i2", "--no-display-name", "--", "cat"); 213 275 runCli(dir, { PTY_SESSION: "i2" }, "rename", "has-a-display"); 214 276 215 277 const r = runCli(dir, { PTY_SESSION: "i2" }, "rename", "--clear"); ··· 222 284 describe("lookup by displayName", () => { 223 285 it("pty list references a session by its displayName for peek/stats/etc", () => { 224 286 const dir = makeSessionDir(); 225 - runCli(dir, {}, "run", "-d", "--name", "raw1", "--no-display-name", "--", "cat"); 287 + runCli(dir, {}, "run", "-d", "--id", "raw1", "--no-display-name", "--", "cat"); 226 288 runCli(dir, {}, "rename", "raw1", "friendly"); 227 289 228 290 // stats by displayName should resolve the same session as by name ··· 233 295 collectPid(dir, "raw1"); 234 296 }); 235 297 }); 298 + 299 + describe("commands resolve a long displayName even when it would fail validateName", () => { 300 + // Regression test for schickling-assistant's PR #45 finding: 301 + // long displayNames could be CREATED but not OPERATED on, because the 302 + // command handlers ran `validateName(ref)` before `resolveRef(ref)`. 303 + // `validateName` is the strict, sock-path-bounded validator; a long 304 + // displayName legitimately fails it. Resolution paths must NOT run 305 + // strict validation — that's reserved for id creation. 306 + const LONG_LABEL = "org.cos.orc-payments-platform.orc-checkout-api.worker-authz-service.subworker-db-migrations.verifier-contracts"; 307 + 308 + it("creates a 110-char displayName cleanly", () => { 309 + const dir = makeSessionDir(); 310 + expect(LONG_LABEL.length).toBe(110); 311 + const r = runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 312 + expect(r.status).toBe(0); 313 + const sessions = listJson(dir); 314 + expect(sessions).toHaveLength(1); 315 + expect(sessions[0].displayName).toBe(LONG_LABEL); 316 + collectPid(dir, sessions[0].name); 317 + }); 318 + 319 + it("pty peek <longDisplayName> works", () => { 320 + const dir = makeSessionDir(); 321 + runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 322 + const r = runCli(dir, {}, "peek", "--plain", LONG_LABEL); 323 + expect(r.status).toBe(0); 324 + // Don't assert on screen content — just that the command resolves and exits 0. 325 + collectPid(dir, listJson(dir)[0].name); 326 + }); 327 + 328 + it("pty send <longDisplayName> works", () => { 329 + const dir = makeSessionDir(); 330 + runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 331 + const r = runCli(dir, {}, "send", LONG_LABEL, "hi"); 332 + expect(r.status).toBe(0); 333 + collectPid(dir, listJson(dir)[0].name); 334 + }); 335 + 336 + it("pty tag <longDisplayName> works", () => { 337 + const dir = makeSessionDir(); 338 + runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 339 + const r = runCli(dir, {}, "tag", LONG_LABEL, "role=worker"); 340 + expect(r.status).toBe(0); 341 + const s = listJson(dir).find((s: any) => s.displayName === LONG_LABEL)!; 342 + expect(s.tags.role).toBe("worker"); 343 + collectPid(dir, s.name); 344 + }); 345 + 346 + it("pty events <longDisplayName> --recent works", () => { 347 + const dir = makeSessionDir(); 348 + runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 349 + const r = runCli(dir, {}, "events", "--recent", LONG_LABEL); 350 + expect(r.status).toBe(0); 351 + collectPid(dir, listJson(dir)[0].name); 352 + }); 353 + 354 + it("pty kill <longDisplayName> works", () => { 355 + const dir = makeSessionDir(); 356 + runCli(dir, {}, "run", "-d", "--name", LONG_LABEL, "--", "cat"); 357 + const r = runCli(dir, {}, "kill", LONG_LABEL); 358 + expect(r.status).toBe(0); 359 + const running = listJson(dir).filter((s: any) => s.status === "running"); 360 + expect(running).toHaveLength(0); 361 + }); 362 + });
+3 -3
tests/nesting-prevention.test.ts
··· 246 246 const target = uniqueName(); 247 247 await startDaemon(dir, target); 248 248 249 - const r = runCliNested(dir, "outer-session", "run", "-a", "--name", target, "--", "cat"); 249 + const r = runCliNested(dir, "outer-session", "run", "-a", "--id", target, "--", "cat"); 250 250 expect(r.status).not.toBe(0); 251 251 expect(r.stderr).toContain('already inside pty session "outer-session"'); 252 252 expect(r.stderr).toContain(target); ··· 257 257 const target = uniqueName(); 258 258 // No daemon started — target doesn't exist. 259 259 260 - const r = runCliNested(dir, "outer-session", "run", "-a", "--name", target, "--", "true"); 260 + const r = runCliNested(dir, "outer-session", "run", "-a", "--id", target, "--", "true"); 261 261 // Should hit the exec-directly path: exec `true` which exits 0. 262 262 expect(r.stderr).toContain("Already inside pty session"); 263 263 expect(r.stderr).toContain("running directly"); ··· 268 268 const target = uniqueName(); 269 269 await startDaemon(dir, target); 270 270 271 - const r = runCliNested(dir, "outer-session", "run", "-a", "--force", "--name", target, "--", "cat"); 271 + const r = runCliNested(dir, "outer-session", "run", "-a", "--force", "--id", target, "--", "cat"); 272 272 // With --force we fall through past the nesting guard. The child run 273 273 // command will likely fail for a different reason (no daemon spawn in 274 274 // the nested-exec path) — just confirm the nesting error didn't fire.
+2 -2
tests/nesting.test.ts
··· 155 155 it("-d flag bypasses nesting check", async () => { 156 156 const dir = makeSessionDir(); 157 157 const name = uniqueName(); 158 - const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 158 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--id", name, "--", "cat"], { 159 159 env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: "outer-session" }, 160 160 encoding: "utf-8", 161 161 timeout: 10000, ··· 194 194 const env = { ...process.env, PTY_SESSION_DIR: dir }; 195 195 delete env.PTY_SESSION; 196 196 197 - const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 197 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--id", name, "--", "cat"], { 198 198 env, 199 199 encoding: "utf-8", 200 200 timeout: 10000,
+6 -6
tests/ptyfile.test.ts
··· 65 65 describe("commandWithEnvExports", () => { 66 66 it("returns the bare command when env is absent", () => { 67 67 expect(commandWithEnvExports({ 68 - name: "x", shortName: "x", command: "echo hi", 68 + displayName: "x", shortName: "x", id: null, command: "echo hi", 69 69 })).toBe("echo hi"); 70 70 }); 71 71 72 72 it("returns the bare command when env is empty", () => { 73 73 expect(commandWithEnvExports({ 74 - name: "x", shortName: "x", command: "echo hi", env: {}, 74 + displayName: "x", shortName: "x", id: null, command: "echo hi", env: {}, 75 75 })).toBe("echo hi"); 76 76 }); 77 77 78 78 it("prepends export statements", () => { 79 79 expect(commandWithEnvExports({ 80 - name: "x", shortName: "x", command: "echo $FOO", 80 + displayName: "x", shortName: "x", id: null, command: "echo $FOO", 81 81 env: { FOO: "bar" }, 82 82 })).toBe("export FOO='bar'; echo $FOO"); 83 83 }); 84 84 85 85 it("emits one export per env entry", () => { 86 86 const out = commandWithEnvExports({ 87 - name: "x", shortName: "x", command: "do-thing", 87 + displayName: "x", shortName: "x", id: null, command: "do-thing", 88 88 env: { A: "1", B: "two" }, 89 89 }); 90 90 expect(out).toContain("export A='1'"); ··· 94 94 95 95 it("escapes single quotes in values", () => { 96 96 expect(commandWithEnvExports({ 97 - name: "x", shortName: "x", command: "echo $MSG", 97 + displayName: "x", shortName: "x", id: null, command: "echo $MSG", 98 98 env: { MSG: "it's a value" }, 99 99 })).toBe(`export MSG='it'\\''s a value'; echo $MSG`); 100 100 }); 101 101 102 102 it("handles values with shell metacharacters safely", () => { 103 103 expect(commandWithEnvExports({ 104 - name: "x", shortName: "x", command: "go", 104 + displayName: "x", shortName: "x", id: null, command: "go", 105 105 env: { PATH_LIKE: "$HOME/bin:/usr/bin; echo pwned" }, 106 106 })).toBe(`export PATH_LIKE='$HOME/bin:/usr/bin; echo pwned'; go`); 107 107 });
+4 -4
tests/spawn-options.test.ts
··· 150 150 const name = uniqueName(); 151 151 152 152 // Use CLI to spawn (exercises spawnDaemon with options object internally) 153 - const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--name", name, "--", "cat"], { 153 + const result = spawnSync(nodeBin, [cliPath, "run", "-d", "--id", name, "--", "cat"], { 154 154 env: { ...process.env, PTY_SESSION_DIR: dir }, 155 155 encoding: "utf-8", 156 156 timeout: 10000, ··· 232 232 const secret = "pty_isolated_test_secret_must_not_leak"; 233 233 234 234 const runResult = spawnSync(nodeBin, [ 235 - cliPath, "run", "-d", "--name", name, "--isolate-env", 235 + cliPath, "run", "-d", "--id", name, "--isolate-env", 236 236 "--", "sh", "-c", "env > /tmp/pty-iso-env.txt; sleep 30", 237 237 ], { 238 238 env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SECRET_TEST: secret }, ··· 264 264 265 265 const marker = "pty_legacy_env_test_marker"; 266 266 const runResult = spawnSync(nodeBin, [ 267 - cliPath, "run", "-d", "--name", name, 267 + cliPath, "run", "-d", "--id", name, 268 268 "--", "sh", "-c", "env > /tmp/pty-legacy-env.txt; sleep 30", 269 269 ], { 270 270 env: { ...process.env, PTY_SESSION_DIR: dir, PTY_LEGACY_MARKER: marker }, ··· 484 484 delete callerEnv.TERM; 485 485 486 486 const runResult = spawnSync(nodeBin, [ 487 - cliPath, "run", "-d", "--name", name, "--isolate-env", 487 + cliPath, "run", "-d", "--id", name, "--isolate-env", 488 488 "--", "sh", "-c", `env > ${JSON.stringify(dumpFile)}; sleep 30`, 489 489 ], { 490 490 env: callerEnv,
+17 -6
tests/supervisor-hardening.test.ts
··· 95 95 describe("displayCommand formatting", () => { 96 96 it("pty run shows command with args in list", () => { 97 97 const dir = makeSessionDir(); 98 - const name = uniqueName(); 98 + const id = uniqueName(); 99 99 100 - runCli(dir, "run", "-d", "--name", name, "--", "echo", "hello", "world"); 100 + runCli(dir, "run", "-d", "--id", id, "--", "echo", "hello", "world"); 101 101 // Wait for session to start 102 102 const start = Date.now(); 103 103 while (Date.now() - start < 3000) { 104 - const meta = readMeta(dir, name); 104 + const meta = readMeta(dir, id); 105 105 if (meta) break; 106 106 } 107 107 108 - const meta = readMeta(dir, name); 108 + const meta = readMeta(dir, id); 109 109 expect(meta).not.toBeNull(); 110 110 expect(meta.displayCommand).toBe("echo hello world"); 111 111 }, 15000); ··· 120 120 121 121 runCli(dir, "up", projDir); 122 122 123 - const meta = readMeta(dir, "serve"); 123 + // pty up gives the session a random short id as the on-disk name and 124 + // sets `serve` as the displayName. Wait for the session to register, then 125 + // look up the on-disk name by displayName. 126 + const start = Date.now(); 127 + let sess: any = undefined; 128 + while (Date.now() - start < 3000) { 129 + const listResult = JSON.parse(runCli(dir, "list", "--json").stdout) as any[]; 130 + sess = listResult.find((s: any) => s.displayName === "serve"); 131 + if (sess) break; 132 + } 133 + expect(sess).toBeDefined(); 134 + const meta = readMeta(dir, sess.name); 124 135 expect(meta).not.toBeNull(); 125 136 expect(meta.displayCommand).toBe("echo server running"); 126 137 // command should be /bin/sh, args should be ["-c", "echo server running"] ··· 247 258 const dir = makeSessionDir(); 248 259 249 260 // Try to spawn with a nonexistent command — daemon will crash 250 - const result = runCli(dir, "run", "-d", "--name", "leak-test", "--", "/nonexistent/command"); 261 + const result = runCli(dir, "run", "-d", "--id", "leak-test", "--", "/nonexistent/command"); 251 262 252 263 // The command should have failed 253 264 expect(result.status).not.toBe(0);
+4 -4
tests/tags.test.ts
··· 257 257 const name = uniqueName(); 258 258 259 259 const result = spawnSync(nodeBin, [ 260 - cliPath, "run", "-d", "--name", name, 260 + cliPath, "run", "-d", "--id", name, 261 261 "--tag", "owner=forge", "--tag", "env=staging", 262 262 "--", "cat", 263 263 ], { ··· 318 318 await new Promise((r) => setTimeout(r, 1000)); // wait for exit 319 319 320 320 // Recreate with run -a (no new --tag flags) 321 - const result = spawnSync(nodeBin, [cliPath, "run", "-a", "-d", "--name", name, "--", "cat"], { 321 + const result = spawnSync(nodeBin, [cliPath, "run", "-a", "-d", "--id", name, "--", "cat"], { 322 322 env: { ...process.env, PTY_SESSION_DIR: dir }, 323 323 encoding: "utf-8", 324 324 timeout: 10000, ··· 347 347 348 348 // Recreate with run -a with NEW tags 349 349 const result = spawnSync(nodeBin, [ 350 - cliPath, "run", "-a", "-d", "--name", name, 350 + cliPath, "run", "-a", "-d", "--id", name, 351 351 "--tag", "owner=new", 352 352 "--", "cat", 353 353 ], { ··· 373 373 const dir = makeSessionDir(); 374 374 375 375 const result = spawnSync(nodeBin, [ 376 - cliPath, "run", "-d", "--name", "bad-tag", 376 + cliPath, "run", "-d", "--id", "bad-tag", 377 377 "--tag", "no-equals-sign", 378 378 "--", "cat", 379 379 ], {
+14 -14
tests/up-down.test.ts
··· 118 118 const sessions = listJson(sessDir); 119 119 const running = sessions.filter((s: any) => s.status === "running"); 120 120 expect(running).toHaveLength(2); 121 - expect(running.map((s: any) => s.name).sort()).toEqual(["db", "web"]); 121 + expect(running.map((s: any) => s.displayName).sort()).toEqual(["db", "web"]); 122 122 }, 15000); 123 123 124 124 it("propagates env from pty.toml into the spawned session", async () => { ··· 187 187 188 188 // Verify tags were applied 189 189 const sessions = listJson(sessDir); 190 - const session = sessions.find((s: any) => s.name === "syncme"); 190 + const session = sessions.find((s: any) => s.displayName === "syncme"); 191 191 expect(session.tags.strategy).toBe("permanent"); 192 192 expect(session.tags.role).toBe("server"); 193 193 expect(session.tags.ptyfile).toBeDefined(); ··· 211 211 runCli(sessDir, "up", projDir); 212 212 213 213 const sessions = listJson(sessDir); 214 - const session = sessions.find((s: any) => s.name === "manual"); 214 + const session = sessions.find((s: any) => s.displayName === "manual"); 215 215 expect(session.tags.role).toBe("server"); 216 216 expect(session.tags.custom).toBe("yes"); 217 217 }, 15000); ··· 228 228 runCli(sessDir, "up", projDir); 229 229 230 230 let sessions = listJson(sessDir); 231 - let session = sessions.find((s: any) => s.name === "remover"); 231 + let session = sessions.find((s: any) => s.displayName === "remover"); 232 232 expect(session.tags.role).toBe("server"); 233 233 expect(session.tags.env).toBe("dev"); 234 234 ··· 242 242 expect(result.stdout).toContain("-env"); 243 243 244 244 sessions = listJson(sessDir); 245 - session = sessions.find((s: any) => s.name === "remover"); 245 + session = sessions.find((s: any) => s.displayName === "remover"); 246 246 expect(session.tags.role).toBe("server"); 247 247 expect(session.tags.env).toBeUndefined(); 248 248 // Metadata tags should still be present ··· 271 271 expect(result.stdout).toContain("-role"); 272 272 273 273 const sessions = listJson(sessDir); 274 - const session = sessions.find((s: any) => s.name === "cleared"); 274 + const session = sessions.find((s: any) => s.displayName === "cleared"); 275 275 expect(session.tags.role).toBeUndefined(); 276 276 expect(session.tags.env).toBeUndefined(); 277 277 expect(session.tags.ptyfile).toBeDefined(); ··· 299 299 runCli(sessDir, "up", projDir); 300 300 301 301 const sessions = listJson(sessDir); 302 - const session = sessions.find((s: any) => s.name === "mixer"); 302 + const session = sessions.find((s: any) => s.displayName === "mixer"); 303 303 expect(session.tags.role).toBeUndefined(); 304 304 expect(session.tags.custom).toBe("yes"); 305 305 }, 20000); ··· 325 325 expect(result.stdout).not.toContain("-env"); 326 326 327 327 const sessions = listJson(sessDir); 328 - const session = sessions.find((s: any) => s.name === "mover"); 328 + const session = sessions.find((s: any) => s.displayName === "mover"); 329 329 expect(session.tags.env).toBe("prod"); 330 330 }, 20000); 331 331 ··· 358 358 runCli(sessDir, "up", projDir); 359 359 360 360 const sessions = listJson(sessDir); 361 - const session = sessions.find((s: any) => s.name === "tagged"); 361 + const session = sessions.find((s: any) => s.displayName === "tagged"); 362 362 expect(session).toBeDefined(); 363 363 expect(session.tags.role).toBe("server"); 364 364 expect(session.tags.env).toBe("dev"); ··· 376 376 runCli(sessDir, "up", projDir); 377 377 378 378 const sessions = listJson(sessDir); 379 - const session = sessions.find((s: any) => s.name === "checkdir"); 379 + const session = sessions.find((s: any) => s.displayName === "checkdir"); 380 380 expect(session).toBeDefined(); 381 381 expect(session.cwd).toBe(projDir); 382 382 }, 15000); ··· 401 401 expect(result.stdout).toContain("myapp-worker (started)"); 402 402 403 403 const sessions = listJson(sessDir); 404 - const names = sessions.filter((s: any) => s.status === "running").map((s: any) => s.name); 404 + const names = sessions.filter((s: any) => s.status === "running").map((s: any) => s.displayName); 405 405 expect(names.sort()).toEqual(["myapp-web", "myapp-worker"]); 406 406 }, 15000); 407 407 ··· 427 427 const sessions = listJson(sessDir); 428 428 const running = sessions.filter((s: any) => s.status === "running"); 429 429 expect(running).toHaveLength(1); 430 - expect(running[0].name).toBe("myapp-web"); 430 + expect(running[0].displayName).toBe("myapp-web"); 431 431 }, 15000); 432 432 433 433 it("sets ptyfile tags on created sessions", () => { ··· 441 441 runCli(sessDir, "up", projDir); 442 442 443 443 const sessions = listJson(sessDir); 444 - const session = sessions.find((s: any) => s.name === "tracked"); 444 + const session = sessions.find((s: any) => s.displayName === "tracked"); 445 445 expect(session).toBeDefined(); 446 446 expect(session.tags.ptyfile).toBe(projDir + "/pty.toml"); 447 447 expect(session.tags["ptyfile.session"]).toBe("tracked"); ··· 540 540 const sessions = listJson(sessDir); 541 541 const running = sessions.filter((s: any) => s.status === "running"); 542 542 expect(running).toHaveLength(1); 543 - expect(running[0].name).toBe("keep"); 543 + expect(running[0].displayName).toBe("keep"); 544 544 }, 15000); 545 545 546 546 it("reports when nothing to stop", () => {
+180
tests/up-name-decouple.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-updc-")); 13 + afterAll(() => { 14 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 + }); 16 + 17 + const sessionDirs: string[] = []; 18 + 19 + function makeProjectDir(): string { 20 + const dir = fs.mkdtempSync(path.join(testRoot, "p-")); 21 + return dir; 22 + } 23 + 24 + function makeSessionDir(): string { 25 + const dir = fs.mkdtempSync(path.join(testRoot, "s-")); 26 + sessionDirs.push(dir); 27 + return dir; 28 + } 29 + 30 + function writePtyToml(dir: string, content: string): void { 31 + fs.writeFileSync(path.join(dir, "pty.toml"), content); 32 + } 33 + 34 + function runCli(sessionDir: string, ...args: string[]): { status: number | null; stdout: string; stderr: string } { 35 + const r = spawnSync(nodeBin, [cliPath, ...args], { 36 + cwd: os.tmpdir(), 37 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 38 + encoding: "utf-8", 39 + timeout: 15000, 40 + }); 41 + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; 42 + } 43 + 44 + function listJson(sessionDir: string): any[] { 45 + const r = runCli(sessionDir, "list", "--json"); 46 + if (!r.stdout.trim()) return []; 47 + return JSON.parse(r.stdout); 48 + } 49 + 50 + afterEach(() => { 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 pid = parseInt(fs.readFileSync(path.join(dir, e), "utf-8").trim(), 10); 58 + if (!isNaN(pid)) { try { process.kill(pid, "SIGTERM"); } catch {} } 59 + } 60 + } 61 + for (const e of entries) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 62 + } catch {} 63 + } 64 + sessionDirs.length = 0; 65 + }); 66 + 67 + describe("pty up: on-disk name decoupled from display label", () => { 68 + it("spawns sessions with a random short id; displayName carries the toml-derived label", () => { 69 + const projDir = makeProjectDir(); 70 + const sessDir = makeSessionDir(); 71 + writePtyToml(projDir, ` 72 + prefix = "myapp" 73 + 74 + [sessions.web] 75 + command = "cat" 76 + `); 77 + 78 + runCli(sessDir, "up", projDir); 79 + const sessions = listJson(sessDir); 80 + expect(sessions).toHaveLength(1); 81 + const s = sessions[0]; 82 + // On-disk name is a short random base32-ish id, NOT "myapp-web". 83 + expect(s.name).toMatch(/^[a-z0-9]{6,12}$/); 84 + expect(s.name).not.toBe("myapp-web"); 85 + // Display label is the prefix-shortName combo. 86 + expect(s.displayName).toBe("myapp-web"); 87 + expect(s.tags["ptyfile.session"]).toBe("web"); 88 + }, 15000); 89 + 90 + it("supports a long prefix that would have exceeded the sock path limit under the old model", () => { 91 + const projDir = makeProjectDir(); 92 + const sessDir = makeSessionDir(); 93 + // 90-char prefix + "-web" + ".sock" + session-dir would blow past 104. 94 + const longPrefix = "p".repeat(90); 95 + writePtyToml(projDir, ` 96 + prefix = "${longPrefix}" 97 + 98 + [sessions.web] 99 + command = "cat" 100 + `); 101 + 102 + const result = runCli(sessDir, "up", projDir); 103 + expect(result.status).toBe(0); 104 + const sessions = listJson(sessDir); 105 + expect(sessions).toHaveLength(1); 106 + expect(sessions[0].displayName).toBe(`${longPrefix}-web`); 107 + // On-disk name is short. 108 + expect(sessions[0].name.length).toBeLessThan(20); 109 + }, 15000); 110 + 111 + it("re-running pty up matches existing sessions by (ptyfile, ptyfile.session) tag pair, not by name", () => { 112 + const projDir = makeProjectDir(); 113 + const sessDir = makeSessionDir(); 114 + writePtyToml(projDir, ` 115 + [sessions.svc] 116 + command = "cat" 117 + `); 118 + 119 + runCli(sessDir, "up", projDir); 120 + const firstId = listJson(sessDir).find((s: any) => s.displayName === "svc")!.name; 121 + 122 + // Re-run pty up; should detect already-running and NOT spawn a second. 123 + const second = runCli(sessDir, "up", projDir); 124 + expect(second.stdout).toContain("svc (already running)"); 125 + const sessions = listJson(sessDir).filter((s: any) => s.displayName === "svc"); 126 + expect(sessions).toHaveLength(1); 127 + expect(sessions[0].name).toBe(firstId); 128 + }, 15000); 129 + 130 + it("honors pty.toml `id = \"...\"` to pin the on-disk identifier", () => { 131 + const projDir = makeProjectDir(); 132 + const sessDir = makeSessionDir(); 133 + writePtyToml(projDir, ` 134 + [sessions.svc] 135 + id = "pinned" 136 + command = "cat" 137 + `); 138 + 139 + runCli(sessDir, "up", projDir); 140 + const sessions = listJson(sessDir); 141 + expect(sessions).toHaveLength(1); 142 + expect(sessions[0].name).toBe("pinned"); 143 + expect(sessions[0].displayName).toBe("svc"); 144 + }, 15000); 145 + 146 + it("honors pty.toml `display_name = \"...\"` to override the default label", () => { 147 + const projDir = makeProjectDir(); 148 + const sessDir = makeSessionDir(); 149 + writePtyToml(projDir, ` 150 + prefix = "myapp" 151 + 152 + [sessions.web] 153 + display_name = "My Web Server" 154 + command = "cat" 155 + `); 156 + 157 + runCli(sessDir, "up", projDir); 158 + const sessions = listJson(sessDir); 159 + expect(sessions).toHaveLength(1); 160 + expect(sessions[0].displayName).toBe("My Web Server"); 161 + expect(sessions[0].name).toMatch(/^[a-z0-9]{6,12}$/); 162 + }, 15000); 163 + 164 + it("operations (kill, peek, send) resolve by displayName for toml-spawned sessions", () => { 165 + const projDir = makeProjectDir(); 166 + const sessDir = makeSessionDir(); 167 + writePtyToml(projDir, ` 168 + [sessions.svc] 169 + command = "cat" 170 + `); 171 + 172 + runCli(sessDir, "up", projDir); 173 + // Resolve by displayName 174 + const killResult = runCli(sessDir, "kill", "svc"); 175 + expect(killResult.status).toBe(0); 176 + // No running sessions left 177 + const sessions = listJson(sessDir).filter((s: any) => s.status === "running"); 178 + expect(sessions).toHaveLength(0); 179 + }, 15000); 180 + });