diff --git a/CHANGELOG.md b/CHANGELOG.md index 24dcbb3..2ea23da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/package.json b/package.json index 4affee2..dbd5c2e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/plugins-model.test.ts b/src/plugins-model.test.ts new file mode 100644 index 0000000..6bc3f64 --- /dev/null +++ b/src/plugins-model.test.ts @@ -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); + }); +}); diff --git a/src/plugins-model.ts b/src/plugins-model.ts index 3bdada3..70fb3bc 100644 --- a/src/plugins-model.ts +++ b/src/plugins-model.ts @@ -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 { @@ -23,6 +38,17 @@ export interface PluginDefinition { description: string; variants: Partial>; 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 = { @@ -391,7 +417,34 @@ const DEFAULT_PLUGINS: Record = { 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 { @@ -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, @@ -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; } diff --git a/src/utils/add-plugin.test.ts b/src/utils/add-plugin.test.ts index 5d1aa9f..d209238 100644 --- a/src/utils/add-plugin.test.ts +++ b/src/utils/add-plugin.test.ts @@ -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'; @@ -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); diff --git a/src/utils/add-plugin.ts b/src/utils/add-plugin.ts index 71fe058..d9d875d 100644 --- a/src/utils/add-plugin.ts +++ b/src/utils/add-plugin.ts @@ -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); diff --git a/src/utils/apply-plugin-update.test.ts b/src/utils/apply-plugin-update.test.ts index 4e23d40..f46dcee 100644 --- a/src/utils/apply-plugin-update.test.ts +++ b/src/utils/apply-plugin-update.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/src/plugins-model', () => ({ PluginRegistry: { load: vi.fn() }, + isTrackable: vi.fn(), })); vi.mock('@/src/utils/workspace', () => ({ @@ -34,11 +35,14 @@ vi.mock('execa', () => ({ execaCommand: vi.fn(), })); -import { PluginRegistry } from '@/src/plugins-model'; +import { PluginRegistry, isTrackable } from '@/src/plugins-model'; import { applyPluginUpdate } from '@/src/utils/apply-plugin-update'; import { saveBaseVersions } from '@/src/utils/base-store'; import { fetchRegistryItem } from '@/src/utils/install-registry-files'; -import { mocks } from '@/src/utils/test-helpers'; +import { + MOCK_SELF_DISTRIBUTING_PLUGIN, + mocks, +} from '@/src/utils/test-helpers'; import { getCachedUsername } from '@/src/utils/username-cache'; import { validateProject } from '@/src/utils/workspace'; import { execaCommand } from 'execa'; @@ -47,6 +51,33 @@ import fs from 'fs-extra'; describe('applyPluginUpdate', () => { beforeEach(() => { vi.clearAllMocks(); + + // Registry-backed is the norm; the self-distributing case opts out below. + vi.mocked(isTrackable).mockReturnValue(true); + }); + + it('returns failure for a self-distributing plugin', async () => { + mocks.mockValidProject(validateProject); + mocks.mockUsername(getCachedUsername, 'user'); + mocks.mockPluginRegistry(PluginRegistry.load, { + validatePlugin: MOCK_SELF_DISTRIBUTING_PLUGIN, + }); + vi.mocked(isTrackable).mockReturnValue(false); + + const result = await applyPluginUpdate({ + projectPath: '/fake', + pluginId: 'expo', + files: [], + }); + + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.reason).toContain('not tracked by the registry'); + } + + expect(fetchRegistryItem).not.toHaveBeenCalled(); + expect(saveBaseVersions).not.toHaveBeenCalled(); }); function setupCommon(deps?: Record) { diff --git a/src/utils/apply-plugin-update.ts b/src/utils/apply-plugin-update.ts index d123db4..f5e7700 100644 --- a/src/utils/apply-plugin-update.ts +++ b/src/utils/apply-plugin-update.ts @@ -2,7 +2,7 @@ import { dirname, join } from 'path'; import fs from 'fs-extra'; -import { PluginRegistry } from '@/src/plugins-model'; +import { PluginRegistry, isTrackable } from '@/src/plugins-model'; import { saveBaseVersions } from '@/src/utils/base-store'; import { fetchRegistryItem } from '@/src/utils/install-registry-files'; import { @@ -52,7 +52,14 @@ export async function applyPluginUpdate( cacheUsername(username); const registry = await PluginRegistry.load(); - registry.validatePlugin(options.pluginId, variant); + const plugin = registry.validatePlugin(options.pluginId, variant); + + if (!isTrackable(plugin)) { + return { + success: false, + reason: `Plugin "${plugin.name}" ships its own files and is not tracked by the registry, so there is nothing to apply. Re-run its codemod to pull a newer version.`, + }; + } const item = await fetchRegistryItem(variant, options.pluginId, username, majorVersion); const remoteByPath = new Map(item.files.map((f) => [f.target, f.content])); diff --git a/src/utils/check-plugin-update.test.ts b/src/utils/check-plugin-update.test.ts index 9a2226d..6ecbe5f 100644 --- a/src/utils/check-plugin-update.test.ts +++ b/src/utils/check-plugin-update.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/src/plugins-model', () => ({ PluginRegistry: { load: vi.fn() }, + isTrackable: vi.fn(), })); vi.mock('@/src/utils/workspace', () => ({ @@ -29,11 +30,14 @@ vi.mock('fs-extra', () => ({ }, })); -import { PluginRegistry } from '@/src/plugins-model'; +import { PluginRegistry, isTrackable } from '@/src/plugins-model'; import { computeFileStatus, hasBaseVersions, readBaseVersion } from '@/src/utils/base-store'; import { checkPluginUpdate } from '@/src/utils/check-plugin-update'; import { fetchRegistryItem } from '@/src/utils/install-registry-files'; -import { mocks } from '@/src/utils/test-helpers'; +import { + MOCK_SELF_DISTRIBUTING_PLUGIN, + mocks, +} from '@/src/utils/test-helpers'; import { getCachedUsername } from '@/src/utils/username-cache'; import { validateProject } from '@/src/utils/workspace'; import fs from 'fs-extra'; @@ -41,6 +45,32 @@ import fs from 'fs-extra'; describe('checkPluginUpdate', () => { beforeEach(() => { vi.clearAllMocks(); + + // Registry-backed is the norm; the self-distributing case opts out below. + vi.mocked(isTrackable).mockReturnValue(true); + }); + + it('returns failure for a self-distributing plugin', async () => { + mocks.mockValidProject(validateProject); + mocks.mockUsername(getCachedUsername, 'user'); + mocks.mockPluginRegistry(PluginRegistry.load, { + validatePlugin: MOCK_SELF_DISTRIBUTING_PLUGIN, + }); + vi.mocked(isTrackable).mockReturnValue(false); + + const result = await checkPluginUpdate({ + projectPath: '/fake', + pluginId: 'expo', + }); + + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.reason).toContain('not tracked by the registry'); + } + + // Would 404 — there is no registry entry to diff against. + expect(fetchRegistryItem).not.toHaveBeenCalled(); }); it('returns failure when no username', async () => { diff --git a/src/utils/check-plugin-update.ts b/src/utils/check-plugin-update.ts index b54f33b..5c42917 100644 --- a/src/utils/check-plugin-update.ts +++ b/src/utils/check-plugin-update.ts @@ -2,7 +2,7 @@ import { join } from 'path'; import fs from 'fs-extra'; -import { PluginRegistry } from '@/src/plugins-model'; +import { PluginRegistry, isTrackable } from '@/src/plugins-model'; import { computeFileStatus, hasBaseVersions, @@ -56,7 +56,14 @@ export async function checkPluginUpdate( cacheUsername(username); const registry = await PluginRegistry.load(); - registry.validatePlugin(options.pluginId, variant); + const plugin = registry.validatePlugin(options.pluginId, variant); + + if (!isTrackable(plugin)) { + return { + success: false, + reason: `Plugin "${plugin.name}" ships its own files and is not tracked by the registry, so it cannot be diffed for updates. Re-run its codemod to pull a newer version.`, + }; + } const item = await fetchRegistryItem(variant, options.pluginId, username, majorVersion); const cwd = process.cwd(); diff --git a/src/utils/outdated-plugins.ts b/src/utils/outdated-plugins.ts index 7202e84..2485365 100644 --- a/src/utils/outdated-plugins.ts +++ b/src/utils/outdated-plugins.ts @@ -7,6 +7,7 @@ import { type PluginDefinition, getPath, isInstalled, + isTrackable, } from '@/src/plugins-model'; import { fetchRegistryItem } from '@/src/utils/install-registry-files'; import { @@ -81,7 +82,9 @@ export async function outdatedPlugins( const installed: PluginDefinition[] = []; for (const p of plugins) { - if (await isInstalled(p, variant)) { + // Self-distributing plugins have nothing in the registry to compare + // against, so they can never be reported as outdated. + if (isTrackable(p) && (await isInstalled(p, variant))) { installed.push(p); } } diff --git a/src/utils/test-helpers.ts b/src/utils/test-helpers.ts index 72eda75..25cb038 100644 --- a/src/utils/test-helpers.ts +++ b/src/utils/test-helpers.ts @@ -10,6 +10,14 @@ export const MOCK_PLUGIN: PluginDefinition = { postInstallMessage: 'Run migrations', }; +export const MOCK_SELF_DISTRIBUTING_PLUGIN: PluginDefinition = { + id: 'expo', + name: 'Expo Mobile App', + description: 'Add an Expo mobile app', + selfDistributing: true, + variants: { 'next-supabase': { envVars: [], path: 'packages/mobile-ui' } }, +}; + export const MOCK_PLUGIN_WITH_ENV: PluginDefinition = { id: 'posthog', name: 'PostHog',