@edspencer/paddock 0.79.1 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +7 -0
  3. package/packages/server/dist/app.js +19 -15
  4. package/packages/server/dist/auth.d.ts +3 -2
  5. package/packages/server/dist/auth.js +11 -0
  6. package/packages/server/dist/background-live.d.ts +17 -0
  7. package/packages/server/dist/background-live.js +24 -0
  8. package/packages/server/dist/busy.d.ts +96 -0
  9. package/packages/server/dist/busy.js +88 -0
  10. package/packages/server/dist/cli/args.d.ts +63 -7
  11. package/packages/server/dist/cli/args.js +227 -20
  12. package/packages/server/dist/cli/busy-command.d.ts +58 -0
  13. package/packages/server/dist/cli/busy-command.js +108 -0
  14. package/packages/server/dist/cli/busy.d.ts +69 -0
  15. package/packages/server/dist/cli/busy.js +150 -0
  16. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  17. package/packages/server/dist/cli/exit-codes.js +48 -0
  18. package/packages/server/dist/cli/output.d.ts +52 -0
  19. package/packages/server/dist/cli/output.js +84 -0
  20. package/packages/server/dist/cli/paddock.js +98 -37
  21. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  22. package/packages/server/dist/cli/service/index.d.ts +68 -2
  23. package/packages/server/dist/cli/service/index.js +321 -60
  24. package/packages/server/dist/cli/service/launchd.d.ts +23 -1
  25. package/packages/server/dist/cli/service/launchd.js +145 -25
  26. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  27. package/packages/server/dist/cli/service/logs.js +115 -0
  28. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  29. package/packages/server/dist/cli/service/path-env.js +120 -0
  30. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  31. package/packages/server/dist/cli/service/ready.js +30 -0
  32. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  33. package/packages/server/dist/cli/service/spec.js +34 -7
  34. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  35. package/packages/server/dist/cli/service/systemd.js +127 -22
  36. package/packages/server/dist/herdctl.d.ts +19 -0
  37. package/packages/server/dist/herdctl.js +26 -0
  38. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  39. package/packages/server/dist/management-mcp-server.js +4 -13
  40. package/packages/server/dist/routes/meta.js +56 -3
  41. package/packages/server/dist/runtime-file.d.ts +26 -0
  42. package/packages/server/dist/runtime-file.js +91 -0
  43. package/packages/server/dist/service-log.d.ts +29 -0
  44. package/packages/server/dist/service-log.js +104 -0
  45. package/packages/server/dist/start.d.ts +33 -0
  46. package/packages/server/dist/start.js +126 -8
  47. package/packages/server/dist/version.d.ts +3 -0
  48. package/packages/server/dist/version.js +32 -0
  49. package/packages/server/dist/ws-context.d.ts +9 -0
  50. package/packages/server/dist/ws-turn.js +1 -0
  51. package/packages/server/package.json +1 -1
  52. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-o0KCr3aB.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-39mafH8b.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-Bq7GVguY.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BoBIru1g.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-C0kwFyYp.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-MkhXlB8z.js} +1 -1
  62. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-DExlwAwO.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
  66. package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
  77. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-XjElsvQ7.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-BwTIA8rI.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
  90. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-CvaWErFv.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
  95. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BxOawONt.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-Dvs1QCcz.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.js} +1 -1
  110. package/packages/web/dist/index.html +1 -1
  111. package/packages/web/dist/sw.js +1 -1
  112. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  113. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -22,14 +22,23 @@ export interface ServiceSpec {
22
22
  * interpreter explicitly removes the lookup entirely.
23
23
  */
24
24
  scriptPath: string;
25
- /** Arguments after the script: always starts with the `start` verb. */
25
+ /** Arguments after the script: always starts with the `serve` verb. */
26
26
  args: string[];
27
27
  /** A directory the service can sit in that it certainly owns. */
28
28
  workingDirectory: string;
29
- /** Where stdout/stderr go on launchd. systemd uses journald and ignores these. */
30
- stdoutPath: string;
31
- stderrPath: string;
32
- /** PATH for the service, captured at install time. */
29
+ /**
30
+ * The one file both streams append to, on every OS (SPEC §7).
31
+ *
32
+ * launchd names it twice (`StandardOutPath` and `StandardErrorPath`); systemd
33
+ * names it twice as well (`StandardOutput=append:` / `StandardError=append:`)
34
+ * rather than using the journal. That is what makes `paddock service logs`
35
+ * one Node implementation instead of two, and it is also what makes the
36
+ * Linux logs survive a journal that is volatile or rate-limited — the default
37
+ * on plenty of distributions, where `journalctl --user` after a reboot showed
38
+ * nothing at all.
39
+ */
40
+ logPath: string;
41
+ /** PATH for the service: Node's bin, the usable inherited entries, the system dirs. */
33
42
  pathEnv: string;
34
43
  }
35
44
  export interface SpecInput {
@@ -44,6 +53,19 @@ export interface SpecInput {
44
53
  dataDirArg?: string;
45
54
  verbose?: boolean;
46
55
  pathEnv?: string;
56
+ /**
57
+ * Injected by tests: "is this an existing directory?", and which system
58
+ * directories to append.
59
+ *
60
+ * Not a knob — a necessity. `servicePath` keeps only directories that EXIST,
61
+ * which is what stops a unit naming a `node` that went away with a terminal
62
+ * (path-env.ts). That makes the rendered unit depend on the filesystem it was
63
+ * rendered on, so a golden plist asserted on this box would differ from the
64
+ * same golden on a macOS runner, for reasons that have nothing to do with the
65
+ * code under test. Pinning both makes the goldens about the renderer again.
66
+ */
67
+ pathExists?: (p: string) => boolean;
68
+ systemDirs?: readonly string[];
47
69
  }
48
70
  /**
49
71
  * The service's own directory inside the data dir.
@@ -58,14 +80,31 @@ export interface SpecInput {
58
80
  * subdirectory is neither.
59
81
  */
60
82
  export declare function serviceDir(dataDir: string): string;
83
+ /**
84
+ * Where the service's log lives: `<dataDir>/logs/service.log` (SPEC §7).
85
+ *
86
+ * One file, both streams, every platform — so `paddock service logs` is one
87
+ * implementation and the answer to "where are the logs" is one path. It is NOT
88
+ * under {@link serviceDir}: the logs outlive any one install (`uninstall` keeps
89
+ * them deliberately), and the server rotates this file itself
90
+ * (`service-log.ts`), which means something other than the service CLI has to
91
+ * be able to name it from the data dir alone.
92
+ */
93
+ export declare function logsDir(dataDir: string): string;
94
+ /** The single log file both streams append to. */
95
+ export declare function serviceLogPath(dataDir: string): string;
61
96
  /** The data dir the service will use, resolved the same way `paddock.ts` does. */
62
97
  export declare function defaultDataDir(homeDir?: string): string;
63
98
  /**
64
99
  * Assemble the spec.
65
100
  *
66
- * The argument vector leads with the `start` verb even though bare `paddock`
101
+ * The argument vector leads with the `serve` verb even though bare `paddock`
67
102
  * would do the same thing. It costs nothing and it is the difference between
68
103
  * `launchctl print` showing an argv you can read and one you have to recognise.
104
+ * It was `start` until the CLI contract made `serve` the name for running in
105
+ * the foreground (SPEC §1); `start` is a permanent alias, so a unit written by
106
+ * an older Paddock keeps working untouched and only gets `serve` when something
107
+ * rewrites it.
69
108
  *
70
109
  * `--data-dir` is forwarded ONLY when the user passed it. The default of
71
110
  * setting nothing is load-bearing: with no data dir named anywhere, the service
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
+ import { servicePath } from "./path-env.js";
13
14
  /**
14
15
  * Reverse-DNS on macOS, plain on Linux. Both are stable identifiers a user may
15
16
  * end up typing (`launchctl print gui/501/net.edspencer.paddock`,
@@ -37,6 +38,23 @@ export const DEFAULT_PORT = "7233";
37
38
  export function serviceDir(dataDir) {
38
39
  return path.join(dataDir, "service");
39
40
  }
41
+ /**
42
+ * Where the service's log lives: `<dataDir>/logs/service.log` (SPEC §7).
43
+ *
44
+ * One file, both streams, every platform — so `paddock service logs` is one
45
+ * implementation and the answer to "where are the logs" is one path. It is NOT
46
+ * under {@link serviceDir}: the logs outlive any one install (`uninstall` keeps
47
+ * them deliberately), and the server rotates this file itself
48
+ * (`service-log.ts`), which means something other than the service CLI has to
49
+ * be able to name it from the data dir alone.
50
+ */
51
+ export function logsDir(dataDir) {
52
+ return path.join(dataDir, "logs");
53
+ }
54
+ /** The single log file both streams append to. */
55
+ export function serviceLogPath(dataDir) {
56
+ return path.join(logsDir(dataDir), "service.log");
57
+ }
40
58
  /** The data dir the service will use, resolved the same way `paddock.ts` does. */
41
59
  export function defaultDataDir(homeDir = os.homedir()) {
42
60
  return path.join(homeDir, ".paddock");
@@ -44,9 +62,13 @@ export function defaultDataDir(homeDir = os.homedir()) {
44
62
  /**
45
63
  * Assemble the spec.
46
64
  *
47
- * The argument vector leads with the `start` verb even though bare `paddock`
65
+ * The argument vector leads with the `serve` verb even though bare `paddock`
48
66
  * would do the same thing. It costs nothing and it is the difference between
49
67
  * `launchctl print` showing an argv you can read and one you have to recognise.
68
+ * It was `start` until the CLI contract made `serve` the name for running in
69
+ * the foreground (SPEC §1); `start` is a permanent alias, so a unit written by
70
+ * an older Paddock keeps working untouched and only gets `serve` when something
71
+ * rewrites it.
50
72
  *
51
73
  * `--data-dir` is forwarded ONLY when the user passed it. The default of
52
74
  * setting nothing is load-bearing: with no data dir named anywhere, the service
@@ -58,7 +80,7 @@ export function defaultDataDir(homeDir = os.homedir()) {
58
80
  * service opening a browser tab at login is not a feature.
59
81
  */
60
82
  export function buildSpec(input) {
61
- const args = ["start"];
83
+ const args = ["serve"];
62
84
  if (input.port !== undefined)
63
85
  args.push("--port", input.port);
64
86
  if (input.host !== undefined)
@@ -67,15 +89,20 @@ export function buildSpec(input) {
67
89
  args.push("--data-dir", input.dataDirArg);
68
90
  if (input.verbose === true)
69
91
  args.push("--verbose");
70
- const dir = serviceDir(input.dataDir);
71
92
  return {
72
93
  nodePath: input.nodePath,
73
94
  scriptPath: input.scriptPath,
74
95
  args,
75
- workingDirectory: dir,
76
- stdoutPath: path.join(dir, "paddock.log"),
77
- stderrPath: path.join(dir, "paddock.error.log"),
78
- pathEnv: input.pathEnv ?? "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
96
+ workingDirectory: serviceDir(input.dataDir),
97
+ logPath: serviceLogPath(input.dataDir),
98
+ // Node's own bin directory leads, so the interpreter the unit names can
99
+ // find its neighbours; `servicePath` drops the installing shell's
100
+ // per-session entries. See path-env.ts for why inheriting PATH verbatim
101
+ // writes a unit that rots when a terminal closes.
102
+ pathEnv: servicePath(path.dirname(input.nodePath), input.pathEnv, {
103
+ ...(input.pathExists !== undefined ? { exists: input.pathExists } : {}),
104
+ ...(input.systemDirs !== undefined ? { systemDirs: input.systemDirs } : {}),
105
+ }),
79
106
  };
80
107
  }
81
108
  /**
@@ -1,5 +1,5 @@
1
1
  import { type ServiceSpec } from "./spec.js";
2
- import type { Runner, ServiceBackend } from "./backend.js";
2
+ import type { RunResult, Runner, ServiceBackend } from "./backend.js";
3
3
  /**
4
4
  * `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
5
5
  *
@@ -28,6 +28,17 @@ import type { Runner, ServiceBackend } from "./backend.js";
28
28
  * caller with no opinion still gets the right answer.)
29
29
  */
30
30
  export declare function unitPath(homeDir?: string, xdgConfigHome?: string): string;
31
+ /** One `ExecStart` word: specifiers and variables escaped, quoted when it has to be. */
32
+ export declare function execWord(a: string): string;
33
+ /**
34
+ * The other way: the words of an `ExecStart` line.
35
+ *
36
+ * Reads both shapes on purpose. A unit written before #796's quoting is a bare
37
+ * whitespace-separated line, and its owner is exactly the person whose `status`
38
+ * has to keep working — so unquoted words pass through unchanged, and quoted
39
+ * ones are unescaped.
40
+ */
41
+ export declare function execWords(line: string): string[];
31
42
  /**
32
43
  * Render the unit.
33
44
  *
@@ -47,11 +58,32 @@ export declare function unitPath(homeDir?: string, xdgConfigHome?: string): stri
47
58
  * "start at login" for a user unit; `multi-user.target` is the system-unit
48
59
  * spelling and does nothing here.
49
60
  *
50
- * No `StandardOutput=` — a user unit's output goes to the journal, which is
51
- * where a Linux user will look, and duplicating it into a file would give two
52
- * answers to "where are the logs".
61
+ * ## Logs go to a file, not the journal (SPEC §7)
62
+ *
63
+ * `StandardOutput=append:` / `StandardError=append:` name one file, the same
64
+ * one launchd's two keys name. The journal was the obvious choice and the wrong
65
+ * one for three reasons: `journalctl --user` shows nothing at all on a distro
66
+ * whose journal is volatile (the output is gone after a reboot, which is
67
+ * exactly when someone looks); it makes "where are the logs" have a different
68
+ * answer on each platform, so `paddock service logs` would be two
69
+ * implementations; and it rules out Windows, which the contract requires
70
+ * nothing rule out.
71
+ *
72
+ * The directory has to exist first — systemd creates the file, not its parent —
73
+ * which `install` below does.
74
+ *
75
+ * ## Every ExecStart word is quoted
76
+ *
77
+ * `%` starts a specifier anywhere in a unit file and `$` starts a variable in
78
+ * `ExecStart`, so both are escaped; a word containing whitespace, a quote or a
79
+ * backslash is quoted. Paths with a space in them are the common case
80
+ * (`/Users/Ed Spencer/.paddock`), and the old unqualified `join(" ")` wrote a
81
+ * unit systemd then split into the wrong arguments — a service that fails at
82
+ * login having printed nothing.
53
83
  */
54
84
  export declare function renderUnit(spec: ServiceSpec): string;
85
+ /** The log file an installed unit appends to, or undefined if it names none. */
86
+ export declare function parseUnitLogPath(text: string): string | undefined;
55
87
  /**
56
88
  * Does an installed unit carry `Restart=always` (#872)?
57
89
  *
@@ -64,4 +96,15 @@ export declare function renderUnit(spec: ServiceSpec): string;
64
96
  export declare function unitKeepsAlive(text: string): boolean;
65
97
  /** Read `ExecStart=` back out of an installed unit, split into an argv. */
66
98
  export declare function parseUnitArgv(text: string): string[];
99
+ /**
100
+ * What a `systemctl` failure means, in the manager's own words where it has any.
101
+ *
102
+ * The `status === null` branch is the one QA found: `spawnSync` reports a
103
+ * missing binary that way, with no output at all, so the message was
104
+ * `systemctl --user enable --now failed (exit null).` and then a blank line —
105
+ * which tells someone on a container with no systemd nothing whatsoever. That
106
+ * is also the single likeliest way this command fails on a machine it was never
107
+ * going to work on.
108
+ */
109
+ export declare function systemctlFailure(args: string[], r: RunResult): string;
67
110
  export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;
@@ -59,6 +59,39 @@ export function unitPath(homeDir, xdgConfigHome) {
59
59
  : (process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")));
60
60
  return path.join(base, "systemd", "user", SYSTEMD_UNIT);
61
61
  }
62
+ /**
63
+ * Unit-file escaping, ported from Local Review's `server/service/systemd.ts`
64
+ * (`pct`, `cEscape`, `cUnescape`, `execWord`, `execWords`) @ cli-contract spec
65
+ * 0.1; local changes: doc comments only. Wave C4 moves these to
66
+ * `reference/service/` and re-syncs.
67
+ *
68
+ * `%` starts a specifier EVERYWHERE in a unit file, so it is doubled; `$`
69
+ * starts a variable in `ExecStart`, so it is doubled there too.
70
+ */
71
+ const pct = (s) => s.replace(/%/g, "%%");
72
+ const cEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
73
+ const cUnescape = (s) => s.replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
74
+ /** One `ExecStart` word: specifiers and variables escaped, quoted when it has to be. */
75
+ export function execWord(a) {
76
+ const s = pct(a).replace(/\$/g, "$$$$");
77
+ return s === "" || /[\s"'\\;]/.test(s) ? `"${cEscape(s)}"` : s;
78
+ }
79
+ /**
80
+ * The other way: the words of an `ExecStart` line.
81
+ *
82
+ * Reads both shapes on purpose. A unit written before #796's quoting is a bare
83
+ * whitespace-separated line, and its owner is exactly the person whose `status`
84
+ * has to keep working — so unquoted words pass through unchanged, and quoted
85
+ * ones are unescaped.
86
+ */
87
+ export function execWords(line) {
88
+ const out = [];
89
+ for (const m of line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)) {
90
+ const w = m[1] !== undefined ? cUnescape(m[1]) : m[2];
91
+ out.push(w.replace(/\$\$/g, "$").replace(/%%/g, "%"));
92
+ }
93
+ return out;
94
+ }
62
95
  /**
63
96
  * Render the unit.
64
97
  *
@@ -78,21 +111,44 @@ export function unitPath(homeDir, xdgConfigHome) {
78
111
  * "start at login" for a user unit; `multi-user.target` is the system-unit
79
112
  * spelling and does nothing here.
80
113
  *
81
- * No `StandardOutput=` — a user unit's output goes to the journal, which is
82
- * where a Linux user will look, and duplicating it into a file would give two
83
- * answers to "where are the logs".
114
+ * ## Logs go to a file, not the journal (SPEC §7)
115
+ *
116
+ * `StandardOutput=append:` / `StandardError=append:` name one file, the same
117
+ * one launchd's two keys name. The journal was the obvious choice and the wrong
118
+ * one for three reasons: `journalctl --user` shows nothing at all on a distro
119
+ * whose journal is volatile (the output is gone after a reboot, which is
120
+ * exactly when someone looks); it makes "where are the logs" have a different
121
+ * answer on each platform, so `paddock service logs` would be two
122
+ * implementations; and it rules out Windows, which the contract requires
123
+ * nothing rule out.
124
+ *
125
+ * The directory has to exist first — systemd creates the file, not its parent —
126
+ * which `install` below does.
127
+ *
128
+ * ## Every ExecStart word is quoted
129
+ *
130
+ * `%` starts a specifier anywhere in a unit file and `$` starts a variable in
131
+ * `ExecStart`, so both are escaped; a word containing whitespace, a quote or a
132
+ * backslash is quoted. Paths with a space in them are the common case
133
+ * (`/Users/Ed Spencer/.paddock`), and the old unqualified `join(" ")` wrote a
134
+ * unit systemd then split into the wrong arguments — a service that fails at
135
+ * login having printed nothing.
84
136
  */
85
137
  export function renderUnit(spec) {
86
- const exec = [spec.nodePath, spec.scriptPath, ...spec.args].join(" ");
87
- return `[Unit]
138
+ const exec = [spec.nodePath, spec.scriptPath, ...spec.args].map(execWord).join(" ");
139
+ return `# Generated by Paddock — \`paddock service install\` rewrites this file.
140
+ # Node: ${spec.nodePath}
141
+ [Unit]
88
142
  Description=Paddock — persistent, resumable Claude Code sessions, by project
89
143
  Documentation=https://paddock.edspencer.net
90
144
 
91
145
  [Service]
92
146
  Type=simple
93
147
  ExecStart=${exec}
94
- WorkingDirectory=${spec.workingDirectory}
95
- Environment=PATH=${spec.pathEnv}
148
+ WorkingDirectory=${pct(spec.workingDirectory)}
149
+ Environment="PATH=${cEscape(pct(spec.pathEnv))}"
150
+ StandardOutput=append:${pct(spec.logPath)}
151
+ StandardError=append:${pct(spec.logPath)}
96
152
  Restart=always
97
153
  RestartSec=10
98
154
 
@@ -100,6 +156,11 @@ RestartSec=10
100
156
  WantedBy=default.target
101
157
  `;
102
158
  }
159
+ /** The log file an installed unit appends to, or undefined if it names none. */
160
+ export function parseUnitLogPath(text) {
161
+ const m = /^StandardOutput=append:(.*)$/m.exec(text);
162
+ return m === null ? undefined : m[1].trim().replace(/%%/g, "%");
163
+ }
103
164
  /**
104
165
  * Does an installed unit carry `Restart=always` (#872)?
105
166
  *
@@ -117,15 +178,40 @@ export function parseUnitArgv(text) {
117
178
  const m = /^ExecStart=(.*)$/m.exec(text);
118
179
  if (m === null)
119
180
  return [];
120
- return m[1].trim().split(/\s+/).filter(Boolean);
181
+ return execWords(m[1].trim());
121
182
  }
122
- /** Run a `systemctl` and turn a non-zero exit into the manager's own words. */
183
+ /**
184
+ * What a `systemctl` failure means, in the manager's own words where it has any.
185
+ *
186
+ * The `status === null` branch is the one QA found: `spawnSync` reports a
187
+ * missing binary that way, with no output at all, so the message was
188
+ * `systemctl --user enable --now failed (exit null).` and then a blank line —
189
+ * which tells someone on a container with no systemd nothing whatsoever. That
190
+ * is also the single likeliest way this command fails on a machine it was never
191
+ * going to work on.
192
+ */
193
+ export function systemctlFailure(args, r) {
194
+ if (r.status === null || r.status === 127) {
195
+ return ("systemctl is not available, so Paddock cannot install a service here.\n" +
196
+ "It needs systemd's per-user instance (`systemctl --user`) — a container " +
197
+ "or a non-systemd distribution has none.\n" +
198
+ "Run `paddock serve` in a terminal, or keep it up with whatever this " +
199
+ "machine uses.");
200
+ }
201
+ if (/Failed to connect to (user scope )?bus|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR/.test(r.stderr)) {
202
+ return (`cannot reach systemd's per-user instance (${r.stderr.trim()}).\n` +
203
+ "Run this from a login session, and check that $XDG_RUNTIME_DIR is set " +
204
+ "(usually /run/user/$(id -u)).");
205
+ }
206
+ return (`systemctl ${args.join(" ")} failed (exit ${String(r.status)}).\n` +
207
+ `${r.stderr || r.stdout}`.trim());
208
+ }
209
+ /** Run a `systemctl` and turn a non-zero exit into something actionable. */
123
210
  function systemctlOrThrow(run, args) {
124
211
  const r = run("systemctl", args);
125
212
  if (r.status === 0)
126
213
  return;
127
- throw new Error(`systemctl ${args.join(" ")} failed (exit ${String(r.status)}).\n` +
128
- `${r.stderr || r.stdout}`.trim());
214
+ throw new Error(systemctlFailure(args, r));
129
215
  }
130
216
  export function createSystemdBackend(run, homeDir, xdgConfigHome) {
131
217
  // Both forwarded, neither defaulted here: `unitPath` owns the precedence, and
@@ -140,15 +226,19 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
140
226
  install(spec) {
141
227
  fs.mkdirSync(path.dirname(file), { recursive: true });
142
228
  fs.mkdirSync(spec.workingDirectory, { recursive: true });
229
+ // systemd creates the log FILE for `append:` but not its directory, and a
230
+ // missing directory fails the unit at start with the server never having
231
+ // run — no output anywhere, because the thing that would have written it
232
+ // is what failed.
233
+ fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
143
234
  fs.writeFileSync(file, renderUnit(spec), "utf8");
144
235
  // Without this the manager keeps serving the previous unit from cache and
145
236
  // an "upgrade" silently changes nothing.
146
237
  run("systemctl", ["--user", "daemon-reload"]);
147
- const enabled = run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT]);
148
- if (enabled.status !== 0) {
149
- throw new Error(`systemctl --user enable --now failed (exit ${String(enabled.status)}).\n` +
150
- `${enabled.stderr || enabled.stdout}`.trim());
151
- }
238
+ const args = ["--user", "enable", "--now", SYSTEMD_UNIT];
239
+ const enabled = run("systemctl", args);
240
+ if (enabled.status !== 0)
241
+ throw new Error(systemctlFailure(args, enabled));
152
242
  },
153
243
  uninstall() {
154
244
  const existed = fs.existsSync(file);
@@ -188,21 +278,36 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
188
278
  const active = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
189
279
  const pid = run("systemctl", ["--user", "show", "-p", "MainPID", "--value", SYSTEMD_UNIT]);
190
280
  const mainPid = pid.stdout.trim();
281
+ const logPath = parseUnitLogPath(text);
191
282
  return {
192
283
  registered: true,
193
284
  running: active.stdout.trim() === "active",
194
285
  ...(mainPid !== "" && mainPid !== "0" ? { pid: mainPid } : {}),
195
286
  argv: argv.slice(2),
287
+ ...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
288
+ ...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
289
+ ...(logPath !== undefined ? { logPath } : {}),
196
290
  keepsAlive: unitKeepsAlive(text),
197
291
  };
198
292
  },
199
- logsHint() {
200
- return ` Logs: journalctl --user -u ${SYSTEMD_UNIT} -f`;
201
- },
202
- lingerNote() {
293
+ /**
294
+ * `loginctl show-user <user> -p Linger` (SPEC §7).
295
+ *
296
+ * Parsed rather than trusted to exit codes: `loginctl` is absent in plenty
297
+ * of containers and exits non-zero for a user logind has no record of, and
298
+ * neither of those is "linger is off". An unparseable answer is reported as
299
+ * unknown, which prints nothing — the alternative is handing a `sudo`
300
+ * command to someone whose machine has no `loginctl` to run it with.
301
+ */
302
+ linger() {
203
303
  const user = os.userInfo().username;
204
- return (" A user service stops when you log out. To keep Paddock up:\n" +
205
- ` loginctl enable-linger ${user}`);
304
+ const r = run("loginctl", ["show-user", user, "-p", "Linger"]);
305
+ const m = /Linger=(yes|no)/i.exec(`${r.stdout}${r.stderr}`);
306
+ return {
307
+ applies: true,
308
+ user,
309
+ ...(m !== null ? { enabled: m[1].toLowerCase() === "yes" } : {}),
310
+ };
206
311
  },
207
312
  };
208
313
  }
@@ -371,6 +371,25 @@ export declare class HerdctlService {
371
371
  * error, returns `false` — the pre-#397 behaviour.
372
372
  */
373
373
  isSdkSessionLive(sessionId: string): boolean;
374
+ /**
375
+ * How many scheduler jobs are running right now, read from memory.
376
+ *
377
+ * This exists for `GET /api/busy` (CLI contract SPEC §4), which must read
378
+ * in-memory state and never walk the disk. The number the contract's ROADMAP
379
+ * names is `fleetStatus().counts.runningJobs` — but {@link fleetStatus} is
380
+ * built from `getAgentInfo()`, which re-reads the fleet state FILE on every
381
+ * call, and that re-parse has been this server's single largest CPU cost.
382
+ *
383
+ * The two are the same number. `counts.runningJobs` is
384
+ * `Σ scheduler.getRunningJobCount(agent.qualifiedName)` over the configured
385
+ * agents, and `getTotalRunningJobCount()` is the size of the same scheduler
386
+ * map keyed `<agent>/<schedule>` — one entry per running agent+schedule pair
387
+ * either way. So this reads the authority directly instead of a derived
388
+ * snapshot of it.
389
+ *
390
+ * Null-safe like {@link isSdkSessionLive}: no fleet or no scheduler means 0.
391
+ */
392
+ runningJobCount(): number;
374
393
  fleetStatus(): Promise<FleetStatus>;
375
394
  agents(): Promise<AgentInfo[]>;
376
395
  /**
@@ -528,6 +528,32 @@ export class HerdctlService {
528
528
  return false;
529
529
  }
530
530
  }
531
+ /**
532
+ * How many scheduler jobs are running right now, read from memory.
533
+ *
534
+ * This exists for `GET /api/busy` (CLI contract SPEC §4), which must read
535
+ * in-memory state and never walk the disk. The number the contract's ROADMAP
536
+ * names is `fleetStatus().counts.runningJobs` — but {@link fleetStatus} is
537
+ * built from `getAgentInfo()`, which re-reads the fleet state FILE on every
538
+ * call, and that re-parse has been this server's single largest CPU cost.
539
+ *
540
+ * The two are the same number. `counts.runningJobs` is
541
+ * `Σ scheduler.getRunningJobCount(agent.qualifiedName)` over the configured
542
+ * agents, and `getTotalRunningJobCount()` is the size of the same scheduler
543
+ * map keyed `<agent>/<schedule>` — one entry per running agent+schedule pair
544
+ * either way. So this reads the authority directly instead of a derived
545
+ * snapshot of it.
546
+ *
547
+ * Null-safe like {@link isSdkSessionLive}: no fleet or no scheduler means 0.
548
+ */
549
+ runningJobCount() {
550
+ try {
551
+ return this.fleet?.getScheduler()?.getTotalRunningJobCount() ?? 0;
552
+ }
553
+ catch {
554
+ return 0;
555
+ }
556
+ }
531
557
  async fleetStatus() {
532
558
  return this.manager.getFleetStatus();
533
559
  }
@@ -1,3 +1,22 @@
1
+ /**
2
+ * The MCP server for external Management API callers (issue #312 M2).
3
+ *
4
+ * Adapts the SAME tool definitions the in-process keeper gets — an
5
+ * {@link InjectedMcpServerDef} produced by `selfMcpServerDefForOps` — onto the
6
+ * official MCP SDK, so an external client sees exactly the toolset a keeper
7
+ * sees, minus whatever its scope hides. Nothing is redefined here: if a tool is
8
+ * added to the self-MCP, it appears over `/mcp` with no further work, which is
9
+ * the "upgrade the server, every client's toolset upgrades" property the epic
10
+ * calls out.
11
+ *
12
+ * ── Why the LOW-LEVEL `Server`, not `McpServer` ────────────────────────────
13
+ * The high-level `McpServer.registerTool` wants Zod schemas and converts them to
14
+ * JSON Schema for the wire. Our tools already carry hand-written JSON Schema
15
+ * (shared with herdctl's injected-MCP bridge), so going through Zod would mean
16
+ * translating JSON Schema → Zod → JSON Schema and hoping the round trip is
17
+ * faithful. Implementing `tools/list` + `tools/call` directly passes our schemas
18
+ * through verbatim and keeps ONE source of truth for the tool surface.
19
+ */
1
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
21
  import type { InjectedMcpServerDef } from "@herdctl/core";
3
22
  import { type ManagementPrincipal } from "./management-policy.js";
@@ -5,9 +24,8 @@ import { type ManagementPrincipal } from "./management-policy.js";
5
24
  export declare const MANAGEMENT_MCP_SERVER_NAME = "paddock";
6
25
  /**
7
26
  * Package version, advertised to the client so it can report which Paddock it
8
- * is talking to. Resolved at runtime (dist/management-mcp-server.js →
9
- * ../package.json), the same way app.ts resolves it for the OpenAPI document,
10
- * so it tracks releases without a build step.
27
+ * is talking to. One resolution, shared with the OpenAPI document and
28
+ * `/api/health` (version.ts), so the three cannot disagree.
11
29
  */
12
30
  export declare const MANAGEMENT_MCP_SERVER_VERSION: string;
13
31
  /**
@@ -17,27 +17,18 @@
17
17
  * faithful. Implementing `tools/list` + `tools/call` directly passes our schemas
18
18
  * through verbatim and keeps ONE source of truth for the tool surface.
19
19
  */
20
- import { createRequire } from "node:module";
21
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
21
  import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
22
  import { ManagementDeniedError } from "./management-policy.js";
23
+ import { PADDOCK_VERSION } from "./version.js";
24
24
  /** Server identity advertised in the MCP `initialize` handshake. */
25
25
  export const MANAGEMENT_MCP_SERVER_NAME = "paddock";
26
26
  /**
27
27
  * Package version, advertised to the client so it can report which Paddock it
28
- * is talking to. Resolved at runtime (dist/management-mcp-server.js →
29
- * ../package.json), the same way app.ts resolves it for the OpenAPI document,
30
- * so it tracks releases without a build step.
28
+ * is talking to. One resolution, shared with the OpenAPI document and
29
+ * `/api/health` (version.ts), so the three cannot disagree.
31
30
  */
32
- export const MANAGEMENT_MCP_SERVER_VERSION = (() => {
33
- try {
34
- const require = createRequire(import.meta.url);
35
- return require("../package.json").version ?? "0.0.0";
36
- }
37
- catch {
38
- return "0.0.0";
39
- }
40
- })();
31
+ export const MANAGEMENT_MCP_SERVER_VERSION = PADDOCK_VERSION;
41
32
  /**
42
33
  * Build an MCP server exposing `def`'s tools to `principal`.
43
34
  *