diff --git a/packages/appkit/src/internal/ast-grep.ts b/packages/appkit/src/internal/ast-grep.ts
new file mode 100644
index 000000000..d39709bfd
--- /dev/null
+++ b/packages/appkit/src/internal/ast-grep.ts
@@ -0,0 +1,39 @@
+import { createRequire } from "node:module";
+
+/**
+ * Lazy, memoized loader for `@ast-grep/napi` (appkit side).
+ *
+ * See `packages/shared/src/cli/ast-grep.ts` for the full rationale on why this is
+ * loaded lazily via `require` rather than a top-level `import` (the native binary
+ * ships as an optionalDependency and can be absent on a remote-install that omits
+ * optional deps, in which case `require` throws `MODULE_NOT_FOUND`).
+ *
+ * In appkit, ast-grep only powers optional conveniences — serving-endpoint type
+ * auto-discovery and the dev-only source-location Vite plugin — so every caller
+ * DEGRADES (skips the feature) when the native binary is unavailable rather than
+ * failing. That keeps importing the `@databricks/appkit` barrel and booting a
+ * server safe even when the platform binary was never materialized, regardless of
+ * how the production server bundle is built.
+ *
+ * `createRequire` keeps the call sites synchronous (e.g. the sync serving
+ * extractor and the sync Vite `transform` hook).
+ */
+const _require = createRequire(import.meta.url);
+
+let cached: typeof import("@ast-grep/napi") | null | undefined;
+
+/**
+ * Load `@ast-grep/napi`, or return `null` if its native binary is unavailable on
+ * this platform. The underlying `require` runs at most once (memoized).
+ */
+export function tryLoadAstGrep(): typeof import("@ast-grep/napi") | null {
+ if (cached !== undefined) return cached;
+ let mod: typeof import("@ast-grep/napi") | null;
+ try {
+ mod = _require("@ast-grep/napi");
+ } catch {
+ mod = null;
+ }
+ cached = mod;
+ return mod;
+}
diff --git a/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts b/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts
index 863fb7755..f4590dcde 100644
--- a/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts
+++ b/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts
@@ -1,7 +1,11 @@
import path from "node:path";
-import { Lang, parse, type SgNode } from "@ast-grep/napi";
+import type { SgNode } from "@ast-grep/napi";
import MagicString from "magic-string";
import type { Plugin } from "vite";
+import { tryLoadAstGrep } from "../../internal/ast-grep";
+
+/** Warn at most once per process when ast-grep is unavailable (dev-only plugin). */
+let warnedAstGrepUnavailable = false;
const JSX_ELEMENT_MATCHER = {
rule: {
@@ -68,6 +72,23 @@ export function reactSourceLocPlugin(
transform(code, id) {
if (!shouldTransform(id)) return;
+ // Lazy-load ast-grep. If its native binary is unavailable, degrade: skip
+ // source-location annotation (a dev convenience) instead of crashing the
+ // dev server. Warn once so the cause is visible without spamming the log.
+ const astGrep = tryLoadAstGrep();
+ if (!astGrep) {
+ if (!warnedAstGrepUnavailable) {
+ warnedAstGrepUnavailable = true;
+ console.warn(
+ "[appkit:react-source-loc] @ast-grep/napi's native binary is " +
+ `unavailable (${process.platform}-${process.arch}); skipping ` +
+ "data-source annotation for this dev session.",
+ );
+ }
+ return;
+ }
+ const { Lang, parse } = astGrep;
+
const cleanId = cleanModuleId(id);
const root = parse(Lang.Tsx, code).root();
const s = new MagicString(code);
diff --git a/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.degrade.test.ts b/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.degrade.test.ts
new file mode 100644
index 000000000..bdb532874
--- /dev/null
+++ b/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.degrade.test.ts
@@ -0,0 +1,36 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+// Force the lazy ast-grep loader to report the native binary as unavailable.
+vi.mock("../../../internal/ast-grep", () => ({
+ tryLoadAstGrep: vi.fn(() => null),
+}));
+
+import { reactSourceLocPlugin } from "../react-source-loc-vite-plugin";
+
+interface TestableHooks {
+ transform?: (code: string, id: string) => unknown;
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("reactSourceLocPlugin — ast-grep unavailable (degrade)", () => {
+ // The "warn once" guard is module-level state, so a single test exercises
+ // both behaviours: each call skips (returns undefined) but only the first
+ // emits a warning.
+ it("skips data-source annotation and warns exactly once across calls", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { transform } = reactSourceLocPlugin({
+ projectRoot: "/app",
+ }) as unknown as TestableHooks;
+
+ expect(transform?.("const a =
;", "/app/src/A.tsx")).toBeUndefined();
+ expect(
+ transform?.("const b = ;", "/app/src/B.tsx"),
+ ).toBeUndefined();
+
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0].join(" ")).toContain("native binary is");
+ });
+});
diff --git a/packages/appkit/src/type-generator/serving/server-file-extractor.ts b/packages/appkit/src/type-generator/serving/server-file-extractor.ts
index b26b0bf15..dd5cd9ab2 100644
--- a/packages/appkit/src/type-generator/serving/server-file-extractor.ts
+++ b/packages/appkit/src/type-generator/serving/server-file-extractor.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
-import { Lang, parse, type SgNode } from "@ast-grep/napi";
+import type { SgNode } from "@ast-grep/napi";
+import { tryLoadAstGrep } from "../../internal/ast-grep";
import { createLogger } from "../../logging/logger";
import type { EndpointConfig } from "../../plugins/serving/types";
@@ -48,6 +49,23 @@ export function extractServingEndpoints(
return null;
}
+ // Lazy-load ast-grep. If its native binary is unavailable (e.g. an install
+ // that omitted optional deps), degrade gracefully: callers fall back to the
+ // explicit `endpoints` option or the DATABRICKS_SERVING_ENDPOINT_NAME env var.
+ const astGrep = tryLoadAstGrep();
+ if (!astGrep) {
+ logger.warn(
+ "@ast-grep/napi's native binary is unavailable (%s-%s); skipping " +
+ "serving-endpoint auto-discovery for %s. Pass endpoints explicitly via " +
+ "appKitServingTypesPlugin({ endpoints }) or set DATABRICKS_SERVING_ENDPOINT_NAME.",
+ process.platform,
+ process.arch,
+ serverFilePath,
+ );
+ return null;
+ }
+ const { Lang, parse } = astGrep;
+
const lang = serverFilePath.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript;
const ast = parse(lang, content);
const root = ast.root();
diff --git a/packages/appkit/src/type-generator/serving/tests/server-file-extractor.degrade.test.ts b/packages/appkit/src/type-generator/serving/tests/server-file-extractor.degrade.test.ts
new file mode 100644
index 000000000..d177c6f89
--- /dev/null
+++ b/packages/appkit/src/type-generator/serving/tests/server-file-extractor.degrade.test.ts
@@ -0,0 +1,37 @@
+import fs from "node:fs";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+// Force the lazy ast-grep loader to report the native binary as unavailable.
+vi.mock("../../../internal/ast-grep", () => ({
+ tryLoadAstGrep: vi.fn(() => null),
+}));
+
+import { extractServingEndpoints } from "../server-file-extractor";
+
+describe("extractServingEndpoints — ast-grep unavailable (degrade)", () => {
+ beforeEach(() => {
+ // Valid server file with inline endpoints; extraction would succeed if
+ // ast-grep were available, so a null result proves the degrade path ran.
+ vi.spyOn(fs, "readFileSync").mockReturnValue(
+ `import { createApp, serving } from "@databricks/appkit";
+createApp({ plugins: [serving({ endpoints: { demo: { env: "X" } } })] });`,
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test("returns null instead of throwing", () => {
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ expect(extractServingEndpoints("/app/server/index.ts")).toBeNull();
+ });
+
+ test("warns that serving-endpoint auto-discovery was skipped", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ extractServingEndpoints("/app/server/index.ts");
+ const logged = warn.mock.calls.map((args) => args.join(" ")).join("\n");
+ expect(logged).toContain("native binary is unavailable");
+ expect(logged).toContain("/app/server/index.ts");
+ });
+});
diff --git a/packages/appkit/src/type-generator/serving/tests/server-file-extractor.test.ts b/packages/appkit/src/type-generator/serving/tests/server-file-extractor.test.ts
index 4d1a73c71..8009d1ea4 100644
--- a/packages/appkit/src/type-generator/serving/tests/server-file-extractor.test.ts
+++ b/packages/appkit/src/type-generator/serving/tests/server-file-extractor.test.ts
@@ -1,11 +1,21 @@
import fs from "node:fs";
import path from "node:path";
-import { afterEach, describe, expect, test, vi } from "vitest";
+import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
+import { tryLoadAstGrep } from "../../../internal/ast-grep";
import {
extractServingEndpoints,
findServerFile,
} from "../server-file-extractor";
+// ast-grep is now loaded lazily (and memoized) the first time it is needed.
+// Warm that cache with the real `fs` up front, because the tests below stub
+// `fs.readFileSync` globally — and Node's underlying `require` reads module
+// files through `fs`, so a stub active at first-load time would break the
+// native-module require and, via memoization, poison every later test.
+beforeAll(() => {
+ tryLoadAstGrep();
+});
+
describe("findServerFile", () => {
afterEach(() => {
vi.restoreAllMocks();
diff --git a/packages/shared/src/cli/ast-grep.test.ts b/packages/shared/src/cli/ast-grep.test.ts
new file mode 100644
index 000000000..a73979ae9
--- /dev/null
+++ b/packages/shared/src/cli/ast-grep.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test, vi } from "vitest";
+
+// Simulate a platform where @ast-grep/napi's native binary was never
+// materialized (e.g. an Apps remote-install that omitted optional deps):
+// createRequire hands back a require() that throws MODULE_NOT_FOUND for the
+// package, exactly like the real failure on deploy.
+vi.mock("node:module", async () => {
+ const actual =
+ await vi.importActual("node:module");
+ return {
+ ...actual,
+ createRequire: (...args: Parameters) => {
+ const realRequire = actual.createRequire(...args);
+ const fakeRequire = ((id: string) => {
+ if (id === "@ast-grep/napi") {
+ const err = new Error(
+ "Cannot find module '@ast-grep/napi-linux-x64-gnu'",
+ ) as NodeJS.ErrnoException;
+ err.code = "MODULE_NOT_FOUND";
+ throw err;
+ }
+ return realRequire(id);
+ }) as NodeJS.Require;
+ return fakeRequire;
+ },
+ };
+});
+
+const { AstGrepUnavailableError, loadAstGrepOrThrow, tryLoadAstGrep } =
+ await import("./ast-grep");
+
+describe("@ast-grep/napi lazy loader — native binary unavailable", () => {
+ test("tryLoadAstGrep() returns null instead of throwing", () => {
+ expect(tryLoadAstGrep()).toBeNull();
+ });
+
+ test("loadAstGrepOrThrow() throws an AstGrepUnavailableError", () => {
+ expect(() => loadAstGrepOrThrow()).toThrow(AstGrepUnavailableError);
+ });
+
+ test("the thrown message is actionable, not a raw MODULE_NOT_FOUND stack", () => {
+ let message = "";
+ try {
+ loadAstGrepOrThrow();
+ } catch (error) {
+ message = (error as Error).message;
+ }
+ // Names the package and the concrete remedy...
+ expect(message).toContain("@ast-grep/napi");
+ expect(message).toContain("--include=optional");
+ // ...and never surfaces the opaque native-binary failure to the user.
+ expect(message).not.toContain("MODULE_NOT_FOUND");
+ });
+});
diff --git a/packages/shared/src/cli/ast-grep.ts b/packages/shared/src/cli/ast-grep.ts
new file mode 100644
index 000000000..cb239aacb
--- /dev/null
+++ b/packages/shared/src/cli/ast-grep.ts
@@ -0,0 +1,72 @@
+import { createRequire } from "node:module";
+
+/**
+ * Lazy, memoized loader for `@ast-grep/napi` (shared CLI side).
+ *
+ * `@ast-grep/napi` is a native (N-API) module whose platform binary ships as an
+ * optionalDependency (`@ast-grep/napi-`). When that binary is not
+ * materialized — e.g. a Databricks Apps remote-install that omits optional
+ * dependencies — the parent package's `index.js` throws `MODULE_NOT_FOUND` the
+ * moment it is `require`d. A top-level static import of the package therefore
+ * crashes the whole CLI during module evaluation, before any command action can
+ * run (which is why a command-level try/catch never fires — you get a raw stack).
+ *
+ * Loading it lazily through `require` instead (a) keeps it off the module-eval
+ * path, so merely loading the CLI never touches the native binary, and (b) lets
+ * us catch a missing binary and turn it into either a graceful degrade
+ * ({@link tryLoadAstGrep}) or a clear, actionable error
+ * ({@link loadAstGrepOrThrow}) rather than an opaque stack trace.
+ *
+ * `createRequire` (rather than dynamic `await import()`) is deliberate: it keeps
+ * the call sites synchronous, so the sync parse-using functions stay sync with no
+ * async-propagation refactor.
+ */
+const _require = createRequire(import.meta.url);
+
+let cached: typeof import("@ast-grep/napi") | null | undefined;
+
+/**
+ * Thrown when `@ast-grep/napi`'s native binary is unavailable and the caller
+ * requires it. Carries an actionable message; CLI commands catch this to print
+ * the message (not a raw `MODULE_NOT_FOUND` stack) and exit non-zero.
+ */
+export class AstGrepUnavailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "AstGrepUnavailableError";
+ }
+}
+
+/**
+ * Load `@ast-grep/napi`, or return `null` if its native binary is unavailable on
+ * this platform. The underlying `require` runs at most once (memoized).
+ */
+export function tryLoadAstGrep(): typeof import("@ast-grep/napi") | null {
+ if (cached !== undefined) return cached;
+ let mod: typeof import("@ast-grep/napi") | null;
+ try {
+ mod = _require("@ast-grep/napi");
+ } catch {
+ mod = null;
+ }
+ cached = mod;
+ return mod;
+}
+
+/**
+ * Load `@ast-grep/napi`, or throw an {@link AstGrepUnavailableError} with an
+ * actionable message. Use from commands that cannot function without it
+ * (`lint`, `plugin sync`, `codemod`).
+ */
+export function loadAstGrepOrThrow(): typeof import("@ast-grep/napi") {
+ const mod = tryLoadAstGrep();
+ if (!mod) {
+ throw new AstGrepUnavailableError(
+ "@ast-grep/napi's native binary is unavailable for this platform " +
+ `(${process.platform}-${process.arch}). Reinstall with optional ` +
+ "dependencies enabled (e.g. `npm install --include=optional`) so the " +
+ "platform package (@ast-grep/napi-) is materialized.",
+ );
+ }
+ return mod;
+}
diff --git a/packages/shared/src/cli/commands/codemod/on-plugins-ready.ts b/packages/shared/src/cli/commands/codemod/on-plugins-ready.ts
index 37faeefe8..125b6ffc4 100644
--- a/packages/shared/src/cli/commands/codemod/on-plugins-ready.ts
+++ b/packages/shared/src/cli/commands/codemod/on-plugins-ready.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
-import { Lang, parse } from "@ast-grep/napi";
import { Command } from "commander";
+import { AstGrepUnavailableError, loadAstGrepOrThrow } from "../../ast-grep";
const SEARCH_DIRS = ["server", "src", "."];
const CANDIDATE_NAMES = ["server.ts", "index.ts"];
@@ -55,6 +55,7 @@ function findTsFiles(dir: string, files: string[] = []): string[] {
}
function isAlreadyMigrated(content: string): boolean {
+ const { Lang, parse } = loadAstGrepOrThrow();
const ast = parse(Lang.TypeScript, content);
const root = ast.root();
return root.findAll("createApp({ $$$PROPS })").some((match) => {
@@ -435,31 +436,39 @@ function runCodemod(options: { path?: string; write?: boolean }) {
let hasChanges = false;
- for (const file of files) {
- const relPath = path.relative(rootDir, file);
- const result = migrateFile(file);
+ try {
+ for (const file of files) {
+ const relPath = path.relative(rootDir, file);
+ const result = migrateFile(file);
- for (const warning of result.warnings) {
- console.log(` ${relPath}: ${warning}`);
- }
+ for (const warning of result.warnings) {
+ console.log(` ${relPath}: ${warning}`);
+ }
- if (!result.migrated) {
- if (result.warnings.length === 0) {
- console.log(` ${relPath}: No migration needed.`);
+ if (!result.migrated) {
+ if (result.warnings.length === 0) {
+ console.log(` ${relPath}: No migration needed.`);
+ }
+ continue;
}
- continue;
- }
- hasChanges = true;
+ hasChanges = true;
- if (write) {
- fs.writeFileSync(file, result.content, "utf-8");
- console.log(` ${relPath}: Migrated successfully.`);
- } else {
- console.log(`\n--- ${relPath} (dry run) ---`);
- console.log(result.content);
- console.log("---");
+ if (write) {
+ fs.writeFileSync(file, result.content, "utf-8");
+ console.log(` ${relPath}: Migrated successfully.`);
+ } else {
+ console.log(`\n--- ${relPath} (dry run) ---`);
+ console.log(result.content);
+ console.log("---");
+ }
+ }
+ } catch (error) {
+ if (error instanceof AstGrepUnavailableError) {
+ console.error(error.message);
+ process.exit(1);
}
+ throw error;
}
if (hasChanges && !write) {
diff --git a/packages/shared/src/cli/commands/codemod/tests/on-plugins-ready.degrade.test.ts b/packages/shared/src/cli/commands/codemod/tests/on-plugins-ready.degrade.test.ts
new file mode 100644
index 000000000..1a5da8bab
--- /dev/null
+++ b/packages/shared/src/cli/commands/codemod/tests/on-plugins-ready.degrade.test.ts
@@ -0,0 +1,34 @@
+import fs from "node:fs";
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+// Force loadAstGrepOrThrow to behave as it would when the native binary is
+// unavailable, while keeping the real AstGrepUnavailableError class so the
+// command's `instanceof` discrimination still holds.
+vi.mock("../../../ast-grep", async () => {
+ const actual =
+ await vi.importActual(
+ "../../../ast-grep",
+ );
+ return {
+ ...actual,
+ loadAstGrepOrThrow: vi.fn(() => {
+ throw new actual.AstGrepUnavailableError("ast-grep unavailable (test)");
+ }),
+ };
+});
+
+import { AstGrepUnavailableError } from "../../../ast-grep";
+import { migrateFile } from "../on-plugins-ready";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("codemod on-plugins-ready — ast-grep unavailable (require)", () => {
+ test("migrateFile throws AstGrepUnavailableError, not a raw MODULE_NOT_FOUND", () => {
+ vi.spyOn(fs, "readFileSync").mockReturnValue("const x = 1;");
+ expect(() => migrateFile("/fake/server.ts")).toThrow(
+ AstGrepUnavailableError,
+ );
+ });
+});
diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts
index 03c1631a1..1d35895f7 100644
--- a/packages/shared/src/cli/commands/generate-types.ts
+++ b/packages/shared/src/cli/commands/generate-types.ts
@@ -93,9 +93,14 @@ async function runGenerateTypes(
});
console.log(`Generated serving types: ${servingOutFile}`);
} catch (error) {
+ // Only treat this as "appkit is not installed" when the module that could
+ // not be found is actually @databricks/appkit (the dynamic import above).
+ // A bare "Cannot find module" check would misreport unrelated failures —
+ // e.g. a missing @ast-grep/napi native binary — as a missing appkit.
if (
error instanceof Error &&
- error.message.includes("Cannot find module")
+ error.message.includes("Cannot find module") &&
+ error.message.includes("@databricks/appkit")
) {
console.error(
"Error: The 'generate-types' command is only available in @databricks/appkit.",
diff --git a/packages/shared/src/cli/commands/lint.ts b/packages/shared/src/cli/commands/lint.ts
index 00eb55ad8..162554a02 100644
--- a/packages/shared/src/cli/commands/lint.ts
+++ b/packages/shared/src/cli/commands/lint.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
-import { Lang, parse } from "@ast-grep/napi";
import { Command } from "commander";
+import { AstGrepUnavailableError, loadAstGrepOrThrow } from "../ast-grep";
interface Rule {
id: string;
@@ -74,6 +74,7 @@ interface Violation {
}
function lintFile(filePath: string, rules: Rule[]): Violation[] {
+ const { Lang, parse } = loadAstGrepOrThrow();
const violations: Violation[] = [];
const content = fs.readFileSync(filePath, "utf-8");
const lang = filePath.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript;
@@ -119,9 +120,17 @@ function runLint() {
const allViolations: Violation[] = [];
- for (const file of files) {
- const violations = lintFile(file, rules);
- allViolations.push(...violations);
+ try {
+ for (const file of files) {
+ const violations = lintFile(file, rules);
+ allViolations.push(...violations);
+ }
+ } catch (error) {
+ if (error instanceof AstGrepUnavailableError) {
+ console.error(error.message);
+ process.exit(1);
+ }
+ throw error;
}
if (allViolations.length === 0) {
diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts
index e331e27f3..af6980ecf 100644
--- a/packages/shared/src/cli/commands/plugin/sync/sync.test.ts
+++ b/packages/shared/src/cli/commands/plugin/sync/sync.test.ts
@@ -1,7 +1,7 @@
import path from "node:path";
-import { Lang, parse } from "@ast-grep/napi";
import { describe, expect, it } from "vitest";
import { templateFieldEntrySchema } from "../../../../schemas/manifest";
+import { loadAstGrepOrThrow } from "../../../ast-grep";
import {
isWithinDirectory,
parseImports,
@@ -9,6 +9,10 @@ import {
shouldAllowJsManifestForPackage,
} from "./sync";
+// Load ast-grep through the SDK's lazy loader (the binary is present in CI/dev,
+// where these tests run). Mirrors how the command itself obtains it.
+const { Lang, parse } = loadAstGrepOrThrow();
+
describe("plugin sync", () => {
describe("isWithinDirectory", () => {
it("returns true when filePath equals boundary", () => {
diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts
index 6aab3987b..b4a19681a 100644
--- a/packages/shared/src/cli/commands/plugin/sync/sync.ts
+++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts
@@ -1,11 +1,12 @@
import fs from "node:fs";
import path from "node:path";
-import { Lang, parse, type SgNode } from "@ast-grep/napi";
+import type { SgNode } from "@ast-grep/napi";
import { Command } from "commander";
import {
TEMPLATE_SCAFFOLDING,
templateFieldEntrySchema,
} from "../../../../schemas/manifest";
+import { AstGrepUnavailableError, loadAstGrepOrThrow } from "../../../ast-grep";
import {
loadManifestFromFile,
type ResolvedManifest,
@@ -646,6 +647,9 @@ async function runPluginsSync(options: {
}
const content = fs.readFileSync(serverFile, "utf-8");
+ // Parsing the server file requires ast-grep. Load it lazily and surface a
+ // clear, actionable error if its native binary is unavailable.
+ const { Lang, parse } = loadAstGrepOrThrow();
const lang = serverFile.endsWith(".tsx") ? Lang.Tsx : Lang.TypeScript;
const ast = parse(lang, content);
const root = ast.root();
@@ -914,7 +918,9 @@ Examples:
)
.action((opts) =>
runPluginsSync(opts).catch((err) => {
- console.error(err);
+ // A missing ast-grep binary already carries an actionable message; print
+ // just that (not a raw stack). Anything else keeps full-error logging.
+ console.error(err instanceof AstGrepUnavailableError ? err.message : err);
process.exit(1);
}),
);