@edspencer/paddock 0.82.0 → 0.82.1

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 (107) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.js +2 -2
  3. package/packages/server/dist/cli/busy-command.js +1 -1
  4. package/packages/server/dist/cli/busy.js +10 -12
  5. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  6. package/packages/server/dist/cli/exit-codes.js +42 -45
  7. package/packages/server/dist/cli/output.js +5 -6
  8. package/packages/server/dist/cli/paddock.js +9 -9
  9. package/packages/server/dist/cli/service/index.d.ts +2 -2
  10. package/packages/server/dist/cli/service/index.js +30 -24
  11. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  12. package/packages/server/dist/cli/service/launchd.js +104 -304
  13. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  14. package/packages/server/dist/cli/service/logs.js +33 -75
  15. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  16. package/packages/server/dist/cli/service/manager.js +21 -0
  17. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  18. package/packages/server/dist/cli/service/path-env.js +30 -81
  19. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  20. package/packages/server/dist/cli/service/spec.js +9 -0
  21. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  22. package/packages/server/dist/cli/service/systemd.js +106 -262
  23. package/packages/server/dist/cli/update/health.d.ts +48 -0
  24. package/packages/server/dist/cli/update/health.js +64 -0
  25. package/packages/server/dist/cli/update/install-method.d.ts +29 -76
  26. package/packages/server/dist/cli/update/install-method.js +103 -178
  27. package/packages/server/dist/cli/update/lock.d.ts +7 -12
  28. package/packages/server/dist/cli/update/lock.js +28 -62
  29. package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
  30. package/packages/server/dist/cli/update/package-manager.js +95 -160
  31. package/packages/server/dist/cli/update/registry.d.ts +17 -54
  32. package/packages/server/dist/cli/update/registry.js +46 -89
  33. package/packages/server/dist/cli/update/semver.d.ts +18 -41
  34. package/packages/server/dist/cli/update/semver.js +74 -124
  35. package/packages/server/dist/cli/update/update-check.d.ts +15 -26
  36. package/packages/server/dist/cli/update/update-check.js +30 -60
  37. package/packages/server/dist/cli/update/update.d.ts +57 -91
  38. package/packages/server/dist/cli/update/update.js +230 -431
  39. package/packages/server/dist/cli/update-command.js +3 -5
  40. package/packages/server/package.json +1 -1
  41. package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  42. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  43. package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-BKqI0I1E.js} +1 -1
  44. package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  45. package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  46. package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-DerkA-nE.js} +1 -1
  47. package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-NwfC3_w7.js} +3 -3
  48. package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-BeORa0UH.js} +1 -1
  49. package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-BYJs8qJ6.js} +1 -1
  50. package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-w-kEaAYU.js} +1 -1
  51. package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-Bv56hasM.js} +1 -1
  52. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  53. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  54. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  55. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  56. package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  57. package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  58. package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  59. package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  60. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  64. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  65. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  66. package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CYuJAM2U.js} +1 -1
  67. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  68. package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  69. package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  70. package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  71. package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  72. package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  74. package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-M1lrfXRU.js} +1 -1
  75. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  76. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  77. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  78. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  79. package/packages/web/dist/assets/{index-CHOtvlnO.js → index-1E9yHciS.js} +2 -2
  80. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  81. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  82. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  83. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  84. package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-D3U8NOkt.js} +1 -1
  85. package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-BiArdWKV.js} +4 -4
  86. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  87. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  88. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  89. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  90. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  91. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  92. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  94. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  95. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  96. package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  97. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  98. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  99. package/packages/web/dist/index.html +1 -1
  100. package/packages/web/dist/sw.js +1 -1
  101. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  102. package/packages/server/dist/cli/service/backend.js +0 -19
  103. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
  104. package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
  105. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
  106. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
  107. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
@@ -1,102 +1,60 @@
1
- /**
2
- * `paddock service logs [-f] [-n <lines>]` (SPEC §1, §7).
3
- *
4
- * One implementation for every platform, which is the whole reason the units
5
- * write to a file rather than to the journal: `journalctl --user -u paddock` on
6
- * Linux and a pair of files on macOS meant "where are the logs" had two
7
- * answers, neither of which Windows could ever have.
8
- *
9
- * Reading the tail of a file is deliberately done by hand rather than by
10
- * spawning `tail`: `tail -f` is not on every box this runs on (and is `Get-
11
- * Content -Wait` on the platform the contract requires us not to rule out),
12
- * and shelling out would make the output depend on which `tail` won a PATH
13
- * lookup.
14
- */
15
- import fs from "node:fs";
16
- /** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
1
+ // From edspencer/cli-contract reference/service/logs.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
2
+ // `<tool> service logs [-f] [-n <lines>]` (SPEC §1, §7): one Node implementation on every OS, because every unit
3
+ // writes to `<home>/logs/service.log`. No `tail`: `tail -f` isn't on Windows, and which `tail` won a PATH lookup would
4
+ // change the output. Node built-ins only.
5
+ import fs from 'node:fs';
6
+ /** How many lines `logs` prints when nobody says (SPEC §1). */
17
7
  export const DEFAULT_LOG_LINES = 50;
18
- /** How much of the end of the file to read for a tail, in bytes. */
8
+ /** Only the end of the file is read for a tail: a log can reach tens of megabytes between rotations. */
19
9
  const TAIL_WINDOW = 256 * 1024;
20
- /** The gap between size checks when following. */
21
- const FOLLOW_INTERVAL_MS = 400;
22
- /**
23
- * The last `lines` lines of `file`.
24
- *
25
- * Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
26
- * and can reach tens of megabytes between rotations, and reading all of it to
27
- * print fifty lines would make `logs` slower than the thing it is reporting on.
28
- * A window that happens to start mid-line drops that partial first line rather
29
- * than printing half of one.
30
- */
10
+ /** The last `lines` lines of `file`. A window that starts mid-line drops that fragment. */
31
11
  export function tailFile(file, lines) {
32
- const fd = fs.openSync(file, "r");
12
+ const fd = fs.openSync(file, 'r');
33
13
  try {
34
14
  const size = fs.fstatSync(fd).size;
35
15
  const start = Math.max(0, size - TAIL_WINDOW);
36
- const length = size - start;
37
- if (length === 0)
38
- return "";
39
- const buf = Buffer.alloc(length);
40
- fs.readSync(fd, buf, 0, length, start);
41
- let text = buf.toString("utf8");
42
- // A window that starts mid-line: drop the fragment.
43
- if (start > 0) {
44
- const nl = text.indexOf("\n");
45
- text = nl === -1 ? "" : text.slice(nl + 1);
46
- }
47
- const all = text.split("\n");
48
- // A trailing newline produces a final empty element that is not a line.
49
- if (all[all.length - 1] === "")
16
+ if (size === start)
17
+ return '';
18
+ const buf = Buffer.alloc(size - start);
19
+ fs.readSync(fd, buf, 0, buf.length, start);
20
+ let text = buf.toString('utf8');
21
+ if (start > 0)
22
+ text = text.includes('\n') ? text.slice(text.indexOf('\n') + 1) : '';
23
+ const all = text.split('\n');
24
+ if (all.at(-1) === '')
50
25
  all.pop();
51
- return all.slice(-lines).join("\n");
26
+ return all.slice(-lines).join('\n');
52
27
  }
53
28
  finally {
54
29
  fs.closeSync(fd);
55
30
  }
56
31
  }
57
32
  /**
58
- * Print what gets appended to `file`, from `from` onwards, until the process is
59
- * interrupted.
60
- *
61
- * Polling rather than `fs.watch`: the writer here is the supervisor's own file
62
- * descriptor, and `fs.watch` on an appended-to file is unreliable across
63
- * platforms (no events at all on some network filesystems, and a stream of
64
- * them on others). A 400 ms poll of the size is both portable and cheap.
65
- *
66
- * Rotation is handled by the one case that can actually happen: the server
67
- * rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
68
- * and the size goes DOWN. Seeing that, this resets to the new start rather than
69
- * reading past the end forever, which is what a naive follower does — it goes
70
- * quiet after the first rotation and never recovers.
33
+ * Prints what's appended to `file` until stopped. Polls the size (`fs.watch` on an appended file is unreliable across
34
+ * platforms). The tool rotates by copy-and-truncate, so the size goes down; seeing that, it starts again from 0
35
+ * rather than reading past the end for ever. A file that vanishes is waited for.
71
36
  */
72
- export async function followFile(file, opts) {
73
- const interval = opts.intervalMs ?? FOLLOW_INTERVAL_MS;
74
- const write = opts.write ?? ((s) => process.stdout.write(s));
75
- const keepGoing = opts.keepGoing ?? (() => true);
76
- let offset = opts.from;
77
- while (keepGoing()) {
78
- await new Promise((r) => setTimeout(r, interval));
37
+ export async function followFile(file, o) {
38
+ const write = o.write ?? ((s) => process.stdout.write(s));
39
+ let offset = o.from;
40
+ while (o.keepGoing?.() ?? true) {
41
+ await new Promise((r) => setTimeout(r, o.intervalMs ?? 400));
79
42
  let size;
80
43
  try {
81
44
  size = fs.statSync(file).size;
82
45
  }
83
46
  catch {
84
- // The file can vanish: an uninstall, or a rotation that renames. Wait for
85
- // it to come back rather than giving up — `logs -f` is left running.
86
47
  continue;
87
48
  }
88
- if (size < offset) {
89
- // Truncated: the rotation above, or a hand-run `: > service.log`.
49
+ if (size < offset)
90
50
  offset = 0;
91
- }
92
51
  if (size === offset)
93
52
  continue;
94
- const fd = fs.openSync(file, "r");
53
+ const fd = fs.openSync(file, 'r');
95
54
  try {
96
- const length = size - offset;
97
- const buf = Buffer.alloc(length);
98
- fs.readSync(fd, buf, 0, length, offset);
99
- write(buf.toString("utf8"));
55
+ const buf = Buffer.alloc(size - offset);
56
+ fs.readSync(fd, buf, 0, buf.length, offset);
57
+ write(buf.toString('utf8'));
100
58
  offset = size;
101
59
  }
102
60
  finally {
@@ -104,7 +62,7 @@ export async function followFile(file, opts) {
104
62
  }
105
63
  }
106
64
  }
107
- /** The file's size, or 0 if it is not there — where a follow should start. */
65
+ /** The file's size, or 0 if it isn't there: where a follow starts. */
108
66
  export function sizeOf(file) {
109
67
  try {
110
68
  return fs.statSync(file).size;
@@ -0,0 +1,93 @@
1
+ /** Who the service belongs to. */
2
+ export interface ServiceIdentity {
3
+ /** `Paddock`: for messages and the unit's header. */
4
+ name: string;
5
+ /** `paddock`: the bin, for the commands messages print. */
6
+ tool: string;
7
+ /** The launchd label: `net.edspencer.paddock`. A person may type it (`launchctl print gui/501/<label>`). */
8
+ label: string;
9
+ /** The systemd user unit: `paddock.service`. */
10
+ unit: string;
11
+ /** systemd's `Description=`. */
12
+ description: string;
13
+ /** systemd's `Documentation=`, if there's a page. */
14
+ documentation?: string;
15
+ }
16
+ /** What the unit runs, already resolved: every path absolute and real. */
17
+ export interface ServiceSpec {
18
+ /** `realpath(process.execPath)`. */
19
+ nodePath: string;
20
+ /** `realpath(<package>/…/cli.js)`, never the bin shim: its `#!/usr/bin/env node` needs a `node` on a PATH a
21
+ * service manager doesn't have under a version manager. */
22
+ scriptPath: string;
23
+ /** After the script: `serve` and the options. */
24
+ args: string[];
25
+ /** A folder the service owns: `<home>/service`. */
26
+ workingDirectory: string;
27
+ /** `<home>/logs/service.log`: both streams, appended, on every OS. */
28
+ logPath: string;
29
+ /** The service's PATH (path-env.ts). */
30
+ pathEnv: string;
31
+ /** More environment, if the tool needs any. Never a credential: a unit file is readable. */
32
+ env?: Record<string, string>;
33
+ }
34
+ /** Linux only: whether the user lingers (SPEC §7). Undefined `enabled` means the check couldn't answer. */
35
+ export interface LingerState {
36
+ /** False where there's no such thing (macOS, Windows). */
37
+ applies: boolean;
38
+ enabled?: boolean;
39
+ user?: string;
40
+ }
41
+ /** What the service manager and the unit file say. */
42
+ export interface ServiceState {
43
+ /** A unit file is installed. */
44
+ registered: boolean;
45
+ /** The service manager says the process is up. */
46
+ running: boolean;
47
+ pid?: string;
48
+ /** The tool's arguments the unit records, with Node and the script taken off. */
49
+ argv: string[];
50
+ /** The Node and script the unit names, so `status` can say when one has gone (nvm, fnm and volta remove them). */
51
+ nodePath?: string;
52
+ cliPath?: string;
53
+ /** The log the unit names. Read back, so `logs` reads the file the service writes even if an older unit named another. */
54
+ logPath?: string;
55
+ /** The unit restarts the service after a clean exit (launchd `KeepAlive`, systemd `Restart=always`). True when nothing's registered. */
56
+ keepsAlive: boolean;
57
+ }
58
+ export interface ServiceManager {
59
+ platform: 'darwin' | 'linux' | 'win32';
60
+ /** Where the unit file goes. */
61
+ unitPath: string;
62
+ /** The launchd label or systemd unit: the string a person would type. */
63
+ label: string;
64
+ /** For people: `a launchd LaunchAgent`, `a systemd --user service`. */
65
+ kind: string;
66
+ /** Writes the unit, enables it and starts it, replacing a running one. */
67
+ install(spec: ServiceSpec): void;
68
+ /** Stops it, disables it and removes the unit; true if there was one. Keeps the data and the logs. */
69
+ uninstall(): boolean;
70
+ /**
71
+ * `start`, `stop` and `restart` throw with the manager's own words. `stop` doesn't disable: the service comes back
72
+ * at the next login or boot (SPEC §1). `restart` re-reads the unit.
73
+ */
74
+ start(): void;
75
+ stop(): void;
76
+ restart(): void;
77
+ status(): ServiceState;
78
+ linger(): LingerState;
79
+ }
80
+ export interface RunResult {
81
+ /** null when it couldn't be run or was killed. */
82
+ status: number | null;
83
+ stdout: string;
84
+ stderr: string;
85
+ }
86
+ export type Runner = (cmd: string, args: string[]) => RunResult;
87
+ export declare const spawnRunner: Runner;
88
+ /** Blocks for `ms`: the Runner is synchronous, so the waits between its calls are too. */
89
+ export declare function sleepSync(ms: number): void;
90
+ /** No service manager here yet: Windows (P3), or anything else. */
91
+ export declare class UnsupportedPlatformError extends Error {
92
+ name: string;
93
+ }
@@ -0,0 +1,21 @@
1
+ // From edspencer/cli-contract reference/service/manager.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
2
+ // The seam between what a tool's service is and how a service manager spells it (SPEC §7). launchd.ts and
3
+ // systemd.ts implement `ServiceManager`; Task Scheduler (Windows, P3) is the third, later, behind the same interface.
4
+ // Every command goes through a `Runner`, so the lifecycle is tested with a fake one, on any OS. Node built-ins only.
5
+ //
6
+ // The Runner is synchronous on purpose: `update`'s service hooks are, and a sleep between launchd's bootout and
7
+ // bootstrap (`sleepSync`) is all the waiting there is.
8
+ import { spawnSync } from 'node:child_process';
9
+ export const spawnRunner = (cmd, args) => {
10
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 60_000, windowsHide: true });
11
+ return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
12
+ };
13
+ /** Blocks for `ms`: the Runner is synchronous, so the waits between its calls are too. */
14
+ export function sleepSync(ms) {
15
+ if (ms > 0)
16
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
17
+ }
18
+ /** No service manager here yet: Windows (P3), or anything else. */
19
+ export class UnsupportedPlatformError extends Error {
20
+ name = 'UnsupportedPlatformError';
21
+ }
@@ -1,27 +1,16 @@
1
- /** PATH entries that belong to one shell session or one npm invocation. */
1
+ /** Entries that belong to one shell or one npm run. */
2
2
  export declare const PER_SHELL_PATTERNS: readonly RegExp[];
3
- /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
3
+ /** Where the usual tools live, after the inherited PATH. Only the ones that exist are kept. */
4
4
  export declare const SYSTEM_DIRS: readonly string[];
5
5
  export interface ServicePathOptions {
6
- /** Injected for tests: "is this an existing directory?". */
6
+ /** Injected for tests: is this an existing folder? */
7
7
  exists?: (p: string) => boolean;
8
- /** Injected for tests: the system directories to append. */
8
+ /** Injected for tests: the system folders to add. */
9
9
  systemDirs?: readonly string[];
10
10
  }
11
- /**
12
- * The job's PATH: Node's bin directory, then the usable part of `inherited`,
13
- * then the system directories. De-duplicated, order preserved.
14
- */
11
+ /** Node's bin folder, then what's worth keeping of `inherited`, then the system folders; no duplicates. */
15
12
  export declare function servicePath(nodeBin: string, inherited: string | undefined, opts?: ServicePathOptions): string;
16
- /** `which`: the first executable called `name` on `pathVar`, or undefined. */
13
+ /** The first executable called `name` on `pathVar`. */
17
14
  export declare function which(name: string, pathVar: string, isExec?: (p: string) => boolean): string | undefined;
18
- /**
19
- * Of `paths`, those that are no longer on disk.
20
- *
21
- * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
22
- * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
23
- * version is uninstalled: the unit still names an absolute interpreter path,
24
- * the supervisor still dutifully tries to exec it, and the failure arrives as
25
- * Paddock being absent with nothing in its own logs — because it never ran.
26
- */
15
+ /** Those of `paths` that aren't on disk any more: `status` and `doctor` warn about a unit's Node that has gone. */
27
16
  export declare function missingPaths(paths: readonly (string | undefined)[], exists?: (p: string) => boolean): string[];
@@ -1,52 +1,21 @@
1
- /**
2
- * The PATH a generated unit gives the service (SPEC §7).
3
- *
4
- * From edspencer/cli-contract — Local Review's `server/service/spec.ts`
5
- * (`PER_SHELL_PATTERNS`, `SYSTEM_DIRS`, `servicePath`, `which`) @ spec 0.1;
6
- * local changes: Paddock's own doc comments, `node:path`'s platform-aware
7
- * `delimiter`/`join` instead of hard-coded `:` and `path.posix`, and a
8
- * `missingFromPath` helper the CLI uses for its unit warnings. Wave C4 moves
9
- * this to `reference/service/` and re-syncs.
10
- *
11
- * ## Why a unit cannot just inherit the installing shell's PATH
12
- *
13
- * Paddock used to record `process.env.PATH` verbatim. That is the PATH of one
14
- * terminal at one moment, and several of its entries are *per-session*:
15
- *
16
- * - **fnm** puts a symlink per terminal under `fnm_multishells/<pid>_<ts>/bin`.
17
- * The directory is deleted when that shell exits — and fnm never cleans up
18
- * the stale ones, so a unit installed from an fnm shell names a `node`
19
- * directory that stops existing the moment the tab closes.
20
- * - **npx** adds its own per-invocation cache; `npm run` adds `node-gyp-bin`
21
- * and the package's `node_modules/.bin`. A long-lived background job has no
22
- * business looking in any of them for `git`.
23
- *
24
- * So the job's PATH is rebuilt: Node's own bin directory first (so the `node`
25
- * the unit runs can resolve its neighbours), then whatever of the installing
26
- * shell's PATH is worth keeping, then the system directories. Entries that are
27
- * relative, per-shell, or simply not there are dropped — a PATH naming
28
- * directories that do not exist is how `git` goes missing at some later login,
29
- * silently, with the symptom appearing in an agent's turn rather than at
30
- * install time.
31
- */
32
- import fs from "node:fs";
33
- import path from "node:path";
34
- /** PATH entries that belong to one shell session or one npm invocation. */
1
+ // From edspencer/cli-contract reference/service/path-env.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
2
+ // The PATH a unit gives the service (SPEC §7): Node's own bin folder first, then the installing shell's PATH without
3
+ // its per-shell entries, then the system folders. Only folders that exist are kept. Node built-ins only.
4
+ //
5
+ // A unit can't just record the shell's PATH: fnm puts a folder per terminal (`fnm_multishells/<pid>_<ts>/bin`) on it
6
+ // that goes when the terminal closes, and npx and `npm run` add their own. A unit naming those loses `node` or `git`
7
+ // at some later login, with the symptom showing up far from the cause.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ /** Entries that belong to one shell or one npm run. */
35
11
  export const PER_SHELL_PATTERNS = [
36
- /[/\\]fnm_multishells[/\\]/, // fnm: a symlink per terminal, never cleaned up
37
- /[/\\]_npx[/\\]/, // npx's per-invocation package cache
38
- /[/\\]node-gyp-bin$/, // added by `npm run` for the script it is running
39
- /[/\\]node_modules[/\\]\.bin$/, // likewise (and by npx): a package's tools, not the user's
40
- ];
41
- /** Where the usual tools live, after the inherited PATH. Only existing ones are kept. */
42
- export const SYSTEM_DIRS = [
43
- "/opt/homebrew/bin",
44
- "/usr/local/bin",
45
- "/usr/bin",
46
- "/bin",
47
- "/usr/sbin",
48
- "/sbin",
12
+ /[/\\]fnm_multishells[/\\]/, // fnm: a folder per terminal, never cleaned up
13
+ /[/\\]_npx[/\\]/, // npx's cache
14
+ /[/\\]node-gyp-bin$/, // added by `npm run`
15
+ /[/\\]node_modules[/\\]\.bin$/, // a package's own tools, added by `npm run` and npx
49
16
  ];
17
+ /** Where the usual tools live, after the inherited PATH. Only the ones that exist are kept. */
18
+ export const SYSTEM_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
50
19
  function isDir(p) {
51
20
  try {
52
21
  return fs.statSync(p).isDirectory();
@@ -66,55 +35,35 @@ function isExecutableFile(p) {
66
35
  return false;
67
36
  }
68
37
  }
69
- /** Kept on the job's PATH: non-empty, absolute, not per-shell, and an existing directory. */
70
- function keep(dir, exists) {
71
- if (dir === "" || !path.isAbsolute(dir))
72
- return false;
73
- if (PER_SHELL_PATTERNS.some((re) => re.test(dir) || re.test(`${dir}${path.sep}`)))
74
- return false;
75
- return exists(dir);
76
- }
77
- /**
78
- * The job's PATH: Node's bin directory, then the usable part of `inherited`,
79
- * then the system directories. De-duplicated, order preserved.
80
- */
38
+ /** Node's bin folder, then what's worth keeping of `inherited`, then the system folders; no duplicates. */
81
39
  export function servicePath(nodeBin, inherited, opts = {}) {
82
40
  const exists = opts.exists ?? isDir;
83
- const system = opts.systemDirs ?? SYSTEM_DIRS;
84
41
  const out = [];
85
42
  const add = (d) => {
86
- // Trailing separators would make `/usr/bin/` and `/usr/bin` two entries.
87
- const clean = d.length > 1 ? d.replace(/[/\\]+$/, "") : d;
88
- if (!out.includes(clean) && keep(clean, exists))
43
+ const clean = d.length > 1 ? d.replace(/[/\\]+$/, '') : d;
44
+ if (clean === '' || !path.isAbsolute(clean) || out.includes(clean))
45
+ return;
46
+ if (PER_SHELL_PATTERNS.some((re) => re.test(clean) || re.test(`${clean}${path.sep}`)))
47
+ return;
48
+ if (exists(clean))
89
49
  out.push(clean);
90
50
  };
91
51
  add(nodeBin);
92
- for (const d of (inherited ?? "").split(path.delimiter))
52
+ for (const d of (inherited ?? '').split(path.delimiter))
93
53
  add(d);
94
- for (const d of system)
54
+ for (const d of opts.systemDirs ?? SYSTEM_DIRS)
95
55
  add(d);
96
56
  return out.join(path.delimiter);
97
57
  }
98
- /** `which`: the first executable called `name` on `pathVar`, or undefined. */
58
+ /** The first executable called `name` on `pathVar`. */
99
59
  export function which(name, pathVar, isExec = isExecutableFile) {
100
60
  for (const dir of pathVar.split(path.delimiter)) {
101
- if (dir === "")
102
- continue;
103
- const p = path.join(dir, name);
104
- if (isExec(p))
105
- return p;
61
+ if (dir !== '' && isExec(path.join(dir, name)))
62
+ return path.join(dir, name);
106
63
  }
107
64
  return undefined;
108
65
  }
109
- /**
110
- * Of `paths`, those that are no longer on disk.
111
- *
112
- * This is the check behind `status`'s and (from wave G) `doctor`'s "the Node it
113
- * runs has gone" warning. It happens under nvm, fnm and volta the moment a Node
114
- * version is uninstalled: the unit still names an absolute interpreter path,
115
- * the supervisor still dutifully tries to exec it, and the failure arrives as
116
- * Paddock being absent with nothing in its own logs — because it never ran.
117
- */
66
+ /** Those of `paths` that aren't on disk any more: `status` and `doctor` warn about a unit's Node that has gone. */
118
67
  export function missingPaths(paths, exists = (p) => fs.existsSync(p)) {
119
- return paths.filter((p) => p !== undefined && p !== "" && !exists(p));
68
+ return paths.filter((p) => p !== undefined && p !== '' && !exists(p));
120
69
  }
@@ -1,3 +1,4 @@
1
+ import type { ServiceIdentity, ServiceSpec } from "./manager.js";
1
2
  /**
2
3
  * Reverse-DNS on macOS, plain on Linux. Both are stable identifiers a user may
3
4
  * end up typing (`launchctl print gui/501/net.edspencer.paddock`,
@@ -6,41 +7,13 @@
6
7
  */
7
8
  export declare const LAUNCHD_LABEL = "net.edspencer.paddock";
8
9
  export declare const SYSTEMD_UNIT = "paddock.service";
10
+ /** Who the reference's launchd and systemd writers are writing for. */
11
+ export declare const PADDOCK_SERVICE: ServiceIdentity;
9
12
  /** Default port, matching the server's own (#741). Kept here so the generated
10
13
  * unit can name a port even when the user passed no flag — a unit that relies
11
14
  * on a default is a unit whose port changes under it on upgrade. */
12
15
  export declare const DEFAULT_PORT = "7233";
13
- export interface ServiceSpec {
14
- /** Absolute path to the `node` that will run us — `process.execPath`. */
15
- nodePath: string;
16
- /**
17
- * Absolute path to `dist/cli/paddock.js`.
18
- *
19
- * NOT the `bin` shim. npm installs a bin as a symlink whose `#!/usr/bin/env
20
- * node` shebang has to find `node` on PATH, and launchd's PATH is a stub —
21
- * under a version manager there is no `node` on it at all. Invoking the
22
- * interpreter explicitly removes the lookup entirely.
23
- */
24
- scriptPath: string;
25
- /** Arguments after the script: always starts with the `serve` verb. */
26
- args: string[];
27
- /** A directory the service can sit in that it certainly owns. */
28
- workingDirectory: string;
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. */
42
- pathEnv: string;
43
- }
16
+ export type { ServiceSpec } from "./manager.js";
44
17
  export interface SpecInput {
45
18
  nodePath: string;
46
19
  scriptPath: string;
@@ -19,6 +19,15 @@ import { servicePath } from "./path-env.js";
19
19
  */
20
20
  export const LAUNCHD_LABEL = "net.edspencer.paddock";
21
21
  export const SYSTEMD_UNIT = "paddock.service";
22
+ /** Who the reference's launchd and systemd writers are writing for. */
23
+ export const PADDOCK_SERVICE = {
24
+ name: "Paddock",
25
+ tool: "paddock",
26
+ label: LAUNCHD_LABEL,
27
+ unit: SYSTEMD_UNIT,
28
+ description: "Paddock — persistent, resumable Claude Code sessions, by project",
29
+ documentation: "https://paddock.edspencer.net",
30
+ };
22
31
  /** Default port, matching the server's own (#741). Kept here so the generated
23
32
  * unit can name a port even when the user passed no flag — a unit that relies
24
33
  * on a default is a unit whose port changes under it on upgrade. */
@@ -1,110 +1,36 @@
1
- import { type ServiceSpec } from "./spec.js";
2
- import type { RunResult, Runner, ServiceBackend } from "./backend.js";
1
+ import type { Runner, RunResult, ServiceIdentity, ServiceManager, ServiceSpec } from './manager.js';
3
2
  /**
4
- * `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
5
- *
6
- * ## An injected home outranks the ambient variable
7
- *
8
- * The precedence is: an explicit `xdgConfigHome` argument, then an explicitly
9
- * **injected** `homeDir`, then — only when the caller named neither — the real
10
- * environment. That middle rung is the whole point, and getting it wrong was a
11
- * live bug rather than a style question.
12
- *
13
- * The first version read `process.env.XDG_CONFIG_HOME` before falling back to
14
- * `homeDir`. On any machine with that variable set — CI, and plenty of Linux
15
- * desktops — the `homeDir` parameter was therefore **a lie**: a caller passing a
16
- * throwaway directory still resolved to the real config home. Two things
17
- * followed. Test isolation quietly stopped working, so which assertion passed
18
- * depended on test ORDER rather than test content. And the suite wrote a real
19
- * `paddock.service` into the runner's actual config directory, outside every
20
- * temp dir it thought it was confined to — which on a contributor's machine
21
- * would have installed a unit they never asked for.
22
- *
23
- * So: passing a home is a statement about where home is, and it wins. The
24
- * ambient read survives only on the branch where nobody said otherwise, which
25
- * is the real-use branch and the one where honouring `XDG_CONFIG_HOME` is
26
- * correct. (`paddock.ts` threads the variable in explicitly as well, so the
27
- * production path does not depend on this fallback — it is here so that a
28
- * caller with no opinion still gets the right answer.)
3
+ * `${XDG_CONFIG_HOME:-~/.config}/systemd/user/<unit>`. A home passed in beats the ambient `XDG_CONFIG_HOME`: a test
4
+ * that names a throwaway home must not write a unit into the real config folder because the variable was set.
29
5
  */
30
- export declare function unitPath(homeDir?: string, xdgConfigHome?: string): string;
31
- /** One `ExecStart` word: specifiers and variables escaped, quoted when it has to be. */
6
+ export declare function unitPath(id: Pick<ServiceIdentity, 'unit'>, homeDir?: string, xdgConfigHome?: string): string;
7
+ /** One `ExecStart` word: specifiers and variables escaped, and quoted when it has to be. */
32
8
  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
- */
9
+ /** The words of an `ExecStart` line. Reads unquoted words too, so an older unit's `status` still works. */
41
10
  export declare function execWords(line: string): string[];
42
11
  /**
43
- * Render the unit.
44
- *
45
- * `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
46
- * `on-failure`, which for a service is the wrong default for the same reason it
47
- * was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
48
- * else that signals the user manager — was read as an intended stop and Paddock
49
- * stayed down.
50
- *
51
- * `always` does not make the unit unstoppable. systemd never restarts a unit it
52
- * was itself asked to stop, so `systemctl --user stop` (and therefore
53
- * `uninstall`) remains the clean, explicit way to put it down. That is the
54
- * difference between an exit code, which has to guess at intent, and a command,
55
- * which carries it.
56
- *
57
- * `WantedBy=default.target` is what makes `systemctl --user enable` mean
58
- * "start at login" for a user unit; `multi-user.target` is the system-unit
59
- * spelling and does nothing here.
60
- *
61
- * ## Logs go to a file, not the journal (SPEC §7)
12
+ * The unit.
62
13
  *
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.
14
+ * - `Restart=always`, matching launchd's `KeepAlive`: a server exits 0 on SIGTERM, so `on-failure` would leave it
15
+ * down after a logout signals the user's manager. systemd never restarts a unit it was asked to stop.
16
+ * - Both streams `append:` to one file rather than the journal, which is volatile on many distributions and would
17
+ * make `logs` a different program on each OS. systemd creates the file, not its folder, so `install` makes that.
18
+ * - Every `ExecStart` word is escaped and quoted where needed: paths with spaces are common (`/Users/Ed Spencer/…`).
83
19
  */
84
- export declare function renderUnit(spec: ServiceSpec): string;
85
- /** The log file an installed unit appends to, or undefined if it names none. */
20
+ export declare function renderUnit(id: ServiceIdentity, spec: ServiceSpec): string;
21
+ /** `ExecStart=`, read back as an argv. */
22
+ export declare function parseUnitArgv(text: string): string[];
23
+ /** The log the unit appends to, if it names one. */
86
24
  export declare function parseUnitLogPath(text: string): string | undefined;
87
- /**
88
- * Does an installed unit carry `Restart=always` (#872)?
89
- *
90
- * The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
91
- * value it has to reject is the `on-failure` this file used to write, which
92
- * leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
93
- * Anything unrecognised reads as "not the fixed shape", which at worst tells a
94
- * hand-edited unit's owner to re-run install.
95
- */
25
+ /** Whether the unit has `Restart=always` (the older `on-failure` doesn't count). */
96
26
  export declare function unitKeepsAlive(text: string): boolean;
97
- /** Read `ExecStart=` back out of an installed unit, split into an argv. */
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;
110
- export declare function createSystemdBackend(run: Runner, homeDir?: string, xdgConfigHome?: string): ServiceBackend;
27
+ /** What a `systemctl` failure means, in the manager's words where it has any. */
28
+ export declare function systemctlFailure(id: Pick<ServiceIdentity, 'name' | 'tool'>, args: string[], r: RunResult): string;
29
+ export interface SystemdOptions {
30
+ run: Runner;
31
+ homeDir?: string;
32
+ xdgConfigHome?: string;
33
+ /** Injected for tests: whose linger to ask about. */
34
+ user?: string;
35
+ }
36
+ export declare function createSystemd(id: ServiceIdentity, o: SystemdOptions): ServiceManager;