├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── checks.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ └── cli.spec.ts ├── codecov.yml ├── commitlint.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── commands.ts ├── config.ts ├── index.ts └── utils │ ├── executeCommand.ts │ ├── fileExists.ts │ └── git │ ├── checkout.ts │ ├── cloneRepo.ts │ ├── commit.ts │ ├── hasChanges.ts │ ├── isAncestor.ts │ ├── latestHash.ts │ ├── remoteDefaultBranch.ts │ └── reset.ts ├── tsconfig.eslint.json ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb-base', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: './tsconfig.eslint.json', 11 | allowImportExportEverywhere: true, 12 | }, 13 | plugins: [ 14 | 'prefer-object-spread', 15 | '@typescript-eslint', 16 | ], 17 | env: { 18 | commonjs: true, 19 | node: true, 20 | }, 21 | settings: { 22 | 'import/resolver': { 23 | typescript: { 24 | project: './tsconfig.json', 25 | }, 26 | node: { 27 | extensions: ['.js', '.ts', '.json'], 28 | }, 29 | }, 30 | 'import/extensions': [ 31 | '.ts', 32 | ], 33 | }, 34 | rules: { 35 | /* 36 | * Typescript 37 | */ 38 | 'no-void': ['error', { allowAsStatement: true }], 39 | indent: 'off', 40 | '@typescript-eslint/indent': ['error', 2], 41 | 'no-shadow': 'off', 42 | '@typescript-eslint/no-shadow': ['error'], 43 | 'import/extensions': 'off', 44 | 'import/no-cycle': ['error', { maxDepth: 1 }], 45 | '@typescript-eslint/restrict-template-expressions': ['error', { 46 | allowNullish: true, 47 | }], 48 | '@typescript-eslint/no-unsafe-member-access': 'off', 49 | '@typescript-eslint/no-unsafe-return': 'off', 50 | '@typescript-eslint/no-unsafe-assignment': 'off', 51 | '@typescript-eslint/no-unsafe-call': 'off', 52 | '@typescript-eslint/explicit-module-boundary-types': 'off', 53 | '@typescript-eslint/unbound-method': 'off', 54 | '@typescript-eslint/no-use-before-define': ['error', { functions: false }], 55 | '@typescript-eslint/member-delimiter-style': ['error', { 56 | multiline: { 57 | delimiter: 'comma', 58 | requireLast: true, 59 | }, 60 | singleline: { 61 | delimiter: 'comma', 62 | requireLast: false, 63 | }, 64 | }], 65 | '@typescript-eslint/explicit-function-return-type': 'off', 66 | '@typescript-eslint/no-explicit-any': 'off', 67 | '@typescript-eslint/no-unused-vars': ['error', { 68 | vars: 'all', 69 | args: 'after-used', 70 | ignoreRestSiblings: true, 71 | }], 72 | /* 73 | * Airbnb 74 | */ 75 | // Overrides 76 | 'import/no-extraneous-dependencies': ['error', { 77 | devDependencies: [ 78 | '.eslintrc.js', 79 | '**/*.test.ts', 80 | '**/*.spec.ts', 81 | '__tests__/**/*.ts', 82 | '*.config.ts', 83 | '**/vite.config.ts', 84 | ], 85 | }], 86 | 'arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], 87 | 'func-names': 'error', // Changed from 'warn' to 'error'. 88 | 'import/no-absolute-path': 'off', // Turned off because we use absolute paths instead of '../'. 89 | 'implicit-arrow-linebreak': 'off', // Turned of because of bullshit 90 | 'no-alert': 'error', // Changed from 'warn' to 'error'. 91 | 'no-console': 'error', // Changed from 'warn' to 'error'. 92 | 'no-constant-condition': 'error', // Changed from 'warn' to 'error'. 93 | 'no-underscore-dangle': ['error', { // Make some exceptions for often used fields 94 | allow: [ 95 | '_id', 96 | '_aggregated', 97 | '_details', 98 | ], 99 | }], 100 | semi: ['error', 'never'], // Changed from 'always' to 'never', because we never use semicolons. 101 | // Additions 102 | 'no-warning-comments': 'warn', 103 | 'import/order': ['error', { 104 | groups: [ 105 | 'internal', 106 | 'builtin', 107 | 'external', 108 | 'parent', 109 | 'sibling', 110 | 'index', 111 | ], 112 | 'newlines-between': 'never', 113 | }], 114 | /* 115 | * Extentions 116 | */ 117 | 'no-use-before-define': ['error', { functions: false }], 118 | 'object-curly-newline': 'off', 119 | 'max-len': [ 120 | 2, 121 | { 122 | code: 100, 123 | ignoreComments: true, 124 | ignoreUrls: true, 125 | ignoreTemplateLiterals: true, 126 | ignorePattern: "mf\\s*\\(\\s*(['\"])(.*?)\\1\\s*,\\s*.*?\\s*,?\\s*(['\"])(.*?)\\3,?.*?\\)", 127 | }, 128 | ], 129 | 'prefer-object-spread/prefer-object-spread': 'error', 130 | 'object-shorthand': ['error', 'always'], 131 | }, 132 | } 133 | -------------------------------------------------------------------------------- /.github/workflows/checks.yaml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - development 8 | pull_request: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | preflight: 16 | if: ${{ github.event.pull_request.head.ref != 'main' && github.event.pull_request.head.ref != 'development' }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | node-version: [18.x, 20.x, 21.x] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Load node modules cache 27 | id: modules-cache 28 | uses: actions/cache@v4 29 | timeout-minutes: 5 30 | continue-on-error: true 31 | with: 32 | path: | 33 | **/node_modules 34 | key: ${{ runner.OS }}-modules-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.OS }}-modules-${{ matrix.node-version }}- 37 | - name: install modules 38 | run: | 39 | npm install --no-audit --force --loglevel=error --no-update-notifier 40 | 41 | linting: 42 | needs: preflight 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: latest 49 | - name: Load node modules cache 50 | id: modules-cache 51 | uses: actions/cache/restore@v4 52 | timeout-minutes: 5 53 | continue-on-error: false 54 | with: 55 | fail-on-cache-miss: true 56 | path: | 57 | **/node_modules 58 | key: ${{ runner.OS }}-modules-20.x-${{ hashFiles('**/package-lock.json') }} 59 | - name: linting 60 | run: | 61 | npm run build 62 | npm run lint 63 | 64 | typecheck: 65 | needs: preflight 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: actions/setup-node@v4 70 | with: 71 | node-version: latest 72 | - name: Load node modules cache 73 | id: modules-cache 74 | uses: actions/cache/restore@v4 75 | timeout-minutes: 5 76 | continue-on-error: false 77 | with: 78 | fail-on-cache-miss: true 79 | path: | 80 | **/node_modules 81 | key: ${{ runner.OS }}-modules-20.x-${{ hashFiles('**/package-lock.json') }} 82 | - name: typecheck 83 | run: | 84 | npm run build 85 | npm run type-check 86 | 87 | unit-tests: 88 | needs: preflight 89 | runs-on: ubuntu-latest 90 | strategy: 91 | matrix: 92 | node-version: [18.x, 20.x, 21.x] 93 | steps: 94 | - uses: actions/checkout@v4 95 | - uses: actions/setup-node@v4 96 | with: 97 | node-version: ${{ matrix.node-version }} 98 | - name: Load node modules cache 99 | id: modules-cache 100 | uses: actions/cache/restore@v4 101 | timeout-minutes: 5 102 | continue-on-error: false 103 | with: 104 | fail-on-cache-miss: true 105 | path: | 106 | **/node_modules 107 | key: ${{ runner.OS }}-modules-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 108 | - name: unit testing 109 | run: | 110 | npm run build 111 | npm test -- --coverage run 112 | - name: Upload coverage reports to Codecov 113 | uses: codecov/codecov-action@v5 114 | env: 115 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 116 | 117 | build: 118 | needs: preflight 119 | runs-on: ubuntu-latest 120 | strategy: 121 | matrix: 122 | node-version: [18.x, 20.x, 21.x] 123 | steps: 124 | - uses: actions/checkout@v4 125 | - uses: actions/setup-node@v4 126 | with: 127 | node-version: ${{ matrix.node-version }} 128 | - name: Load node modules cache 129 | id: modules-cache 130 | uses: actions/cache/restore@v4 131 | timeout-minutes: 5 132 | continue-on-error: false 133 | with: 134 | fail-on-cache-miss: true 135 | path: | 136 | **/node_modules 137 | key: ${{ runner.OS }}-modules-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }} 138 | - name: build package 139 | run: | 140 | npm run build 141 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '!refs/tags/*' 7 | - '!*' 8 | - v* 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | hello_world_job: 15 | runs-on: ubuntu-latest 16 | name: Create Release 17 | steps: 18 | - uses: maxnowack/action-release-generator@v1.4.0 19 | with: 20 | token: "${{ secrets.GITHUB_TOKEN }}" 21 | useNameFromRef: 'true' 22 | ref: "${{ github.ref }}" 23 | badwords: 'ci:,docs:,chore:,chore(deps),fix(deps)' 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /tmp 3 | dist 4 | node_modules 5 | coverage 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Max Nowack 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 |

2 | Current Version 3 | Status Checks 4 | Coverage 5 | License 6 | Stargazers 7 | npm 8 |

9 | 10 | # blend 11 | 12 | Blend is a lightweight Node.js tool designed to simplify managing dependencies within a project without using Git submodules. It provides an alternative approach for handling external code or resources that are necessary for your project's functionality. 13 | 14 | ## Features 15 | 16 | * **Dependency Management:** Easily add, update, commit, and remove dependencies. 17 | * **Simplified Workflow:** Streamlined commands for managing dependencies without the complexities of Git submodules. 18 | * **Automatic Dependency Tracking:** Keeps track of dependencies and their versions locally. 19 | 20 | ## Installation 21 | 22 | Blend can be installed via npm (note the missing 'e'): 23 | 24 | ```bash 25 | npm install -g blnd 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Adding a Dependency 31 | 32 | To add a dependency to your project, use the `add` command: 33 | 34 | ```bash 35 | blend add [] 36 | ``` 37 | 38 | * ``: The URL of the repository containing the dependency. 39 | * ``: The path to the directory or file within the repository. 40 | * `[]` (optional): The local directory where the dependency will be copied. If not provided, it defaults to the remote path. 41 | 42 | ### Updating Dependencies 43 | 44 | To update dependencies to their latest versions, use the `update` command: 45 | 46 | ```bash 47 | blend update 48 | ``` 49 | 50 | ### Committing Changes 51 | 52 | After making changes to a dependency, commit the changes using the commit command: 53 | 54 | ```bash 55 | blend commit "" 56 | ``` 57 | 58 | * ``: The local path of the dependency. 59 | * `""`: The commit message describing the changes made. 60 | 61 | ### Removing a Dependency 62 | 63 | To remove a dependency from your project, use the `remove` command: 64 | 65 | ```bash 66 | blend remove 67 | ``` 68 | 69 | * ``: The local path of the dependency. 70 | 71 | ## FAQ 72 | 73 | ### Q: Why Use Blend Instead of Git Submodules? 74 | Git submodules can be complex and difficult to manage. Blend provides a simpler, more intuitive way to manage dependencies without the overhead of Git submodules. 75 | 76 | ### Q: What programming languages can I use with Blend? 77 | Blend is language-agnostic and can be used with any programming language. 78 | 79 | ### Q: Can Blend handle dependencies from private repositories? 80 | Sure! Blend uses your local git configuration to authenticate with the remote repository. If you have access to the private repository, Blend will be able to fetch the dependencies from it. 81 | 82 | ### Q: What happens if a dependency's remote version is updated after it's been added with Blend? 83 | You can run `blend update` to update the dependencies to their latest versions. Blend will fetch the latest versions of the dependencies from their remote repositories and update the local copies in your project. 84 | 85 | ### Q: Is Blend suitable for managing large-scale projects with numerous dependencies? 86 | Yes, Blend is designed to handle projects of all sizes, including those with numerous dependencies. 87 | 88 | ### Q: Can Blend be integrated into continuous integration (CI) workflows? 89 | You don't have to. Blend stores copies of the dependencies in your project repository, so they are available to your CI system without any additional setup. 90 | 91 | ### Q: Does Blend support version locking or pinning for dependencies? 92 | Blend is designed to always use the latest version of a dependency. But you can add a dependency from a specific branch or tag to ensure that you always use a specific version. You can do this by adding a hash to the dependency's git URL. 93 | 94 | ### Q: How does Blend manage conflicts when updating dependencies? 95 | Blend does not manage conflicts when updating dependencies. It's up to you to resolve any conflicts that may arise when updating dependencies. 96 | 97 | ### Q: Can Blend be used in conjunction with package managers like npm or yarn? 98 | Absolutely! Blend is designed to work alongside package managers like npm or yarn. You can use Blend to manage your git dependencies while using npm or yarn to manage your package dependencies. 99 | 100 | ### Q: What happens if a dependency is removed or renamed in the remote repository? 101 | If a dependency is removed or renamed in the remote repository, you will need to manually remove or update the dependency in your project. Blend does not automatically handle these changes. 102 | 103 | ### Q: Does Blend support recursive dependencies or nested dependency structures? 104 | Not yet. Blend does not currently support recursive dependencies or nested dependency structures. However, this is a feature that we are considering for future releases. 105 | 106 | ## Why Blend is a Better Alternative to [Bit](https://bit.dev/) 107 | 108 | Bit overcomplicates the simple task of managing dependencies at the file level. For instance, it introduces its own version manager for handling different versions of Bit itself. Additionally, Bit necessitates an external server to store components, whereas Blend utilizes existing repository infrastructure, minimizing setup requirements. 109 | 110 | While Bit is open source, the company behind it seeks profit, leading to potential attempts to create additional revenue streams and lock users into their ecosystem. 111 | 112 | In contrast, Blend simplifies dependency management by eliminating unnecessary overhead. It operates directly on top of Git and doesn't require any additional infrastructure. 113 | 114 | ## Why Blend is a Better Alternative to [Git Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) 115 | 116 | Git submodules have been a traditional method for managing dependencies within Git repositories. However, they come with various complexities and limitations that can hinder project maintenance and collaboration. There are numerous articles and discussions about the challenges of using Git submodules, such as [this one](https://codingkilledthecat.wordpress.com/2012/04/28/why-your-company-shouldnt-use-git-submodules/). There is also a discussion on Hacker News [why Git submodules are so bad](https://news.ycombinator.com/item?id=31792303). 117 | 118 | Here's why Blend offers a superior alternative: 119 | 120 | ### Simplified Dependency Management 121 | 122 | Git submodules introduce additional complexities to the workflow, such as submodule initialization, updates, and synchronizations. Blend simplifies dependency management with straightforward commands for adding, updating, committing, and removing dependencies, while keeping copies of the dependencies within the project repository. 123 | 124 | #### Enhanced Flexibility 125 | 126 | Git submodules often require users to navigate between multiple repositories, making it cumbersome to work with dependencies. Blend allows for easier integration and management of dependencies directly within the project repository, enhancing flexibility and ease of use. 127 | 128 | #### Reduced Overhead 129 | 130 | Managing Git submodules involves maintaining separate repository configurations and tracking changes across multiple repositories. Blend reduces this overhead by consolidating dependency management within a single tool, streamlining the development process. 131 | 132 | #### Improved Collaboration 133 | 134 | Git submodules can lead to complexities and conflicts, especially in collaborative environments with multiple contributors. Blend facilitates smoother collaboration by providing a unified approach to dependency management, reducing the likelihood of conflicts and enhancing productivity. 135 | 136 | #### Seamless Integration 137 | 138 | Blend seamlessly integrates with existing Git workflows, allowing users to leverage familiar version control practices while benefiting from simplified dependency management. Whether you're working on a small project or a large-scale application, Blend offers a seamless and efficient solution for managing dependencies. 139 | 140 | #### Conclusion 141 | 142 | While Git submodules have served as a traditional method for managing dependencies, their complexities and limitations often outweigh their benefits. Blend provides a modern and efficient alternative, offering simplified dependency management, enhanced flexibility, reduced overhead, improved collaboration, and seamless integration with existing workflows. Make the switch to Blend for a smoother and more efficient dependency management experience. 143 | 144 | 145 | ## License 146 | Licensed under MIT license. Copyright (c) 2024 Max Nowack 147 | 148 | ## Contributions 149 | Contributions are welcome. Please open issues and/or file Pull Requests. 150 | 151 | ## Maintainers 152 | - Max Nowack ([maxnowack](https://github.com/maxnowack)) 153 | -------------------------------------------------------------------------------- /__tests__/cli.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | /* eslint-disable no-await-in-loop */ 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { describe, it, expect, afterAll, vi } from 'vitest' 6 | import temp from 'temp' 7 | import executeCommand from '../src/utils/executeCommand.js' 8 | import { add, update, commit, remove, clearRepoCache } from '../src/commands.js' 9 | import { createConfig, parseConfig } from '../src/config' 10 | import latestHash from '../src/utils/git/latestHash.js' 11 | 12 | const exists = async (file: string) => fs.promises.access(file) 13 | .then(() => true) 14 | .catch(() => false) 15 | 16 | async function setupGit( 17 | gitFiles: Record, 18 | dependencies: { 19 | repo?: string, 20 | hash?: string, 21 | remotePath: string, 22 | localPath: string, 23 | }[] = [], 24 | ) { 25 | if (process.env.CI) { 26 | await executeCommand('git', ['config', '--global', 'user.email', 'test@example.com']) 27 | await executeCommand('git', ['config', '--global', 'user.name', 'Test User']) 28 | await executeCommand('git', ['config', '--global', 'init.defaultBranch', 'main']) 29 | } 30 | 31 | const testFolder = temp.mkdirSync() 32 | const gitFolder = temp.mkdirSync() 33 | 34 | await executeCommand('git', ['init'], { cwd: gitFolder }) 35 | await executeCommand('git', ['config', 'receive.denyCurrentBranch', 'ignore'], { cwd: gitFolder }) 36 | for (const [file, content] of Object.entries(gitFiles)) { 37 | await fs.promises.writeFile(path.join(gitFolder, file), content) 38 | } 39 | 40 | await executeCommand('git', ['add', '.'], { cwd: gitFolder }) 41 | await executeCommand('git', ['commit', '-m', 'initial commit'], { cwd: gitFolder }) 42 | const hash = await latestHash(gitFolder) 43 | 44 | await fs.promises.writeFile( 45 | path.join(testFolder, 'blend.yml'), 46 | createConfig({ 47 | dependencies: dependencies.map(dep => ({ 48 | repo: dep.repo ?? gitFolder, 49 | hash: dep.hash ?? hash, 50 | remotePath: dep.remotePath, 51 | localPath: dep.localPath, 52 | })), 53 | }), 54 | ) 55 | await expect(exists(path.join(testFolder, 'blend.yml'))).resolves.toBe(true) 56 | 57 | vi.spyOn(process, 'cwd').mockReturnValue(testFolder) 58 | if (dependencies.length > 0) await update() 59 | for (const dep of dependencies) { 60 | const localPath = path.join(testFolder, dep.localPath) 61 | await expect(exists(localPath)).resolves.toBe(true) 62 | } 63 | clearRepoCache() 64 | return { testFolder, gitFolder } 65 | } 66 | 67 | describe('blend cli', () => { 68 | describe('add', () => { 69 | it('should add a dependency', async () => { 70 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }) 71 | await expect(add(gitFolder, 'test.txt')).resolves.not.toThrow() 72 | await expect(exists(path.join(testFolder, 'test.txt'))).resolves.toBe(true) 73 | 74 | await expect(exists(path.join(testFolder, 'blend.yml'))).resolves.toBe(true) 75 | const config = parseConfig(await fs.promises.readFile( 76 | path.join(testFolder, 'blend.yml'), 77 | 'utf-8', 78 | )) 79 | expect(config).toMatchObject({ 80 | dependencies: [{ 81 | repo: gitFolder, 82 | remotePath: 'test.txt', 83 | localPath: 'test.txt', 84 | }], 85 | }) 86 | }) 87 | 88 | it('should add a dependency with a local path', async () => { 89 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }) 90 | 91 | await expect(add(gitFolder, 'test.txt', 'test2.txt')).resolves.not.toThrow() 92 | await expect(exists(path.join(testFolder, 'test2.txt'))).resolves.toBe(true) 93 | 94 | await expect(exists(path.join(testFolder, 'blend.yml'))).resolves.toBe(true) 95 | const config = parseConfig(await fs.promises.readFile( 96 | path.join(testFolder, 'blend.yml'), 97 | 'utf-8', 98 | )) 99 | expect(config).toMatchObject({ 100 | dependencies: [{ 101 | repo: gitFolder, 102 | remotePath: 'test.txt', 103 | localPath: 'test2.txt', 104 | }], 105 | }) 106 | }) 107 | 108 | it('should add a dependency with a branch', async () => { 109 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }) 110 | 111 | await expect(add(`${gitFolder}#main`, 'test.txt', 'test3.txt')).resolves.not.toThrow() 112 | await expect(exists(path.join(testFolder, 'test3.txt'))).resolves.toBe(true) 113 | 114 | await expect(exists(path.join(testFolder, 'blend.yml'))).resolves.toBe(true) 115 | const config = parseConfig(await fs.promises.readFile( 116 | path.join(testFolder, 'blend.yml'), 117 | 'utf-8', 118 | )) 119 | expect(config).toMatchObject({ 120 | dependencies: [{ 121 | repo: `${gitFolder}#main`, 122 | remotePath: 'test.txt', 123 | localPath: 'test3.txt', 124 | }], 125 | }) 126 | }) 127 | 128 | it('should fail if the local path already exists', async () => { 129 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }, [{ 130 | remotePath: 'test.txt', 131 | localPath: 'test.txt', 132 | }]) 133 | await fs.promises.writeFile(path.join(testFolder, 'test2.txt'), 'test2') 134 | await expect(add(gitFolder, 'test2.txt')).rejects.toThrowError('already exists') 135 | }) 136 | 137 | it('should fail if the dependency already exists', async () => { 138 | const { gitFolder } = await setupGit({ 'test.txt': 'test' }, [{ 139 | remotePath: 'test.txt', 140 | localPath: 'test.txt', 141 | }]) 142 | await expect(add(gitFolder, 'test.txt')).rejects.toThrowError('already exists') 143 | }) 144 | 145 | it('should fail if the repository does not exist', async () => { 146 | await setupGit({ 'test.txt': 'test' }) 147 | await expect(add('/tmp/does-not-exist', 'test.txt')) 148 | .rejects.toThrowError('Unable to access repository') 149 | }) 150 | 151 | it('should fail if the remote path does not exist', async () => { 152 | const { gitFolder } = await setupGit({ 'test.txt': 'test' }) 153 | await expect(add(gitFolder, 'does-not-exist')) 154 | .rejects.toThrowError('does not exist in the repository') 155 | }) 156 | }) 157 | 158 | describe('update', () => { 159 | it('should pass if there are no updates', async () => { 160 | await setupGit({ 'test.txt': 'test' }, [{ 161 | remotePath: 'test.txt', 162 | localPath: 'test.txt', 163 | }]) 164 | await expect(update()).resolves.not.toThrow() 165 | }) 166 | 167 | it('should update a dependency', async () => { 168 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }, [{ 169 | remotePath: 'test.txt', 170 | localPath: 'test.txt', 171 | }]) 172 | await fs.promises.writeFile(path.join(gitFolder, 'test.txt'), 'test2') 173 | await executeCommand('git', ['add', '.'], { cwd: gitFolder }) 174 | await executeCommand('git', ['commit', '-m', 'update'], { cwd: gitFolder }) 175 | await expect(update()).resolves.not.toThrow() 176 | 177 | const localPath = path.join(testFolder, 'test.txt') 178 | await expect(exists(localPath)).resolves.toBe(true) 179 | await expect(fs.promises.readFile(localPath, 'utf-8')).resolves.toBe('test2') 180 | }) 181 | 182 | it('should not update if there are local changes', async () => { 183 | const { testFolder } = await setupGit({ 'test.txt': 'test' }, [{ 184 | remotePath: 'test.txt', 185 | localPath: 'test.txt', 186 | }]) 187 | await fs.promises.writeFile(path.join(testFolder, 'test.txt'), 'test2') 188 | await expect(update()).resolves.not.toThrow() 189 | await expect(fs.promises.readFile(path.join(testFolder, 'test.txt'), 'utf-8')) 190 | .resolves.toBe('test2') 191 | }) 192 | 193 | it('should not update if there are local and remote changes', async () => { 194 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }, [{ 195 | remotePath: 'test.txt', 196 | localPath: 'test.txt', 197 | }]) 198 | await fs.promises.writeFile(path.join(testFolder, 'test.txt'), 'test2') 199 | await fs.promises.writeFile(path.join(gitFolder, 'test.txt'), 'test3') 200 | await executeCommand('git', ['add', '.'], { cwd: gitFolder }) 201 | await executeCommand('git', ['commit', '-m', 'update'], { cwd: gitFolder }) 202 | 203 | await expect(update()).resolves.not.toThrow() 204 | await expect(fs.promises.readFile(path.join(testFolder, 'test.txt'), 'utf-8')) 205 | .resolves.toBe('test2') 206 | }) 207 | 208 | it('should fail if there are no dependencies', async () => { 209 | await setupGit({ 'test.txt': 'test' }) 210 | await expect(update()).rejects.toThrowError('No dependencies found') 211 | }) 212 | 213 | it('should fail if the hash of a dependency can not be resolved in the remote', async () => { 214 | const { testFolder } = await setupGit({ 'test.txt': 'test' }, [{ 215 | remotePath: 'test.txt', 216 | localPath: 'test.txt', 217 | hash: 'xxx', 218 | }]) 219 | await expect(update()).rejects.toThrowError('does not exist in the repository') 220 | await expect(fs.promises.readFile(path.join(testFolder, 'test.txt'), 'utf-8')) 221 | .resolves.toBe('test') 222 | }) 223 | 224 | it('should fail in an empty directory', async () => { 225 | const testFolder = temp.mkdirSync() 226 | vi.spyOn(process, 'cwd').mockReturnValue(testFolder) 227 | await expect(update()).rejects.toThrowError('No dependencies found') 228 | }) 229 | }) 230 | 231 | describe('commit', () => { 232 | it('should fail if the dependency has no changes', async () => { 233 | await setupGit({ 'test.txt': 'test' }, [{ 234 | remotePath: 'test.txt', 235 | localPath: 'test.txt', 236 | }]) 237 | await expect(commit('test.txt', 'commit message')).rejects.toThrowError('has no changes') 238 | }) 239 | 240 | it('should fail if the commit message is empty', async () => { 241 | await setupGit({ 'test.txt': 'test' }, [{ 242 | remotePath: 'test.txt', 243 | localPath: 'test.txt', 244 | }]) 245 | await expect(commit('test.txt', '')).rejects.toThrowError('Please provide a commit message') 246 | }) 247 | 248 | it('should commit a dependency', async () => { 249 | const { testFolder } = await setupGit({ 'test.txt': 'test' }, [{ 250 | remotePath: 'test.txt', 251 | localPath: 'test.txt', 252 | }]) 253 | await fs.promises.writeFile(path.join(testFolder, 'test.txt'), 'test2') 254 | await expect(commit('test.txt', 'commit message')).resolves.not.toThrow() 255 | }) 256 | 257 | it('should fail if the dependency does not exist', async () => { 258 | await setupGit({ 'test.txt': 'test' }, [{ 259 | remotePath: 'test.txt', 260 | localPath: 'test.txt', 261 | }]) 262 | await expect(commit('does-not-exist', 'commit message')) 263 | .rejects.toThrowError('does not exist') 264 | }) 265 | 266 | it('should fail if the provided path is not a dependency', async () => { 267 | const { testFolder } = await setupGit({ 'test.txt': 'test' }, [{ 268 | remotePath: 'test.txt', 269 | localPath: 'test.txt', 270 | }]) 271 | await fs.promises.writeFile(path.join(testFolder, 'test2.txt'), 'test2') 272 | await expect(commit('test2.txt', 'commit')).rejects.toThrowError('is not a dependency') 273 | }) 274 | 275 | it('should fail if there is a newer remote version', async () => { 276 | const { testFolder, gitFolder } = await setupGit({ 'test.txt': 'test' }, [{ 277 | remotePath: 'test.txt', 278 | localPath: 'test.txt', 279 | }]) 280 | await fs.promises.writeFile(path.join(gitFolder, 'test.txt'), 'test3') 281 | await executeCommand('git', ['add', '.'], { cwd: gitFolder }) 282 | await executeCommand('git', ['commit', '-m', 'update'], { cwd: gitFolder }) 283 | 284 | await fs.promises.writeFile(path.join(testFolder, 'test.txt'), 'test2') 285 | await expect(commit('test.txt', 'commit')).rejects.toThrowError('has a newer remote version') 286 | }) 287 | }) 288 | 289 | describe('remove', () => { 290 | it('should remove a dependency', async () => { 291 | await setupGit({ 'test.txt': 'test' }, [{ 292 | remotePath: 'test.txt', 293 | localPath: 'test.txt', 294 | }]) 295 | await expect(remove('test.txt')).resolves.not.toThrow() 296 | }) 297 | 298 | it('should fail if the dependency does not exist', async () => { 299 | await setupGit({ 'test.txt': 'test' }, [{ 300 | remotePath: 'test.txt', 301 | localPath: 'test.txt', 302 | }]) 303 | await expect(remove('does-not-exist')).rejects.toThrowError('does not exist') 304 | }) 305 | 306 | it('should fail if the provided path is not a dependency', async () => { 307 | const { testFolder } = await setupGit({ 'test.txt': 'test' }, [{ 308 | remotePath: 'test.txt', 309 | localPath: 'test.txt', 310 | }]) 311 | await fs.promises.writeFile(path.join(testFolder, 'test2.txt'), 'test2') 312 | await expect(remove('test2.txt')).rejects.toThrowError('is not a dependency') 313 | }) 314 | }) 315 | 316 | afterAll(() => { 317 | temp.cleanupSync() 318 | }) 319 | }) 320 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 100% 6 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blnd", 3 | "version": "1.0.3", 4 | "description": "", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "blend": "dist/index.mjs" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "scripts": { 14 | "dev": "node --loader ts-node/esm src/index.ts", 15 | "prepare": "npm run build && husky install", 16 | "build": "rimraf dist && vite build && chmod +x dist/index.mjs", 17 | "lint": "eslint . --max-warnings=0", 18 | "test": "vitest", 19 | "coverage": "vitest run --coverage", 20 | "type-check": "tsc --noEmit" 21 | }, 22 | "keywords": [ 23 | "git", 24 | "lightweight", 25 | "package-manager", 26 | "dependency-manager", 27 | "modules", 28 | "dependencies" 29 | ], 30 | "author": "Max Nowack ", 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/maxnowack/blend.git" 34 | }, 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@commitlint/cli": "19.8.1", 38 | "@commitlint/config-conventional": "19.8.1", 39 | "@rollup/plugin-typescript": "12.1.2", 40 | "@types/js-yaml": "^4.0.9", 41 | "@types/node": "^22.0.0", 42 | "@types/temp": "^0.9.4", 43 | "@typescript-eslint/eslint-plugin": "^7.18.0", 44 | "@typescript-eslint/parser": "^7.18.0", 45 | "@vitest/coverage-istanbul": "^2.0.4", 46 | "eslint": "^8.56.0", 47 | "eslint-config-airbnb-base": "^15.0.0", 48 | "eslint-import-resolver-typescript": "^4.0.0", 49 | "eslint-plugin-import": "^2.29.1", 50 | "eslint-plugin-prefer-object-spread": "^1.2.1", 51 | "eslint-plugin-vitest": "0.5.4", 52 | "husky": "9.1.7", 53 | "rimraf": "6.0.1", 54 | "rollup-plugin-typescript-paths": "1.5.0", 55 | "ts-node": "^10.9.2", 56 | "tslib": "2.8.1", 57 | "typescript": "^5.5.4", 58 | "vite": "6.3.5", 59 | "vite-plugin-dts": "4.5.4", 60 | "vite-tsconfig-paths": "5.1.4", 61 | "vitest": "^2.0.4", 62 | "vitest-github-actions-reporter": "^0.11.1" 63 | }, 64 | "dependencies": { 65 | "js-yaml": "^4.1.0", 66 | "temp": "^0.9.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "lockFileMaintenance": { 7 | "enabled": true 8 | }, 9 | "branchNameStrict": true, 10 | "labels": ["dependencies"], 11 | "rebaseWhen": "conflicted", 12 | "automerge": true, 13 | "platformAutomerge": true, 14 | "automergeType": "pr", 15 | "dependencyDashboard": false, 16 | "packageRules": [ 17 | { 18 | "matchDepTypes": ["devDependencies"], 19 | "addLabels": ["tooling"] 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import temp from 'temp' 4 | import { 5 | addDependencyIfNotExists, 6 | getLocalConfig, 7 | getLocalConfigDir, 8 | saveLocalConfig, 9 | } from './config.js' 10 | import cloneRepo from './utils/git/cloneRepo.js' 11 | import checkout from './utils/git/checkout.js' 12 | import latestHash from './utils/git/latestHash.js' 13 | import exists from './utils/fileExists.js' 14 | import isAncestor from './utils/git/isAncestor.js' 15 | import hasChanges from './utils/git/hasChanges.js' 16 | import commitChanges from './utils/git/commit.js' 17 | import reset from './utils/git/reset.js' 18 | import remoteDefaultBranch from './utils/git/remoteDefaultBranch.js' 19 | 20 | function getUrlAndBranch(repo: string): { url: string, branch?: string } { 21 | const [url, branch] = repo.split('#') 22 | return { url, branch } 23 | } 24 | 25 | const repoCache = new Map() 26 | async function cloneRepoAndCheckout(repo: string) { 27 | if (!repoCache.has(repo)) { 28 | const destinationPath = temp.mkdirSync() 29 | const { url, branch } = getUrlAndBranch(repo) 30 | await cloneRepo(url, destinationPath) 31 | if (branch != null) { 32 | await checkout(destinationPath, branch) 33 | } 34 | repoCache.set(repo, destinationPath) 35 | } 36 | return repoCache.get(repo) as string 37 | } 38 | export function clearRepoCache() { 39 | repoCache.clear() 40 | } 41 | 42 | export async function add( 43 | repo: string, 44 | remotePath: string, 45 | localPath = remotePath, 46 | addToConfig = true, 47 | ) { 48 | console.log(`Adding ${localPath} ...`) // eslint-disable-line no-console 49 | const localDir = path.join(await getLocalConfigDir(), localPath) 50 | if (await exists(localDir)) { 51 | throw new Error(`${localPath} already exists! Please remove it first`) 52 | } 53 | 54 | const repoPath = await cloneRepoAndCheckout(repo) 55 | const fullRemotePath = path.join(repoPath, remotePath) 56 | if (!(await exists(fullRemotePath))) { 57 | throw new Error(`Path ${remotePath} does not exist in the repository`) 58 | } 59 | const commitHash = await latestHash(repoPath) 60 | 61 | await fs.promises.cp(fullRemotePath, localDir, { 62 | recursive: true, 63 | }) 64 | 65 | if (addToConfig) { 66 | const localConfig = addDependencyIfNotExists(await getLocalConfig(), { 67 | repo, 68 | hash: commitHash, 69 | localPath, 70 | remotePath, 71 | }) 72 | await saveLocalConfig(localConfig) 73 | } 74 | } 75 | 76 | async function statusCheck(localPath: string): Promise<{ 77 | found: true, 78 | pathExists: false, 79 | upToDate?: never, 80 | hasLocalChanges?: never, 81 | hasNewerVersion?: never, 82 | } | { 83 | found: false, 84 | pathExists: boolean, 85 | upToDate?: never, 86 | hasLocalChanges?: never, 87 | hasNewerVersion?: never, 88 | } | { 89 | found: true, 90 | pathExists: true, 91 | upToDate: boolean, 92 | hasLocalChanges: boolean, 93 | hasNewerVersion: boolean, 94 | }> { 95 | const localDir = path.join(await getLocalConfigDir(), localPath) 96 | const localConfig = await getLocalConfig() 97 | const dependency = localConfig.dependencies?.find(dep => dep.localPath === localPath) 98 | const pathExists = await exists(localDir) 99 | if (!dependency) return { found: false, pathExists } 100 | if (!pathExists) return { found: true, pathExists: false } 101 | 102 | const repoPath = await cloneRepoAndCheckout(dependency.repo) 103 | const commitHash = await latestHash(repoPath) 104 | const upToDate = commitHash === dependency.hash 105 | 106 | const hasNewerVersion = !upToDate && await isAncestor(dependency.hash, commitHash, repoPath) 107 | await checkout(repoPath, dependency.hash) 108 | const fullRemotePath = path.join(repoPath, dependency.remotePath) 109 | await fs.promises.cp(localDir, fullRemotePath, { 110 | recursive: true, 111 | }) 112 | 113 | const hasLocalChanges = await hasChanges(fullRemotePath) 114 | const branch = getUrlAndBranch(dependency.repo).branch || await remoteDefaultBranch(repoPath) 115 | await reset(repoPath, branch) 116 | 117 | return { 118 | found: true, 119 | pathExists: true, 120 | upToDate, 121 | hasLocalChanges, 122 | hasNewerVersion, 123 | } 124 | } 125 | 126 | export async function update() { 127 | const localConfig = await getLocalConfig() 128 | const dependencies = localConfig.dependencies || [] 129 | if (dependencies.length === 0) { 130 | throw new Error('No dependencies found') 131 | } 132 | console.log(`Updating ${dependencies.length} dependencies ...`) // eslint-disable-line no-console 133 | const newDependencies: NonNullable<(typeof localConfig)['dependencies']> = [] 134 | await dependencies.reduce(async (promise, dependency) => { 135 | await promise 136 | const { 137 | pathExists, 138 | upToDate, 139 | hasLocalChanges, 140 | hasNewerVersion, 141 | } = await statusCheck(dependency.localPath) 142 | 143 | if (!pathExists) { 144 | console.log(`${dependency.localPath} does not exist.`) // eslint-disable-line no-console 145 | await add(dependency.repo, dependency.remotePath, dependency.localPath, false) 146 | newDependencies.push(dependency) 147 | return 148 | } 149 | 150 | if (hasLocalChanges) { 151 | if (hasNewerVersion) { 152 | console.log(`${dependency.localPath} has changes and a newer remote version. Please first update to the new remote version manually and start commiting your changes. Skipping ...`) // eslint-disable-line no-console 153 | } else { 154 | console.log(`${dependency.localPath} has changes. Please commit them using 'blend commit ${dependency.localPath} ""' Skipping ...`) // eslint-disable-line no-console 155 | } 156 | newDependencies.push(dependency) 157 | return 158 | } 159 | 160 | if (upToDate) { 161 | console.log(`${dependency.localPath} is up to date. Skipping ...`) // eslint-disable-line no-console 162 | newDependencies.push(dependency) 163 | return 164 | } 165 | 166 | console.log(`Updating ${dependency.localPath} ...`) // eslint-disable-line no-console 167 | 168 | const repoPath = await cloneRepoAndCheckout(dependency.repo) 169 | const fullRemotePath = path.join(repoPath, dependency.remotePath) 170 | 171 | const localDir = path.join(await getLocalConfigDir(), dependency.localPath) 172 | await fs.promises.cp(fullRemotePath, localDir, { 173 | recursive: true, 174 | }) 175 | 176 | const commitHash = await latestHash(repoPath) 177 | newDependencies.push({ 178 | ...dependency, 179 | hash: commitHash, 180 | }) 181 | }, Promise.resolve()) 182 | 183 | // Write the new config to the local blend.yaml 184 | await saveLocalConfig({ 185 | ...localConfig, 186 | dependencies: newDependencies, 187 | }) 188 | } 189 | 190 | export async function commit(localPath: string, message: string) { 191 | console.log(`Committing ${localPath} ...`) // eslint-disable-line no-console 192 | const { 193 | found, 194 | pathExists, 195 | hasLocalChanges, 196 | hasNewerVersion, 197 | } = await statusCheck(localPath) 198 | 199 | if (!message) { 200 | throw new Error('Please provide a commit message') 201 | } 202 | 203 | if (!pathExists) { 204 | throw new Error(`Path ${localPath} does not exist`) 205 | } 206 | if (!found) { 207 | throw new Error(`Path ${localPath} is not a dependency`) 208 | } 209 | 210 | if (!hasLocalChanges) { 211 | throw new Error(`Path ${localPath} has no changes`) 212 | } 213 | 214 | if (hasNewerVersion) { 215 | throw new Error(`Path ${localPath} has a newer remote version. Please update to the new remote version manually first`) 216 | } 217 | 218 | const localConfig = await getLocalConfig() 219 | const dependency = localConfig.dependencies?.find(dep => dep.localPath === localPath) 220 | if (!dependency) throw new Error('Dependency not found') 221 | 222 | const repoPath = await cloneRepoAndCheckout(dependency.repo) 223 | const fullRemotePath = path.join(repoPath, dependency.remotePath) 224 | const fullLocalPath = path.join(await getLocalConfigDir(), localPath) 225 | await fs.promises.cp(fullLocalPath, fullRemotePath, { 226 | recursive: true, 227 | force: true, 228 | }) 229 | 230 | await commitChanges(repoPath, message) 231 | 232 | const commitHash = await latestHash(repoPath) 233 | await saveLocalConfig({ 234 | ...localConfig, 235 | dependencies: (localConfig.dependencies || []).map((dep) => { 236 | if (dep.localPath !== localPath) return dep 237 | return { 238 | ...dep, 239 | hash: commitHash, 240 | } 241 | }), 242 | }) 243 | } 244 | 245 | export async function remove(localPath: string) { 246 | const filePath = path.join(await getLocalConfigDir(), localPath) 247 | if (!(await exists(filePath))) { 248 | throw new Error(`${localPath} does not exist`) 249 | } 250 | 251 | console.log(`Removing ${localPath} ...`) // eslint-disable-line no-console 252 | const localConfig = await getLocalConfig() 253 | localConfig.dependencies = (localConfig.dependencies || []) 254 | const dependencyIndex = localConfig.dependencies.findIndex(dep => dep.localPath === localPath) 255 | if (dependencyIndex === -1) { 256 | throw new Error(`${localPath} is not a dependency`) 257 | } 258 | localConfig.dependencies.splice(dependencyIndex, 1) 259 | 260 | await fs.promises.unlink(filePath) 261 | await saveLocalConfig(localConfig) 262 | } 263 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import yaml from 'js-yaml' 4 | import exists from './utils/fileExists.js' 5 | 6 | interface Dependency { 7 | repo: string, 8 | hash: string, 9 | remotePath: string, 10 | localPath: string, 11 | } 12 | 13 | interface Hooks { 14 | preinstall?: string, 15 | postinstall?: string, 16 | preuninstall?: string, 17 | postuninstall?: string, 18 | } 19 | 20 | interface Config { 21 | name?: string, 22 | description?: string, 23 | hooks?: Hooks, 24 | dependencies?: Dependency[], 25 | } 26 | 27 | // function extractFirstBlockComment(filePath: string): string | null { 28 | // const content = fs.readFileSync(filePath, 'utf-8') 29 | // const blockCommentRegex = /\/\*(.*?)\*\//s 30 | // const match = content.match(blockCommentRegex) 31 | // return match ? match[1].trim() : null 32 | // } 33 | 34 | export function parseConfig(yamlString: string): Config { 35 | return yaml.load(yamlString) as Config 36 | } 37 | 38 | export function createConfig(config: Config): string { 39 | return yaml.dump(config) 40 | } 41 | 42 | export function addDependencyIfNotExists(config: Config, dependency: Dependency) { 43 | const newConfig = { ...config, dependencies: [...config.dependencies || []] } 44 | if (!newConfig.dependencies.some(dep => dep.localPath === dependency.localPath)) { 45 | newConfig.dependencies.push(dependency) 46 | } 47 | return newConfig 48 | } 49 | export async function getConfig(filePath: string): Promise { 50 | if (!(await exists(filePath))) return null 51 | if (path.basename(filePath) === 'blend.yml') { 52 | const yamlContent = fs.readFileSync(filePath, 'utf-8') 53 | return parseConfig(yamlContent) 54 | } 55 | // const stats = await fs.promises.stat(filePath) 56 | // if (stats.isDirectory()) { 57 | // const blendYmlPath = path.join(filePath, 'blend.yml') 58 | // if (await exists(blendYmlPath)) { 59 | // const yamlContent = fs.readFileSync(blendYmlPath, 'utf-8') 60 | // return parseConfig(yamlContent) 61 | // } 62 | // throw new Error(`File not found: ${blendYmlPath}`) 63 | // } 64 | 65 | // const comment = extractFirstBlockComment(filePath) 66 | // if (comment) return parseConfig(comment) 67 | // return null 68 | /* istanbul ignore next -- @preserve */ 69 | throw new Error(`File not found: ${filePath}`) 70 | } 71 | 72 | export async function getLocalConfigDir() { 73 | return Promise.resolve(process.cwd()) 74 | } 75 | 76 | export async function getLocalConfigPath() { 77 | return Promise.resolve(path.join(await getLocalConfigDir(), 'blend.yml')) 78 | } 79 | 80 | export async function getLocalConfig() { 81 | const localConfigPath = await getLocalConfigPath() 82 | return (await getConfig(localConfigPath)) || { 83 | name: undefined, 84 | description: undefined, 85 | hooks: undefined, 86 | dependencies: [], 87 | } 88 | } 89 | 90 | export async function saveLocalConfig(config: Config) { 91 | const localConfigPath = await getLocalConfigPath() 92 | await fs.promises.writeFile(localConfigPath, createConfig(config), 'utf-8') 93 | } 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable no-console */ 3 | import temp from 'temp' 4 | import * as commands from './commands.js' 5 | 6 | temp.track() 7 | 8 | interface AddParameters { 9 | type: 'add', 10 | repo: string, 11 | remotePath: string, 12 | localPath?: string, 13 | } 14 | interface UpdateParameters { 15 | type: 'update', 16 | } 17 | interface CommitParameters { 18 | type: 'commit', 19 | localPath: string, 20 | message: string, 21 | } 22 | interface RemoveParameters { 23 | type: 'remove', 24 | path: string, 25 | } 26 | type ScriptParameters = AddParameters | UpdateParameters | CommitParameters | RemoveParameters 27 | function getParameters(): ScriptParameters | null { 28 | const [type, ...args] = process.argv.slice(2) 29 | 30 | if (type === 'add') { 31 | if (args.length < 2) { 32 | throw new Error('add requires at least 2 arguments: repo, path, (localPath)') 33 | } 34 | const [repo, remotePath, localPath] = args 35 | return { type, repo, remotePath, localPath } 36 | } 37 | 38 | if (type === 'update') { 39 | if (args.length !== 0) throw new Error('update requires 0 arguments') 40 | return { type } 41 | } 42 | 43 | if (type === 'commit') { 44 | if (args.length !== 2) throw new Error('commit requires 2 arguments: path, message') 45 | const [localPath, message] = args 46 | return { type, localPath, message } 47 | } 48 | 49 | if (type === 'remove') { 50 | if (args.length !== 1) throw new Error('remove requires 1 argument: path') 51 | const [path] = args 52 | return { type, path } 53 | } 54 | 55 | if (type !== 'help' && type !== '--help' && type !== '-h' && type != null) { 56 | console.log(`Unknown command: ${type}\n`) 57 | } 58 | console.log('Usage: blend add ') 59 | console.log(' blend update') 60 | console.log(' blend commit ') 61 | console.log(' blend remove ') 62 | return null 63 | } 64 | 65 | const start = async () => { 66 | const parameters = getParameters() 67 | if (parameters == null) return 68 | 69 | if (parameters.type === 'add') { 70 | await commands.add(parameters.repo, parameters.remotePath, parameters.localPath) 71 | } else if (parameters.type === 'update') { 72 | await commands.update() 73 | } else if (parameters.type === 'commit') { 74 | await commands.commit(parameters.localPath, parameters.message) 75 | } else if (parameters.type === 'remove') { 76 | await commands.remove(parameters.path) 77 | } 78 | } 79 | 80 | start() 81 | .catch((err) => { 82 | console.error(err.message) 83 | process.exit(1) 84 | }) 85 | -------------------------------------------------------------------------------- /src/utils/executeCommand.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess, ExecOptions } from 'node:child_process' 2 | 3 | const childProcesses: ChildProcess[] = [] 4 | /* istanbul ignore next -- @preserve */ 5 | process.on('exit', () => { 6 | childProcesses.forEach((cp) => { 7 | cp.kill() 8 | }) 9 | }) 10 | interface Options extends ExecOptions { 11 | outFn?: (data: any) => void, 12 | errFn?: (data: any) => void, 13 | stdoutPipe?: NodeJS.WritableStream, 14 | stderrPipe?: NodeJS.WritableStream, 15 | } 16 | export default function executeCommand( 17 | command: string, 18 | args: string[], 19 | options?: Options, 20 | ) { 21 | return new Promise((resolve, reject) => { 22 | const childProcess = spawn(command, args, options) 23 | childProcesses.push(childProcess) 24 | let errString = '' 25 | let outString = '' 26 | if (childProcess.stdout) { 27 | if (options?.stdoutPipe) childProcess.stdout.pipe(options.stdoutPipe || process.stdout) 28 | childProcess.stdout.on('data', (chunk) => { 29 | if (options?.outFn) options.outFn(chunk) 30 | outString += chunk 31 | }) 32 | } 33 | if (childProcess.stderr) { 34 | if (options?.stderrPipe) childProcess.stderr.pipe(options.stderrPipe || process.stderr) 35 | childProcess.stderr.on('data', (chunk) => { 36 | if (options?.errFn) options.errFn(chunk) 37 | errString += chunk 38 | }) 39 | } 40 | 41 | childProcess.on('close', (code) => { 42 | if (code && code > 0) { 43 | reject(new Error(`command '${command} exited with code ${code}':\n${errString}`)) 44 | return 45 | } 46 | resolve(outString) 47 | }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/fileExists.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | 3 | const exists = (filePath: string) => 4 | fs.promises.access(filePath).then(() => true).catch(() => false) 5 | 6 | export default exists 7 | -------------------------------------------------------------------------------- /src/utils/git/checkout.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function checkout(repo: string, branch: string) { 4 | return executeCommand('git', ['checkout', branch], { 5 | cwd: repo, 6 | }).catch((error) => { 7 | if (error.message.includes('did not match any file(s) known to git')) { 8 | throw new Error(`'${branch}' does not exist in the repository`) 9 | } 10 | /* istanbul ignore next -- @preserve */ 11 | throw error 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/git/cloneRepo.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function cloneRepo(repo: string, path: string) { 4 | return executeCommand('git', ['clone', repo, path]) 5 | .catch((error) => { 6 | if (error.message.includes('does not exist')) { 7 | throw new Error(`Unable to access repository ${repo}`) 8 | } 9 | /* istanbul ignore next -- @preserve */ 10 | throw error 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/git/commit.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function commit(repoPath: string, message: string) { 4 | await executeCommand('git', ['add', '.'], { cwd: repoPath }) 5 | await executeCommand('git', ['commit', '-m', message], { cwd: repoPath }) 6 | await executeCommand('git', ['push'], { cwd: repoPath }) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/git/hasChanges.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import executeCommand from '../executeCommand.js' 3 | 4 | export default async function hasChanges(filePath: string) { 5 | const untrackedFiles = (await executeCommand('git', [ 6 | 'ls-files', 7 | '--others', 8 | '--exclude-standard', 9 | path.basename(filePath), 10 | ], { 11 | cwd: path.dirname(filePath), 12 | })).trim().split('\n').filter(Boolean) 13 | if (untrackedFiles.length > 0) return true 14 | 15 | return executeCommand('git', [ 16 | 'diff', 17 | '-s', 18 | '--exit-code', 19 | filePath, 20 | ], { 21 | cwd: path.dirname(filePath), 22 | }) 23 | .then(() => false) 24 | .catch(() => true) 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/git/isAncestor.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function isAncestor( 4 | ancestor: string, 5 | descendant: string, 6 | repoPath: string, 7 | ) { 8 | return executeCommand('git', [ 9 | 'merge-base', 10 | '--is-ancestor', 11 | ancestor, 12 | descendant, 13 | ], { 14 | cwd: repoPath, 15 | }) 16 | .then(() => true) 17 | .catch(() => false) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/git/latestHash.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function latestHash(repo: string) { 4 | const output = await executeCommand('git', ['rev-parse', 'HEAD'], { 5 | cwd: repo, 6 | }) 7 | return output.trim() 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/git/remoteDefaultBranch.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | 3 | export default async function remoteDefaultBranch(repoPath: string) { 4 | const remoteInfo = await executeCommand('git', ['remote', 'show', 'origin'], { 5 | cwd: repoPath, 6 | }) 7 | const match = remoteInfo.match(/HEAD branch: (.+)/) 8 | /* istanbul ignore if -- @preserve */ 9 | if (!match) throw new Error('Unable to find default branch') 10 | return match[1] 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/git/reset.ts: -------------------------------------------------------------------------------- 1 | import executeCommand from '../executeCommand.js' 2 | import checkout from './checkout.js' 3 | 4 | export default async function reset(repoPath: string, branch?: string) { 5 | await executeCommand('git', [ 6 | 'reset', 7 | '--hard', 8 | ...branch != null ? [branch] : [], 9 | ], { 10 | cwd: repoPath, 11 | }) 12 | if (branch) await checkout(repoPath, branch) 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [], 4 | "include": [ 5 | ".eslintrc.cjs", 6 | "docs/.vitepress/config.mts", 7 | "**/*.spec.ts", 8 | "**/*.ts", 9 | "**/*.tsx", 10 | "**/*.js", 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "module": "ESNext", 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "declaration": true, 9 | "rootDirs": ["./src"], 10 | "outDir": "./dist", 11 | "strict": true, 12 | "downlevelIteration": true, 13 | "lib": ["es2017", "dom"], 14 | "typeRoots": [ 15 | "node_modules/@types", 16 | ".typings" 17 | ], 18 | "baseUrl": ".", 19 | "noErrorTruncation": true, 20 | }, 21 | "include": [ 22 | "**/*.ts", 23 | ".eslintrc.cjs", 24 | "vite.config.ts", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/triple-slash-reference 2 | /// 3 | import path from 'path' 4 | import { defineConfig } from 'vite' 5 | import GithubActionsReporter from 'vitest-github-actions-reporter' 6 | import typescript from '@rollup/plugin-typescript' 7 | import { typescriptPaths } from 'rollup-plugin-typescript-paths' 8 | import dts from 'vite-plugin-dts' 9 | import tsconfigPaths from 'vite-tsconfig-paths' 10 | 11 | export default defineConfig({ 12 | plugins: [ 13 | dts(), 14 | tsconfigPaths(), 15 | ], 16 | build: { 17 | manifest: true, 18 | minify: true, 19 | reportCompressedSize: true, 20 | lib: { 21 | name: 'blend', 22 | entry: path.resolve(__dirname, 'src/index.ts'), 23 | fileName: format => (format === 'es' ? 'index.mjs' : `index.${format}.js`), 24 | }, 25 | rollupOptions: { 26 | external: [ 27 | 'node:fs', 28 | 'node:path', 29 | 'node:child_process', 30 | 'os', 31 | 'path', 32 | 'fs', 33 | 'constants', 34 | 'url', 35 | 'assert', 36 | ], 37 | plugins: [ 38 | typescriptPaths({ 39 | preserveExtensions: true, 40 | }), 41 | typescript({ 42 | sourceMap: false, 43 | declaration: true, 44 | outDir: 'dist', 45 | }), 46 | ], 47 | }, 48 | }, 49 | test: { 50 | coverage: { 51 | provider: 'istanbul', 52 | exclude: [ 53 | '.eslintrc.cjs', 54 | '**/*.spec.ts', 55 | '**/dist/**', 56 | '**/node_modules/**', 57 | '**/vite.config.ts', 58 | 'commitlint.config.js', 59 | 'src/index.ts', 60 | ], 61 | }, 62 | reporters: process.env.GITHUB_ACTIONS 63 | ? ['default', new GithubActionsReporter()] 64 | : 'default', 65 | }, 66 | }) 67 | --------------------------------------------------------------------------------