@edspencer/paddock 0.82.1 → 0.83.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 (80) hide show
  1. package/package.json +1 -1
  2. package/packages/server/dist/cli/args.d.ts +29 -4
  3. package/packages/server/dist/cli/args.js +143 -5
  4. package/packages/server/dist/cli/doctor.d.ts +60 -0
  5. package/packages/server/dist/cli/doctor.js +580 -0
  6. package/packages/server/dist/cli/paddock.js +28 -3
  7. package/packages/server/dist/cli/service/index.d.ts +29 -1
  8. package/packages/server/dist/cli/service/index.js +135 -9
  9. package/packages/server/dist/cli/service/launchd.js +11 -1
  10. package/packages/server/dist/cli/service/ready.d.ts +16 -23
  11. package/packages/server/dist/cli/service/ready.js +42 -0
  12. package/packages/server/dist/cli/service/systemd.js +15 -2
  13. package/packages/server/dist/runtime-file.d.ts +1 -1
  14. package/packages/server/dist/runtime-file.js +9 -13
  15. package/packages/server/package.json +1 -1
  16. package/packages/web/dist/assets/{DeleteProjectDialog-CgHpQctt.js → DeleteProjectDialog-B07MKgv4.js} +1 -1
  17. package/packages/web/dist/assets/DiscoverPage-D5C2fkeQ.js +2 -0
  18. package/packages/web/dist/assets/{DiscoverView-BKqI0I1E.js → DiscoverView-DpAAyBEf.js} +1 -1
  19. package/packages/web/dist/assets/{InstanceConfigPage-DL3raCK1.js → InstanceConfigPage-YX0I_jVA.js} +1 -1
  20. package/packages/web/dist/assets/{MarkdownRenderer-DcjEuf3s.js → MarkdownRenderer-C-CUuFhT.js} +1 -1
  21. package/packages/web/dist/assets/{ProjectRedirect-DerkA-nE.js → ProjectRedirect-qyXbTK56.js} +1 -1
  22. package/packages/web/dist/assets/{ProjectView-NwfC3_w7.js → ProjectView-CguNOv8k.js} +3 -3
  23. package/packages/web/dist/assets/{ProjectsGrid-BeORa0UH.js → ProjectsGrid-CH2Lz2Hf.js} +1 -1
  24. package/packages/web/dist/assets/{RootHome-BYJs8qJ6.js → RootHome-CUGUc8za.js} +1 -1
  25. package/packages/web/dist/assets/{Surfaces-w-kEaAYU.js → Surfaces-11or1q_3.js} +1 -1
  26. package/packages/web/dist/assets/{arc-Bv56hasM.js → arc-CQHeCzrk.js} +1 -1
  27. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BHEknx9i.js → architectureDiagram-3BPJPVTR-dGcuFxAa.js} +1 -1
  28. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DtxTMrPX.js → blockDiagram-GPEHLZMM-CGc9h97S.js} +1 -1
  29. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DRpEYOlM.js → c4Diagram-AAUBKEIU-BpX0sqhH.js} +1 -1
  30. package/packages/web/dist/assets/channel-BAeygx-e.js +2 -0
  31. package/packages/web/dist/assets/{chunk-2J33WTMH-S4s7GCg7.js → chunk-2J33WTMH-uORGdO9D.js} +1 -1
  32. package/packages/web/dist/assets/{chunk-4BX2VUAB-BkWVPLf1.js → chunk-4BX2VUAB-C8zI2PFy.js} +1 -1
  33. package/packages/web/dist/assets/{chunk-55IACEB6-4-FQp01F.js → chunk-55IACEB6-D22Iv6j6.js} +1 -1
  34. package/packages/web/dist/assets/{chunk-727SXJPM-BeyDWdIb.js → chunk-727SXJPM-DdbHXAtS.js} +1 -1
  35. package/packages/web/dist/assets/{chunk-AQP2D5EJ-ZSxKy745.js → chunk-AQP2D5EJ-BeKbob3a.js} +1 -1
  36. package/packages/web/dist/assets/{chunk-FMBD7UC4-Bq8uV9j-.js → chunk-FMBD7UC4-DtLvFU_y.js} +1 -1
  37. package/packages/web/dist/assets/{chunk-ND2GUHAM-BOFsRyHk.js → chunk-ND2GUHAM-BWJFo6B1.js} +1 -1
  38. package/packages/web/dist/assets/{chunk-QZHKN3VN-uZ6oNc4m.js → chunk-QZHKN3VN-DeFkNg3S.js} +1 -1
  39. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Cq2-sBMa.js +2 -0
  40. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Cq2-sBMa.js +2 -0
  41. package/packages/web/dist/assets/{core-CYuJAM2U.js → core-CTNNIHx5.js} +1 -1
  42. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DIzkVSFb.js → cose-bilkent-S5V4N54A-BmtlyA-v.js} +1 -1
  43. package/packages/web/dist/assets/{dagre-BM42HDAG-CHR6jJBv.js → dagre-BM42HDAG-oS3rC9CI.js} +1 -1
  44. package/packages/web/dist/assets/{diagram-2AECGRRQ-BAFXCH8Q.js → diagram-2AECGRRQ-BzIBqz6A.js} +1 -1
  45. package/packages/web/dist/assets/{diagram-5GNKFQAL-BJ5t5gbz.js → diagram-5GNKFQAL-BgIT2g8R.js} +1 -1
  46. package/packages/web/dist/assets/{diagram-KO2AKTUF-D9Xt0IVg.js → diagram-KO2AKTUF-DACT41Re.js} +1 -1
  47. package/packages/web/dist/assets/{diagram-LMA3HP47-Bq38u6sy.js → diagram-LMA3HP47-BstA3UkI.js} +1 -1
  48. package/packages/web/dist/assets/{diagram-OG6HWLK6-CMQVqkpi.js → diagram-OG6HWLK6-Dp3Cn5n_.js} +1 -1
  49. package/packages/web/dist/assets/{documentTitle-M1lrfXRU.js → documentTitle-DRSQmA8q.js} +1 -1
  50. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C63M1g9b.js → erDiagram-TEJ5UH35-wf8PyGx_.js} +1 -1
  51. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-D3j8ACN7.js → flowDiagram-I6XJVG4X-CJ4xx6EQ.js} +1 -1
  52. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-B1Fd1A2Y.js → ganttDiagram-6RSMTGT7-z9obPrPM.js} +1 -1
  53. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-CEhZhDdR.js → gitGraphDiagram-PVQCEYII-Cf4Cu7Tk.js} +1 -1
  54. package/packages/web/dist/assets/{index-1E9yHciS.js → index-Bsv-raz2.js} +2 -2
  55. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-Ctquaew6.js → infoDiagram-5YYISTIA-BIfuiYxX.js} +1 -1
  56. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DV4GvA1R.js → ishikawaDiagram-YF4QCWOH-1pACxMTX.js} +1 -1
  57. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-C_Cy9waA.js → journeyDiagram-JHISSGLW-XGbzfw-v.js} +1 -1
  58. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-CGr9ctEf.js → kanban-definition-UN3LZRKU-Bvl-nMak.js} +1 -1
  59. package/packages/web/dist/assets/{linear-D3U8NOkt.js → linear-uNToO4bv.js} +1 -1
  60. package/packages/web/dist/assets/{mermaid.core-BiArdWKV.js → mermaid.core-CrChvyj6.js} +4 -4
  61. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CZIpzj8Z.js → mindmap-definition-RKZ34NQL-C6DaQ4Fm.js} +1 -1
  62. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-1MaeS3rt.js → pieDiagram-4H26LBE5-CEh8lKyF.js} +1 -1
  63. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B5pgQ54e.js → quadrantDiagram-W4KKPZXB-z4zvICSM.js} +1 -1
  64. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-BNnxrpGr.js → requirementDiagram-4Y6WPE33-BBltkjsj.js} +1 -1
  65. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-DsxVH-hM.js → sankeyDiagram-5OEKKPKP-D0s95wQ4.js} +1 -1
  66. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-Ke9LcFZI.js → sequenceDiagram-3UESZ5HK-kQa65Nbt.js} +1 -1
  67. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-UJzmflND.js → stateDiagram-AJRCARHV-BqVIoyED.js} +1 -1
  68. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-B6OXxEra.js +2 -0
  69. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-BRjNALVI.js → timeline-definition-PNZ67QCA-Bo5L62WI.js} +1 -1
  70. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-ClMAspxz.js → vennDiagram-CIIHVFJN-CN9rH4lT.js} +1 -1
  71. package/packages/web/dist/assets/{wardley-L42UT6IY-UPv4JEmz.js → wardley-L42UT6IY-BKhnD_W_.js} +1 -1
  72. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-BgYJbdEx.js → wardleyDiagram-YWT4CUSO-XHTbZzXj.js} +1 -1
  73. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-Bfk5HjGx.js → xychartDiagram-2RQKCTM6--t9XJLbR.js} +1 -1
  74. package/packages/web/dist/index.html +1 -1
  75. package/packages/web/dist/sw.js +1 -1
  76. package/packages/web/dist/assets/DiscoverPage-CG6cf-0i.js +0 -2
  77. package/packages/web/dist/assets/channel-CdIiAqY2.js +0 -2
  78. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DCCUHba0.js +0 -2
  79. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DCCUHba0.js +0 -2
  80. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-DgUc3HkP.js +0 -2
@@ -0,0 +1,580 @@
1
+ /**
2
+ * `paddock doctor [--json] [--offline]` (CLI contract SPEC §1).
3
+ *
4
+ * One `✓` / `!` / `✗` line a check (`-` for one that doesn't apply here), with a
5
+ * `fix:` line under every failure and warning. Exit 1 if any check failed;
6
+ * warnings don't change the code.
7
+ *
8
+ * The contract's eight come first, with the ids every tool uses so a script can
9
+ * read any of them: `node-engines`, `install-method`, `unit`,
10
+ * `running-version`, `port`, `linger`, `data-home` and `update`. Then Paddock's
11
+ * own: the config loads (`config`), the projects folder (`projects`), a Claude
12
+ * login it can use (`claude-login`) and a `claude` on PATH (`claude-cli`).
13
+ *
14
+ * It only reads, with one exception: like `update --check`, the `update` check
15
+ * writes what it found to `<data-dir>/update-check.json`, so `service status`
16
+ * can repeat it (SPEC §3) — but only into a data dir that's already there, so
17
+ * `doctor` never creates an instance. It asks the npm registry only because a
18
+ * person ran it, and `--offline` doesn't. It never prints a credential: the
19
+ * login check says which KIND of login it found.
20
+ *
21
+ * The service checks reuse `service status`'s own report (`buildStatus`), so the
22
+ * two cannot disagree about what is installed and what is running.
23
+ */
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { MIN_NODE_MAJOR } from "./args.js";
28
+ import { EXIT } from "./exit-codes.js";
29
+ import { buildStatus, serviceBackend, } from "./service/index.js";
30
+ import { defaultDataDir, DEFAULT_PORT } from "./service/spec.js";
31
+ import { missingPaths, which } from "./service/path-env.js";
32
+ import { identifyPaddock as identify, isListening } from "./service/ready.js";
33
+ import { describeInstall, detectInstall, updateCommand } from "./update/install-method.js";
34
+ import { npmConfigGet, npmGlobalRoot } from "./update/package-manager.js";
35
+ import { RegistryError, resolveRegistry, resolveTarget } from "./update/registry.js";
36
+ import { compareVersions, isPrerelease, satisfiesEngines } from "./update/semver.js";
37
+ import { updateNotice, writeUpdateCheck } from "./update/update-check.js";
38
+ import { readLiveRuntimeFile } from "../runtime-file.js";
39
+ import { PADDOCK_NAME, PADDOCK_VERSION } from "../version.js";
40
+ import { PADDOCK_IMAGE, PADDOCK_PACKAGE, paddockChangelogUrl, } from "./update-command.js";
41
+ const MARK = { pass: "✓", warn: "!", fail: "✗", skip: "-" };
42
+ /** The variables that log Claude Code in without a file (the same list `claude-home.ts` checks at boot). */
43
+ const TOKEN_ENV_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
44
+ /** `/home/me/x` → `~/x`, for lines a person reads. */
45
+ function tildify(p, home) {
46
+ if (p === home)
47
+ return "~";
48
+ return p.startsWith(`${home}${path.sep}`) ? `~${p.slice(home.length)}` : p;
49
+ }
50
+ export async function diagnose(i) {
51
+ const checks = [];
52
+ const add = (c) => void checks.push(c);
53
+ const ctx = i.ctx;
54
+ const env = i.env ?? process.env;
55
+ const t = (p) => tildify(p, ctx.homeDir);
56
+ let notice;
57
+ // ---- how it was installed (SPEC §6). First, because the engines check reads
58
+ // the package root it finds.
59
+ const info = detectInstall({
60
+ packageName: PADDOCK_PACKAGE,
61
+ envPrefix: "PADDOCK",
62
+ entry: i.entry,
63
+ env,
64
+ npmGlobalRoot: i.npmGlobalRoot ?? (() => npmGlobalRoot({ env })),
65
+ });
66
+ // ---- this Node against the package's `engines`
67
+ const nodeVersion = i.nodeVersion ?? process.versions.node;
68
+ const engines = enginesOf(info.packageRoot) ?? `>=${MIN_NODE_MAJOR}`;
69
+ if (satisfiesEngines(nodeVersion, { node: engines }).ok === false) {
70
+ add({
71
+ id: "node-engines",
72
+ status: "fail",
73
+ message: `Node ${nodeVersion} is too old: Paddock ${PADDOCK_VERSION} needs Node ${engines}`,
74
+ fix: `install Node ${engines} (nvm, fnm, Homebrew, nodejs.org), then run paddock with it`,
75
+ });
76
+ }
77
+ else {
78
+ add({ id: "node-engines", status: "pass", message: `Node ${nodeVersion} (Paddock needs ${engines})` });
79
+ }
80
+ const how = describeInstall(info, PADDOCK_PACKAGE);
81
+ if (info.canUpdate) {
82
+ add({
83
+ id: "install-method",
84
+ status: "pass",
85
+ message: `Paddock ${PADDOCK_VERSION}, ${how}: \`paddock update\` can update it`,
86
+ });
87
+ }
88
+ else {
89
+ const cmd = updateCommand(info, { packageName: PADDOCK_PACKAGE, image: PADDOCK_IMAGE });
90
+ add({
91
+ id: "install-method",
92
+ status: "warn",
93
+ message: `Paddock ${PADDOCK_VERSION}, ${how}: \`paddock update\` can't update it`,
94
+ fix: `${cmd.command}${cmd.note !== undefined ? ` (${cmd.note})` : ""}`,
95
+ });
96
+ }
97
+ // ---- the unit: present, its Node and CLI still there, the same install as this CLI
98
+ let backend;
99
+ let report;
100
+ try {
101
+ backend = serviceBackend(ctx);
102
+ report = await buildStatus(ctx, backend);
103
+ }
104
+ catch (err) {
105
+ add({
106
+ id: "unit",
107
+ status: "skip",
108
+ message: `there's no service here: ${firstLine(err.message)}`,
109
+ });
110
+ }
111
+ if (report !== undefined && !report.installed) {
112
+ add({
113
+ id: "unit",
114
+ status: "skip",
115
+ message: "Paddock isn't installed as a service (`paddock service install` runs it from login)",
116
+ });
117
+ }
118
+ else if (report !== undefined) {
119
+ const before = checks.length;
120
+ for (const gone of missingPaths([report.node ?? undefined, report.cli ?? undefined])) {
121
+ add({
122
+ id: "unit",
123
+ status: "fail",
124
+ message: `the service runs ${t(gone)}, which isn't there any more (a Node version removed with nvm, fnm or volta, or Paddock reinstalled elsewhere)`,
125
+ fix: "paddock service install --refresh (re-writes the unit for this Node and this install, keeping its port, host and data dir)",
126
+ });
127
+ }
128
+ if (report.warnings.some((w) => w.includes("#872"))) {
129
+ add({
130
+ id: "unit",
131
+ status: "warn",
132
+ message: "the unit predates a fix (#872): a clean stop (sleep, logout, a stray kill) leaves Paddock down until it's started by hand",
133
+ fix: "paddock service install --refresh",
134
+ });
135
+ }
136
+ if (report.cli !== null && fs.existsSync(report.cli) && !samePath(report.cli, ctx.scriptPath)) {
137
+ add({
138
+ id: "unit",
139
+ status: "warn",
140
+ message: `the service runs a different Paddock install (${t(report.cli)}) from this one (${t(ctx.scriptPath)})`,
141
+ fix: "run `paddock service install --refresh` from the install the service should run",
142
+ });
143
+ }
144
+ if (!report.running) {
145
+ add({
146
+ id: "unit",
147
+ status: "warn",
148
+ message: `the service is installed (${report.manager}) but not running`,
149
+ fix: "paddock service start (and `paddock service logs` if it doesn't stay up)",
150
+ });
151
+ }
152
+ else if (!report.answering) {
153
+ add({
154
+ id: "unit",
155
+ status: "warn",
156
+ message: `the service is running, but ${report.url ?? "its URL"} isn't answering`,
157
+ fix: "paddock service logs",
158
+ });
159
+ }
160
+ if (checks.length === before) {
161
+ add({
162
+ id: "unit",
163
+ status: "pass",
164
+ message: `the service is installed (${report.manager}), running and answering at ${report.url ?? "?"}: ${t(report.unit)}`,
165
+ });
166
+ }
167
+ }
168
+ // The instance this run is about: an explicit --data-dir, else the unit's,
169
+ // else PADDOCK_DATA_DIR, else ~/.paddock. It's where `update-check.json` goes,
170
+ // and where `service status` reads it back from.
171
+ const home = path.resolve(i.dataDir ??
172
+ (report?.installed === true && report.dataDir !== null ? report.dataDir : undefined) ??
173
+ ctx.envDataDir ??
174
+ defaultDataDir(ctx.homeDir));
175
+ // ---- the running version is the installed one: the service's, or a `serve` in a terminal
176
+ const live = readLiveRuntimeFile(home);
177
+ const running = report?.answering === true
178
+ ? { version: report.runningVersion, where: report.url ?? "the service", installed: report.installedVersion, via: "service" }
179
+ : live !== null
180
+ ? { version: live.version, where: live.url, installed: PADDOCK_VERSION, via: "serve" }
181
+ : undefined;
182
+ if (running === undefined) {
183
+ add({ id: "running-version", status: "skip", message: "Paddock isn't running" });
184
+ }
185
+ else if (running.version !== null && running.installed !== null && running.version !== running.installed) {
186
+ add({
187
+ id: "running-version",
188
+ status: "fail",
189
+ message: `${running.where} is running ${running.version}, but ${running.installed} is installed: it's still running the code it started with`,
190
+ fix: running.via === "service"
191
+ ? "paddock service restart"
192
+ : "stop the `paddock serve` in its terminal (Ctrl-C) and start it again",
193
+ });
194
+ }
195
+ else {
196
+ add({
197
+ id: "running-version",
198
+ status: "pass",
199
+ message: `${running.where} is running ${running.version ?? "an unknown version"}${running.via === "serve" ? " (in a terminal)" : ""}`,
200
+ });
201
+ }
202
+ // ---- the port: free, or held by Paddock
203
+ const port = Number(report?.installed === true && report.port !== null
204
+ ? report.port
205
+ : (live?.port ?? env.PORT ?? DEFAULT_PORT));
206
+ const host = loopbackFor(report?.installed === true && report.host !== null ? report.host : "127.0.0.1");
207
+ const listening = await (i.isListening ?? isListening)(host, port);
208
+ if (!listening) {
209
+ add({ id: "port", status: "pass", message: `port ${port} is free` });
210
+ }
211
+ else {
212
+ const who = await (i.identify ?? identify)(`http://${urlHost(host)}:${port}`);
213
+ if (who?.paddock === true) {
214
+ add({
215
+ id: "port",
216
+ status: "pass",
217
+ message: `port ${port} is Paddock's${who.version !== undefined ? ` (${who.version})` : ""}`,
218
+ });
219
+ }
220
+ else {
221
+ add({
222
+ id: "port",
223
+ status: "fail",
224
+ // Something answered /api/health but not /api/busy: another tool, or a
225
+ // Paddock from before the busy check, which can't be told apart.
226
+ message: who !== undefined
227
+ ? `something that isn't Paddock (or is a Paddock older than 0.81) is listening on port ${port}`
228
+ : `something that isn't Paddock is listening on port ${port}`,
229
+ fix: report?.installed === true
230
+ ? "stop it, or move the service: `paddock service install --port <n>`"
231
+ : "stop it, or run Paddock on another port: `paddock --port <n>`",
232
+ });
233
+ }
234
+ }
235
+ // ---- linger, on Linux: without it the service stops when you log out
236
+ let linger;
237
+ try {
238
+ linger = backend?.linger();
239
+ }
240
+ catch {
241
+ linger = undefined;
242
+ }
243
+ if (linger === undefined || !linger.applies) {
244
+ add({ id: "linger", status: "skip", message: "linger only applies to Linux" });
245
+ }
246
+ else {
247
+ const user = linger.user ?? "$USER";
248
+ if (linger.enabled === true) {
249
+ add({ id: "linger", status: "pass", message: `linger is on for ${user}: the service keeps running when you log out` });
250
+ }
251
+ else if (linger.enabled === false && report?.installed === true) {
252
+ add({
253
+ id: "linger",
254
+ status: "warn",
255
+ message: `linger is off for ${user}: the service stops when you log out, and starts only when you log in`,
256
+ fix: `sudo loginctl enable-linger ${user} (Paddock never runs sudo itself)`,
257
+ });
258
+ }
259
+ else if (linger.enabled === false) {
260
+ add({
261
+ id: "linger",
262
+ status: "pass",
263
+ message: `linger is off for ${user}, which only matters once Paddock runs as a service`,
264
+ });
265
+ }
266
+ else {
267
+ add({ id: "linger", status: "skip", message: "couldn't ask loginctl about linger" });
268
+ }
269
+ }
270
+ // ---- the data home is writable
271
+ const writable = writableOrCreatable(home);
272
+ if (writable === true) {
273
+ add({ id: "data-home", status: "pass", message: `the data dir ${t(home)} is writable` });
274
+ }
275
+ else if (writable === "creatable") {
276
+ add({
277
+ id: "data-home",
278
+ status: "pass",
279
+ message: `the data dir ${t(home)} doesn't exist yet; Paddock makes it the first time it runs`,
280
+ });
281
+ }
282
+ else {
283
+ add({
284
+ id: "data-home",
285
+ status: "fail",
286
+ message: `the data dir ${t(home)} isn't writable: ${writable}`,
287
+ fix: `check the permissions of ${t(home)}: it holds your projects, chats and settings`,
288
+ });
289
+ }
290
+ // ---- Paddock's own: the config loads, and what it resolves to
291
+ let cfg;
292
+ try {
293
+ cfg = await (i.loadConfig ?? loadInstanceConfig)(home);
294
+ add({ id: "config", status: "pass", message: "the config loads" });
295
+ }
296
+ catch (err) {
297
+ add({
298
+ id: "config",
299
+ status: "fail",
300
+ message: `the config doesn't load, so \`paddock serve\` would fail too: ${firstLine(err.message)}`,
301
+ fix: "fix what it names (`paddock config show` prints the same error)",
302
+ });
303
+ }
304
+ if (cfg === undefined) {
305
+ add({ id: "projects", status: "skip", message: "the config didn't load, so the projects folder is unknown" });
306
+ }
307
+ else {
308
+ const projects = writableOrCreatable(cfg.projectsRoot);
309
+ if (projects === true) {
310
+ add({ id: "projects", status: "pass", message: `the projects folder ${t(cfg.projectsRoot)} is writable` });
311
+ }
312
+ else if (projects === "creatable") {
313
+ add({
314
+ id: "projects",
315
+ status: "pass",
316
+ message: `the projects folder ${t(cfg.projectsRoot)} doesn't exist yet; Paddock makes it the first time it runs`,
317
+ });
318
+ }
319
+ else {
320
+ add({
321
+ id: "projects",
322
+ status: "fail",
323
+ message: `the projects folder ${t(cfg.projectsRoot)} isn't writable: ${projects}`,
324
+ fix: "fix its permissions, or point PADDOCK_PROJECTS_DIR (or `projectsRoot:` in paddock.config.yaml) somewhere writable",
325
+ });
326
+ }
327
+ }
328
+ // ---- a Claude login it can use. Which KIND, never the value.
329
+ if (cfg !== undefined)
330
+ add(await loginCheck(cfg, env, i, t, report?.installed === true));
331
+ else
332
+ add({ id: "claude-login", status: "skip", message: "the config didn't load, so Paddock's Claude home is unknown" });
333
+ // ---- a `claude` on PATH: the sweeper always shells out to it, and on
334
+ // driveMode: batch so do chats and triggers.
335
+ const claude = which("claude", env.PATH ?? "");
336
+ if (claude !== undefined) {
337
+ add({ id: "claude-cli", status: "pass", message: `claude is on PATH (${t(claude)})` });
338
+ }
339
+ else {
340
+ const batch = cfg?.driveMode === "batch";
341
+ add({
342
+ id: "claude-cli",
343
+ status: batch ? "fail" : "warn",
344
+ message: batch
345
+ ? "there's no `claude` on PATH, and driveMode is batch: every chat turn and trigger runs it, so they'll all fail"
346
+ : "there's no `claude` on PATH: chats work (they use the SDK's own), but the sweeper that keeps OVERVIEW.md and CHANGELOG.md current runs `claude` and will fail",
347
+ fix: "npm install -g @anthropic-ai/claude-code (then re-run `paddock service install --refresh` if it's a service, so the unit's PATH has it)",
348
+ });
349
+ }
350
+ // ---- a newer version (SPEC §1). The only check that asks the network, and --offline skips it.
351
+ if (i.offline === true) {
352
+ add({ id: "update", status: "skip", message: "not asking the registry for a newer version (--offline)" });
353
+ }
354
+ else {
355
+ try {
356
+ const target = await resolveTarget({
357
+ packageName: PADDOCK_PACKAGE,
358
+ registry: i.registry ?? resolveRegistry(env, (k) => npmConfigGet(k, { env })),
359
+ ...(i.registryFetch !== undefined ? { fetch: i.registryFetch } : {}),
360
+ });
361
+ const cmp = compareVersions(target.version, PADDOCK_VERSION);
362
+ const available = cmp > 0 && !isPrerelease(target.version);
363
+ const cmd = info.canUpdate
364
+ ? { command: "paddock update" }
365
+ : updateCommand(info, { packageName: PADDOCK_PACKAGE, target: target.version, image: PADDOCK_IMAGE });
366
+ // Only into a data dir that's there. Making it here would make the instance:
367
+ // `serve` prints its first-run welcome only when the data dir is new, and
368
+ // `config show` stays out of it for the same reason.
369
+ if (fs.existsSync(home))
370
+ writeUpdateCheck(home, {
371
+ name: PADDOCK_PACKAGE,
372
+ current: PADDOCK_VERSION,
373
+ target: target.version,
374
+ tag: target.tag,
375
+ updateAvailable: available,
376
+ installMethod: info.method,
377
+ canUpdate: info.canUpdate,
378
+ command: available ? cmd.command : null,
379
+ engines: target.engines,
380
+ nodeOk: satisfiesEngines(nodeVersion, target.engines !== null ? { node: target.engines } : undefined).ok !== false,
381
+ changelog: paddockChangelogUrl(target.version),
382
+ checkedAt: new Date().toISOString(),
383
+ });
384
+ if (available) {
385
+ notice = updateNotice({
386
+ name: PADDOCK_NAME,
387
+ current: PADDOCK_VERSION,
388
+ target: target.version,
389
+ command: cmd.command,
390
+ ...("note" in cmd && cmd.note !== undefined ? { note: cmd.note } : {}),
391
+ changelog: paddockChangelogUrl(target.version),
392
+ });
393
+ add({
394
+ id: "update",
395
+ status: "warn",
396
+ message: `Paddock ${target.version} is available (you have ${PADDOCK_VERSION})`,
397
+ fix: cmd.command,
398
+ });
399
+ }
400
+ else {
401
+ add({
402
+ id: "update",
403
+ status: "pass",
404
+ message: cmp < 0
405
+ ? `${PADDOCK_VERSION} is newer than the registry's latest (${target.version})`
406
+ : `${PADDOCK_VERSION} is the latest`,
407
+ });
408
+ }
409
+ }
410
+ catch (err) {
411
+ add({
412
+ id: "update",
413
+ status: "warn",
414
+ message: `couldn't ask the registry for a newer version: ${err instanceof RegistryError ? `${err.message} (${err.url})` : firstLine(err.message)}`,
415
+ fix: "check the network (or npm_config_registry), or skip this check with --offline",
416
+ });
417
+ }
418
+ }
419
+ return { checks, ...(notice !== undefined ? { notice } : {}) };
420
+ }
421
+ /** The `claude-login` check: what kind of login Paddock would find, never its value. */
422
+ async function loginCheck(cfg, env, i, t, service) {
423
+ const tokenVar = TOKEN_ENV_VARS.find((v) => (env[v] ?? "").trim() !== "");
424
+ if (tokenVar !== undefined) {
425
+ return {
426
+ id: "claude-login",
427
+ status: "pass",
428
+ message: `${tokenVar} is set in this shell` +
429
+ (service ? " (the service doesn't inherit this shell's environment, so it needs a login of its own)" : ""),
430
+ };
431
+ }
432
+ if (fs.existsSync(path.join(cfg.claudeHome, ".credentials.json"))) {
433
+ return { id: "claude-login", status: "pass", message: `a Claude login is in Paddock's Claude home (${t(cfg.claudeHome)})` };
434
+ }
435
+ const userHome = i.userClaudeHome ?? path.join(os.homedir(), ".claude");
436
+ if (cfg.credentials === "host") {
437
+ if (fs.existsSync(path.join(userHome, ".credentials.json"))) {
438
+ return {
439
+ id: "claude-login",
440
+ status: "pass",
441
+ message: `Paddock shares this machine's Claude Code login (${t(userHome)}, claude.credentials: host)`,
442
+ };
443
+ }
444
+ if (i.ctx.platform === "darwin") {
445
+ const keychain = await (i.keychain ?? probeKeychain)();
446
+ if (keychain === "found") {
447
+ return {
448
+ id: "claude-login",
449
+ status: "pass",
450
+ message: "Paddock shares this machine's Claude Code login, from the macOS Keychain (claude.credentials: host)",
451
+ };
452
+ }
453
+ }
454
+ }
455
+ return {
456
+ id: "claude-login",
457
+ status: "warn",
458
+ message: `no Claude login found for Paddock: none in its Claude home (${t(cfg.claudeHome)}), no ${TOKEN_ENV_VARS.join("/")} in the environment` +
459
+ (cfg.credentials === "host" ? ", and none on this machine to share" : ", and claude.credentials: own shares none of this machine's") +
460
+ ". Chats will fail with \"Not logged in\"",
461
+ fix: `CLAUDE_CONFIG_DIR=${t(cfg.claudeHome)} claude login (or \`claude setup-token\` and put the token in CLAUDE_CODE_OAUTH_TOKEN)`,
462
+ };
463
+ }
464
+ /** The checklist, for a person. */
465
+ export function renderChecks(checks) {
466
+ return (checks
467
+ .map((c) => `${MARK[c.status]} ${c.message}` +
468
+ (c.fix !== undefined && (c.status === "fail" || c.status === "warn") ? `\n fix: ${c.fix}` : ""))
469
+ .join("\n") + "\n");
470
+ }
471
+ /** 1 if any check failed (SPEC §1); warnings don't change it. */
472
+ export function doctorExit(checks) {
473
+ return checks.some((c) => c.status === "fail") ? EXIT.FAILED : EXIT.OK;
474
+ }
475
+ /** `paddock doctor`: run the checks, print them, and hand back the exit code. */
476
+ export async function runDoctor(opts, ctx) {
477
+ const { checks, notice } = await diagnose({
478
+ ctx,
479
+ ...(opts.dataDir !== undefined ? { dataDir: opts.dataDir } : {}),
480
+ ...(opts.offline === true ? { offline: true } : {}),
481
+ entry: process.argv[1] ?? ctx.scriptPath,
482
+ });
483
+ const code = doctorExit(checks);
484
+ if (opts.json === true) {
485
+ process.stdout.write(`${JSON.stringify({ ok: code === EXIT.OK, version: PADDOCK_VERSION, checks }, null, 2)}\n`);
486
+ }
487
+ else {
488
+ process.stdout.write(renderChecks(checks));
489
+ }
490
+ // The update notice goes to stderr (SPEC §3), with or without --json.
491
+ if (notice !== undefined)
492
+ process.stderr.write(`\n${notice}\n`);
493
+ return code;
494
+ }
495
+ // ---- the real implementations of the injected seams
496
+ /** The package's `engines.node`, read from the package root §6's walk-up found. */
497
+ function enginesOf(packageRoot) {
498
+ if (packageRoot === undefined)
499
+ return undefined;
500
+ try {
501
+ const raw = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
502
+ return typeof raw.engines?.node === "string" ? raw.engines.node : undefined;
503
+ }
504
+ catch {
505
+ return undefined;
506
+ }
507
+ }
508
+ /**
509
+ * Load the instance's config the way `paddock serve` would, without creating
510
+ * anything. `loadPaddockConfig` reads `PADDOCK_DATA_DIR`, so it's set for the
511
+ * call and put back.
512
+ */
513
+ async function loadInstanceConfig(dataDir) {
514
+ const before = process.env.PADDOCK_DATA_DIR;
515
+ process.env.PADDOCK_DATA_DIR = dataDir;
516
+ try {
517
+ const { loadPaddockConfig } = await import("../config.js");
518
+ const cfg = loadPaddockConfig({ createDataDir: false });
519
+ return {
520
+ projectsRoot: cfg.projectsRoot,
521
+ claudeHome: cfg.claudeHome,
522
+ credentials: cfg.claude.credentials,
523
+ driveMode: cfg.driveMode,
524
+ };
525
+ }
526
+ finally {
527
+ if (before === undefined)
528
+ delete process.env.PADDOCK_DATA_DIR;
529
+ else
530
+ process.env.PADDOCK_DATA_DIR = before;
531
+ }
532
+ }
533
+ async function probeKeychain() {
534
+ const { probeMacosKeychainLogin } = await import("../claude-home.js");
535
+ return probeMacosKeychainLogin();
536
+ }
537
+ /** True if `dir` is writable; "creatable" if it doesn't exist but its nearest existing parent is; else why not. */
538
+ export function writableOrCreatable(dir) {
539
+ let probe = dir;
540
+ while (!fs.existsSync(probe)) {
541
+ const parent = path.dirname(probe);
542
+ if (parent === probe)
543
+ return "no part of it exists";
544
+ probe = parent;
545
+ }
546
+ try {
547
+ if (!fs.statSync(probe).isDirectory())
548
+ return `${probe} isn't a folder`;
549
+ fs.accessSync(probe, fs.constants.W_OK);
550
+ return probe === dir ? true : "creatable";
551
+ }
552
+ catch (err) {
553
+ return err.message;
554
+ }
555
+ }
556
+ function samePath(a, b) {
557
+ const real = (p) => {
558
+ try {
559
+ return fs.realpathSync(p);
560
+ }
561
+ catch {
562
+ return path.resolve(p);
563
+ }
564
+ };
565
+ return real(a) === real(b);
566
+ }
567
+ /** A wildcard bind is reached on loopback. */
568
+ function loopbackFor(host) {
569
+ if (host === "0.0.0.0" || host === "")
570
+ return "127.0.0.1";
571
+ if (host === "::" || host === "[::]")
572
+ return "::1";
573
+ return host;
574
+ }
575
+ function urlHost(host) {
576
+ return host.includes(":") ? `[${host}]` : host;
577
+ }
578
+ function firstLine(s) {
579
+ return s.split("\n")[0] ?? s;
580
+ }
@@ -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, BUSY_USAGE, UPDATE_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
26
+ import { CliError, UsageError, USAGE, USAGE_SYNOPSIS, SERVICE_USAGE, CONFIG_USAGE, BUSY_USAGE, UPDATE_USAGE, DOCTOR_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";
@@ -289,7 +289,9 @@ async function main() {
289
289
  ? BUSY_USAGE
290
290
  : command.verb === "update"
291
291
  ? UPDATE_USAGE
292
- : USAGE);
292
+ : command.verb === "doctor"
293
+ ? DOCTOR_USAGE
294
+ : USAGE);
293
295
  return EXIT.OK;
294
296
  }
295
297
  if (opts.version) {
@@ -329,6 +331,21 @@ async function main() {
329
331
  throw err;
330
332
  }
331
333
  }
334
+ // `doctor` (SPEC §1). Dynamic for the same reason `busy` and `update` are; it
335
+ // loads the config only to check it, and starts nothing.
336
+ if (command.verb === "doctor") {
337
+ const { runDoctor } = await import("./doctor.js");
338
+ try {
339
+ return await runDoctor(opts, serviceContext());
340
+ }
341
+ catch (err) {
342
+ if (err instanceof CliError)
343
+ return fail(err.message, err.exitCode);
344
+ if (err instanceof Error)
345
+ return fail(err.message);
346
+ throw err;
347
+ }
348
+ }
332
349
  addBundledBinsToPath();
333
350
  // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
334
351
  // used to make the current directory the workspace by setting
@@ -352,7 +369,10 @@ async function main() {
352
369
  // dynamic import below is picked up with no special-casing in config.ts.
353
370
  // Precedence is explicit flag > existing env > our default.
354
371
  const dataDir = resolveDataDir(opts);
355
- noteFirstRun(dataDir);
372
+ // `--quiet` (SPEC §3): the banner line and nothing else, so not the
373
+ // first-run note either (it is prose on stderr, but prose all the same).
374
+ if (opts.quiet !== true)
375
+ noteFirstRun(dataDir);
356
376
  process.env.PADDOCK_DATA_DIR = dataDir;
357
377
  if (opts.port)
358
378
  process.env.PORT = opts.port;
@@ -404,6 +424,11 @@ async function main() {
404
424
  // runner, and by anything that wants the URL without guessing the port — so
405
425
  // the friendly block below it stays, but below it.
406
426
  console.log(`${PADDOCK_NAME} ${PADDOCK_VERSION}: ${url}`);
427
+ if (opts.quiet === true) {
428
+ if (opts.open)
429
+ openBrowser(url);
430
+ return EXIT.OK;
431
+ }
407
432
  // Still name the data dir, even though it no longer varies with cwd: "which
408
433
  // instance am I looking at" is a real question the moment anyone passes
409
434
  // `--data-dir`, and the answer should be readable off the two lines the tool