@edspencer/paddock 0.71.1 → 0.71.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +11 -3
  3. package/packages/server/dist/cli/args.js +20 -2
  4. package/packages/server/dist/cli/service/backend.d.ts +33 -0
  5. package/packages/server/dist/cli/service/index.d.ts +3 -3
  6. package/packages/server/dist/cli/service/index.js +124 -2
  7. package/packages/server/dist/cli/service/launchd.d.ts +27 -4
  8. package/packages/server/dist/cli/service/launchd.js +88 -14
  9. package/packages/server/dist/cli/service/systemd.d.ts +21 -2
  10. package/packages/server/dist/cli/service/systemd.js +58 -5
  11. package/packages/server/package.json +1 -1
  12. package/packages/web/dist/assets/{Chip-Db--PJFH.js → Chip-Cp32dDmz.js} +1 -1
  13. package/packages/web/dist/assets/DiscoverPage-Cs439wTf.js +2 -0
  14. package/packages/web/dist/assets/{DiscoverView-C4hOB3nr.js → DiscoverView-DoGAlUXT.js} +1 -1
  15. package/packages/web/dist/assets/{InstanceConfigPage-U78M95nW.js → InstanceConfigPage-BDypeo_r.js} +1 -1
  16. package/packages/web/dist/assets/{MarkdownRenderer-DdoNl0Ao.js → MarkdownRenderer-DA5CpdGI.js} +1 -1
  17. package/packages/web/dist/assets/{ProjectMenu-BxQu14ze.js → ProjectMenu-DXXXtXeh.js} +1 -1
  18. package/packages/web/dist/assets/{ProjectRedirect-QuGeN-qx.js → ProjectRedirect-k10uM_63.js} +1 -1
  19. package/packages/web/dist/assets/ProjectView-BxjcMlSO.js +13 -0
  20. package/packages/web/dist/assets/{ProjectsGrid-CUFNVTif.js → ProjectsGrid-DrzZ7R8M.js} +1 -1
  21. package/packages/web/dist/assets/{RootHome-SIxo-xX9.js → RootHome-rWodzOC8.js} +1 -1
  22. package/packages/web/dist/assets/{arc-C0DRwCCT.js → arc-BKAVFR26.js} +1 -1
  23. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Wwpf4cvW.js → architectureDiagram-3BPJPVTR-UzIlxaCQ.js} +1 -1
  24. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DKmCbgWk.js → blockDiagram-GPEHLZMM-Eu4BV7Ea.js} +1 -1
  25. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CJ8LiLsj.js → c4Diagram-AAUBKEIU-DR8PZaoa.js} +1 -1
  26. package/packages/web/dist/assets/channel-C-sodtn7.js +2 -0
  27. package/packages/web/dist/assets/{chunk-2J33WTMH-C3P-O4C3.js → chunk-2J33WTMH-CgvIIrI1.js} +1 -1
  28. package/packages/web/dist/assets/{chunk-4BX2VUAB-Cs9u7x61.js → chunk-4BX2VUAB-CyJWrZqN.js} +1 -1
  29. package/packages/web/dist/assets/{chunk-55IACEB6-C1st284U.js → chunk-55IACEB6-DXEnZN1d.js} +1 -1
  30. package/packages/web/dist/assets/{chunk-727SXJPM-BjCAWo7B.js → chunk-727SXJPM-Co89QTmp.js} +1 -1
  31. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BP6S9w2Q.js → chunk-AQP2D5EJ-BRxTzfev.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-FMBD7UC4-C3ySTaPs.js → chunk-FMBD7UC4-DUj18CvF.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-ND2GUHAM-eNPD4vzc.js → chunk-ND2GUHAM-BQMMJobN.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-QZHKN3VN-BbBpgHkO.js → chunk-QZHKN3VN-COx8LZCe.js} +1 -1
  35. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-D2eBPVdq.js +2 -0
  36. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-D2eBPVdq.js +2 -0
  37. package/packages/web/dist/assets/{core-ZwDDec0y.js → core-DnpcT0s6.js} +1 -1
  38. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DiM4eeF5.js → cose-bilkent-S5V4N54A-B-4NadKI.js} +1 -1
  39. package/packages/web/dist/assets/{dagre-BM42HDAG-DKIEUM4M.js → dagre-BM42HDAG-CuaYJexd.js} +1 -1
  40. package/packages/web/dist/assets/{diagram-2AECGRRQ-CqlX7j8t.js → diagram-2AECGRRQ-C9_anxrA.js} +1 -1
  41. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dph5apqo.js → diagram-5GNKFQAL-Ckf3kFlY.js} +1 -1
  42. package/packages/web/dist/assets/{diagram-KO2AKTUF-TW6LyHGt.js → diagram-KO2AKTUF-BrpUuY5x.js} +1 -1
  43. package/packages/web/dist/assets/{diagram-LMA3HP47-BxxIIv5a.js → diagram-LMA3HP47-B9lyXZXW.js} +1 -1
  44. package/packages/web/dist/assets/{diagram-OG6HWLK6-B8M0DeV7.js → diagram-OG6HWLK6-DT7ro32m.js} +1 -1
  45. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Bfpy6UQa.js → erDiagram-TEJ5UH35-ZZn9bHQh.js} +1 -1
  46. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DstRzxek.js → flowDiagram-I6XJVG4X-BxQ82PY_.js} +1 -1
  47. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bg0oFbAl.js → ganttDiagram-6RSMTGT7-BxliloOH.js} +1 -1
  48. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CX4IozE-.js → gitGraphDiagram-PVQCEYII-BlwODdH9.js} +1 -1
  49. package/packages/web/dist/assets/{index-ASW1Jl0Y.js → index-Cx9y6c3f.js} +2 -2
  50. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CG2qCk6u.js → infoDiagram-5YYISTIA-nanSBFyG.js} +1 -1
  51. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CPErvl3a.js → ishikawaDiagram-YF4QCWOH-yZHr_s28.js} +1 -1
  52. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bm6krUsf.js → journeyDiagram-JHISSGLW-BKjDqQEQ.js} +1 -1
  53. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B4CC8CAx.js → kanban-definition-UN3LZRKU-QusvHqc6.js} +1 -1
  54. package/packages/web/dist/assets/{linear-CJ1DbrOX.js → linear-4xRoD38Z.js} +1 -1
  55. package/packages/web/dist/assets/{mermaid.core-Dk2kkhf9.js → mermaid.core-CeulYvDv.js} +4 -4
  56. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CThvf_YD.js → mindmap-definition-RKZ34NQL-DSjYt2Kp.js} +1 -1
  57. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-DvkHcng6.js → pieDiagram-4H26LBE5-Ci0gO3CA.js} +1 -1
  58. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-CqhrTh9q.js → quadrantDiagram-W4KKPZXB-y2qHkaHP.js} +1 -1
  59. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZykiLym.js → requirementDiagram-4Y6WPE33-D9hiWbad.js} +1 -1
  60. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D-TzCKMx.js → sankeyDiagram-5OEKKPKP-DA3Mvoze.js} +1 -1
  61. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C5pIItVu.js → sequenceDiagram-3UESZ5HK-DWACrljw.js} +1 -1
  62. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DE6G2JfB.js → stateDiagram-AJRCARHV-CFGEhoTb.js} +1 -1
  63. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CLfbDXLX.js +2 -0
  64. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-C1VK2OfU.js → timeline-definition-PNZ67QCA-Cjygpd9e.js} +1 -1
  65. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CtOozEwa.js → vennDiagram-CIIHVFJN-zQnLaxgv.js} +1 -1
  66. package/packages/web/dist/assets/{wardley-L42UT6IY-DvHGK-lb.js → wardley-L42UT6IY-DGmt-Lo2.js} +1 -1
  67. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DxKwZ3-G.js → wardleyDiagram-YWT4CUSO-Dj3vDukN.js} +1 -1
  68. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-HM4j3l6y.js → xychartDiagram-2RQKCTM6-C0bGbwbL.js} +1 -1
  69. package/packages/web/dist/index.html +1 -1
  70. package/packages/web/dist/sw.js +1 -1
  71. package/packages/web/dist/assets/DiscoverPage-BFo4FTzZ.js +0 -2
  72. package/packages/web/dist/assets/ProjectView-FUcyJMUf.js +0 -13
  73. package/packages/web/dist/assets/channel-DEmtNKeB.js +0 -2
  74. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-dAGAuccL.js +0 -2
  75. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-dAGAuccL.js +0 -2
  76. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BP6oowh1.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edspencer/paddock",
3
- "version": "0.71.1",
3
+ "version": "0.71.2",
4
4
  "description": "Project-first launchpad for Claude Code: server-hosted, persistent, resumable sessions organized by project.",
5
5
  "keywords": [
6
6
  "claude",
@@ -36,8 +36,16 @@ export interface CliOptions {
36
36
  export declare class CliError extends Error {
37
37
  }
38
38
  export declare const MIN_NODE_MAJOR = 22;
39
- /** What `paddock service` can be asked to do. */
40
- export declare const SERVICE_ACTIONS: readonly ["install", "uninstall", "status"];
39
+ /**
40
+ * What `paddock service` can be asked to do.
41
+ *
42
+ * `start`/`stop`/`restart` join the original three in #873. Note that `start`
43
+ * is also a top-level VERB — `paddock start` runs a server in this terminal,
44
+ * `paddock service start` asks the supervisor to run one. The overlap is safe
45
+ * because `parseCommand` dispatches on `argv[0]` alone and only then reads an
46
+ * action, so the two never compete for the same token.
47
+ */
48
+ export declare const SERVICE_ACTIONS: readonly ["install", "uninstall", "status", "start", "stop", "restart"];
41
49
  export type ServiceAction = (typeof SERVICE_ACTIONS)[number];
42
50
  /** The leading words {@link parseCommand} recognises. Anything else is a flag. */
43
51
  export declare const VERBS: readonly ["start", "service"];
@@ -100,4 +108,4 @@ export declare function nodeVersionProblem(nodeVersion: string): string | undefi
100
108
  */
101
109
  export declare function explainListenError(err: unknown, host: string, port: string): string;
102
110
  export declare const USAGE = "paddock \u2014 run a Paddock instance locally\n\nUsage\n npx @edspencer/paddock [options] start the server (the default)\n paddock start [options] the same thing, said out loud\n paddock service <install|uninstall|status>\n run it in the background from login\n (`paddock service --help`)\n\nOptions\n -p, --port <port> HTTP/WS port (default 7233, or $PORT)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Projects + state (default ~/.paddock, or $PADDOCK_DATA_DIR)\n -o, --open Open the app in your browser once it is listening\n --verbose Show the server's own logs (quiet by default)\n -v, --version Print the Paddock version and exit\n -h, --help Show this help\n\nOpening your own directories\n Where you run this from does not matter \u2014 the instance is decided by its data\n dir alone. Directories are added inside the app, on the Discover screen: it\n reads your Claude Code history, lists the directories you have worked in, and\n links the ones you tick as projects. A new instance opens on it.\n\n Importing a directory does not write into it. No .paddock/, no .chats/, no\n .gitignore edits, no CLAUDE.md \u2014 the project record and the copied transcripts\n both live in the data dir, and the project just points at the path. Your own\n ~/.claude transcripts are copied, never moved or deleted, so your terminal\n `claude` keeps working exactly as before.\n\nCredentials\n Paddock drives Claude Code, so it needs Claude credentials \u2014 and if you\n already use Claude Code on this machine, it uses the login you already have.\n That is a macOS Keychain entry on a Mac, or your ~/.claude/.credentials.json\n elsewhere (symlinked in, never copied). Reading a login writes nothing.\n\n Otherwise: a CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment,\n or a one-off `CLAUDE_CONFIG_DIR=<data-dir>/claude-home claude login`. With no\n login at all anywhere, run `claude setup-token`. Paddock says at startup when\n it can find none.\n\nSharing your Claude Code state\n Apart from that login, Paddock writes nothing outside its data dir by\n default: transcripts go to each project's .chats/, and your ~/.claude is read\n for config only. Each thing it can share is one key in\n <data-dir>/paddock.config.yaml:\n\n claude:\n transcripts: host # own | host, default own\n credentials: host # own | host, default host\n instructions: host # own | host, default own\n hooks: host # own | host, default own\n mcpServers: host # own | host, default own\n\n transcripts: host makes a chat and a `claude --resume` in the same directory\n the same file, live in both directions; deleting such a chat in Paddock\n releases it rather than removing it, because it is your history rather than\n Paddock's copy. credentials: own is the opt-out from sharing the login above.\n\n instructions: host loads your ~/.claude CLAUDE.md, agents/, commands/ and\n plugins/. Off by default, which is a change: your curated CLAUDE.md does not\n reach Paddock's agents unless you say so. Each project's own CLAUDE.md always\n does.\n\n hooks: host runs the shell commands your ~/.claude/settings.json binds to\n tool use. Off by default \u2014 inheriting someone's hooks is not something to\n discover after the fact. Its other keys (permissions, model, statusline)\n apply either way: under hooks: own Paddock writes its own settings.json\n carrying them with hooks dropped, regenerated at startup.\n\n mcpServers: host attaches the MCP servers declared in your ~/.claude.json \u2014\n the top-level ones, plus a project's own when its directory matches. To give\n this instance a server your machine doesn't have, declare it instead in a\n sibling mcpServers: block of the same file; use env:VAR_NAME anywhere a\n string goes so tokens stay out of it. That keeps a token out of the file, not\n out of `ps`: leave driveMode on its default (session) for a server holding\n one, since batch passes the definition to claude as a command-line argument.\n\nYour data\n Everything lives in one directory \u2014 ~/.paddock unless you pass --data-dir.\n Projects, chat transcripts and settings all persist there between runs. Move\n that directory to move your instance; delete it to start over. Nothing is\n stored anywhere else.\n\nNotes\n Binds loopback with authentication disabled, which is safe for a laptop. To\n expose it on a network, set PADDOCK_AUTH_MODE first \u2014 Paddock refuses to bind\n a routable interface wide open. See AUTH.md.\n\nDocs: https://github.com/edspencer/paddock";
103
- export declare const SERVICE_USAGE = "paddock service \u2014 keep Paddock running in the background\n\nUsage\n paddock service install [options] register it and start it now\n paddock service uninstall stop it and deregister it\n paddock service status is it registered, is it running, where are the logs\n\nOptions (install only \u2014 recorded in the generated unit)\n -p, --port <port> HTTP/WS port (default 7233)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Only if you want an instance SEPARATE from your\n terminal one. Left out of the unit when you omit it\n AND `PADDOCK_DATA_DIR` is unset in this shell, so\n `paddock service` and a bare `paddock` are the same\n ~/.paddock instance reached two ways. If\n `PADDOCK_DATA_DIR` IS set here, that path is recorded\n in the unit \u2014 the service would otherwise point\n somewhere your terminal does not.\n --verbose Record the server's own logs, not just warnings\n\nAt login, not at boot\n This registers a per-USER service \u2014 a launchd LaunchAgent on macOS, a\n `systemd --user` unit on Linux \u2014 so it runs as you, with your own Claude\n login. That is not incidental: on macOS your Claude login is a Keychain item\n that only a logged-in user session can read. A boot-time system daemon has no\n such session and could not use it.\n\n So Paddock starts when you LOG IN, not when the machine boots. After a\n restart that nobody logs into, Paddock is not running. That is the design, not\n a fault.\n\n On Linux, a user service is also stopped when you log out. To keep it up:\n\n loginctl enable-linger $USER\n\nWhere it lives\n macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist\n logs in <data-dir>/service/\n Linux ~/.config/systemd/user/paddock.service\n logs via journalctl --user -u paddock.service -f\n\nInstalled from npx?\n `service install` refuses. An npx cache path is hash-keyed and npm may prune\n it, so the unit would work until it silently didn't, at some future login.\n Install properly first:\n\n npm i -g @edspencer/paddock && paddock service install\n\nA note on access\n Paddock binds loopback with authentication off, which is right for a laptop.\n A service is up for as long as you are logged in rather than as long as a\n terminal tab, so that window is longer \u2014 but it is not wider: any local\n process that could reach the port could already read the same Claude login as\n you. Set PADDOCK_AUTH_MODE if you want a credential on it anyway.\n\nDocs: https://github.com/edspencer/paddock";
111
+ export declare const SERVICE_USAGE = "paddock service \u2014 keep Paddock running in the background\n\nUsage\n paddock service install [options] register it and start it now\n paddock service uninstall stop it and deregister it\n paddock service status is it registered, is it running, where are the logs\n paddock service start start an installed service that is stopped\n paddock service stop stop it, and leave it installed\n paddock service restart stop it and start it again, re-reading config\n\nOptions (install only \u2014 recorded in the generated unit)\n -p, --port <port> HTTP/WS port (default 7233)\n --host <host> Bind address (default 127.0.0.1)\n -d, --data-dir <path> Only if you want an instance SEPARATE from your\n terminal one. Left out of the unit when you omit it\n AND `PADDOCK_DATA_DIR` is unset in this shell, so\n `paddock service` and a bare `paddock` are the same\n ~/.paddock instance reached two ways. If\n `PADDOCK_DATA_DIR` IS set here, that path is recorded\n in the unit \u2014 the service would otherwise point\n somewhere your terminal does not.\n --verbose Record the server's own logs, not just warnings\n\nAt login, not at boot\n This registers a per-USER service \u2014 a launchd LaunchAgent on macOS, a\n `systemd --user` unit on Linux \u2014 so it runs as you, with your own Claude\n login. That is not incidental: on macOS your Claude login is a Keychain item\n that only a logged-in user session can read. A boot-time system daemon has no\n such session and could not use it.\n\n So Paddock starts when you LOG IN, not when the machine boots. After a\n restart that nobody logs into, Paddock is not running. That is the design, not\n a fault.\n\n On Linux, a user service is also stopped when you log out. To keep it up:\n\n loginctl enable-linger $USER\n\nWhere it lives\n macOS ~/Library/LaunchAgents/net.edspencer.paddock.plist\n logs in <data-dir>/service/\n Linux ~/.config/systemd/user/paddock.service\n logs via journalctl --user -u paddock.service -f\n\nInstalled from npx?\n `service install` refuses. An npx cache path is hash-keyed and npm may prune\n it, so the unit would work until it silently didn't, at some future login.\n Install properly first:\n\n npm i -g @edspencer/paddock && paddock service install\n\nA note on access\n Paddock binds loopback with authentication off, which is right for a laptop.\n A service is up for as long as you are logged in rather than as long as a\n terminal tab, so that window is longer \u2014 but it is not wider: any local\n process that could reach the port could already read the same Claude login as\n you. Set PADDOCK_AUTH_MODE if you want a credential on it anyway.\n\nDocs: https://github.com/edspencer/paddock";
@@ -27,8 +27,23 @@
27
27
  export class CliError extends Error {
28
28
  }
29
29
  export const MIN_NODE_MAJOR = 22;
30
- /** What `paddock service` can be asked to do. */
31
- export const SERVICE_ACTIONS = ["install", "uninstall", "status"];
30
+ /**
31
+ * What `paddock service` can be asked to do.
32
+ *
33
+ * `start`/`stop`/`restart` join the original three in #873. Note that `start`
34
+ * is also a top-level VERB — `paddock start` runs a server in this terminal,
35
+ * `paddock service start` asks the supervisor to run one. The overlap is safe
36
+ * because `parseCommand` dispatches on `argv[0]` alone and only then reads an
37
+ * action, so the two never compete for the same token.
38
+ */
39
+ export const SERVICE_ACTIONS = [
40
+ "install",
41
+ "uninstall",
42
+ "status",
43
+ "start",
44
+ "stop",
45
+ "restart",
46
+ ];
32
47
  /** The leading words {@link parseCommand} recognises. Anything else is a flag. */
33
48
  export const VERBS = ["start", "service"];
34
49
  function isServiceAction(token) {
@@ -278,6 +293,9 @@ Usage
278
293
  paddock service install [options] register it and start it now
279
294
  paddock service uninstall stop it and deregister it
280
295
  paddock service status is it registered, is it running, where are the logs
296
+ paddock service start start an installed service that is stopped
297
+ paddock service stop stop it, and leave it installed
298
+ paddock service restart stop it and start it again, re-reading config
281
299
 
282
300
  Options (install only — recorded in the generated unit)
283
301
  -p, --port <port> HTTP/WS port (default 7233)
@@ -20,6 +20,21 @@ export interface ServiceState {
20
20
  pid?: string;
21
21
  /** The Paddock arguments recorded in the installed unit, interpreter stripped. */
22
22
  argv: string[];
23
+ /**
24
+ * Does the unit ON DISK come back after a graceful stop (#872)?
25
+ *
26
+ * Read from the installed file rather than from what we would write today,
27
+ * because those differ for exactly the people who need to hear about it.
28
+ * Upgrading the package does not rewrite a unit, so anyone who installed
29
+ * before #872 still has `SuccessfulExit: false` / `Restart=on-failure` — the
30
+ * shape where one sleep or logout stops Paddock for good. They cannot see
31
+ * that from the outside: the service simply is not running one morning.
32
+ * `status` is where they look, so `status` is where it has to say so.
33
+ *
34
+ * Meaningless when `registered` is false, and `true` there — "nothing to warn
35
+ * about" — so no caller has to special-case a unit that does not exist.
36
+ */
37
+ keepsAlive: boolean;
23
38
  }
24
39
  export interface ServiceBackend {
25
40
  platform: "darwin" | "linux";
@@ -31,6 +46,24 @@ export interface ServiceBackend {
31
46
  /** True if something was actually there to remove. */
32
47
  uninstall(): boolean;
33
48
  status(): ServiceState;
49
+ /**
50
+ * Start / stop / restart an already-installed unit (#873).
51
+ *
52
+ * These exist so an intended stop has a channel of its OWN. Before them the
53
+ * only stop was `uninstall`, which also deregisters, so "bounce it after
54
+ * editing config" meant either losing the unit or dropping to raw
55
+ * `launchctl kickstart -k gui/$(id -u)/net.edspencer.paddock` — undiscoverable,
56
+ * and different on each platform. The exit-code heuristic that #872 removed
57
+ * was carrying the same meaning by inference; a command carries it directly.
58
+ *
59
+ * Each throws with the supervisor's own stderr rather than returning a
60
+ * boolean: the interesting failures here (a unit that was booted out, a
61
+ * manager that is not running) have text worth showing, and swallowing it
62
+ * would leave `start` printing a URL for a server that never came up.
63
+ */
64
+ start(): void;
65
+ stop(): void;
66
+ restart(): void;
34
67
  /** How to read the logs, as printed lines. */
35
68
  logsHint(spec: ServiceSpec): string;
36
69
  /** The `enable-linger` warning, or undefined where it does not apply. */
@@ -21,9 +21,9 @@ export interface ServiceContext {
21
21
  /**
22
22
  * "Did the URL answer?", injected for the same reason `run` is.
23
23
  *
24
- * Without a seam here every install test would really poll 127.0.0.1 for the
25
- * full timeout and then assert on the disappointed message — twenty seconds
26
- * per case, and green for the wrong reason.
24
+ * Without a seam here every `install` / `start` / `restart` test would really
25
+ * poll 127.0.0.1 for the full timeout and then assert on the disappointed
26
+ * message — twenty seconds per case, and green for the wrong reason.
27
27
  */
28
28
  checkReady?: (url: string) => Promise<Readiness>;
29
29
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `paddock service install | uninstall | status` (#796).
2
+ * `paddock service install | uninstall | status | start | stop | restart`
3
+ * (#796; the lifecycle three added in #873).
3
4
  *
4
5
  * Registers Paddock as a **per-user** background service — a launchd
5
6
  * LaunchAgent on macOS, a `systemd --user` unit on Linux — so it is up from
@@ -26,7 +27,7 @@ import os from "node:os";
26
27
  import path from "node:path";
27
28
  import { CliError } from "../args.js";
28
29
  import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
- import { spawnRunner } from "./backend.js";
30
+ import { spawnRunner, } from "./backend.js";
30
31
  import { createLaunchdBackend } from "./launchd.js";
31
32
  import { createSystemdBackend } from "./systemd.js";
32
33
  import { waitForReady, READY_TIMEOUT_LABEL } from "./ready.js";
@@ -77,6 +78,25 @@ function specFor(opts, ctx) {
77
78
  const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" +
78
79
  " you, so it can use the Claude login you already have. After a restart\n" +
79
80
  " that nobody logs into, Paddock is not running.";
81
+ /**
82
+ * What `status` says about a unit installed before #872.
83
+ *
84
+ * Upgrading the package leaves the unit file alone, so the people still holding
85
+ * the broken shape are exactly the people who will never notice — the symptom
86
+ * is Paddock being absent one morning, with nothing in the logs, because it was
87
+ * signalled rather than crashed. Naming the fix here is the only path from that
88
+ * symptom back to a working service.
89
+ */
90
+ function staleUnitNote(backend) {
91
+ const key = backend.platform === "darwin"
92
+ ? "KeepAlive is conditional on a non-zero exit"
93
+ : "Restart=on-failure";
94
+ return (` ⚠ This unit predates a fix (#872): ${key}, so a graceful stop —\n` +
95
+ " the kind sleep, logout or a stray `kill` sends — puts Paddock down\n" +
96
+ " until you start it again by hand. Re-run to update the unit:\n" +
97
+ "\n" +
98
+ " paddock service install");
99
+ }
80
100
  /**
81
101
  * Say what is about to happen, before the part that looks like a hang (#861).
82
102
  *
@@ -135,6 +155,7 @@ async function install(opts, ctx, backend) {
135
155
  ...(linger !== undefined ? ["", linger] : []),
136
156
  "",
137
157
  " paddock service status is it up, and where",
158
+ " paddock service restart bounce it after editing config",
138
159
  " paddock service uninstall stop it and remove the unit",
139
160
  "",
140
161
  ].join("\n"));
@@ -194,16 +215,117 @@ function status(opts, ctx, backend) {
194
215
  backend.logsHint(spec),
195
216
  "",
196
217
  AT_LOGIN,
218
+ ...(state.keepsAlive ? [] : ["", staleUnitNote(backend)]),
197
219
  ...(linger !== undefined && !state.running ? ["", linger] : []),
198
220
  "",
199
221
  ].join("\n"));
200
222
  }
223
+ /**
224
+ * The URL an installed unit is actually serving, or undefined if none is.
225
+ *
226
+ * Read out of the unit rather than recomputed from today's flags, for the same
227
+ * reason `status` does it: the port in the file is the port the server is on.
228
+ */
229
+ function installedUrl(state) {
230
+ return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
231
+ }
232
+ /** "It isn't installed" — the same answer, wherever it is asked. */
233
+ function notInstalled(backend) {
234
+ return [
235
+ "",
236
+ " Paddock is not installed as a service.",
237
+ ` Looked for: ${backend.unitPath}`,
238
+ "",
239
+ " paddock service install register it and start it now",
240
+ "",
241
+ ].join("\n");
242
+ }
243
+ /**
244
+ * `start` / `restart` (#873), which differ only in what they tolerate.
245
+ *
246
+ * `start` on something already running is reported and not acted on — asking
247
+ * launchd to bootstrap a loaded job is an error, and "it is already up" is the
248
+ * outcome the user wanted anyway. `restart` has no such case: bouncing a
249
+ * stopped service is a legitimate way to start it, and the sequence each
250
+ * backend uses handles both states.
251
+ *
252
+ * Both then WAIT. Neither supervisor's "started" means the port answers — it
253
+ * means the fork succeeded — so printing a URL on the strength of it produces a
254
+ * success message that can sit over a crash-loop (a port clash restarts every
255
+ * `ThrottleInterval`, and `launchctl print` says `running` for most of that
256
+ * window). The wait is what makes the last line true.
257
+ */
258
+ async function startOrRestart(action, ctx, backend) {
259
+ const before = backend.status();
260
+ if (!before.registered) {
261
+ console.log(notInstalled(backend));
262
+ return;
263
+ }
264
+ const url = installedUrl(before);
265
+ if (action === "start" && before.running) {
266
+ console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
267
+ return;
268
+ }
269
+ console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
270
+ if (action === "start")
271
+ backend.start();
272
+ else
273
+ backend.restart();
274
+ const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
275
+ const after = backend.status();
276
+ console.log([
277
+ "",
278
+ readiness === "ready"
279
+ ? ` ✓ Paddock is running${pid(after)} at ${url}`
280
+ : ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
281
+ ` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
282
+ ...(readiness === "ready" ? [] : ["", " paddock service status", backend.logsHint(specForLogs(ctx, after))]),
283
+ "",
284
+ ].join("\n"));
285
+ }
286
+ /** `stop` (#873): put it down, leave it installed. */
287
+ function stop(backend) {
288
+ const state = backend.status();
289
+ if (!state.registered) {
290
+ console.log(notInstalled(backend));
291
+ return;
292
+ }
293
+ if (!state.running) {
294
+ console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
295
+ return;
296
+ }
297
+ backend.stop();
298
+ console.log([
299
+ "",
300
+ " Paddock is stopped. The unit is still installed.",
301
+ ` Unit: ${backend.unitPath}`,
302
+ "",
303
+ " It comes back when you log in again, or now with:",
304
+ "",
305
+ " paddock service start",
306
+ "",
307
+ ].join("\n"));
308
+ }
309
+ function pid(state) {
310
+ return state.pid !== undefined ? ` (pid ${state.pid})` : "";
311
+ }
312
+ /** The spec `logsHint` needs, built from the INSTALLED unit's data dir. */
313
+ function specForLogs(ctx, state) {
314
+ const i = state.argv.indexOf("--data-dir");
315
+ const dataDir = i >= 0 && state.argv[i + 1] !== undefined ? state.argv[i + 1] : undefined;
316
+ const { spec } = specFor({ open: false, verbose: false, help: false, version: false }, { ...ctx, ...(dataDir !== undefined ? { envDataDir: dataDir } : {}) });
317
+ return spec;
318
+ }
201
319
  export async function runService(action, opts, ctx) {
202
320
  const backend = backendFor(ctx);
203
321
  if (action === "install")
204
322
  return install(opts, ctx, backend);
205
323
  if (action === "uninstall")
206
324
  return uninstall(ctx, backend);
325
+ if (action === "start" || action === "restart")
326
+ return startOrRestart(action, ctx, backend);
327
+ if (action === "stop")
328
+ return stop(backend);
207
329
  return status(opts, ctx, backend);
208
330
  }
209
331
  /** Exported for the entrypoint's context assembly, and for tests. */
@@ -5,10 +5,22 @@ export declare function plistPath(homeDir?: string): string;
5
5
  /**
6
6
  * Render the plist.
7
7
  *
8
- * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
9
- * false` means "restart it if it exits nonzero, leave it alone if it exits
10
- * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
11
- * make stopping Paddock a wrestling match.
8
+ * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
9
+ * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
10
+ * nonzero, leave it alone if it exits cleanly" — which reads like the polite
11
+ * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
12
+ * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
13
+ * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
14
+ * to stop", and Paddock stayed down until the next login. A hard crash was the
15
+ * survivable case; the graceful stop was the terminal one.
16
+ *
17
+ * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
18
+ * does not hold for the stop this CLI actually performs. `launchctl bootout`
19
+ * unloads the job from the domain, and KeepAlive only applies to a job that is
20
+ * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
21
+ * does fight is `launchctl stop`, which is why nothing here uses it.
22
+ *
23
+ * Intent belongs in an explicit command, not in an exit code.
12
24
  *
13
25
  * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
14
26
  * launchd hands a process a stub PATH that a version manager's `node`, and the
@@ -32,6 +44,17 @@ export declare function renderPlist(spec: ServiceSpec): string;
32
44
  * would be the larger risk.
33
45
  */
34
46
  export declare function parsePlistArgv(xml: string): string[];
47
+ /**
48
+ * Does an installed plist carry the unconditional `KeepAlive` from #872?
49
+ *
50
+ * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
51
+ * and nothing else. The shape it must NOT accept is the old
52
+ * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
53
+ * leaves a service dead after a graceful stop — so a `false` here is the signal
54
+ * `status` turns into "re-run install", and defaulting to `true` on anything
55
+ * unrecognised would silence exactly the case this exists to catch.
56
+ */
57
+ export declare function plistKeepsAlive(xml: string): boolean;
35
58
  /**
36
59
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
37
60
  *
@@ -51,10 +51,22 @@ function xmlEscape(s) {
51
51
  /**
52
52
  * Render the plist.
53
53
  *
54
- * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
55
- * false` means "restart it if it exits nonzero, leave it alone if it exits
56
- * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
57
- * make stopping Paddock a wrestling match.
54
+ * `KeepAlive` is unconditional `<true/>`, and that is a deliberate reversal
55
+ * (#872). It used to be `{ SuccessfulExit: false }` — "restart it if it exits
56
+ * nonzero, leave it alone if it exits cleanly" — which reads like the polite
57
+ * choice and is, for a service, a trap. `start.ts` handles SIGTERM by shutting
58
+ * down cleanly and exiting `0`, so **every** SIGTERM the OS routinely sends a
59
+ * LaunchAgent (sleep, logout, a stray `kill`) looked to launchd like "it meant
60
+ * to stop", and Paddock stayed down until the next login. A hard crash was the
61
+ * survivable case; the graceful stop was the terminal one.
62
+ *
63
+ * The worry that motivated the dict — that `<true/>` fights a deliberate stop —
64
+ * does not hold for the stop this CLI actually performs. `launchctl bootout`
65
+ * unloads the job from the domain, and KeepAlive only applies to a job that is
66
+ * still loaded, so `uninstall` still stops Paddock in one call. What `<true/>`
67
+ * does fight is `launchctl stop`, which is why nothing here uses it.
68
+ *
69
+ * Intent belongs in an explicit command, not in an exit code.
58
70
  *
59
71
  * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
60
72
  * launchd hands a process a stub PATH that a version manager's `node`, and the
@@ -82,10 +94,7 @@ ${strings}
82
94
  <key>RunAtLoad</key>
83
95
  <true/>
84
96
  <key>KeepAlive</key>
85
- <dict>
86
- <key>SuccessfulExit</key>
87
- <false/>
88
- </dict>
97
+ <true/>
89
98
  <key>ThrottleInterval</key>
90
99
  <integer>10</integer>
91
100
  <key>WorkingDirectory</key>
@@ -123,6 +132,19 @@ export function parsePlistArgv(xml) {
123
132
  .replace(/&lt;/g, "<")
124
133
  .replace(/&amp;/g, "&"));
125
134
  }
135
+ /**
136
+ * Does an installed plist carry the unconditional `KeepAlive` from #872?
137
+ *
138
+ * Deliberately narrow: it matches `<key>KeepAlive</key>` followed by `<true/>`
139
+ * and nothing else. The shape it must NOT accept is the old
140
+ * `<dict><key>SuccessfulExit</key><false/></dict>`, which is the one that
141
+ * leaves a service dead after a graceful stop — so a `false` here is the signal
142
+ * `status` turns into "re-run install", and defaulting to `true` on anything
143
+ * unrecognised would silence exactly the case this exists to catch.
144
+ */
145
+ export function plistKeepsAlive(xml) {
146
+ return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
147
+ }
126
148
  /**
127
149
  * Read liveness out of `launchctl print gui/<uid>/<label>`.
128
150
  *
@@ -139,6 +161,10 @@ export function parseLaunchctlPrint(out) {
139
161
  ...(pid !== null ? { pid: pid[1] } : {}),
140
162
  };
141
163
  }
164
+ /** One phrasing for "launchctl said no", so every caller reports it the same way. */
165
+ function launchctlFailure(verb, r) {
166
+ return (`launchctl ${verb} failed (exit ${String(r.status)}).\n` + `${r.stderr || r.stdout}`.trim());
167
+ }
142
168
  function target() {
143
169
  return `gui/${process.getuid?.() ?? 0}/${LAUNCHD_LABEL}`;
144
170
  }
@@ -164,10 +190,8 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
164
190
  // they are shims over this one that report success in cases where it
165
191
  // reports the actual error.
166
192
  const boot = run("launchctl", ["bootstrap", domain(), file]);
167
- if (boot.status !== 0) {
168
- throw new Error(`launchctl bootstrap failed (exit ${String(boot.status)}).\n` +
169
- `${boot.stderr || boot.stdout}`.trim());
170
- }
193
+ if (boot.status !== 0)
194
+ throw new Error(launchctlFailure("bootstrap", boot));
171
195
  // `RunAtLoad` should already have started it; `kickstart -k` makes that
172
196
  // true rather than probable, and is the same command a later restart uses.
173
197
  run("launchctl", ["kickstart", "-k", target()]);
@@ -179,10 +203,59 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
179
203
  fs.rmSync(file, { force: true });
180
204
  return existed;
181
205
  },
206
+ /**
207
+ * Load the job back into the GUI domain and make sure it is running (#873).
208
+ *
209
+ * `bootstrap`, not `launchctl start`: `stop` below BOOTS OUT, so a stopped
210
+ * Paddock is not merely idle, it is absent from the domain — and `start` on
211
+ * a label launchd has never heard of fails. Bootstrapping is also what a
212
+ * login does, so `service start` and the next login take the same path.
213
+ */
214
+ start() {
215
+ const boot = run("launchctl", ["bootstrap", domain(), file]);
216
+ if (boot.status !== 0)
217
+ throw new Error(launchctlFailure("bootstrap", boot));
218
+ run("launchctl", ["kickstart", target()]);
219
+ },
220
+ /**
221
+ * `bootout`, not `launchctl stop`.
222
+ *
223
+ * With #872's unconditional `KeepAlive`, `stop` is a request launchd
224
+ * immediately overrules — it stops the process and then relaunches it,
225
+ * which is exactly the wrestling match the old conditional KeepAlive was
226
+ * meant to avoid. `bootout` unloads the job, so KeepAlive no longer applies
227
+ * and it stays down. The plist is untouched, so `status` still reports it
228
+ * registered, `start` puts it back, and the next LOGIN starts it again via
229
+ * `RunAtLoad` — a stop is for this session, not forever. Uninstall is the
230
+ * one that is forever.
231
+ */
232
+ stop() {
233
+ const out = run("launchctl", ["bootout", target()]);
234
+ // Exit 3 / "No such process" is launchd for "it was not loaded", which is
235
+ // the state the caller asked for. Anything else is worth surfacing.
236
+ if (out.status !== 0 && !/no such process/i.test(`${out.stderr}${out.stdout}`)) {
237
+ throw new Error(launchctlFailure("bootout", out));
238
+ }
239
+ },
240
+ /**
241
+ * Boot out, bootstrap, kick. Not `kickstart -k` alone, which needs the job
242
+ * to be loaded and so fails on the case `restart` most needs to handle: a
243
+ * service that is stopped. This sequence is identical to `install`'s minus
244
+ * writing the plist, which also gives `restart` the property the issue asks
245
+ * for — the unit is re-read on the way up, so an edited plist takes effect.
246
+ */
247
+ restart() {
248
+ run("launchctl", ["bootout", target()]);
249
+ const boot = run("launchctl", ["bootstrap", domain(), file]);
250
+ if (boot.status !== 0)
251
+ throw new Error(launchctlFailure("bootstrap", boot));
252
+ run("launchctl", ["kickstart", "-k", target()]);
253
+ },
182
254
  status() {
183
255
  if (!fs.existsSync(file))
184
- return { registered: false, running: false, argv: [] };
185
- const argv = parsePlistArgv(fs.readFileSync(file, "utf8"));
256
+ return { registered: false, running: false, argv: [], keepsAlive: true };
257
+ const xml = fs.readFileSync(file, "utf8");
258
+ const argv = parsePlistArgv(xml);
186
259
  const printed = run("launchctl", ["print", target()]);
187
260
  const parsed = printed.status === 0
188
261
  ? parseLaunchctlPrint(printed.stdout)
@@ -194,6 +267,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
194
267
  // Strip the interpreter and script: what a reader wants from `status`
195
268
  // is the Paddock invocation, not the absolute path to node twice.
196
269
  argv: argv.slice(2),
270
+ keepsAlive: plistKeepsAlive(xml),
197
271
  };
198
272
  },
199
273
  logsHint(spec) {
@@ -31,8 +31,17 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
31
31
  /**
32
32
  * Render the unit.
33
33
  *
34
- * `Restart=on-failure` rather than `always`, matching launchd's `SuccessfulExit:
35
- * false` — a clean shutdown should stay shut down.
34
+ * `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
35
+ * `on-failure`, which for a service is the wrong default for the same reason it
36
+ * was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
37
+ * else that signals the user manager — was read as an intended stop and Paddock
38
+ * stayed down.
39
+ *
40
+ * `always` does not make the unit unstoppable. systemd never restarts a unit it
41
+ * was itself asked to stop, so `systemctl --user stop` (and therefore
42
+ * `uninstall`) remains the clean, explicit way to put it down. That is the
43
+ * difference between an exit code, which has to guess at intent, and a command,
44
+ * which carries it.
36
45
  *
37
46
  * `WantedBy=default.target` is what makes `systemctl --user enable` mean
38
47
  * "start at login" for a user unit; `multi-user.target` is the system-unit
@@ -43,6 +52,16 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
43
52
  * answers to "where are the logs".
44
53
  */
45
54
  export declare function renderUnit(spec: ServiceSpec): string;
55
+ /**
56
+ * Does an installed unit carry `Restart=always` (#872)?
57
+ *
58
+ * The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
59
+ * value it has to reject is the `on-failure` this file used to write, which
60
+ * leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
61
+ * Anything unrecognised reads as "not the fixed shape", which at worst tells a
62
+ * hand-edited unit's owner to re-run install.
63
+ */
64
+ export declare function unitKeepsAlive(text: string): boolean;
46
65
  /** Read `ExecStart=` back out of an installed unit, split into an argv. */
47
66
  export declare function parseUnitArgv(text: string): string[];
48
67
  export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;