@edspencer/paddock 0.79.1 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/app.d.ts +7 -0
  3. package/packages/server/dist/app.js +19 -15
  4. package/packages/server/dist/auth.d.ts +3 -2
  5. package/packages/server/dist/auth.js +11 -0
  6. package/packages/server/dist/background-live.d.ts +17 -0
  7. package/packages/server/dist/background-live.js +24 -0
  8. package/packages/server/dist/busy.d.ts +96 -0
  9. package/packages/server/dist/busy.js +88 -0
  10. package/packages/server/dist/cli/args.d.ts +63 -7
  11. package/packages/server/dist/cli/args.js +227 -20
  12. package/packages/server/dist/cli/busy-command.d.ts +58 -0
  13. package/packages/server/dist/cli/busy-command.js +108 -0
  14. package/packages/server/dist/cli/busy.d.ts +69 -0
  15. package/packages/server/dist/cli/busy.js +150 -0
  16. package/packages/server/dist/cli/exit-codes.d.ts +49 -0
  17. package/packages/server/dist/cli/exit-codes.js +48 -0
  18. package/packages/server/dist/cli/output.d.ts +52 -0
  19. package/packages/server/dist/cli/output.js +84 -0
  20. package/packages/server/dist/cli/paddock.js +98 -37
  21. package/packages/server/dist/cli/service/backend.d.ts +48 -4
  22. package/packages/server/dist/cli/service/index.d.ts +68 -2
  23. package/packages/server/dist/cli/service/index.js +321 -60
  24. package/packages/server/dist/cli/service/launchd.d.ts +23 -1
  25. package/packages/server/dist/cli/service/launchd.js +145 -25
  26. package/packages/server/dist/cli/service/logs.d.ts +39 -0
  27. package/packages/server/dist/cli/service/logs.js +115 -0
  28. package/packages/server/dist/cli/service/path-env.d.ts +27 -0
  29. package/packages/server/dist/cli/service/path-env.js +120 -0
  30. package/packages/server/dist/cli/service/ready.d.ts +22 -0
  31. package/packages/server/dist/cli/service/ready.js +30 -0
  32. package/packages/server/dist/cli/service/spec.d.ts +45 -6
  33. package/packages/server/dist/cli/service/spec.js +34 -7
  34. package/packages/server/dist/cli/service/systemd.d.ts +47 -4
  35. package/packages/server/dist/cli/service/systemd.js +127 -22
  36. package/packages/server/dist/herdctl.d.ts +19 -0
  37. package/packages/server/dist/herdctl.js +26 -0
  38. package/packages/server/dist/management-mcp-server.d.ts +21 -3
  39. package/packages/server/dist/management-mcp-server.js +4 -13
  40. package/packages/server/dist/routes/meta.js +56 -3
  41. package/packages/server/dist/runtime-file.d.ts +26 -0
  42. package/packages/server/dist/runtime-file.js +91 -0
  43. package/packages/server/dist/service-log.d.ts +29 -0
  44. package/packages/server/dist/service-log.js +104 -0
  45. package/packages/server/dist/start.d.ts +33 -0
  46. package/packages/server/dist/start.js +126 -8
  47. package/packages/server/dist/version.d.ts +3 -0
  48. package/packages/server/dist/version.js +32 -0
  49. package/packages/server/dist/ws-context.d.ts +9 -0
  50. package/packages/server/dist/ws-turn.js +1 -0
  51. package/packages/server/package.json +1 -1
  52. package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-o0KCr3aB.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-39mafH8b.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-Bq7GVguY.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BoBIru1g.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-C0kwFyYp.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-MkhXlB8z.js} +1 -1
  62. package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-DExlwAwO.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
  66. package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
  77. package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-XjElsvQ7.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-BwTIA8rI.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
  90. package/packages/web/dist/assets/{index-C4tsPSvG.js → index-CvaWErFv.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
  95. package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BxOawONt.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-Dvs1QCcz.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.js} +1 -1
  110. package/packages/web/dist/index.html +1 -1
  111. package/packages/web/dist/sw.js +1 -1
  112. package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
  113. package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
@@ -0,0 +1,150 @@
1
+ // From edspencer/cli-contract reference/busy-client.ts @ SPEC 0.1; local changes: `.ts` import specifiers → `.js`
2
+ // and `../runtime-file.js` (the writer lives server-side, next to the boot path that writes the file); `EXIT.OK` /
3
+ // `EXIT.BUSY` / `EXIT.NOT_RUNNING` / `EXIT.FAILED` → Paddock's lowercase `EXIT.ok` / `busy` / `notRunning` /
4
+ // `failure` (see `exit-codes.ts`, which wave C4 re-syncs); and `hint` widened from a string to
5
+ // `string | ((result) => string | undefined)`, with {@link runBusyCommand} printing it too — that is what carries
6
+ // Paddock's keeper-chat trap (ROADMAP "Busy, per tool"), where the line to print depends on WHICH counts are
7
+ // non-zero, so a fixed string could not say it.
8
+ //
9
+ // The CLI's side of the busy check (SPEC §4): find the running instance through `<home>/runtime.json`, ask its
10
+ // `/api/busy`, and map the answer to exit 0 (idle), 75 (busy) or 3 (nothing answers), with §4's messages.
11
+ // Used by `<tool> busy`, and before `service stop`, `service restart` and `update`. Node built-ins only.
12
+ import { EXIT } from "./exit-codes.js";
13
+ import { readLiveRuntimeFile } from "../runtime-file.js";
14
+ /**
15
+ * Asks the running instance whether it's busy. There's no fallback to a default port: an instance that's running
16
+ * wrote `runtime.json`, and guessing a port would ask whatever else is listening there.
17
+ */
18
+ export async function checkBusy(o) {
19
+ const base = o.url ?? readLiveRuntimeFile(o.home)?.url;
20
+ if (!base)
21
+ return { state: "not-running" };
22
+ const url = new URL("/api/busy", base).toString();
23
+ const doFetch = o.fetch ?? globalThis.fetch;
24
+ let res;
25
+ try {
26
+ res = await doFetch(url, {
27
+ signal: AbortSignal.timeout(o.timeoutMs ?? 3000),
28
+ headers: { accept: "application/json" },
29
+ });
30
+ }
31
+ catch {
32
+ // Refused, reset or timed out: nothing is answering, which §4 calls not running.
33
+ return { state: "not-running" };
34
+ }
35
+ if (!res.ok)
36
+ return { state: "unknown", url, detail: `HTTP ${res.status}` };
37
+ let body;
38
+ try {
39
+ body = await res.json();
40
+ }
41
+ catch {
42
+ return { state: "unknown", url, detail: "the answer isn't JSON" };
43
+ }
44
+ if (!isBusyAnswer(body))
45
+ return { state: "unknown", url, detail: "the answer has no boolean `busy`" };
46
+ return { state: body.busy ? "busy" : "idle", url, answer: body };
47
+ }
48
+ function isBusyAnswer(v) {
49
+ if (typeof v !== "object" || v === null || typeof v.busy !== "boolean")
50
+ return false;
51
+ const counts = v.counts;
52
+ if (counts === undefined)
53
+ return true;
54
+ return (typeof counts === "object" &&
55
+ counts !== null &&
56
+ Object.values(counts).every((n) => Number.isInteger(n) && n >= 0));
57
+ }
58
+ /** `2 chats and 1 job are running`, from the non-zero counts; '' if there are none. */
59
+ export function describeCounts(counts = {}, nouns = {}) {
60
+ const parts = Object.entries(counts)
61
+ .filter(([, n]) => n > 0)
62
+ .map(([kind, n]) => {
63
+ const [one, many] = nouns[kind] ?? [kind, `${kind}s`];
64
+ return `${n} ${n === 1 ? one : many}`;
65
+ });
66
+ if (parts.length === 0)
67
+ return "";
68
+ const list = parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
69
+ const total = Object.values(counts).reduce((a, n) => a + (n > 0 ? n : 0), 0);
70
+ return `${list} ${total === 1 ? "is" : "are"} running`;
71
+ }
72
+ /** The sentence for a person (SPEC §4): `Paddock is busy: 2 chats are running.` */
73
+ export function busyMessage(name, result, nouns = {}) {
74
+ switch (result.state) {
75
+ case "not-running":
76
+ return `${name} isn't running.`;
77
+ case "idle":
78
+ return `${name} isn't busy.`;
79
+ case "busy": {
80
+ const what = describeCounts(result.answer.counts, nouns);
81
+ return what ? `${name} is busy: ${what}.` : `${name} is busy.`;
82
+ }
83
+ case "unknown":
84
+ return `Something answered at ${result.url}, but not as ${name}'s busy check (${result.detail}).`;
85
+ }
86
+ }
87
+ /** 0 idle, 75 busy, 3 nothing answers, 1 an answer that isn't a busy answer. */
88
+ export function busyExitCode(result) {
89
+ switch (result.state) {
90
+ case "idle":
91
+ return EXIT.ok;
92
+ case "busy":
93
+ return EXIT.busy;
94
+ case "not-running":
95
+ return EXIT.notRunning;
96
+ case "unknown":
97
+ return EXIT.failure;
98
+ }
99
+ }
100
+ /** What `<tool> busy --json` prints: valid against `schemas/busy.schema.json`. */
101
+ export function busyJson(result) {
102
+ if (result.state === "not-running")
103
+ return { running: false, busy: false };
104
+ if (result.state === "unknown")
105
+ return { running: true, busy: false, url: base(result.url), error: result.detail };
106
+ return {
107
+ running: true,
108
+ busy: result.answer.busy,
109
+ url: base(result.url),
110
+ ...(result.answer.version !== undefined ? { version: result.answer.version } : {}),
111
+ counts: result.answer.counts ?? {},
112
+ };
113
+ }
114
+ const base = (busyUrl) => busyUrl.replace(/\/api\/busy$/, "");
115
+ function resolveHint(hint, result) {
116
+ return typeof hint === "function" ? hint(result) : hint;
117
+ }
118
+ /** `<tool> busy [--json]`. Returns the exit code; the caller sets `process.exitCode`. */
119
+ export async function runBusyCommand(o) {
120
+ const result = await checkBusy(o);
121
+ const message = busyMessage(o.name, result, o.nouns);
122
+ o.out.result(busyJson(result));
123
+ if (result.state === "unknown")
124
+ o.out.error(message);
125
+ else
126
+ o.out.say(message);
127
+ // LOCAL CHANGE: the hint rides along with a busy answer here too, not only on the guard's refusal — someone who
128
+ // ran `paddock busy` from inside a chat needs the same sentence as someone whose `service stop` was refused.
129
+ const hint = result.state === "busy" ? resolveHint(o.hint, result) : undefined;
130
+ if (hint !== undefined)
131
+ o.out.say(hint);
132
+ return busyExitCode(result);
133
+ }
134
+ /**
135
+ * The check before `service stop`, `service restart` and `update` (SPEC §4). Returns null to go ahead, or 75 after
136
+ * printing the refusal. With `--force` it prints the same message, prefixed `Overriding:`, and goes ahead.
137
+ * Nothing answering goes ahead: there's nothing to cut short.
138
+ */
139
+ export async function guardBusy(o) {
140
+ const result = await checkBusy(o);
141
+ if (result.state !== "busy")
142
+ return null;
143
+ const message = busyMessage(o.name, result, o.nouns);
144
+ if (o.force) {
145
+ o.out.warn(`Overriding: ${message}`);
146
+ return null;
147
+ }
148
+ const hint = resolveHint(o.hint, result) ?? "Try again once it has finished, or use --force.";
149
+ return o.out.error(`${message} ${hint}`, EXIT.busy);
150
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The CLI contract's exit codes (SPEC §2), as constants.
3
+ *
4
+ * From edspencer/cli-contract SPEC.md §2 @ 0.1. Written here rather than copied
5
+ * from `reference/exit-codes.ts` because the reference module did not exist yet
6
+ * when Paddock aligned; wave C4 re-syncs this file to it and adds the usual
7
+ * provenance header. The numbers, and what each means, are the contract.
8
+ *
9
+ * | 0 | done; running and answering; up to date; not busy
10
+ * | 1 | failed
11
+ * | 2 | usage: an unknown command or flag, a bad value
12
+ * | 3 | not running: installed but not running or not answering
13
+ * | 4 | not installed: there is no service unit
14
+ * | 5 | this install cannot update itself (`update`, not yet built)
15
+ * | 6 | the update failed and was rolled back (`update`, not yet built)
16
+ * | 7 | the update failed and could not be rolled back
17
+ * | 75 | busy: refused, and why was printed (`busy`, not yet built)
18
+ *
19
+ * Codes 3 and 4 are what `systemctl status` and LSB init scripts use for "not
20
+ * running" and "no such service", so a script that already branches on them
21
+ * keeps working. 75 is `EX_TEMPFAIL` from `sysexits.h`, which is also the code
22
+ * that makes Watchtower's pre-update hook skip an update.
23
+ *
24
+ * The three `update` codes are declared now and unused until wave C2. They are
25
+ * here because the contract's whole point is that the numbers mean the same
26
+ * thing in every tool: adding them later, from a different file, is how two
27
+ * tools end up disagreeing about what 5 means.
28
+ */
29
+ export declare const EXIT: {
30
+ /** Done. Running and answering. Up to date. Not busy. */
31
+ readonly ok: 0;
32
+ /** Failed — the catch-all. */
33
+ readonly failure: 1;
34
+ /** Usage: an unknown command or flag, or a bad value. Usage follows the message. */
35
+ readonly usage: 2;
36
+ /** Not running: installed, but not running or not answering. */
37
+ readonly notRunning: 3;
38
+ /** Not installed: there is no service unit. */
39
+ readonly notInstalled: 4;
40
+ /** This install cannot update itself; the right command was printed. */
41
+ readonly cannotSelfUpdate: 5;
42
+ /** The update failed and was rolled back; the old version is running again. */
43
+ readonly rolledBack: 6;
44
+ /** The update failed and could not be rolled back; a person is needed. */
45
+ readonly needsHuman: 7;
46
+ /** Busy: refused, and why was printed. Try later, or use `--force`. */
47
+ readonly busy: 75;
48
+ };
49
+ export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The CLI contract's exit codes (SPEC §2), as constants.
3
+ *
4
+ * From edspencer/cli-contract SPEC.md §2 @ 0.1. Written here rather than copied
5
+ * from `reference/exit-codes.ts` because the reference module did not exist yet
6
+ * when Paddock aligned; wave C4 re-syncs this file to it and adds the usual
7
+ * provenance header. The numbers, and what each means, are the contract.
8
+ *
9
+ * | 0 | done; running and answering; up to date; not busy
10
+ * | 1 | failed
11
+ * | 2 | usage: an unknown command or flag, a bad value
12
+ * | 3 | not running: installed but not running or not answering
13
+ * | 4 | not installed: there is no service unit
14
+ * | 5 | this install cannot update itself (`update`, not yet built)
15
+ * | 6 | the update failed and was rolled back (`update`, not yet built)
16
+ * | 7 | the update failed and could not be rolled back
17
+ * | 75 | busy: refused, and why was printed (`busy`, not yet built)
18
+ *
19
+ * Codes 3 and 4 are what `systemctl status` and LSB init scripts use for "not
20
+ * running" and "no such service", so a script that already branches on them
21
+ * keeps working. 75 is `EX_TEMPFAIL` from `sysexits.h`, which is also the code
22
+ * that makes Watchtower's pre-update hook skip an update.
23
+ *
24
+ * The three `update` codes are declared now and unused until wave C2. They are
25
+ * here because the contract's whole point is that the numbers mean the same
26
+ * thing in every tool: adding them later, from a different file, is how two
27
+ * tools end up disagreeing about what 5 means.
28
+ */
29
+ export const EXIT = {
30
+ /** Done. Running and answering. Up to date. Not busy. */
31
+ ok: 0,
32
+ /** Failed — the catch-all. */
33
+ failure: 1,
34
+ /** Usage: an unknown command or flag, or a bad value. Usage follows the message. */
35
+ usage: 2,
36
+ /** Not running: installed, but not running or not answering. */
37
+ notRunning: 3,
38
+ /** Not installed: there is no service unit. */
39
+ notInstalled: 4,
40
+ /** This install cannot update itself; the right command was printed. */
41
+ cannotSelfUpdate: 5,
42
+ /** The update failed and was rolled back; the old version is running again. */
43
+ rolledBack: 6,
44
+ /** The update failed and could not be rolled back; a person is needed. */
45
+ needsHuman: 7,
46
+ /** Busy: refused, and why was printed. Try later, or use `--force`. */
47
+ busy: 75,
48
+ };
@@ -0,0 +1,52 @@
1
+ export interface OutputStream {
2
+ write(chunk: string): unknown;
3
+ isTTY?: boolean;
4
+ }
5
+ export interface OutputOptions {
6
+ /** The bin, for `<tool>: <message>`. */
7
+ tool: string;
8
+ /** `--json` was given: stdout carries exactly one JSON document, and prose goes to stderr. */
9
+ json?: boolean;
10
+ stdout?: OutputStream;
11
+ stderr?: OutputStream;
12
+ env?: NodeJS.ProcessEnv;
13
+ }
14
+ /**
15
+ * Whether to colour what goes to this stream (SPEC §3).
16
+ *
17
+ * - `NO_COLOR` set to anything but the empty string: never (https://no-color.org).
18
+ * - `FORCE_COLOR` set: `0` or `false` means never, anything else always.
19
+ * - Otherwise only on a TTY, and not when `TERM=dumb`.
20
+ */
21
+ export declare function useColor(stream: OutputStream, env?: NodeJS.ProcessEnv): boolean;
22
+ export interface Paint {
23
+ bold(s: string): string;
24
+ dim(s: string): string;
25
+ red(s: string): string;
26
+ green(s: string): string;
27
+ yellow(s: string): string;
28
+ }
29
+ /** Colour functions that do nothing when `on` is false. */
30
+ export declare function paint(on: boolean): Paint;
31
+ export interface Output {
32
+ readonly json: boolean;
33
+ /** Colour for prose on stdout (never with `--json`, which has no prose on stdout). */
34
+ readonly color: Paint;
35
+ /** Colour for stderr. */
36
+ readonly errColor: Paint;
37
+ /** Prose for a person: stdout, or stderr under `--json`. */
38
+ say(line: string): void;
39
+ /** A warning on stderr, always. */
40
+ warn(line: string): void;
41
+ /** `<tool>: <message>` on stderr. Returns `code` (default 1), for `return out.error(…)`. Sets nothing itself. */
42
+ error(message: string, code?: number): number;
43
+ /** The one JSON document `--json` promises. Prints nothing without `--json`. */
44
+ result(doc: unknown): void;
45
+ }
46
+ /** One place for a command's output. Make it once per command, after the flags are parsed. */
47
+ export declare function createOutput(o: OutputOptions): Output;
48
+ /**
49
+ * A closed pipe (`<tool> … | head -1`) exits quietly (SPEC §2): without this, Node throws EPIPE from a later write
50
+ * and prints a stack trace. Call once, at startup.
51
+ */
52
+ export declare function exitQuietlyOnEpipe(stream?: NodeJS.WriteStream, exit?: (code: number) => void): void;
@@ -0,0 +1,84 @@
1
+ // From edspencer/cli-contract reference/output.ts @ SPEC 0.1; local changes: `./exit-codes.ts` → `./exit-codes.js`
2
+ // (Paddock compiles to ESM-with-extensions, not a TS runtime); `EXIT.FAILED`/`EXIT.USAGE` → Paddock's lowercase
3
+ // `EXIT.failure`/`EXIT.usage`, because Paddock's own `exit-codes.ts` predates the reference module and wave C4
4
+ // re-syncs that file; and the reference's `UsageError` / `reportUsageError` are NOT copied, because `cli/args.ts`
5
+ // already has a `UsageError` (a `CliError` subclass carrying its own code) that the entrypoint already reports.
6
+ //
7
+ // A CLI's output, the way SPEC §2 and §3 say: errors as `<tool>: <message>` on stderr, colour only on a TTY (honouring
8
+ // NO_COLOR and FORCE_COLOR), and with `--json`, one JSON document on stdout and every word of prose on stderr.
9
+ // Node built-ins only.
10
+ import { EXIT } from "./exit-codes.js";
11
+ /**
12
+ * Whether to colour what goes to this stream (SPEC §3).
13
+ *
14
+ * - `NO_COLOR` set to anything but the empty string: never (https://no-color.org).
15
+ * - `FORCE_COLOR` set: `0` or `false` means never, anything else always.
16
+ * - Otherwise only on a TTY, and not when `TERM=dumb`.
17
+ */
18
+ export function useColor(stream, env = process.env) {
19
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
20
+ return false;
21
+ if (env.FORCE_COLOR !== undefined)
22
+ return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
23
+ if (env.TERM === "dumb")
24
+ return false;
25
+ return stream.isTTY === true;
26
+ }
27
+ const sgr = (open, close) => (s) => `\u001b[${open}m${s}\u001b[${close}m`;
28
+ const plain = (s) => s;
29
+ /** Colour functions that do nothing when `on` is false. */
30
+ export function paint(on) {
31
+ return on
32
+ ? {
33
+ bold: sgr(1, 22),
34
+ dim: sgr(2, 22),
35
+ red: sgr(31, 39),
36
+ green: sgr(32, 39),
37
+ yellow: sgr(33, 39),
38
+ }
39
+ : { bold: plain, dim: plain, red: plain, green: plain, yellow: plain };
40
+ }
41
+ /** One place for a command's output. Make it once per command, after the flags are parsed. */
42
+ export function createOutput(o) {
43
+ const stdout = o.stdout ?? process.stdout;
44
+ const stderr = o.stderr ?? process.stderr;
45
+ const env = o.env ?? process.env;
46
+ const json = o.json === true;
47
+ let printed = false;
48
+ const line = (s) => (s.endsWith("\n") ? s : `${s}\n`);
49
+ return {
50
+ json,
51
+ color: paint(!json && useColor(stdout, env)),
52
+ errColor: paint(useColor(stderr, env)),
53
+ say(s) {
54
+ (json ? stderr : stdout).write(line(s));
55
+ },
56
+ warn(s) {
57
+ stderr.write(line(s));
58
+ },
59
+ error(message, code = EXIT.failure) {
60
+ stderr.write(line(`${o.tool}: ${message}`));
61
+ return code;
62
+ },
63
+ result(doc) {
64
+ if (!json)
65
+ return;
66
+ if (printed)
67
+ throw new Error("--json prints one document; result() was called twice");
68
+ printed = true;
69
+ stdout.write(`${JSON.stringify(doc, null, 2)}\n`);
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * A closed pipe (`<tool> … | head -1`) exits quietly (SPEC §2): without this, Node throws EPIPE from a later write
75
+ * and prints a stack trace. Call once, at startup.
76
+ */
77
+ export function exitQuietlyOnEpipe(stream = process.stdout, exit = (c) => process.exit(c)) {
78
+ stream.on("error", (err) => {
79
+ if (err.code === "EPIPE")
80
+ exit(typeof process.exitCode === "number" ? process.exitCode : 0);
81
+ else
82
+ throw err;
83
+ });
84
+ }
@@ -23,8 +23,10 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { CliError, USAGE, SERVICE_USAGE, CONFIG_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
- import { runService, safeHomeDir } from "./service/index.js";
26
+ import { CliError, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, BUSY_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
+ import { EXIT } from "./exit-codes.js";
28
+ import { runService, safeHomeDir, realpathOr } from "./service/index.js";
29
+ import { PADDOCK_NAME, PADDOCK_VERSION } from "../version.js";
28
30
  /**
29
31
  * This module's own file and directory: `<…>/packages/server/dist/cli/`.
30
32
  *
@@ -62,18 +64,27 @@ function findUp(rel) {
62
64
  }
63
65
  /** Nearest ancestor holding a `package.json` — `packages/server` in both layouts. */
64
66
  const packageRoot = findUp("package.json") ?? path.resolve(moduleDir, "../..");
65
- function fail(message) {
67
+ /**
68
+ * Report a failure, and hand back the code to exit with (SPEC §2, §3).
69
+ *
70
+ * Two things here are the contract rather than style. Errors go to **stderr**
71
+ * as `paddock: <message>`, so a `--json` document on stdout stays the only
72
+ * thing on stdout. And the code is RETURNED rather than passed to
73
+ * `process.exit()`: exiting mid-write truncates piped output, which is how a
74
+ * `paddock service status | jq` loses the end of its document. `main` assigns
75
+ * `process.exitCode` and lets the process end on its own.
76
+ *
77
+ * A usage error also prints the synopsis after the message, because the spec
78
+ * says the usage follows the message — the short one, not {@link USAGE}'s
79
+ * thousand words, which would bury the line that says what was wrong.
80
+ */
81
+ function fail(message, code = EXIT.failure) {
66
82
  console.error(`paddock: ${message}`);
67
- process.exit(1);
68
- }
69
- function readVersion() {
70
- try {
71
- const raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf8");
72
- return JSON.parse(raw).version ?? "unknown";
73
- }
74
- catch {
75
- return "unknown";
83
+ if (code === EXIT.usage) {
84
+ console.error("");
85
+ console.error(USAGE_SYNOPSIS);
76
86
  }
87
+ return code;
77
88
  }
78
89
  /**
79
90
  * Put our bundled `claude` on PATH for child processes.
@@ -121,7 +132,10 @@ function addBundledBinsToPath() {
121
132
  function noteFirstRun(dataDir) {
122
133
  if (fs.existsSync(dataDir))
123
134
  return;
124
- console.log([
135
+ // stderr, because stdout's first line belongs to the banner (SPEC §3). This
136
+ // block is prose for a person and it only appears on a first run — which is
137
+ // exactly the run a conformance check or a script reading the URL performs.
138
+ console.error([
125
139
  "",
126
140
  ` Welcome to Paddock. Creating a new instance in ${dataDir}`,
127
141
  " Projects, chats and settings persist there between runs.",
@@ -161,12 +175,17 @@ async function service(command) {
161
175
  // which `main` has already handled. Typed rather than asserted.
162
176
  if (action === undefined) {
163
177
  console.log(SERVICE_USAGE);
164
- return;
178
+ return EXIT.ok;
165
179
  }
166
180
  try {
167
- await runService(action, opts, {
181
+ return await runService(action, opts, {
168
182
  platform: process.platform,
169
- nodePath: process.execPath,
183
+ // Realpath'd (SPEC §7). `process.execPath` under a version manager is
184
+ // routinely a symlink into a per-shell directory — fnm's
185
+ // `fnm_multishells/<pid>_<ts>/bin/node` is deleted when that terminal
186
+ // exits — so a unit naming the symlink stops working when the tab closes,
187
+ // at some later login, with nothing in the logs because it never ran.
188
+ nodePath: realpathOr(process.execPath),
170
189
  scriptPath: entryScript,
171
190
  packageRoot,
172
191
  homeDir: safeHomeDir(),
@@ -180,10 +199,12 @@ async function service(command) {
180
199
  });
181
200
  }
182
201
  catch (err) {
202
+ // A CliError carries its own code: an unsupported platform or an npx
203
+ // refusal is a failure (1), a misspelled flag is usage (2).
183
204
  if (err instanceof CliError)
184
- fail(err.message);
205
+ return fail(err.message, err.exitCode);
185
206
  if (err instanceof Error)
186
- fail(err.message);
207
+ return fail(err.message);
187
208
  throw err;
188
209
  }
189
210
  }
@@ -214,12 +235,13 @@ async function config(command) {
214
235
  // which `main` has already handled. Typed rather than asserted.
215
236
  if (action === undefined) {
216
237
  console.log(CONFIG_USAGE);
217
- return;
238
+ return EXIT.ok;
218
239
  }
219
240
  process.env.PADDOCK_DATA_DIR = resolveDataDir(opts);
220
241
  const { runConfig } = await import("./config.js");
221
242
  try {
222
243
  await runConfig(action, opts);
244
+ return EXIT.ok;
223
245
  }
224
246
  catch (err) {
225
247
  // A config that will not load reaches here as a CliError already carrying
@@ -227,9 +249,9 @@ async function config(command) {
227
249
  // because it is a bug in this command rather than in the configuration.
228
250
  // Same shape as `service` above.
229
251
  if (err instanceof CliError)
230
- fail(err.message);
252
+ return fail(err.message, err.exitCode);
231
253
  if (err instanceof Error)
232
- fail(err.message);
254
+ return fail(err.message);
233
255
  throw err;
234
256
  }
235
257
  }
@@ -239,29 +261,43 @@ async function main() {
239
261
  command = parseCommand(process.argv.slice(2));
240
262
  }
241
263
  catch (err) {
264
+ // Parse failures are usage failures: exit 2, not 1 (SPEC §2).
265
+ if (err instanceof UsageError)
266
+ return fail(err.message, err.exitCode);
242
267
  if (err instanceof CliError)
243
- fail(err.message);
268
+ return fail(err.message, err.exitCode);
244
269
  throw err;
245
270
  }
246
271
  const opts = command.opts;
247
272
  if (opts.help) {
248
- console.log(command.verb === "service" ? SERVICE_USAGE : command.verb === "config" ? CONFIG_USAGE : USAGE);
249
- return;
273
+ console.log(command.verb === "service"
274
+ ? SERVICE_USAGE
275
+ : command.verb === "config"
276
+ ? CONFIG_USAGE
277
+ : command.verb === "busy"
278
+ ? BUSY_USAGE
279
+ : USAGE);
280
+ return EXIT.ok;
250
281
  }
251
282
  if (opts.version) {
252
- console.log(readVersion());
253
- return;
283
+ // Bare: the version and a newline, nothing else (SPEC §1). Scripts parse this.
284
+ console.log(PADDOCK_VERSION);
285
+ return EXIT.ok;
254
286
  }
255
287
  const problem = nodeVersionProblem(process.versions.node);
256
288
  if (problem !== undefined)
257
- fail(problem);
258
- if (command.verb === "service") {
259
- await service(command);
260
- return;
261
- }
262
- if (command.verb === "config") {
263
- await config(command);
264
- return;
289
+ return fail(problem);
290
+ if (command.verb === "service")
291
+ return service(command);
292
+ if (command.verb === "config")
293
+ return config(command);
294
+ // `busy` (SPEC §1, §4). Nothing is imported from the server for this: the
295
+ // answer comes off the wire, and `cli/busy.ts` is Node built-ins only — which
296
+ // is what keeps a question meant for a Watchtower hook from loading Fastify,
297
+ // herdctl and the project layer to ask it.
298
+ if (command.verb === "busy") {
299
+ const { runBusy } = await import("./busy-command.js");
300
+ return runBusy(resolveDataDir(opts), opts.json === true);
265
301
  }
266
302
  addBundledBinsToPath();
267
303
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
@@ -330,9 +366,14 @@ async function main() {
330
366
  await start();
331
367
  }
332
368
  catch (err) {
333
- fail(explainListenError(err, host, port));
369
+ return fail(explainListenError(err, host, port));
334
370
  }
335
371
  const url = `http://${host}:${port}`;
372
+ // The contract's banner line, FIRST on stdout and in exactly this shape:
373
+ // `<Name> <version>: <url>` (SPEC §3). It is parsed — by the conformance
374
+ // runner, and by anything that wants the URL without guessing the port — so
375
+ // the friendly block below it stays, but below it.
376
+ console.log(`${PADDOCK_NAME} ${PADDOCK_VERSION}: ${url}`);
336
377
  // Still name the data dir, even though it no longer varies with cwd: "which
337
378
  // instance am I looking at" is a real question the moment anyone passes
338
379
  // `--data-dir`, and the answer should be readable off the two lines the tool
@@ -346,8 +387,28 @@ async function main() {
346
387
  ].join("\n"));
347
388
  if (opts.open)
348
389
  openBrowser(url);
390
+ // 0, but NOT yet: a foreground serve keeps running because the server holds
391
+ // the event loop open. `process.exitCode` is set and never acted on until the
392
+ // listener closes, which is what SIGTERM does (start.ts).
393
+ return EXIT.ok;
349
394
  }
350
- main().catch((err) => {
395
+ /**
396
+ * A closed pipe exits quietly (SPEC §2).
397
+ *
398
+ * `paddock service logs | head -5` closes the pipe under us, and Node's default
399
+ * is an unhandled EPIPE with a stack trace — printed to the terminal, after the
400
+ * output the user asked for, about a thing that is not a problem.
401
+ */
402
+ process.stdout.on("error", (err) => {
403
+ if (err.code === "EPIPE")
404
+ process.exit(0);
405
+ });
406
+ main().then((code) => {
407
+ // `process.exitCode`, never `process.exit()` (SPEC §2): exiting mid-write
408
+ // truncates piped output, which is how `paddock config show --json | jq`
409
+ // loses the end of a large document.
410
+ process.exitCode = code;
411
+ }, (err) => {
351
412
  console.error("fatal:", err);
352
- process.exit(1);
413
+ process.exitCode = EXIT.failure;
353
414
  });