Skip to content

Commit 4ce3219

Browse files
committed
feat(plugins): add plugins migrate command for schema updates
When a plugin releases a new version with schema changes, developers can now run `codapult plugins migrate [name]` to diff the installed schema block against the latest plugin source, patch schema.ts, and generate a proper Drizzle migration. Supports --push flag for dev mode. - Add 'update' action to patchSchemaTables (diff-aware replace) - Register codapult_plugins_migrate MCP tool (19 tools total) - Add 3 unit tests for update/unchanged/not-installed cases
1 parent 7337abf commit 4ce3219

6 files changed

Lines changed: 292 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ npx @codapult/cli <command>
2727

2828
- `codapult plugins add <name>` — install a plugin from a local directory
2929
- `codapult plugins remove <name>` — uninstall a plugin
30+
- `codapult plugins migrate [name]` — update plugin schema and generate DB migration (`--push` for dev mode)
3031
- `codapult plugins list` — list installed plugins
3132

3233
### Code Generation
@@ -61,7 +62,7 @@ npx @codapult/cli <command>
6162

6263
## MCP Server
6364

64-
The CLI includes an MCP (Model Context Protocol) server with 18 tools, 6 resources, and 2 prompt templates for AI-assisted development. See the [MCP documentation](https://codapult.dev/docs/developer-tools/mcp) for details.
65+
The CLI includes an MCP (Model Context Protocol) server with 19 tools, 6 resources, and 2 prompt templates for AI-assisted development. See the [MCP documentation](https://codapult.dev/docs/developer-tools/mcp) for details.
6566

6667
## License
6768

‎src/commands/plugins.ts‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,119 @@ export async function pluginsRemoveCommand(name: string): Promise<void> {
263263
console.log();
264264
}
265265

266+
// ---------------------------------------------------------------------------
267+
// codapult plugins migrate [name]
268+
// ---------------------------------------------------------------------------
269+
270+
function getInstalledPluginNames(root: string): string[] {
271+
const pluginsDir = resolve(root, 'src/plugins');
272+
if (!existsSync(pluginsDir)) return [];
273+
return readdirSync(pluginsDir)
274+
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
275+
.map((f) => f.replace('.ts', ''));
276+
}
277+
278+
export async function pluginsMigrateCommand(
279+
name: string | undefined,
280+
options: { push?: boolean },
281+
): Promise<void> {
282+
const root = findProjectRoot();
283+
if (!root) {
284+
fail('Not inside a Codapult project.');
285+
process.exit(1);
286+
}
287+
288+
const pluginNames = name ? [name] : getInstalledPluginNames(root);
289+
290+
if (pluginNames.length === 0) {
291+
info('No installed plugins found.');
292+
dim('Use: codapult plugins add <name>');
293+
return;
294+
}
295+
296+
heading(name ? `Migrating plugin: ${name}` : 'Migrating all installed plugins');
297+
298+
let schemaUpdated = false;
299+
300+
for (const pName of pluginNames) {
301+
const result = resolveManifest(root, pName);
302+
if (!result) {
303+
warn(`Plugin "${pName}" — manifest not found, skipping.`);
304+
continue;
305+
}
306+
307+
const { manifest, pluginDir } = result;
308+
309+
if (!manifest.install.schemaTables) {
310+
dim(`${manifest.name}: no schema tables — skipping.`);
311+
continue;
312+
}
313+
314+
const updated = patchSchemaTables(
315+
root,
316+
manifest.name,
317+
pluginDir,
318+
manifest.install.schemaTables,
319+
'update',
320+
);
321+
322+
if (updated) {
323+
success(`${manifest.name}: schema updated in schema.ts`);
324+
schemaUpdated = true;
325+
} else {
326+
dim(`${manifest.name}: schema is already up to date.`);
327+
}
328+
329+
// Also refresh imports in case the plugin added new drizzle column types
330+
if (manifest.install.schemaImports && manifest.install.schemaImports.length > 0) {
331+
patchSchemaImports(root, manifest.name, manifest.install.schemaImports, 'add');
332+
}
333+
}
334+
335+
if (!schemaUpdated) {
336+
console.log();
337+
info('All plugin schemas are up to date — no migration needed.');
338+
return;
339+
}
340+
341+
console.log();
342+
343+
if (options.push) {
344+
info('Applying schema changes (db:push)...');
345+
if (!execQuiet('pnpm db:push', root)) {
346+
fail('db:push failed — run manually: pnpm db:push');
347+
process.exit(1);
348+
}
349+
success('Database schema applied');
350+
} else {
351+
info('Generating migration...');
352+
try {
353+
execSync('pnpm db:generate', { cwd: root, stdio: 'inherit' });
354+
success('Migration generated');
355+
} catch {
356+
fail('db:generate failed — run manually: pnpm db:generate');
357+
process.exit(1);
358+
}
359+
360+
console.log();
361+
const apply = await confirm('Apply the migration now (pnpm db:migrate)?');
362+
if (apply) {
363+
try {
364+
execSync('pnpm db:migrate', { cwd: root, stdio: 'inherit' });
365+
success('Migration applied');
366+
} catch {
367+
fail('db:migrate failed — run manually: pnpm db:migrate');
368+
process.exit(1);
369+
}
370+
} else {
371+
info('Skipped. Apply later with: pnpm db:migrate');
372+
}
373+
}
374+
375+
heading('Done!');
376+
console.log();
377+
}
378+
266379
// ---------------------------------------------------------------------------
267380
// codapult plugins list
268381
// ---------------------------------------------------------------------------

‎src/index.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { setupCommand } from './commands/setup.js';
66
import { updateCommand } from './commands/update.js';
77
import { doctorCommand } from './commands/doctor.js';
88
import { configShowCommand } from './commands/config-show.js';
9-
import { pluginsAddCommand, pluginsRemoveCommand, pluginsListCommand } from './commands/plugins.js';
9+
import {
10+
pluginsAddCommand,
11+
pluginsRemoveCommand,
12+
pluginsListCommand,
13+
pluginsMigrateCommand,
14+
} from './commands/plugins.js';
1015
import {
1116
generatePageCommand,
1217
generateApiCommand,
@@ -73,6 +78,12 @@ plugins
7378

7479
plugins.command('remove <name>').description('uninstall a plugin').action(pluginsRemoveCommand);
7580

81+
plugins
82+
.command('migrate [name]')
83+
.description('update plugin schema and generate DB migration')
84+
.option('--push', 'use db:push instead of db:generate + db:migrate (dev mode)')
85+
.action(pluginsMigrateCommand);
86+
7687
plugins.command('list').description('list installed plugins').action(pluginsListCommand);
7788

7889
const generate = program

‎src/mcp/tools/plugins.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,84 @@ export function registerPluginTools(server: McpServer): void {
193193
};
194194
},
195195
);
196+
197+
server.registerTool(
198+
'codapult_plugins_migrate',
199+
{
200+
title: 'Migrate Plugin Schema',
201+
description:
202+
"Update an installed plugin's schema in schema.ts to match the latest version. " +
203+
'Returns whether the schema changed. After this, run db:generate + db:migrate (production) ' +
204+
'or db:push (development) to apply changes to the database.',
205+
inputSchema: {
206+
name: z
207+
.string()
208+
.optional()
209+
.describe('Plugin name (e.g. "crm"). Omit to migrate all installed plugins.'),
210+
},
211+
},
212+
({ name }) => {
213+
const root = getRoot();
214+
215+
const pluginsDir = resolve(root, 'src/plugins');
216+
const pluginNames: string[] = name
217+
? [name]
218+
: existsSync(pluginsDir)
219+
? readdirSync(pluginsDir)
220+
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
221+
.map((f) => f.replace('.ts', ''))
222+
: [];
223+
224+
if (pluginNames.length === 0) {
225+
return { content: [{ type: 'text' as const, text: 'No installed plugins found.' }] };
226+
}
227+
228+
const results: { plugin: string; updated: boolean; error?: string }[] = [];
229+
230+
for (const pName of pluginNames) {
231+
const res = resolveManifest(root, pName);
232+
if (!res) {
233+
results.push({ plugin: pName, updated: false, error: 'manifest not found' });
234+
continue;
235+
}
236+
237+
const { manifest, pluginDir } = res;
238+
239+
if (!manifest.install.schemaTables) {
240+
results.push({ plugin: pName, updated: false });
241+
continue;
242+
}
243+
244+
const updated = patchSchemaTables(
245+
root,
246+
manifest.name,
247+
pluginDir,
248+
manifest.install.schemaTables,
249+
'update',
250+
);
251+
252+
const imports = manifest.install.schemaImports;
253+
if (updated && imports && imports.length > 0) {
254+
patchSchemaImports(root, manifest.name, imports, 'add');
255+
}
256+
257+
results.push({ plugin: pName, updated });
258+
}
259+
260+
const anyUpdated = results.some((r) => r.updated);
261+
const output = {
262+
results,
263+
schemaChanged: anyUpdated,
264+
nextSteps: anyUpdated
265+
? [
266+
'Run: pnpm db:generate (create migration file)',
267+
'Run: pnpm db:migrate (apply migration)',
268+
'Or for development: pnpm db:push',
269+
]
270+
: [],
271+
};
272+
273+
return { content: [{ type: 'text' as const, text: JSON.stringify(output, null, 2) }] };
274+
},
275+
);
196276
}

‎src/utils/patchers.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,55 @@ describe('patchSchemaTables', () => {
160160
expect(written).not.toContain('widget');
161161
expect(written).toContain('user');
162162
});
163+
164+
it('replaces marked block with new content on update', () => {
165+
const schema = `export const user = sqliteTable('user', {});\n\n// --- plugin:test-plugin:start ---\nexport const widget = sqliteTable('widget', {\n id: text('id'),\n});\n// --- plugin:test-plugin:end ---\n`;
166+
const newTables = `export const widget = sqliteTable('widget', {\n id: text('id'),\n name: text('name'),\n});\n`;
167+
168+
mockedExists.mockReturnValue(true);
169+
mockedRead.mockImplementation((p) => {
170+
const path = p as string;
171+
if (path.includes('schema.ts')) return schema;
172+
return newTables;
173+
});
174+
175+
const result = patchSchemaTables(ROOT, 'test-plugin', '/plugins/test', 'tables.ts', 'update');
176+
177+
expect(result).toBe(true);
178+
const written = mockedWrite.mock.calls[0][1] as string;
179+
expect(written).toContain("name: text('name')");
180+
expect(written).toContain('// --- plugin:test-plugin:start ---');
181+
expect(written).toContain('// --- plugin:test-plugin:end ---');
182+
});
183+
184+
it('returns false on update when schema is unchanged', () => {
185+
const tables = `export const widget = sqliteTable('widget', {});`;
186+
const schema = `export const user = sqliteTable('user', {});\n\n// --- plugin:test-plugin:start ---\n${tables}\n// --- plugin:test-plugin:end ---\n`;
187+
188+
mockedExists.mockReturnValue(true);
189+
mockedRead.mockImplementation((p) => {
190+
const path = p as string;
191+
if (path.includes('schema.ts')) return schema;
192+
return tables + '\n';
193+
});
194+
195+
const result = patchSchemaTables(ROOT, 'test-plugin', '/plugins/test', 'tables.ts', 'update');
196+
197+
expect(result).toBe(false);
198+
expect(mockedWrite).not.toHaveBeenCalled();
199+
});
200+
201+
it('returns false on update when plugin is not installed', () => {
202+
const schema = `export const user = sqliteTable('user', {});`;
203+
204+
mockedExists.mockReturnValue(true);
205+
mockedRead.mockReturnValue(schema);
206+
207+
const result = patchSchemaTables(ROOT, 'test-plugin', '/plugins/test', 'tables.ts', 'update');
208+
209+
expect(result).toBe(false);
210+
expect(mockedWrite).not.toHaveBeenCalled();
211+
});
163212
});
164213

165214
// ---------------------------------------------------------------------------

‎src/utils/patchers.ts‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,39 +78,61 @@ export function patchSchemaImports(
7878
writeFileSync(schemaPath, content, 'utf-8');
7979
}
8080

81+
function extractMarkedBlock(content: string, name: string): string | null {
82+
const start = MARKER_START(name);
83+
const end = MARKER_END(name);
84+
const startIdx = content.indexOf(start);
85+
const endIdx = content.indexOf(end);
86+
if (startIdx === -1 || endIdx === -1) return null;
87+
return content.slice(startIdx + start.length, endIdx);
88+
}
89+
90+
function readPluginTables(pluginDir: string, tablesFile: string): string | null {
91+
const tablesPath = resolve(pluginDir, tablesFile);
92+
if (!existsSync(tablesPath)) return null;
93+
94+
let tables = readFileSync(tablesPath, 'utf-8');
95+
// Strip import lines — host schema already has them
96+
tables = tables.replace(/^import\s+.*;\s*\n/gm, '');
97+
// Strip type/interface declarations
98+
tables = tables.replace(/^export\s+type\s+.*;\s*\n/gm, '');
99+
tables = tables.replace(/^(?:export\s+)?interface\s+\w+\s*\{[\s\S]*?\}\s*\n/gm, '');
100+
return tables.trim();
101+
}
102+
81103
export function patchSchemaTables(
82104
projectRoot: string,
83105
pluginName: string,
84106
pluginDir: string,
85107
tablesFile: string,
86-
action: 'add' | 'remove',
87-
): void {
108+
action: 'add' | 'remove' | 'update',
109+
): boolean {
88110
const schemaPath = resolve(projectRoot, 'src/lib/db/schema.ts');
89-
if (!existsSync(schemaPath)) return;
111+
if (!existsSync(schemaPath)) return false;
90112
let content = readFileSync(schemaPath, 'utf-8');
91113

92114
if (action === 'remove') {
93115
content = removeMarkedBlock(content, pluginName);
94116
writeFileSync(schemaPath, content, 'utf-8');
95-
return;
117+
return true;
96118
}
97119

98-
if (hasMarker(content, pluginName)) return;
120+
if (action === 'add' && hasMarker(content, pluginName)) return false;
99121

100-
const tablesPath = resolve(pluginDir, tablesFile);
101-
if (!existsSync(tablesPath)) return;
122+
const tables = readPluginTables(pluginDir, tablesFile);
123+
if (!tables) return false;
102124

103-
let tables = readFileSync(tablesPath, 'utf-8');
104-
105-
// Strip import lines — host schema already has them
106-
tables = tables.replace(/^import\s+.*;\s*\n/gm, '');
107-
// Strip type exports
108-
tables = tables.replace(/^export\s+type\s+.*;\s*\n/gm, '');
109-
tables = tables.trim();
125+
if (action === 'update') {
126+
if (!hasMarker(content, pluginName)) return false;
127+
const oldBlock = extractMarkedBlock(content, pluginName);
128+
if (oldBlock !== null && oldBlock.trim() === tables) return false;
129+
content = removeMarkedBlock(content, pluginName);
130+
}
110131

111132
const block = `\n${MARKER_START(pluginName)}\n${tables}\n${MARKER_END(pluginName)}\n`;
112133
content = content.trimEnd() + '\n' + block;
113134
writeFileSync(schemaPath, content, 'utf-8');
135+
return true;
114136
}
115137

116138
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)