Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0744f9a
feat(cli): add appkit doctor command
IamGalymzhan Jul 23, 2026
7569c08
Merge branch 'main' into feat/doctor-command
IamGalymzhan Jul 27, 2026
99ec294
refactor(cli): simplify appkit doctor command
IamGalymzhan Jul 27, 2026
93315f6
feat(shared): improve appkit doctor auth diagnostics and report reada…
IamGalymzhan Jul 29, 2026
463560c
test(shared): fix doctor report detail-block assertion
IamGalymzhan Jul 29, 2026
87271e6
Merge branch 'main' into feat/doctor-command
IamGalymzhan Jul 29, 2026
cbf5100
Merge branch 'main' into feat/doctor-command
IamGalymzhan Jul 30, 2026
12191f8
feat(shared): make doctor DAB-aware with wiring checks and refined re…
IamGalymzhan Jul 30, 2026
d8a4cbd
test(shared): strip ANSI codes in doctor report test capture
IamGalymzhan Jul 30, 2026
f955f06
fix(shared): address doctor review — declare js-yaml, fail on bad YAM…
IamGalymzhan Jul 30, 2026
ce641bf
fix(shared): pass doctor profile via SDK config, not process.env muta…
IamGalymzhan Jul 30, 2026
f2d3139
fix(shared): redact secrets from doctor report and --json
IamGalymzhan Jul 31, 2026
bbe3b24
fix(shared): error (not warn) on an unwired REQUIRED env var
IamGalymzhan Jul 31, 2026
5a2568e
fix(shared): bound doctor's live calls with a 10s timeout
IamGalymzhan Jul 31, 2026
b7e54b1
fix(shared): make doctor summary authoritative + add exitCode to report
IamGalymzhan Jul 31, 2026
121ce81
fix(shared): guard checkResource so one throw can't crash the report
IamGalymzhan Jul 31, 2026
b64f14e
fix(shared): silence Lakebase connector's raw error dump in doctor
IamGalymzhan Jul 31, 2026
006b7a7
Merge branch 'main' into feat/doctor-command
IamGalymzhan Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/appkit/src/connectors/lakebase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ import { createLogger } from "../../logging/logger";
* @returns PostgreSQL pool with appkit integration
*/
export function createLakebasePool(config?: Partial<LakebasePoolConfig>): Pool {
const logger = createLogger("connectors:lakebase");

return createLakebasePoolBase({
logger: createLogger("connectors:lakebase"),
...config,
logger,
});
}

Expand Down
4 changes: 4 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"devDependencies": {
"@types/express": "4.17.23",
"@types/js-yaml": "4.0.9",
"@types/json-schema": "7.0.15",
"@types/node": "25.2.3",
"@types/ws": "8.18.1"
Expand All @@ -43,6 +44,9 @@
"@standard-schema/spec": "1.1.0",
"@clack/prompts": "1.0.1",
"commander": "12.1.0",
"dotenv": "16.6.1",
"js-yaml": "4.2.0",
"picocolors": "1.1.1",
"zod": "4.3.6"
}
}
172 changes: 172 additions & 0 deletions packages/shared/src/cli/commands/doctor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# `appkit doctor`

Diagnoses whether an AppKit app's declared Databricks resources are actually
usable — going beyond the startup env-var presence check to probe existence and
reachability against the live workspace.

## The check model — three layers

For each declared resource, doctor runs the offline `config` layer then the live
`existence` layer; `auth` runs once up front. It stops at the first hard failure
so the reported problem is the *root* cause, not a symptom.

| Layer | Question | How |
| ------------ | ----------------------------------------------------- | ---------------------------------------------------------- |
| `auth` | Can we authenticate to the workspace at all? | validate `DATABRICKS_HOST` is a real URL, then `currentUser.me()` — once, app-wide; a failure skips the live layer |
| `config` | Are the resource's field env vars **present**? | offline presence check of `process.env` (presence only — see note) |
| `existence` | Does the resource exist and is it reachable? | cheapest per-type live probe (`warehouses.get`, `servingEndpoints.get`, …); Lakebase runs a real `SELECT 1` |

`config` checks env-var presence only; whether a value points at a real resource
is the `existence` layer's job. (`DATABRICKS_HOST` is the exception — `auth`
validates it structurally, since a bad host means no client can be built.)

Every live call (`auth`'s `currentUser.me()` and each `existence` probe) is
bounded by a 10s wall-clock deadline (`withTimeout`). A reachable-but-unresponsive
endpoint must never hang doctor — it's a CI gate that has to return — so a
timeout surfaces as an error (`PROBE_TIMEOUT`, or an auth failure) rather than a
hung process. The race can't cancel the underlying request, but it stops doctor
*waiting* on it. (Lakebase connections are separately bounded at 10s by the
pool's `connectionTimeoutMillis`.)

## Which plugins are checked

`plugin sync` writes `appkit.plugins.json` cataloguing *every* plugin the
installed packages ship (for `apps init`), and marks the ones actually wired
into `createApp` with `requiredByTemplate: true`. Doctor checks exactly those, so
an unused built-in doesn't produce phantom "missing env var" errors.

> **Known limitation — non-GA plugins aren't checked yet.** `plugin sync` strips
> `requiredByTemplate` for beta/experimental plugins (its step 6b), so a *used*
> non-GA plugin (e.g. the agents plugin requiring a serving endpoint) is
> currently skipped. A proper fix needs a usage signal that survives sync
> regardless of stability tier (e.g. a separate `used` marker written before the
> strip); that's a `plugin sync` change tracked as a fast-follow. Until then,
> doctor covers GA plugins wired into your app.

## Resource provenance: external vs bundle-managed

Each resource is one of two kinds, which changes what doctor does with it:

- **external** — referenced by id/name (`${var.*}` or a literal in
`databricks.yml`). It should exist now, so the live `existence` probe applies.
- **bundle-managed** — created by this bundle on deploy
(`${resources.<type>.<key>.*}`). It doesn't exist until `bundle deploy`, so
probing it would be a false `NOT_FOUND`; doctor reports it as *will be created
on deploy* instead.

Provenance comes from `bundle.ts`, which reads `databricks.yml` + `app.yaml` and
classifies each binding, then overlays `origin` onto the manifest-sourced
targets. Apps with no `databricks.yml` behave exactly as before (everything
treated as external).

### Wiring check (`checks-wiring.ts`)

An offline, auth-independent check of the three-file join that
`databricks bundle validate` can't see (it doesn't know `app.yaml`↔plugin
wiring):

- `VALUEFROM_UNBOUND` — an `app.yaml` `valueFrom: <name>` matches no
`databricks.yml` binding (the env var would never be injected).
- `BUNDLE_REF_MISSING` — a `${resources.<type>.<key>.*}` binding references a
resource not declared in the bundle (deploy would fail).
- `ENV_UNWIRED` (warn) — a plugin needs an env var no `app.yaml` entry provides.

A wiring `error` gates the exit code, so `appkit doctor` catches deploy-breaking
misconfiguration pre-deploy.

## Report rendering (`report.ts`)

One flat, severity-sorted checklist — no titled sub-sections (which read as
inconsistent next to the header-less `Auth` row). `Auth` is always the first
row; every resource and wiring finding follows in the same list, sorted
most-severe first. Bundle-managed resources appear as *will be created on
deploy* rather than a probe result.

Optimised for a quiet happy path: a healthy resource is just a green tick and
its name — no plugin/type attribution, no per-layer output. Detail (and a
`Hint:`, offset by a blank line so it sits apart from the error) appears only
beneath rows that aren't `ok`. There's no header block; the profile in play is
shown only when auth *fails* (attached under the auth row, where it's the first
thing to sanity-check).

Colour (via `picocolors`) uses one tight palette:

- **glyphs** carry status — green `✓` / yellow `⚠` / red `✗` / dim `•`;
- **cyan** marks a literal token you'd type or reference — a `"quoted"` id or
binding name, or a `` `backticked` `` code/command span;
- **bold** marks a `SCREAMING_SNAKE` env-var name (bold + cyan when it sits
inside a code span);
- resource *names* are left unstyled — only the id/code you'd act on is coloured.

`picocolors` auto-disables for non-TTY output and honours `NO_COLOR`, so
piped/CI output is plain text.

### Auth outcomes

Host and credentials aren't resolved by doctor — an empty `WorkspaceClient({})`
defers to the SDK's unified-auth chain (explicit env, then the selected
`~/.databrickscfg` profile, then OAuth). `--profile` is forwarded via
`DATABRICKS_CONFIG_PROFILE`; with neither host nor profile set, the SDK falls
back to the default profile. Doctor emits:

| Code | When | Detected |
| ---- | ---- | -------- |
| `HOST_INVALID` | `DATABRICKS_HOST` set but malformed / placeholder | offline, before any network |
| `SDK_NOT_INSTALLED` | Databricks SDK not resolvable | client build |
| `AUTH_OK` | `currentUser.me()` succeeded | live |
| `AUTH_FAILED` | `me()` threw | live |

`AUTH_FAILED` carries an action-first `hint` inferred from the SDK message
(workspace unreachable, profile not found, expired login / no credentials). Each
hint says what to *run* and to confirm the profile/host actually in use — a
failure can mean the wrong target, not just a stale token. `detail` is the short
headline `authentication failed`; the full SDK message is kept in `raw` and shown
only with `--detail` — in both the human report and `--json` (i.e. `--json`
alone omits `raw`, since it can carry sensitive detail and CI often captures it;
`--json --detail` opts back in). `DATABRICKS_HOST` is stored with any embedded
`user:pass@` userinfo stripped, so credentials never reach the report or JSON.
The report labels hints `Hint:`.

## Files

- `types.ts` — the contract: layers, statuses, `ResourceTarget`, `DoctorReport`.
- `resolve-targets.ts` — reads `appkit.plugins.json` → `ResourceTarget[]`.
- `bundle.ts` — reads `databricks.yml` + `app.yaml` for binding provenance
(external vs bundle-managed) and the env↔binding wiring map.
- `databricks-client.ts` — the sole SDK seam: dynamic `import()` of the SDK /
`@databricks/appkit`, builds a `WorkspaceClient` and Lakebase pool, graceful
fallback when uninstalled.
- `checks.ts` — `checkAuth`, `checkConfig`.
- `checks-existence.ts` — per-type existence probe dispatch + error classifier.
- `checks-wiring.ts` — offline three-file join / bundle-ref consistency check.
- `run.ts` — orchestration: auth once → overlay origin → per resource
(config → existence, skipping bundle-managed) → wiring check.
- `report.ts` — runtime / deploy sections, `--json`, exit code.
- `index.ts` — the Commander command + flags.

## Flags

- `--profile <name>` — Databricks CLI profile to authenticate with.
- `--env-file <path>` — load an env file (e.g. `.env.local`) before checking.
- `--detail` — show full underlying error messages.
- `--json` — machine-readable report.

Exit code is non-zero if auth, any resource, or any wiring finding is in an
`error` state, so `appkit doctor` can gate CI / pre-deploy.

The report's `summary` counts *everything* with a status — resources, the auth
check, and wiring findings — so a `--json` consumer can trust
`summary.error === 0` to mean "nothing failed". The report also carries a
top-level `exitCode` (0/1) as the single unambiguous pass/fail signal. Both are
computed once in `runDoctor`, so `--json` and the human report never disagree.

Checks run as the identity that runs doctor (the developer locally, the app in
deployment).

## Existence coverage

Control-plane `.get()` for `sql_warehouse`, `serving_endpoint`, `genie_space`,
`job`, `volume`, `vector_search_index`, and `uc_function`; a real `SELECT 1`
connection for `postgres`/Lakebase. Other types report `skipped`.

Requires live credentials (a configured Databricks profile/token).
116 changes: 116 additions & 0 deletions packages/shared/src/cli/commands/doctor/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { originForEnvVars, readBundleInfo } from "./bundle";

function tmp(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "doctor-bundle-"));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content);
}
return dir;
}

const BUNDLE = `
resources:
apps:
app:
name: my-app
resources:
- name: sql-warehouse # external: \${var.*}
sql_warehouse:
id: \${var.sql_warehouse_id}
permission: CAN_USE
- name: report-job # bundle-managed: \${resources.*}
job:
id: \${resources.jobs.report.id}
permission: CAN_MANAGE_RUN
jobs:
report:
name: report
`;

const APP_YAML = `
env:
- name: DATABRICKS_WAREHOUSE_ID
valueFrom: sql-warehouse
- name: DATABRICKS_JOB_REPORT
valueFrom: report-job
`;

describe("readBundleInfo", () => {
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});

it("returns present:false when no databricks.yml exists", () => {
const dir = tmp({});
dirs.push(dir);
const info = readBundleInfo(dir);
expect(info.present).toBe(false);
expect(info.bindings.size).toBe(0);
});

it("throws on malformed databricks.yml instead of treating it as absent", () => {
// A present-but-unparseable bundle is a deploy-breaking error; swallowing it
// would let doctor skip all wiring checks and report a false all-clear.
const dir = tmp({ "databricks.yml": "resources: [ this: is: not: valid" });
dirs.push(dir);
expect(() => readBundleInfo(dir)).toThrow(/databricks\.yml/);
});

it("throws on malformed app.yaml", () => {
const dir = tmp({
"databricks.yml": BUNDLE,
"app.yaml": "env:\n - name: X\n valueFrom: bad-indent",
});
dirs.push(dir);
expect(() => readBundleInfo(dir)).toThrow(/app\.yaml/);
});

it("classifies external (var) vs bundle-managed (resources ref) bindings", () => {
const dir = tmp({ "databricks.yml": BUNDLE, "app.yaml": APP_YAML });
dirs.push(dir);
const info = readBundleInfo(dir);
expect(info.present).toBe(true);
expect(info.bindings.get("sql-warehouse")?.origin).toBe("external");
const job = info.bindings.get("report-job");
expect(job?.origin).toBe("bundle-managed");
expect(job?.ref).toEqual({ type: "jobs", key: "report" });
});

it("records declared bundle resources and the app.yaml env→binding map", () => {
const dir = tmp({ "databricks.yml": BUNDLE, "app.yaml": APP_YAML });
dirs.push(dir);
const info = readBundleInfo(dir);
expect(info.declaredResources.has("jobs.report")).toBe(true);
expect(info.envToBinding.get("DATABRICKS_WAREHOUSE_ID")).toBe(
"sql-warehouse",
);
});
});

describe("originForEnvVars", () => {
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});

it("resolves origin by walking env var → binding → origin", () => {
const dir = tmp({ "databricks.yml": BUNDLE, "app.yaml": APP_YAML });
dirs.push(dir);
const info = readBundleInfo(dir);
expect(originForEnvVars(["DATABRICKS_WAREHOUSE_ID"], info)).toBe(
"external",
);
expect(originForEnvVars(["DATABRICKS_JOB_REPORT"], info)).toBe(
"bundle-managed",
);
// Unknown env / no bundle → undefined (caller treats as external).
expect(originForEnvVars(["NOPE"], info)).toBeUndefined();
});
});
Loading
Loading