@edspencer/paddock 0.80.0 → 0.82.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 +1 -1
  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 +70 -29
  11. package/packages/server/dist/cli/args.js +306 -7
  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/output.d.ts +52 -0
  17. package/packages/server/dist/cli/output.js +84 -0
  18. package/packages/server/dist/cli/paddock.js +65 -21
  19. package/packages/server/dist/cli/service/index.d.ts +64 -0
  20. package/packages/server/dist/cli/service/index.js +208 -7
  21. package/packages/server/dist/cli/service/launchd.d.ts +15 -1
  22. package/packages/server/dist/cli/service/launchd.js +116 -18
  23. package/packages/server/dist/cli/service/ready.d.ts +30 -0
  24. package/packages/server/dist/cli/service/ready.js +35 -0
  25. package/packages/server/dist/cli/service/systemd.js +27 -0
  26. package/packages/server/dist/cli/update/install-method.d.ts +131 -0
  27. package/packages/server/dist/cli/update/install-method.js +258 -0
  28. package/packages/server/dist/cli/update/lock.d.ts +40 -0
  29. package/packages/server/dist/cli/update/lock.js +108 -0
  30. package/packages/server/dist/cli/update/package-manager.d.ts +109 -0
  31. package/packages/server/dist/cli/update/package-manager.js +221 -0
  32. package/packages/server/dist/cli/update/registry.d.ts +89 -0
  33. package/packages/server/dist/cli/update/registry.js +145 -0
  34. package/packages/server/dist/cli/update/semver.d.ts +68 -0
  35. package/packages/server/dist/cli/update/semver.js +232 -0
  36. package/packages/server/dist/cli/update/update-check.d.ts +80 -0
  37. package/packages/server/dist/cli/update/update-check.js +116 -0
  38. package/packages/server/dist/cli/update/update.d.ts +160 -0
  39. package/packages/server/dist/cli/update/update.js +609 -0
  40. package/packages/server/dist/cli/update-command.d.ts +20 -0
  41. package/packages/server/dist/cli/update-command.js +113 -0
  42. package/packages/server/dist/herdctl.d.ts +19 -0
  43. package/packages/server/dist/herdctl.js +26 -0
  44. package/packages/server/dist/routes/meta.js +45 -0
  45. package/packages/server/dist/runtime-file.d.ts +26 -0
  46. package/packages/server/dist/runtime-file.js +91 -0
  47. package/packages/server/dist/start.d.ts +33 -0
  48. package/packages/server/dist/start.js +115 -8
  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-DHczLhwQ.js → DeleteProjectDialog-CphdGB3R.js} +1 -1
  53. package/packages/web/dist/assets/DiscoverPage-C4OPLNO8.js +2 -0
  54. package/packages/web/dist/assets/{DiscoverView-DtTOpTji.js → DiscoverView-B_kcF4sm.js} +1 -1
  55. package/packages/web/dist/assets/{InstanceConfigPage-BRoBh1C1.js → InstanceConfigPage-CvUK5DKW.js} +1 -1
  56. package/packages/web/dist/assets/{MarkdownRenderer-DJf8IJuk.js → MarkdownRenderer-DvZ-j_Ao.js} +1 -1
  57. package/packages/web/dist/assets/{ProjectRedirect-EioguUKW.js → ProjectRedirect-DayHvUH2.js} +1 -1
  58. package/packages/web/dist/assets/{ProjectView-BiKnGfk6.js → ProjectView-CCw83gUi.js} +3 -3
  59. package/packages/web/dist/assets/{ProjectsGrid-D6kfaIal.js → ProjectsGrid-B3Klg2H4.js} +1 -1
  60. package/packages/web/dist/assets/{RootHome-DpcyuV6e.js → RootHome-BxcDvpgI.js} +1 -1
  61. package/packages/web/dist/assets/{Surfaces-CHM29PhP.js → Surfaces-7UlhFBSG.js} +1 -1
  62. package/packages/web/dist/assets/{arc-fiqD05Hu.js → arc-DNkh5o5Q.js} +1 -1
  63. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BuuB2Nkf.js → architectureDiagram-3BPJPVTR-BrgN3oLF.js} +1 -1
  64. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DQMBuzfi.js → blockDiagram-GPEHLZMM-D05XOJSk.js} +1 -1
  65. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BKN0UxCH.js → c4Diagram-AAUBKEIU-CKaMBhMP.js} +1 -1
  66. package/packages/web/dist/assets/channel-JrEedxB8.js +2 -0
  67. package/packages/web/dist/assets/{chunk-2J33WTMH-DW37EupX.js → chunk-2J33WTMH-8hgadCQc.js} +1 -1
  68. package/packages/web/dist/assets/{chunk-4BX2VUAB-Dyv7zV_c.js → chunk-4BX2VUAB-CBfEC-pu.js} +1 -1
  69. package/packages/web/dist/assets/{chunk-55IACEB6-DzMR0dOk.js → chunk-55IACEB6-DV1eMgHk.js} +1 -1
  70. package/packages/web/dist/assets/{chunk-727SXJPM-BdZ3Sor8.js → chunk-727SXJPM-Cfgl5fxY.js} +1 -1
  71. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B4i7_og0.js → chunk-AQP2D5EJ-BXhqD295.js} +1 -1
  72. package/packages/web/dist/assets/{chunk-FMBD7UC4-jcTr85it.js → chunk-FMBD7UC4-TFGOPtek.js} +1 -1
  73. package/packages/web/dist/assets/{chunk-ND2GUHAM-fgMsadqB.js → chunk-ND2GUHAM-CHFM665R.js} +1 -1
  74. package/packages/web/dist/assets/{chunk-QZHKN3VN-bJNHYS1W.js → chunk-QZHKN3VN-CqEf6MMT.js} +1 -1
  75. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CHJiQp-W.js +2 -0
  76. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CHJiQp-W.js +2 -0
  77. package/packages/web/dist/assets/{core-Bk4KFrmN.js → core-DmEaKxBb.js} +1 -1
  78. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIqCSlQy.js → cose-bilkent-S5V4N54A-BnGI6jkr.js} +1 -1
  79. package/packages/web/dist/assets/{dagre-BM42HDAG-6nyhtVQz.js → dagre-BM42HDAG-CKoPx61E.js} +1 -1
  80. package/packages/web/dist/assets/{diagram-2AECGRRQ-CjizfipR.js → diagram-2AECGRRQ-mMmtlBl1.js} +1 -1
  81. package/packages/web/dist/assets/{diagram-5GNKFQAL-DlgB5vWb.js → diagram-5GNKFQAL-Cdz9Mtnt.js} +1 -1
  82. package/packages/web/dist/assets/{diagram-KO2AKTUF-CNC9qanV.js → diagram-KO2AKTUF-DQxM3wYj.js} +1 -1
  83. package/packages/web/dist/assets/{diagram-LMA3HP47-DSJ-s9lh.js → diagram-LMA3HP47-BWrinPsq.js} +1 -1
  84. package/packages/web/dist/assets/{diagram-OG6HWLK6-C9FoHBDg.js → diagram-OG6HWLK6-U6MYUve2.js} +1 -1
  85. package/packages/web/dist/assets/{documentTitle-Cj_AY97j.js → documentTitle-DGjRXuTP.js} +1 -1
  86. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-z6gku4Yt.js → erDiagram-TEJ5UH35-xqMCsXxY.js} +1 -1
  87. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Dl5c_FgU.js → flowDiagram-I6XJVG4X-PMfy_czF.js} +1 -1
  88. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CIscJ-Dt.js → ganttDiagram-6RSMTGT7-whhYYE4N.js} +1 -1
  89. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CvttOXM_.js → gitGraphDiagram-PVQCEYII-C51JW4tf.js} +1 -1
  90. package/packages/web/dist/assets/{index-DN82do6Z.js → index-CHOtvlnO.js} +2 -2
  91. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-dzWYdDJj.js → infoDiagram-5YYISTIA-SH8cr905.js} +1 -1
  92. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-8yX9k4bo.js → ishikawaDiagram-YF4QCWOH-BjqaoU3U.js} +1 -1
  93. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BmitzJDR.js → journeyDiagram-JHISSGLW-CBmdEVrd.js} +1 -1
  94. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BZmIkfkW.js → kanban-definition-UN3LZRKU-U8njjMLZ.js} +1 -1
  95. package/packages/web/dist/assets/{linear-BBLXE4o7.js → linear-DF66GPs1.js} +1 -1
  96. package/packages/web/dist/assets/{mermaid.core-mSKff4yu.js → mermaid.core-CouJz7Ph.js} +4 -4
  97. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BWAUa4CP.js → mindmap-definition-RKZ34NQL-BYIf0oFt.js} +1 -1
  98. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-VmVdiL4C.js → pieDiagram-4H26LBE5-Cm8rgg09.js} +1 -1
  99. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Dybfv6NU.js → quadrantDiagram-W4KKPZXB-C3Il5kOH.js} +1 -1
  100. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BPnOvB7w.js → requirementDiagram-4Y6WPE33-D3PE2nTe.js} +1 -1
  101. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-YXIEmKw7.js → sankeyDiagram-5OEKKPKP-DdmvBM18.js} +1 -1
  102. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DintIRUe.js → sequenceDiagram-3UESZ5HK-B8M_GAIq.js} +1 -1
  103. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-eMsjahWp.js → stateDiagram-AJRCARHV-DZkXnK1_.js} +1 -1
  104. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CjCdT9Ls.js +2 -0
  105. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-as4zRCPn.js → timeline-definition-PNZ67QCA-IHXq7rqi.js} +1 -1
  106. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CPzvUkM0.js → vennDiagram-CIIHVFJN-Dga7tLtT.js} +1 -1
  107. package/packages/web/dist/assets/{wardley-L42UT6IY-q6moXj5g.js → wardley-L42UT6IY-CmcwnEJ-.js} +1 -1
  108. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BsEEb9Gm.js → wardleyDiagram-YWT4CUSO-zHuSLrD4.js} +1 -1
  109. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DSgZlcZW.js → xychartDiagram-2RQKCTM6-D93MU_ic.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-BSGSzTEs.js +0 -2
  113. package/packages/web/dist/assets/channel-B7yh6Su_.js +0 -2
  114. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +0 -2
  115. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +0 -2
  116. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.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,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,7 +23,7 @@ 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, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
26
+ import { CliError, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, BUSY_USAGE, UPDATE_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
27
  import { EXIT } from "./exit-codes.js";
28
28
  import { runService, safeHomeDir, realpathOr } from "./service/index.js";
29
29
  import { PADDOCK_NAME, PADDOCK_VERSION } from "../version.js";
@@ -178,25 +178,7 @@ async function service(command) {
178
178
  return EXIT.ok;
179
179
  }
180
180
  try {
181
- return await runService(action, opts, {
182
- platform: process.platform,
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),
189
- scriptPath: entryScript,
190
- packageRoot,
191
- homeDir: safeHomeDir(),
192
- ...(process.env.PADDOCK_DATA_DIR !== undefined
193
- ? { envDataDir: process.env.PADDOCK_DATA_DIR }
194
- : {}),
195
- ...(process.env.XDG_CONFIG_HOME !== undefined
196
- ? { xdgConfigHome: process.env.XDG_CONFIG_HOME }
197
- : {}),
198
- ...(process.env.PATH !== undefined ? { pathEnv: process.env.PATH } : {}),
199
- });
181
+ return await runService(action, opts, serviceContext());
200
182
  }
201
183
  catch (err) {
202
184
  // A CliError carries its own code: an unsupported platform or an npx
@@ -208,6 +190,35 @@ async function service(command) {
208
190
  throw err;
209
191
  }
210
192
  }
193
+ /**
194
+ * The facts about THIS install that every unit-reading command needs.
195
+ *
196
+ * One function, used by `paddock service` and by `paddock update`, because the
197
+ * two have to agree: an `update` that refreshed a unit built from a different
198
+ * Node path or a different script than `service status` reports would be the
199
+ * worst kind of nearly-working.
200
+ */
201
+ function serviceContext() {
202
+ return {
203
+ platform: process.platform,
204
+ // Realpath'd (SPEC §7). `process.execPath` under a version manager is
205
+ // routinely a symlink into a per-shell directory — fnm's
206
+ // `fnm_multishells/<pid>_<ts>/bin/node` is deleted when that terminal
207
+ // exits — so a unit naming the symlink stops working when the tab closes,
208
+ // at some later login, with nothing in the logs because it never ran.
209
+ nodePath: realpathOr(process.execPath),
210
+ scriptPath: entryScript,
211
+ packageRoot,
212
+ homeDir: safeHomeDir(),
213
+ ...(process.env.PADDOCK_DATA_DIR !== undefined
214
+ ? { envDataDir: process.env.PADDOCK_DATA_DIR }
215
+ : {}),
216
+ ...(process.env.XDG_CONFIG_HOME !== undefined
217
+ ? { xdgConfigHome: process.env.XDG_CONFIG_HOME }
218
+ : {}),
219
+ ...(process.env.PATH !== undefined ? { pathEnv: process.env.PATH } : {}),
220
+ };
221
+ }
211
222
  /**
212
223
  * Which instance this invocation is about: an explicit flag, else the
213
224
  * environment, else `~/.paddock`.
@@ -270,7 +281,15 @@ async function main() {
270
281
  }
271
282
  const opts = command.opts;
272
283
  if (opts.help) {
273
- console.log(command.verb === "service" ? SERVICE_USAGE : command.verb === "config" ? CONFIG_USAGE : USAGE);
284
+ console.log(command.verb === "service"
285
+ ? SERVICE_USAGE
286
+ : command.verb === "config"
287
+ ? CONFIG_USAGE
288
+ : command.verb === "busy"
289
+ ? BUSY_USAGE
290
+ : command.verb === "update"
291
+ ? UPDATE_USAGE
292
+ : USAGE);
274
293
  return EXIT.ok;
275
294
  }
276
295
  if (opts.version) {
@@ -285,6 +304,31 @@ async function main() {
285
304
  return service(command);
286
305
  if (command.verb === "config")
287
306
  return config(command);
307
+ // `busy` (SPEC §1, §4). Nothing is imported from the server for this: the
308
+ // answer comes off the wire, and `cli/busy.ts` is Node built-ins only — which
309
+ // is what keeps a question meant for a Watchtower hook from loading Fastify,
310
+ // herdctl and the project layer to ask it.
311
+ if (command.verb === "busy") {
312
+ const { runBusy } = await import("./busy-command.js");
313
+ return runBusy(resolveDataDir(opts), opts.json === true);
314
+ }
315
+ // `update` (SPEC §1, §5). Imported dynamically for the same reason `busy` is:
316
+ // nothing in it needs the server, and a command whose whole job is to replace
317
+ // the files it would have loaded has particularly good reason not to load
318
+ // them.
319
+ if (command.verb === "update") {
320
+ const { runUpdateCommand } = await import("./update-command.js");
321
+ try {
322
+ return await runUpdateCommand(opts, serviceContext());
323
+ }
324
+ catch (err) {
325
+ if (err instanceof CliError)
326
+ return fail(err.message, err.exitCode);
327
+ if (err instanceof Error)
328
+ return fail(err.message);
329
+ throw err;
330
+ }
331
+ }
288
332
  addBundledBinsToPath();
289
333
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
290
334
  // used to make the current directory the workspace by setting
@@ -1,6 +1,7 @@
1
1
  import { type CliOptions, type ServiceAction } from "../args.js";
2
2
  import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
3
  import { type Runner } from "./backend.js";
4
+ import { type UpdateSummary } from "../update/update-check.js";
4
5
  import { type HealthAnswer, type Readiness } from "./ready.js";
5
6
  export interface ServiceContext {
6
7
  /** `process.platform`. */
@@ -31,6 +32,18 @@ export interface ServiceContext {
31
32
  * is — `status` has to be assertable without a server to ask.
32
33
  */
33
34
  probeHealth?: (url: string) => Promise<HealthAnswer | undefined>;
35
+ /**
36
+ * The busy check `stop` and `restart` run first (SPEC §1, §4): null to go
37
+ * ahead, or 75 having printed the refusal.
38
+ *
39
+ * Injected for the third time for the third version of the same reason: the
40
+ * real one reads `<home>/runtime.json` and fetches a URL, so a test of "does
41
+ * stop refuse" would otherwise need a server to be busy at it.
42
+ */
43
+ guardBusy?: (action: "stop" | "restart", home: string, opts: {
44
+ force?: boolean;
45
+ json?: boolean;
46
+ }) => Promise<number | null>;
34
47
  }
35
48
  /**
36
49
  * The version of the Paddock the unit points at.
@@ -72,6 +85,22 @@ export interface StatusReport {
72
85
  cli: string | null;
73
86
  /** Linux only: null where the platform has no such concept. */
74
87
  linger: boolean | null;
88
+ /**
89
+ * What the last `update --check` found, from `<data-dir>/update-check.json`
90
+ * (SPEC §3).
91
+ *
92
+ * `status` never asks the registry itself — the contract is explicit that
93
+ * nothing contacts it by itself, and a status command run by a monitoring
94
+ * loop would be exactly the background check §3 forbids. So this repeats a
95
+ * file somebody's `update --check` or `doctor` wrote.
96
+ *
97
+ * **Absent, not null, when there is nothing to repeat.** The contract's
98
+ * `schemas/service-status.schema.json` types this key as an OBJECT, so a
99
+ * `null` fails the schema and the conformance runner says so — which is how
100
+ * this was found. The other optional fields here are `| null` because the
101
+ * schema types THOSE as nullable; this one it does not.
102
+ */
103
+ update?: UpdateSummary;
75
104
  /** A stale unit, a missing Node — things that are wrong but not fatal. */
76
105
  warnings: string[];
77
106
  }
@@ -82,6 +111,41 @@ export interface StatusReport {
82
111
  * never `process.exit()`, which truncates piped output mid-write.
83
112
  */
84
113
  export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): Promise<number>;
114
+ /**
115
+ * What `paddock update` needs to know and do about the service (SPEC §5).
116
+ *
117
+ * Built here rather than in the update module so that everything which reads a
118
+ * unit file stays in one place: `update` gets the same answer `status` and
119
+ * `logs` get, from the same backend, including the #824 rule that the instance
120
+ * in question is the one the UNIT describes and not whatever `--data-dir` the
121
+ * invocation carries.
122
+ *
123
+ * Returns `home` too, which is the data dir `update.lock` and
124
+ * `update-check.json` go in. It has to be the unit's: the cache's reader is
125
+ * `service status`, which looks in the unit's data dir, so writing it anywhere
126
+ * else would be writing a file nothing reads.
127
+ */
128
+ export declare function updateServiceHooks(ctx: ServiceContext): {
129
+ hooks: ServiceHooksForUpdate;
130
+ home: string;
131
+ installed: boolean;
132
+ };
133
+ /** The shape {@link updateServiceHooks} returns — structurally `UpdateServiceHooks`. */
134
+ export interface ServiceHooksForUpdate {
135
+ inspect(): {
136
+ installed: boolean;
137
+ running: boolean;
138
+ url?: string;
139
+ log?: string;
140
+ };
141
+ stop(): void;
142
+ refresh(cliPath: string): {
143
+ status: number | null;
144
+ stdout: string;
145
+ stderr: string;
146
+ };
147
+ tail(lines: number): string;
148
+ }
85
149
  /** Exported for the entrypoint's context assembly, and for tests. */
86
150
  export { serviceDir, isNpxPath };
87
151
  export type { ServiceSpec };