@edspencer/paddock 0.82.0 → 0.83.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 (114) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +29 -4
  3. package/packages/server/dist/cli/args.js +145 -7
  4. package/packages/server/dist/cli/busy-command.js +1 -1
  5. package/packages/server/dist/cli/busy.js +10 -12
  6. package/packages/server/dist/cli/doctor.d.ts +60 -0
  7. package/packages/server/dist/cli/doctor.js +580 -0
  8. package/packages/server/dist/cli/exit-codes.d.ts +21 -45
  9. package/packages/server/dist/cli/exit-codes.js +42 -45
  10. package/packages/server/dist/cli/output.js +5 -6
  11. package/packages/server/dist/cli/paddock.js +37 -12
  12. package/packages/server/dist/cli/service/index.d.ts +30 -2
  13. package/packages/server/dist/cli/service/index.js +163 -31
  14. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  15. package/packages/server/dist/cli/service/launchd.js +114 -304
  16. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  17. package/packages/server/dist/cli/service/logs.js +33 -75
  18. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  19. package/packages/server/dist/cli/service/manager.js +21 -0
  20. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  21. package/packages/server/dist/cli/service/path-env.js +30 -81
  22. package/packages/server/dist/cli/service/ready.d.ts +16 -23
  23. package/packages/server/dist/cli/service/ready.js +42 -0
  24. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  25. package/packages/server/dist/cli/service/spec.js +9 -0
  26. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  27. package/packages/server/dist/cli/service/systemd.js +118 -261
  28. package/packages/server/dist/cli/update/health.d.ts +48 -0
  29. package/packages/server/dist/cli/update/health.js +64 -0
  30. package/packages/server/dist/cli/update/install-method.d.ts +29 -76
  31. package/packages/server/dist/cli/update/install-method.js +103 -178
  32. package/packages/server/dist/cli/update/lock.d.ts +7 -12
  33. package/packages/server/dist/cli/update/lock.js +28 -62
  34. package/packages/server/dist/cli/update/package-manager.d.ts +39 -74
  35. package/packages/server/dist/cli/update/package-manager.js +95 -160
  36. package/packages/server/dist/cli/update/registry.d.ts +17 -54
  37. package/packages/server/dist/cli/update/registry.js +46 -89
  38. package/packages/server/dist/cli/update/semver.d.ts +18 -41
  39. package/packages/server/dist/cli/update/semver.js +74 -124
  40. package/packages/server/dist/cli/update/update-check.d.ts +15 -26
  41. package/packages/server/dist/cli/update/update-check.js +30 -60
  42. package/packages/server/dist/cli/update/update.d.ts +57 -91
  43. package/packages/server/dist/cli/update/update.js +230 -431
  44. package/packages/server/dist/cli/update-command.js +3 -5
  45. package/packages/server/dist/runtime-file.d.ts +1 -1
  46. package/packages/server/dist/runtime-file.js +9 -13
  47. package/packages/server/package.json +1 -1
  48. package/packages/web/dist/assets/{DeleteProjectDialog-CphdGB3R.js → DeleteProjectDialog-B07MKgv4.js} +1 -1
  49. package/packages/web/dist/assets/DiscoverPage-D5C2fkeQ.js +2 -0
  50. package/packages/web/dist/assets/{DiscoverView-B_kcF4sm.js → DiscoverView-DpAAyBEf.js} +1 -1
  51. package/packages/web/dist/assets/{InstanceConfigPage-CvUK5DKW.js → InstanceConfigPage-YX0I_jVA.js} +1 -1
  52. package/packages/web/dist/assets/{MarkdownRenderer-DvZ-j_Ao.js → MarkdownRenderer-C-CUuFhT.js} +1 -1
  53. package/packages/web/dist/assets/{ProjectRedirect-DayHvUH2.js → ProjectRedirect-qyXbTK56.js} +1 -1
  54. package/packages/web/dist/assets/{ProjectView-CCw83gUi.js → ProjectView-CguNOv8k.js} +3 -3
  55. package/packages/web/dist/assets/{ProjectsGrid-B3Klg2H4.js → ProjectsGrid-CH2Lz2Hf.js} +1 -1
  56. package/packages/web/dist/assets/{RootHome-BxcDvpgI.js → RootHome-CUGUc8za.js} +1 -1
  57. package/packages/web/dist/assets/{Surfaces-7UlhFBSG.js → Surfaces-11or1q_3.js} +1 -1
  58. package/packages/web/dist/assets/{arc-DNkh5o5Q.js → arc-CQHeCzrk.js} +1 -1
  59. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BrgN3oLF.js → architectureDiagram-3BPJPVTR-dGcuFxAa.js} +1 -1
  60. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-D05XOJSk.js → blockDiagram-GPEHLZMM-CGc9h97S.js} +1 -1
  61. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-CKaMBhMP.js → c4Diagram-AAUBKEIU-BpX0sqhH.js} +1 -1
  62. package/packages/web/dist/assets/channel-BAeygx-e.js +2 -0
  63. package/packages/web/dist/assets/{chunk-2J33WTMH-8hgadCQc.js → chunk-2J33WTMH-uORGdO9D.js} +1 -1
  64. package/packages/web/dist/assets/{chunk-4BX2VUAB-CBfEC-pu.js → chunk-4BX2VUAB-C8zI2PFy.js} +1 -1
  65. package/packages/web/dist/assets/{chunk-55IACEB6-DV1eMgHk.js → chunk-55IACEB6-D22Iv6j6.js} +1 -1
  66. package/packages/web/dist/assets/{chunk-727SXJPM-Cfgl5fxY.js → chunk-727SXJPM-DdbHXAtS.js} +1 -1
  67. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BXhqD295.js → chunk-AQP2D5EJ-BeKbob3a.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-FMBD7UC4-TFGOPtek.js → chunk-FMBD7UC4-DtLvFU_y.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-ND2GUHAM-CHFM665R.js → chunk-ND2GUHAM-BWJFo6B1.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-QZHKN3VN-CqEf6MMT.js → chunk-QZHKN3VN-DeFkNg3S.js} +1 -1
  71. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Cq2-sBMa.js +2 -0
  72. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Cq2-sBMa.js +2 -0
  73. package/packages/web/dist/assets/{core-DmEaKxBb.js → core-CTNNIHx5.js} +1 -1
  74. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BnGI6jkr.js → cose-bilkent-S5V4N54A-BmtlyA-v.js} +1 -1
  75. package/packages/web/dist/assets/{dagre-BM42HDAG-CKoPx61E.js → dagre-BM42HDAG-oS3rC9CI.js} +1 -1
  76. package/packages/web/dist/assets/{diagram-2AECGRRQ-mMmtlBl1.js → diagram-2AECGRRQ-BzIBqz6A.js} +1 -1
  77. package/packages/web/dist/assets/{diagram-5GNKFQAL-Cdz9Mtnt.js → diagram-5GNKFQAL-BgIT2g8R.js} +1 -1
  78. package/packages/web/dist/assets/{diagram-KO2AKTUF-DQxM3wYj.js → diagram-KO2AKTUF-DACT41Re.js} +1 -1
  79. package/packages/web/dist/assets/{diagram-LMA3HP47-BWrinPsq.js → diagram-LMA3HP47-BstA3UkI.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-OG6HWLK6-U6MYUve2.js → diagram-OG6HWLK6-Dp3Cn5n_.js} +1 -1
  81. package/packages/web/dist/assets/{documentTitle-DGjRXuTP.js → documentTitle-DRSQmA8q.js} +1 -1
  82. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-xqMCsXxY.js → erDiagram-TEJ5UH35-wf8PyGx_.js} +1 -1
  83. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-PMfy_czF.js → flowDiagram-I6XJVG4X-CJ4xx6EQ.js} +1 -1
  84. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-whhYYE4N.js → ganttDiagram-6RSMTGT7-z9obPrPM.js} +1 -1
  85. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-C51JW4tf.js → gitGraphDiagram-PVQCEYII-Cf4Cu7Tk.js} +1 -1
  86. package/packages/web/dist/assets/{index-CHOtvlnO.js → index-Bsv-raz2.js} +2 -2
  87. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-SH8cr905.js → infoDiagram-5YYISTIA-BIfuiYxX.js} +1 -1
  88. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-BjqaoU3U.js → ishikawaDiagram-YF4QCWOH-1pACxMTX.js} +1 -1
  89. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CBmdEVrd.js → journeyDiagram-JHISSGLW-XGbzfw-v.js} +1 -1
  90. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-U8njjMLZ.js → kanban-definition-UN3LZRKU-Bvl-nMak.js} +1 -1
  91. package/packages/web/dist/assets/{linear-DF66GPs1.js → linear-uNToO4bv.js} +1 -1
  92. package/packages/web/dist/assets/{mermaid.core-CouJz7Ph.js → mermaid.core-CrChvyj6.js} +4 -4
  93. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BYIf0oFt.js → mindmap-definition-RKZ34NQL-C6DaQ4Fm.js} +1 -1
  94. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-Cm8rgg09.js → pieDiagram-4H26LBE5-CEh8lKyF.js} +1 -1
  95. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-C3Il5kOH.js → quadrantDiagram-W4KKPZXB-z4zvICSM.js} +1 -1
  96. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-D3PE2nTe.js → requirementDiagram-4Y6WPE33-BBltkjsj.js} +1 -1
  97. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DdmvBM18.js → sankeyDiagram-5OEKKPKP-D0s95wQ4.js} +1 -1
  98. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-B8M_GAIq.js → sequenceDiagram-3UESZ5HK-kQa65Nbt.js} +1 -1
  99. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DZkXnK1_.js → stateDiagram-AJRCARHV-BqVIoyED.js} +1 -1
  100. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-B6OXxEra.js +2 -0
  101. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-IHXq7rqi.js → timeline-definition-PNZ67QCA-Bo5L62WI.js} +1 -1
  102. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-Dga7tLtT.js → vennDiagram-CIIHVFJN-CN9rH4lT.js} +1 -1
  103. package/packages/web/dist/assets/{wardley-L42UT6IY-CmcwnEJ-.js → wardley-L42UT6IY-BKhnD_W_.js} +1 -1
  104. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-zHuSLrD4.js → wardleyDiagram-YWT4CUSO-XHTbZzXj.js} +1 -1
  105. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-D93MU_ic.js → xychartDiagram-2RQKCTM6--t9XJLbR.js} +1 -1
  106. package/packages/web/dist/index.html +1 -1
  107. package/packages/web/dist/sw.js +1 -1
  108. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  109. package/packages/server/dist/cli/service/backend.js +0 -19
  110. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +0 -2
  111. package/packages/web/dist/assets/channel-JrEedxB8.js +0 -2
  112. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +0 -2
  113. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +0 -2
  114. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +0 -2
@@ -1,39 +1,20 @@
1
- /** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
1
+ /** How many lines `logs` prints when nobody says (SPEC §1). */
2
2
  export declare const DEFAULT_LOG_LINES = 50;
3
- /**
4
- * The last `lines` lines of `file`.
5
- *
6
- * Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
7
- * and can reach tens of megabytes between rotations, and reading all of it to
8
- * print fifty lines would make `logs` slower than the thing it is reporting on.
9
- * A window that happens to start mid-line drops that partial first line rather
10
- * than printing half of one.
11
- */
3
+ /** The last `lines` lines of `file`. A window that starts mid-line drops that fragment. */
12
4
  export declare function tailFile(file: string, lines: number): string;
13
5
  export interface FollowOptions {
14
- /** Where to start reading from — normally the file's size after the tail. */
6
+ /** Where to start: the file's size after the tail. */
15
7
  from: number;
16
- /** Injected for tests. */
17
8
  intervalMs?: number;
18
- /** Injected for tests: return false to stop the loop. */
9
+ /** Return false to stop. */
19
10
  keepGoing?: () => boolean;
20
11
  write?: (s: string) => void;
21
12
  }
22
13
  /**
23
- * Print what gets appended to `file`, from `from` onwards, until the process is
24
- * interrupted.
25
- *
26
- * Polling rather than `fs.watch`: the writer here is the supervisor's own file
27
- * descriptor, and `fs.watch` on an appended-to file is unreliable across
28
- * platforms (no events at all on some network filesystems, and a stream of
29
- * them on others). A 400 ms poll of the size is both portable and cheap.
30
- *
31
- * Rotation is handled by the one case that can actually happen: the server
32
- * rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
33
- * and the size goes DOWN. Seeing that, this resets to the new start rather than
34
- * reading past the end forever, which is what a naive follower does — it goes
35
- * quiet after the first rotation and never recovers.
14
+ * Prints what's appended to `file` until stopped. Polls the size (`fs.watch` on an appended file is unreliable across
15
+ * platforms). The tool rotates by copy-and-truncate, so the size goes down; seeing that, it starts again from 0
16
+ * rather than reading past the end for ever. A file that vanishes is waited for.
36
17
  */
37
- export declare function followFile(file: string, opts: FollowOptions): Promise<void>;
38
- /** The file's size, or 0 if it is not there — where a follow should start. */
18
+ export declare function followFile(file: string, o: FollowOptions): Promise<void>;
19
+ /** The file's size, or 0 if it isn't there: where a follow starts. */
39
20
  export declare function sizeOf(file: string): number;
@@ -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,26 +1,3 @@
1
- /**
2
- * "Is it actually up?" — the bounded readiness poll the service CLI uses before
3
- * it claims success (#873, #861).
4
- *
5
- * Every service command that starts something has the same failure mode without
6
- * this: the supervisor accepts the request and returns, so the CLI prints a URL
7
- * and a cheerful past tense while the server is still binding — or is already
8
- * crash-looping on a port clash, which `launchctl` and `systemctl` will both
9
- * happily report as "running" for the ten seconds between restarts. A message
10
- * that can sit over a crash-loop and still say "running at <url>" is not a
11
- * status line, it is a guess.
12
- *
13
- * So the check is deliberately end-to-end: an HTTP request to the URL a human
14
- * would open, not a pid lookup. `/api/health` is the probe because it is in
15
- * `ALWAYS_PUBLIC_PATHS` (`auth.ts`) — it answers whatever `PADDOCK_AUTH_MODE`
16
- * the installed unit runs with, so this does not quietly stop working for the
17
- * people who configured a credential.
18
- *
19
- * The result is a tri-state on purpose. "I could not confirm it" is a real and
20
- * common answer — an unusual bind host, a slow first boot — and it is not the
21
- * same claim as "it failed". Callers print it as a caveat, not an error, and
22
- * point at the logs.
23
- */
24
1
  /** Where the poll got to when it stopped. */
25
2
  export type Readiness = "ready" | "timeout";
26
3
  /** What `/api/health` said, when it said anything (SPEC §8). */
@@ -111,3 +88,19 @@ export type VersionVerdict = {
111
88
  export declare function waitForVersion(opts: ReadyOptions & {
112
89
  version: string;
113
90
  }): Promise<VersionVerdict>;
91
+ /** Is anything accepting connections at host:port? A second at most. */
92
+ export declare function isListening(host: string, port: number): Promise<boolean>;
93
+ /**
94
+ * Is what answers at `url` a Paddock, and which version? Undefined when nothing
95
+ * answers `/api/health` there.
96
+ *
97
+ * `/api/health`'s `{ ok, version }` is the CLI contract's, so every one of the
98
+ * six tools answers it alike; `/api/busy`'s `counts.chat` and
99
+ * `counts.background` are Paddock's own. A Paddock older than 0.81 has no
100
+ * `/api/busy`, and reads as not-Paddock here — callers that can tell it apart
101
+ * some other way (an installed unit on that port) should.
102
+ */
103
+ export declare function identifyPaddock(url: string, fetchImpl?: typeof fetch): Promise<{
104
+ paddock: boolean;
105
+ version?: string;
106
+ } | undefined>;
@@ -21,6 +21,7 @@
21
21
  * same claim as "it failed". Callers print it as a caveat, not an error, and
22
22
  * point at the logs.
23
23
  */
24
+ import net from "node:net";
24
25
  /**
25
26
  * Ask `<url>/api/health` once, and say what it answered.
26
27
  *
@@ -134,3 +135,44 @@ export async function waitForVersion(opts) {
134
135
  await sleep(intervalMs);
135
136
  }
136
137
  }
138
+ /** Is anything accepting connections at host:port? A second at most. */
139
+ export function isListening(host, port) {
140
+ return new Promise((resolve) => {
141
+ const s = net.connect({ host, port });
142
+ s.setTimeout(1000);
143
+ s.once("connect", () => {
144
+ s.destroy();
145
+ resolve(true);
146
+ });
147
+ s.once("timeout", () => {
148
+ s.destroy();
149
+ resolve(false);
150
+ });
151
+ s.once("error", () => resolve(false));
152
+ });
153
+ }
154
+ /**
155
+ * Is what answers at `url` a Paddock, and which version? Undefined when nothing
156
+ * answers `/api/health` there.
157
+ *
158
+ * `/api/health`'s `{ ok, version }` is the CLI contract's, so every one of the
159
+ * six tools answers it alike; `/api/busy`'s `counts.chat` and
160
+ * `counts.background` are Paddock's own. A Paddock older than 0.81 has no
161
+ * `/api/busy`, and reads as not-Paddock here — callers that can tell it apart
162
+ * some other way (an installed unit on that port) should.
163
+ */
164
+ export async function identifyPaddock(url, fetchImpl = fetch) {
165
+ const health = await fetchHealth(url, { fetchImpl });
166
+ if (health === undefined)
167
+ return undefined;
168
+ const version = health.version !== undefined ? { version: health.version } : {};
169
+ try {
170
+ const res = await fetchImpl(`${url}/api/busy`, { signal: AbortSignal.timeout(2_000) });
171
+ const body = (await res.json());
172
+ const counts = body.counts ?? {};
173
+ return { paddock: health.ok && typeof counts.chat === "number" && "background" in counts, ...version };
174
+ }
175
+ catch {
176
+ return { paddock: false, ...version };
177
+ }
178
+ }
@@ -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;