From 26dcc77305c61466d0ae63f506b68bb831f0d74a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Wed, 17 Jun 2026 21:13:43 -0700 Subject: [PATCH] feat(cli): expose programmatic Node install API (#479) --- packages/cli/README.md | 88 +++++++ packages/cli/package.json | 5 + packages/cli/src/api.ts | 40 +++ packages/cli/src/commands/add.ts | 2 +- packages/cli/src/commands/init.ts | 2 +- packages/cli/src/commands/remove.ts | 11 +- packages/cli/src/utils/detect.ts | 2 +- packages/cli/src/utils/install-react-grab.ts | 140 ++++++++++ packages/cli/src/utils/install-skill.ts | 77 +----- .../cli/src/utils/prompt-skill-install.ts | 65 +++++ packages/cli/test/install-react-grab.test.ts | 249 ++++++++++++++++++ packages/cli/vite.config.ts | 2 +- 12 files changed, 608 insertions(+), 75 deletions(-) create mode 100644 packages/cli/src/api.ts create mode 100644 packages/cli/src/utils/install-react-grab.ts create mode 100644 packages/cli/src/utils/prompt-skill-install.ts create mode 100644 packages/cli/test/install-react-grab.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index edf415e9d..56754990e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -67,6 +67,94 @@ npx grab@latest configure --mode hold --hold-duration 500 npx grab@latest configure ``` +## Node API + +`@react-grab/cli/api` exposes the same primitives that power the CLI, so you can build your own installer or wrap React Grab setup inside another tool. Importing it runs no code, unlike the CLI entry (`.`), which parses `argv` on import. + +### `installReactGrab(options?)` + +A high-level, non-interactive orchestrator. It detects the project, installs `react-grab` with the detected package manager, and applies the framework-specific development-only setup. It returns a structured result instead of printing or exiting. + +```ts +import { installReactGrab } from "@react-grab/cli/api"; + +const result = await installReactGrab({ cwd: process.cwd() }); + +console.log(result.framework); // "next" | "vite" | "tanstack" | "webpack" +console.log(result.didInstallPackage); // whether react-grab was added to deps +console.log(result.didChangeFile); // whether an entry file was modified +console.log(result.transform.filePath); // the file that was (or would be) edited +``` + +| Option | Type | Description | +| ----------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | Project directory (default: `process.cwd()`) | +| `framework` | `Framework` | Override framework detection | +| `nextRouterType` | `NextRouterType` | Override Next.js router detection (`app` / `pages`) | +| `packageManager` | `PackageManager` | Override package-manager detection | +| `skipPackageInstall` | `boolean` | Skip installing the `react-grab` npm package | +| `skipTransform` | `boolean` | Skip editing the framework entry file | +| `dryRun` | `boolean` | Compute the changes without installing or writing | +| `installPackageOptions` | `Omit` | Passed through to `installPackages` (e.g. `silent`, `isDev`); `cwd`/`packageManager` are controlled by the orchestrator | + +Failures throw a `ReactGrabInstallError` whose `code` identifies the cause, with the original error preserved on `error.cause`: + +- `unsupported-framework`: framework has no automatic setup (Remix, Astro, SvelteKit, Gatsby) +- `unknown-framework`: no supported framework detected +- `transform-failed`: entry file could not be located or edited +- `install-failed`: package manager failed to install `react-grab` +- `write-failed`: edited file could not be written + +`installReactGrab` configures a single project at `cwd` and does not walk a monorepo. Point `cwd` at the app you want to set up, or call `findReactProjects` first to locate the apps in a workspace. + +By default the call mutates your project: it runs the package manager and edits a framework entry file. Pass `dryRun: true` to compute the change set (returned on `result.transform`) without installing or writing. + +### Low-level building blocks + +If you want full control, compose the same functions the orchestrator uses: + +```ts +import { + detectProject, + previewTransform, + applyTransform, + installPackages, + getPackagesToInstall, + installSkill, +} from "@react-grab/cli/api"; + +const project = await detectProject(process.cwd()); +const transform = previewTransform( + project.projectRoot, + project.framework, + project.nextRouterType, + project.isReactGrabConfigured, +); +``` + +Install the package only when it's missing, then write the previewed edit. `previewTransform` sets `noChanges` when React Grab is already wired up, so guard on it before calling `applyTransform`, which writes `transform.newContent` to `transform.filePath`: + +```ts +if (!project.hasReactGrab) { + await installPackages(getPackagesToInstall(), { + cwd: project.projectRoot, + packageManager: project.packageManager, + }); +} + +if (transform.success && transform.newContent && !transform.noChanges) { + applyTransform(transform); +} + +await installSkill({ cwd: project.projectRoot }); +``` + +The full export surface, each with its TypeScript types: + +- Detection: `detectProject`, `detectFramework`, `detectPackageManager`, `detectNextRouterType`, `detectReactGrab`, `detectReactGrabConfigured`, `detectUnsupportedFramework`, `findReactProjects` +- Transforms: `previewTransform`, `previewOptionsTransform`, `previewCdnTransform`, `applyTransform`, `hasFrameworkEntryPoint` +- Installation: `installPackages`, `getPackagesToInstall`, `installSkill`, `removeSkill` + ## Supported Frameworks The CLI currently configures: diff --git a/packages/cli/package.json b/packages/cli/package.json index 98ef1272c..95efa453a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,11 @@ "types": "./dist/cli.d.ts", "import": "./dist/cli.js", "require": "./dist/cli.cjs" + }, + "./api": { + "types": "./dist/api.d.ts", + "import": "./dist/api.js", + "require": "./dist/api.cjs" } }, "scripts": { diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts new file mode 100644 index 000000000..b176d3726 --- /dev/null +++ b/packages/cli/src/api.ts @@ -0,0 +1,40 @@ +export { + detectFramework, + detectNextRouterType, + detectPackageManager, + detectProject, + detectReactGrab, + detectReactGrabConfigured, + detectUnsupportedFramework, + findReactProjects, +} from "./utils/detect.js"; +export type { + Framework, + NextRouterType, + PackageManager, + ProjectInfo, + UnsupportedFramework, + WorkspaceProject, +} from "./utils/detect.js"; + +export { + applyTransform, + hasFrameworkEntryPoint, + previewCdnTransform, + previewOptionsTransform, + previewTransform, +} from "./utils/transform.js"; +export type { ReactGrabOptions, TransformResult } from "./utils/transform.js"; + +export { getPackagesToInstall, installPackages } from "./utils/install.js"; +export type { InstallPackageOptions } from "./utils/install.js"; + +export { installReactGrab, ReactGrabInstallError } from "./utils/install-react-grab.js"; +export type { + InstallReactGrabOptions, + InstallReactGrabResult, + ReactGrabInstallErrorCode, +} from "./utils/install-react-grab.js"; + +export { installSkill, removeSkill } from "./utils/install-skill.js"; +export type { InstallSkillOptions } from "./utils/install-skill.js"; diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index a18ee2481..8894258ef 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -5,7 +5,7 @@ import { detectNonInteractive } from "../utils/is-non-interactive.js"; import { detectProject } from "../utils/detect.js"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; -import { promptSkillInstall } from "../utils/install-skill.js"; +import { promptSkillInstall } from "../utils/prompt-skill-install.js"; import { logger } from "../utils/logger.js"; import { spinner } from "../utils/spinner.js"; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 61ea0aa28..9f95c38f9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -5,7 +5,7 @@ import pc from "picocolors"; import { detectNonInteractive } from "../utils/is-non-interactive.js"; import { prompts } from "../utils/prompts.js"; import { applyTransformWithFeedback, installPackagesWithFeedback } from "../utils/cli-helpers.js"; -import { promptSkillInstall } from "../utils/install-skill.js"; +import { promptSkillInstall } from "../utils/prompt-skill-install.js"; import { detectProject, findReactProjects, diff --git a/packages/cli/src/commands/remove.ts b/packages/cli/src/commands/remove.ts index ab4d01743..96c11efbc 100644 --- a/packages/cli/src/commands/remove.ts +++ b/packages/cli/src/commands/remove.ts @@ -3,7 +3,7 @@ import { Command } from "commander"; import pc from "picocolors"; import { handleError } from "../utils/handle-error.js"; import { highlighter } from "../utils/highlighter.js"; -import { removeSkill } from "../utils/install-skill.js"; +import { agentLabel, removeSkill } from "../utils/install-skill.js"; import { logger } from "../utils/logger.js"; const VERSION = process.env.VERSION ?? "0.0.1"; @@ -19,14 +19,17 @@ export const remove = new Command() try { logger.break(); - const removedCount = await removeSkill({ cwd: resolve(opts.cwd), global: opts.global }); + const removedAgents = await removeSkill({ cwd: resolve(opts.cwd), global: opts.global }); + for (const agent of removedAgents) { + logger.log(` ${highlighter.success("\u2713")} ${agentLabel(agent)}`); + } logger.break(); - if (removedCount === 0) { + if (removedAgents.length === 0) { logger.log("React Grab skill is not installed in any detected agent."); } else { logger.log( - `${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`, + `${highlighter.success("Removed")} the React Grab skill from ${removedAgents.length} agent${removedAgents.length === 1 ? "" : "s"}.`, ); } logger.break(); diff --git a/packages/cli/src/utils/detect.ts b/packages/cli/src/utils/detect.ts index fb01180a4..0bc84ee92 100644 --- a/packages/cli/src/utils/detect.ts +++ b/packages/cli/src/utils/detect.ts @@ -10,7 +10,7 @@ export type Framework = "next" | "vite" | "tanstack" | "webpack" | "unknown"; export type NextRouterType = "app" | "pages" | "unknown"; export type UnsupportedFramework = "remix" | "astro" | "sveltekit" | "gatsby" | null; -interface ProjectInfo { +export interface ProjectInfo { packageManager: PackageManager; framework: Framework; nextRouterType: NextRouterType; diff --git a/packages/cli/src/utils/install-react-grab.ts b/packages/cli/src/utils/install-react-grab.ts new file mode 100644 index 000000000..3dc620134 --- /dev/null +++ b/packages/cli/src/utils/install-react-grab.ts @@ -0,0 +1,140 @@ +import { resolve } from "node:path"; +import { + detectNextRouterType, + detectProject, + type Framework, + type NextRouterType, + type PackageManager, +} from "./detect.js"; +import { getPackagesToInstall, installPackages, type InstallPackageOptions } from "./install.js"; +import { applyTransform, previewTransform, type TransformResult } from "./transform.js"; + +export type ReactGrabInstallErrorCode = + | "unsupported-framework" + | "unknown-framework" + | "transform-failed" + | "install-failed" + | "write-failed"; + +export class ReactGrabInstallError extends Error { + readonly code: ReactGrabInstallErrorCode; + + constructor(message: string, code: ReactGrabInstallErrorCode, options?: ErrorOptions) { + super(message, options); + this.name = "ReactGrabInstallError"; + this.code = code; + } +} + +export interface InstallReactGrabOptions { + cwd?: string; + framework?: Framework; + nextRouterType?: NextRouterType; + packageManager?: PackageManager; + skipPackageInstall?: boolean; + skipTransform?: boolean; + dryRun?: boolean; + installPackageOptions?: Omit; +} + +export interface InstallReactGrabResult { + projectRoot: string; + framework: Framework; + nextRouterType: NextRouterType; + packageManager: PackageManager; + alreadyConfigured: boolean; + didInstallPackage: boolean; + didChangeFile: boolean; + dryRun: boolean; + transform: TransformResult; +} + +export const installReactGrab = async ( + options: InstallReactGrabOptions = {}, +): Promise => { + const cwd = resolve(options.cwd ?? process.cwd()); + const project = await detectProject(cwd); + + const framework = options.framework ?? project.framework; + const packageManager = options.packageManager ?? project.packageManager; + + if (project.unsupportedFramework && !options.framework) { + throw new ReactGrabInstallError( + `${project.unsupportedFramework} is not supported by automatic setup.`, + "unsupported-framework", + ); + } + + if (framework === "unknown") { + throw new ReactGrabInstallError( + "Could not detect a supported framework. Pass `framework` explicitly to override detection.", + "unknown-framework", + ); + } + + // Detection only resolves the router type when the *detected* framework is + // Next.js, so derive it ourselves when the caller overrides to Next. + const nextRouterType = + options.nextRouterType ?? + (framework === "next" && project.nextRouterType === "unknown" + ? detectNextRouterType(project.projectRoot) + : project.nextRouterType); + + const alreadyConfigured = project.isReactGrabConfigured; + + const transform = previewTransform( + project.projectRoot, + framework, + nextRouterType, + alreadyConfigured, + ); + + if (!transform.success && !options.skipTransform) { + throw new ReactGrabInstallError(transform.message, "transform-failed"); + } + + const didInstallPackage = !options.skipPackageInstall && !options.dryRun && !project.hasReactGrab; + + if (didInstallPackage) { + try { + await installPackages(getPackagesToInstall(), { + ...options.installPackageOptions, + cwd: project.projectRoot, + packageManager, + }); + } catch (error) { + throw new ReactGrabInstallError( + error instanceof Error ? error.message : "Failed to install the react-grab package.", + "install-failed", + { cause: error }, + ); + } + } + + // Mirrors applyTransform's own write guard (it does not require originalContent), + // so an empty source file still receives the injected setup. + const hasPendingFileChange = !transform.noChanges && Boolean(transform.newContent); + const didChangeFile = hasPendingFileChange && !options.skipTransform && !options.dryRun; + + if (didChangeFile) { + const writeResult = applyTransform(transform); + if (!writeResult.success) { + throw new ReactGrabInstallError( + writeResult.error ?? `Failed to write to ${transform.filePath}`, + "write-failed", + ); + } + } + + return { + projectRoot: project.projectRoot, + framework, + nextRouterType, + packageManager, + alreadyConfigured, + didInstallPackage, + didChangeFile, + dryRun: Boolean(options.dryRun), + transform, + }; +}; diff --git a/packages/cli/src/utils/install-skill.ts b/packages/cli/src/utils/install-skill.ts index 6793dcc1f..fa3e8c413 100644 --- a/packages/cli/src/utils/install-skill.ts +++ b/packages/cli/src/utils/install-skill.ts @@ -10,10 +10,6 @@ import { isUniversalSkillAgent, } from "agent-install/skill"; import { detectAvailableAgents } from "./detect-agents.js"; -import { highlighter } from "./highlighter.js"; -import { logger } from "./logger.js"; -import { prompts } from "./prompts.js"; -import { spinner } from "./spinner.js"; const SKILL_NAME = "react-grab"; @@ -21,7 +17,7 @@ const SKILL_NAME = "react-grab"; // so installs work offline and stay pinned to this CLI version. const SKILL_SOURCE = fileURLToPath(new URL("../skills/react-grab", import.meta.url)); -const agentLabel = (agent: SkillAgentType): string => getSkillAgentConfig(agent).displayName; +export const agentLabel = (agent: SkillAgentType): string => getSkillAgentConfig(agent).displayName; // Universal agents share the canonical .agents/skills directory; others use // their own. Mirror where agent-install actually writes so removal lands. @@ -33,69 +29,19 @@ const installedSkillDir = (agent: SkillAgentType, global: boolean, cwd: string): SKILL_NAME, ); -interface PromptSkillInstallOptions { - yes?: boolean; +export interface InstallSkillOptions { + agents?: SkillAgentType[]; global?: boolean; cwd?: string; } -export const promptSkillInstall = async ({ - yes = false, +export const installSkill = async ({ + agents, global = false, cwd = process.cwd(), -}: PromptSkillInstallOptions = {}): Promise => { - const detectedAgents = await detectAvailableAgents(); - if (detectedAgents.length === 0) { - logger.warn("No supported agents detected."); - return false; - } - - let selectedAgents = detectedAgents; - if (!yes) { - const { confirmed } = await prompts({ - type: "confirm", - name: "confirmed", - message: `Install the React Grab skill (${global ? "global" : "this project"})?`, - initial: true, - }); - if (!confirmed) return false; - - const { agents } = await prompts({ - type: "multiselect", - name: "agents", - message: `Install the React Grab skill (${global ? "global" : "this project"}) for:`, - choices: detectedAgents.map((agent) => ({ - title: agentLabel(agent), - value: agent, - selected: true, - })), - instructions: false, - min: 1, - }); - selectedAgents = agents ?? []; - if (selectedAgents.length === 0) return false; - } - - const installSpinner = spinner("Installing React Grab skill.").start(); - const { installed, failed } = await add({ - source: SKILL_SOURCE, - agents: selectedAgents, - global, - cwd, - mode: "copy", - }); - - if (installed.length === 0) { - installSpinner.fail("Failed to install React Grab skill."); - return false; - } - installSpinner.succeed( - `Installed React Grab skill for ${installed.map((record) => agentLabel(record.agent)).join(", ")}.`, - ); - for (const record of failed) { - logger.log(` ${highlighter.error("\u2717")} ${agentLabel(record.agent)} ${record.error}`); - } - return true; +}: InstallSkillOptions = {}): Promise>> => { + const targetAgents = agents ?? (await detectAvailableAgents()); + return add({ source: SKILL_SOURCE, agents: targetAgents, global, cwd, mode: "copy" }); }; interface RemoveSkillOptions { @@ -108,7 +54,7 @@ interface RemoveSkillOptions { export const removeSkill = async ({ cwd = process.cwd(), global = false, -}: RemoveSkillOptions = {}): Promise => { +}: RemoveSkillOptions = {}): Promise => { const agents = await detectAvailableAgents(); const removedAgents: SkillAgentType[] = []; const dirsToRemove = new Set(); @@ -121,8 +67,5 @@ export const removeSkill = async ({ for (const skillDir of dirsToRemove) { rmSync(skillDir, { recursive: true, force: true }); } - for (const agent of removedAgents) { - logger.log(` ${highlighter.success("\u2713")} ${agentLabel(agent)}`); - } - return removedAgents.length; + return removedAgents; }; diff --git a/packages/cli/src/utils/prompt-skill-install.ts b/packages/cli/src/utils/prompt-skill-install.ts new file mode 100644 index 000000000..762995301 --- /dev/null +++ b/packages/cli/src/utils/prompt-skill-install.ts @@ -0,0 +1,65 @@ +import { detectAvailableAgents } from "./detect-agents.js"; +import { highlighter } from "./highlighter.js"; +import { agentLabel, installSkill } from "./install-skill.js"; +import { logger } from "./logger.js"; +import { prompts } from "./prompts.js"; +import { spinner } from "./spinner.js"; + +interface PromptSkillInstallOptions { + yes?: boolean; + global?: boolean; + cwd?: string; +} + +export const promptSkillInstall = async ({ + yes = false, + global = false, + cwd = process.cwd(), +}: PromptSkillInstallOptions = {}): Promise => { + const detectedAgents = await detectAvailableAgents(); + if (detectedAgents.length === 0) { + logger.warn("No supported agents detected."); + return false; + } + + let selectedAgents = detectedAgents; + if (!yes) { + const { confirmed } = await prompts({ + type: "confirm", + name: "confirmed", + message: `Install the React Grab skill (${global ? "global" : "this project"})?`, + initial: true, + }); + if (!confirmed) return false; + + const { agents } = await prompts({ + type: "multiselect", + name: "agents", + message: `Install the React Grab skill (${global ? "global" : "this project"}) for:`, + choices: detectedAgents.map((agent) => ({ + title: agentLabel(agent), + value: agent, + selected: true, + })), + instructions: false, + min: 1, + }); + selectedAgents = agents ?? []; + if (selectedAgents.length === 0) return false; + } + + const installSpinner = spinner("Installing React Grab skill.").start(); + const { installed, failed } = await installSkill({ agents: selectedAgents, global, cwd }); + + if (installed.length === 0) { + installSpinner.fail("Failed to install React Grab skill."); + return false; + } + installSpinner.succeed( + `Installed React Grab skill for ${installed.map((record) => agentLabel(record.agent)).join(", ")}.`, + ); + for (const record of failed) { + logger.log(` ${highlighter.error("\u2717")} ${agentLabel(record.agent)} ${record.error}`); + } + return true; +}; diff --git a/packages/cli/test/install-react-grab.test.ts b/packages/cli/test/install-react-grab.test.ts new file mode 100644 index 000000000..e3e380bbd --- /dev/null +++ b/packages/cli/test/install-react-grab.test.ts @@ -0,0 +1,249 @@ +import { vi, describe, expect, it, beforeEach } from "vite-plus/test"; +import type { ProjectInfo } from "../src/utils/detect.js"; + +vi.mock("../src/utils/detect.js", () => ({ + detectProject: vi.fn(), + detectNextRouterType: vi.fn(() => "app"), +})); + +vi.mock("../src/utils/install.js", () => ({ + installPackages: vi.fn(), + getPackagesToInstall: vi.fn(() => ["react-grab"]), +})); + +vi.mock("../src/utils/transform.js", () => ({ + previewTransform: vi.fn(), + applyTransform: vi.fn(), +})); + +import { detectNextRouterType, detectProject } from "../src/utils/detect.js"; +import { installPackages } from "../src/utils/install.js"; +import { applyTransform, previewTransform } from "../src/utils/transform.js"; +import { installReactGrab, ReactGrabInstallError } from "../src/utils/install-react-grab.js"; + +const mockDetectProject = vi.mocked(detectProject); +const mockDetectNextRouterType = vi.mocked(detectNextRouterType); +const mockInstallPackages = vi.mocked(installPackages); +const mockPreviewTransform = vi.mocked(previewTransform); +const mockApplyTransform = vi.mocked(applyTransform); + +const baseProject = { + packageManager: "pnpm", + framework: "vite", + nextRouterType: "unknown", + isMonorepo: false, + projectRoot: "/app", + hasReactGrab: false, + isReactGrabConfigured: false, + reactGrabVersion: null, + unsupportedFramework: null, +} satisfies ProjectInfo; + +const pendingTransform = { + success: true, + filePath: "/app/src/main.tsx", + message: "Add React Grab", + originalContent: "render()", + newContent: 'import("react-grab");\n\nrender()', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockApplyTransform.mockReturnValue({ success: true }); + mockDetectNextRouterType.mockReturnValue("app"); +}); + +describe("installReactGrab", () => { + it("installs the package and writes the entry file for a fresh project", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + + const result = await installReactGrab({ cwd: "/app" }); + + expect(mockInstallPackages).toHaveBeenCalledWith(["react-grab"], { + cwd: "/app", + packageManager: "pnpm", + }); + expect(mockApplyTransform).toHaveBeenCalledTimes(1); + expect(result.didInstallPackage).toBe(true); + expect(result.didChangeFile).toBe(true); + expect(result.framework).toBe("vite"); + }); + + it("does not install the package when it is already present", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, hasReactGrab: true }); + mockPreviewTransform.mockReturnValue(pendingTransform); + + const result = await installReactGrab({ cwd: "/app" }); + + expect(mockInstallPackages).not.toHaveBeenCalled(); + expect(result.didInstallPackage).toBe(false); + expect(result.didChangeFile).toBe(true); + }); + + it("never installs or writes during a dry run", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + + const result = await installReactGrab({ cwd: "/app", dryRun: true }); + + expect(mockInstallPackages).not.toHaveBeenCalled(); + expect(mockApplyTransform).not.toHaveBeenCalled(); + expect(result.dryRun).toBe(true); + expect(result.didInstallPackage).toBe(false); + expect(result.didChangeFile).toBe(false); + }); + + it("does not write when there are no pending changes", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, isReactGrabConfigured: true }); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/app/src/main.tsx", + message: "React Grab is already configured", + noChanges: true, + }); + + const result = await installReactGrab({ cwd: "/app" }); + + expect(mockApplyTransform).not.toHaveBeenCalled(); + expect(result.alreadyConfigured).toBe(true); + expect(result.didChangeFile).toBe(false); + }); + + it("honors framework and package manager overrides", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, framework: "unknown" }); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/app/app/layout.tsx", + message: "Add React Grab", + originalContent: "x", + newContent: "y", + }); + + const result = await installReactGrab({ + cwd: "/app", + framework: "next", + nextRouterType: "app", + packageManager: "npm", + }); + + expect(mockPreviewTransform).toHaveBeenCalledWith("/app", "next", "app", false); + expect(mockInstallPackages).toHaveBeenCalledWith(["react-grab"], { + cwd: "/app", + packageManager: "npm", + }); + expect(result.framework).toBe("next"); + }); + + it("throws for unsupported frameworks", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, unsupportedFramework: "remix" }); + + await expect(installReactGrab({ cwd: "/app" })).rejects.toMatchObject({ + code: "unsupported-framework", + }); + expect(mockInstallPackages).not.toHaveBeenCalled(); + }); + + it("throws an unknown-framework error when no framework can be resolved", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, framework: "unknown" }); + + await expect(installReactGrab({ cwd: "/app" })).rejects.toMatchObject({ + code: "unknown-framework", + }); + await expect(installReactGrab({ cwd: "/app" })).rejects.toBeInstanceOf(ReactGrabInstallError); + }); + + it("derives the Next.js router type when only the framework is overridden", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject, framework: "unknown" }); + mockPreviewTransform.mockReturnValue({ + success: true, + filePath: "/app/app/layout.tsx", + message: "Add React Grab", + originalContent: "x", + newContent: "y", + }); + + const result = await installReactGrab({ cwd: "/app", framework: "next" }); + + expect(mockDetectNextRouterType).toHaveBeenCalledWith("/app"); + expect(mockPreviewTransform).toHaveBeenCalledWith("/app", "next", "app", false); + expect(result.nextRouterType).toBe("app"); + }); + + it("skips package installation when skipPackageInstall is set", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + + const result = await installReactGrab({ cwd: "/app", skipPackageInstall: true }); + + expect(mockInstallPackages).not.toHaveBeenCalled(); + expect(result.didInstallPackage).toBe(false); + expect(result.didChangeFile).toBe(true); + }); + + it("does not write or throw on a failed transform when skipTransform is set", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue({ + success: false, + filePath: "", + message: "Could not find entry file", + }); + + const result = await installReactGrab({ cwd: "/app", skipTransform: true }); + + expect(mockInstallPackages).toHaveBeenCalledTimes(1); + expect(mockApplyTransform).not.toHaveBeenCalled(); + expect(result.didChangeFile).toBe(false); + }); + + it("pins cwd and packageManager over installPackageOptions and passes through extras", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + + await installReactGrab({ + cwd: "/app", + installPackageOptions: { silent: true }, + }); + + expect(mockInstallPackages).toHaveBeenCalledWith(["react-grab"], { + silent: true, + cwd: "/app", + packageManager: "pnpm", + }); + }); + + it("wraps package-manager failures in an install-failed error", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + mockInstallPackages.mockRejectedValueOnce(new Error("network down")); + + await expect(installReactGrab({ cwd: "/app" })).rejects.toMatchObject({ + code: "install-failed", + message: "network down", + }); + expect(mockApplyTransform).not.toHaveBeenCalled(); + }); + + it("throws when the transform fails", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue({ + success: false, + filePath: "", + message: "Could not find entry file", + }); + + await expect(installReactGrab({ cwd: "/app" })).rejects.toMatchObject({ + code: "transform-failed", + }); + }); + + it("throws when writing the entry file fails", async () => { + mockDetectProject.mockResolvedValue({ ...baseProject }); + mockPreviewTransform.mockReturnValue(pendingTransform); + mockApplyTransform.mockReturnValue({ success: false, error: "permission denied" }); + + await expect(installReactGrab({ cwd: "/app" })).rejects.toMatchObject({ + code: "write-failed", + }); + }); +}); diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index f85cfb1e4..3dffba229 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -7,7 +7,7 @@ const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as { export default defineConfig({ pack: { - entry: ["src/cli.ts"], + entry: ["src/cli.ts", "src/api.ts"], format: ["cjs", "esm"], dts: true, clean: true,