From 5130bcb6b749b0df7ad8a5ea3e4588ded7db8f4d Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Mon, 15 Jun 2026 17:02:27 +0200 Subject: [PATCH] fix(appkit): lazy-load @ast-grep/napi so a missing native binary degrades gracefully @ast-grep/napi's platform binary ships as an optionalDependency. When it is omitted (e.g. a Databricks Apps remote-install running `npm install --omit=optional`), the parent package throws MODULE_NOT_FOUND on require, so a top-level import crashed the appkit CLI, the @databricks/appkit barrel, and typegen with an uncaught stack. Load @ast-grep/napi lazily via createRequire behind two memoized loaders (shared CLI + appkit) so it is never touched at module-eval time. ast-grep is optional in some paths (serving-endpoint extraction, the dev source-loc Vite plugin), which now degrade to existing fallbacks with a warning; the commands that require it (lint, plugin sync, codemod) surface a clear actionable error and exit non-zero instead of a raw MODULE_NOT_FOUND. Also narrow the generate-types missing-module catch so a native-binary failure is not misreported as a missing @databricks/appkit. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/internal/ast-grep.ts | 39 ++++++++++ .../server/react-source-loc-vite-plugin.ts | 23 +++++- ...act-source-loc-vite-plugin.degrade.test.ts | 36 ++++++++++ .../serving/server-file-extractor.ts | 20 +++++- .../server-file-extractor.degrade.test.ts | 37 ++++++++++ .../tests/server-file-extractor.test.ts | 12 +++- packages/shared/src/cli/ast-grep.test.ts | 54 ++++++++++++++ packages/shared/src/cli/ast-grep.ts | 72 +++++++++++++++++++ .../cli/commands/codemod/on-plugins-ready.ts | 49 +++++++------ .../tests/on-plugins-ready.degrade.test.ts | 34 +++++++++ .../shared/src/cli/commands/generate-types.ts | 7 +- packages/shared/src/cli/commands/lint.ts | 17 +++-- .../src/cli/commands/plugin/sync/sync.test.ts | 6 +- .../src/cli/commands/plugin/sync/sync.ts | 10 ++- 14 files changed, 385 insertions(+), 31 deletions(-) create mode 100644 packages/appkit/src/internal/ast-grep.ts create mode 100644 packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.degrade.test.ts create mode 100644 packages/appkit/src/type-generator/serving/tests/server-file-extractor.degrade.test.ts create mode 100644 packages/shared/src/cli/ast-grep.test.ts create mode 100644 packages/shared/src/cli/ast-grep.ts create mode 100644 packages/shared/src/cli/commands/codemod/tests/on-plugins-ready.degrade.test.ts 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); }), );