├── .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 |
3 |
4 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------