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
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/create-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"recast": "^0.23.11",
"semver": "^7.3.5",
"title-case": "^4.3.0",
"valibot": "^1.1.0",
"which": "^5.0.0",
"yaml": "^2.7.0"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/create-plugin/src/bin/run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import minimist from 'minimist';
import { generate, update, migrate, version, provisioning } from '../commands/index.js';
import { add, generate, update, migrate, version, provisioning } from '../commands/index.js';
import { isUnsupportedPlatform } from '../utils/utils.os.js';
import { argv, commandName } from '../utils/utils.cli.js';
import { output } from '../utils/utils.console.js';
Expand All @@ -23,6 +23,7 @@ const commands: Record<string, (argv: minimist.ParsedArgs) => void> = {
update,
version,
provisioning,
add,
};
const command = commands[commandName] || 'generate';

Expand Down
12 changes: 12 additions & 0 deletions packages/create-plugin/src/codemods/additions/additions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import defaultAdditions from './additions.js';

describe('additions json', () => {
// as addition scripts are imported dynamically when add is run we assert the path is valid
defaultAdditions.forEach((addition) => {
it(`should have a valid addition script path for ${addition.name}`, () => {
expect(async () => {
await import(addition.scriptPath);
}).not.toThrow();
});
});
});
10 changes: 10 additions & 0 deletions packages/create-plugin/src/codemods/additions/additions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Codemod } from '../types.js';
import { resolveScriptPath } from '../utils.js';

export default [
{
name: 'example-addition',
description: 'Example addition demonstrating Valibot schema with type inference',
scriptPath: resolveScriptPath(import.meta.url, './scripts/example-addition.js'),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scripts are now imported from the shared codemod runner, so paths must be resolved relative to this registry file's location to ensure correct resolution at runtime

Copy link
Collaborator

@jackw jackw Nov 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we discussed during the call, let's move the directories around so we don't have to juggle the script path resolution.

},
] satisfies Codemod[];
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';

import { Context } from '../../context.js';
import migrate from './example-addition.js';

describe('example-addition', () => {
it('should add example script to package.json', () => {
const context = new Context('/virtual');

context.addFile('package.json', JSON.stringify({ scripts: {}, dependencies: {}, devDependencies: {} }));

const result = migrate(context, { featureName: 'testFeature', enabled: true, frameworks: ['react'] });

const packageJson = JSON.parse(result.getFile('package.json') || '{}');
expect(packageJson.scripts['example-script']).toBe('echo "Running testFeature"');
});

it('should add dev dependency', () => {
const context = new Context('/virtual');

context.addFile('package.json', JSON.stringify({ scripts: {}, dependencies: {}, devDependencies: {} }));

const result = migrate(context, { featureName: 'myFeature', enabled: false, frameworks: ['react'] });

const packageJson = JSON.parse(result.getFile('package.json') || '{}');
expect(packageJson.devDependencies['example-dev-dep']).toBe('^1.0.0');
});

it('should create feature TypeScript file with options', () => {
const context = new Context('/virtual');

context.addFile('package.json', JSON.stringify({ scripts: {}, dependencies: {}, devDependencies: {} }));

const result = migrate(context, {
featureName: 'myFeature',
enabled: false,
port: 4000,
frameworks: ['react', 'vue'],
});

expect(result.doesFileExist('src/features/myFeature.ts')).toBe(true);
const featureCode = result.getFile('src/features/myFeature.ts');
expect(featureCode).toContain('export const myFeature');
expect(featureCode).toContain('enabled: false');
expect(featureCode).toContain('port: 4000');
expect(featureCode).toContain('frameworks: ["react","vue"]');
expect(featureCode).toContain('myFeature initialized on port 4000');
});

it('should delete deprecated file if it exists', () => {
const context = new Context('/virtual');

context.addFile('package.json', JSON.stringify({ scripts: {}, dependencies: {}, devDependencies: {} }));
context.addFile('src/deprecated.ts', 'export const old = true;');

const result = migrate(context, { featureName: 'testFeature', enabled: true, frameworks: ['react'] });

expect(result.doesFileExist('src/deprecated.ts')).toBe(false);
});

it('should rename old-config.json if it exists', () => {
const context = new Context('/virtual');

context.addFile('package.json', JSON.stringify({ scripts: {}, dependencies: {}, devDependencies: {} }));
context.addFile('src/old-config.json', JSON.stringify({ old: true }));

const result = migrate(context, { featureName: 'testFeature', enabled: true, frameworks: ['react'] });

expect(result.doesFileExist('src/old-config.json')).toBe(false);
expect(result.doesFileExist('src/new-config.json')).toBe(true);
const newConfig = JSON.parse(result.getFile('src/new-config.json') || '{}');
expect(newConfig.old).toBe(true);
});

it('should not add script if it already exists', () => {
const context = new Context('/virtual');

context.addFile(
'package.json',
JSON.stringify({
scripts: { 'example-script': 'existing command' },
dependencies: {},
devDependencies: {},
})
);

const result = migrate(context, { featureName: 'testFeature', enabled: true, frameworks: ['react'] });

const packageJson = JSON.parse(result.getFile('package.json') || '{}');
expect(packageJson.scripts['example-script']).toBe('existing command');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as v from 'valibot';
import type { Context } from '../../context.js';
import { addDependenciesToPackageJson } from '../../utils.js';

/**
* Example addition demonstrating Valibot schema with type inference
* Schema defines validation rules, defaults and types are automatically inferred
*/
export const schema = v.object({
featureName: v.pipe(
v.string(),
v.minLength(3, 'Feature name must be at least 3 characters'),
v.maxLength(50, 'Feature name must be at most 50 characters')
),
enabled: v.optional(v.boolean(), true),
port: v.optional(
v.pipe(v.number(), v.minValue(1000, 'Port must be at least 1000'), v.maxValue(65535, 'Port must be at most 65535'))
),
frameworks: v.optional(v.array(v.string()), ['react']),
});

// Type is automatically inferred from the schema
type ExampleOptions = v.InferOutput<typeof schema>;

export default function exampleAddition(context: Context, options: ExampleOptions): Context {
// These options have been validated by the framework
const { featureName, enabled, port, frameworks } = options;

const rawPkgJson = context.getFile('./package.json') ?? '{}';
const packageJson = JSON.parse(rawPkgJson);

if (packageJson.scripts && !packageJson.scripts['example-script']) {
packageJson.scripts['example-script'] = `echo "Running ${featureName}"`;
context.updateFile('./package.json', JSON.stringify(packageJson, null, 2));
}

addDependenciesToPackageJson(context, {}, { 'example-dev-dep': '^1.0.0' });

if (!context.doesFileExist(`./src/features/${featureName}.ts`)) {
const featureCode = `export const ${featureName} = {
name: '${featureName}',
enabled: ${enabled},
port: ${port ?? 3000},
frameworks: ${JSON.stringify(frameworks)},
init() {
console.log('${featureName} initialized on port ${port ?? 3000}');
},
};
`;
context.addFile(`./src/features/${featureName}.ts`, featureCode);
}

if (context.doesFileExist('./src/deprecated.ts')) {
context.deleteFile('./src/deprecated.ts');
}

if (context.doesFileExist('./src/old-config.json')) {
context.renameFile('./src/old-config.json', './src/new-config.json');
}

return context;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Context } from './context.js';
describe('Context', () => {
describe('getFile', () => {
it('should read a file from the file system', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
const content = context.getFile('foo/bar.ts');
expect(content).toEqual("console.log('foo/bar.ts');\n");
});
Expand All @@ -16,14 +16,14 @@ describe('Context', () => {
});

it('should get a file that was updated in the current context', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
context.updateFile('foo/bar.ts', 'content');
const content = context.getFile('foo/bar.ts');
expect(content).toEqual('content');
});

it('should not return a file that was marked for deletion', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
context.deleteFile('foo/bar.ts');
const content = context.getFile('foo/bar.ts');
expect(content).toEqual(undefined);
Expand Down Expand Up @@ -77,7 +77,7 @@ describe('Context', () => {

describe('renameFile', () => {
it('should rename a file', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
context.renameFile('foo/bar.ts', 'new-file.txt');
expect(context.listChanges()).toEqual({
'new-file.txt': { content: "console.log('foo/bar.ts');\n", changeType: 'add' },
Expand All @@ -102,20 +102,20 @@ describe('Context', () => {

describe('readDir', () => {
it('should read the directory', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
const files = context.readDir('foo');
expect(files).toEqual(['foo/bar.ts', 'foo/baz.ts']);
});

it('should filter out deleted files', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
context.deleteFile('foo/bar.ts');
const files = context.readDir('foo');
expect(files).toEqual(['foo/baz.ts']);
});

it('should include files that are only added to the context', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
context.addFile('foo/foo.txt', '');
const files = context.readDir('foo');
expect(files).toEqual(['foo/bar.ts', 'foo/baz.ts', 'foo/foo.txt']);
Expand All @@ -124,7 +124,7 @@ describe('Context', () => {

describe('normalisePath', () => {
it('should normalise the path', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
expect(context.normalisePath('foo/bar.ts')).toEqual('foo/bar.ts');
expect(context.normalisePath('./foo/bar.ts')).toEqual('foo/bar.ts');
expect(context.normalisePath('/foo/bar.ts')).toEqual('foo/bar.ts');
Expand All @@ -133,12 +133,12 @@ describe('Context', () => {

describe('hasChanges', () => {
it('should return FALSE if the context has no changes', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);
expect(context.hasChanges()).toEqual(false);
});

it('should return TRUE if the context has changes', () => {
const context = new Context(`${__dirname}/fixtures`);
const context = new Context(`${__dirname}/migrations/fixtures`);

context.addFile('foo.ts', '');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { constants, accessSync, readFileSync, readdirSync } from 'node:fs';
import { relative, normalize, join, dirname } from 'node:path';
import { migrationsDebug } from './utils.js';
import { debug } from '../utils/utils.cli.js';

const codemodsDebug = debug.extend('codemods');

export type ContextFile = Record<
string,
Expand Down Expand Up @@ -58,7 +60,7 @@ export class Context {
if (originalContent !== content) {
this.files[path] = { content, changeType: 'update' };
} else {
migrationsDebug(`Context.updateFile() - no updates for ${filePath}`);
codemodsDebug(`Context.updateFile() - no updates for ${filePath}`);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export default [
{
name: 'migration-key1',
version: '5.0.0',
description: 'Update project to use new cache directory',
scriptPath: './5-0-0-cache-directory.js',
},
{
name: 'migration-key2',
version: '5.4.0',
description: 'Update project to use new cache directory',
scriptPath: './5-4-0-cache-directory.js',
},
{
name: 'migration-key3',
version: '6.0.0',
description: 'Update project to use new cache directory',
scriptPath: './5-4-0-cache-directory.js',
},
];
Loading
Loading