Files
lobehub__lobehub/apps/cli/src/pkg.ts
Arvin Xu 8b7a674d22 ✨ feat(cli): add lh doctor to diagnose the whole chain in one command (#19626)
* ✨ feat(cli): add `lh doctor` to diagnose the whole chain in one command

Diagnosing a broken `lh` today means knowing which layer to suspect: `status`
answers only "does the gateway accept this machine", `whoami` only "who am I".
Everything else — a node too old for the CLI's own imports, a device gateway
that belongs to another deployment, a workspace scope saved under a different
account, a provider with no key — surfaces much later as an error that names
the symptom rather than the cause.

`lh doctor` walks the chain in order and stops reporting derived symptoms once
it finds a root cause:

  runtime → endpoints → credentials → scope → run context → server → device →
  execution

- Profiles: bare `doctor` runs the core layers (fast, cheap); `--profile
  connect | agent | hetero | selfhost | all` adds the deeper ones.
- `--offline` skips everything that touches the network, `--deep` opts into the
  costly checks, so a diagnostic never spends money by default.
- `--json` prints the same report object the terminal renderer consumes, one
  entry per check with `status`, `detail`, `evidence`, `fix` and — for skips —
  the upstream check or flag that excluded it.
- Exit code contract: 0 unless something failed, `--strict` promotes warnings.
- `--fix` applies only the repairs a check knows how to make (stale workspace
  scope, unparseable settings.json, loose permissions, leftover daemon state)
  and re-runs the check so the report shows the repaired state.
- `--deep --agent <id|slug>` starts a real run and reports tokens and cost,
  proving the chain end to end instead of inspecting it.

Also extracts the credential precedence (`LOBEHUB_JWT` > explicit flags >
`LOBEHUB_CLI_API_KEY` > stored login) into `pickAuthSource`, shared by
`resolveToken`, the tRPC client and doctor — it was spelled out twice and had
already drifted on which env names it accepts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): stop doctor from leaking proxy credentials or repairing healthy state

Five findings from an independent review of the doctor series, all real:

- `--fix` deleted the pid/status files of a daemon that was merely reconnecting,
  orphaning a live process `connect stop` could no longer find. Repairs now run
  only for the finding a check marks `evidence.repairable`, and each repair
  re-checks which state it was handed. The runner skips repairs entirely for
  findings without that marker, so a warning with nothing to undo no longer
  reports a failed repair.
- Proxy env vars were copied into the report verbatim. `http://user:pass@host`
  is a normal way to configure one, and the report is meant to be pasted into an
  issue — URL credentials are now redacted.
- A self-hosted server with the default device gateway failed `endpoints.resolution`,
  which skipped reachability, credentials, identity and every agent check behind
  it. An installation that only uses the HTTP API needs no device gateway, so
  this is a warning; the device profile's own handshake still fails concretely.
- `--fix` dropped a valid saved workspace scope whenever `LOBEHUB_WORKSPACE_ID`
  overrode it for one run, silently moving the user to personal scope once the
  override went away.
- `getAiProviderList` reports a provider's toggle, not whether a key is stored,
  so an enabled-but-keyless provider was reported as ready and the run then died
  with `InvalidProviderAPIKey` — the exact failure the check exists to pre-empt.
  Agent readiness now reads that one provider's key vault and names where the key
  comes from, saying "not verified" rather than passing when a restricted API key
  cannot see vaults at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 📝 docs(cli): record why the node check cannot catch a too-old runtime

Closing that gap means changing how the CLI is launched, which is not this
command's to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): keep doctor from crashing, over-claiming, or leaking identifiers

Ten findings from a second independent review, all confirmed:

- A socket failure reaching the gateway arrives as an EventEmitter `error`
  event, which `connect().catch()` cannot see. With no listener the whole
  report died with "Unhandled error" instead of reporting a failed handshake —
  verified by removing the new listener and reproducing the crash against a
  refused port.
- Server, gateway and proxy URLs are redacted at the report boundary, not just
  in the proxy variable: a basic-auth URL in `LOBEHUB_SERVER` used to print its
  password in `--json`. Gateway error text also quoted the WebSocket query
  string, carrying hostname, device id and user id; the account email is now
  masked too. The raw values still flow to whatever connects with them.
- "Enabled on the server" no longer claims a key exists. `deepseek` is enabled
  unconditionally in the server's own config, and `ENABLED_OPENAI` /
  `ENABLED_OLLAMA` default to true, so the report now says the server reports
  it enabled and that server-side keys are invisible from here.
- A provider holding only a `baseURL` is a local runtime (Ollama, LM Studio),
  not a missing key: it warns instead of failing, so it no longer blocks the
  deep round trip through `dependsOn`.
- The round trip only reads "no longer tracked" as success once it has seen the
  run live. The server also answers null when there is no state at all, which
  made the check incapable of failing.
- A saved workspace scope is only repairable when the account is identified and
  differs. Under `LOBEHUB_CLI_API_KEY` there is no readable subject at all, and
  `--fix` was deleting a selection that was still valid for the stored login.
- The settings repair no longer overwrites an existing `.bak`.
- The update check bounds its own registry request: the runner's timeout would
  otherwise surface as a failure and a non-zero exit, for something this check
  only ever reports as a warning.
- The command sets `process.exitCode` instead of calling `process.exit`, which
  does not wait for stdout to drain and can truncate a piped JSON report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): redact the server URL the workspace-scope check reports

Sweeping every check's evidence for an injected credential — the way the
acceptance round does — turned up one the previous pass missed: the scope check
copies the resolved server URL verbatim, so a basic-auth self-hosted URL put its
password into `--json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): make the URL redactor linear instead of backtracking

CodeQL flagged js/polynomial-redos: matching the URL lazily and then requiring
a `?` backtracks across the remainder of the string at every starting position,
so an error message with many `ws://` runs takes quadratic time. Tokenise
first and decide per token — same output, no backtracking, with a test that
fails if the quadratic shape comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* 🐛 fix(cli): scrub the doctor report centrally and stop it lingering after exit

Seven findings from a third independent review, all confirmed:

- Redaction had leaked in a third place: Node's fetch error quotes the full
  request URL, and capability and daemon evidence copied gateway URLs as-is.
  Per-check redaction had now missed an instance in each of three rounds, so
  the runner scrubs every result — detail, fix, evidence, repair messages —
  before it enters the report: URL userinfo, WebSocket query strings and email
  addresses, by linear token scanning rather than a backtracking pattern.
- With `process.exit` gone, the process waited for the event loop to empty, so
  a request abandoned by a check timeout, or the registry check's uncleared
  timer, kept it alive — up to the full `--timeout` after the report printed.
  The command now exits from a stdout write callback: after the output has
  drained, and no later.
- The registry check clears its timer and gets a runner budget that always
  outlasts its own deadline; a sub-second `--timeout` no longer turns its
  warning into a failure.
- A settings.json that cannot be read is no longer classified as corrupt and
  offered to `--fix`, which moved a possibly valid file aside unparsed. The
  repair also re-confirms the parse failure before moving anything.
- A run that stops being tracked is inconclusive even after it was seen
  running: the server returns null for lost state too, so only `isCompleted`
  proves the round trip.
- A live daemon that has not reported a connection state is a warning, not
  "connected as device unknown".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:00:18 +08:00

23 lines
867 B
TypeScript

import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as {
engines?: { node?: string };
name: string;
version: string;
};
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
/** The node range this build is published for, e.g. `>=22.15`. */
export const cliNodeEngine = pkg.engines?.node;