├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .gitignore ├── babel.config.js ├── foo.js ├── main-config.json └── migration.ts ├── lerna.json ├── package.json ├── packages └── code-migrate │ ├── __tests__ │ ├── abort.test.ts │ ├── async.test.ts │ ├── cli │ │ ├── onlySubCommands.test.ts │ │ ├── onlySubCommands.ts │ │ ├── withDefaultCommand.test.ts │ │ └── withDefaultCommand.ts │ ├── create.test.ts │ ├── e2e │ │ ├── full.e2e.test.ts │ │ ├── no-ts-node.test.ts │ │ └── transform.e2e.test.ts │ ├── fixtures │ │ ├── .DS_Store │ │ ├── abort │ │ │ ├── __after__ │ │ │ │ ├── baz.json │ │ │ │ └── foo.json │ │ │ ├── __before__ │ │ │ │ ├── baz.json │ │ │ │ └── foo.json │ │ │ └── migration.ts │ │ ├── async │ │ │ ├── __after__ │ │ │ │ └── baz.json │ │ │ ├── __before__ │ │ │ │ └── baz.json │ │ │ └── migration.ts │ │ ├── create │ │ │ ├── __after__ │ │ │ │ ├── bar.txt │ │ │ │ ├── foo-bar.json │ │ │ │ └── foo.json │ │ │ ├── __before__ │ │ │ │ └── foo.json │ │ │ └── migration.ts │ │ ├── error │ │ │ ├── __after__ │ │ │ │ └── baz.json │ │ │ ├── __before__ │ │ │ │ └── baz.json │ │ │ └── migration.ts │ │ ├── full-with-errors │ │ │ ├── __after__ │ │ │ │ ├── .env │ │ │ │ ├── .gitignore │ │ │ │ ├── cool-config.json │ │ │ │ └── foo.js │ │ │ ├── __before__ │ │ │ │ ├── .gitignore │ │ │ │ ├── babel.config.js │ │ │ │ ├── foo.js │ │ │ │ └── main-config.json │ │ │ └── migration.ts │ │ ├── full │ │ │ ├── __after__ │ │ │ │ ├── .env │ │ │ │ ├── .gitignore │ │ │ │ ├── cool-config.json │ │ │ │ └── foo.js │ │ │ ├── __before__ │ │ │ │ ├── .gitignore │ │ │ │ ├── babel.config.js │ │ │ │ ├── foo.js │ │ │ │ └── main-config.json │ │ │ └── migration.ts │ │ ├── ignore │ │ │ ├── __after__ │ │ │ │ └── baz.json │ │ │ ├── __before__ │ │ │ │ └── baz.json │ │ │ └── migration.ts │ │ ├── no-ts-node │ │ │ ├── __after__ │ │ │ │ └── txt.txt │ │ │ ├── __before__ │ │ │ │ └── txt.txt │ │ │ └── migration.js │ │ ├── remove │ │ │ ├── __after__ │ │ │ │ └── placeholder.txt │ │ │ ├── __before__ │ │ │ │ ├── baz.json │ │ │ │ └── placeholder.txt │ │ │ └── migration.ts │ │ ├── rename │ │ │ ├── __after__ │ │ │ │ └── foo-bar.json │ │ │ ├── __before__ │ │ │ │ └── foo.json │ │ │ └── migration.ts │ │ └── transform │ │ │ ├── __after__ │ │ │ └── baz.json │ │ │ ├── __before__ │ │ │ └── baz.json │ │ │ └── migration.ts │ ├── ignore.test.ts │ ├── remove.test.ts │ ├── rename.test.ts │ ├── reportFile.test.ts │ ├── reporters │ │ ├── __snapshots__ │ │ │ └── markdown.test.ts.snap │ │ ├── default.test.ts │ │ └── markdown.test.ts │ ├── transform.test.ts │ └── utils.ts │ ├── babel.config.js │ ├── bin │ └── code-migrate │ ├── jest.config.ts │ ├── package.json │ ├── src │ ├── File.ts │ ├── Migration.ts │ ├── VirtualFileSystem.ts │ ├── cli │ │ ├── code-migrate-cli.ts │ │ └── createCli.ts │ ├── events │ │ ├── MigrationEmitter.ts │ │ └── index.ts │ ├── hooks │ │ ├── afterHook.ts │ │ └── index.ts │ ├── index.ts │ ├── loadUserMigrationFile.ts │ ├── migrate.ts │ ├── reporters │ │ ├── createMarkdownReport.ts │ │ ├── createReport.ts │ │ ├── default-reporter.ts │ │ ├── getReporter.ts │ │ ├── index.ts │ │ ├── markdown-reporter.ts │ │ ├── quiet-reporter.ts │ │ └── writeReportFile.ts │ ├── runMigration.ts │ ├── tasks │ │ ├── createTask.ts │ │ ├── index.ts │ │ ├── removeTask.ts │ │ ├── renameTask.ts │ │ ├── runTask.ts │ │ └── transformTask.ts │ ├── testing │ │ └── createTestkit.ts │ ├── types.ts │ └── utils.ts │ ├── testing │ ├── index.d.ts │ └── index.js │ ├── tsconfig.json │ ├── tsconfig.prod.json │ └── yarn-error.log ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | fixtures/error 2 | build 3 | example/**/double.js 4 | example/**/single.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true, 5 | "jest/globals": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 12, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint", 18 | "prettier", 19 | "jest" 20 | ], 21 | "rules": { 22 | "prefer-const": "off", 23 | "prettier/prettier": ["error", { "singleQuote": true }], 24 | "jest/no-identical-title": "error", 25 | "jest/prefer-to-have-length": "warn", 26 | "jest/valid-expect": "error", 27 | "@typescript-eslint/ban-ts-comment": "off", 28 | "@typescript-eslint/no-explicit-any": "off", 29 | "@typescript-eslint/explicit-module-boundary-types": "off", 30 | "@typescript-eslint/no-var-requires": "off", 31 | "@typescript-eslint/no-non-null-assertion": "off" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 14.x 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 14.x 21 | - run: npm install -g yarn 22 | - name: yarn install, build, and lint 23 | run: | 24 | yarn 25 | yarn build 26 | yarn lint 27 | 28 | build: 29 | runs-on: ubuntu-latest 30 | strategy: 31 | matrix: 32 | node-version: [12.x, 14.x, 16.x] 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v1 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | - run: npm install -g yarn 40 | - name: yarn install, build, and test 41 | run: | 42 | yarn 43 | yarn build 44 | yarn test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | tsconfig.tsbuildinfo 4 | tsconfig.prod.tsbuildinfo 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | ".git": true, 4 | "**/.git": false, 5 | "**/node_modules": false, 6 | }, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.11.1 (Apr 28, 2022) 4 | 5 | Export types ([#16](https://github.com/ranyitz/code-migrate/pull/16)) 6 | 7 | ## v0.11.0 (Mar 7, 2021) 8 | 9 | Support not bringing TypeScript as a dependency for `js` migration files ([#12](https://github.com/ranyitz/code-migrate/pull/12)) 10 | 11 | ## v0.10.1 (Feb 23, 2021) 12 | 13 | Support not bringing TypeScript as a dependency for `js` migration files ([#12](https://github.com/ranyitz/code-migrate/pull/12)) 14 | 15 | ## v0.10.0 (Feb 21, 2021) 16 | 17 | Support markdown reporter ([#11](https://github.com/ranyitz/code-migrate/pull/11)) 18 | 19 | ## v0.9.0 (Feb 20, 2021) 20 | 21 | Added the `--reportFile` option to generate a markdown report file ([#9](https://github.com/ranyitz/code-migrate/pull/9)) 22 | 23 | ## v0.8.0 (Feb 20, 2021) 24 | 25 | Improve output and report ([#8](https://github.com/ranyitz/code-migrate/pull/8)) 26 | 27 | ## v0.7.0 (Feb 17, 2021) 28 | 29 | Add support for e2e tests through the testkit ([#6](https://github.com/ranyitz/code-migrate/pull/6)) 30 | An ability to generate a CLI from code-migrate's API ([#7](https://github.com/ranyitz/code-migrate/pull/7)) 31 | 32 | ## v0.6.5 (Feb 14, 2021) 33 | 34 | Add a `.test()` method that dynamically creates tests to the testkit ([#5](https://github.com/ranyitz/code-migrate/pull/5)) 35 | 36 | ## v0.6.4 (Feb 14, 2021) 37 | 38 | use the tsconfig which is relative to the migration file and not the cwd on which the command has ran ([#4](https://github.com/ranyitz/code-migrate/pull/4)) 39 | 40 | ## v0.6.3 (Feb 11, 2021) 41 | 42 | Ignore `.git` directories when globbing ([#3](https://github.com/ranyitz/code-migrate/pull/3)) 43 | 44 | ## v0.6.2 (Feb 11, 2021) 45 | 46 | Support async migration function ([#2](https://github.com/ranyitz/code-migrate/pull/2)) 47 | 48 | ## v0.6.0 (Feb 2, 2021) 49 | 50 | Add abort function to transform task ([#1](https://github.com/ranyitz/code-migrate/pull/1)) 51 | 52 | ## v0.5.0 (Jan 22, 2021) 53 | 54 | Initial working version 55 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ranyitz@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hey! Thanks for your interest in improving `code-migrate`! There are plenty of ways you can help! 4 | 5 | Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved. 6 | 7 | ## Submitting an issue 8 | 9 | Please provide us with an issue in case you've found a bug, want a new feature, have an awesome idea, or there is something you want to discuss. 10 | 11 | ## Submitting a Pull Request 12 | 13 | Good pull requests, such as patches, improvements, and new features, are a fantastic help. They should remain focused in scope and avoid containing unrelated commits. 14 | 15 | ## Local Setup 16 | Fork the repo, clone, install dependencies and run the tests: 17 | 18 | ``` 19 | git clone git@github.com:/code-migrate.git 20 | cd code-migrate 21 | yarn install 22 | yarn test 23 | ``` 24 | 25 | ### Test 26 | 27 | In order to run the tests in watch mode ([jest](https://github.com/facebook/jest)), run the following command: 28 | 29 | ``` 30 | yarn test:watch 31 | ``` 32 | 33 | ### Lint 34 | 35 | In order to run the linter ([eslint](https://github.com/eslint/eslint)), run the following command: 36 | 37 | ``` 38 | yarn lint 39 | ``` 40 | 41 | * The linter will run before commit on staged files, using [husky](https://github.com/typicode/husky) and [lint-stage](https://github.com/okonet/lint-staged). 42 | 43 | ## Release a new version 44 | 45 | > This will work only if you have an admin over the npm package 46 | 47 | Run the following command 48 | 49 | ```bash 50 | yarn createVersion 51 | ``` 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ran Yitzhaki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

🎒 Code Migrate

2 |

A framework for writing codebase migrations on JavaScript/NodeJS based projects

3 |

4 | code-migrate 5 |

6 |

7 | 8 | Build Status 9 | 10 | 11 | NPM version 12 | 13 | 14 | License 15 | 16 |

17 | 18 | ## Why 19 | Writing an automatic migration script usually takes time. Besides implementing the transformations to the code/configuration, you also need to handle other concerns like publishing a CLI application, generating a report, handling errors during the migration process, writing tests, and more. 20 | 21 | Providing a polished experience usually results in a lot of work which we can not always justify. In some cases, maintainers resort to either API stagnation or leaving the heavy lifting to their users. If you're maintaining a library or a toolkit, you'd want your users to upgrade with minimal effort. And you want to write the migration script in a minimal amount of time. 22 | 23 | ## Features 24 | 25 | * Declarative way to define your migration tasks, leaving you with focusing only on the transformation logic. 26 | 27 | * The migration is separated into two parts, the first one is processing all of the tasks and the second is writing them to the file-system. This ensures that in case of an error, nothing will be written to the file-system. It also lets the user approve the migration via a prompt from the CLI. 28 | 29 | * Even though nothing is written while processing the tasks, all file system operations are written to a virtual file system which makes sure that tasks that depend on each other will work as expected. For example, if you change a file on the first task, the second task will see its updated version. 30 | 31 | * Code Migrate creates a beautiful report of the changes sorted by tasks. 32 | 33 | * There is a testkit that helps with the process of writing the migration. You can define `__before__` and `__after__` directories and use TDD to implement the migration with fewer mistakes and with a quick feedback loop. 34 | 35 | ______________ 36 | 37 | 38 | - [CLI](#cli) 39 | - [Node API](#node-api) 40 | * [createCli](#createcli) 41 | * [runMigration](#runmigration) 42 | - [Writing a migration file](#writing-a-migration-file) 43 | * [migrate](#migrate) 44 | * [tasks](#tasks) 45 | + [transform](#transform) 46 | + [rename](#rename) 47 | + [remove](#remove) 48 | + [create](#create) 49 | * [options](#options) 50 | + [fs](#fs) 51 | + [cwd](#cwd) 52 | - [Testing](#testing) 53 | * [createTestkit](#createtestkit) 54 | * [testkit.test](#testkittest) 55 | * [testkit.run](#testkitrun) 56 | - [Aborting a transformation](#aborting-a-transformation) 57 | 58 | ## CLI 59 | ``` 60 | Usage 61 | $ npx code-migrate 62 | 63 | Options 64 | --version, -v Version number 65 | --help, -h Displays this message 66 | --dry, -d Dry-run mode, does not modify files 67 | --yes, -y Skip all confirmation prompts 68 | --cwd Runs the migration on this directory [defaults to process.cwd()] 69 | --reporter Choose a reporter ("default"/"quiet"/"markdown"/"path/to/custom/reporter") 70 | --reportFile Create a markdown report and output it to a file [for example "report.md"] 71 | ``` 72 | ## Node API 73 | 74 | ### createCli 75 | Create a CLI application that runs your migration. You should create a bin file and [configure it through npm](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#bin), from that file, call the `createCli` function to create the CLI that runs your migration. 76 | ```ts 77 | type CreateCli = ({ 78 | binName: string; 79 | migrationFile?: string; 80 | version: string; 81 | subCommands?: Record; 82 | }) => Promise 83 | ``` 84 | 85 | #### subCommands 86 | > The migrationFile provided on for createCli functions as the default command 87 | 88 | If you want your CLI to include sub commands, for example `my-migration-cli foo` you can define it like that: 89 | ```ts 90 | createCli({ 91 | binName: 'my-migration-cli', 92 | version: require('package.json').version, 93 | subCommands: { 94 | foo: { 95 | migrationFile: './path/to/foo.js' 96 | } 97 | } 98 | }) 99 | ``` 100 | 101 | ### runMigration 102 | Run a migration programatically, you can also create a custom CLI or run through node API using the `runMigration` function. 103 | ```ts 104 | type RunMigration = ({ 105 | cwd: string; 106 | migrationFilePath: string; 107 | dry: boolean; 108 | yes: boolean; 109 | quite: boolean 110 | reportFile: string; 111 | }) => Promise; 112 | ``` 113 | 114 | ## Writing a migration file 115 | write a `js`/`ts` file that call the `migrate` function. 116 | 117 | ```ts 118 | // migration.ts 119 | import { migrate } from 'code-migrate'; 120 | 121 | migrate( 122 | 'automatic migration for my library', 123 | ({ transform, rename, create, remove }) => { 124 | transform( 125 | 'add the build directory to .gitignore', 126 | '.gitignore', 127 | ({ source }) => source.trim() + '\nbuild\n' 128 | ); 129 | 130 | transform( 131 | 'remove "use strict"; from all .js files', 132 | '**/*.js', 133 | ({ source }) => { 134 | return source.replace(/("|')use strict("|');?/, ''); 135 | } 136 | ); 137 | 138 | remove('remove babel config', 'babel.config.js'); 139 | 140 | rename('rename the main config to cool config', 'main-config.json', () => { 141 | return 'cool-config.json'; 142 | }); 143 | 144 | create('create an .env file', () => { 145 | return { 146 | fileName: '.env', 147 | source: 'HELLO=WORLD\n', 148 | }; 149 | }); 150 | } 151 | ); 152 | ``` 153 | 154 | _NOTE: if you decide to use TypeScript, code-migrate will use a tsconfig.json file relative to the migration file, don't forget to add `tsconfig.json` to the files array in package.json so it will be used in the migrations_ 155 | 156 | **Use with global `migrate` function** 157 | 158 | The code-migrate runner does not require that you'll import the `migrate` function, it will also work on the global: 159 | 160 | ```ts 161 | migrate('example migration', ({ create }) => { 162 | create('add foo.json file', () => { 163 | return { fileName: 'foo.json', source: JSON.stringify({ foo: 'bar' }) }; 164 | }); 165 | }); 166 | ``` 167 | 168 | For TypeScript and autocompletion add this line to any `d.ts` file required in your `tsconfig.json` `files`/`include` arrays. 169 | 170 | ```ts 171 | declare let migrate: import('code-migrate').Migrate; 172 | ``` 173 | 174 | ### migrate 175 | Similar to the way test runners work, Code Migrate will expose a global migrate function, you can also import it from `code-migrate` module, which will only work in the context of the runner. Use it to define your migration tasks. 176 | 177 | ```ts 178 | type Migrate = ( 179 | title: string, 180 | fn: ( 181 | tasks: { 182 | transform: Transform, 183 | rename: Rename, 184 | remove: Remove, 185 | create: Create 186 | }, 187 | options: { cwd: string, fs: VirtualFileSystem } 188 | ) => void 189 | ) => void; 190 | 191 | ``` 192 | ### tasks 193 | migration is defined by a series of tasks from the following list 194 | 195 | #### transform 196 | Change the source code of a file or multiple files 197 | 198 | ```ts 199 | type Transform = ( 200 | title: string, 201 | pattern: Pattern, 202 | transformFn: TransformFn 203 | ) => void; 204 | 205 | type TransformFn = ({ 206 | fileName: string; 207 | source: string; 208 | abort: () => void 209 | }) => string | SerializeableJsonObject; 210 | ``` 211 | 212 | #### rename 213 | Change the name of a file/directory or multiple files/directories 214 | 215 | ```ts 216 | type Rename = ( 217 | title: string, 218 | pattern: Pattern, 219 | renameFn: RenameFn 220 | ) => void; 221 | 222 | type RenameFn = ({ fileName: string }) => string; 223 | ``` 224 | #### remove 225 | Delete a file/directory or multiple files/directories 226 | ```ts 227 | type Remove = (title: string, pattern: Pattern, fn: removeFn) => void; 228 | 229 | type RemoveFn = ({ source: string; fileName: string }) => void; 230 | 231 | ``` 232 | #### create 233 | Create a new file/directory or multiple files/directories 234 | ```ts 235 | 236 | type Create = ( 237 | title: string, 238 | patternOrCreateFn: EmptyCreateFn | Pattern, 239 | createFn?: CreateFn 240 | ) => void; 241 | ``` 242 | 243 | ### options 244 | #### fs 245 | Virtual file System which implements a subset of the `fs` module API. You can use it to perform custom file system operations that will be part of the migration process. They will only be written at the end of the migration and will relay on former tasks. 246 | 247 | #### cwd 248 | The working directory in which the migration currently runs. 249 | 250 | ## Testing 251 | Code Migrate comes with a testkit that lets you write tests for your migration. Use jest or any other test runner to run your suite: 252 | 253 | You'll need to create fixtures of the `__before__` and the `__after__` states, the testkit expects those directories and knows how to accept a migration file and a fixtures directory as parameters. 254 | 255 | Let's consider the following file structure: 256 | ``` 257 | . 258 | ├── __after__ 259 | │ └── bar.json 260 | ├── __before__ 261 | │ └── foo.json 262 | ├── migration.test.ts 263 | └── migration.ts 264 | ``` 265 | 266 | And the following migration file: 267 | ```ts 268 | // migration.ts 269 | 270 | migrate('my migration', ({ transform }) => { 271 | rename( 272 | 'replace foo with bar in all json files', 273 | '**/*.json', 274 | ({ filename }) => filename.replace('foo', 'bar'); 275 | ); 276 | }); 277 | ``` 278 | ### createTestkit 279 | ```ts 280 | createTestkit({ migrationFile: string, command: string[] }) 281 | ``` 282 | 283 | The test initializes the testkit which accepts an optional path to the migration file, otherwise, looks for a `migration.ts` file in the fixture directory. 284 | 285 | There is an optional `command` property, when used code-migrate will run a command instead of the testkit, this also tests the CLI in an e2e manner. 286 | 287 | To run the following command 288 | ```bash 289 | $ node /absolute/path/to/bin.js -y 290 | ``` 291 | 292 | Initialize the testkit with the following command property: 293 | ``` 294 | command: ['node', '/absolute/path/to/bin.js', '-y'] 295 | ``` 296 | 297 | ### testkit.test 298 | ```ts 299 | testkit.test({ fixtures: string, title?: string }) 300 | ``` 301 | > Creates a test supporting jest, mocha & jasmine 302 | ```ts 303 | // migration.test.ts 304 | 305 | import { createTestkit } from 'code-migrate/testing'; 306 | import path from 'path'; 307 | 308 | createTestkit({ migrationFile: 'migration.ts' }).test({ 309 | fixtures: __dirname, 310 | title: 'should rename foo.json to bar.json' 311 | }); 312 | ``` 313 | 314 | ### testkit.run 315 | ```ts 316 | testkit.run({ fixtures: string }) 317 | ``` 318 | > Notice that this method is async, and therefore needs to be returned or awaited 319 | ```ts 320 | // migration.test.ts 321 | 322 | import { createTestkit } from 'code-migrate/testing'; 323 | import path from 'path'; 324 | 325 | const testkit = createTestkit({ 326 | migrationFile: path.join(__dirname, 'migration.ts'), 327 | }); 328 | 329 | it('should rename foo.json to bar.json', async () => { 330 | await testkit.run({ fixtures: __dirname }); 331 | }); 332 | ``` 333 | 334 | ## Aborting a transformation 335 | There are cases that you would like to abort the whole transformation in case a single file failed. In this case you'll have the `abort` function. Calling it will abort the whole transfomation, this also means that files that were already processed won't be written to the fileSystem. 336 | 337 | ```ts 338 | transform( 339 | 'attempt to migrate something complex', 340 | '**/*.js', 341 | ({ source, abort }) => { 342 | const result = tryMigratingSomething(source) 343 | 344 | // In case a single file didn't pass 345 | // We want to abort and don't change any *.js file 346 | if (result.pass === false) { 347 | abort(); 348 | } 349 | 350 | return result.source; 351 | } 352 | ); 353 | ``` 354 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/example/.gitignore -------------------------------------------------------------------------------- /example/babel.config.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/example/babel.config.js -------------------------------------------------------------------------------- /example/foo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable */ 3 | myFunction(); 4 | 5 | function myFunction() { 6 | y = 3.14; 7 | } 8 | -------------------------------------------------------------------------------- /example/main-config.json: -------------------------------------------------------------------------------- 1 | {foo, bar} 2 | -------------------------------------------------------------------------------- /example/migration.ts: -------------------------------------------------------------------------------- 1 | // migration.ts 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate( 5 | 'automatic migration for my library', 6 | ({ transform, rename, create, remove }) => { 7 | transform( 8 | 'add the build directory to .gitignore', 9 | '.gitignore', 10 | ({ source }) => source.trim() + '\nbuild\n' 11 | ); 12 | 13 | transform( 14 | 'remove "use strict"; from all .js files', 15 | '**/*.js', 16 | ({ source }) => { 17 | return source.replace(/("|')use strict("|');?/, '').trimStart(); 18 | } 19 | ); 20 | 21 | remove('remove babel config', 'babel.config.js'); 22 | 23 | rename('rename the main config to cool config', 'main-config.json', () => { 24 | return 'cool-config.json'; 25 | }); 26 | 27 | transform('parse config and fail', 'cool-config.json', ({ source }) => { 28 | return JSON.parse(source); 29 | }); 30 | 31 | create('create an .env file', () => { 32 | return { 33 | fileName: '.env', 34 | source: 'HELLO=WORLD\n', 35 | }; 36 | }); 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "useWorkspaces": true, 7 | "version": "0.11.1" 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-migrate-monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "prepare": "husky install", 9 | "build-clean": "rm -rf ./packages/*/build ./packages/*/tsconfig.tsbuildinfo", 10 | "clean": "yarn build-clean && rm -rf ./packages/*/node_modules && rm -rf ./node_modules", 11 | "watch": "yarn tsc -b --watch --incremental packages/code-migrate/tsconfig.prod.json", 12 | "build": "yarn tsc -b packages/code-migrate/tsconfig.prod.json", 13 | "lint": "eslint .", 14 | "test": "yarn --cwd=packages/code-migrate test ", 15 | "createVersion": "yarn build-clean && yarn build && yarn test && lerna publish" 16 | }, 17 | "devDependencies": { 18 | "@babel/preset-env": "^7.12.1", 19 | "@babel/preset-typescript": "^7.12.1", 20 | "@types/jest": "^26.0.15", 21 | "@types/node": "^14.14.8", 22 | "@typescript-eslint/eslint-plugin": "^4.8.1", 23 | "@typescript-eslint/parser": "^4.8.1", 24 | "ast-types": "^0.14.2", 25 | "eslint": "^7.13.0", 26 | "eslint-plugin-jest": "^24.1.3", 27 | "eslint-plugin-prettier": "^3.1.4", 28 | "husky": ">=4", 29 | "jest": "^26.6.3", 30 | "lerna": "^3.22.1", 31 | "lint-staged": ">=10", 32 | "prettier": "^2.1.2", 33 | "recast": "^0.20.4", 34 | "ts-node": "^9.0.0", 35 | "type-fest": "^0.19.0", 36 | "typescript": "^4.0.5" 37 | }, 38 | "lint-staged": { 39 | "*.ts": "eslint --cache --fix" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/abort.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('abort'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/async.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('async'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/cli/onlySubCommands.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture, tsConfigPath, tsNodeBin } from '../utils'; 3 | import { createTestkit } from 'code-migrate/testing'; 4 | import execa from 'execa'; 5 | 6 | const onlySubCommandsBin = path.join(__dirname, 'onlySubCommands.ts'); 7 | 8 | const fixturesFull = resolveFixture('full'); 9 | const migrationFileFull = path.join(fixturesFull, 'migration.ts'); 10 | 11 | const fixturesTransform = resolveFixture('transform'); 12 | const migrationFileTransform = path.join(fixturesTransform, 'migration.ts'); 13 | 14 | createTestkit({ 15 | migrationFile: migrationFileFull, 16 | command: [ 17 | tsNodeBin, 18 | '-P', 19 | tsConfigPath, 20 | onlySubCommandsBin, 21 | 'full', 22 | '--yes', 23 | '--reporter=quiet', 24 | ], 25 | }).test({ 26 | fixtures: fixturesFull, 27 | }); 28 | 29 | createTestkit({ 30 | migrationFile: migrationFileTransform, 31 | command: [ 32 | tsNodeBin, 33 | '-P', 34 | tsConfigPath, 35 | onlySubCommandsBin, 36 | 'transform', 37 | '--yes', 38 | '--reporter=quiet', 39 | ], 40 | }).test({ 41 | fixtures: fixturesTransform, 42 | }); 43 | 44 | test('should render the help usage appropriately', () => { 45 | const { stdout } = execa.sync(tsNodeBin, [ 46 | '-P', 47 | tsConfigPath, 48 | onlySubCommandsBin, 49 | '--help', 50 | ]); 51 | 52 | expect(stdout).toMatch('$ code-migrate '); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/cli/onlySubCommands.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture } from '../utils'; 3 | import { createCli } from '../../src/cli/createCli'; 4 | 5 | const migrationFileFull = path.join(resolveFixture('full'), 'migration.ts'); 6 | const migrationFileTransform = path.join( 7 | resolveFixture('transform'), 8 | 'migration.ts' 9 | ); 10 | 11 | createCli({ 12 | binName: 'code-migrate', 13 | version: require('../../package.json').version, 14 | subCommands: { 15 | full: { 16 | migrationFile: migrationFileFull, 17 | }, 18 | transform: { 19 | migrationFile: migrationFileTransform, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/cli/withDefaultCommand.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture, tsConfigPath, tsNodeBin } from '../utils'; 3 | import { createTestkit } from 'code-migrate/testing'; 4 | import execa from 'execa'; 5 | 6 | const withDefaultCommandBin = path.join(__dirname, 'withDefaultCommand.ts'); 7 | 8 | const fixturesFull = resolveFixture('full'); 9 | const migrationFileFull = path.join(fixturesFull, 'migration.ts'); 10 | 11 | const fixturesTransform = resolveFixture('transform'); 12 | const migrationFileTransform = path.join(fixturesTransform, 'migration.ts'); 13 | 14 | createTestkit({ 15 | migrationFile: migrationFileFull, 16 | command: [ 17 | tsNodeBin, 18 | '-P', 19 | tsConfigPath, 20 | withDefaultCommandBin, 21 | 'full', 22 | '--yes', 23 | '--reporter=quiet', 24 | ], 25 | }).test({ 26 | fixtures: fixturesFull, 27 | }); 28 | 29 | createTestkit({ 30 | migrationFile: migrationFileTransform, 31 | command: [ 32 | tsNodeBin, 33 | '-P', 34 | tsConfigPath, 35 | withDefaultCommandBin, 36 | '--yes', 37 | '--reporter=quiet', 38 | ], 39 | }).test({ 40 | fixtures: fixturesTransform, 41 | }); 42 | 43 | test('should render the help usage appropriately', () => { 44 | const { stdout } = execa.sync(tsNodeBin, [ 45 | '-P', 46 | tsConfigPath, 47 | withDefaultCommandBin, 48 | '--help', 49 | ]); 50 | 51 | expect(stdout).toMatch('$ code-migrate [full]'); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/cli/withDefaultCommand.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture } from '../utils'; 3 | import { createCli } from '../../src/cli/createCli'; 4 | 5 | const migrationFileFull = path.join(resolveFixture('full'), 'migration.ts'); 6 | const migrationFileTransform = path.join( 7 | resolveFixture('transform'), 8 | 'migration.ts' 9 | ); 10 | 11 | createCli({ 12 | binName: 'code-migrate', 13 | version: require('../../package.json').version, 14 | migrationFile: migrationFileTransform, 15 | subCommands: { 16 | full: { 17 | migrationFile: migrationFileFull, 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/create.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('create'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/e2e/full.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture } from '../utils'; 3 | import { createTestkit } from 'code-migrate/testing'; 4 | 5 | const binFile = path.join(__dirname, '../../bin/code-migrate'); 6 | const fixtures = resolveFixture('full'); 7 | const migrationFile = path.join(fixtures, 'migration.ts'); 8 | 9 | createTestkit({ 10 | migrationFile, 11 | command: ['node', binFile, migrationFile, '--yes', '--reporter=quiet'], 12 | }).test({ 13 | fixtures, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/e2e/no-ts-node.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture, runMigrationAndGetOutput } from '../utils'; 3 | 4 | const fixtures = resolveFixture('no-ts-node'); 5 | const migrationFile = path.join(fixtures, 'migration.js'); 6 | 7 | test('should fail when used on a js file and use TypeScript', async () => { 8 | expect.assertions(1); 9 | try { 10 | await runMigrationAndGetOutput({ 11 | fixtures, 12 | migrationFile, 13 | }); 14 | } catch (error) { 15 | expect(error).toBeInstanceOf(Error); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/e2e/transform.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture } from '../utils'; 3 | import { createTestkit } from 'code-migrate/testing'; 4 | 5 | const binFile = path.join(__dirname, '../../bin/code-migrate'); 6 | const fixtures = resolveFixture('transform'); 7 | const migrationFile = path.join(fixtures, 'migration.ts'); 8 | 9 | createTestkit({ 10 | migrationFile, 11 | command: ['node', binFile, migrationFile, '--yes', '--reporter=quiet'], 12 | }).test({ 13 | fixtures, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/.DS_Store -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/abort/__after__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "baz" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/abort/__after__/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/abort/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/abort/__before__/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/abort/migration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate('abort', ({ transform }) => { 5 | transform( 6 | 'transform bar to baz in json contents', 7 | 'baz.json', 8 | ({ fileName, source }) => { 9 | return source.replace('bar', path.basename(fileName, '.json')); 10 | } 11 | ); 12 | 13 | transform( 14 | 'transform world to there in foo.json', 15 | '*.json', 16 | ({ source, abort }) => { 17 | // since ono of the files contain this 18 | // we expect all of the transformation to be aborted 19 | if (source.includes('foo')) { 20 | abort(); 21 | } 22 | 23 | return source.replace('world', 'there'); 24 | } 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/async/__after__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "baz" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/async/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/async/migration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate('transform', async ({ transform }) => { 5 | await new Promise((r) => setTimeout(r, 0)); 6 | 7 | transform( 8 | 'transform bar to baz in json contents', 9 | '*.json', 10 | ({ fileName, source }) => { 11 | return source.replace('bar', path.basename(fileName, '.json')); 12 | } 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/create/__after__/bar.txt: -------------------------------------------------------------------------------- 1 | baz 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/create/__after__/foo-bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar-bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/create/__after__/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/create/__before__/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/create/migration.ts: -------------------------------------------------------------------------------- 1 | import { migrate } from 'code-migrate'; 2 | 3 | migrate('create', ({ create }) => { 4 | create( 5 | 'create another file with added an bar', 6 | '*.json', 7 | ({ fileName, source }) => { 8 | return { 9 | fileName: fileName.replace('foo', 'foo-bar'), 10 | source: source.replace('bar', 'bar-bar'), 11 | }; 12 | } 13 | ); 14 | 15 | create('create bar.txt', () => { 16 | return { 17 | fileName: 'bar.txt', 18 | source: 'baz\n', 19 | }; 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/error/__after__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/error/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar", 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/error/migration.ts: -------------------------------------------------------------------------------- 1 | import { migrate } from 'code-migrate'; 2 | 3 | migrate('error', ({ transform }) => { 4 | transform('transform bar to baz in json contents', '*.json', ({ source }) => { 5 | return JSON.parse(source); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__after__/.env: -------------------------------------------------------------------------------- 1 | HELLO=WORLD 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__after__/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | build 3 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__after__/cool-config.json: -------------------------------------------------------------------------------- 1 | {foo, bar} 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__after__/foo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | myFunction(); 3 | 4 | function myFunction() { 5 | y = 3.14; 6 | } 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/.gitignore -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/babel.config.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/babel.config.js -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/foo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable */ 3 | myFunction(); 4 | 5 | function myFunction() { 6 | y = 3.14; 7 | } 8 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/__before__/main-config.json: -------------------------------------------------------------------------------- 1 | {foo, bar} 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full-with-errors/migration.ts: -------------------------------------------------------------------------------- 1 | // migration.ts 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate( 5 | 'automatic migration for my library', 6 | ({ transform, rename, create, remove }) => { 7 | transform( 8 | 'add the build directory to .gitignore', 9 | '.gitignore', 10 | ({ source }) => source.trim() + '\nbuild\n' 11 | ); 12 | 13 | transform( 14 | 'remove "use strict"; from all .js files', 15 | '**/*.js', 16 | ({ source }) => { 17 | return source.replace(/("|')use strict("|');?/, '').trimStart(); 18 | } 19 | ); 20 | 21 | remove('remove babel config', 'babel.config.js'); 22 | 23 | rename('rename the main config to cool config', 'main-config.json', () => { 24 | return 'cool-config.json'; 25 | }); 26 | 27 | transform('parse config and fail', 'cool-config.json', ({ source }) => { 28 | return JSON.parse(source); 29 | }); 30 | 31 | create('create an .env file', () => { 32 | return { 33 | fileName: '.env', 34 | source: 'HELLO=WORLD\n', 35 | }; 36 | }); 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__after__/.env: -------------------------------------------------------------------------------- 1 | HELLO=WORLD 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__after__/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | build 3 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__after__/cool-config.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__after__/foo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | myFunction(); 3 | 4 | function myFunction() { 5 | y = 3.14; 6 | } 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__before__/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/full/__before__/.gitignore -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__before__/babel.config.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/full/__before__/babel.config.js -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__before__/foo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable */ 3 | myFunction(); 4 | 5 | function myFunction() { 6 | y = 3.14; 7 | } 8 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/__before__/main-config.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/full/migration.ts: -------------------------------------------------------------------------------- 1 | // migration.ts 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate( 5 | 'automatic migration for my library', 6 | ({ transform, rename, create, remove }) => { 7 | transform( 8 | 'add the build directory to .gitignore', 9 | '.gitignore', 10 | ({ source }) => source.trim() + '\nbuild\n' 11 | ); 12 | 13 | transform( 14 | 'remove "use strict"; from all .js files', 15 | '**/*.js', 16 | ({ source }) => { 17 | return source.replace(/("|')use strict("|');?/, '').trimStart(); 18 | } 19 | ); 20 | 21 | remove('remove babel config', 'babel.config.js'); 22 | 23 | rename('rename the main config to cool config', 'main-config.json', () => { 24 | return 'cool-config.json'; 25 | }); 26 | 27 | create('create an .env file', () => { 28 | return { 29 | fileName: '.env', 30 | source: 'HELLO=WORLD\n', 31 | }; 32 | }); 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/ignore/__after__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "baz" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/ignore/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/ignore/migration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate('transform', ({ transform }) => { 5 | transform( 6 | 'transform bar to baz in json contents', 7 | '**/*.json', 8 | ({ fileName, source }) => { 9 | return source.replace('bar', path.basename(fileName, '.json')); 10 | } 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/no-ts-node/__after__/txt.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/no-ts-node/__before__/txt.txt: -------------------------------------------------------------------------------- 1 | hello 2 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/no-ts-node/migration.js: -------------------------------------------------------------------------------- 1 | import { migrate } from 'code-migrate'; 2 | 3 | migrate('error', ({ transform }) => { 4 | transform('transform bar to baz in json contents', '*.json', ({ source }) => { 5 | let a: string; 6 | 7 | console.log(a); 8 | return JSON.parse(source); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/remove/__after__/placeholder.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/remove/__after__/placeholder.txt -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/remove/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/remove/__before__/placeholder.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranyitz/code-migrate/17dd0e47cc867597371876a30238c96a62125e1c/packages/code-migrate/__tests__/fixtures/remove/__before__/placeholder.txt -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/remove/migration.ts: -------------------------------------------------------------------------------- 1 | import { migrate } from 'code-migrate'; 2 | 3 | migrate('remove', ({ remove }) => { 4 | remove('remove json files', '*.json'); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/rename/__after__/foo-bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/rename/__before__/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/rename/migration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate('rename', ({ rename }) => { 5 | rename('transform foo.json to foo-bar.json', 'foo.json', ({ fileName }) => { 6 | return `${path.basename(fileName, '.json')}-bar.json`; 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/transform/__after__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "baz" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/transform/__before__/baz.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": "bar" 3 | } 4 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/fixtures/transform/migration.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { migrate } from 'code-migrate'; 3 | 4 | migrate('transform', ({ transform }) => { 5 | transform( 6 | 'transform bar to baz in json contents', 7 | '*.json', 8 | ({ fileName, source }) => { 9 | return source.replace('bar', path.basename(fileName, '.json')); 10 | } 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/ignore.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('ignore'), 6 | title: 'ignore node_modules & .git directories', 7 | }); 8 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/remove.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('remove'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/rename.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('rename'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/reportFile.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture } from './utils'; 3 | import { createTestkit } from 'code-migrate/testing'; 4 | import fs from 'fs-extra'; 5 | import execa from 'execa'; 6 | 7 | const binFile = path.join(__dirname, '../bin/code-migrate'); 8 | const fixtures = resolveFixture('full-with-errors'); 9 | const migrationFile = path.join(fixtures, 'migration.ts'); 10 | 11 | test('reportFile markdown report', async () => { 12 | const testkit = createTestkit({ 13 | migrationFile, 14 | migrationFunction: async ({ cwd }) => { 15 | await execa( 16 | 'node', 17 | [binFile, migrationFile, '--yes', '--reportFile=report.md'], 18 | { 19 | cwd, 20 | } 21 | ); 22 | }, 23 | }); 24 | 25 | const { cwd } = await testkit.run({ 26 | fixtures, 27 | }); 28 | 29 | const reportPath = path.join(cwd, 'report.md'); 30 | const report = fs.readFileSync(reportPath, 'utf-8'); 31 | 32 | expect(report).toMatch('## automatic migration for my library'); 33 | expect(report).toMatch('#### add the build directory to .gitignore'); 34 | expect(report).toMatch('```\n.gitignore\n```'); 35 | expect(report).toMatch('**ERROR** `cool-config.json`'); 36 | expect(report).toMatch( 37 | '```\nSyntaxError: Unexpected token f in JSON at position 1' 38 | ); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/reporters/__snapshots__/markdown.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`markdown reporter error 1`] = ` 4 | " 5 | ## error 6 | > /static-directory 7 | 8 | ### ⚠️ The following migration tasks were failed 9 | 10 | #### transform bar to baz in json contents 11 | **ERROR** \`baz.json\` 12 | \`\`\` 13 | SyntaxError: Unexpected token } in JSON at position 18 14 | at function (/path/to/file) 15 | at function (/path/to/file) 16 | at function (/path/to/file) 17 | at function (/path/to/file) 18 | at function (/path/to/file) 19 | at function (/path/to/file) 20 | at function (/path/to/file) 21 | at function (/path/to/file) 22 | at function (/path/to/file) 23 | at function (/path/to/file) 24 | \`\`\` 25 | 26 | **🤷‍ No changes have been made**" 27 | `; 28 | 29 | exports[`markdown reporter passing 1`] = ` 30 | " 31 | ## transform 32 | > /static-directory 33 | 34 | #### transform bar to baz in json contents 35 | \`\`\` 36 | baz.json 37 | \`\`\`" 38 | `; 39 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/reporters/default.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { resolveFixture, runMigrationAndGetOutput } from '../utils'; 3 | 4 | describe('default reporter', () => { 5 | it('passing', async () => { 6 | const fixtures = resolveFixture('transform'); 7 | const migrationFile = path.join(fixtures, 'migration.ts'); 8 | 9 | let output = await runMigrationAndGetOutput({ 10 | fixtures, 11 | migrationFile, 12 | }); 13 | 14 | expect(output).toMatch('🏃‍ Running: transform'); 15 | expect(output).toMatch('📁 On:'); 16 | expect(output).toMatch('baz.json'); 17 | expect(output).toMatch('READY'); 18 | expect(output).toMatch('The migration has been completed successfully'); 19 | }); 20 | 21 | it('error', async () => { 22 | const fixtures = resolveFixture('error'); 23 | const migrationFile = path.join(fixtures, 'migration.ts'); 24 | 25 | const output = await runMigrationAndGetOutput({ 26 | fixtures, 27 | migrationFile, 28 | }); 29 | 30 | expect(output).toMatch('🏃‍ Running: error'); 31 | expect(output).toMatch('📁 On:'); 32 | expect(output).toMatch( 33 | 'The following migration tasks were failed, but you can still migrate the rest' 34 | ); 35 | expect(output).toMatch('ERROR'); 36 | expect(output).toMatch('baz.json'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/reporters/markdown.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { 3 | resolveFixture, 4 | runMigrationAndGetOutput, 5 | sanitizeStacktrace, 6 | } from '../utils'; 7 | 8 | const sanitizeDynamicContent = (output: string) => { 9 | output = output.replace(/> \/([\w/\-_]+)/, '> /static-directory'); 10 | 11 | return sanitizeStacktrace(output); 12 | }; 13 | 14 | describe('markdown reporter', () => { 15 | it('passing', async () => { 16 | const fixtures = resolveFixture('transform'); 17 | const migrationFile = path.join(fixtures, 'migration.ts'); 18 | 19 | let output = await runMigrationAndGetOutput({ 20 | fixtures, 21 | migrationFile, 22 | reporterName: 'markdown', 23 | }); 24 | 25 | expect(sanitizeDynamicContent(output)).toMatchSnapshot(); 26 | }); 27 | 28 | it('error', async () => { 29 | const fixtures = resolveFixture('error'); 30 | const migrationFile = path.join(fixtures, 'migration.ts'); 31 | 32 | const output = await runMigrationAndGetOutput({ 33 | fixtures, 34 | migrationFile, 35 | reporterName: 'markdown', 36 | }); 37 | 38 | expect(sanitizeDynamicContent(output)).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveFixture } from './utils'; 2 | import { createTestkit } from 'code-migrate/testing'; 3 | 4 | createTestkit({ migrationFile: 'migration.ts' }).test({ 5 | fixtures: resolveFixture('transform'), 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/__tests__/utils.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import path from 'path'; 3 | import stripAnsi from 'strip-ansi'; 4 | // TODO: Replace this package with native call after node v.15 5 | // @ts-expect-error no types 6 | import replaceAll from 'string.prototype.replaceall'; 7 | import { createTestkit } from '../src/testing/createTestkit'; 8 | 9 | const root = path.join(__dirname, '../../../'); 10 | 11 | export const tsNodeBin = path.join(root, 'node_modules/.bin/ts-node'); 12 | export const tsConfigPath = path.join(root, 'tsconfig.json'); 13 | 14 | const binFile = path.join(__dirname, '../bin/code-migrate'); 15 | 16 | export const resolveFixture = (fixtureName: string): string => { 17 | return path.resolve(__dirname, 'fixtures', fixtureName); 18 | }; 19 | 20 | export const runMigrationAndGetOutput = async ({ 21 | fixtures, 22 | migrationFile, 23 | reporterName, 24 | }: { 25 | fixtures: string; 26 | migrationFile: string; 27 | reporterName?: string; 28 | }) => { 29 | let output = ''; 30 | 31 | const testkit = createTestkit({ 32 | migrationFile, 33 | migrationFunction: async ({ cwd }) => { 34 | let { stdout } = await execa( 35 | 'node', 36 | [ 37 | binFile, 38 | migrationFile, 39 | '--yes', 40 | ...(reporterName ? [`--reporter=${reporterName}`] : []), 41 | ], 42 | { 43 | cwd, 44 | } 45 | ); 46 | 47 | output = stdout; 48 | }, 49 | }); 50 | 51 | await testkit.run({ 52 | fixtures, 53 | }); 54 | 55 | return stripAnsi(output); 56 | }; 57 | 58 | export const sanitizeStacktrace = (output: string) => { 59 | return replaceAll( 60 | output, 61 | / {3}at( [\w.<>\d~!_:-]+)? \(?[\w.<>\d~!_/\\:-]+\)?/gim, 62 | ' at function (/path/to/file)' 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /packages/code-migrate/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/code-migrate/bin/code-migrate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../build/cli/code-migrate-cli'); 4 | -------------------------------------------------------------------------------- /packages/code-migrate/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | export default { 7 | testEnvironment: 'node', 8 | testMatch: ['**/__tests__/**/*.test.ts'], 9 | modulePathIgnorePatterns: ['build', '__before__', '__after__'], 10 | resetModules: true, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/code-migrate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-migrate", 3 | "version": "0.11.1", 4 | "main": "build/index.js", 5 | "types": "build/index.d.ts", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/ranyitz/code-migrate.git" 9 | }, 10 | "bin": { 11 | "code-migrate": "bin/code-migrate" 12 | }, 13 | "files": [ 14 | "src", 15 | "build", 16 | "testing", 17 | "bin" 18 | ], 19 | "license": "MIT", 20 | "scripts": { 21 | "watch": "yarn tsc --watch --incremental", 22 | "build": "yarn tsc", 23 | "lint": "eslint .", 24 | "test": "jest" 25 | }, 26 | "dependencies": { 27 | "arg": "^5.0.0", 28 | "chalk": "^4.1.0", 29 | "execa": "^5.0.0", 30 | "expect": "^26.6.2", 31 | "fs-extra": "^9.0.1", 32 | "globby": "^11.0.1", 33 | "import-fresh": "^3.2.2", 34 | "lodash": "^4.17.20", 35 | "mock-require": "^3.0.3", 36 | "prompts": "^2.4.0", 37 | "tempy": "^1.0.0", 38 | "ts-node": "^9.0.0" 39 | }, 40 | "devDependencies": { 41 | "@types/fs-extra": "^9.0.4", 42 | "@types/globby": "^9.1.0", 43 | "@types/lodash": "^4.14.165", 44 | "@types/prompts": "^2.0.9", 45 | "prompts": "^2.4.0", 46 | "string.prototype.replaceall": "^1.0.4", 47 | "strip-ansi": "^6.0.0", 48 | "tempy": "^1.0.0", 49 | "typed-emitter": "^1.3.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/code-migrate/src/File.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { Stats } from 'fs-extra'; 3 | import { Pattern } from './types'; 4 | import globby from 'globby'; 5 | import { isUndefined } from 'lodash'; 6 | import { Migration } from './Migration'; 7 | 8 | export class File { 9 | cwd: string; 10 | fileName: string; 11 | migration: Migration; 12 | _source: string | undefined; 13 | _stats: Stats | null | undefined; 14 | 15 | constructor({ 16 | cwd, 17 | fileName, 18 | source, 19 | migration, 20 | }: { 21 | cwd: string; 22 | fileName: string; 23 | source?: string; 24 | migration: Migration; 25 | }) { 26 | this.cwd = cwd; 27 | this.fileName = fileName; 28 | this._source = source; 29 | this._stats = undefined; 30 | this.migration = migration; 31 | } 32 | 33 | get path(): string { 34 | // TODO: support ablsolute paths as well 35 | return path.join(this.cwd, this.fileName); 36 | } 37 | 38 | get stats() { 39 | if (!this._stats) { 40 | try { 41 | this._stats = this.migration.fs.lstatSync(this.path); 42 | } catch (err) { 43 | this._stats = null; 44 | } 45 | } 46 | 47 | return this._stats; 48 | } 49 | 50 | get exists() { 51 | // this.stats is null in case the file isn't exists 52 | return !!this.stats; 53 | } 54 | 55 | get isDirectory() { 56 | return !!this.stats?.isDirectory(); 57 | } 58 | 59 | get source(): string { 60 | if (isUndefined(this._source)) { 61 | this._source = this.migration.fs.readFileSync(this.path); 62 | } 63 | 64 | return this._source; 65 | } 66 | } 67 | 68 | export const getFiles = ( 69 | cwd: string, 70 | pattern: Pattern, 71 | migration: Migration 72 | ): Array => { 73 | const fileNames = globby.sync(pattern, { 74 | cwd, 75 | gitignore: true, 76 | ignore: ['**/node_modules/**', '**/.git/**'], 77 | dot: true, 78 | // @ts-expect-error 79 | fs: migration.fs.fileSystemAdapterMethods, 80 | }); 81 | 82 | if (!fileNames) { 83 | return []; 84 | } 85 | 86 | const files: Array = fileNames.map((fileName) => { 87 | return new File({ cwd, fileName, migration }); 88 | }); 89 | 90 | return files; 91 | }; 92 | -------------------------------------------------------------------------------- /packages/code-migrate/src/Migration.ts: -------------------------------------------------------------------------------- 1 | import { runSingleTask } from './tasks/runTask'; 2 | import { MigrationEmitter } from './events'; 3 | import type { 4 | Options, 5 | TaskResult, 6 | RegisterCreateTask, 7 | RegisterRemoveTask, 8 | RegisterRenameTask, 9 | RegisterTransformTask, 10 | RegisterAfterHook, 11 | Task, 12 | TaskError, 13 | } from './types'; 14 | import { isPattern } from './utils'; 15 | import { getReporter } from './reporters'; 16 | import { VirtualFileSystem } from './VirtualFileSystem'; 17 | import { AfterHookFn } from './hooks'; 18 | 19 | export type RegisterMethods = { 20 | transform: RegisterTransformTask; 21 | rename: RegisterRenameTask; 22 | remove: RegisterRemoveTask; 23 | create: RegisterCreateTask; 24 | after: RegisterAfterHook; 25 | }; 26 | 27 | export class Migration { 28 | options: Options; 29 | events: MigrationEmitter; 30 | fs: VirtualFileSystem; 31 | results: Array; 32 | errors: Array; 33 | afterHooks: Array; 34 | title: string | null; 35 | 36 | constructor(options: Options) { 37 | this.title = null; 38 | this.options = options; 39 | this.events = new MigrationEmitter(this); 40 | this.fs = new VirtualFileSystem({ cwd: options.cwd }); 41 | this.results = []; 42 | this.errors = []; 43 | this.afterHooks = []; 44 | } 45 | 46 | runTask(task: Task) { 47 | const { taskErrors, taskResults } = runSingleTask(task, this); 48 | 49 | this.results.push(...taskResults); 50 | this.errors.push(...taskErrors); 51 | } 52 | 53 | transform: RegisterTransformTask = (title, pattern, transformFn) => { 54 | this.runTask({ type: 'transform', title, pattern, fn: transformFn }); 55 | }; 56 | 57 | rename: RegisterRenameTask = (title, pattern, renameFn) => { 58 | this.runTask({ type: 'rename', title, pattern, fn: renameFn }); 59 | }; 60 | 61 | remove: RegisterRemoveTask = (title, pattern, removeFn) => { 62 | this.runTask({ type: 'remove', title, pattern, fn: removeFn }); 63 | }; 64 | 65 | create: RegisterCreateTask = (title, patternOrCreateFn, createFn) => { 66 | let task: Task; 67 | 68 | if (isPattern(patternOrCreateFn)) { 69 | if (!createFn) { 70 | throw new Error( 71 | `When using a pattern for the second argument of the createTask function 72 | You must supply a createFunction as the third argument` 73 | ); 74 | } 75 | 76 | task = { 77 | type: 'create', 78 | title, 79 | pattern: patternOrCreateFn, 80 | fn: createFn, 81 | }; 82 | } else { 83 | task = { type: 'create', title, fn: patternOrCreateFn }; 84 | } 85 | 86 | this.runTask(task); 87 | }; 88 | 89 | after: RegisterAfterHook = (afterFn: AfterHookFn) => { 90 | this.afterHooks.push(afterFn); 91 | }; 92 | 93 | registerMethods: RegisterMethods = { 94 | transform: this.transform, 95 | rename: this.rename, 96 | remove: this.remove, 97 | create: this.create, 98 | after: this.after, 99 | }; 100 | 101 | write() { 102 | this.fs.writeChangesToDisc(); 103 | this.afterHooks.forEach((fn) => fn()); 104 | } 105 | 106 | static init(options: Options): Migration { 107 | const migration = new Migration(options); 108 | 109 | const reporter = getReporter(options.reporter, { 110 | cwd: options.cwd, 111 | }); 112 | 113 | reporter(migration); 114 | 115 | return migration; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/code-migrate/src/VirtualFileSystem.ts: -------------------------------------------------------------------------------- 1 | import fs, { BigIntOptions, Dirent, PathLike, Stats } from 'fs-extra'; 2 | import pathModule from 'path'; 3 | import { FileSystemAdapter } from 'fast-glob'; 4 | import { isUndefined } from 'lodash'; 5 | 6 | type FileInformation = { 7 | data?: string; 8 | stats?: Stats; 9 | action: 'remove' | 'write' | 'none'; 10 | }; 11 | 12 | export class VirtualFileSystem implements FileSystemAdapter { 13 | cwd: string; 14 | map: Map; 15 | 16 | constructor({ cwd }: { cwd: string }) { 17 | this.cwd = cwd; 18 | this.map = new Map(); 19 | } 20 | 21 | private getFile(path: string): FileInformation { 22 | if (!this.map.has(path)) { 23 | this.map.set(path, { action: 'none' }); 24 | } 25 | 26 | return this.map.get(path)!; 27 | } 28 | 29 | // @ts-ignore 30 | statSync(path: PathLike, options: BigIntOptions): Stats { 31 | if (options) { 32 | throw new Error('need to implenent bigIntOptions'); 33 | } 34 | 35 | path = path.toString(); 36 | 37 | if (!this.map.has(path)) { 38 | this.map.set(path, { action: 'none' }); 39 | } 40 | 41 | const file = this.getFile(path)!; 42 | 43 | if (!file.stats) { 44 | const stats = fs.statSync(path); 45 | this.map.set(path, { ...file, stats }); 46 | } 47 | 48 | return this.getFile(path).stats!; 49 | } 50 | 51 | // @ts-expect-error - options type is too big 52 | readdirSync(path: PathLike, options: any): Array { 53 | const files = fs.readdirSync(path, options); 54 | 55 | const writtenFilePaths = this.getWrittenFilePaths() 56 | .filter((p) => pathModule.dirname(p) === path) 57 | .map((p) => new DirentLike(p)); 58 | 59 | const removedFilePaths = this.getRemovedFilePaths().filter( 60 | (p) => pathModule.dirname(p) === path 61 | ); 62 | 63 | const allFilePaths = [...files, ...writtenFilePaths]; 64 | 65 | // @ts-expect-error 66 | const all = allFilePaths.filter((p) => !removedFilePaths.includes(p.name)); 67 | // @ts-expect-error 68 | return all; 69 | } 70 | 71 | getWrittenFilePaths() { 72 | const written: Array = []; 73 | 74 | this.map.forEach((fileInformation, path) => { 75 | if (fileInformation.action === 'write') { 76 | written.push(path); 77 | } 78 | }); 79 | 80 | return written; 81 | } 82 | 83 | getRemovedFilePaths() { 84 | const removed: Array = []; 85 | 86 | this.map.forEach((fileInformation, path) => { 87 | if (fileInformation.action === 'remove') { 88 | removed.push(path); 89 | } 90 | }); 91 | 92 | return removed; 93 | } 94 | 95 | writeFileSync(path: string, data: string): void { 96 | const file = this.getFile(path); 97 | file.data = data; 98 | file.action = 'write'; 99 | // @ts-expect-error 100 | file.stats = new StatsLike(); 101 | } 102 | 103 | removeSync(path: string): void { 104 | const file = this.getFile(path); 105 | file.action = 'remove'; 106 | } 107 | 108 | // @ts-expect-error - options type is too big 109 | lstatSync(path: PathLike): Stats { 110 | path = path.toString(); 111 | 112 | if (!this.map.has(path)) { 113 | this.map.set(path, { action: 'none' }); 114 | } 115 | 116 | const file = this.getFile(path)!; 117 | 118 | if (!file.stats) { 119 | const stats = fs.lstatSync(path); 120 | this.map.set(path, { ...file, stats }); 121 | } 122 | 123 | return this.getFile(path).stats!; 124 | } 125 | 126 | readFileSync(path: string): string { 127 | const file = this.getFile(path); 128 | 129 | if (file.action === 'remove') { 130 | // We'll might need to deal with that later, 131 | // because globby can read file that was deleted 132 | throw new Error('cannot read a file which is removed'); 133 | } 134 | 135 | if (isUndefined(file.data)) { 136 | const data = fs.readFileSync(path, 'utf-8'); 137 | this.map.set(path, { ...file, data }); 138 | } 139 | 140 | return this.getFile(path).data!; 141 | } 142 | 143 | renameSync(oldPath: string, newPath: string): void { 144 | const oldFile = this.getFile(oldPath); 145 | const oldFileData = this.readFileSync(oldPath); 146 | const oldFileStats = this.lstatSync(oldPath); 147 | oldFile.action = 'remove'; 148 | 149 | const newFile = this.getFile(newPath); 150 | newFile.data = oldFileData; 151 | newFile.stats = oldFileStats; 152 | newFile.action = 'write'; 153 | } 154 | 155 | existsSync(path: string): boolean { 156 | if (this.map.has(path)) { 157 | const file = this.getFile(path); 158 | if (file.data && file.action !== 'remove') return true; 159 | return false; 160 | } else { 161 | return fs.existsSync(path); 162 | } 163 | } 164 | 165 | ensureFileSync(path: string): void { 166 | if (this.existsSync(path)) { 167 | return; 168 | } 169 | 170 | // File doesn't exist, create an empty one 171 | const file = this.getFile(path); 172 | file.data = ''; 173 | // @ts-expect-error 174 | file.stats = new StatsLike(); 175 | file.action = 'write'; 176 | } 177 | 178 | writeChangesToDisc() { 179 | this.map.forEach((fileInformation, path) => { 180 | if (fileInformation.action === 'write') { 181 | fs.writeFileSync(path, fileInformation.data!); 182 | } else if (fileInformation.action === 'remove') { 183 | fs.removeSync(path); 184 | } 185 | }); 186 | } 187 | 188 | fileSystemAdapterMethods = { 189 | readdirSync: this.readdirSync.bind(this), 190 | lstatSync: this.lstatSync.bind(this), 191 | statSync: this.statSync.bind(this), 192 | }; 193 | } 194 | 195 | class DirentLike implements Dirent { 196 | name: string; 197 | constructor(name: string) { 198 | this.name = name; 199 | } 200 | isFile = (): boolean => { 201 | return false; 202 | }; 203 | isDirectory = (): boolean => { 204 | return true; 205 | }; 206 | isBlockDevice = (): boolean => { 207 | return false; 208 | }; 209 | isCharacterDevice = (): boolean => { 210 | return false; 211 | }; 212 | isSymbolicLink = (): boolean => { 213 | return false; 214 | }; 215 | isFIFO = (): boolean => { 216 | return false; 217 | }; 218 | isSocket = (): boolean => { 219 | return false; 220 | }; 221 | } 222 | 223 | // @ts-expect-error we only need the methods 224 | class StatsLike implements Stats { 225 | isFile = (): boolean => { 226 | return true; 227 | }; 228 | isDirectory = (): boolean => { 229 | return false; 230 | }; 231 | isBlockDevice = (): boolean => { 232 | return false; 233 | }; 234 | isCharacterDevice = (): boolean => { 235 | return false; 236 | }; 237 | isSymbolicLink = (): boolean => { 238 | return false; 239 | }; 240 | isFIFO = (): boolean => { 241 | return false; 242 | }; 243 | isSocket = (): boolean => { 244 | return false; 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /packages/code-migrate/src/cli/code-migrate-cli.ts: -------------------------------------------------------------------------------- 1 | import { createCli } from './createCli'; 2 | 3 | createCli({ 4 | binName: 'code-migrate', 5 | version: require('../../package.json').version, 6 | }); 7 | -------------------------------------------------------------------------------- /packages/code-migrate/src/cli/createCli.ts: -------------------------------------------------------------------------------- 1 | import arg from 'arg'; 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import fs from 'fs-extra'; 5 | import { runMigration } from '../runMigration'; 6 | 7 | /** 8 | * 9 | * @param options.binName the name of the bin file, as presented in the --help output 10 | * @param options.migrationFile absolute path to the migrationFile 11 | * @param options.version path to the verison of the CLI (package.json's version) 12 | * @param options.subCommands create sub commands for multiple migrations on the same application 13 | */ 14 | export const createCli = async ({ 15 | binName, 16 | migrationFile: customMigrationFile, 17 | version, 18 | subCommands, 19 | }: { 20 | binName: string; 21 | migrationFile?: string; 22 | version: string; 23 | subCommands?: Record; 24 | }) => { 25 | process.on('unhandledRejection', (error) => { 26 | throw error; 27 | }); 28 | 29 | const args = arg( 30 | { 31 | // Types 32 | '--version': Boolean, 33 | '--help': Boolean, 34 | '--verbose': Boolean, 35 | '--cwd': String, 36 | '--dry': Boolean, 37 | '--yes': Boolean, 38 | '--reportFile': String, 39 | '--reporter': String, 40 | 41 | // Aliases 42 | '-v': '--version', 43 | '-h': '--help', 44 | '-d': '--dry', 45 | '-y': '--yes', 46 | }, 47 | { 48 | permissive: false, 49 | } 50 | ); 51 | 52 | if (args['--version']) { 53 | console.log(version); 54 | process.exit(0); 55 | } 56 | 57 | const subCommand = args._[0]; 58 | 59 | let migrationFile: string; 60 | 61 | if (subCommand && subCommands) { 62 | if (subCommand in subCommands) { 63 | migrationFile = subCommands[subCommand].migrationFile; 64 | } else { 65 | console.log(chalk.red`unknown command ${chalk.bold(subCommand)}`); 66 | console.log(chalk.red`Please use one of the following sub commands:`); 67 | 68 | Object.keys(subCommands).forEach((subCommand) => { 69 | console.log(chalk.red` > ${subCommand}`); 70 | }); 71 | 72 | process.exit(1); 73 | } 74 | } else { 75 | migrationFile = customMigrationFile || args._[0]; 76 | } 77 | 78 | let helpUsage = binName; 79 | 80 | if (subCommands) { 81 | const subCommandsString = Object.keys(subCommands).join('|'); 82 | if (migrationFile) { 83 | helpUsage += ` [${subCommandsString}]`; 84 | } else { 85 | helpUsage += ` <${subCommandsString}>`; 86 | } 87 | } else if (!migrationFile) { 88 | helpUsage += ' '; 89 | } 90 | 91 | if (args['--help']) { 92 | console.log(` 93 | Usage 94 | $ ${helpUsage} 95 | 96 | Options 97 | --version, -v Version number 98 | --help, -h Displays this message 99 | --dry, -d Dry-run mode, does not modify files 100 | --yes, -y Skip all confirmation prompts. Useful in CI to automatically answer the confirmation prompt 101 | --cwd Runs the migration on this directory [defaults to process.cwd()] 102 | --reporter Choose a reporter ("default"/"quiet"/"markdown"/"path/to/custom/reporter") 103 | --reportFile Create a markdown report and output it to a file [for example "report.md"] 104 | `); 105 | 106 | process.exit(0); 107 | } 108 | 109 | let migrationFileAbsolutePath; 110 | 111 | if (!path.isAbsolute(migrationFile)) { 112 | migrationFileAbsolutePath = path.join(process.cwd(), migrationFile); 113 | } else { 114 | migrationFileAbsolutePath = migrationFile; 115 | } 116 | 117 | if (!fs.existsSync(migrationFileAbsolutePath)) { 118 | console.error( 119 | chalk.red( 120 | `couldn't find a migration file on "${migrationFileAbsolutePath}"` 121 | ) 122 | ); 123 | 124 | process.exit(1); 125 | } 126 | 127 | const customCwd = args['--cwd']; 128 | const cwdAbsolutePath = customCwd && path.join(process.cwd(), customCwd); 129 | 130 | if (cwdAbsolutePath && !fs.existsSync(cwdAbsolutePath)) { 131 | console.error(chalk.red(`passed cwd "${cwdAbsolutePath}" does not exists`)); 132 | process.exit(1); 133 | } 134 | 135 | const cwd = cwdAbsolutePath || process.cwd(); 136 | 137 | await runMigration({ 138 | cwd, 139 | migrationFilePath: migrationFileAbsolutePath, 140 | dry: !!args['--dry'], 141 | yes: !!args['--yes'], 142 | reportFile: args['--reportFile'], 143 | reporter: args['--reporter'], 144 | }); 145 | }; 146 | -------------------------------------------------------------------------------- /packages/code-migrate/src/events/MigrationEmitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import TypedEmitter from 'typed-emitter'; 3 | import type { File } from '../File'; 4 | import type { Migration } from '../Migration'; 5 | import type { Task, TaskError, TaskResult } from '../types'; 6 | 7 | interface MigrationEvents { 8 | ['task-start']: ({ file, task }: { file: File; task: Task }) => void; 9 | ['task-fail']: (taskError: TaskError) => void; 10 | ['task-success']: (taskResult: TaskResult) => void; 11 | ['create-success-abort']: ({ task }: { task: Task }) => void; 12 | ['create-success-override']: ({ 13 | originalFile, 14 | newFile, 15 | task, 16 | }: { 17 | originalFile: File; 18 | newFile: File; 19 | task: Task; 20 | }) => void; 21 | ['task-noop']: ({ file, task }: { file: File; task: Task }) => void; 22 | ['migration-start']: ({ migration }: { migration: Migration }) => void; 23 | ['migration-after-run']: ({ 24 | migration, 25 | options, 26 | }: { 27 | migration: Migration; 28 | options: { dry: boolean; reportFile: string | undefined }; 29 | }) => void; 30 | ['migration-after-write']: () => void; 31 | ['migration-after-prompt-aborted']: () => void; 32 | ['migration-after-prompt-confirmed']: () => void; 33 | ['migration-before-prompt']: () => void; 34 | } 35 | 36 | export class MigrationEmitter extends (EventEmitter as new () => TypedEmitter) { 37 | migration: Migration; 38 | 39 | constructor(migration: Migration) { 40 | super(); 41 | this.migration = migration; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/code-migrate/src/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MigrationEmitter'; 2 | -------------------------------------------------------------------------------- /packages/code-migrate/src/hooks/afterHook.ts: -------------------------------------------------------------------------------- 1 | export type AfterHookFn = () => void; 2 | -------------------------------------------------------------------------------- /packages/code-migrate/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './afterHook'; 2 | -------------------------------------------------------------------------------- /packages/code-migrate/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './migrate'; 2 | export * from './runMigration'; 3 | export * from './cli/createCli'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /packages/code-migrate/src/loadUserMigrationFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import importFresh from 'import-fresh'; 3 | import { Migration } from './Migration'; 4 | import type { Migrate } from './migrate'; 5 | 6 | /** 7 | * 8 | * @param migration migration instance 9 | * @param migrationFile An absolute path to the users migration file 10 | */ 11 | export const loadUserMigrationFile = async ( 12 | migration: Migration, 13 | migrationFile: string 14 | ): Promise => { 15 | return new Promise((resolve) => { 16 | // Load user's migration file 17 | const migrate: Migrate = async (title, fn) => { 18 | migration.title = title; 19 | migration.events.emit('migration-start', { migration }); 20 | 21 | await fn(migration.registerMethods, { 22 | ...migration.options, 23 | fs: migration.fs, 24 | }); 25 | 26 | resolve(); 27 | }; 28 | 29 | // @ts-expect-error not sure how to type this 30 | globalThis.migrate = migrate; 31 | 32 | if (migrationFile.endsWith('.ts')) { 33 | require('ts-node').register({ 34 | dir: path.dirname(migrationFile), 35 | transpileOnly: true, 36 | ignore: [], 37 | }); 38 | } 39 | 40 | if (typeof jest !== 'undefined') { 41 | jest.doMock('code-migrate', () => { 42 | return { 43 | __esModule: true, 44 | migrate, 45 | }; 46 | }); 47 | } else { 48 | require('mock-require')('code-migrate', { 49 | migrate, 50 | }); 51 | } 52 | 53 | importFresh(migrationFile); 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/code-migrate/src/migrate.ts: -------------------------------------------------------------------------------- 1 | import { RegisterMethods } from './Migration'; 2 | import { Options } from './types'; 3 | import { VirtualFileSystem } from './VirtualFileSystem'; 4 | 5 | type OptionalPromise = T | Promise; 6 | 7 | // The global migrate function which is used 8 | // by the user in order to register migration tasks 9 | export type Migrate = ( 10 | title: string, 11 | fn: ( 12 | RegisterTasks: RegisterMethods, 13 | options: Options & { fs: VirtualFileSystem } 14 | ) => OptionalPromise 15 | ) => void; 16 | 17 | /** 18 | * @param title The title of the migration 19 | * @param fn callback function that accepts the tasks to register 20 | * 21 | */ 22 | export const migrate: Migrate = () => { 23 | throw new Error( 24 | `Do not use "migrate" outside of the code-migrate environment. 25 | Please try the following command: 26 | 27 | npx code-migrate \n` 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/createMarkdownReport.ts: -------------------------------------------------------------------------------- 1 | import { TaskError, TaskResult } from '../types'; 2 | import { groupBy, isEmpty } from 'lodash'; 3 | import { Migration } from '../Migration'; 4 | 5 | export const formatSingleTaskResult = (taskResult: TaskResult) => { 6 | switch (taskResult.type) { 7 | case 'transform': { 8 | return taskResult.newFile.fileName; 9 | } 10 | 11 | case 'create': { 12 | return taskResult.newFile.fileName; 13 | } 14 | 15 | case 'remove': { 16 | return taskResult.file.fileName; 17 | } 18 | 19 | case 'rename': { 20 | const { originalFile, newFile } = taskResult; 21 | return `${originalFile.fileName} -> ${newFile.fileName}`; 22 | } 23 | 24 | default: { 25 | // @ts-expect-error ts thinks that task is "never" 26 | throw new Error(`unknown taskResult type "${taskResult.type}"`); 27 | } 28 | } 29 | }; 30 | 31 | export const formatSingleTaskError = (taskError: TaskError) => { 32 | return ( 33 | `**ERROR** \`${taskError.file?.fileName}\`\n` + 34 | '```\n' + 35 | taskError.error.stack + 36 | '\n```' 37 | ); 38 | }; 39 | 40 | export const createMarkdownReport = (migration: Migration) => { 41 | const output = []; 42 | 43 | output.push(`## ${migration.title} 44 | > ${migration.options.cwd}`); 45 | 46 | for (const [taskTitle, taskResults] of Object.entries( 47 | groupBy(migration.results, 'task.title') 48 | )) { 49 | output.push( 50 | `#### ${taskTitle}` + 51 | '\n' + 52 | '```\n' + 53 | taskResults.map(formatSingleTaskResult).join('\n') + 54 | '\n```' 55 | ); 56 | } 57 | 58 | if (migration.errors.length > 0) { 59 | output.push('### ⚠️ ' + `The following migration tasks were failed`); 60 | 61 | for (const [taskTitle, taskErrors] of Object.entries( 62 | groupBy(migration.errors, 'task.title') 63 | )) { 64 | output.push( 65 | `#### ${taskTitle}` + 66 | '\n' + 67 | taskErrors.map(formatSingleTaskError).join('\n') 68 | ); 69 | } 70 | } 71 | 72 | if (isEmpty(migration.results)) { 73 | output.push('**🤷‍ No changes have been made**'); 74 | } 75 | 76 | return output.join('\n\n'); 77 | }; 78 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/createReport.ts: -------------------------------------------------------------------------------- 1 | import { red, blue, underline, bold, supportsColor, reset } from 'chalk'; 2 | import { TaskError, TaskResult } from '../types'; 3 | import { groupBy, isEmpty } from 'lodash'; 4 | import { Migration } from '../Migration'; 5 | 6 | const ERROR_TEXT = 'ERROR'; 7 | const READY_TEXT = 'READY'; 8 | 9 | const ERROR = supportsColor 10 | ? reset.inverse.bold.red(` ${ERROR_TEXT} `) 11 | : ERROR_TEXT; 12 | 13 | const READY = supportsColor 14 | ? reset.inverse.bold.green(` ${READY_TEXT} `) 15 | : READY_TEXT; 16 | 17 | export const formatSingleTaskResult = (taskResult: TaskResult) => { 18 | switch (taskResult.type) { 19 | case 'transform': { 20 | return `${READY} ${taskResult.newFile.fileName}`; 21 | } 22 | 23 | case 'create': { 24 | return `${READY} ${taskResult.newFile.fileName}`; 25 | } 26 | 27 | case 'remove': { 28 | return `${READY} ${taskResult.file.fileName}`; 29 | } 30 | 31 | case 'rename': { 32 | const { originalFile, newFile } = taskResult; 33 | return `${READY} ${originalFile.fileName} -> ${newFile.fileName}`; 34 | } 35 | 36 | default: { 37 | // @ts-expect-error ts thinks that task is "never" 38 | throw new Error(`unknown taskResult type "${taskResult.type}"`); 39 | } 40 | } 41 | }; 42 | 43 | export const formatSingleTaskError = (taskError: TaskError) => { 44 | return `${ERROR} ${taskError.file?.fileName} 45 | 46 | ${taskError.error.stack}`; 47 | }; 48 | 49 | export const createReport = (migration: Migration) => { 50 | const output = []; 51 | 52 | for (const [taskTitle, taskResults] of Object.entries( 53 | groupBy(migration.results, 'task.title') 54 | )) { 55 | output.push( 56 | underline(bold(taskTitle)) + 57 | '\n' + 58 | taskResults.map(formatSingleTaskResult).join('\n') 59 | ); 60 | } 61 | 62 | if (migration.errors.length > 0) { 63 | output.push( 64 | '⚠️ ' + 65 | red( 66 | `The following migration tasks were failed, but you can still migrate the rest` + 67 | ' ⚠️' 68 | ) 69 | ); 70 | 71 | for (const [taskTitle, taskErrors] of Object.entries( 72 | groupBy(migration.errors, 'task.title') 73 | )) { 74 | output.push( 75 | underline(bold(taskTitle)) + 76 | '\n' + 77 | taskErrors.map(formatSingleTaskError).join('\n') 78 | ); 79 | } 80 | } 81 | 82 | if (isEmpty(migration.results)) { 83 | output.push(blue('🤷‍ No changes have been made')); 84 | } 85 | 86 | return output.join('\n\n'); 87 | }; 88 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/default-reporter.ts: -------------------------------------------------------------------------------- 1 | import { bold, cyan, green, red } from 'chalk'; 2 | import { createReport } from './createReport'; 3 | import { Migration } from '../Migration'; 4 | import type { Reporter } from './'; 5 | 6 | export const defaultReporter: Reporter = (migration: Migration): void => { 7 | const { events } = migration; 8 | 9 | events.on('migration-start', ({ migration }) => { 10 | console.log(`${cyan('🏃‍ Running:')} ${migration.title}`); 11 | console.log(`${cyan('📁 On:')} ${migration.options.cwd}`); 12 | }); 13 | 14 | events.on('migration-after-run', ({ migration, options: { dry } }) => { 15 | if (dry) { 16 | console.log(); 17 | console.log(bold('dry-run mode, no files will be modified')); 18 | } 19 | 20 | console.log(); 21 | console.log(createReport(migration)); 22 | }); 23 | 24 | events.on('migration-before-prompt', () => { 25 | // space the prompt 26 | console.log(); 27 | }); 28 | 29 | events.on('migration-after-write', () => { 30 | console.log(); 31 | console.log(green('The migration has been completed successfully 🎉')); 32 | }); 33 | 34 | events.on('migration-after-prompt-aborted', () => { 35 | console.log(); 36 | console.log(red('Migration aborted')); 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/getReporter.ts: -------------------------------------------------------------------------------- 1 | import type { Reporter } from './'; 2 | import { defaultReporter, quietReporter, markdownReporter } from './'; 3 | import path from 'path'; 4 | 5 | const reporters: Record = { 6 | default: defaultReporter, 7 | quiet: quietReporter, 8 | markdown: markdownReporter, 9 | }; 10 | 11 | export const getReporter = ( 12 | reporterName: string | undefined = 'default', 13 | { cwd }: { cwd: string } 14 | ) => { 15 | if (reporterName in reporters) { 16 | return reporters[reporterName]; 17 | } else { 18 | const reporterPath = path.isAbsolute(reporterName) 19 | ? reporterName 20 | : path.resolve(cwd, reporterName); 21 | 22 | return require(reporterPath); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/index.ts: -------------------------------------------------------------------------------- 1 | import { Migration } from '../Migration'; 2 | 3 | export * from './default-reporter'; 4 | export * from './quiet-reporter'; 5 | export * from './markdown-reporter'; 6 | export * from './getReporter'; 7 | 8 | export type Reporter = (migration: Migration) => void; 9 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/markdown-reporter.ts: -------------------------------------------------------------------------------- 1 | import { bold, red } from 'chalk'; 2 | import { Migration } from '../Migration'; 3 | import type { Reporter } from './'; 4 | import { createMarkdownReport } from './createMarkdownReport'; 5 | 6 | export const markdownReporter: Reporter = (migration: Migration): void => { 7 | const { events } = migration; 8 | 9 | events.on('migration-after-run', ({ migration, options: { dry } }) => { 10 | if (dry) { 11 | console.log(); 12 | console.log(bold('dry-run mode, no files will be modified')); 13 | } 14 | 15 | console.log(); 16 | console.log(createMarkdownReport(migration)); 17 | }); 18 | 19 | events.on('migration-before-prompt', () => { 20 | // space the prompt 21 | console.log(); 22 | }); 23 | 24 | events.on('migration-after-prompt-aborted', () => { 25 | console.log(); 26 | console.log(red('Migration aborted')); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/quiet-reporter.ts: -------------------------------------------------------------------------------- 1 | import { red } from 'chalk'; 2 | import { Migration } from '../Migration'; 3 | import type { Reporter } from './'; 4 | 5 | export const quietReporter: Reporter = ({ events }: Migration): void => { 6 | events.on('task-fail', ({ error, task, file }) => { 7 | console.log(task.title); 8 | console.error(`${red('X')} ${task.type} failed: ${file?.path}`); 9 | console.error(); 10 | console.error(error); 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/code-migrate/src/reporters/writeReportFile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | import { Migration } from '../Migration'; 4 | import { createMarkdownReport } from './createMarkdownReport'; 5 | import { magenta } from 'chalk'; 6 | 7 | export const writeReportFile = (migration: Migration, reportFile: string) => { 8 | const absoluteReportFile = path.isAbsolute(reportFile) 9 | ? reportFile 10 | : path.join(process.cwd(), reportFile); 11 | 12 | fs.outputFileSync(absoluteReportFile, createMarkdownReport(migration)); 13 | console.log(); 14 | console.log(`Markdown report was written to ${magenta(absoluteReportFile)}`); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/code-migrate/src/runMigration.ts: -------------------------------------------------------------------------------- 1 | import prompts from 'prompts'; 2 | import { Migration } from './Migration'; 3 | import { loadUserMigrationFile } from './loadUserMigrationFile'; 4 | import { isEmpty } from 'lodash'; 5 | import { writeReportFile } from './reporters/writeReportFile'; 6 | 7 | type RunMigration = ({ 8 | cwd, 9 | migrationFilePath, 10 | dry, 11 | yes, 12 | reportFile, 13 | }: { 14 | cwd: string; 15 | migrationFilePath: string; 16 | dry: boolean; 17 | yes: boolean; 18 | reportFile: string | undefined; 19 | reporter: string | undefined; 20 | }) => Promise; 21 | 22 | /** 23 | * 24 | * @param options.cwd The directory of the project which the migration runs on 25 | * @param options.migrationFilePath path to the migration file 26 | * @param options.dry Dry run mode 27 | * @param options.yes Do not prompt the user with confirmation 28 | * Run a migration 29 | * @param options.reporter Use a custom reporter ("default"/"quiet"/"markdown") 30 | * @param options.reportFile Create a markdown report and output it to a file 31 | * 32 | */ 33 | export const runMigration: RunMigration = async ({ 34 | cwd, 35 | migrationFilePath, 36 | dry, 37 | yes, 38 | reporter, 39 | reportFile, 40 | }) => { 41 | const migration = Migration.init({ cwd, reporter }); 42 | const { events } = migration; 43 | 44 | await loadUserMigrationFile(migration, migrationFilePath); 45 | 46 | events.emit('migration-after-run', { 47 | migration, 48 | options: { dry, reportFile }, 49 | }); 50 | 51 | if (isEmpty(migration.results)) { 52 | process.exit(0); 53 | } 54 | 55 | if (!yes && !dry) { 56 | events.emit('migration-before-prompt'); 57 | const response = await prompts({ 58 | type: 'confirm', 59 | name: 'value', 60 | message: `Press 'y' to execute the migration on the above files`, 61 | initial: true, 62 | }); 63 | 64 | if (!response.value) { 65 | events.emit('migration-after-prompt-aborted'); 66 | process.exit(1); 67 | } 68 | 69 | events.emit('migration-after-prompt-confirmed'); 70 | } 71 | 72 | if (!dry) { 73 | migration.write(); 74 | events.emit('migration-after-write'); 75 | } 76 | 77 | if (reportFile) { 78 | writeReportFile(migration, reportFile); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/createTask.ts: -------------------------------------------------------------------------------- 1 | import { File, getFiles } from '../File'; 2 | import { RunTask } from './runTask'; 3 | import { TaskResult, Pattern, TaskError } from '../types'; 4 | import { isNull, isUndefined } from 'lodash'; 5 | 6 | export type CreateReturnValue = { source?: string; fileName?: string } | null; 7 | 8 | export type EmptyCreateFn = () => CreateReturnValue; 9 | 10 | export type CreateFn = ({ 11 | fileName, 12 | source, 13 | }: { 14 | fileName: string; 15 | source: string; 16 | }) => CreateReturnValue; 17 | 18 | export type CreateTask = { 19 | type: 'create'; 20 | title: string; 21 | pattern?: Pattern; 22 | fn: CreateFn | EmptyCreateFn; 23 | }; 24 | 25 | export const runCreateTask: RunTask = (task, migration) => { 26 | let taskResults: Array = []; 27 | let taskErrors: Array = []; 28 | 29 | let createdFiles: CreateReturnValue[] = []; 30 | 31 | if (!task.pattern) { 32 | try { 33 | // @ts-expect-error 34 | const createdFile = task.fn(); 35 | createdFiles.push(createdFile); 36 | } catch (error) { 37 | const taskError: TaskError = { 38 | type: task.type, 39 | task, 40 | error, 41 | }; 42 | 43 | migration.events.emit('task-fail', taskError); 44 | 45 | taskErrors.push(taskError); 46 | } 47 | } else { 48 | const files = getFiles(migration.options.cwd, task.pattern, migration); 49 | 50 | for (let file of files) { 51 | migration.events.emit('task-start', { file, task }); 52 | 53 | try { 54 | const createdFile = task.fn({ 55 | fileName: file.fileName, 56 | source: file.source, 57 | }); 58 | 59 | createdFiles.push(createdFile); 60 | } catch (error) { 61 | const taskError: TaskError = { 62 | type: task.type, 63 | task, 64 | error, 65 | }; 66 | 67 | migration.events.emit('task-fail', taskError); 68 | 69 | taskErrors.push(taskError); 70 | } 71 | } 72 | } 73 | 74 | for (let createdFile of createdFiles) { 75 | if (isNull(createdFile)) { 76 | migration.events.emit('create-success-abort', { task }); 77 | continue; 78 | } 79 | 80 | if (!createdFile.fileName) { 81 | throw new Error( 82 | 'the return value of a create function needs to contain an object with { fileName: }' 83 | ); 84 | } 85 | 86 | if (isUndefined(createdFile.source)) { 87 | throw new Error( 88 | 'the return value of a create function needs to contain an object with { source: }' 89 | ); 90 | } 91 | 92 | const originalFile = new File({ 93 | cwd: migration.options.cwd, 94 | fileName: createdFile.fileName, 95 | migration, 96 | }); 97 | 98 | const newFile = new File({ 99 | cwd: migration.options.cwd, 100 | fileName: createdFile.fileName, 101 | source: createdFile.source, 102 | migration, 103 | }); 104 | 105 | const taskResult: TaskResult = { 106 | newFile, 107 | type: task.type, 108 | task, 109 | }; 110 | 111 | if (originalFile.exists) { 112 | // Add the original File to mark that the file exists 113 | 114 | taskResult.originalFile = originalFile; 115 | 116 | // @ts-expect-error - we know that originalFile exists here 117 | migration.events.emit('create-success-override', taskResult); 118 | } 119 | 120 | migration.events.emit('task-success', taskResult); 121 | 122 | migration.fs.writeFileSync(newFile.path, newFile.source); 123 | 124 | taskResults.push(taskResult); 125 | } 126 | 127 | return { taskErrors, taskResults }; 128 | }; 129 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './renameTask'; 2 | export * from './transformTask'; 3 | export * from './removeTask'; 4 | export * from './createTask'; 5 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/removeTask.ts: -------------------------------------------------------------------------------- 1 | import { getFiles } from '../File'; 2 | import { RunTask } from './runTask'; 3 | import { TaskResult, Pattern } from '../types'; 4 | 5 | export type RemoveReturnValue = { fileName?: string }; 6 | 7 | export type RemoveFn = ({ 8 | fileName, 9 | source, 10 | }: { 11 | fileName: string; 12 | source: string; 13 | }) => void; 14 | 15 | export type RemoveTask = { 16 | type: 'remove'; 17 | title: string; 18 | pattern: Pattern; 19 | fn?: RemoveFn; 20 | }; 21 | 22 | export const runRemoveTask: RunTask = (task, migration) => { 23 | const files = getFiles(migration.options.cwd, task.pattern, migration); 24 | 25 | const taskResults: Array = []; 26 | 27 | for (let file of files) { 28 | migration.events.emit('task-start', { file, task }); 29 | 30 | if (!file.exists) { 31 | migration.events.emit('task-noop', { 32 | task, 33 | file, 34 | }); 35 | 36 | continue; 37 | } 38 | 39 | if (task.fn) task.fn(file); 40 | 41 | migration.events.emit('task-success', { 42 | task, 43 | file, 44 | type: task.type, 45 | }); 46 | 47 | migration.fs.removeSync(file.path); 48 | const taskResult = { type: task.type, file, task }; 49 | taskResults.push(taskResult); 50 | } 51 | 52 | return { taskResults, taskErrors: [] }; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/renameTask.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | import { File, getFiles } from '../File'; 3 | import { RunTask } from './runTask'; 4 | import { TaskResult, Pattern, TaskError } from '../types'; 5 | 6 | export type RenameReturnValue = string; 7 | 8 | export type RenameFn = ({ 9 | fileName, 10 | }: { 11 | fileName: string; 12 | }) => RenameReturnValue; 13 | 14 | export type RenameTask = { 15 | type: 'rename'; 16 | title: string; 17 | pattern: Pattern; 18 | fn: RenameFn; 19 | }; 20 | 21 | export const runRenameTask: RunTask = (task, migration) => { 22 | const files = getFiles(migration.options.cwd, task.pattern, migration); 23 | 24 | let taskResults: Array = []; 25 | let taskErrors: Array = []; 26 | 27 | for (let file of files) { 28 | migration.events.emit('task-start', { file, task }); 29 | 30 | let renamedFile: RenameReturnValue; 31 | 32 | try { 33 | renamedFile = task.fn(file); 34 | } catch (error) { 35 | const taskError: TaskError = { 36 | type: task.type, 37 | file, 38 | error, 39 | task, 40 | }; 41 | 42 | migration.events.emit('task-fail', taskError); 43 | 44 | taskErrors.push(taskError); 45 | continue; 46 | } 47 | 48 | const newFile = new File({ 49 | cwd: migration.options.cwd, 50 | fileName: renamedFile || file.fileName, 51 | source: file.source, 52 | migration, 53 | }); 54 | 55 | const isRenamed = !isEqual(newFile.fileName, file.fileName); 56 | 57 | if (isRenamed) { 58 | const taskResult = { 59 | task, 60 | originalFile: file, 61 | newFile, 62 | type: task.type, 63 | }; 64 | 65 | migration.events.emit('task-success', taskResult); 66 | 67 | migration.fs.renameSync(file.path, newFile.path); 68 | 69 | taskResults.push(taskResult); 70 | continue; 71 | } 72 | 73 | migration.events.emit('task-noop', { 74 | task, 75 | file, 76 | }); 77 | } 78 | 79 | return { taskResults, taskErrors }; 80 | }; 81 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/runTask.ts: -------------------------------------------------------------------------------- 1 | import type { TaskResult, Task, TaskError } from '../types'; 2 | import { Migration } from '../Migration'; 3 | import { runTransformTask } from './transformTask'; 4 | import { runRenameTask } from './renameTask'; 5 | import { runCreateTask } from './createTask'; 6 | import { runRemoveTask } from './removeTask'; 7 | 8 | type RunTaskReturnValue = { 9 | taskResults: Array; 10 | taskErrors: Array; 11 | }; 12 | 13 | export function runSingleTask( 14 | task: Task, 15 | migration: Migration 16 | ): RunTaskReturnValue { 17 | let chosenRunTask: RunTask; 18 | 19 | switch (task.type) { 20 | case 'transform': { 21 | chosenRunTask = runTransformTask; 22 | break; 23 | } 24 | 25 | case 'create': { 26 | chosenRunTask = runCreateTask; 27 | break; 28 | } 29 | 30 | case 'remove': { 31 | chosenRunTask = runRemoveTask; 32 | break; 33 | } 34 | 35 | case 'rename': { 36 | chosenRunTask = runRenameTask; 37 | break; 38 | } 39 | 40 | default: { 41 | // @ts-expect-error ts thinks that task is "never" 42 | throw new Error(`unknown task type "${task.type}"`); 43 | } 44 | } 45 | 46 | return chosenRunTask(task, migration); 47 | } 48 | 49 | export type RunTask = ( 50 | task: T, 51 | migration: Migration 52 | ) => RunTaskReturnValue; 53 | -------------------------------------------------------------------------------- /packages/code-migrate/src/tasks/transformTask.ts: -------------------------------------------------------------------------------- 1 | import { isEqual, isObject } from 'lodash'; 2 | import { File, getFiles } from '../File'; 3 | import { RunTask } from './runTask'; 4 | import { TaskResult, TaskError, Pattern } from '../types'; 5 | import { strigifyJson } from '../utils'; 6 | 7 | type TransformReturnValue = string | Record; 8 | 9 | export type TransformFn = ({ 10 | fileName, 11 | source, 12 | abort, 13 | }: { 14 | fileName: string; 15 | source: string; 16 | abort: () => void; 17 | }) => TransformReturnValue; 18 | 19 | export type TransformTask = { 20 | type: 'transform'; 21 | title: string; 22 | pattern: Pattern; 23 | fn: TransformFn; 24 | }; 25 | 26 | export const runTransformTask: RunTask = (task, migration) => { 27 | const files = getFiles(migration.options.cwd, task.pattern, migration); 28 | let aborted = false; 29 | 30 | const abort = () => { 31 | aborted = true; 32 | }; 33 | 34 | let taskResults: Array = []; 35 | let taskErrors: Array = []; 36 | 37 | for (let file of files) { 38 | // We let the user control this callback 39 | // If it was call, we want to stop looping the files 40 | if (aborted) { 41 | break; 42 | } 43 | 44 | migration.events.emit('task-start', { file, task }); 45 | 46 | let transformedSource: TransformReturnValue; 47 | 48 | try { 49 | transformedSource = task.fn({ 50 | source: file.source, 51 | fileName: file.fileName, 52 | abort, 53 | }); 54 | } catch (error) { 55 | const taskError: TaskError = { 56 | file, 57 | type: task.type, 58 | task, 59 | error, 60 | }; 61 | 62 | migration.events.emit('task-fail', taskError); 63 | 64 | taskErrors.push(taskError); 65 | continue; 66 | } 67 | 68 | let source; 69 | 70 | if (isObject(transformedSource)) { 71 | source = strigifyJson(transformedSource); 72 | } else { 73 | source = transformedSource; 74 | } 75 | 76 | const newFile = new File({ 77 | cwd: migration.options.cwd, 78 | fileName: file.fileName, 79 | source, 80 | migration, 81 | }); 82 | 83 | const isRenamed = !isEqual(newFile.fileName, file.fileName); 84 | const isModified = isRenamed || !isEqual(newFile.source, file.source); 85 | 86 | if (isModified) { 87 | const taskResult = { 88 | originalFile: file, 89 | newFile, 90 | type: task.type, 91 | task, 92 | }; 93 | 94 | migration.events.emit('task-success', taskResult); 95 | 96 | taskResults.push(taskResult); 97 | continue; 98 | } 99 | 100 | migration.events.emit('task-noop', { 101 | task, 102 | file, 103 | }); 104 | } 105 | 106 | if (aborted) { 107 | return { taskErrors: [], taskResults: [] }; 108 | } 109 | 110 | taskResults.forEach((result) => { 111 | if (result.type !== 'transform') { 112 | throw new Error('wrong action type'); 113 | } 114 | 115 | migration.fs.removeSync(result.originalFile.path); 116 | migration.fs.writeFileSync(result.newFile.path, result.newFile.source); 117 | }); 118 | 119 | return { taskResults, taskErrors }; 120 | }; 121 | -------------------------------------------------------------------------------- /packages/code-migrate/src/testing/createTestkit.ts: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | import path from 'path'; 4 | import tempy from 'tempy'; 5 | import fs from 'fs-extra'; 6 | import globby from 'globby'; 7 | import expect from 'expect'; 8 | import execa from 'execa'; 9 | import { Migration } from '../Migration'; 10 | import { loadUserMigrationFile } from '../loadUserMigrationFile'; 11 | import chalk from 'chalk'; 12 | 13 | type MigrationFunction = ({ cwd }: { cwd: string }) => void | Promise; 14 | 15 | /** 16 | * @param options.migrationFile an absolute path to a migration file or a relative path 17 | * to the fixtures directory, defaults to migration.ts 18 | * @param options.command command that runs the migration. 19 | * e.g. ['node', './path/to/bin.js', '-y'] -> $ node ./path/to/bin.js -y 20 | * @param options.migrationFunction a function that performs the migration 21 | * 22 | */ 23 | export const createTestkit = ({ 24 | migrationFile, 25 | command, 26 | migrationFunction, 27 | }: { 28 | migrationFile: string; 29 | command?: string[]; 30 | migrationFunction?: MigrationFunction; 31 | }) => { 32 | return new MigrationTestkit({ migrationFile, command, migrationFunction }); 33 | }; 34 | 35 | export class MigrationTestkit { 36 | migrationFile: string; 37 | command?: string[]; 38 | migrationFunction?: MigrationFunction; 39 | 40 | constructor({ 41 | migrationFile, 42 | command, 43 | migrationFunction, 44 | }: { 45 | migrationFile: string; 46 | command?: string[]; 47 | migrationFunction?: MigrationFunction; 48 | }) { 49 | this.migrationFile = migrationFile; 50 | this.command = command; 51 | this.migrationFunction = migrationFunction; 52 | } 53 | 54 | /** 55 | * @param options.fixtures an absolute path to a fixtures directory 56 | * which contains \_\_before__ and \_\_after__ directories 57 | */ 58 | async run({ fixtures }: { fixtures: string }): Promise<{ cwd: string }> { 59 | if (!fixtures) { 60 | throw new Error('must provide "fixtures" path'); 61 | } 62 | 63 | if (!path.isAbsolute(fixtures)) { 64 | throw new Error(`fixtures path must be an absolute path`); 65 | } 66 | 67 | if (!fs.existsSync(fixtures)) { 68 | throw new Error(`fixture path ${fixtures} doesn't exist`); 69 | } 70 | 71 | const beforeDirectory = path.join(fixtures, '__before__'); 72 | const afterDirectory = path.join(fixtures, '__after__'); 73 | 74 | if (!fs.existsSync(beforeDirectory)) { 75 | throw new Error(`please create a "__before__" directory in ${fixtures}`); 76 | } 77 | 78 | if (!fs.existsSync(afterDirectory)) { 79 | throw new Error( 80 | `please create a "__after__" directory in ${afterDirectory}` 81 | ); 82 | } 83 | 84 | const workingDir = tempy.directory(); 85 | 86 | fs.copySync(beforeDirectory, workingDir); 87 | 88 | const command = this.command; 89 | 90 | if (command && Array.isArray(command) && command.length > 0) { 91 | try { 92 | execa.sync(command[0], command.slice(1), { 93 | cwd: workingDir, 94 | stdio: 'inherit', 95 | }); 96 | } catch (error) { 97 | console.error( 98 | chalk.red(`The following command failed with errors: 99 | ${command.join(' ')}`) 100 | ); 101 | 102 | // Look at the output of the command in order to understand the cause of the problem 103 | throw new Error(error); 104 | } 105 | } else if (this.migrationFunction) { 106 | await this.migrationFunction({ cwd: workingDir }); 107 | } else { 108 | let migrationFile = this.migrationFile; 109 | 110 | // join with fixtures directory in case of a relative path 111 | if (!path.isAbsolute(migrationFile)) { 112 | migrationFile = path.join(fixtures, migrationFile); 113 | } 114 | 115 | if (!fs.existsSync(fixtures)) { 116 | throw new Error(`migration file ${migrationFile} doesn't exist`); 117 | } 118 | 119 | const migration = Migration.init({ cwd: workingDir, reporter: 'quiet' }); 120 | await loadUserMigrationFile(migration, migrationFile); 121 | 122 | migration.write(); 123 | } 124 | 125 | const expectedFiles = globby.sync('**/*', { 126 | cwd: afterDirectory, 127 | gitignore: true, 128 | dot: true, 129 | ignore: ['**/node_modules/**'], 130 | }); 131 | 132 | const resultFiles = globby.sync('**/*', { 133 | cwd: workingDir, 134 | gitignore: true, 135 | dot: true, 136 | ignore: ['**/node_modules/**'], 137 | }); 138 | 139 | expectedFiles.forEach((fileName) => { 140 | try { 141 | expect(resultFiles).toContain(fileName); 142 | } catch (error) { 143 | throw new Error(` 144 | Migration file: ${this.migrationFile} 145 | 146 | ${error.toString()}`); 147 | } 148 | 149 | const expectedFilePath = path.join(afterDirectory, fileName); 150 | const expectedFileContents = fs.readFileSync(expectedFilePath, 'utf-8'); 151 | const resultFilePath = path.join(workingDir, fileName); 152 | const resultFileContents = fs.readFileSync(resultFilePath, 'utf-8'); 153 | 154 | try { 155 | expect(resultFileContents).toBe(expectedFileContents); 156 | } catch (error) { 157 | throw new Error(` 158 | Migration file: ${this.migrationFile} 159 | Expected file: ${expectedFilePath} 160 | Recieved file: ${resultFilePath} 161 | 162 | ${error.toString()}`); 163 | } 164 | }); 165 | 166 | return { 167 | cwd: workingDir, 168 | }; 169 | } 170 | 171 | /** 172 | * @param options.fixtures an absolute path to a fixtures directory 173 | * which contains \_\_before__ and \_\_after__ directories 174 | * @param options.title test title 175 | */ 176 | async test({ fixtures, title }: { fixtures: string; title?: string }) { 177 | const name = path.basename(fixtures); 178 | 179 | it(title ?? name, () => this.run({ fixtures })); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /packages/code-migrate/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { File } from './File'; 2 | import type { 3 | CreateFn, 4 | CreateTask, 5 | EmptyCreateFn, 6 | RemoveFn, 7 | RemoveTask, 8 | RenameFn, 9 | RenameTask, 10 | TransformFn, 11 | TransformTask, 12 | } from './tasks'; 13 | 14 | import type { AfterHookFn } from './hooks'; 15 | 16 | export type Pattern = string | string[]; 17 | 18 | export type Options = { cwd: string; reporter?: string }; 19 | 20 | export type TaskType = 'transform' | 'rename' | 'remove' | 'create'; 21 | 22 | export type Task = TransformTask | RenameTask | RemoveTask | CreateTask; 23 | 24 | export type RegisterTransformTask = ( 25 | title: string, 26 | pattern: Pattern, 27 | transformFn: TransformFn 28 | ) => void; 29 | 30 | export type RegisterRenameTask = ( 31 | title: string, 32 | pattern: Pattern, 33 | renameFn: RenameFn 34 | ) => void; 35 | 36 | export type RegisterRemoveTask = ( 37 | title: string, 38 | pattern: Pattern, 39 | removeFn?: RemoveFn 40 | ) => void; 41 | 42 | export type RegisterCreateTask = ( 43 | title: string, 44 | patternOrCreateFn: EmptyCreateFn | Pattern, 45 | createFn?: CreateFn 46 | ) => void; 47 | 48 | export type RegisterAfterHook = (afterHook: AfterHookFn) => void; 49 | 50 | export type TaskError = 51 | | { 52 | type: 'transform'; 53 | file: File; 54 | task: Task; 55 | error: Error; 56 | } 57 | | { 58 | type: 'rename'; 59 | file: File; 60 | task: Task; 61 | error: Error; 62 | } 63 | | { 64 | type: 'remove'; 65 | file: File; 66 | task: Task; 67 | error: Error; 68 | } 69 | | { 70 | type: 'create'; 71 | file?: File; 72 | task: Task; 73 | error: Error; 74 | }; 75 | 76 | export type TaskResult = 77 | | { 78 | type: 'transform'; 79 | originalFile: File; 80 | newFile: File; 81 | task: Task; 82 | } 83 | | { 84 | type: 'rename'; 85 | originalFile: File; 86 | newFile: File; 87 | task: Task; 88 | } 89 | | { 90 | type: 'remove'; 91 | file: File; 92 | task: Task; 93 | } 94 | | { 95 | type: 'create'; 96 | originalFile?: File; 97 | newFile: File; 98 | task: Task; 99 | }; 100 | -------------------------------------------------------------------------------- /packages/code-migrate/src/utils.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { isArray } from 'lodash'; 3 | import { Pattern } from './types'; 4 | 5 | export function isTruthy(x: T | undefined | null): x is T { 6 | return x !== undefined && x !== null; 7 | } 8 | 9 | export function isPattern(maybePattern: any): maybePattern is Pattern { 10 | return typeof maybePattern === 'string' || isArray(maybePattern); 11 | } 12 | 13 | export const strigifyJson = (object: Record) => 14 | JSON.stringify(object, null, 2).replace(/\n/g, os.EOL) + os.EOL; 15 | -------------------------------------------------------------------------------- /packages/code-migrate/testing/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../build/testing/createTestkit'; 2 | -------------------------------------------------------------------------------- /packages/code-migrate/testing/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../build/testing/createTestkit'); 2 | -------------------------------------------------------------------------------- /packages/code-migrate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // Needed for the IDE 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "rootDir": "./", 6 | "noEmit": true 7 | }, 8 | "include": ["./migrate.d.ts", "__tests__", "src"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/code-migrate/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "rootDir": "src", 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "exclude": [ 13 | "**/build/**/*" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------