@edspencer/paddock 0.79.1 → 0.80.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.
- package/package.json +1 -1
- package/packages/server/dist/app.js +18 -14
- package/packages/server/dist/cli/args.d.ts +47 -7
- package/packages/server/dist/cli/args.js +112 -18
- package/packages/server/dist/cli/exit-codes.d.ts +49 -0
- package/packages/server/dist/cli/exit-codes.js +48 -0
- package/packages/server/dist/cli/paddock.js +84 -37
- package/packages/server/dist/cli/service/backend.d.ts +48 -4
- package/packages/server/dist/cli/service/index.d.ts +56 -2
- package/packages/server/dist/cli/service/index.js +283 -56
- package/packages/server/dist/cli/service/launchd.d.ts +8 -0
- package/packages/server/dist/cli/service/launchd.js +29 -7
- package/packages/server/dist/cli/service/logs.d.ts +39 -0
- package/packages/server/dist/cli/service/logs.js +115 -0
- package/packages/server/dist/cli/service/path-env.d.ts +27 -0
- package/packages/server/dist/cli/service/path-env.js +120 -0
- package/packages/server/dist/cli/service/ready.d.ts +22 -0
- package/packages/server/dist/cli/service/ready.js +30 -0
- package/packages/server/dist/cli/service/spec.d.ts +45 -6
- package/packages/server/dist/cli/service/spec.js +34 -7
- package/packages/server/dist/cli/service/systemd.d.ts +47 -4
- package/packages/server/dist/cli/service/systemd.js +127 -22
- package/packages/server/dist/management-mcp-server.d.ts +21 -3
- package/packages/server/dist/management-mcp-server.js +4 -13
- package/packages/server/dist/routes/meta.js +11 -3
- package/packages/server/dist/service-log.d.ts +29 -0
- package/packages/server/dist/service-log.js +104 -0
- package/packages/server/dist/start.js +11 -0
- package/packages/server/dist/version.d.ts +3 -0
- package/packages/server/dist/version.js +32 -0
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{DeleteProjectDialog-BcxJMA1w.js → DeleteProjectDialog-DHczLhwQ.js} +1 -1
- package/packages/web/dist/assets/DiscoverPage-BSGSzTEs.js +2 -0
- package/packages/web/dist/assets/{DiscoverView-DOmTmiVv.js → DiscoverView-DtTOpTji.js} +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-DWLQqHW0.js → InstanceConfigPage-BRoBh1C1.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-Y25JWlMR.js → MarkdownRenderer-DJf8IJuk.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-CCI1VWM6.js → ProjectRedirect-EioguUKW.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DzfVhjya.js → ProjectView-BiKnGfk6.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-yIyyb2HA.js → ProjectsGrid-D6kfaIal.js} +1 -1
- package/packages/web/dist/assets/{RootHome-x0Om9-Fd.js → RootHome-DpcyuV6e.js} +1 -1
- package/packages/web/dist/assets/{Surfaces-wbcMQIsK.js → Surfaces-CHM29PhP.js} +1 -1
- package/packages/web/dist/assets/{arc-tWN7FYIq.js → arc-fiqD05Hu.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-Ccj6Vlyy.js → architectureDiagram-3BPJPVTR-BuuB2Nkf.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-B7iGXwqY.js → blockDiagram-GPEHLZMM-DQMBuzfi.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-SkLKFSso.js → c4Diagram-AAUBKEIU-BKN0UxCH.js} +1 -1
- package/packages/web/dist/assets/channel-B7yh6Su_.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-BrS8C-YK.js → chunk-2J33WTMH-DW37EupX.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-BUR6nG8w.js → chunk-4BX2VUAB-Dyv7zV_c.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-BRBNJb-u.js → chunk-55IACEB6-DzMR0dOk.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-DKeNPTXz.js → chunk-727SXJPM-BdZ3Sor8.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-BOS1K-DX.js → chunk-AQP2D5EJ-B4i7_og0.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-D3PZL8fo.js → chunk-FMBD7UC4-jcTr85it.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DA9Y77Zj.js → chunk-ND2GUHAM-fgMsadqB.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-wPph-weq.js → chunk-QZHKN3VN-bJNHYS1W.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DIlhMPgi.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DIlhMPgi.js +2 -0
- package/packages/web/dist/assets/{core-I1PQdGMJ.js → core-Bk4KFrmN.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-BA7N2vVK.js → cose-bilkent-S5V4N54A-BIqCSlQy.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-DKpON5p2.js → dagre-BM42HDAG-6nyhtVQz.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-Db0I-eEL.js → diagram-2AECGRRQ-CjizfipR.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-BEuqOTfn.js → diagram-5GNKFQAL-DlgB5vWb.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-CnyX_0Xk.js → diagram-KO2AKTUF-CNC9qanV.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CYvknzDd.js → diagram-LMA3HP47-DSJ-s9lh.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-DlgKxbYz.js → diagram-OG6HWLK6-C9FoHBDg.js} +1 -1
- package/packages/web/dist/assets/{documentTitle-Cvob-HVs.js → documentTitle-Cj_AY97j.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-BkO5JmiM.js → erDiagram-TEJ5UH35-z6gku4Yt.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DG0o_zc1.js → flowDiagram-I6XJVG4X-Dl5c_FgU.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-Bs71No2S.js → ganttDiagram-6RSMTGT7-CIscJ-Dt.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CxODhRKd.js → gitGraphDiagram-PVQCEYII-CvttOXM_.js} +1 -1
- package/packages/web/dist/assets/{index-C4tsPSvG.js → index-DN82do6Z.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-C412TQaw.js → infoDiagram-5YYISTIA-dzWYdDJj.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DUlcZXQQ.js → ishikawaDiagram-YF4QCWOH-8yX9k4bo.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-iL0klVhn.js → journeyDiagram-JHISSGLW-BmitzJDR.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CO02Zl_Q.js → kanban-definition-UN3LZRKU-BZmIkfkW.js} +1 -1
- package/packages/web/dist/assets/{linear-DtmLY8AG.js → linear-BBLXE4o7.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-Dvzxug80.js → mermaid.core-mSKff4yu.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-q950AAWL.js → mindmap-definition-RKZ34NQL-BWAUa4CP.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-BVrv8A-H.js → pieDiagram-4H26LBE5-VmVdiL4C.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-Bp5D2MFq.js → quadrantDiagram-W4KKPZXB-Dybfv6NU.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-yZfQU4Ix.js → requirementDiagram-4Y6WPE33-BPnOvB7w.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-5R8_OZir.js → sankeyDiagram-5OEKKPKP-YXIEmKw7.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C3A9rinG.js → sequenceDiagram-3UESZ5HK-DintIRUe.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-Cshrt_XB.js → stateDiagram-AJRCARHV-eMsjahWp.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CpAOGzbO.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-kNkiddCX.js → timeline-definition-PNZ67QCA-as4zRCPn.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-BiNJm17g.js → vennDiagram-CIIHVFJN-CPzvUkM0.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-CjExbCuX.js → wardley-L42UT6IY-q6moXj5g.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-CMn3z-G2.js → wardleyDiagram-YWT4CUSO-BsEEb9Gm.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-B-UhnooW.js → xychartDiagram-2RQKCTM6-DSgZlcZW.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/dist/assets/DiscoverPage-CSPRrFVk.js +0 -2
- package/packages/web/dist/assets/channel-B2NSmlBB.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B3tLFeZC.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B3tLFeZC.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgiazF78.js +0 -2
|
@@ -26,11 +26,14 @@ import fs from "node:fs";
|
|
|
26
26
|
import os from "node:os";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { CliError } from "../args.js";
|
|
29
|
-
import {
|
|
29
|
+
import { EXIT } from "../exit-codes.js";
|
|
30
|
+
import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, serviceLogPath, } from "./spec.js";
|
|
30
31
|
import { spawnRunner, } from "./backend.js";
|
|
31
32
|
import { createLaunchdBackend } from "./launchd.js";
|
|
32
33
|
import { createSystemdBackend } from "./systemd.js";
|
|
33
|
-
import {
|
|
34
|
+
import { missingPaths } from "./path-env.js";
|
|
35
|
+
import { DEFAULT_LOG_LINES, followFile, sizeOf, tailFile } from "./logs.js";
|
|
36
|
+
import { waitForReady, fetchHealth, READY_TIMEOUT_LABEL, } from "./ready.js";
|
|
34
37
|
/**
|
|
35
38
|
* Pick a backend, or explain why there isn't one.
|
|
36
39
|
*
|
|
@@ -87,15 +90,13 @@ const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots
|
|
|
87
90
|
* signalled rather than crashed. Naming the fix here is the only path from that
|
|
88
91
|
* symptom back to a working service.
|
|
89
92
|
*/
|
|
90
|
-
function
|
|
93
|
+
function staleUnitWarning(backend) {
|
|
91
94
|
const key = backend.platform === "darwin"
|
|
92
95
|
? "KeepAlive is conditional on a non-zero exit"
|
|
93
96
|
: "Restart=on-failure";
|
|
94
|
-
return (`
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"\n" +
|
|
98
|
-
" paddock service install");
|
|
97
|
+
return (`This unit predates a fix (#872): ${key}, so a graceful stop — the kind ` +
|
|
98
|
+
"sleep, logout or a stray `kill` sends — puts Paddock down until you start " +
|
|
99
|
+
"it again by hand. Re-run `paddock service install` to update the unit.");
|
|
99
100
|
}
|
|
100
101
|
/**
|
|
101
102
|
* Say what is about to happen, before the part that looks like a hang (#861).
|
|
@@ -121,6 +122,17 @@ function installProgress(backend) {
|
|
|
121
122
|
" macOS may take a few seconds to approve the background item."
|
|
122
123
|
: `\n Installing ${kind} and starting Paddock…`;
|
|
123
124
|
}
|
|
125
|
+
/** The `enable-linger` line, printed only to the people it applies to (SPEC §7). */
|
|
126
|
+
function lingerNote(linger) {
|
|
127
|
+
if (!linger.applies || linger.enabled === true)
|
|
128
|
+
return undefined;
|
|
129
|
+
const head = linger.enabled === false
|
|
130
|
+
? " Lingering is off, so this service stops when you log out — including"
|
|
131
|
+
: " If you log out (this check could not reach loginctl), a user service";
|
|
132
|
+
return (`${head}\n` +
|
|
133
|
+
" out of an SSH session. To keep Paddock up, once:\n" +
|
|
134
|
+
` sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
|
|
135
|
+
}
|
|
124
136
|
async function install(opts, ctx, backend) {
|
|
125
137
|
if (isNpxPath(ctx.packageRoot))
|
|
126
138
|
throw new CliError(NPX_REFUSAL);
|
|
@@ -137,7 +149,10 @@ async function install(opts, ctx, backend) {
|
|
|
137
149
|
console.log(` Waiting for ${url} to answer…`);
|
|
138
150
|
const readiness = await (ctx.checkReady ?? ((u) => waitForReady({ url: u })))(url);
|
|
139
151
|
const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
|
|
140
|
-
|
|
152
|
+
// Asked of the OS now, rather than printed at everyone (SPEC §7): the people
|
|
153
|
+
// who have already enabled lingering were being told to enable it at every
|
|
154
|
+
// install, which is how the line stopped being read by the people it is for.
|
|
155
|
+
const linger = lingerNote(backend.linger());
|
|
141
156
|
console.log([
|
|
142
157
|
"",
|
|
143
158
|
readiness === "ready"
|
|
@@ -149,7 +164,7 @@ async function install(opts, ctx, backend) {
|
|
|
149
164
|
...(readiness === "ready" ? [] : [` URL: ${url}`]),
|
|
150
165
|
` Data: ${dataDir}`,
|
|
151
166
|
` Unit: ${backend.unitPath}`,
|
|
152
|
-
|
|
167
|
+
` Logs: ${spec.logPath} (paddock service logs -f)`,
|
|
153
168
|
"",
|
|
154
169
|
AT_LOGIN,
|
|
155
170
|
...(linger !== undefined ? ["", linger] : []),
|
|
@@ -159,66 +174,228 @@ async function install(opts, ctx, backend) {
|
|
|
159
174
|
" paddock service uninstall stop it and remove the unit",
|
|
160
175
|
"",
|
|
161
176
|
].join("\n"));
|
|
177
|
+
// "It is installed but did not answer" is a failure, and used to exit 0 — so
|
|
178
|
+
// an Ansible task or a CI step could not tell it from a working install.
|
|
179
|
+
return readiness === "ready" ? EXIT.ok : EXIT.failure;
|
|
162
180
|
}
|
|
163
181
|
function uninstall(ctx, backend) {
|
|
164
182
|
const existed = backend.uninstall();
|
|
165
183
|
console.log(existed
|
|
166
184
|
? `\n Paddock's ${backend.platform === "darwin" ? "LaunchAgent" : "user service"} is stopped and removed.\n ${backend.unitPath}\n\n Your data is untouched — it was never in there.\n`
|
|
167
185
|
: `\n No Paddock service was installed (${backend.unitPath}).\n Nothing to do.\n`);
|
|
168
|
-
// Leave the service directory
|
|
169
|
-
//
|
|
186
|
+
// Leave the service directory and the logs: the last thing anyone wants from
|
|
187
|
+
// `uninstall` is for the record of why they are uninstalling to vanish (and
|
|
188
|
+
// SPEC §1 requires it — `uninstall` keeps the data home and the logs).
|
|
170
189
|
void ctx;
|
|
190
|
+
// 0 either way: "there was nothing to remove" is the state the caller asked
|
|
191
|
+
// for. `uninstall` is idempotent on purpose, so a teardown script can run it
|
|
192
|
+
// without first asking whether it has anything to do.
|
|
193
|
+
return EXIT.ok;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The data dir the INSTALLED unit uses, read out of its own argument vector.
|
|
197
|
+
*
|
|
198
|
+
* Today's `--data-dir` flag is deliberately ignored (#824): every reporting
|
|
199
|
+
* surface here describes the unit on disk, and letting a flag through printed
|
|
200
|
+
* the unit's data dir beside a log path under a directory the service never
|
|
201
|
+
* writes to. `--data-dir` is documented as install-only (SERVICE_USAGE).
|
|
202
|
+
*/
|
|
203
|
+
function dataDirOf(state, ctx) {
|
|
204
|
+
const i = state.argv.indexOf("--data-dir");
|
|
205
|
+
return i >= 0 && state.argv[i + 1] !== undefined
|
|
206
|
+
? state.argv[i + 1]
|
|
207
|
+
: defaultDataDir(ctx.homeDir);
|
|
208
|
+
}
|
|
209
|
+
/** The URL the installed unit serves, read back out of the unit. */
|
|
210
|
+
function urlOf(state) {
|
|
211
|
+
return `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The log file to read: the one the unit names, else where we would write it.
|
|
215
|
+
*
|
|
216
|
+
* A unit written before the logs moved to `<dataDir>/logs/service.log` names
|
|
217
|
+
* the old `<dataDir>/service/paddock.log`, and that is the file its service is
|
|
218
|
+
* still appending to — so that is the file `logs` has to read. The fallback
|
|
219
|
+
* covers a unit we could not parse.
|
|
220
|
+
*/
|
|
221
|
+
function logPathOf(state, dataDir) {
|
|
222
|
+
return state.logPath ?? serviceLogPath(dataDir);
|
|
171
223
|
}
|
|
172
|
-
|
|
224
|
+
/**
|
|
225
|
+
* The version of the Paddock the unit points at.
|
|
226
|
+
*
|
|
227
|
+
* Found by walking up from the `cli.js` in the unit to the nearest
|
|
228
|
+
* `package.json`, which is `packages/server/package.json` in both the repo and
|
|
229
|
+
* the published tarball — the same hop the CLI itself makes to answer
|
|
230
|
+
* `--version`. Undefined when the unit names a path that is no longer there,
|
|
231
|
+
* which is a state `status` reports rather than guesses around.
|
|
232
|
+
*/
|
|
233
|
+
export function installedVersionOf(cliPath) {
|
|
234
|
+
if (cliPath === undefined)
|
|
235
|
+
return undefined;
|
|
236
|
+
let dir = path.dirname(cliPath);
|
|
237
|
+
for (;;) {
|
|
238
|
+
const manifest = path.join(dir, "package.json");
|
|
239
|
+
if (fs.existsSync(manifest)) {
|
|
240
|
+
try {
|
|
241
|
+
const raw = JSON.parse(fs.readFileSync(manifest, "utf8"));
|
|
242
|
+
return raw.version;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const parent = path.dirname(dir);
|
|
249
|
+
if (parent === dir)
|
|
250
|
+
return undefined;
|
|
251
|
+
dir = parent;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function buildStatus(ctx, backend) {
|
|
173
255
|
const state = backend.status();
|
|
256
|
+
const base = {
|
|
257
|
+
installed: state.registered,
|
|
258
|
+
args: state.argv,
|
|
259
|
+
manager: backend.platform === "darwin" ? "launchd" : "systemd",
|
|
260
|
+
unit: backend.unitPath,
|
|
261
|
+
};
|
|
262
|
+
const linger = backend.linger();
|
|
263
|
+
const lingerValue = linger.applies ? (linger.enabled ?? null) : null;
|
|
174
264
|
if (!state.registered) {
|
|
265
|
+
return {
|
|
266
|
+
...base,
|
|
267
|
+
running: false,
|
|
268
|
+
answering: false,
|
|
269
|
+
installedVersion: null,
|
|
270
|
+
runningVersion: null,
|
|
271
|
+
port: null,
|
|
272
|
+
host: null,
|
|
273
|
+
url: null,
|
|
274
|
+
pid: null,
|
|
275
|
+
log: null,
|
|
276
|
+
dataDir: null,
|
|
277
|
+
node: null,
|
|
278
|
+
cli: null,
|
|
279
|
+
linger: lingerValue,
|
|
280
|
+
warnings: [],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const dataDir = dataDirOf(state, ctx);
|
|
284
|
+
const url = urlOf(state);
|
|
285
|
+
const health = await (ctx.probeHealth ?? ((u) => fetchHealth(u)))(url);
|
|
286
|
+
const installedVersion = installedVersionOf(state.cliPath);
|
|
287
|
+
const warnings = [];
|
|
288
|
+
if (!state.keepsAlive)
|
|
289
|
+
warnings.push(staleUnitWarning(backend));
|
|
290
|
+
for (const gone of missingPaths([state.nodePath, state.cliPath])) {
|
|
291
|
+
warnings.push(`The unit runs ${gone}, which is not there any more. ` +
|
|
292
|
+
"That happens when a Node version is removed (nvm, fnm, volta) or the " +
|
|
293
|
+
"package is reinstalled elsewhere. Re-run `paddock service install`.");
|
|
294
|
+
}
|
|
295
|
+
if (installedVersion !== undefined &&
|
|
296
|
+
health?.version !== undefined &&
|
|
297
|
+
installedVersion !== health.version) {
|
|
298
|
+
warnings.push(`It is running ${health.version}, but ${installedVersion} is installed. ` +
|
|
299
|
+
"The package was upgraded under a service that has not restarted: " +
|
|
300
|
+
"`paddock service restart`.");
|
|
301
|
+
}
|
|
302
|
+
if (linger.applies && linger.enabled === false) {
|
|
303
|
+
warnings.push("Lingering is off, so this service stops when you log out — including out " +
|
|
304
|
+
`of an SSH session. To keep it up: sudo loginctl enable-linger ${linger.user ?? "$USER"}`);
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
...base,
|
|
308
|
+
running: state.running,
|
|
309
|
+
answering: health?.ok === true,
|
|
310
|
+
installedVersion: installedVersion ?? null,
|
|
311
|
+
runningVersion: health?.version ?? null,
|
|
312
|
+
port: Number(portFromArgv(state.argv)),
|
|
313
|
+
host: hostFromArgv(state.argv),
|
|
314
|
+
url,
|
|
315
|
+
pid: state.pid !== undefined ? Number(state.pid) : null,
|
|
316
|
+
log: logPathOf(state, dataDir),
|
|
317
|
+
dataDir,
|
|
318
|
+
node: state.nodePath ?? null,
|
|
319
|
+
cli: state.cliPath ?? null,
|
|
320
|
+
linger: lingerValue,
|
|
321
|
+
warnings,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* `status` (SPEC §1, §2).
|
|
326
|
+
*
|
|
327
|
+
* The exit code is the headline and the reason this command is worth scripting:
|
|
328
|
+
*
|
|
329
|
+
* - **4** — no unit. Nothing to start, nothing to ask.
|
|
330
|
+
* - **3** — installed, but not running or not answering. "Not answering"
|
|
331
|
+
* counts, because a supervisor reports a crash-looping job as `running` for
|
|
332
|
+
* most of the window between restarts, and a service nothing can reach is not
|
|
333
|
+
* up whatever the supervisor thinks.
|
|
334
|
+
* - **0** — installed, running and answering.
|
|
335
|
+
*
|
|
336
|
+
* Those are the numbers `systemctl status` and LSB init scripts already use, so
|
|
337
|
+
* an Ansible task or a shell `if` written against either keeps working.
|
|
338
|
+
*/
|
|
339
|
+
async function status(opts, ctx, backend) {
|
|
340
|
+
const report = await buildStatus(ctx, backend);
|
|
341
|
+
if (opts.json === true) {
|
|
342
|
+
// Exactly one JSON document on stdout, and nothing else (SPEC §1). Every
|
|
343
|
+
// other line this command can print goes to stderr under --json or is not
|
|
344
|
+
// printed at all.
|
|
345
|
+
console.log(JSON.stringify(report, null, 2));
|
|
346
|
+
return statusExit(report);
|
|
347
|
+
}
|
|
348
|
+
if (!report.installed) {
|
|
175
349
|
console.log([
|
|
176
350
|
"",
|
|
177
351
|
" Paddock is not installed as a service.",
|
|
178
|
-
` Looked for: ${
|
|
352
|
+
` Looked for: ${report.unit}`,
|
|
179
353
|
"",
|
|
180
354
|
" paddock service install register it and start it now",
|
|
181
355
|
"",
|
|
182
356
|
].join("\n"));
|
|
183
|
-
return;
|
|
357
|
+
return EXIT.notInstalled;
|
|
184
358
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const dataFromUnit = state.argv.indexOf("--data-dir");
|
|
189
|
-
const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
|
|
190
|
-
? state.argv[dataFromUnit + 1]
|
|
191
|
-
: defaultDataDir(ctx.homeDir);
|
|
192
|
-
// Drop `--data-dir` before building the log-path spec (#824). `resolveDataDir`
|
|
193
|
-
// gives `opts.dataDir` precedence over `ctx.envDataDir`, which is right for
|
|
194
|
-
// `install` (see the comment there) and wrong here: this whole block reports
|
|
195
|
-
// the INSTALLED unit, so letting today's flag through printed the unit's data
|
|
196
|
-
// dir beside a `Logs:` path under a directory the service never writes to. On
|
|
197
|
-
// macOS that log path is real and load-bearing, not just cosmetic.
|
|
198
|
-
//
|
|
199
|
-
// `--data-dir` is documented as install-only (SERVICE_USAGE), so ignoring it
|
|
200
|
-
// here matches both the help text and the stated intent of this block.
|
|
201
|
-
const statusOpts = { ...opts };
|
|
202
|
-
delete statusOpts.dataDir;
|
|
203
|
-
const { spec } = specFor(statusOpts, { ...ctx, envDataDir: dataDir });
|
|
204
|
-
const linger = backend.lingerNote();
|
|
359
|
+
const versions = report.runningVersion !== null && report.runningVersion !== report.installedVersion
|
|
360
|
+
? ` Version: ${report.runningVersion} running, ${report.installedVersion ?? "unknown"} installed`
|
|
361
|
+
: ` Version: ${report.runningVersion ?? report.installedVersion ?? "unknown"}`;
|
|
205
362
|
console.log([
|
|
206
363
|
"",
|
|
207
|
-
|
|
208
|
-
?
|
|
209
|
-
|
|
364
|
+
report.running
|
|
365
|
+
? report.answering
|
|
366
|
+
? ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""} and answering.`
|
|
367
|
+
: ` Paddock is running${report.pid !== null ? ` (pid ${String(report.pid)})` : ""}, but ${report.url ?? "its URL"} is not answering.`
|
|
368
|
+
: report.answering
|
|
369
|
+
? // Found in QA, and worth its own line: the supervisor does not have
|
|
370
|
+
// the job, yet something IS serving that URL — usually a `paddock
|
|
371
|
+
// serve` in a terminal on the same port. "Not running" on its own
|
|
372
|
+
// reads as "nothing there", and then `service start` fails on a
|
|
373
|
+
// port clash for reasons the status screen had the answer to.
|
|
374
|
+
` The service is not running, but something IS answering at ${report.url ?? "its URL"}\n` +
|
|
375
|
+
" — most likely a `paddock serve` in a terminal. The service cannot take\n" +
|
|
376
|
+
" that port while it is held."
|
|
377
|
+
: " Paddock is registered as a service, but not running right now.",
|
|
210
378
|
"",
|
|
211
|
-
` URL: ${url}`,
|
|
212
|
-
|
|
213
|
-
`
|
|
214
|
-
`
|
|
215
|
-
|
|
379
|
+
` URL: ${report.url ?? "unknown"}`,
|
|
380
|
+
versions,
|
|
381
|
+
` Data: ${report.dataDir ?? "unknown"}`,
|
|
382
|
+
` Unit: ${report.unit}`,
|
|
383
|
+
` Args: paddock ${report.args.join(" ")}`,
|
|
384
|
+
` Logs: ${report.log ?? "unknown"} (paddock service logs -f)`,
|
|
216
385
|
"",
|
|
217
386
|
AT_LOGIN,
|
|
218
|
-
...(
|
|
219
|
-
...(linger !== undefined && !state.running ? ["", linger] : []),
|
|
387
|
+
...report.warnings.flatMap((w) => ["", ` ⚠ ${w}`]),
|
|
220
388
|
"",
|
|
221
389
|
].join("\n"));
|
|
390
|
+
return statusExit(report);
|
|
391
|
+
}
|
|
392
|
+
/** The exit code a report means (SPEC §2). */
|
|
393
|
+
function statusExit(report) {
|
|
394
|
+
if (!report.installed)
|
|
395
|
+
return EXIT.notInstalled;
|
|
396
|
+
if (!report.running || !report.answering)
|
|
397
|
+
return EXIT.notRunning;
|
|
398
|
+
return EXIT.ok;
|
|
222
399
|
}
|
|
223
400
|
/**
|
|
224
401
|
* The URL an installed unit is actually serving, or undefined if none is.
|
|
@@ -259,12 +436,12 @@ async function startOrRestart(action, ctx, backend) {
|
|
|
259
436
|
const before = backend.status();
|
|
260
437
|
if (!before.registered) {
|
|
261
438
|
console.log(notInstalled(backend));
|
|
262
|
-
return;
|
|
439
|
+
return EXIT.notInstalled;
|
|
263
440
|
}
|
|
264
441
|
const url = installedUrl(before);
|
|
265
442
|
if (action === "start" && before.running) {
|
|
266
443
|
console.log(`\n Paddock is already running${pid(before)}.\n\n URL: ${url}\n`);
|
|
267
|
-
return;
|
|
444
|
+
return EXIT.ok;
|
|
268
445
|
}
|
|
269
446
|
console.log(`\n ${action === "start" ? "Starting" : "Restarting"} Paddock…`);
|
|
270
447
|
if (action === "start")
|
|
@@ -279,20 +456,29 @@ async function startOrRestart(action, ctx, backend) {
|
|
|
279
456
|
? ` ✓ Paddock is running${pid(after)} at ${url}`
|
|
280
457
|
: ` Paddock was ${action === "start" ? "started" : "restarted"}, but ${url} did not\n` +
|
|
281
458
|
` answer within ${READY_TIMEOUT_LABEL}. It may still be coming up — check:`,
|
|
282
|
-
...(readiness === "ready"
|
|
459
|
+
...(readiness === "ready"
|
|
460
|
+
? []
|
|
461
|
+
: [
|
|
462
|
+
"",
|
|
463
|
+
" paddock service status",
|
|
464
|
+
` paddock service logs (${logPathOf(after, dataDirOf(after, ctx))})`,
|
|
465
|
+
]),
|
|
283
466
|
"",
|
|
284
467
|
].join("\n"));
|
|
468
|
+
// A start that never answered is a failure, not a success with a caveat: the
|
|
469
|
+
// caller — often an Ansible task — has no other way to find out.
|
|
470
|
+
return readiness === "ready" ? EXIT.ok : EXIT.failure;
|
|
285
471
|
}
|
|
286
472
|
/** `stop` (#873): put it down, leave it installed. */
|
|
287
473
|
function stop(backend) {
|
|
288
474
|
const state = backend.status();
|
|
289
475
|
if (!state.registered) {
|
|
290
476
|
console.log(notInstalled(backend));
|
|
291
|
-
return;
|
|
477
|
+
return EXIT.notInstalled;
|
|
292
478
|
}
|
|
293
479
|
if (!state.running) {
|
|
294
480
|
console.log(`\n Paddock is already stopped.\n Unit: ${backend.unitPath}\n`);
|
|
295
|
-
return;
|
|
481
|
+
return EXIT.ok;
|
|
296
482
|
}
|
|
297
483
|
backend.stop();
|
|
298
484
|
console.log([
|
|
@@ -305,17 +491,56 @@ function stop(backend) {
|
|
|
305
491
|
" paddock service start",
|
|
306
492
|
"",
|
|
307
493
|
].join("\n"));
|
|
494
|
+
return EXIT.ok;
|
|
308
495
|
}
|
|
309
496
|
function pid(state) {
|
|
310
497
|
return state.pid !== undefined ? ` (pid ${state.pid})` : "";
|
|
311
498
|
}
|
|
312
|
-
/**
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
499
|
+
/**
|
|
500
|
+
* `logs [-f] [-n <lines>]` (SPEC §1, §7).
|
|
501
|
+
*
|
|
502
|
+
* Reads the file the INSTALLED unit names, which is the only file its service
|
|
503
|
+
* is actually appending to — a unit written before the logs moved still names
|
|
504
|
+
* the old path, and sending its owner to a file nothing writes to would be
|
|
505
|
+
* worse than sending them nowhere.
|
|
506
|
+
*
|
|
507
|
+
* Exit 4 with no unit, like every other command that needs one. A unit whose
|
|
508
|
+
* log file does not exist yet is NOT an error: a service installed thirty
|
|
509
|
+
* seconds ago and still booting has nothing to show, and that is worth saying
|
|
510
|
+
* rather than failing over.
|
|
511
|
+
*/
|
|
512
|
+
async function logs(opts, ctx, backend) {
|
|
513
|
+
const state = backend.status();
|
|
514
|
+
if (!state.registered) {
|
|
515
|
+
console.log(notInstalled(backend));
|
|
516
|
+
return EXIT.notInstalled;
|
|
517
|
+
}
|
|
518
|
+
const file = logPathOf(state, dataDirOf(state, ctx));
|
|
519
|
+
const lines = opts.lines ?? DEFAULT_LOG_LINES;
|
|
520
|
+
if (!fs.existsSync(file)) {
|
|
521
|
+
// stderr: with `-f` this is a caveat ahead of a stream, and prose on stdout
|
|
522
|
+
// would be mixed into the log output itself.
|
|
523
|
+
console.error(`paddock: nothing has been written to ${file} yet.`);
|
|
524
|
+
if (opts.follow !== true)
|
|
525
|
+
return EXIT.ok;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const tail = tailFile(file, lines);
|
|
529
|
+
if (tail !== "")
|
|
530
|
+
console.log(tail);
|
|
531
|
+
}
|
|
532
|
+
if (opts.follow === true) {
|
|
533
|
+
console.error(`paddock: following ${file} — Ctrl-C to stop.`);
|
|
534
|
+
await followFile(file, { from: sizeOf(file) });
|
|
535
|
+
}
|
|
536
|
+
return EXIT.ok;
|
|
318
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Dispatch, returning the exit code rather than setting one (SPEC §2).
|
|
540
|
+
*
|
|
541
|
+
* The code is returned up to `paddock.ts`, which assigns `process.exitCode` —
|
|
542
|
+
* never `process.exit()`, which truncates piped output mid-write.
|
|
543
|
+
*/
|
|
319
544
|
export async function runService(action, opts, ctx) {
|
|
320
545
|
const backend = backendFor(ctx);
|
|
321
546
|
if (action === "install")
|
|
@@ -326,6 +551,8 @@ export async function runService(action, opts, ctx) {
|
|
|
326
551
|
return startOrRestart(action, ctx, backend);
|
|
327
552
|
if (action === "stop")
|
|
328
553
|
return stop(backend);
|
|
554
|
+
if (action === "logs")
|
|
555
|
+
return logs(opts, ctx, backend);
|
|
329
556
|
return status(opts, ctx, backend);
|
|
330
557
|
}
|
|
331
558
|
/** Exported for the entrypoint's context assembly, and for tests. */
|
|
@@ -31,6 +31,12 @@ export declare function plistPath(homeDir?: string): string;
|
|
|
31
31
|
*
|
|
32
32
|
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
33
33
|
* from the service and from a terminal.
|
|
34
|
+
*
|
|
35
|
+
* `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
|
|
36
|
+
* launchd opens both in append mode, so interleaving is what a reader wants
|
|
37
|
+
* anyway — a warning and the stack trace that followed it used to land in two
|
|
38
|
+
* separate files, in an order nothing recorded. One file is also what makes
|
|
39
|
+
* `paddock service logs` one implementation across platforms.
|
|
34
40
|
*/
|
|
35
41
|
export declare function renderPlist(spec: ServiceSpec): string;
|
|
36
42
|
/**
|
|
@@ -55,6 +61,8 @@ export declare function parsePlistArgv(xml: string): string[];
|
|
|
55
61
|
* unrecognised would silence exactly the case this exists to catch.
|
|
56
62
|
*/
|
|
57
63
|
export declare function plistKeepsAlive(xml: string): boolean;
|
|
64
|
+
/** The log file an installed plist appends to, read back from `StandardOutPath`. */
|
|
65
|
+
export declare function parsePlistLogPath(xml: string): string | undefined;
|
|
58
66
|
/**
|
|
59
67
|
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
60
68
|
*
|
|
@@ -77,6 +77,12 @@ function xmlEscape(s) {
|
|
|
77
77
|
*
|
|
78
78
|
* No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
|
|
79
79
|
* from the service and from a terminal.
|
|
80
|
+
*
|
|
81
|
+
* `StandardOutPath` and `StandardErrorPath` name the SAME file (SPEC §7).
|
|
82
|
+
* launchd opens both in append mode, so interleaving is what a reader wants
|
|
83
|
+
* anyway — a warning and the stack trace that followed it used to land in two
|
|
84
|
+
* separate files, in an order nothing recorded. One file is also what makes
|
|
85
|
+
* `paddock service logs` one implementation across platforms.
|
|
80
86
|
*/
|
|
81
87
|
export function renderPlist(spec) {
|
|
82
88
|
const argv = [spec.nodePath, spec.scriptPath, ...spec.args];
|
|
@@ -100,9 +106,9 @@ ${strings}
|
|
|
100
106
|
<key>WorkingDirectory</key>
|
|
101
107
|
<string>${xmlEscape(spec.workingDirectory)}</string>
|
|
102
108
|
<key>StandardOutPath</key>
|
|
103
|
-
<string>${xmlEscape(spec.
|
|
109
|
+
<string>${xmlEscape(spec.logPath)}</string>
|
|
104
110
|
<key>StandardErrorPath</key>
|
|
105
|
-
<string>${xmlEscape(spec.
|
|
111
|
+
<string>${xmlEscape(spec.logPath)}</string>
|
|
106
112
|
<key>EnvironmentVariables</key>
|
|
107
113
|
<dict>
|
|
108
114
|
<key>PATH</key>
|
|
@@ -145,6 +151,17 @@ export function parsePlistArgv(xml) {
|
|
|
145
151
|
export function plistKeepsAlive(xml) {
|
|
146
152
|
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(xml);
|
|
147
153
|
}
|
|
154
|
+
/** The log file an installed plist appends to, read back from `StandardOutPath`. */
|
|
155
|
+
export function parsePlistLogPath(xml) {
|
|
156
|
+
const m = /<key>StandardOutPath<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(xml);
|
|
157
|
+
if (m === null)
|
|
158
|
+
return undefined;
|
|
159
|
+
return m[1]
|
|
160
|
+
.replace(/"/g, '"')
|
|
161
|
+
.replace(/>/g, ">")
|
|
162
|
+
.replace(/</g, "<")
|
|
163
|
+
.replace(/&/g, "&");
|
|
164
|
+
}
|
|
148
165
|
/**
|
|
149
166
|
* Read liveness out of `launchctl print gui/<uid>/<label>`.
|
|
150
167
|
*
|
|
@@ -180,6 +197,9 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
180
197
|
install(spec) {
|
|
181
198
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
182
199
|
fs.mkdirSync(spec.workingDirectory, { recursive: true });
|
|
200
|
+
// launchd creates neither the log file's directory nor, reliably, a log
|
|
201
|
+
// file whose parent is missing — the job just fails to spawn.
|
|
202
|
+
fs.mkdirSync(path.dirname(spec.logPath), { recursive: true });
|
|
183
203
|
fs.writeFileSync(file, renderPlist(spec), "utf8");
|
|
184
204
|
// `bootstrap` fails outright if the label is already loaded, so a
|
|
185
205
|
// reinstall has to unload first. A first install has nothing to unload,
|
|
@@ -260,6 +280,7 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
260
280
|
const parsed = printed.status === 0
|
|
261
281
|
? parseLaunchctlPrint(printed.stdout)
|
|
262
282
|
: { running: false, pid: undefined };
|
|
283
|
+
const logPath = parsePlistLogPath(xml);
|
|
263
284
|
return {
|
|
264
285
|
registered: true,
|
|
265
286
|
running: parsed.running,
|
|
@@ -267,14 +288,15 @@ export function createLaunchdBackend(run, homeDir = os.homedir()) {
|
|
|
267
288
|
// Strip the interpreter and script: what a reader wants from `status`
|
|
268
289
|
// is the Paddock invocation, not the absolute path to node twice.
|
|
269
290
|
argv: argv.slice(2),
|
|
291
|
+
...(argv[0] !== undefined ? { nodePath: argv[0] } : {}),
|
|
292
|
+
...(argv[1] !== undefined ? { cliPath: argv[1] } : {}),
|
|
293
|
+
...(logPath !== undefined ? { logPath } : {}),
|
|
270
294
|
keepsAlive: plistKeepsAlive(xml),
|
|
271
295
|
};
|
|
272
296
|
},
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
lingerNote() {
|
|
277
|
-
return undefined;
|
|
297
|
+
/** launchd has no linger: a LaunchAgent's lifetime IS the login session. */
|
|
298
|
+
linger() {
|
|
299
|
+
return { applies: false };
|
|
278
300
|
},
|
|
279
301
|
};
|
|
280
302
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** How many lines `logs` prints when nobody says otherwise (SPEC §1). */
|
|
2
|
+
export declare const DEFAULT_LOG_LINES = 50;
|
|
3
|
+
/**
|
|
4
|
+
* The last `lines` lines of `file`.
|
|
5
|
+
*
|
|
6
|
+
* Only the last {@link TAIL_WINDOW} bytes are read. A service log is append-only
|
|
7
|
+
* and can reach tens of megabytes between rotations, and reading all of it to
|
|
8
|
+
* print fifty lines would make `logs` slower than the thing it is reporting on.
|
|
9
|
+
* A window that happens to start mid-line drops that partial first line rather
|
|
10
|
+
* than printing half of one.
|
|
11
|
+
*/
|
|
12
|
+
export declare function tailFile(file: string, lines: number): string;
|
|
13
|
+
export interface FollowOptions {
|
|
14
|
+
/** Where to start reading from — normally the file's size after the tail. */
|
|
15
|
+
from: number;
|
|
16
|
+
/** Injected for tests. */
|
|
17
|
+
intervalMs?: number;
|
|
18
|
+
/** Injected for tests: return false to stop the loop. */
|
|
19
|
+
keepGoing?: () => boolean;
|
|
20
|
+
write?: (s: string) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Print what gets appended to `file`, from `from` onwards, until the process is
|
|
24
|
+
* interrupted.
|
|
25
|
+
*
|
|
26
|
+
* Polling rather than `fs.watch`: the writer here is the supervisor's own file
|
|
27
|
+
* descriptor, and `fs.watch` on an appended-to file is unreliable across
|
|
28
|
+
* platforms (no events at all on some network filesystems, and a stream of
|
|
29
|
+
* them on others). A 400 ms poll of the size is both portable and cheap.
|
|
30
|
+
*
|
|
31
|
+
* Rotation is handled by the one case that can actually happen: the server
|
|
32
|
+
* rotates by COPY-AND-TRUNCATE (see `service-log.ts`), so the inode is stable
|
|
33
|
+
* and the size goes DOWN. Seeing that, this resets to the new start rather than
|
|
34
|
+
* reading past the end forever, which is what a naive follower does — it goes
|
|
35
|
+
* quiet after the first rotation and never recovers.
|
|
36
|
+
*/
|
|
37
|
+
export declare function followFile(file: string, opts: FollowOptions): Promise<void>;
|
|
38
|
+
/** The file's size, or 0 if it is not there — where a follow should start. */
|
|
39
|
+
export declare function sizeOf(file: string): number;
|