@edspencer/paddock 0.80.0 → 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 (97) 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 +20 -4
  11. package/packages/server/dist/cli/args.js +120 -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 +16 -2
  19. package/packages/server/dist/cli/service/index.d.ts +12 -0
  20. package/packages/server/dist/cli/service/index.js +38 -4
  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/herdctl.d.ts +19 -0
  24. package/packages/server/dist/herdctl.js +26 -0
  25. package/packages/server/dist/routes/meta.js +45 -0
  26. package/packages/server/dist/runtime-file.d.ts +26 -0
  27. package/packages/server/dist/runtime-file.js +91 -0
  28. package/packages/server/dist/start.d.ts +33 -0
  29. package/packages/server/dist/start.js +115 -8
  30. package/packages/server/dist/ws-context.d.ts +9 -0
  31. package/packages/server/dist/ws-turn.js +1 -0
  32. package/packages/server/package.json +1 -1
  33. package/packages/web/dist/assets/{DeleteProjectDialog-DHczLhwQ.js → DeleteProjectDialog-D8oIJ8HC.js} +1 -1
  34. package/packages/web/dist/assets/DiscoverPage-CQlzxsn4.js +2 -0
  35. package/packages/web/dist/assets/{DiscoverView-DtTOpTji.js → DiscoverView-o0KCr3aB.js} +1 -1
  36. package/packages/web/dist/assets/{InstanceConfigPage-BRoBh1C1.js → InstanceConfigPage-RSJz7oL3.js} +1 -1
  37. package/packages/web/dist/assets/{MarkdownRenderer-DJf8IJuk.js → MarkdownRenderer-39mafH8b.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectRedirect-EioguUKW.js → ProjectRedirect-Bq7GVguY.js} +1 -1
  39. package/packages/web/dist/assets/{ProjectView-BiKnGfk6.js → ProjectView-BoBIru1g.js} +3 -3
  40. package/packages/web/dist/assets/{ProjectsGrid-D6kfaIal.js → ProjectsGrid-mJ4gqbr6.js} +1 -1
  41. package/packages/web/dist/assets/{RootHome-DpcyuV6e.js → RootHome-C0kwFyYp.js} +1 -1
  42. package/packages/web/dist/assets/{Surfaces-CHM29PhP.js → Surfaces-MkhXlB8z.js} +1 -1
  43. package/packages/web/dist/assets/{arc-fiqD05Hu.js → arc-DExlwAwO.js} +1 -1
  44. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BuuB2Nkf.js → architectureDiagram-3BPJPVTR-CZly3W79.js} +1 -1
  45. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DQMBuzfi.js → blockDiagram-GPEHLZMM-AgES73i3.js} +1 -1
  46. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-BKN0UxCH.js → c4Diagram-AAUBKEIU-N9mD8Pfk.js} +1 -1
  47. package/packages/web/dist/assets/channel-ExsAG7qT.js +2 -0
  48. package/packages/web/dist/assets/{chunk-2J33WTMH-DW37EupX.js → chunk-2J33WTMH-Dsx_kfUl.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-4BX2VUAB-Dyv7zV_c.js → chunk-4BX2VUAB-BQjqcjPj.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-55IACEB6-DzMR0dOk.js → chunk-55IACEB6-CCxpYQpY.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-727SXJPM-BdZ3Sor8.js → chunk-727SXJPM-DpFRdnUa.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-AQP2D5EJ-B4i7_og0.js → chunk-AQP2D5EJ-D2BAlzoO.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-FMBD7UC4-jcTr85it.js → chunk-FMBD7UC4-BFaMRzUF.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-ND2GUHAM-fgMsadqB.js → chunk-ND2GUHAM-B9NGJ9xZ.js} +1 -1
  55. package/packages/web/dist/assets/{chunk-QZHKN3VN-bJNHYS1W.js → chunk-QZHKN3VN-CF1IHyMS.js} +1 -1
  56. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-si7g9okq.js +2 -0
  57. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-si7g9okq.js +2 -0
  58. package/packages/web/dist/assets/{core-Bk4KFrmN.js → core-XjElsvQ7.js} +1 -1
  59. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BIqCSlQy.js → cose-bilkent-S5V4N54A-CF972Flx.js} +1 -1
  60. package/packages/web/dist/assets/{dagre-BM42HDAG-6nyhtVQz.js → dagre-BM42HDAG-DsneI8YE.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-2AECGRRQ-CjizfipR.js → diagram-2AECGRRQ-BHyrVzIk.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-5GNKFQAL-DlgB5vWb.js → diagram-5GNKFQAL-Dj4q8xEi.js} +1 -1
  63. package/packages/web/dist/assets/{diagram-KO2AKTUF-CNC9qanV.js → diagram-KO2AKTUF-BHe3Puv_.js} +1 -1
  64. package/packages/web/dist/assets/{diagram-LMA3HP47-DSJ-s9lh.js → diagram-LMA3HP47-wzt6G2FE.js} +1 -1
  65. package/packages/web/dist/assets/{diagram-OG6HWLK6-C9FoHBDg.js → diagram-OG6HWLK6-CZMbMRJq.js} +1 -1
  66. package/packages/web/dist/assets/{documentTitle-Cj_AY97j.js → documentTitle-BwTIA8rI.js} +1 -1
  67. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-z6gku4Yt.js → erDiagram-TEJ5UH35-Dtu14BL1.js} +1 -1
  68. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Dl5c_FgU.js → flowDiagram-I6XJVG4X-T34ql9j0.js} +1 -1
  69. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-CIscJ-Dt.js → ganttDiagram-6RSMTGT7-DQLOeCXK.js} +1 -1
  70. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CvttOXM_.js → gitGraphDiagram-PVQCEYII-Bk11zea6.js} +1 -1
  71. package/packages/web/dist/assets/{index-DN82do6Z.js → index-CvaWErFv.js} +2 -2
  72. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-dzWYdDJj.js → infoDiagram-5YYISTIA-CsE83lu0.js} +1 -1
  73. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-8yX9k4bo.js → ishikawaDiagram-YF4QCWOH-DNVgA93n.js} +1 -1
  74. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-BmitzJDR.js → journeyDiagram-JHISSGLW-CLpSNjM3.js} +1 -1
  75. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BZmIkfkW.js → kanban-definition-UN3LZRKU-BRzuXcKr.js} +1 -1
  76. package/packages/web/dist/assets/{linear-BBLXE4o7.js → linear-BxOawONt.js} +1 -1
  77. package/packages/web/dist/assets/{mermaid.core-mSKff4yu.js → mermaid.core-Dvs1QCcz.js} +4 -4
  78. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BWAUa4CP.js → mindmap-definition-RKZ34NQL-Dmt7pYwF.js} +1 -1
  79. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-VmVdiL4C.js → pieDiagram-4H26LBE5-BHiZaJD-.js} +1 -1
  80. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Dybfv6NU.js → quadrantDiagram-W4KKPZXB-U0A1noRE.js} +1 -1
  81. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BPnOvB7w.js → requirementDiagram-4Y6WPE33-CZLsr6yi.js} +1 -1
  82. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-YXIEmKw7.js → sankeyDiagram-5OEKKPKP-BTlLGILU.js} +1 -1
  83. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-DintIRUe.js → sequenceDiagram-3UESZ5HK-DpSS_Gb1.js} +1 -1
  84. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-eMsjahWp.js → stateDiagram-AJRCARHV-D9srE-tV.js} +1 -1
  85. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-AxWqsDh-.js +2 -0
  86. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-as4zRCPn.js → timeline-definition-PNZ67QCA-BYQ5CKab.js} +1 -1
  87. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CPzvUkM0.js → vennDiagram-CIIHVFJN-B4W2CtSV.js} +1 -1
  88. package/packages/web/dist/assets/{wardley-L42UT6IY-q6moXj5g.js → wardley-L42UT6IY--XUV7GvD.js} +1 -1
  89. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BsEEb9Gm.js → wardleyDiagram-YWT4CUSO-Bl2VIEGY.js} +1 -1
  90. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DSgZlcZW.js → xychartDiagram-2RQKCTM6-Bj2H9Rcf.js} +1 -1
  91. package/packages/web/dist/index.html +1 -1
  92. package/packages/web/dist/sw.js +1 -1
  93. package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +0 -2
  94. package/packages/web/dist/assets/channel-B7yh6Su_.js +0 -2
  95. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +0 -2
  97. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +0 -2
@@ -0,0 +1,58 @@
1
+ import { type BusyResult, type Nouns } from "./busy.js";
2
+ /**
3
+ * How each count in `/api/busy` is said in a sentence (`busy.ts` on the server
4
+ * decides the keys).
5
+ *
6
+ * `background` is spelled out rather than left to the default pluraliser,
7
+ * because "3 backgrounds are running" is not English — and because "background
8
+ * task" is the thing a user recognises from the running-work bar in the UI,
9
+ * where they watched it start.
10
+ */
11
+ export declare const PADDOCK_NOUNS: Nouns;
12
+ /**
13
+ * What to do about a busy answer — and for Paddock, the first thing to say is
14
+ * **where to run the command from**.
15
+ *
16
+ * This is the keeper-chat trap, and it is specific to Paddock and Managers
17
+ * among the six tools: Paddock's agents can drive Paddock. A keeper chat that
18
+ * runs `paddock service restart` or `paddock update` is itself one of the chat
19
+ * turns the answer just counted, so it will always see itself as busy — and
20
+ * `--force` from there does not override a mistaken reading, it kills the chat
21
+ * that typed it, mid-sentence, along with whatever it had not finished. The
22
+ * agent then has no way to report what happened, because the thing that would
23
+ * report it is the thing that died.
24
+ *
25
+ * So when `chat` is non-zero the advice is not "wait or use --force": it is
26
+ * "run this from a terminal". When the busy work is background tasks or
27
+ * scheduled jobs only, nobody is holding the keyboard inside the service and
28
+ * the ordinary advice applies.
29
+ */
30
+ export declare function paddockBusyHint(result: BusyResult): string | undefined;
31
+ /**
32
+ * `paddock busy [--json]` — exit 0 not busy, 75 busy, 3 nothing answering
33
+ * (SPEC §1, §2).
34
+ *
35
+ * The instance is found through `<data-dir>/runtime.json` and nowhere else, so
36
+ * this reports on the same instance `paddock serve` and `paddock config show`
37
+ * resolve — `resolveDataDir` in paddock.ts is the one rule all three use.
38
+ */
39
+ export declare function runBusy(home: string, json: boolean): Promise<number>;
40
+ /**
41
+ * The check before `service stop` and `service restart` (SPEC §1): refuse with
42
+ * 75 unless `--force`, which prints the same message prefixed `Overriding:` and
43
+ * carries on. Returns null to go ahead.
44
+ *
45
+ * `home` is the data dir of the INSTALLED unit, not whatever `--data-dir` this
46
+ * invocation carries — the same rule `status` and `logs` follow (#824), because
47
+ * the instance being stopped is the one the unit describes.
48
+ *
49
+ * Two deliberate non-refusals. Nothing answering goes ahead: there is nothing
50
+ * to cut short, which is also the answer for a service that is already stopped.
51
+ * And an instance running a Paddock from before this release has no
52
+ * `runtime.json`, so it reads as not running and the stop proceeds exactly as
53
+ * it did before — an upgrade cannot make a stop start failing.
54
+ */
55
+ export declare function guardServiceBusy(action: "stop" | "restart", home: string, opts: {
56
+ force?: boolean;
57
+ json?: boolean;
58
+ }): Promise<number | null>;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Paddock's bindings for the copied busy client (`cli/busy.ts`): the nouns its
3
+ * message uses, the keeper-chat trap, and the two entry points — `paddock busy`
4
+ * and the check `service stop` / `service restart` run first (CLI contract
5
+ * SPEC §1, §4).
6
+ *
7
+ * Kept separate from `cli/busy.ts` so that file stays a straight copy of
8
+ * `reference/busy-client.ts`, which is what wave C4 re-syncs. Everything in
9
+ * here is Paddock-specific by definition: the contract says each tool's kinds of
10
+ * work are its own.
11
+ */
12
+ import { EXIT } from "./exit-codes.js";
13
+ import { createOutput } from "./output.js";
14
+ import { guardBusy, runBusyCommand, } from "./busy.js";
15
+ import { PADDOCK_NAME } from "../version.js";
16
+ /**
17
+ * How each count in `/api/busy` is said in a sentence (`busy.ts` on the server
18
+ * decides the keys).
19
+ *
20
+ * `background` is spelled out rather than left to the default pluraliser,
21
+ * because "3 backgrounds are running" is not English — and because "background
22
+ * task" is the thing a user recognises from the running-work bar in the UI,
23
+ * where they watched it start.
24
+ */
25
+ export const PADDOCK_NOUNS = {
26
+ chat: ["chat turn", "chat turns"],
27
+ background: ["background task", "background tasks"],
28
+ job: ["scheduled job", "scheduled jobs"],
29
+ };
30
+ /**
31
+ * What to do about a busy answer — and for Paddock, the first thing to say is
32
+ * **where to run the command from**.
33
+ *
34
+ * This is the keeper-chat trap, and it is specific to Paddock and Managers
35
+ * among the six tools: Paddock's agents can drive Paddock. A keeper chat that
36
+ * runs `paddock service restart` or `paddock update` is itself one of the chat
37
+ * turns the answer just counted, so it will always see itself as busy — and
38
+ * `--force` from there does not override a mistaken reading, it kills the chat
39
+ * that typed it, mid-sentence, along with whatever it had not finished. The
40
+ * agent then has no way to report what happened, because the thing that would
41
+ * report it is the thing that died.
42
+ *
43
+ * So when `chat` is non-zero the advice is not "wait or use --force": it is
44
+ * "run this from a terminal". When the busy work is background tasks or
45
+ * scheduled jobs only, nobody is holding the keyboard inside the service and
46
+ * the ordinary advice applies.
47
+ */
48
+ export function paddockBusyHint(result) {
49
+ if (result.state !== "busy")
50
+ return undefined;
51
+ const chats = result.answer.counts?.chat ?? 0;
52
+ if (chats > 0) {
53
+ // The lead sentence is short because `guardBusy` prints it on the same line
54
+ // as the message, after `paddock: `. The reasoning goes underneath, indented
55
+ // like every other continuation this CLI prints.
56
+ return ("Run it from a terminal, not from inside a Paddock chat.\n" +
57
+ " One of the turns counted above may be the chat asking, and --force from there\n" +
58
+ " stops the service running that chat — which then never gets to say so.\n" +
59
+ " Otherwise: wait for the turns to finish.");
60
+ }
61
+ return "Try again once it has finished, or use --force.";
62
+ }
63
+ /** The options both entry points share: name, nouns and hint are fixed for Paddock. */
64
+ function paddockBusyOptions(home, out) {
65
+ return { home, out, name: PADDOCK_NAME, nouns: PADDOCK_NOUNS, hint: paddockBusyHint };
66
+ }
67
+ /**
68
+ * `paddock busy [--json]` — exit 0 not busy, 75 busy, 3 nothing answering
69
+ * (SPEC §1, §2).
70
+ *
71
+ * The instance is found through `<data-dir>/runtime.json` and nowhere else, so
72
+ * this reports on the same instance `paddock serve` and `paddock config show`
73
+ * resolve — `resolveDataDir` in paddock.ts is the one rule all three use.
74
+ */
75
+ export async function runBusy(home, json) {
76
+ const out = createOutput({ tool: "paddock", json });
77
+ return runBusyCommand(paddockBusyOptions(home, out));
78
+ }
79
+ /**
80
+ * The check before `service stop` and `service restart` (SPEC §1): refuse with
81
+ * 75 unless `--force`, which prints the same message prefixed `Overriding:` and
82
+ * carries on. Returns null to go ahead.
83
+ *
84
+ * `home` is the data dir of the INSTALLED unit, not whatever `--data-dir` this
85
+ * invocation carries — the same rule `status` and `logs` follow (#824), because
86
+ * the instance being stopped is the one the unit describes.
87
+ *
88
+ * Two deliberate non-refusals. Nothing answering goes ahead: there is nothing
89
+ * to cut short, which is also the answer for a service that is already stopped.
90
+ * And an instance running a Paddock from before this release has no
91
+ * `runtime.json`, so it reads as not running and the stop proceeds exactly as
92
+ * it did before — an upgrade cannot make a stop start failing.
93
+ */
94
+ export async function guardServiceBusy(action, home, opts) {
95
+ // No `result()` call on this path, so `--json` only decides which stream the
96
+ // prose goes to — the one JSON document a `--json` stop may print belongs to
97
+ // the stop, not to the check it ran first.
98
+ const out = createOutput({ tool: "paddock", json: opts.json === true });
99
+ const refusal = await guardBusy({
100
+ ...paddockBusyOptions(home, out),
101
+ ...(opts.force === true ? { force: true } : {}),
102
+ });
103
+ if (refusal === null)
104
+ return null;
105
+ // Spell out the way past it, after the hint about where to run it from.
106
+ out.warn(`\n paddock service ${action} --force ${action} it anyway\n`);
107
+ return EXIT.busy;
108
+ }
@@ -0,0 +1,69 @@
1
+ import type { Output } from "./output.js";
2
+ /** What `/api/busy` answers (SPEC §4). Counts only, until §10's first question is settled. */
3
+ export interface BusyAnswer {
4
+ busy: boolean;
5
+ version?: string;
6
+ counts?: Record<string, number>;
7
+ }
8
+ export type BusyResult = {
9
+ state: "not-running";
10
+ } | {
11
+ state: "idle" | "busy";
12
+ url: string;
13
+ answer: BusyAnswer;
14
+ }
15
+ /** Something answered, but not with a busy answer: an older version without `/api/busy`, or another program. */
16
+ | {
17
+ state: "unknown";
18
+ url: string;
19
+ detail: string;
20
+ };
21
+ export interface FindOptions {
22
+ /** The tool's data home, holding `runtime.json`. */
23
+ home: string;
24
+ /** Ask this URL instead of the one in `runtime.json` (`--url`, or a port the user named). */
25
+ url?: string;
26
+ /** How long to wait for an answer. §4 asks for well under a second; this allows for a slow machine. */
27
+ timeoutMs?: number;
28
+ fetch?: typeof globalThis.fetch;
29
+ }
30
+ /**
31
+ * Asks the running instance whether it's busy. There's no fallback to a default port: an instance that's running
32
+ * wrote `runtime.json`, and guessing a port would ask whatever else is listening there.
33
+ */
34
+ export declare function checkBusy(o: FindOptions): Promise<BusyResult>;
35
+ /** A kind's singular and plural, where adding an "s" is wrong: `{ batch: ['batch', 'batches'] }`. */
36
+ export type Nouns = Record<string, [singular: string, plural: string]>;
37
+ /** `2 chats and 1 job are running`, from the non-zero counts; '' if there are none. */
38
+ export declare function describeCounts(counts?: Record<string, number>, nouns?: Nouns): string;
39
+ /** The sentence for a person (SPEC §4): `Paddock is busy: 2 chats are running.` */
40
+ export declare function busyMessage(name: string, result: BusyResult, nouns?: Nouns): string;
41
+ /** 0 idle, 75 busy, 3 nothing answers, 1 an answer that isn't a busy answer. */
42
+ export declare function busyExitCode(result: BusyResult): number;
43
+ /** What `<tool> busy --json` prints: valid against `schemas/busy.schema.json`. */
44
+ export declare function busyJson(result: BusyResult): Record<string, unknown>;
45
+ /**
46
+ * LOCAL CHANGE (see the header): what to suggest when the answer is busy, as a string or as a function of the
47
+ * result. {@link resolveHint} is what both the command and the guard call, so the two can never print different
48
+ * advice about the same answer.
49
+ */
50
+ export type BusyHint = string | ((result: BusyResult) => string | undefined);
51
+ export interface BusyCommandOptions extends FindOptions {
52
+ /** The product's name, for messages: `Local Review`. */
53
+ name: string;
54
+ out: Output;
55
+ nouns?: Nouns;
56
+ /** LOCAL CHANGE: the advice printed after a busy message — Paddock's keeper-chat trap. */
57
+ hint?: BusyHint;
58
+ }
59
+ /** `<tool> busy [--json]`. Returns the exit code; the caller sets `process.exitCode`. */
60
+ export declare function runBusyCommand(o: BusyCommandOptions): Promise<number>;
61
+ export interface GuardOptions extends BusyCommandOptions {
62
+ force?: boolean;
63
+ }
64
+ /**
65
+ * The check before `service stop`, `service restart` and `update` (SPEC §4). Returns null to go ahead, or 75 after
66
+ * printing the refusal. With `--force` it prints the same message, prefixed `Overriding:`, and goes ahead.
67
+ * Nothing answering goes ahead: there's nothing to cut short.
68
+ */
69
+ export declare function guardBusy(o: GuardOptions): Promise<number | null>;
@@ -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, 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";
@@ -270,7 +270,13 @@ async function main() {
270
270
  }
271
271
  const opts = command.opts;
272
272
  if (opts.help) {
273
- console.log(command.verb === "service" ? SERVICE_USAGE : command.verb === "config" ? CONFIG_USAGE : USAGE);
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);
274
280
  return EXIT.ok;
275
281
  }
276
282
  if (opts.version) {
@@ -285,6 +291,14 @@ async function main() {
285
291
  return service(command);
286
292
  if (command.verb === "config")
287
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);
301
+ }
288
302
  addBundledBinsToPath();
289
303
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
290
304
  // used to make the current directory the workspace by setting
@@ -31,6 +31,18 @@ export interface ServiceContext {
31
31
  * is — `status` has to be assertable without a server to ask.
32
32
  */
33
33
  probeHealth?: (url: string) => Promise<HealthAnswer | undefined>;
34
+ /**
35
+ * The busy check `stop` and `restart` run first (SPEC §1, §4): null to go
36
+ * ahead, or 75 having printed the refusal.
37
+ *
38
+ * Injected for the third time for the third version of the same reason: the
39
+ * real one reads `<home>/runtime.json` and fetches a URL, so a test of "does
40
+ * stop refuse" would otherwise need a server to be busy at it.
41
+ */
42
+ guardBusy?: (action: "stop" | "restart", home: string, opts: {
43
+ force?: boolean;
44
+ json?: boolean;
45
+ }) => Promise<number | null>;
34
46
  }
35
47
  /**
36
48
  * The version of the Paddock the unit points at.
@@ -432,7 +432,7 @@ function notInstalled(backend) {
432
432
  * `ThrottleInterval`, and `launchctl print` says `running` for most of that
433
433
  * window). The wait is what makes the last line true.
434
434
  */
435
- async function startOrRestart(action, ctx, backend) {
435
+ async function startOrRestart(action, opts, ctx, backend) {
436
436
  const before = backend.status();
437
437
  if (!before.registered) {
438
438
  console.log(notInstalled(backend));
@@ -443,6 +443,14 @@ async function startOrRestart(action, ctx, backend) {
443
443
  console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
444
444
  return EXIT.ok;
445
445
  }
446
+ // A restart stops the instance first, so it asks the same question `stop`
447
+ // asks (SPEC §1): refuse with 75 unless `--force`. `start` does not — there
448
+ // is nothing running for it to cut short, and asking would be theatre.
449
+ if (action === "restart") {
450
+ const refused = await busyCheck(action, opts, ctx, before);
451
+ if (refused !== null)
452
+ return refused;
453
+ }
446
454
  console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
447
455
  if (action === "start")
448
456
  backend.start();
@@ -469,8 +477,26 @@ async function startOrRestart(action, ctx, backend) {
469
477
  // caller — often an Ansible task — has no other way to find out.
470
478
  return readiness === "ready" ? EXIT.ok : EXIT.failure;
471
479
  }
480
+ /**
481
+ * The busy check, asked of the instance the UNIT describes (SPEC §1, §4).
482
+ *
483
+ * `home` is `dataDirOf(state, ctx)` — the unit's own data dir, not whatever
484
+ * `--data-dir` this invocation carries. Same rule as `status` and `logs`
485
+ * (#824): the instance about to be stopped is the one the unit points at, and
486
+ * reading `runtime.json` from somewhere else would ask a different Paddock
487
+ * whether this one is busy.
488
+ *
489
+ * Returns null to go ahead, or the code to return (75).
490
+ */
491
+ async function busyCheck(action, opts, ctx, state) {
492
+ const guard = ctx.guardBusy ?? (await import("../busy-command.js")).guardServiceBusy;
493
+ return guard(action, dataDirOf(state, ctx), {
494
+ ...(opts.force === true ? { force: true } : {}),
495
+ ...(opts.json === true ? { json: true } : {}),
496
+ });
497
+ }
472
498
  /** `stop` (#873): put it down, leave it installed. */
473
- function stop(backend) {
499
+ async function stop(opts, ctx, backend) {
474
500
  const state = backend.status();
475
501
  if (!state.registered) {
476
502
  console.log(notInstalled(backend));
@@ -480,6 +506,12 @@ function stop(backend) {
480
506
  console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
481
507
  return EXIT.ok;
482
508
  }
509
+ // Refuse with 75 if it is in the middle of something, unless --force (SPEC
510
+ // §4). Deliberately AFTER the two cases above: an unit that is not there, or
511
+ // a service already stopped, has no instance to ask and no work to cut short.
512
+ const refused = await busyCheck("stop", opts, ctx, state);
513
+ if (refused !== null)
514
+ return refused;
483
515
  backend.stop();
484
516
  console.log([
485
517
  "",
@@ -542,15 +574,17 @@ async function logs(opts, ctx, backend) {
542
574
  * never `process.exit()`, which truncates piped output mid-write.
543
575
  */
544
576
  export async function runService(action, opts, ctx) {
577
+ // `--force` is rejected on every action but `stop` and `restart` — in
578
+ // `parseCommand`, once, for every verb. See the note there.
545
579
  const backend = backendFor(ctx);
546
580
  if (action === "install")
547
581
  return install(opts, ctx, backend);
548
582
  if (action === "uninstall")
549
583
  return uninstall(ctx, backend);
550
584
  if (action === "start" || action === "restart")
551
- return startOrRestart(action, ctx, backend);
585
+ return startOrRestart(action, opts, ctx, backend);
552
586
  if (action === "stop")
553
- return stop(backend);
587
+ return stop(opts, ctx, backend);
554
588
  if (action === "logs")
555
589
  return logs(opts, ctx, backend);
556
590
  return status(opts, ctx, backend);