@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,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;
@@ -1,152 +1,56 @@
1
+ // From edspencer/cli-contract reference/service/systemd.ts @ 0.2; local changes: `.ts` import specifiers → `.js`.
2
+ // Linux: a systemd user unit (`systemctl --user`, SPEC §7), running as the user. Without linger the user's manager,
3
+ // and the service with it, stops at their last logout, SSH sessions included; `linger()` asks, and the tool prints
4
+ // the `sudo loginctl enable-linger` line rather than running it. Node built-ins only.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
1
8
  /**
2
- * The Linux backend: a `systemd --user` unit (#796).
3
- *
4
- * ## Not the server story
5
- *
6
- * Paddock already documents itself under systemd — `guides/proxmox-lxc.md`
7
- * Path B, provisioned by the `paddock-deploy` Ansible recipes. That is a
8
- * SYSTEM unit, running as a service user, behind a reverse proxy, with a token
9
- * in its environment. This is the other one: a user unit, running as you, on
10
- * loopback, using the Claude login you already have. They should not be
11
- * confused, and this module writes nothing outside `~/.config/systemd/user`.
12
- *
13
- * ## Lingering
14
- *
15
- * A `--user` manager is started at your first login and stopped at your last
16
- * logout, so by default the unit dies when you log out — including when you log
17
- * out of an SSH session, which is how most people meet this. `loginctl
18
- * enable-linger <user>` keeps the manager (and therefore Paddock) alive.
19
- *
20
- * That is called out in the install output rather than being run for the user:
21
- * lingering is a real change to how the machine behaves after they leave it,
22
- * and it needs a privilege this command otherwise never asks for.
23
- */
24
- import fs from "node:fs";
25
- import os from "node:os";
26
- import path from "node:path";
27
- import { SYSTEMD_UNIT } from "./spec.js";
28
- /**
29
- * `~/.config/systemd/user/paddock.service`, honouring `XDG_CONFIG_HOME`.
30
- *
31
- * ## An injected home outranks the ambient variable
32
- *
33
- * The precedence is: an explicit `xdgConfigHome` argument, then an explicitly
34
- * **injected** `homeDir`, then — only when the caller named neither — the real
35
- * environment. That middle rung is the whole point, and getting it wrong was a
36
- * live bug rather than a style question.
37
- *
38
- * The first version read `process.env.XDG_CONFIG_HOME` before falling back to
39
- * `homeDir`. On any machine with that variable set — CI, and plenty of Linux
40
- * desktops — the `homeDir` parameter was therefore **a lie**: a caller passing a
41
- * throwaway directory still resolved to the real config home. Two things
42
- * followed. Test isolation quietly stopped working, so which assertion passed
43
- * depended on test ORDER rather than test content. And the suite wrote a real
44
- * `paddock.service` into the runner's actual config directory, outside every
45
- * temp dir it thought it was confined to — which on a contributor's machine
46
- * would have installed a unit they never asked for.
47
- *
48
- * So: passing a home is a statement about where home is, and it wins. The
49
- * ambient read survives only on the branch where nobody said otherwise, which
50
- * is the real-use branch and the one where honouring `XDG_CONFIG_HOME` is
51
- * correct. (`paddock.ts` threads the variable in explicitly as well, so the
52
- * production path does not depend on this fallback — it is here so that a
53
- * caller with no opinion still gets the right answer.)
9
+ * `${XDG_CONFIG_HOME:-~/.config}/systemd/user/<unit>`. A home passed in beats the ambient `XDG_CONFIG_HOME`: a test
10
+ * that names a throwaway home must not write a unit into the real config folder because the variable was set.
54
11
  */
55
- export function unitPath(homeDir, xdgConfigHome) {
56
- const base = xdgConfigHome ??
57
- (homeDir !== undefined
58
- ? path.join(homeDir, ".config")
59
- : (process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")));
60
- return path.join(base, "systemd", "user", SYSTEMD_UNIT);
12
+ export function unitPath(id, homeDir, xdgConfigHome) {
13
+ const base = xdgConfigHome ?? (homeDir !== undefined ? path.join(homeDir, '.config') : (process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config')));
14
+ return path.join(base, 'systemd', 'user', id.unit);
61
15
  }
62
- /**
63
- * Unit-file escaping, ported from Local Review's `server/service/systemd.ts`
64
- * (`pct`, `cEscape`, `cUnescape`, `execWord`, `execWords`) @ cli-contract spec
65
- * 0.1; local changes: doc comments only. Wave C4 moves these to
66
- * `reference/service/` and re-syncs.
67
- *
68
- * `%` starts a specifier EVERYWHERE in a unit file, so it is doubled; `$`
69
- * starts a variable in `ExecStart`, so it is doubled there too.
70
- */
71
- const pct = (s) => s.replace(/%/g, "%%");
72
- const cEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t");
73
- const cUnescape = (s) => s.replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
74
- /** One `ExecStart` word: specifiers and variables escaped, quoted when it has to be. */
16
+ // `%` starts a specifier anywhere in a unit file, so it's doubled; `$` starts a variable in `ExecStart`, so it's
17
+ // doubled there too.
18
+ const pct = (s) => s.replace(/%/g, '%%');
19
+ const cEscape = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
20
+ const cUnescape = (s) => s.replace(/\\(.)/g, (_, c) => (c === 'n' ? '\n' : c === 't' ? '\t' : c));
21
+ /** One `ExecStart` word: specifiers and variables escaped, and quoted when it has to be. */
75
22
  export function execWord(a) {
76
- const s = pct(a).replace(/\$/g, "$$$$");
77
- return s === "" || /[\s"'\\;]/.test(s) ? `"${cEscape(s)}"` : s;
23
+ const s = pct(a).replace(/\$/g, '$$$$');
24
+ return s === '' || /[\s"'\\;]/.test(s) ? `"${cEscape(s)}"` : s;
78
25
  }
79
- /**
80
- * The other way: the words of an `ExecStart` line.
81
- *
82
- * Reads both shapes on purpose. A unit written before #796's quoting is a bare
83
- * whitespace-separated line, and its owner is exactly the person whose `status`
84
- * has to keep working — so unquoted words pass through unchanged, and quoted
85
- * ones are unescaped.
86
- */
26
+ /** The words of an `ExecStart` line. Reads unquoted words too, so an older unit's `status` still works. */
87
27
  export function execWords(line) {
88
- const out = [];
89
- for (const m of line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)) {
90
- const w = m[1] !== undefined ? cUnescape(m[1]) : m[2];
91
- out.push(w.replace(/\$\$/g, "$").replace(/%%/g, "%"));
92
- }
93
- return out;
28
+ return [...line.matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)].map((m) => (m[1] !== undefined ? cUnescape(m[1]) : m[2]).replace(/\$\$/g, '$').replace(/%%/g, '%'));
94
29
  }
95
30
  /**
96
- * Render the unit.
97
- *
98
- * `Restart=always`, matching launchd's `KeepAlive=<true/>` (#872). It was
99
- * `on-failure`, which for a service is the wrong default for the same reason it
100
- * was wrong there: `start.ts` exits `0` on SIGTERM, so a logout — or anything
101
- * else that signals the user manager — was read as an intended stop and Paddock
102
- * stayed down.
103
- *
104
- * `always` does not make the unit unstoppable. systemd never restarts a unit it
105
- * was itself asked to stop, so `systemctl --user stop` (and therefore
106
- * `uninstall`) remains the clean, explicit way to put it down. That is the
107
- * difference between an exit code, which has to guess at intent, and a command,
108
- * which carries it.
109
- *
110
- * `WantedBy=default.target` is what makes `systemctl --user enable` mean
111
- * "start at login" for a user unit; `multi-user.target` is the system-unit
112
- * spelling and does nothing here.
31
+ * The unit.
113
32
  *
114
- * ## Logs go to a file, not the journal (SPEC §7)
115
- *
116
- * `StandardOutput=append:` / `StandardError=append:` name one file, the same
117
- * one launchd's two keys name. The journal was the obvious choice and the wrong
118
- * one for three reasons: `journalctl --user` shows nothing at all on a distro
119
- * whose journal is volatile (the output is gone after a reboot, which is
120
- * exactly when someone looks); it makes "where are the logs" have a different
121
- * answer on each platform, so `paddock service logs` would be two
122
- * implementations; and it rules out Windows, which the contract requires
123
- * nothing rule out.
124
- *
125
- * The directory has to exist first — systemd creates the file, not its parent —
126
- * which `install` below does.
127
- *
128
- * ## Every ExecStart word is quoted
129
- *
130
- * `%` starts a specifier anywhere in a unit file and `$` starts a variable in
131
- * `ExecStart`, so both are escaped; a word containing whitespace, a quote or a
132
- * backslash is quoted. Paths with a space in them are the common case
133
- * (`/Users/Ed Spencer/.paddock`), and the old unqualified `join(" ")` wrote a
134
- * unit systemd then split into the wrong arguments — a service that fails at
135
- * login having printed nothing.
33
+ * - `Restart=always`, matching launchd's `KeepAlive`: a server exits 0 on SIGTERM, so `on-failure` would leave it
34
+ * down after a logout signals the user's manager. systemd never restarts a unit it was asked to stop.
35
+ * - Both streams `append:` to one file rather than the journal, which is volatile on many distributions and would
36
+ * make `logs` a different program on each OS. systemd creates the file, not its folder, so `install` makes that.
37
+ * - Every `ExecStart` word is escaped and quoted where needed: paths with spaces are common (`/Users/Ed Spencer/…`).
136
38
  */
137
- export function renderUnit(spec) {
138
- const exec = [spec.nodePath, spec.scriptPath, ...spec.args].map(execWord).join(" ");
139
- return `# Generated by Paddock — \`paddock service install\` rewrites this file.
39
+ export function renderUnit(id, spec) {
40
+ const exec = [spec.nodePath, spec.scriptPath, ...spec.args].map(execWord).join(' ');
41
+ const env = Object.entries({ PATH: spec.pathEnv, ...spec.env })
42
+ .map(([k, v]) => `Environment="${cEscape(pct(`${k}=${v}`))}"`)
43
+ .join('\n');
44
+ return `# Generated by ${id.name}: \`${id.tool} service install\` rewrites this file.
140
45
  # Node: ${spec.nodePath}
141
46
  [Unit]
142
- Description=Paddock — persistent, resumable Claude Code sessions, by project
143
- Documentation=https://paddock.edspencer.net
144
-
47
+ Description=${id.description}
48
+ ${id.documentation !== undefined ? `Documentation=${id.documentation}\n` : ''}
145
49
  [Service]
146
50
  Type=simple
147
51
  ExecStart=${exec}
148
52
  WorkingDirectory=${pct(spec.workingDirectory)}
149
- Environment="PATH=${cEscape(pct(spec.pathEnv))}"
53
+ ${env}
150
54
  StandardOutput=append:${pct(spec.logPath)}
151
55
  StandardError=append:${pct(spec.logPath)}
152
56
  Restart=always
@@ -156,133 +60,101 @@ RestartSec=10
156
60
  WantedBy=default.target
157
61
  `;
158
62
  }
159
- /** The log file an installed unit appends to, or undefined if it names none. */
63
+ /** `ExecStart=`, read back as an argv. */
64
+ export function parseUnitArgv(text) {
65
+ const m = /^ExecStart=(.*)$/m.exec(text);
66
+ return m === null ? [] : execWords(m[1].trim());
67
+ }
68
+ /** The log the unit appends to, if it names one. */
160
69
  export function parseUnitLogPath(text) {
161
70
  const m = /^StandardOutput=append:(.*)$/m.exec(text);
162
- return m === null ? undefined : m[1].trim().replace(/%%/g, "%");
71
+ return m === null ? undefined : m[1].trim().replace(/%%/g, '%');
163
72
  }
164
- /**
165
- * Does an installed unit carry `Restart=always` (#872)?
166
- *
167
- * The counterpart to `plistKeepsAlive`, and narrow for the same reason: the
168
- * value it has to reject is the `on-failure` this file used to write, which
169
- * leaves Paddock down after the graceful exit `start.ts` performs on SIGTERM.
170
- * Anything unrecognised reads as "not the fixed shape", which at worst tells a
171
- * hand-edited unit's owner to re-run install.
172
- */
73
+ /** Whether the unit has `Restart=always` (the older `on-failure` doesn't count). */
173
74
  export function unitKeepsAlive(text) {
174
75
  return /^Restart=always\s*$/m.test(text);
175
76
  }
176
- /** Read `ExecStart=` back out of an installed unit, split into an argv. */
177
- export function parseUnitArgv(text) {
178
- const m = /^ExecStart=(.*)$/m.exec(text);
179
- if (m === null)
180
- return [];
181
- return execWords(m[1].trim());
182
- }
183
- /**
184
- * What a `systemctl` failure means, in the manager's own words where it has any.
185
- *
186
- * The `status === null` branch is the one QA found: `spawnSync` reports a
187
- * missing binary that way, with no output at all, so the message was
188
- * `systemctl --user enable --now failed (exit null).` and then a blank line —
189
- * which tells someone on a container with no systemd nothing whatsoever. That
190
- * is also the single likeliest way this command fails on a machine it was never
191
- * going to work on.
192
- */
193
- export function systemctlFailure(args, r) {
77
+ /** What a `systemctl` failure means, in the manager's words where it has any. */
78
+ export function systemctlFailure(id, args, r) {
79
+ // spawnSync reports a missing binary as status null with no output, which would print nothing useful.
194
80
  if (r.status === null || r.status === 127) {
195
- return ("systemctl is not available, so Paddock cannot install a service here.\n" +
196
- "It needs systemd's per-user instance (`systemctl --user`) — a container " +
197
- "or a non-systemd distribution has none.\n" +
198
- "Run `paddock serve` in a terminal, or keep it up with whatever this " +
199
- "machine uses.");
81
+ return (`systemctl isn't available, so ${id.name} can't install a service here: it needs systemd's per-user instance ` +
82
+ `(\`systemctl --user\`), which a container or a distribution without systemd doesn't have. Run \`${id.tool} serve\` ` +
83
+ 'in a terminal instead, or keep it running with whatever this machine uses.');
200
84
  }
201
85
  if (/Failed to connect to (user scope )?bus|DBUS_SESSION_BUS_ADDRESS|XDG_RUNTIME_DIR/.test(r.stderr)) {
202
- return (`cannot reach systemd's per-user instance (${r.stderr.trim()}).\n` +
203
- "Run this from a login session, and check that $XDG_RUNTIME_DIR is set " +
204
- "(usually /run/user/$(id -u)).");
86
+ return `can't reach systemd's per-user instance (${r.stderr.trim()}). Run this from a login session, with $XDG_RUNTIME_DIR set (usually /run/user/$(id -u)).`;
205
87
  }
206
- return (`systemctl ${args.join(" ")} failed (exit ${String(r.status)}).\n` +
207
- `${r.stderr || r.stdout}`.trim());
208
- }
209
- /** Run a `systemctl` and turn a non-zero exit into something actionable. */
210
- function systemctlOrThrow(run, args) {
211
- const r = run("systemctl", args);
212
- if (r.status === 0)
213
- return;
214
- throw new Error(systemctlFailure(args, r));
88
+ return `systemctl ${args.join(' ')} failed (exit ${String(r.status)}): ${`${r.stderr || r.stdout}`.trim()}`;
215
89
  }
216
- export function createSystemdBackend(run, homeDir, xdgConfigHome) {
217
- // Both forwarded, neither defaulted here: `unitPath` owns the precedence, and
218
- // defaulting `homeDir` to `os.homedir()` at this layer would collapse "the
219
- // caller named a home" into "the caller named nothing" — the exact
220
- // distinction that precedence turns on.
221
- const file = unitPath(homeDir, xdgConfigHome);
90
+ export function createSystemd(id, o) {
91
+ const { run } = o;
92
+ const file = unitPath(id, o.homeDir, o.xdgConfigHome);
93
+ const systemctl = (...args) => {
94
+ const r = run('systemctl', ['--user', ...args]);
95
+ if (r.status !== 0)
96
+ throw new Error(systemctlFailure(id, ['--user', ...args], r));
97
+ };
98
+ const isActive = () => run('systemctl', ['--user', 'is-active', id.unit]).stdout.trim() === 'active';
222
99
  return {
223
- platform: "linux",
100
+ platform: 'linux',
224
101
  unitPath: file,
225
- label: SYSTEMD_UNIT,
102
+ label: id.unit,
103
+ kind: 'a systemd --user service',
226
104
  install(spec) {
105
+ // Asked before the unit is overwritten, while it still describes what's running.
106
+ const wasActive = fs.existsSync(file) && isActive();
227
107
  fs.mkdirSync(path.dirname(file), { recursive: true });
228
108
  fs.mkdirSync(spec.workingDirectory, { recursive: true });
229
- // systemd creates the log FILE for `append:` but not its directory, and a
230
- // missing directory fails the unit at start with the server never having
231
- // run — no output anywhere, because the thing that would have written it
232
- // is what failed.
233
109
  fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
234
- fs.writeFileSync(file, renderUnit(spec), "utf8");
235
- // Without this the manager keeps serving the previous unit from cache and
236
- // an "upgrade" silently changes nothing.
237
- run("systemctl", ["--user", "daemon-reload"]);
238
- const args = ["--user", "enable", "--now", SYSTEMD_UNIT];
239
- const enabled = run("systemctl", args);
240
- if (enabled.status !== 0)
241
- throw new Error(systemctlFailure(args, enabled));
110
+ fs.writeFileSync(file, renderUnit(id, spec), 'utf8');
111
+ // Without a reload the manager keeps serving its cached copy of the old unit.
112
+ run('systemctl', ['--user', 'daemon-reload']);
113
+ systemctl('enable', '--now', id.unit);
114
+ // `enable --now` doesn't touch a unit that's already active: a reinstall over a running service would leave the
115
+ // old process serving and report success. That's the state `update`'s rollback refreshes in, while the failed
116
+ // version is still up. So restart, but only then: on a stopped unit it would be two starts milliseconds apart,
117
+ // and systemd's default limit is five starts in ten seconds.
118
+ if (wasActive)
119
+ systemctl('restart', id.unit);
242
120
  },
243
121
  uninstall() {
244
122
  const existed = fs.existsSync(file);
245
- run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
123
+ run('systemctl', ['--user', 'disable', '--now', id.unit]);
246
124
  if (existed)
247
125
  fs.rmSync(file, { force: true });
248
- run("systemctl", ["--user", "daemon-reload"]);
126
+ run('systemctl', ['--user', 'daemon-reload']);
249
127
  return existed;
250
128
  },
251
129
  /**
252
- * The lifecycle three (#873), which on systemd are just the verbs.
253
- *
254
- * None of them touches `enable`/`disable`: those decide whether Paddock
255
- * comes back at your NEXT login, which is a different question from whether
256
- * it is running now, and quietly answering both would make `stop` a
257
- * surprise the following morning. `uninstall` is where that link is cut.
258
- *
259
- * `restart` re-reads the unit itself, but a `daemon-reload` first is what
260
- * makes an EDITED unit take effect rather than the manager's cached copy —
261
- * the same reason `install` reloads before enabling.
130
+ * `start` clears a failed state first: a unit that hit the start limit stays failed, and won't start, until
131
+ * `reset-failed`. None of the three touches enable or disable: whether it starts at the next login is a
132
+ * different question from whether it's running now.
262
133
  */
263
134
  start() {
264
- systemctlOrThrow(run, ["--user", "start", SYSTEMD_UNIT]);
135
+ run('systemctl', ['--user', 'reset-failed', id.unit]);
136
+ systemctl('start', id.unit);
265
137
  },
266
138
  stop() {
267
- systemctlOrThrow(run, ["--user", "stop", SYSTEMD_UNIT]);
139
+ systemctl('stop', id.unit);
268
140
  },
141
+ /** A reload first, so an edited unit takes effect. */
269
142
  restart() {
270
- run("systemctl", ["--user", "daemon-reload"]);
271
- systemctlOrThrow(run, ["--user", "restart", SYSTEMD_UNIT]);
143
+ run('systemctl', ['--user', 'daemon-reload']);
144
+ run('systemctl', ['--user', 'reset-failed', id.unit]);
145
+ systemctl('restart', id.unit);
272
146
  },
273
147
  status() {
274
148
  if (!fs.existsSync(file))
275
149
  return { registered: false, running: false, argv: [], keepsAlive: true };
276
- const text = fs.readFileSync(file, "utf8");
150
+ const text = fs.readFileSync(file, 'utf8');
277
151
  const argv = parseUnitArgv(text);
278
- const active = run("systemctl", ["--user", "is-active", SYSTEMD_UNIT]);
279
- const pid = run("systemctl", ["--user", "show", "-p", "MainPID", "--value", SYSTEMD_UNIT]);
280
- const mainPid = pid.stdout.trim();
152
+ const pid = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', id.unit]).stdout.trim();
281
153
  const logPath = parseUnitLogPath(text);
282
154
  return {
283
155
  registered: true,
284
- running: active.stdout.trim() === "active",
285
- ...(mainPid !== "" && mainPid !== "0" ? { pid: mainPid } : {}),
156
+ running: isActive(),
157
+ ...(pid !== '' && pid !== '0' ? { pid } : {}),
286
158
  argv: argv.slice(2),
287
159
  ...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
288
160
  ...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
@@ -291,23 +163,22 @@ export function createSystemdBackend(run, homeDir, xdgConfigHome) {
291
163
  };
292
164
  },
293
165
  /**
294
- * `loginctl show-user <user> -p Linger` (SPEC §7).
295
- *
296
- * Parsed rather than trusted to exit codes: `loginctl` is absent in plenty
297
- * of containers and exits non-zero for a user logind has no record of, and
298
- * neither of those is "linger is off". An unparseable answer is reported as
299
- * unknown, which prints nothing — the alternative is handing a `sudo`
300
- * command to someone whose machine has no `loginctl` to run it with.
166
+ * `loginctl show-user <user> -p Linger`, parsed rather than trusted to its exit code: no `loginctl` (a container)
167
+ * or no record of the user isn't "off". An answer it can't read is unknown, and the tool says nothing.
301
168
  */
302
169
  linger() {
303
- const user = os.userInfo().username;
304
- const r = run("loginctl", ["show-user", user, "-p", "Linger"]);
170
+ let user = o.user;
171
+ try {
172
+ user ??= os.userInfo().username;
173
+ }
174
+ catch {
175
+ user = process.env.USER;
176
+ }
177
+ if (user === undefined)
178
+ return { applies: true };
179
+ const r = run('loginctl', ['show-user', user, '-p', 'Linger']);
305
180
  const m = /Linger=(yes|no)/i.exec(`${r.stdout}${r.stderr}`);
306
- return {
307
- applies: true,
308
- user,
309
- ...(m !== null ? { enabled: m[1].toLowerCase() === "yes" } : {}),
310
- };
181
+ return { applies: true, user, ...(m !== null ? { enabled: m[1].toLowerCase() === 'yes' } : {}) };
311
182
  },
312
183
  };
313
184
  }
@@ -0,0 +1,48 @@
1
+ /** What `/api/health` said. */
2
+ export interface HealthAnswer {
3
+ ok: boolean;
4
+ /** The running server's version; absent from a tool that predates the contract. */
5
+ version?: string;
6
+ }
7
+ export interface HealthOptions {
8
+ /** The server's base URL: `http://127.0.0.1:7233`. */
9
+ url: string;
10
+ fetch?: typeof globalThis.fetch;
11
+ }
12
+ /** Asks once. Undefined means nothing answered (with a 2xx), which is an answer, not an error. */
13
+ export declare function fetchHealth(o: HealthOptions & {
14
+ timeoutMs?: number;
15
+ }): Promise<HealthAnswer | undefined>;
16
+ export interface WaitOptions extends HealthOptions {
17
+ timeoutMs?: number;
18
+ intervalMs?: number;
19
+ now?: () => number;
20
+ sleep?: (ms: number) => Promise<void>;
21
+ }
22
+ /**
23
+ * How long to wait before saying it didn't answer. Generous, because it's only spent when something is wrong, and
24
+ * too short a wait says "didn't start" about a service that's only slow to boot.
25
+ */
26
+ export declare const READY_TIMEOUT_MS = 20000;
27
+ /** Polls until `/api/health` answers 2xx, or the time runs out. Each request has its own abort. */
28
+ export declare function waitForReady(o: WaitOptions): Promise<'ready' | 'timeout'>;
29
+ /** What `waitForVersion` found (SPEC §5 step 12). */
30
+ export type HealthVerdict = {
31
+ state: 'ok';
32
+ version: string;
33
+ }
34
+ /** Something answers, but as another version. */
35
+ | {
36
+ state: 'wrong-version';
37
+ version: string;
38
+ } | {
39
+ state: 'no-answer';
40
+ };
41
+ /**
42
+ * Polls until `/api/health` reports exactly `version`. "It answers" isn't enough: the old server still shutting
43
+ * down, another instance on the port, and a supervisor that brought the old unit back all answer, and those are the
44
+ * failures a rollback is for. A wrong version and no answer are kept apart because they read differently in a report.
45
+ */
46
+ export declare function waitForVersion(o: WaitOptions & {
47
+ version: string;
48
+ }): Promise<HealthVerdict>;