@edspencer/paddock 0.81.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 (111) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +56 -31
  3. package/packages/server/dist/cli/args.js +210 -24
  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/exit-codes.d.ts +21 -45
  7. package/packages/server/dist/cli/exit-codes.js +42 -45
  8. package/packages/server/dist/cli/output.js +5 -6
  9. package/packages/server/dist/cli/paddock.js +60 -30
  10. package/packages/server/dist/cli/service/index.d.ts +54 -2
  11. package/packages/server/dist/cli/service/index.js +198 -25
  12. package/packages/server/dist/cli/service/launchd.d.ts +31 -84
  13. package/packages/server/dist/cli/service/launchd.js +104 -304
  14. package/packages/server/dist/cli/service/logs.d.ts +9 -28
  15. package/packages/server/dist/cli/service/logs.js +33 -75
  16. package/packages/server/dist/cli/service/manager.d.ts +93 -0
  17. package/packages/server/dist/cli/service/manager.js +21 -0
  18. package/packages/server/dist/cli/service/path-env.d.ts +7 -18
  19. package/packages/server/dist/cli/service/path-env.js +30 -81
  20. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  21. package/packages/server/dist/cli/service/ready.js +35 -0
  22. package/packages/server/dist/cli/service/spec.d.ts +4 -31
  23. package/packages/server/dist/cli/service/spec.js +9 -0
  24. package/packages/server/dist/cli/service/systemd.d.ts +27 -101
  25. package/packages/server/dist/cli/service/systemd.js +106 -235
  26. package/packages/server/dist/cli/update/health.d.ts +48 -0
  27. package/packages/server/dist/cli/update/health.js +64 -0
  28. package/packages/server/dist/cli/update/install-method.d.ts +84 -0
  29. package/packages/server/dist/cli/update/install-method.js +183 -0
  30. package/packages/server/dist/cli/update/lock.d.ts +35 -0
  31. package/packages/server/dist/cli/update/lock.js +74 -0
  32. package/packages/server/dist/cli/update/package-manager.d.ts +74 -0
  33. package/packages/server/dist/cli/update/package-manager.js +156 -0
  34. package/packages/server/dist/cli/update/registry.d.ts +52 -0
  35. package/packages/server/dist/cli/update/registry.js +102 -0
  36. package/packages/server/dist/cli/update/semver.d.ts +45 -0
  37. package/packages/server/dist/cli/update/semver.js +182 -0
  38. package/packages/server/dist/cli/update/update-check.d.ts +69 -0
  39. package/packages/server/dist/cli/update/update-check.js +86 -0
  40. package/packages/server/dist/cli/update/update.d.ts +126 -0
  41. package/packages/server/dist/cli/update/update.js +408 -0
  42. package/packages/server/dist/cli/update-command.d.ts +20 -0
  43. package/packages/server/dist/cli/update-command.js +111 -0
  44. package/packages/server/package.json +1 -1
  45. package/packages/web/dist/assets/{DeleteProjectDialog-D8oIJ8HC.js → DeleteProjectDialog-CgHpQctt.js} +1 -1
  46. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +2 -0
  47. package/packages/web/dist/assets/{DiscoverView-o0KCr3aB.js → DiscoverView-BKqI0I1E.js} +1 -1
  48. package/packages/web/dist/assets/{InstanceConfigPage-RSJz7oL3.js → InstanceConfigPage-DL3raCK1.js} +1 -1
  49. package/packages/web/dist/assets/{MarkdownRenderer-39mafH8b.js → MarkdownRenderer-DcjEuf3s.js} +1 -1
  50. package/packages/web/dist/assets/{ProjectRedirect-Bq7GVguY.js → ProjectRedirect-DerkA-nE.js} +1 -1
  51. package/packages/web/dist/assets/{ProjectView-BoBIru1g.js → ProjectView-NwfC3_w7.js} +3 -3
  52. package/packages/web/dist/assets/{ProjectsGrid-mJ4gqbr6.js → ProjectsGrid-BeORa0UH.js} +1 -1
  53. package/packages/web/dist/assets/{RootHome-C0kwFyYp.js → RootHome-BYJs8qJ6.js} +1 -1
  54. package/packages/web/dist/assets/{Surfaces-MkhXlB8z.js → Surfaces-w-kEaAYU.js} +1 -1
  55. package/packages/web/dist/assets/{arc-DExlwAwO.js → arc-Bv56hasM.js} +1 -1
  56. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CZly3W79.js → architectureDiagram-3BPJPVTR-BHEknx9i.js} +1 -1
  57. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-AgES73i3.js → blockDiagram-GPEHLZMM-DtxTMrPX.js} +1 -1
  58. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-N9mD8Pfk.js → c4Diagram-AAUBKEIU-DRpEYOlM.js} +1 -1
  59. package/packages/web/dist/assets/channel-CdIiAqY2.js +2 -0
  60. package/packages/web/dist/assets/{chunk-2J33WTMH-Dsx_kfUl.js → chunk-2J33WTMH-S4s7GCg7.js} +1 -1
  61. package/packages/web/dist/assets/{chunk-4BX2VUAB-BQjqcjPj.js → chunk-4BX2VUAB-BkWVPLf1.js} +1 -1
  62. package/packages/web/dist/assets/{chunk-55IACEB6-CCxpYQpY.js → chunk-55IACEB6-4-FQp01F.js} +1 -1
  63. package/packages/web/dist/assets/{chunk-727SXJPM-DpFRdnUa.js → chunk-727SXJPM-BeyDWdIb.js} +1 -1
  64. package/packages/web/dist/assets/{chunk-AQP2D5EJ-D2BAlzoO.js → chunk-AQP2D5EJ-ZSxKy745.js} +1 -1
  65. package/packages/web/dist/assets/{chunk-FMBD7UC4-BFaMRzUF.js → chunk-FMBD7UC4-Bq8uV9j-.js} +1 -1
  66. package/packages/web/dist/assets/{chunk-ND2GUHAM-B9NGJ9xZ.js → chunk-ND2GUHAM-BOFsRyHk.js} +1 -1
  67. package/packages/web/dist/assets/{chunk-QZHKN3VN-CF1IHyMS.js → chunk-QZHKN3VN-uZ6oNc4m.js} +1 -1
  68. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +2 -0
  69. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +2 -0
  70. package/packages/web/dist/assets/{core-XjElsvQ7.js → core-CYuJAM2U.js} +1 -1
  71. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CF972Flx.js → cose-bilkent-S5V4N54A-DIzkVSFb.js} +1 -1
  72. package/packages/web/dist/assets/{dagre-BM42HDAG-DsneI8YE.js → dagre-BM42HDAG-CHR6jJBv.js} +1 -1
  73. package/packages/web/dist/assets/{diagram-2AECGRRQ-BHyrVzIk.js → diagram-2AECGRRQ-BAFXCH8Q.js} +1 -1
  74. package/packages/web/dist/assets/{diagram-5GNKFQAL-Dj4q8xEi.js → diagram-5GNKFQAL-BJ5t5gbz.js} +1 -1
  75. package/packages/web/dist/assets/{diagram-KO2AKTUF-BHe3Puv_.js → diagram-KO2AKTUF-D9Xt0IVg.js} +1 -1
  76. package/packages/web/dist/assets/{diagram-LMA3HP47-wzt6G2FE.js → diagram-LMA3HP47-Bq38u6sy.js} +1 -1
  77. package/packages/web/dist/assets/{diagram-OG6HWLK6-CZMbMRJq.js → diagram-OG6HWLK6-CMQVqkpi.js} +1 -1
  78. package/packages/web/dist/assets/{documentTitle-BwTIA8rI.js → documentTitle-M1lrfXRU.js} +1 -1
  79. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-Dtu14BL1.js → erDiagram-TEJ5UH35-C63M1g9b.js} +1 -1
  80. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-T34ql9j0.js → flowDiagram-I6XJVG4X-D3j8ACN7.js} +1 -1
  81. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DQLOeCXK.js → ganttDiagram-6RSMTGT7-B1Fd1A2Y.js} +1 -1
  82. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-Bk11zea6.js → gitGraphDiagram-PVQCEYII-CEhZhDdR.js} +1 -1
  83. package/packages/web/dist/assets/{index-CvaWErFv.js → index-1E9yHciS.js} +2 -2
  84. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CsE83lu0.js → infoDiagram-5YYISTIA-Ctquaew6.js} +1 -1
  85. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DNVgA93n.js → ishikawaDiagram-YF4QCWOH-DV4GvA1R.js} +1 -1
  86. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-CLpSNjM3.js → journeyDiagram-JHISSGLW-C_Cy9waA.js} +1 -1
  87. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BRzuXcKr.js → kanban-definition-UN3LZRKU-CGr9ctEf.js} +1 -1
  88. package/packages/web/dist/assets/{linear-BxOawONt.js → linear-D3U8NOkt.js} +1 -1
  89. package/packages/web/dist/assets/{mermaid.core-Dvs1QCcz.js → mermaid.core-BiArdWKV.js} +4 -4
  90. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-Dmt7pYwF.js → mindmap-definition-RKZ34NQL-CZIpzj8Z.js} +1 -1
  91. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BHiZaJD-.js → pieDiagram-4H26LBE5-1MaeS3rt.js} +1 -1
  92. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-U0A1noRE.js → quadrantDiagram-W4KKPZXB-B5pgQ54e.js} +1 -1
  93. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CZLsr6yi.js → requirementDiagram-4Y6WPE33-BNnxrpGr.js} +1 -1
  94. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-BTlLGILU.js → sankeyDiagram-5OEKKPKP-DsxVH-hM.js} +1 -1
  95. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DpSS_Gb1.js → sequenceDiagram-3UESZ5HK-Ke9LcFZI.js} +1 -1
  96. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-D9srE-tV.js → stateDiagram-AJRCARHV-UJzmflND.js} +1 -1
  97. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +2 -0
  98. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BYQ5CKab.js → timeline-definition-PNZ67QCA-BRjNALVI.js} +1 -1
  99. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B4W2CtSV.js → vennDiagram-CIIHVFJN-ClMAspxz.js} +1 -1
  100. package/packages/web/dist/assets/{wardley-L42UT6IY--XUV7GvD.js → wardley-L42UT6IY-UPv4JEmz.js} +1 -1
  101. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-Bl2VIEGY.js → wardleyDiagram-YWT4CUSO-BgYJbdEx.js} +1 -1
  102. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bj2H9Rcf.js → xychartDiagram-2RQKCTM6-Bfk5HjGx.js} +1 -1
  103. package/packages/web/dist/index.html +1 -1
  104. package/packages/web/dist/sw.js +1 -1
  105. package/packages/server/dist/cli/service/backend.d.ts +0 -115
  106. package/packages/server/dist/cli/service/backend.js +0 -19
  107. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +0 -2
  108. package/packages/web/dist/assets/channel-ExsAG7qT.js +0 -2
  109. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +0 -2
  110. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +0 -2
  111. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.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
  }
@@ -81,3 +81,33 @@ export declare const READY_TIMEOUT_LABEL = "20 seconds";
81
81
  * would park here well past `timeoutMs`.
82
82
  */
83
83
  export declare function waitForReady(opts: ReadyOptions): Promise<Readiness>;
84
+ /** What {@link waitForVersion} concluded (CLI contract SPEC §5 step 12). */
85
+ export type VersionVerdict = {
86
+ state: "ok";
87
+ version: string;
88
+ }
89
+ /** Something answers, and it is not the version we were waiting for. */
90
+ | {
91
+ state: "wrong-version";
92
+ version: string;
93
+ } | {
94
+ state: "no-answer";
95
+ };
96
+ /**
97
+ * Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
98
+ *
99
+ * The version is the point, and it is what makes `update`'s health check mean
100
+ * something. "The URL answers" is satisfied by the OLD server that has not
101
+ * finished shutting down, by a sibling instance on the same port, and by a
102
+ * supervisor that restarted the old unit because the new one crashed — all
103
+ * three of which are exactly the failures step 13's rollback exists for. An
104
+ * update that polled for a 200 would call every one of them a success.
105
+ *
106
+ * The distinction between "no answer" and "the wrong version answers" is kept
107
+ * because the two read completely differently in a failure report: one is a
108
+ * server that did not come up, the other is a server that came up as something
109
+ * else.
110
+ */
111
+ export declare function waitForVersion(opts: ReadyOptions & {
112
+ version: string;
113
+ }): Promise<VersionVerdict>;
@@ -99,3 +99,38 @@ export async function waitForReady(opts) {
99
99
  await sleep(intervalMs);
100
100
  }
101
101
  }
102
+ /**
103
+ * Poll until `/api/health` reports exactly `version` (SPEC §5 step 12).
104
+ *
105
+ * The version is the point, and it is what makes `update`'s health check mean
106
+ * something. "The URL answers" is satisfied by the OLD server that has not
107
+ * finished shutting down, by a sibling instance on the same port, and by a
108
+ * supervisor that restarted the old unit because the new one crashed — all
109
+ * three of which are exactly the failures step 13's rollback exists for. An
110
+ * update that polled for a 200 would call every one of them a success.
111
+ *
112
+ * The distinction between "no answer" and "the wrong version answers" is kept
113
+ * because the two read completely differently in a failure report: one is a
114
+ * server that did not come up, the other is a server that came up as something
115
+ * else.
116
+ */
117
+ export async function waitForVersion(opts) {
118
+ const { url, version, timeoutMs = READY_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS, fetchImpl = fetch, now = () => Date.now(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), } = opts;
119
+ const deadline = now() + timeoutMs;
120
+ let last;
121
+ for (;;) {
122
+ const health = await fetchHealth(url, {
123
+ fetchImpl,
124
+ timeoutMs: Math.max(250, Math.min(2_000, deadline - now())),
125
+ });
126
+ if (health?.version !== undefined) {
127
+ if (health.version === version)
128
+ return { state: "ok", version };
129
+ last = health.version;
130
+ }
131
+ if (deadline - now() <= 0) {
132
+ return last !== undefined ? { state: "wrong-version", version: last } : { state: "no-answer" };
133
+ }
134
+ await sleep(intervalMs);
135
+ }
136
+ }
@@ -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. */