Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project will be documented in this file.

## 2.1.0 - 2026-08-01

- Add the Expo Mobile App plugin (`expo`) for `next-supabase`, which installs the Expo app, the `@kit/mobile-ui` package and the `/api/v1` mobile API
- Add a `selfDistributing` flag for plugins that bring their own files: installing them skips the registry download and the base-version snapshot, and runs the codemod alone. The file registry stores content as strings, so it cannot carry binary assets
- Exclude self-distributing plugins from `plugins outdated`, and return an explanatory reason from `plugins update`/`apply` instead of failing on a missing registry entry
- Add `paths` to the plugin variant config for plugins that span more than one directory. A plugin now counts as installed only when all of its directories are present, so a partially removed one can be reinstalled rather than being mistaken for a complete install

## 2.0.9 - 2026-07-10

- Add support for the new TanStack Start kits: `tanstack-supabase`, `tanstack-drizzle`, and `tanstack-prisma`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makerkit/cli",
"version": "2.0.9",
"version": "2.1.0",
"description": "A CLI for Makerkit",
"type": "module",
"exports": "./dist/index.js",
Expand Down
136 changes: 136 additions & 0 deletions src/plugins-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('fs-extra', () => ({
default: {
pathExists: vi.fn(),
readJson: vi.fn(),
},
}));

import {
type PluginDefinition,
getPaths,
isInstalled,
isTrackable,
} from '@/src/plugins-model';
import fs from 'fs-extra';

const SINGLE_PATH: PluginDefinition = {
id: 'feedback',
name: 'Feedback',
description: 'Feedback plugin',
variants: {
'next-supabase': { envVars: [], path: 'packages/plugins/feedback' },
},
};

const MULTI_PATH: PluginDefinition = {
id: 'expo',
name: 'Expo Mobile App',
description: 'Add an Expo mobile app',
selfDistributing: true,
variants: {
'next-supabase': {
envVars: [],
path: 'packages/mobile-ui',
paths: ['packages/mobile-ui', 'apps/native', 'apps/web/app/api/v1'],
},
},
};

/** Marks `present` as existing on disk and everything else as missing. */
function mockDisk(present: string[]) {
vi.mocked(fs.pathExists).mockImplementation(((path: string) =>
Promise.resolve(
present.some((p) => path.endsWith(p)),
)) as unknown as typeof fs.pathExists);
}

describe('getPaths', () => {
it('falls back to the single path when paths is absent', () => {
expect(getPaths(SINGLE_PATH, 'next-supabase')).toEqual([
'packages/plugins/feedback',
]);
});

it('returns every owned directory when paths is set', () => {
expect(getPaths(MULTI_PATH, 'next-supabase')).toEqual([
'packages/mobile-ui',
'apps/native',
'apps/web/app/api/v1',
]);
});

it('returns an empty list for a variant with no path', () => {
const plugin: PluginDefinition = {
...SINGLE_PATH,
variants: { 'next-supabase': { envVars: [] } },
};

expect(getPaths(plugin, 'next-supabase')).toEqual([]);
});
});

describe('isTrackable', () => {
it('is true for registry-backed plugins', () => {
expect(isTrackable(SINGLE_PATH)).toBe(true);
});

it('is false for self-distributing plugins', () => {
expect(isTrackable(MULTI_PATH)).toBe(false);
});
});

describe('isInstalled', () => {
beforeEach(() => {
vi.clearAllMocks();

vi.mocked(fs.readJson).mockResolvedValue({
name: '@kit/mobile-ui',
exports: { '.': './src/index.ts' },
});
});

it('is true for a single-path plugin whose package is present', async () => {
mockDisk(['packages/plugins/feedback/package.json']);

await expect(isInstalled(SINGLE_PATH, 'next-supabase')).resolves.toBe(true);
});

it('is false when the package.json is missing', async () => {
mockDisk([]);

await expect(isInstalled(SINGLE_PATH, 'next-supabase')).resolves.toBe(false);
});

it('is false when the package.json has no exports', async () => {
mockDisk(['packages/plugins/feedback/package.json']);
vi.mocked(fs.readJson).mockResolvedValue({ name: 'feedback' });

await expect(isInstalled(SINGLE_PATH, 'next-supabase')).resolves.toBe(false);
});

it('is true for a multi-path plugin when every directory is present', async () => {
mockDisk([
'packages/mobile-ui/package.json',
'apps/native',
'apps/web/app/api/v1',
]);

await expect(isInstalled(MULTI_PATH, 'next-supabase')).resolves.toBe(true);
});

it('is false when the app half of a multi-path plugin was removed', async () => {
// The detection package survives, so a `path`-only check would wrongly
// report this half-removed plugin as installed and refuse to reinstall it.
mockDisk(['packages/mobile-ui/package.json', 'apps/web/app/api/v1']);

await expect(isInstalled(MULTI_PATH, 'next-supabase')).resolves.toBe(false);
});

it('is false when the API routes are missing', async () => {
mockDisk(['packages/mobile-ui/package.json', 'apps/native']);

await expect(isInstalled(MULTI_PATH, 'next-supabase')).resolves.toBe(false);
});
});
102 changes: 100 additions & 2 deletions src/plugins-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,22 @@ export interface EnvVar {

export interface VariantConfig {
envVars: EnvVar[];
/**
* The plugin's own package, used to detect whether it is installed. Must be
* a library package — a directory with a `package.json` declaring both
* `name` and `exports`.
*/
path?: string;
/**
* Every directory the plugin owns, when it spans more than `path`. Apps and
* route trees belong here: they are part of the plugin but are not library
* packages, so they cannot serve as `path`.
*
* All of them have to be present for the plugin to count as installed, so a
* partially removed plugin can be reinstalled rather than being mistaken for
* a complete one.
*/
paths?: string[];
}

export interface PluginDefinition {
Expand All @@ -23,6 +38,17 @@ export interface PluginDefinition {
description: string;
variants: Partial<Record<Variant, VariantConfig>>;
postInstallMessage?: string;
/**
* The plugin brings its own files rather than receiving them from the file
* registry, so installing it is the codemod alone.
*
* The registry stores each file's content as a string, which rules it out
* for anything shipping binary assets, and it is a poor fit for a plugin the
* size of a whole second app. Such a plugin fetches its own sources, and the
* CLI skips both the registry download and the base-version snapshot that
* the update machinery is built on — see `isTrackable`.
*/
selfDistributing?: boolean;
}

const DEFAULT_PLUGINS: Record<string, PluginDefinition> = {
Expand Down Expand Up @@ -391,7 +417,34 @@ const DEFAULT_PLUGINS: Record<string, PluginDefinition> = {
path: 'packages/plugins/directus',
},
},
}
},
expo: {
name: 'Expo Mobile App',
id: 'expo',
description: 'Add an Expo mobile app that shares code with your web app.',
// The codemod fetches apps/native, packages/mobile-ui and the /api/v1
// routes itself — the registry cannot carry the app's binary assets.
selfDistributing: true,
postInstallMessage:
'Set EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_PUBLIC_KEY and EXPO_PUBLIC_API_BASE_URL in apps/native/.env.development, then run: pnpm run start:native',
variants: {
'next-supabase': {
// EXPO_PUBLIC_* vars belong in apps/native/.env.development, which
// ships with working local defaults. Declaring them here would append
// them to the web app's .env files instead.
envVars: [],
// The Expo app itself cannot be the detection path: `isInstalled`
// needs a package.json with `name` and `exports`, and apps/native has
// no `exports` — it is an app, not a library.
path: 'packages/mobile-ui',
paths: [
'packages/mobile-ui',
'apps/native',
'apps/web/app/api/v1',
],
},
},
},
};

export class PluginRegistry {
Expand Down Expand Up @@ -442,6 +495,33 @@ export function getPath(
return plugin.variants[variant]?.path;
}

/**
* Every directory the plugin owns. Falls back to the single `path` for the
* plugins that are one package, which is most of them.
*/
export function getPaths(
plugin: PluginDefinition,
variant: Variant,
): string[] {
const config = plugin.variants[variant];

if (config?.paths?.length) {
return config.paths;
}

return config?.path ? [config.path] : [];
}

/**
* Whether the plugin's files can be compared against the registry, which is
* what every update path here is built on. Self-distributing plugins have no
* registry entry to diff against, so they are reported as up to date rather
* than blowing up on a 404 halfway through `makerkit plugins outdated`.
*/
export function isTrackable(plugin: PluginDefinition): boolean {
return !plugin.selfDistributing;
}

export async function isInstalled(
plugin: PluginDefinition,
variant: Variant,
Expand All @@ -461,8 +541,26 @@ export async function isInstalled(
try {
const pkg = await fs.readJson(pkgJsonPath);

return !!pkg.name && !!pkg.exports;
if (!pkg.name || !pkg.exports) {
return false;
}
} catch {
return false;
}

// A plugin spanning several directories is only installed when all of them
// are there. Without this, deleting the app half of a multi-directory plugin
// would still read as installed, and `plugins add` would refuse to repair it.
for (const ownedPath of getPaths(plugin, variant)) {
// Already proven present by its package.json above.
if (ownedPath === pluginPath) {
continue;
}

if (!(await fs.pathExists(join(process.cwd(), ownedPath)))) {
return false;
}
}

return true;
}
28 changes: 27 additions & 1 deletion src/utils/add-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ import { appendEnvVars } from '@/src/utils/env-vars';
import { isGitClean } from '@/src/utils/git';
import { installRegistryFiles } from '@/src/utils/install-registry-files';
import { runCodemod } from '@/src/utils/run-codemod';
import { MOCK_PLUGIN, mocks } from '@/src/utils/test-helpers';
import {
MOCK_PLUGIN,
MOCK_SELF_DISTRIBUTING_PLUGIN,
mocks,
} from '@/src/utils/test-helpers';
import { cacheUsername, getCachedUsername } from '@/src/utils/username-cache';
import { validateProject } from '@/src/utils/workspace';

Expand Down Expand Up @@ -143,6 +147,28 @@ describe('addPlugin', () => {
expect(saveBaseVersions).toHaveBeenCalled();
});

it('skips the registry download for a self-distributing plugin', async () => {
mocks.mockGitClean(isGitClean, true);
mocks.mockValidProject(validateProject);
mocks.mockUsername(getCachedUsername, 'user');
mocks.mockPluginRegistry(PluginRegistry.load, {
validatePlugin: MOCK_SELF_DISTRIBUTING_PLUGIN,
});
vi.mocked(isInstalled).mockResolvedValue(false);
vi.mocked(runCodemod).mockResolvedValue({ success: true, output: 'done' });
vi.mocked(getEnvVars).mockReturnValue([]);

const result = await addPlugin({ projectPath: '/fake', pluginId: 'expo' });

expect(result.success).toBe(true);

// The registry has no entry for it — fetching would throw before the
// codemod, which is the step that actually installs the plugin.
expect(installRegistryFiles).not.toHaveBeenCalled();
expect(saveBaseVersions).not.toHaveBeenCalled();
expect(runCodemod).toHaveBeenCalledWith('next-supabase', 'expo', undefined);
});

it('skips git check when skipGitCheck is true', async () => {
mocks.mockGitClean(isGitClean, false);
mocks.mockValidProject(validateProject);
Expand Down
20 changes: 17 additions & 3 deletions src/utils/add-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,25 @@ export async function addPlugin(
};
}

const item = await installRegistryFiles(variant, options.pluginId, username, majorVersion);
await saveBaseVersions(options.pluginId, item.files);
// A self-distributing plugin has no registry entry — its codemod brings the
// files. Downloading first would throw before the codemod ever ran.
let codemodVersion: string | undefined;

if (!plugin.selfDistributing) {
const item = await installRegistryFiles(
variant,
options.pluginId,
username,
majorVersion,
);

await saveBaseVersions(options.pluginId, item.files);

codemodVersion = item.codemodVersion;
}

options.onBeforeCodemod?.();
const codemodResult = await runCodemod(variant, options.pluginId, item.codemodVersion);
const codemodResult = await runCodemod(variant, options.pluginId, codemodVersion);
options.onAfterCodemod?.();

const envVars = getEnvVars(plugin, variant);
Expand Down
Loading