├── .changeset └── config.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-bug_report.yml │ ├── 2-feature_request.yml │ └── config.yml └── workflows │ ├── ci.yml │ ├── pkg-pr-new.yml │ └── publish.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── biome.json ├── cli-reference.md ├── jsrepo.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── schemas ├── project-config.json └── registry-config.json ├── scripts └── generate-reference.ts ├── src ├── api │ └── index.ts ├── cli.ts ├── commands │ ├── add.ts │ ├── auth.ts │ ├── build.ts │ ├── exec.ts │ ├── index.ts │ ├── info.ts │ ├── init.ts │ ├── mcp.ts │ ├── publish.ts │ ├── test.ts │ ├── tokens.ts │ └── update.ts ├── constants.ts ├── index.ts ├── types.ts └── utils │ ├── ai.ts │ ├── ascii.ts │ ├── blocks.ts │ ├── blocks │ ├── commander │ │ └── parsers.ts │ ├── package-managers │ │ └── flags.ts │ └── ts │ │ ├── array.ts │ │ ├── lines.ts │ │ ├── pad.ts │ │ ├── promises.ts │ │ ├── result.ts │ │ ├── sleep.ts │ │ ├── strings.ts │ │ ├── time.ts │ │ └── url.ts │ ├── build │ ├── check.ts │ └── index.ts │ ├── config.ts │ ├── context.ts │ ├── dependencies.ts │ ├── diff.ts │ ├── fetch.ts │ ├── files.ts │ ├── format.ts │ ├── get-latest-version.ts │ ├── get-watermark.ts │ ├── language-support │ ├── css.ts │ ├── html.ts │ ├── index.ts │ ├── javascript.ts │ ├── json.ts │ ├── sass.ts │ ├── svelte.ts │ ├── svg.ts │ ├── vue.ts │ └── yaml.ts │ ├── manifest.ts │ ├── mcp.ts │ ├── package.ts │ ├── parse-package-name.ts │ ├── persisted.ts │ ├── preconditions.ts │ ├── prompts.ts │ ├── registry-providers │ ├── azure.ts │ ├── bitbucket.ts │ ├── github.ts │ ├── gitlab.ts │ ├── http.ts │ ├── index.ts │ ├── internal.ts │ ├── jsrepo.ts │ └── types.ts │ └── token-manager.ts ├── tests ├── add.test.ts ├── build.test.ts ├── language-support.test.ts ├── pre-release.test.ts ├── providers.test.ts ├── unwrap-code.test.ts └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "prettier": false 12 | } 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ieedan] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug report 2 | description: Help us improve jsrepo. 3 | labels: ["bug"] 4 | title: "bug: " 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## Thanks for helping us fix jsrepo! 10 | Before continuing make sure you have checked other issues to see if your issue has already been reported / addressed. 11 | - type: textarea 12 | id: desc 13 | attributes: 14 | label: Describe the issue 15 | description: What is happening right now? What is supposed to happen? 16 | placeholder: When I ... jsrepo ... but it should ... 17 | validations: 18 | required: true 19 | - type: markdown 20 | attributes: 21 | value: | 22 | ## Reproduction 23 | 24 | Most of the time it will be difficult to reproduce your issue if we don't have access to the source registry. 25 | Please provide a public registry from any supported provider that can be used to reproduce your issue. 26 | 27 | **For build issues**: 28 | Provide a link to the source repository where the issue can be reproduced as well as any reproduction steps. 29 | 30 | **For install issues**: 31 | Using a public registry please provide the steps needed to reproduce. 32 | - type: input 33 | id: registry-link 34 | attributes: 35 | label: Registry Link 36 | description: Link the registry to use to reproduce. 37 | placeholder: https://github.com/ieedan/std 38 | validations: 39 | required: false 40 | - type: input 41 | id: repro-link 42 | attributes: 43 | label: Reproduction Link 44 | description: Link to the reproduction. 45 | placeholder: https://github.com/jsrepojs/jsrepo-repro 46 | validations: 47 | required: true 48 | - type: textarea 49 | id: repro-steps 50 | attributes: 51 | label: Steps to reproduce 52 | description: What steps should be taken to reproduce your issue. 53 | placeholder: | 54 | Run: 55 | 56 | ``` 57 | jsrepo add utils/math 58 | ``` 59 | validations: 60 | required: true 61 | - type: checkboxes 62 | id: terms 63 | attributes: 64 | label: Validations 65 | description: Please make sure you have checked all of the following. 66 | options: 67 | - label: I have checked other issues to see if my issue was already reported or addressed 68 | required: true 69 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🆕 Feature request 2 | description: Help us improve jsrepo. 3 | labels: ["feature"] 4 | title: "feat: " 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | ## Thanks for helping us improve jsrepo! 10 | Before continuing make sure you have checked other issues to see if your feature was already requested / added. 11 | 12 | - type: textarea 13 | id: desc 14 | attributes: 15 | label: Describe the feature 16 | description: What doesn't jsrepo do now? What should it do? 17 | validations: 18 | required: true 19 | 20 | - type: checkboxes 21 | id: terms 22 | attributes: 23 | label: Validations 24 | description: Please make sure you have checked all of the following. 25 | options: 26 | - label: I have checked other issues to see if my feature was already requested or added 27 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Get Help 4 | url: https://github.com/jsrepojs/jsrepo/discussions/new?category=help 5 | about: If you can't get something to work the way you expect, open a question in our discussion forums. 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | CI: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: "20" 16 | cache: pnpm 17 | 18 | - name: Install dependencies 19 | run: pnpm install 20 | 21 | - name: Check Types 22 | run: pnpm check 23 | 24 | - name: Test 25 | run: pnpm test 26 | 27 | - name: Build 28 | run: pnpm build 29 | -------------------------------------------------------------------------------- /.github/workflows/pkg-pr-new.yml: -------------------------------------------------------------------------------- 1 | name: CLI Preview 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: "20" 15 | cache: pnpm 16 | 17 | - name: Install dependencies 18 | run: pnpm install 19 | 20 | - name: Build 21 | run: pnpm build 22 | 23 | - name: publish preview 24 | # run: | 25 | run: pnpm pkg-pr-new publish --bin --packageManager=pnpm -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Build & Publish Release 13 | if: github.repository == 'jsrepojs/jsrepo' 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: "20" 22 | cache: pnpm 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Create Release Pull Request or Publish 28 | id: changesets 29 | uses: changesets/action@v1 30 | with: 31 | commit: "chore(release): version package" 32 | title: "chore(release): version package" 33 | version: pnpm changeset:version 34 | publish: pnpm ci:release 35 | env: 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NODE_ENV: production 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # out 4 | dist 5 | 6 | # testing 7 | temp-test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aidan Bleser 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 |

jsrepo

7 | 8 |

9 | 10 |

11 | 12 | 13 | 14 |

15 | 16 | **jsrepo** is a CLI to build and distribute your code. It takes inspiration from the way that [shadcn/ui](https://ui.shadcn.com/) allows you install portable blocks of code. 17 | 18 | The goal of jsrepo is to make this method of distributing code easier and more maintainable. 19 | 20 | It does this by unifying the tooling it takes to build a registry, with the tooling it takes to distribute it. As well as providing a rich feature set to make maintaining that code much easier. 21 | 22 | ## Getting Started 23 | 24 | - [Create a registry](https://jsrepo.dev/docs/registry) 25 | - [Download your blocks](https://jsrepo.dev/docs/setup) 26 | 27 | ## Demos 28 | 29 | - [Create your first registry](https://youtu.be/IyJQI3z8PWg) 30 | - [Build your own shadcn-style library with jsrepo](https://youtu.be/zWfBt1vKb84) 31 | - [Building the shadcn/ui registry with jsrepo](https://youtu.be/tj7BUE9V7fw) 32 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "tab", 7 | "indentWidth": 4, 8 | "lineEnding": "lf", 9 | "lineWidth": 100, 10 | "attributePosition": "auto" 11 | }, 12 | "files": { 13 | "ignore": ["dist", "node_modules", "docs", "temp-test"] 14 | }, 15 | "organizeImports": { "enabled": true }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "complexity": { 21 | "noExtraBooleanCast": "error", 22 | "noMultipleSpacesInRegularExpressionLiterals": "error", 23 | "noUselessCatch": "error", 24 | "noUselessTypeConstraint": "error", 25 | "noWith": "error" 26 | }, 27 | "correctness": { 28 | "noConstAssign": "error", 29 | "noConstantCondition": "error", 30 | "noEmptyCharacterClassInRegex": "error", 31 | "noEmptyPattern": "error", 32 | "noGlobalObjectCalls": "error", 33 | "noInvalidConstructorSuper": "error", 34 | "noInvalidNewBuiltin": "error", 35 | "noNonoctalDecimalEscape": "error", 36 | "noPrecisionLoss": "error", 37 | "noSelfAssign": "error", 38 | "noSetterReturn": "error", 39 | "noSwitchDeclarations": "error", 40 | "noUndeclaredVariables": "error", 41 | "noUnreachable": "error", 42 | "noUnreachableSuper": "error", 43 | "noUnsafeFinally": "error", 44 | "noUnsafeOptionalChaining": "error", 45 | "noUnusedLabels": "error", 46 | "noUnusedPrivateClassMembers": "error", 47 | "noUnusedVariables": "error", 48 | "useArrayLiterals": "off", 49 | "useIsNan": "error", 50 | "useValidForDirection": "error", 51 | "useYield": "error" 52 | }, 53 | "style": { 54 | "noNamespace": "error", 55 | "useAsConstAssertion": "error", 56 | "noNonNullAssertion": "off" 57 | }, 58 | "suspicious": { 59 | "noAsyncPromiseExecutor": "error", 60 | "noCatchAssign": "error", 61 | "noClassAssign": "error", 62 | "noCompareNegZero": "error", 63 | "noControlCharactersInRegex": "error", 64 | "noDebugger": "error", 65 | "noDuplicateCase": "error", 66 | "noDuplicateClassMembers": "error", 67 | "noDuplicateObjectKeys": "error", 68 | "noDuplicateParameters": "error", 69 | "noEmptyBlockStatements": "error", 70 | "noExplicitAny": "error", 71 | "noExtraNonNullAssertion": "error", 72 | "noFallthroughSwitchClause": "error", 73 | "noFunctionAssign": "error", 74 | "noGlobalAssign": "error", 75 | "noImportAssign": "error", 76 | "noMisleadingCharacterClass": "error", 77 | "noMisleadingInstantiator": "error", 78 | "noPrototypeBuiltins": "error", 79 | "noRedeclare": "error", 80 | "noShadowRestrictedNames": "error", 81 | "noUnsafeDeclarationMerging": "error", 82 | "noUnsafeNegation": "error", 83 | "useGetterReturn": "error", 84 | "useNamespaceKeyword": "error", 85 | "useValidTypeof": "error" 86 | } 87 | }, 88 | "ignore": ["**/dist/*", "**/node_modules/*"] 89 | }, 90 | "javascript": { 91 | "formatter": { 92 | "jsxQuoteStyle": "double", 93 | "quoteProperties": "asNeeded", 94 | "trailingCommas": "es5", 95 | "semicolons": "always", 96 | "arrowParentheses": "always", 97 | "bracketSpacing": true, 98 | "bracketSameLine": false, 99 | "quoteStyle": "single", 100 | "attributePosition": "auto" 101 | }, 102 | "globals": [] 103 | }, 104 | "overrides": [ 105 | { 106 | "include": ["*.md"], 107 | "formatter": { 108 | "indentStyle": "space", 109 | "indentWidth": 2, 110 | "lineWidth": 100 111 | } 112 | } 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /cli-reference.md: -------------------------------------------------------------------------------- 1 | # jsrepo 2 | 3 | > A CLI to add shared code from remote repositories. 4 | 5 | Latest Version: 2.3.3 6 | 7 | ## Commands 8 | 9 | ### add 10 | 11 | Add blocks to your project. 12 | 13 | #### Usage 14 | ```bash 15 | jsrepo add [options] [blocks...] 16 | ``` 17 | 18 | #### Options 19 | - --formatter : The formatter to use when adding blocks. 20 | - --watermark : Include a watermark at the top of added files. 21 | - --tests : Include tests when adding blocks. 22 | - --paths : The paths where categories should be added to your project. 23 | - -E, --expand: Expands the diff so you see the entire file. 24 | - --max-unchanged : Maximum unchanged lines that will show without being collapsed. (default: 3) 25 | - --repo : Repository to download the blocks from. 26 | - -A, --allow: Allow jsrepo to download code from the provided repo. 27 | - -y, --yes: Skip confirmation prompt. 28 | - --no-cache: Disable caching of resolved git urls. 29 | - --verbose: Include debug logs. 30 | - --cwd : The current working directory. (default: ./) 31 | 32 | ### auth 33 | 34 | Authenticate to jsrepo.com 35 | 36 | #### Usage 37 | ```bash 38 | jsrepo auth [options] 39 | ``` 40 | 41 | #### Options 42 | - --logout: Execute the logout flow. 43 | - --token : The token to use for authenticating to this service. 44 | - --cwd : The current working directory. (default: ./) 45 | 46 | ### build 47 | 48 | Builds the provided --dirs in the project root into a `jsrepo-manifest.json` file. 49 | 50 | #### Usage 51 | ```bash 52 | jsrepo build [options] 53 | ``` 54 | 55 | #### Options 56 | - --dirs [dirs...]: The directories containing the blocks. 57 | - --output-dir : The directory to output the registry to. (Copies jsrepo-manifest.json + all required files) 58 | - --include-blocks [blockNames...]: Include only the blocks with these names. 59 | - --include-categories [categoryNames...]: Include only the categories with these names. 60 | - --exclude-blocks [blockNames...]: Do not include the blocks with these names. 61 | - --exclude-categories [categoryNames...]: Do not include the categories with these names. 62 | - --list-blocks [blockNames...]: List only the blocks with these names. 63 | - --list-categories [categoryNames...]: List only the categories with these names. 64 | - --do-not-list-blocks [blockNames...]: Do not list the blocks with these names. 65 | - --do-not-list-categories [categoryNames...]: Do not list the categories with these names. 66 | - --exclude-deps [deps...]: Dependencies that should not be added. 67 | - --allow-subdirectories: Allow subdirectories to be built. 68 | - --preview: Display a preview of the blocks list. 69 | - --no-output: Do not output a `jsrepo-manifest.json` file. 70 | - --verbose: Include debug logs. 71 | - --cwd : The current working directory. (default: ./) 72 | 73 | ### exec 74 | 75 | Execute a block as a script. 76 | 77 | #### Usage 78 | ```bash 79 | jsrepo exec [options] [script] 80 | ``` 81 | 82 | #### Options 83 | - --repo : Repository to download and run the script from. 84 | - -A, --allow: Allow jsrepo to download code from the provided repo. 85 | - --no-cache: Disable caching of resolved git urls. 86 | - --verbose: Include debug logs. 87 | - --cwd : The current working directory. (default: ./) 88 | 89 | ### info 90 | 91 | Get info about a registry on jsrepo.com 92 | 93 | #### Usage 94 | ```bash 95 | jsrepo info [options] 96 | ``` 97 | 98 | #### Options 99 | - --json: Output the response in formatted JSON. 100 | 101 | ### init 102 | 103 | Initializes your project with a configuration file. 104 | 105 | #### Usage 106 | ```bash 107 | jsrepo init [options] [registries...] 108 | ``` 109 | 110 | #### Options 111 | - --repos [repos...]: Repository to install the blocks from. (DEPRECATED) 112 | - --no-watermark: Will not add a watermark to each file upon adding it to your project. 113 | - --tests: Will include tests with the blocks. 114 | - --formatter : What formatter to use when adding or updating blocks. 115 | - --paths ,: The paths to install the blocks to. (default: [object Object]) 116 | - --config-files ,: The paths to install the config files to. (default: [object Object]) 117 | - -P, --project: Takes you through the steps to initialize a project. 118 | - -R, --registry: Takes you through the steps to initialize a registry. 119 | - --build-script : The name of the build script. (For Registry setup) (default: build:registry) 120 | - --publish-script : The name of the publish script. (For Registry setup) (default: release:registry) 121 | - -E, --expand: Expands the diff so you see the entire file. 122 | - --max-unchanged : Maximum unchanged lines that will show without being collapsed. (default: 3) 123 | - -y, --yes: Skip confirmation prompt. 124 | - --no-cache: Disable caching of resolved git urls. 125 | - --cwd : The current working directory. (default: ./) 126 | 127 | ### mcp 128 | 129 | Interact with jsrepo through an MCP server. 130 | 131 | #### Usage 132 | ```bash 133 | jsrepo mcp [options] 134 | ``` 135 | 136 | #### Options 137 | - --cwd : The current working directory. (default: ./) 138 | 139 | ### publish 140 | 141 | Publish a registry to jsrepo.com. 142 | 143 | #### Usage 144 | ```bash 145 | jsrepo publish [options] 146 | ``` 147 | 148 | #### Options 149 | - --private: When publishing the first version of the registry make it private. 150 | - --dry-run: Test the publish but don't list on jsrepo.com. 151 | - --name : The name of the registry. i.e. @ieedan/std 152 | - --ver : The version of the registry. i.e. 0.0.1 153 | - --dirs [dirs...]: The directories containing the blocks. 154 | - --include-blocks [blockNames...]: Include only the blocks with these names. 155 | - --include-categories [categoryNames...]: Include only the categories with these names. 156 | - --exclude-blocks [blockNames...]: Do not include the blocks with these names. 157 | - --exclude-categories [categoryNames...]: Do not include the categories with these names. 158 | - --list-blocks [blockNames...]: List only the blocks with these names. 159 | - --list-categories [categoryNames...]: List only the categories with these names. 160 | - --do-not-list-blocks [blockNames...]: Do not list the blocks with these names. 161 | - --do-not-list-categories [categoryNames...]: Do not list the categories with these names. 162 | - --exclude-deps [deps...]: Dependencies that should not be added. 163 | - --allow-subdirectories: Allow subdirectories to be built. 164 | - --verbose: Include debug logs. 165 | - --cwd : The current working directory. (default: ./) 166 | 167 | ### test 168 | 169 | Tests local blocks against most recent remote tests. 170 | 171 | #### Usage 172 | ```bash 173 | jsrepo test [options] [blocks...] 174 | ``` 175 | 176 | #### Options 177 | - --repo : Repository to download the blocks from. 178 | - -A, --allow: Allow jsrepo to download code from the provided repo. 179 | - --debug: Leaves the temp test file around for debugging upon failure. 180 | - --no-cache: Disable caching of resolved git urls. 181 | - --verbose: Include debug logs. 182 | - --cwd : The current working directory. (default: ./) 183 | 184 | ### tokens 185 | 186 | Provide a token for access to private repositories. 187 | 188 | #### Usage 189 | ```bash 190 | jsrepo tokens [options] [service] 191 | ``` 192 | 193 | #### Options 194 | - --logout: Execute the logout flow. 195 | - --token : The token to use for authenticating to this service. 196 | - --cwd : The current working directory. (default: ./) 197 | 198 | ### update 199 | 200 | Update blocks to the code in the remote repository. 201 | 202 | #### Usage 203 | ```bash 204 | jsrepo update [options] [blocks...] 205 | ``` 206 | 207 | #### Options 208 | - --all: Update all installed components. 209 | - -E, --expand: Expands the diff so you see the entire file. 210 | - --max-unchanged : Maximum unchanged lines that will show without being collapsed. (default: 3) 211 | - -n, --no: Do update any blocks. 212 | - --repo : Repository to download the blocks from. 213 | - -A, --allow: Allow jsrepo to download code from the provided repo. 214 | - -y, --yes: Skip confirmation prompt. 215 | - --no-cache: Disable caching of resolved git urls. 216 | - --verbose: Include debug logs. 217 | - --cwd : The current working directory. (default: ./) 218 | 219 | -------------------------------------------------------------------------------- /jsrepo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/jsrepo@2.1.0/schemas/project-config.json", 3 | "repos": ["@ieedan/std"], 4 | "includeTests": false, 5 | "watermark": true, 6 | "formatter": "biome", 7 | "configFiles": {}, 8 | "paths": { 9 | "*": "./src/utils/blocks" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsrepo", 3 | "description": "A CLI to add shared code from remote repositories.", 4 | "version": "2.3.3", 5 | "license": "MIT", 6 | "homepage": "https://jsrepo.dev", 7 | "author": { 8 | "name": "Aidan Bleser", 9 | "url": "https://github.com/ieedan" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/jsrepojs/jsrepo" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/jsrepojs/jsrepo/issues" 17 | }, 18 | "keywords": ["repo", "cli", "svelte", "vue", "typescript", "javascript", "shadcn", "registry"], 19 | "type": "module", 20 | "packageManager": "pnpm@10.8.1", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/api/index.d.ts", 24 | "default": "./dist/api/index.js" 25 | } 26 | }, 27 | "bin": "./dist/index.js", 28 | "main": "./dist/index.js", 29 | "files": ["./schemas/**/*", "dist/**/*"], 30 | "scripts": { 31 | "start": "tsup --silent && node ./dist/index.js", 32 | "build": "tsup", 33 | "generate:reference": "pnpm dlx tsx ./scripts/generate-reference.ts ", 34 | "run:dev": "node ./dist/index.js", 35 | "format": "biome format --write", 36 | "lint": "biome lint --write", 37 | "check": "biome check && pnpm check:types", 38 | "test": "vitest", 39 | "check:types": "tsc", 40 | "changeset:version": "changeset version && pnpm generate:reference && pnpm format", 41 | "ci:release": "pnpm build && changeset publish", 42 | "mcp:inspector": "pnpm build && pnpm dlx @modelcontextprotocol/inspector node ./dist/index.js mcp" 43 | }, 44 | "devDependencies": { 45 | "@biomejs/biome": "1.9.4", 46 | "@changesets/cli": "^2.29.4", 47 | "@types/make-fetch-happen": "^10.0.4", 48 | "@types/node": "^22.15.24", 49 | "@types/semver": "^7.7.0", 50 | "@types/validate-npm-package-name": "^4.0.2", 51 | "pkg-pr-new": "^0.0.51", 52 | "tsup": "^8.5.0", 53 | "typescript": "^5.8.3", 54 | "vitest": "^3.1.4" 55 | }, 56 | "dependencies": { 57 | "@anthropic-ai/sdk": "^0.52.0", 58 | "@biomejs/js-api": "^0.7.1", 59 | "@biomejs/wasm-nodejs": "^1.9.4", 60 | "@clack/prompts": "^0.11.0", 61 | "@modelcontextprotocol/sdk": "^1.12.0", 62 | "boxen": "^8.0.1", 63 | "chalk": "^5.4.1", 64 | "commander": "^14.0.0", 65 | "conf": "^13.1.0", 66 | "css-dependency": "^0.0.3", 67 | "diff": "^8.0.2", 68 | "escape-string-regexp": "^5.0.0", 69 | "estree-walker": "^3.0.3", 70 | "get-tsconfig": "^4.10.1", 71 | "ignore": "^7.0.4", 72 | "is-unicode-supported": "^2.1.0", 73 | "make-fetch-happen": "^14.0.3", 74 | "node-machine-id": "^1.1.12", 75 | "ollama": "^0.5.14", 76 | "openai": "^4.103.0", 77 | "oxc-parser": "^0.72.1", 78 | "package-manager-detector": "^1.3.0", 79 | "parse5": "^7.3.0", 80 | "pathe": "^2.0.3", 81 | "prettier": "^3.5.3", 82 | "semver": "^7.7.2", 83 | "sisteransi": "^1.0.5", 84 | "svelte": "^5.33.6", 85 | "tar": "^7.4.3", 86 | "tinyexec": "^1.0.1", 87 | "valibot": "1.1.0", 88 | "validate-npm-package-name": "^6.0.0", 89 | "vue": "^3.5.16" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@biomejs/biome' 3 | - esbuild 4 | -------------------------------------------------------------------------------- /schemas/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "repos": { 6 | "description": "Repositories to download code from.", 7 | "type": "array", 8 | "items": { 9 | "type": "string" 10 | } 11 | }, 12 | "includeTests": { 13 | "description": "When true includes the test files for each block in the same directory.", 14 | "type": "boolean", 15 | "default": "true" 16 | }, 17 | "watermark": { 18 | "description": "When true will add a watermark with the version and repository at the top of the installed files.", 19 | "type": "boolean", 20 | "default": "true" 21 | }, 22 | "formatter": { 23 | "description": "The formatter to use when adding or updating files.", 24 | "type": "string", 25 | "enum": ["prettier", "biome"] 26 | }, 27 | "configFiles": { 28 | "description": "Config file names mapped to their respective path.", 29 | "type": "object", 30 | "additionalProperties": { 31 | "type": "string" 32 | } 33 | }, 34 | "paths": { 35 | "description": "Paths used to map categories to a directory. TypeScript path aliases are allowed, any relative paths must start with `./`", 36 | "type": "object", 37 | "required": ["*"], 38 | "properties": { 39 | "*": { 40 | "type": "string", 41 | "description": "The default path for blocks to be installed in your project." 42 | } 43 | }, 44 | "additionalProperties": { 45 | "type": "string" 46 | }, 47 | "propertyNames": { 48 | "type": "string" 49 | } 50 | } 51 | }, 52 | "required": ["paths", "includeTests"] 53 | } 54 | -------------------------------------------------------------------------------- /schemas/registry-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "properties": { 5 | "name": { 6 | "type": "string", 7 | "description": "The name of the registry on jsrepo.com.", 8 | "examples": ["@ieedan/std", "@shadcn/ui"] 9 | }, 10 | "version": { 11 | "type": "string", 12 | "description": "A semver compatible version of the registry on jsrepo.com or `package` to use the version from the `package.json`", 13 | "format": "semver", 14 | "examples": ["1.0.0", "package"] 15 | }, 16 | "readme": { 17 | "type": "string", 18 | "description": "Path to the README file that will be packaged with the registry.", 19 | "default": "README.md", 20 | "examples": ["README.md"] 21 | }, 22 | "access": { 23 | "description": "Who has access to your registry on jsrepo.com.", 24 | "type": "string", 25 | "default": "public", 26 | "enum": ["public", "private", "marketplace"] 27 | }, 28 | "defaultPaths": { 29 | "type": "object", 30 | "description": "A map of category names to default paths for the registry.", 31 | "additionalProperties": { 32 | "type": "string", 33 | "description": "The default path for the given category." 34 | } 35 | }, 36 | "meta": { 37 | "description": "Optional metadata to include in the manifest file.", 38 | "type": "object", 39 | "properties": { 40 | "authors": { 41 | "description": "The names of the authors of this registry.", 42 | "type": "array", 43 | "items": { 44 | "type": "string" 45 | } 46 | }, 47 | "bugs": { 48 | "description": "Where users should report bugs.", 49 | "type": "string" 50 | }, 51 | "description": { 52 | "description": "A description of the registry.", 53 | "type": "string" 54 | }, 55 | "homepage": { 56 | "description": "The URL to the registry homepage.", 57 | "type": "string", 58 | "format": "uri" 59 | }, 60 | "repository": { 61 | "description": "The source repository for the registry. (Omit this if you are distributing from a git repository)", 62 | "type": "string", 63 | "format": "uri" 64 | }, 65 | "tags": { 66 | "description": "Keywords that describe your registry.", 67 | "type": "array", 68 | "items": { 69 | "type": "string" 70 | } 71 | } 72 | } 73 | }, 74 | "peerDependencies": { 75 | "description": "A list of dependencies that are not installed by blocks or config files but are required for your registry to function in users projects.", 76 | "type": "object", 77 | "additionalProperties": { 78 | "oneOf": [ 79 | { 80 | "type": "string", 81 | "description": "The version or version range that is supported by your registry." 82 | }, 83 | { 84 | "type": "object", 85 | "properties": { 86 | "version": { 87 | "type": "string", 88 | "description": "The version or version range that is supported by your registry." 89 | }, 90 | "message": { 91 | "type": "string", 92 | "description": "A message displayed to users when installing with an incompatible peer dependency." 93 | } 94 | }, 95 | "required": ["version", "message"], 96 | "additionalProperties": false 97 | } 98 | ] 99 | } 100 | }, 101 | "configFiles": { 102 | "description": "Any config files that should be included when initializing the registry.", 103 | "type": "array", 104 | "items": { 105 | "type": "object", 106 | "properties": { 107 | "name": { 108 | "description": "Name of the config file.", 109 | "type": "string" 110 | }, 111 | "path": { 112 | "description": "Path where the config file lives.", 113 | "type": "string" 114 | }, 115 | "expectedPath": { 116 | "description": "Path to default to searching in the users project.", 117 | "type": "string" 118 | }, 119 | "optional": { 120 | "description": "Config file is optional and the user will be prompted accordingly.", 121 | "type": "boolean", 122 | "default": false 123 | } 124 | }, 125 | "required": ["name", "path"] 126 | } 127 | }, 128 | "dirs": { 129 | "description": "Directories that contain the categories you want to build into the manifest.", 130 | "type": "array", 131 | "items": { 132 | "type": "string" 133 | } 134 | }, 135 | "outputDir": { 136 | "description": "The directory to output the registry to. (Copies jsrepo-manifest.json + all required files)", 137 | "type": "string" 138 | }, 139 | "includeBlocks": { 140 | "description": "The names of the blocks that should be included in the manifest.", 141 | "type": "array", 142 | "items": { 143 | "type": "string" 144 | } 145 | }, 146 | "includeCategories": { 147 | "description": "The names of the categories that should be included in the manifest.", 148 | "type": "array", 149 | "items": { 150 | "type": "string" 151 | } 152 | }, 153 | "excludeBlocks": { 154 | "description": "The names of the blocks that should not be included in the manifest.", 155 | "type": "array", 156 | "items": { 157 | "type": "string" 158 | } 159 | }, 160 | "excludeCategories": { 161 | "description": "The names of the categories that should not be included in the manifest.", 162 | "type": "array", 163 | "items": { 164 | "type": "string" 165 | } 166 | }, 167 | "listBlocks": { 168 | "description": "List only the blocks with these names.", 169 | "type": "array", 170 | "items": { 171 | "type": "string" 172 | } 173 | }, 174 | "listCategories": { 175 | "description": "List only the categories with these names.", 176 | "type": "array", 177 | "items": { 178 | "type": "string" 179 | } 180 | }, 181 | "doNotListBlocks": { 182 | "description": "Do not list the blocks with these names.", 183 | "type": "array", 184 | "items": { 185 | "type": "string" 186 | } 187 | }, 188 | "doNotListCategories": { 189 | "description": "Do not list the categories with these names.", 190 | "type": "array", 191 | "items": { 192 | "type": "string" 193 | } 194 | }, 195 | "excludeDeps": { 196 | "description": "Remote dependencies that should not be added to the manifest.", 197 | "type": "array", 198 | "items": { 199 | "type": "string" 200 | } 201 | }, 202 | "allowSubdirectories": { 203 | "description": "Allow subdirectories to be built.", 204 | "type": "boolean", 205 | "default": false 206 | }, 207 | "preview": { 208 | "description": "Display a preview of the blocks list.", 209 | "type": "boolean", 210 | "default": false 211 | }, 212 | "rules": { 213 | "description": "Configure rules when checking manifest after build.", 214 | "type": "object", 215 | "properties": { 216 | "no-category-index-file-dependency": { 217 | "description": "Disallow depending on the index file of a category.", 218 | "type": "string", 219 | "enum": ["error", "warn", "off"], 220 | "default": "warn" 221 | }, 222 | "no-unpinned-dependency": { 223 | "description": "Require all dependencies to have a pinned version.", 224 | "type": "string", 225 | "enum": ["error", "warn", "off"], 226 | "default": "warn" 227 | }, 228 | "require-local-dependency-exists": { 229 | "description": "Require all local dependencies to exist.", 230 | "type": "string", 231 | "enum": ["error", "warn", "off"], 232 | "default": "error" 233 | }, 234 | "max-local-dependencies": { 235 | "description": "Enforces a limit on the amount of local dependencies a block can have.", 236 | "type": "array", 237 | "items": [ 238 | { 239 | "type": "string", 240 | "enum": ["error", "warn", "off"], 241 | "default": "warn" 242 | }, 243 | { 244 | "description": "Max local dependencies", 245 | "type": "number", 246 | "default": 10 247 | } 248 | ], 249 | "default": ["warn", 10] 250 | }, 251 | "no-circular-dependency": { 252 | "description": "Disallow circular dependencies.", 253 | "type": "string", 254 | "enum": ["error", "warn", "off"], 255 | "default": "error" 256 | }, 257 | "no-unused-block": { 258 | "description": "Disallow unused blocks. (Not listed and not a dependency of another block)", 259 | "type": "string", 260 | "enum": ["error", "warn", "off"], 261 | "default": "warn" 262 | }, 263 | "no-framework-dependency": { 264 | "description": "Disallow frameworks (Svelte, Vue, React, etc.) as dependencies.", 265 | "type": "string", 266 | "enum": ["error", "warn", "off"], 267 | "default": "warn" 268 | }, 269 | "require-config-file-exists": { 270 | "description": "Require all of the paths listed in `configFiles` to exist.", 271 | "type": "string", 272 | "enum": ["error", "warn", "off"], 273 | "default": "error" 274 | }, 275 | "no-config-file-framework-dependency": { 276 | "description": "Disallow frameworks (Svelte, Vue, React, etc.) as dependencies of config files.", 277 | "type": "string", 278 | "enum": ["error", "warn", "off"], 279 | "default": "warn" 280 | }, 281 | "no-config-file-unpinned-dependency": { 282 | "description": "Require all dependencies of config files to have a pinned version.", 283 | "type": "string", 284 | "enum": ["error", "warn", "off"], 285 | "default": "warn" 286 | } 287 | }, 288 | "default": { 289 | "no-category-index-file-dependency": "warn", 290 | "no-unpinned-dependency": "warn", 291 | "require-local-dependency-exists": "error", 292 | "max-local-dependencies": ["warn", 10], 293 | "no-circular-dependency": "error", 294 | "no-unused-block": "warn", 295 | "no-framework-dependency": "warn", 296 | "require-config-file-exists": "error", 297 | "no-config-file-framework-dependency": "warn", 298 | "no-config-file-unpinned-dependency": "warn" 299 | } 300 | } 301 | }, 302 | "required": ["dirs"] 303 | } 304 | -------------------------------------------------------------------------------- /scripts/generate-reference.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { cli } from '../src/cli'; 3 | 4 | const docsOutput = './cli-reference.md'; 5 | 6 | const docs = `# ${cli.name()} 7 | 8 | > ${cli.description()} 9 | 10 | Latest Version: ${cli.version()} 11 | 12 | ## Commands 13 | 14 | ${cli.commands 15 | .map((cmd) => { 16 | return `### ${cmd.name()} 17 | 18 | ${cmd.description()} 19 | 20 | #### Usage 21 | \`\`\`bash 22 | ${cli.name()} ${cmd.name()} ${cmd.usage()} 23 | \`\`\` 24 | 25 | #### Options 26 | ${cmd.options 27 | .map((opt) => { 28 | let defaultValue = opt.defaultValue; 29 | if (opt.flags === '--cwd ') { 30 | defaultValue = './'; 31 | } 32 | return `- ${opt.flags}: ${opt.description} ${defaultValue ? `(default: ${defaultValue})\n` : '\n'}`; 33 | }) 34 | .join('')} 35 | `; 36 | }) 37 | .join('')}`; 38 | 39 | fs.writeFileSync(docsOutput, docs); 40 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | // export any public facing apis from here 2 | // nothing exported from this file should rely on node 3 | 4 | export * from '../constants'; 5 | export * from '../types'; 6 | export * from '../utils/registry-providers/index'; 7 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander'; 2 | import pkg from '../package.json'; 3 | import * as commands from './commands'; 4 | 5 | const cli = program 6 | .name(pkg.name) 7 | .description(pkg.description) 8 | .version(pkg.version) 9 | .addCommand(commands.add) 10 | .addCommand(commands.auth) 11 | .addCommand(commands.build) 12 | .addCommand(commands.exec) 13 | .addCommand(commands.info) 14 | .addCommand(commands.init) 15 | .addCommand(commands.mcp) 16 | .addCommand(commands.publish) 17 | .addCommand(commands.test) 18 | .addCommand(commands.tokens) 19 | .addCommand(commands.update); 20 | 21 | export { cli }; 22 | -------------------------------------------------------------------------------- /src/commands/auth.ts: -------------------------------------------------------------------------------- 1 | import { cancel, confirm, isCancel, log, outro } from '@clack/prompts'; 2 | import color from 'chalk'; 3 | import { Command, program } from 'commander'; 4 | import nodeMachineId from 'node-machine-id'; 5 | import * as v from 'valibot'; 6 | import * as ASCII from '../utils/ascii'; 7 | import { sleep } from '../utils/blocks/ts/sleep'; 8 | import { iFetch } from '../utils/fetch'; 9 | import { intro, spinner } from '../utils/prompts'; 10 | import * as jsrepo from '../utils/registry-providers/jsrepo'; 11 | import { TokenManager } from '../utils/token-manager'; 12 | 13 | const schema = v.object({ 14 | token: v.optional(v.string()), 15 | logout: v.boolean(), 16 | cwd: v.string(), 17 | }); 18 | 19 | type Options = v.InferInput; 20 | 21 | export const auth = new Command('auth') 22 | .description('Authenticate to jsrepo.com') 23 | .option('--logout', 'Execute the logout flow.', false) 24 | .option('--token ', 'The token to use for authenticating to this service.') 25 | .option('--cwd ', 'The current working directory.', process.cwd()) 26 | .action(async (opts) => { 27 | const options = v.parse(schema, opts); 28 | 29 | await intro(); 30 | 31 | await _auth(options); 32 | 33 | outro(color.green('All done!')); 34 | }); 35 | 36 | async function _auth(options: Options) { 37 | const tokenManager = new TokenManager(); 38 | 39 | if (options.logout) { 40 | tokenManager.delete('jsrepo'); 41 | log.success(`Logged out of ${ASCII.JSREPO_DOT_COM}!`); 42 | return; 43 | } 44 | 45 | if (options.token !== undefined) { 46 | tokenManager.set('jsrepo', options.token); 47 | log.success(`Logged into ${ASCII.JSREPO_DOT_COM}!`); 48 | return; 49 | } 50 | 51 | if (tokenManager.get('jsrepo') !== undefined) { 52 | const result = await confirm({ 53 | message: 'You are currently signed into jsrepo do you want to sign out?', 54 | initialValue: false, 55 | }); 56 | 57 | if (isCancel(result) || !result) { 58 | cancel('Canceled!'); 59 | process.exit(0); 60 | } 61 | } 62 | 63 | const hardwareId = nodeMachineId.machineIdSync(true); 64 | 65 | let anonSessionId: string; 66 | 67 | try { 68 | const response = await iFetch(`${jsrepo.BASE_URL}/api/login/device`, { 69 | method: 'POST', 70 | headers: { 'content-type': 'application/json' }, 71 | body: JSON.stringify({ hardwareId }), 72 | }); 73 | 74 | if (!response.ok) { 75 | throw new Error('There was an error creating the session'); 76 | } 77 | 78 | const res = await response.json(); 79 | 80 | anonSessionId = res.id; 81 | } catch (err) { 82 | program.error(color.red(err)); 83 | } 84 | 85 | log.step(`Sign in at ${color.cyan(`${jsrepo.BASE_URL}/login/device/${anonSessionId}`)}`); 86 | 87 | const timeout = 1000 * 60 * 60 * 15; // 15 minutes 88 | 89 | const loading = spinner(); 90 | 91 | const pollingTimeout = setTimeout(() => { 92 | loading.stop('You never signed in.'); 93 | 94 | program.error(color.red('Session timed out try again!')); 95 | }, timeout); 96 | 97 | loading.start('Waiting for you to sign in...'); 98 | 99 | while (true) { 100 | // wait initially cause c'mon ain't no way 101 | await sleep(5000); // wait 5 seconds 102 | 103 | const endpoint = `${jsrepo.BASE_URL}/api/login/device/${anonSessionId}`; 104 | 105 | try { 106 | const response = await iFetch(endpoint, { 107 | method: 'PATCH', 108 | headers: { 'content-type': 'application/json' }, 109 | body: JSON.stringify({ hardwareId }), 110 | }); 111 | 112 | if (!response.ok) continue; 113 | 114 | clearTimeout(pollingTimeout); 115 | 116 | const key = await response.text(); 117 | 118 | tokenManager.set('jsrepo', key); 119 | 120 | loading.stop(`Logged into ${ASCII.JSREPO_DOT_COM}!`); 121 | 122 | break; 123 | } catch { 124 | // continue 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { add } from './add'; 2 | import { auth } from './auth'; 3 | import { build } from './build'; 4 | import { exec } from './exec'; 5 | import { info } from './info'; 6 | import { init } from './init'; 7 | import { mcp } from './mcp'; 8 | import { publish } from './publish'; 9 | import { test } from './test'; 10 | import { tokens } from './tokens'; 11 | import { update } from './update'; 12 | 13 | export { add, auth, tokens, build, exec, info, init, mcp, publish, test, update }; 14 | -------------------------------------------------------------------------------- /src/commands/info.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { Command, program } from 'commander'; 3 | import fetch from 'make-fetch-happen'; 4 | import * as v from 'valibot'; 5 | import type { Category, Manifest } from '../types'; 6 | import * as array from '../utils/blocks/ts/array'; 7 | import * as pad from '../utils/blocks/ts/pad'; 8 | import * as jsrepo from '../utils/registry-providers/jsrepo'; 9 | import { TokenManager } from '../utils/token-manager'; 10 | 11 | const schema = v.object({ 12 | json: v.boolean(), 13 | }); 14 | 15 | type Options = v.InferInput; 16 | 17 | export const info = new Command('info') 18 | .description('Get info about a registry on jsrepo.com') 19 | .argument('registry', 'Name of the registry to get the info for i.e. @ieedan/std') 20 | .option('--json', 'Output the response in formatted JSON.', false) 21 | .action(async (registry, opts) => { 22 | const options = v.parse(schema, opts); 23 | 24 | await _info(registry, options); 25 | }); 26 | 27 | async function _info(registry: string, options: Options) { 28 | const tokenManager = new TokenManager(); 29 | 30 | const token = tokenManager.get(jsrepo.jsrepo.name); 31 | 32 | const headers: Record = {}; 33 | 34 | if (token) { 35 | const [key, value] = jsrepo.jsrepo.authHeader!(token); 36 | 37 | headers[key] = value; 38 | } 39 | 40 | const url = new URL(`/api/scopes/${registry}`, jsrepo.BASE_URL).toString(); 41 | 42 | const response = await fetch(url, { headers }); 43 | 44 | if (!response.ok) { 45 | if (response.status === 404) { 46 | program.error(color.red('Registry not found!')); 47 | } else { 48 | program.error( 49 | color.red( 50 | `Error fetching registry! Error: ${response.status} - ${response.statusText}` 51 | ) 52 | ); 53 | } 54 | } 55 | 56 | const result = (await response.json()) as RegistryInfoResponse; 57 | 58 | if (options.json) { 59 | return process.stdout.write(JSON.stringify(result, null, ' ')); 60 | } 61 | 62 | process.stdout.write(formattedOutput(result)); 63 | } 64 | 65 | function formattedOutput(registryInfo: RegistryInfoResponse) { 66 | let out = `${color.cyan(`${registryInfo.name}@${registryInfo.version}`)} | versions: ${color.cyan(registryInfo.versions.length.toString())}\n`; 67 | 68 | if (registryInfo.meta.description) { 69 | out += `${registryInfo.meta.description}\n`; 70 | } 71 | 72 | if (registryInfo.meta.homepage) { 73 | out += `${color.blue(registryInfo.meta.homepage)}\n`; 74 | } 75 | 76 | out += '\n'; 77 | 78 | if (registryInfo.meta.tags) { 79 | out += `keywords: ${registryInfo.meta.tags.map((t) => color.cyan(t)).join(', ')}\n\n`; 80 | } 81 | 82 | const multipleOfThree = (num: number) => num % 3 === 0; 83 | 84 | const blockTitles = registryInfo.categories 85 | .flatMap((c) => c.blocks) 86 | .map((b) => 87 | b.list ? color.blue(`${b.category}/${b.name}`) : color.dim(`${b.category}/${b.name}`) 88 | ); 89 | 90 | const minBlockTitleLength = array.maxLength(blockTitles) + 4; 91 | 92 | out += `blocks: 93 | ${blockTitles 94 | .map((b, i) => { 95 | const isMultipleOfThree = multipleOfThree(i + 1); 96 | const isLast = i + 1 >= blockTitles.length; 97 | 98 | if (isMultipleOfThree) { 99 | return `${b}\n`; 100 | } 101 | 102 | return `${pad.rightPadMin(b, minBlockTitleLength, ' ')}${isLast ? '\n' : ''}`; 103 | }) 104 | .join('')} 105 | `; 106 | 107 | if (registryInfo.meta.authors) { 108 | out += `authors: 109 | ${registryInfo.meta.authors.map((a) => `- ${color.blue(a)}`).join('\n')}\n\n`; 110 | } 111 | 112 | out += `tags: 113 | ${Object.entries(registryInfo.tags) 114 | .map(([tag, version]) => `${color.blue(tag)}: ${version}`) 115 | .join('\n')}\n\n`; 116 | 117 | return out; 118 | } 119 | 120 | type MinUser = { 121 | name: string; 122 | username: string; 123 | email: string; 124 | }; 125 | 126 | type RegistryInfoResponse = { 127 | name: string; 128 | version: string; 129 | releasedBy: MinUser; 130 | primaryLanguage: string; 131 | firstPublishedAt: Date; 132 | meta: { 133 | authors: string[] | undefined; 134 | bugs: string | undefined; 135 | description: string | undefined; 136 | homepage: string | undefined; 137 | repository: string | undefined; 138 | tags: string[] | undefined; 139 | }; 140 | access: NonNullable; 141 | peerDependencies: NonNullable | null; 142 | configFiles: NonNullable | null; 143 | categories: Category[]; 144 | tags: Record; 145 | versions: string[]; 146 | time: Record; 147 | }; 148 | -------------------------------------------------------------------------------- /src/commands/mcp.ts: -------------------------------------------------------------------------------- 1 | import { Command } from 'commander'; 2 | import { connectServer } from '../utils/mcp'; 3 | 4 | export const mcp = new Command('mcp') 5 | .description('Interact with jsrepo through an MCP server.') 6 | .option('--cwd ', 'The current working directory.', process.cwd()) 7 | .action(async () => { 8 | await connectServer().catch((err) => { 9 | console.error(err); 10 | process.exit(1); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/commands/test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { cancel, confirm, isCancel, outro } from '@clack/prompts'; 3 | import color from 'chalk'; 4 | import { Argument, Command, program } from 'commander'; 5 | import escapeStringRegexp from 'escape-string-regexp'; 6 | import oxc from 'oxc-parser'; 7 | import { resolveCommand } from 'package-manager-detector/commands'; 8 | import { detect } from 'package-manager-detector/detect'; 9 | import path from 'pathe'; 10 | import { x } from 'tinyexec'; 11 | import * as v from 'valibot'; 12 | import * as ascii from '../utils/ascii'; 13 | import { getInstalled } from '../utils/blocks'; 14 | import * as url from '../utils/blocks/ts/url'; 15 | import { isTestFile } from '../utils/build'; 16 | import { getPathForBlock, getProjectConfig, resolvePaths } from '../utils/config'; 17 | import { intro, spinner } from '../utils/prompts'; 18 | import * as registry from '../utils/registry-providers/internal'; 19 | 20 | const schema = v.object({ 21 | repo: v.optional(v.string()), 22 | allow: v.boolean(), 23 | debug: v.boolean(), 24 | cache: v.boolean(), 25 | verbose: v.boolean(), 26 | cwd: v.string(), 27 | }); 28 | 29 | type Options = v.InferInput; 30 | 31 | const test = new Command('test') 32 | .description('Tests local blocks against most recent remote tests.') 33 | .addArgument(new Argument('[blocks...]', 'The blocks you want to test.').default([])) 34 | .option('--repo ', 'Repository to download the blocks from.') 35 | .option('-A, --allow', 'Allow jsrepo to download code from the provided repo.', false) 36 | .option('--debug', 'Leaves the temp test file around for debugging upon failure.', false) 37 | .option('--no-cache', 'Disable caching of resolved git urls.') 38 | .option('--verbose', 'Include debug logs.', false) 39 | .option('--cwd ', 'The current working directory.', process.cwd()) 40 | .action(async (blockNames, opts) => { 41 | const options = v.parse(schema, opts); 42 | 43 | await intro(); 44 | 45 | await _test(blockNames, options); 46 | 47 | outro(color.green('All done!')); 48 | }); 49 | 50 | async function _test(blockNames: string[], options: Options) { 51 | const verbose = (msg: string) => { 52 | if (options.verbose) { 53 | console.info(`${ascii.INFO} ${msg}`); 54 | } 55 | }; 56 | 57 | verbose(`Attempting to test ${JSON.stringify(blockNames)}`); 58 | 59 | const config = getProjectConfig(options.cwd).match( 60 | (val) => val, 61 | (err) => program.error(color.red(err)) 62 | ); 63 | 64 | const loading = spinner({ verbose: options.verbose ? verbose : undefined }); 65 | 66 | let repoPaths = config.repos; 67 | 68 | // we just want to override all others if supplied via the CLI 69 | if (options.repo) repoPaths = [options.repo]; 70 | 71 | if (!options.allow && options.repo) { 72 | const result = await confirm({ 73 | message: `Allow ${color.cyan('jsrepo')} to download and run code from ${color.cyan(options.repo)}?`, 74 | initialValue: true, 75 | }); 76 | 77 | if (isCancel(result) || !result) { 78 | cancel('Canceled!'); 79 | process.exit(0); 80 | } 81 | } 82 | 83 | if (!options.verbose) loading.start(`Fetching blocks from ${color.cyan(repoPaths.join(', '))}`); 84 | 85 | const resolvedRepos: registry.RegistryProviderState[] = ( 86 | await registry.forEachPathGetProviderState(repoPaths, { noCache: !options.cache }) 87 | ).match( 88 | (val) => val, 89 | ({ repo, message }) => { 90 | loading.stop(`Failed to get info for ${color.cyan(repo)}`); 91 | program.error(color.red(message)); 92 | } 93 | ); 94 | 95 | verbose(`Resolved ${color.cyan(repoPaths.join(', '))}`); 96 | 97 | verbose(`Fetching blocks from ${color.cyan(repoPaths.join(', '))}`); 98 | 99 | const blocksMap = ( 100 | await registry.fetchBlocks(resolvedRepos, { 101 | verbose: options.verbose ? verbose : undefined, 102 | }) 103 | ).match( 104 | (val) => val, 105 | ({ repo, message }) => { 106 | loading.stop(`Failed fetching blocks from ${color.cyan(repo)}`); 107 | program.error(color.red(message)); 108 | } 109 | ); 110 | 111 | verbose(`Retrieved blocks from ${color.cyan(repoPaths.join(', '))}`); 112 | 113 | if (!options.verbose) loading.stop(`Retrieved blocks from ${color.cyan(repoPaths.join(', '))}`); 114 | 115 | const tempTestDirectory = path.resolve( 116 | path.join(options.cwd, `blocks-tests-temp-${Date.now()}`) 117 | ); 118 | 119 | verbose(`Trying to create the temp directory ${color.bold(tempTestDirectory)}.`); 120 | 121 | fs.mkdirSync(tempTestDirectory, { recursive: true }); 122 | 123 | const cleanUp = () => { 124 | fs.rmSync(tempTestDirectory, { recursive: true, force: true }); 125 | }; 126 | 127 | const installedBlocks = getInstalled(blocksMap, config, options.cwd).map( 128 | (val) => val.specifier 129 | ); 130 | 131 | let testingBlocks = blockNames; 132 | 133 | // in the case that we want to test all files 134 | if (blockNames.length === 0) { 135 | testingBlocks = installedBlocks; 136 | } 137 | 138 | if (testingBlocks.length === 0) { 139 | cleanUp(); 140 | program.error(color.red('There were no blocks found in your project!')); 141 | } 142 | 143 | const testingBlocksMapped: { name: string; block: registry.RemoteBlock }[] = []; 144 | 145 | for (const blockSpecifier of testingBlocks) { 146 | let block: registry.RemoteBlock | undefined = undefined; 147 | 148 | const provider = registry.selectProvider(blockSpecifier); 149 | 150 | // if the block starts with github (or another provider) we know it has been resolved 151 | if (!provider) { 152 | for (const repo of repoPaths) { 153 | // we unwrap because we already checked this 154 | const provider = registry.selectProvider(repo); 155 | 156 | if (!provider) continue; 157 | 158 | const { url: parsedRepo, specifier } = provider.parse( 159 | url.join(repo, blockSpecifier), 160 | { 161 | fullyQualified: true, 162 | } 163 | ); 164 | 165 | const tempBlock = blocksMap.get(url.join(parsedRepo, specifier!)); 166 | 167 | if (tempBlock === undefined) continue; 168 | 169 | block = tempBlock; 170 | 171 | break; 172 | } 173 | } else { 174 | const { url: repo } = provider.parse(blockSpecifier, { fullyQualified: true }); 175 | 176 | const providerState = (await registry.getProviderState(repo)).match( 177 | (val) => val, 178 | (err) => program.error(color.red(err)) 179 | ); 180 | 181 | const map = (await registry.fetchBlocks([providerState])).match( 182 | (val) => val, 183 | (err) => program.error(color.red(err)) 184 | ); 185 | 186 | for (const [k, v] of map) { 187 | blocksMap.set(k, v); 188 | } 189 | 190 | block = blocksMap.get(blockSpecifier); 191 | } 192 | 193 | if (!block) { 194 | program.error( 195 | color.red(`Invalid block! ${color.bold(blockSpecifier)} does not exist!`) 196 | ); 197 | } 198 | 199 | testingBlocksMapped.push({ name: blockSpecifier, block }); 200 | } 201 | 202 | const resolvedPaths = resolvePaths(config.paths, options.cwd).match( 203 | (v) => v, 204 | (err) => program.error(color.red(err)) 205 | ); 206 | 207 | for (const { block } of testingBlocksMapped) { 208 | const providerState = block.sourceRepo; 209 | 210 | const fullSpecifier = url.join(block.sourceRepo.url, block.category, block.name); 211 | 212 | if (!options.verbose) { 213 | loading.start(`Setting up test file for ${color.cyan(fullSpecifier)}`); 214 | } 215 | 216 | if (!block.tests) { 217 | loading.stop(`No tests found for ${color.cyan(fullSpecifier)}`); 218 | continue; 219 | } 220 | 221 | let directory = getPathForBlock(block, resolvedPaths, options.cwd); 222 | 223 | directory = path.relative(tempTestDirectory, directory); 224 | 225 | const getSourceFile = async (filePath: string) => { 226 | const content = await registry.fetchRaw(providerState, filePath); 227 | 228 | if (content.isErr()) { 229 | loading.stop(color.red(`Error fetching ${color.bold(filePath)}`)); 230 | program.error(color.red(`There was an error trying to get ${fullSpecifier}`)); 231 | } 232 | 233 | return content.unwrap(); 234 | }; 235 | 236 | verbose(`Downloading and copying test files for ${fullSpecifier}`); 237 | 238 | const testFiles: string[] = []; 239 | 240 | for (const testFile of block.files.filter((file) => isTestFile(file))) { 241 | const content = await getSourceFile(path.join(block.directory, testFile)); 242 | 243 | const destPath = path.join(tempTestDirectory, testFile); 244 | 245 | fs.writeFileSync(destPath, content); 246 | 247 | testFiles.push(destPath); 248 | } 249 | 250 | // resolve imports for the block 251 | for (const file of testFiles) { 252 | verbose(`Opening test file ${file}`); 253 | 254 | let code = fs.readFileSync(file).toString(); 255 | 256 | const result = oxc.parseSync(file, code); 257 | 258 | for (const mod of result.module.staticImports) { 259 | const moduleSpecifier = mod.moduleRequest.value; 260 | 261 | let newModuleSpecifier: string | undefined = undefined; 262 | 263 | if (moduleSpecifier.startsWith('.')) { 264 | if (block.subdirectory) { 265 | newModuleSpecifier = path.join(directory, block.name, moduleSpecifier); 266 | } else { 267 | newModuleSpecifier = path.join(directory, moduleSpecifier); 268 | } 269 | } 270 | 271 | if (newModuleSpecifier) { 272 | // this way we only replace the exact import since it will be surrounded in quotes 273 | const literalRegex = new RegExp( 274 | `(['"])${escapeStringRegexp(moduleSpecifier)}\\1`, 275 | 'g' 276 | ); 277 | 278 | code = code.replaceAll(literalRegex, `$1${newModuleSpecifier}$1`); 279 | } 280 | } 281 | 282 | fs.writeFileSync(file, code); 283 | } 284 | 285 | verbose(`Completed ${color.cyan.bold(fullSpecifier)} test file`); 286 | 287 | if (!options.verbose) { 288 | loading.stop(`Completed setup for ${color.bold(fullSpecifier)}`); 289 | } 290 | } 291 | 292 | verbose('Beginning testing'); 293 | 294 | const pm = await detect({ cwd: options.cwd }); 295 | 296 | if (pm == null) { 297 | program.error(color.red('Could not detect package manager')); 298 | } 299 | 300 | const resolved = resolveCommand(pm.agent, 'execute', ['vitest', 'run', tempTestDirectory]); 301 | 302 | if (resolved == null) { 303 | program.error(color.red(`Could not resolve add command for '${pm.agent}'.`)); 304 | } 305 | 306 | const testCommand = `${resolved.command} ${resolved.args.join(' ')}`; 307 | 308 | verbose(`Running ${color.cyan(testCommand)} on ${color.cyan(options.cwd)}`); 309 | 310 | try { 311 | const proc = x(resolved.command, resolved.args, { nodeOptions: { cwd: options.cwd } }); 312 | 313 | for await (const line of proc) { 314 | process.stdout.write(`${line}\n`); 315 | } 316 | 317 | cleanUp(); 318 | } catch (err) { 319 | if (options.debug) { 320 | console.info( 321 | `${color.bold('--debug')} flag provided. Skipping cleanup. Run '${color.bold( 322 | testCommand 323 | )}' to retry tests.\n` 324 | ); 325 | } else { 326 | cleanUp(); 327 | } 328 | 329 | program.error(color.red(`Tests failed! Error ${err}`)); 330 | } 331 | } 332 | 333 | export { test }; 334 | -------------------------------------------------------------------------------- /src/commands/tokens.ts: -------------------------------------------------------------------------------- 1 | import { cancel, confirm, isCancel, log, outro, password, select, text } from '@clack/prompts'; 2 | import color from 'chalk'; 3 | import { Argument, Command } from 'commander'; 4 | import * as v from 'valibot'; 5 | import { getProjectConfig } from '../utils/config'; 6 | import { intro } from '../utils/prompts'; 7 | import { http } from '../utils/registry-providers'; 8 | import { TokenManager } from '../utils/token-manager'; 9 | 10 | const schema = v.object({ 11 | token: v.optional(v.string()), 12 | logout: v.boolean(), 13 | cwd: v.string(), 14 | }); 15 | 16 | type Options = v.InferInput; 17 | 18 | const services = ['Anthropic', 'Azure', 'BitBucket', 'GitHub', 'GitLab', 'OpenAI', 'http'].sort(); 19 | 20 | export const tokens = new Command('tokens') 21 | .description('Provide a token for access to private repositories.') 22 | .addArgument( 23 | new Argument('service', 'The service you want to authenticate to.') 24 | .choices(services.map((s) => s.toLowerCase())) 25 | .argOptional() 26 | ) 27 | .option('--logout', 'Execute the logout flow.', false) 28 | .option('--token ', 'The token to use for authenticating to this service.') 29 | .option('--cwd ', 'The current working directory.', process.cwd()) 30 | .action(async (service, opts) => { 31 | const options = v.parse(schema, opts); 32 | 33 | await intro(); 34 | 35 | await _tokens(service, options); 36 | 37 | outro(color.green('All done!')); 38 | }); 39 | 40 | async function _tokens(service: string | undefined, options: Options) { 41 | const configuredRegistries: string[] = getProjectConfig(options.cwd).match( 42 | (v) => v.repos.filter(http.matches), 43 | () => [] 44 | ); 45 | 46 | let selectedService = services.find((s) => s.toLowerCase() === service?.toLowerCase()); 47 | 48 | const storage = new TokenManager(); 49 | 50 | // logout flow 51 | if (options.logout) { 52 | if (selectedService !== undefined) { 53 | if (selectedService === 'http') { 54 | await promptHttpLogout(storage); 55 | 56 | return; 57 | } 58 | 59 | storage.delete(selectedService); 60 | log.success(`Logged out of ${selectedService}.`); 61 | return; 62 | } 63 | 64 | for (const serviceName of services) { 65 | if (serviceName === 'http') { 66 | await promptHttpLogout(storage); 67 | continue; 68 | } 69 | 70 | if (storage.get(serviceName) === undefined) { 71 | log.step(color.gray(`Already logged out of ${color.bold(serviceName)}.`)); 72 | continue; 73 | } 74 | 75 | const response = await confirm({ 76 | message: `Logout of ${color.bold(serviceName)}?`, 77 | initialValue: true, 78 | }); 79 | 80 | if (isCancel(response)) { 81 | cancel('Canceled!'); 82 | process.exit(0); 83 | } 84 | 85 | if (!response) continue; 86 | 87 | storage.delete(serviceName); 88 | } 89 | 90 | return; 91 | } 92 | 93 | // login flow 94 | if (selectedService === undefined) { 95 | const response = await select({ 96 | message: 'Which service do you want to authenticate to?', 97 | options: services.map((serviceName) => ({ 98 | label: serviceName, 99 | value: serviceName, 100 | })), 101 | initialValue: services[0], 102 | }); 103 | 104 | if (isCancel(response)) { 105 | cancel('Canceled!'); 106 | process.exit(0); 107 | } 108 | 109 | selectedService = response; 110 | 111 | if (selectedService === 'http') { 112 | let selectedRegistry = 'Other'; 113 | 114 | if (configuredRegistries.length > 0) { 115 | configuredRegistries.push('Other'); 116 | 117 | const response = await select({ 118 | message: 'Which registry do you want to authenticate to?', 119 | options: configuredRegistries.map((serviceName) => ({ 120 | label: serviceName, 121 | value: serviceName, 122 | })), 123 | initialValue: services[0], 124 | }); 125 | 126 | if (isCancel(response)) { 127 | cancel('Canceled!'); 128 | process.exit(0); 129 | } 130 | 131 | selectedRegistry = new URL(response).origin; 132 | } 133 | 134 | // prompt for registry 135 | if (selectedRegistry === 'Other') { 136 | const response = await text({ 137 | message: 'Please enter the registry url you want to authenticate to:', 138 | placeholder: 'https://example.com', 139 | validate(value) { 140 | if (value.trim() === '') return 'Please provide a value'; 141 | 142 | try { 143 | // try to parse the url 144 | new URL(value); 145 | } catch { 146 | // if parsing fails return the error 147 | return 'Please provide a valid url'; 148 | } 149 | }, 150 | }); 151 | 152 | if (isCancel(response)) { 153 | cancel('Canceled!'); 154 | process.exit(0); 155 | } 156 | 157 | selectedRegistry = new URL(response).origin; 158 | } 159 | 160 | selectedService = `http-${selectedRegistry}`; 161 | } 162 | } 163 | 164 | let serviceName = selectedService; 165 | 166 | if (serviceName.startsWith('http')) { 167 | serviceName = serviceName.slice(5); 168 | } 169 | 170 | if (options.token === undefined) { 171 | const response = await password({ 172 | message: `Paste your token for ${color.bold(serviceName)}:`, 173 | validate(value) { 174 | if (value.trim() === '') return 'Please provide a value'; 175 | }, 176 | }); 177 | 178 | if (isCancel(response) || !response) { 179 | cancel('Canceled!'); 180 | process.exit(0); 181 | } 182 | 183 | options.token = response; 184 | } 185 | 186 | storage.set(selectedService, options.token); 187 | 188 | log.success(`Logged into ${color.bold(serviceName)}.`); 189 | } 190 | 191 | async function promptHttpLogout(storage: TokenManager) { 192 | // list all providers for logout 193 | const registries = storage.getHttpRegistriesWithTokens(); 194 | 195 | if (registries.length === 0) { 196 | log.step(color.gray(`Already logged out of ${color.bold('http')}.`)); 197 | } 198 | 199 | for (const registry of registries) { 200 | let registryUrl: URL; 201 | 202 | try { 203 | registryUrl = new URL(registry); 204 | } catch { 205 | continue; 206 | } 207 | 208 | const response = await confirm({ 209 | message: `Logout of ${color.bold(registryUrl.origin)}?`, 210 | initialValue: true, 211 | }); 212 | 213 | if (isCancel(response)) { 214 | cancel('Canceled!'); 215 | process.exit(0); 216 | } 217 | 218 | if (!response) continue; 219 | 220 | storage.delete(`http-${registryUrl.origin}`); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/commands/update.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { cancel, confirm, isCancel, multiselect, outro } from '@clack/prompts'; 3 | import color from 'chalk'; 4 | import { Command, program } from 'commander'; 5 | import { resolveCommand } from 'package-manager-detector/commands'; 6 | import { detect } from 'package-manager-detector/detect'; 7 | import * as v from 'valibot'; 8 | import * as ascii from '../utils/ascii'; 9 | import { getBlockFilePath, getInstalled, preloadBlocks, resolveTree } from '../utils/blocks'; 10 | import * as url from '../utils/blocks/ts/url'; 11 | import { getProjectConfig, resolvePaths } from '../utils/config'; 12 | import { transformRemoteContent } from '../utils/files'; 13 | import { loadFormatterConfig } from '../utils/format'; 14 | import { getWatermark } from '../utils/get-watermark'; 15 | import { checkPreconditions } from '../utils/preconditions'; 16 | import { 17 | intro, 18 | nextSteps, 19 | promptInstallDependencies, 20 | promptUpdateFile, 21 | spinner, 22 | } from '../utils/prompts'; 23 | import * as registry from '../utils/registry-providers/internal'; 24 | 25 | const schema = v.object({ 26 | all: v.boolean(), 27 | expand: v.boolean(), 28 | maxUnchanged: v.number(), 29 | no: v.boolean(), 30 | repo: v.optional(v.string()), 31 | allow: v.boolean(), 32 | yes: v.boolean(), 33 | cache: v.boolean(), 34 | verbose: v.boolean(), 35 | cwd: v.string(), 36 | }); 37 | 38 | type Options = v.InferInput; 39 | 40 | const update = new Command('update') 41 | .description('Update blocks to the code in the remote repository.') 42 | .argument('[blocks...]', 'Names of the blocks you want to update. ex: (utils/math)') 43 | .option('--all', 'Update all installed components.', false) 44 | .option('-E, --expand', 'Expands the diff so you see the entire file.', false) 45 | .option( 46 | '--max-unchanged ', 47 | 'Maximum unchanged lines that will show without being collapsed.', 48 | (val) => Number.parseInt(val), // this is such a dumb api thing 49 | 3 50 | ) 51 | .option('-n, --no', 'Do update any blocks.', false) 52 | .option('--repo ', 'Repository to download the blocks from.') 53 | .option('-A, --allow', 'Allow jsrepo to download code from the provided repo.', false) 54 | .option('-y, --yes', 'Skip confirmation prompt.', false) 55 | .option('--no-cache', 'Disable caching of resolved git urls.') 56 | .option('--verbose', 'Include debug logs.', false) 57 | .option('--cwd ', 'The current working directory.', process.cwd()) 58 | .action(async (blockNames, opts) => { 59 | const options = v.parse(schema, opts); 60 | 61 | await intro(); 62 | 63 | await _update(blockNames, options); 64 | 65 | outro(color.green('All done!')); 66 | }); 67 | 68 | async function _update(blockNames: string[], options: Options) { 69 | const verbose = (msg: string) => { 70 | if (options.verbose) { 71 | console.info(`${ascii.INFO} ${msg}`); 72 | } 73 | }; 74 | 75 | verbose(`Attempting to update ${JSON.stringify(blockNames)}`); 76 | 77 | const loading = spinner({ verbose: options.verbose ? verbose : undefined }); 78 | 79 | const config = getProjectConfig(options.cwd).match( 80 | (val) => val, 81 | (err) => program.error(color.red(err)) 82 | ); 83 | 84 | let repoPaths = config.repos; 85 | 86 | // we just want to override all others if supplied via the CLI 87 | if (options.repo) repoPaths = [options.repo]; 88 | 89 | // ensure blocks do not provide repos 90 | for (const blockSpecifier of blockNames) { 91 | if (registry.providers.find((p) => blockSpecifier.startsWith(p.name))) { 92 | program.error( 93 | color.red( 94 | `Invalid value provided for block names \`${color.bold(blockSpecifier)}\`. Block names are expected to be provided in the format of \`${color.bold('/')}\`` 95 | ) 96 | ); 97 | } 98 | } 99 | 100 | if (!options.allow && options.repo) { 101 | const result = await confirm({ 102 | message: `Allow ${color.cyan('jsrepo')} to download and run code from ${color.cyan(options.repo)}?`, 103 | initialValue: true, 104 | }); 105 | 106 | if (isCancel(result) || !result) { 107 | cancel('Canceled!'); 108 | process.exit(0); 109 | } 110 | } 111 | 112 | verbose(`Resolving ${color.cyan(repoPaths.join(', '))}`); 113 | 114 | if (!options.verbose) loading.start(`Fetching blocks from ${color.cyan(repoPaths.join(', '))}`); 115 | 116 | const resolvedRepos: registry.RegistryProviderState[] = ( 117 | await registry.forEachPathGetProviderState(repoPaths, { noCache: !options.cache }) 118 | ).match( 119 | (val) => val, 120 | ({ repo, message }) => { 121 | loading.stop(`Failed to get info for ${color.cyan(repo)}`); 122 | program.error(color.red(message)); 123 | } 124 | ); 125 | 126 | verbose(`Resolved ${color.cyan(repoPaths.join(', '))}`); 127 | 128 | verbose(`Fetching blocks from ${color.cyan(repoPaths.join(', '))}`); 129 | 130 | const manifests = (await registry.fetchManifests(resolvedRepos)).match( 131 | (v) => v, 132 | ({ repo, message }) => { 133 | loading.stop(`Failed fetching blocks from ${color.cyan(repo)}`); 134 | program.error(color.red(message)); 135 | } 136 | ); 137 | 138 | const blocksMap = registry.getRemoteBlocks(manifests); 139 | 140 | if (!options.verbose) loading.stop(`Retrieved blocks from ${color.cyan(repoPaths.join(', '))}`); 141 | 142 | verbose(`Retrieved blocks from ${color.cyan(repoPaths.join(', '))}`); 143 | 144 | for (const manifest of manifests) { 145 | checkPreconditions(manifest.state, manifest.manifest, options.cwd); 146 | } 147 | 148 | const installedBlocks = getInstalled(blocksMap, config, options.cwd); 149 | 150 | if (installedBlocks.length === 0) { 151 | program.error( 152 | color.red( 153 | `You haven't installed any blocks yet. Did you mean to \`${color.bold('add')}\`?` 154 | ) 155 | ); 156 | } 157 | 158 | let updatingBlockNames = blockNames; 159 | 160 | if (options.all) { 161 | updatingBlockNames = installedBlocks.map((block) => block.specifier); 162 | } 163 | 164 | // if no blocks are provided prompt the user for what blocks they want 165 | if (updatingBlockNames.length === 0) { 166 | const promptResult = await multiselect({ 167 | message: `Which blocks would you like to ${options.no ? 'diff' : 'update'}?`, 168 | options: installedBlocks 169 | .filter((b) => b.block.list) 170 | .map((block) => { 171 | return { 172 | label: `${color.cyan(block.block.category)}/${block.block.name}`, 173 | value: block.specifier, 174 | }; 175 | }), 176 | required: true, 177 | }); 178 | 179 | if (isCancel(promptResult)) { 180 | cancel('Canceled!'); 181 | process.exit(0); 182 | } 183 | 184 | updatingBlockNames = promptResult as string[]; 185 | } 186 | 187 | verbose(`Preparing to update ${color.cyan(updatingBlockNames.join(', '))}`); 188 | 189 | const updatingBlocks = (await resolveTree(updatingBlockNames, blocksMap, resolvedRepos)).match( 190 | (val) => val, 191 | program.error 192 | ); 193 | 194 | const devDeps: Set = new Set(); 195 | const deps: Set = new Set(); 196 | 197 | const { prettierOptions, biomeOptions } = await loadFormatterConfig({ 198 | formatter: config.formatter, 199 | cwd: options.cwd, 200 | }); 201 | 202 | const resolvedPaths = resolvePaths(config.paths, options.cwd).match( 203 | (v) => v, 204 | (err) => program.error(color.red(err)) 205 | ); 206 | 207 | const preloadedBlocks = preloadBlocks(updatingBlocks, config); 208 | 209 | for (const preloadedBlock of preloadedBlocks) { 210 | const fullSpecifier = url.join( 211 | preloadedBlock.block.sourceRepo.url, 212 | preloadedBlock.block.category, 213 | preloadedBlock.block.name 214 | ); 215 | 216 | const watermark = getWatermark(preloadedBlock.block.sourceRepo.url); 217 | 218 | verbose(`Attempting to update ${fullSpecifier}`); 219 | 220 | if (config.includeTests && preloadedBlock.block.tests) { 221 | verbose('Trying to include tests'); 222 | 223 | devDeps.add('vitest'); 224 | } 225 | 226 | for (const dep of preloadedBlock.block.devDependencies) { 227 | devDeps.add(dep); 228 | } 229 | 230 | for (const dep of preloadedBlock.block.dependencies) { 231 | deps.add(dep); 232 | } 233 | 234 | const files = await preloadedBlock.files; 235 | 236 | process.stdout.write(`${ascii.VERTICAL_LINE}\n`); 237 | 238 | process.stdout.write(`${ascii.VERTICAL_LINE} ${fullSpecifier}\n`); 239 | 240 | for (const file of files) { 241 | const content = file.content.match( 242 | (v) => v, 243 | (err) => program.error(color.red(err)) 244 | ); 245 | 246 | const destPath = getBlockFilePath( 247 | file.name, 248 | preloadedBlock.block, 249 | resolvedPaths, 250 | options.cwd 251 | ); 252 | 253 | const remoteContent = ( 254 | await transformRemoteContent({ 255 | file: { 256 | content, 257 | destPath: destPath, 258 | }, 259 | biomeOptions, 260 | prettierOptions, 261 | config, 262 | imports: preloadedBlock.block._imports_, 263 | watermark, 264 | verbose, 265 | cwd: options.cwd, 266 | }) 267 | ).match( 268 | (v) => v, 269 | (err) => program.error(color.red(err)) 270 | ); 271 | 272 | let localContent = ''; 273 | if (fs.existsSync(destPath)) { 274 | localContent = fs.readFileSync(destPath).toString(); 275 | } 276 | 277 | const updateResult = await promptUpdateFile({ 278 | config: { biomeOptions, prettierOptions, formatter: config.formatter }, 279 | current: { 280 | path: destPath, 281 | content: localContent, 282 | }, 283 | incoming: { 284 | path: url.join(fullSpecifier, file.name), 285 | content: remoteContent, 286 | }, 287 | options: { 288 | ...options, 289 | loading, 290 | verbose: options.verbose ? verbose : undefined, 291 | }, 292 | }); 293 | 294 | if (updateResult.applyChanges) { 295 | loading.start(`Writing changes to ${color.cyan(destPath)}`); 296 | 297 | fs.writeFileSync(destPath, updateResult.updatedContent); 298 | 299 | loading.stop(`Wrote changes to ${color.cyan(destPath)}.`); 300 | } 301 | } 302 | } 303 | 304 | const pm = (await detect({ cwd: options.cwd }))?.agent ?? 'npm'; 305 | 306 | const installResult = await promptInstallDependencies(deps, devDeps, { 307 | yes: options.yes, 308 | no: options.no, 309 | cwd: options.cwd, 310 | pm, 311 | }); 312 | 313 | if (installResult.dependencies.size > 0 || installResult.devDependencies.size > 0) { 314 | // next steps if they didn't install dependencies 315 | let steps = []; 316 | 317 | if (!installResult.installed) { 318 | if (deps.size > 0) { 319 | const cmd = resolveCommand(pm, 'add', [...deps]); 320 | 321 | steps.push( 322 | `Install dependencies \`${color.cyan(`${cmd?.command} ${cmd?.args.join(' ')}`)}\`` 323 | ); 324 | } 325 | 326 | if (devDeps.size > 0) { 327 | const cmd = resolveCommand(pm, 'add', [...devDeps, '-D']); 328 | 329 | steps.push( 330 | `Install dev dependencies \`${color.cyan(`${cmd?.command} ${cmd?.args.join(' ')}`)}\`` 331 | ); 332 | } 333 | } 334 | 335 | // put steps with numbers above here 336 | steps = steps.map((step, i) => `${i + 1}. ${step}`); 337 | 338 | if (!installResult.installed) { 339 | steps.push(''); 340 | } 341 | 342 | steps.push('Import and use the blocks!'); 343 | 344 | const next = nextSteps(steps); 345 | 346 | process.stdout.write(next); 347 | } 348 | } 349 | 350 | export { update }; 351 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MANIFEST_FILE = 'jsrepo-manifest.json'; 2 | export const CONFIG_FILE = 'jsrepo.json'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { cli } from './cli'; 4 | 5 | cli.parse(); 6 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | export const blockSchema = v.object({ 4 | name: v.string(), 5 | category: v.string(), 6 | localDependencies: v.array(v.string()), 7 | dependencies: v.array(v.string()), 8 | devDependencies: v.array(v.string()), 9 | tests: v.boolean(), 10 | list: v.optional(v.boolean(), true), 11 | /** Where to find the block relative to root */ 12 | directory: v.string(), 13 | subdirectory: v.boolean(), 14 | files: v.array(v.string()), 15 | _imports_: v.record(v.string(), v.string()), 16 | }); 17 | 18 | export const categorySchema = v.object({ 19 | name: v.string(), 20 | blocks: v.array(blockSchema), 21 | }); 22 | 23 | export const manifestMeta = v.object({ 24 | authors: v.optional(v.array(v.string())), 25 | bugs: v.optional(v.string()), 26 | description: v.optional(v.string()), 27 | homepage: v.optional(v.string()), 28 | repository: v.optional(v.string()), 29 | tags: v.optional(v.array(v.string())), 30 | }); 31 | 32 | export const peerDependencySchema = v.record( 33 | v.string(), 34 | v.union([ 35 | v.string(), 36 | v.object({ 37 | version: v.string(), 38 | message: v.string(), 39 | }), 40 | ]) 41 | ); 42 | 43 | export type PeerDependency = v.InferOutput; 44 | 45 | export const configFileSchema = v.object({ 46 | name: v.string(), 47 | path: v.string(), 48 | expectedPath: v.optional(v.string()), 49 | optional: v.optional(v.boolean(), false), 50 | }); 51 | 52 | export type ConfigFile = v.InferOutput; 53 | 54 | export const manifestConfigFileSchema = v.object({ 55 | ...configFileSchema.entries, 56 | dependencies: v.optional(v.array(v.string())), 57 | devDependencies: v.optional(v.array(v.string())), 58 | }); 59 | 60 | export const accessLevel = v.union([ 61 | v.literal('public'), 62 | v.literal('private'), 63 | v.literal('marketplace'), 64 | ]); 65 | 66 | export const manifestSchema = v.object({ 67 | name: v.optional(v.string()), 68 | version: v.optional(v.string()), 69 | meta: v.optional(manifestMeta), 70 | access: v.optional(accessLevel), 71 | defaultPaths: v.optional(v.record(v.string(), v.string())), 72 | peerDependencies: v.optional(peerDependencySchema), 73 | configFiles: v.optional(v.array(manifestConfigFileSchema)), 74 | categories: v.array(categorySchema), 75 | }); 76 | 77 | export type Meta = v.InferOutput; 78 | 79 | export type Category = v.InferOutput; 80 | 81 | export type Block = v.InferOutput; 82 | 83 | export type Manifest = v.InferOutput; 84 | -------------------------------------------------------------------------------- /src/utils/ai.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from '@anthropic-ai/sdk'; 2 | import { cancel, isCancel, password, type spinner } from '@clack/prompts'; 3 | import ollama from 'ollama'; 4 | import OpenAI from 'openai'; 5 | import * as lines from './blocks/ts/lines'; 6 | import { TokenManager } from './token-manager'; 7 | 8 | type File = { 9 | path: string; 10 | content: string; 11 | }; 12 | 13 | export type Message = { 14 | role: 'assistant' | 'user'; 15 | content: string; 16 | }; 17 | 18 | export type UpdateFileResult = { 19 | content: string; 20 | /** Prompt constructed by the user (for context) */ 21 | prompt: string; 22 | }; 23 | 24 | export interface Model { 25 | updateFile: (opts: { 26 | originalFile: File; 27 | newFile: File; 28 | loading: ReturnType; 29 | additionalInstructions?: string; 30 | messages?: Message[]; 31 | verbose?: (msg: string) => void; 32 | }) => Promise; 33 | } 34 | 35 | export type ModelName = 'Claude 3.7 Sonnet' | 'OpenAI o3-mini' | 'Phi4'; 36 | 37 | type Prompt = { 38 | system: string; 39 | message: string; 40 | }; 41 | 42 | const models: Record = { 43 | 'Claude 3.7 Sonnet': { 44 | updateFile: async ({ 45 | originalFile, 46 | newFile, 47 | loading, 48 | verbose, 49 | additionalInstructions, 50 | messages, 51 | }) => { 52 | const apiKey = await getApiKey('Anthropic'); 53 | 54 | if (!verbose) loading.start(`Asking ${'Claude 3.7 Sonnet'}`); 55 | 56 | const prompt = createUpdatePrompt({ 57 | originalFile, 58 | newFile, 59 | additionalInstructions, 60 | rePrompt: messages !== undefined && messages.length > 0, 61 | }); 62 | 63 | verbose?.( 64 | `Prompting ${'Claude 3.7 Sonnet'} with:\n${JSON.stringify(prompt, null, '\t')}` 65 | ); 66 | 67 | const text = await getNextCompletionAnthropic({ 68 | model: 'claude-3-7-sonnet-latest', 69 | prompt, 70 | apiKey, 71 | messages, 72 | maxTokens: (originalFile.content.length + newFile.content.length) * 2, 73 | }); 74 | 75 | if (!verbose) loading.stop(`${'Claude 3.7 Sonnet'} updated the file`); 76 | 77 | if (!text) return { content: newFile.content, prompt: prompt.message }; 78 | 79 | return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; 80 | }, 81 | }, 82 | 'OpenAI o3-mini': { 83 | updateFile: async ({ 84 | originalFile, 85 | newFile, 86 | loading, 87 | verbose, 88 | additionalInstructions, 89 | messages, 90 | }) => { 91 | const apiKey = await getApiKey('OpenAI'); 92 | 93 | if (!verbose) loading.start(`Asking ${'OpenAI o3-mini'}`); 94 | 95 | const prompt = createUpdatePrompt({ 96 | originalFile, 97 | newFile, 98 | additionalInstructions, 99 | rePrompt: messages !== undefined && messages.length > 0, 100 | }); 101 | 102 | verbose?.(`Prompting ${'OpenAI o3-mini'} with:\n${JSON.stringify(prompt, null, '\t')}`); 103 | 104 | const text = await getNextCompletionOpenAI({ 105 | model: 'o3-mini', 106 | prompt, 107 | apiKey, 108 | messages, 109 | maxTokens: (originalFile.content.length + newFile.content.length) * 2, 110 | }); 111 | 112 | if (!verbose) loading.stop(`${'OpenAI o3-mini'} updated the file`); 113 | 114 | if (!text) return { content: newFile.content, prompt: prompt.message }; 115 | 116 | return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; 117 | }, 118 | }, 119 | Phi4: { 120 | updateFile: async ({ 121 | originalFile, 122 | newFile, 123 | loading, 124 | verbose, 125 | additionalInstructions, 126 | messages, 127 | }) => { 128 | if (!verbose) loading.start(`Asking ${'Phi4'}`); 129 | 130 | const prompt = createUpdatePrompt({ 131 | originalFile, 132 | newFile, 133 | additionalInstructions, 134 | rePrompt: messages !== undefined && messages.length > 0, 135 | }); 136 | 137 | verbose?.(`Prompting ${'Phi4'} with:\n${JSON.stringify(prompt, null, '\t')}`); 138 | 139 | const text = await getNextCompletionOllama({ model: 'phi4', prompt, messages }); 140 | 141 | if (!verbose) loading.stop(`${'Phi4'} updated the file`); 142 | 143 | if (!text) return { content: newFile.content, prompt: prompt.message }; 144 | 145 | return { content: unwrapCodeFromQuotes(text), prompt: prompt.message }; 146 | }, 147 | }, 148 | }; 149 | 150 | async function getNextCompletionOpenAI({ 151 | prompt, 152 | maxTokens, 153 | model, 154 | apiKey, 155 | messages, 156 | }: { 157 | prompt: Prompt; 158 | messages?: Message[]; 159 | maxTokens: number; 160 | model: OpenAI.Chat.ChatModel; 161 | apiKey: string; 162 | }): Promise { 163 | const openai = new OpenAI({ apiKey }); 164 | 165 | const msg = await openai.chat.completions.create({ 166 | model, 167 | max_completion_tokens: maxTokens, 168 | messages: [ 169 | { 170 | role: 'system', 171 | content: prompt.system, 172 | }, 173 | ...(messages ?? []), 174 | { 175 | role: 'user', 176 | content: prompt.message, 177 | }, 178 | ], 179 | }); 180 | 181 | const first = msg.choices[0]; 182 | 183 | if (first.message.content === null) return null; 184 | 185 | return first.message.content; 186 | } 187 | 188 | async function getNextCompletionAnthropic({ 189 | prompt, 190 | messages, 191 | maxTokens, 192 | model, 193 | apiKey, 194 | }: { 195 | prompt: Prompt; 196 | messages?: Message[]; 197 | maxTokens: number; 198 | model: Anthropic.Messages.Model; 199 | apiKey: string; 200 | }): Promise { 201 | const anthropic = new Anthropic({ apiKey }); 202 | 203 | // didn't want to do it this way but I couldn't get `.map` to work 204 | const history: Anthropic.Messages.MessageParam[] = []; 205 | 206 | // add history 207 | if (messages) { 208 | for (const message of messages) { 209 | history.push({ 210 | role: message.role, 211 | content: [{ type: 'text', text: message.content }], 212 | }); 213 | } 214 | } 215 | 216 | // add new message 217 | history.push({ 218 | role: 'user', 219 | content: [ 220 | { 221 | type: 'text', 222 | text: prompt.message, 223 | }, 224 | ], 225 | }); 226 | 227 | const msg = await anthropic.messages.create({ 228 | model, 229 | max_tokens: Math.min(maxTokens, 8192), 230 | temperature: 0.5, 231 | system: prompt.system, 232 | messages: history, 233 | }); 234 | 235 | const first = msg.content[0]; 236 | 237 | // if we don't get it in the format you want just return the new file 238 | if (first.type !== 'text') return null; 239 | 240 | return first.text; 241 | } 242 | 243 | async function getNextCompletionOllama({ 244 | prompt, 245 | messages, 246 | model, 247 | }: { 248 | prompt: Prompt; 249 | messages?: Message[]; 250 | model: string; 251 | }): Promise { 252 | const resp = await ollama.chat({ 253 | model, 254 | messages: [ 255 | { 256 | role: 'system', 257 | content: prompt.system, 258 | }, 259 | ...(messages ?? []), 260 | { 261 | role: 'user', 262 | content: prompt.message, 263 | }, 264 | ], 265 | }); 266 | 267 | return resp.message.content; 268 | } 269 | 270 | function createUpdatePrompt({ 271 | originalFile, 272 | newFile, 273 | additionalInstructions, 274 | rePrompt, 275 | }: { 276 | originalFile: File; 277 | newFile: File; 278 | additionalInstructions?: string; 279 | rePrompt: boolean; 280 | }): Prompt { 281 | return { 282 | system: 'You will merge two files provided by the user. You will respond only with the resulting code. DO NOT format the code with markdown, DO NOT put the code inside of triple quotes, only return the code as a raw string. DO NOT make unnecessary changes.', 283 | message: rePrompt 284 | ? (additionalInstructions ?? '') 285 | : ` 286 | This is my current file ${originalFile.path}: 287 | 288 | ${originalFile.content} 289 | 290 | 291 | This is the file that has changes I want to update with ${newFile.path}: 292 | 293 | ${newFile.content} 294 | ${additionalInstructions ? `${additionalInstructions}` : ''} 295 | `, 296 | }; 297 | } 298 | 299 | /** The AI isn't always that smart and likes to wrap the code in quotes even though I beg it not to. 300 | * This function attempts to remove the quotes. 301 | */ 302 | export function unwrapCodeFromQuotes(quoted: string): string { 303 | let code = quoted.trim(); 304 | 305 | if (code.startsWith('```')) { 306 | // takes out the entire first line 307 | // this is because often a language will come after the triple quotes 308 | code = lines.get(code).slice(1).join('\n').trim(); 309 | } 310 | 311 | if (code.endsWith('```')) { 312 | const l = lines.get(code); 313 | code = l 314 | .slice(0, l.length - 1) 315 | .join('\n') 316 | .trim(); 317 | } 318 | 319 | return code; 320 | } 321 | 322 | /** Attempts to get the cached api key if it can't it will prompt the user 323 | * 324 | * @param name 325 | * @returns 326 | */ 327 | async function getApiKey(name: 'OpenAI' | 'Anthropic'): Promise { 328 | const storage = new TokenManager(); 329 | 330 | let apiKey = storage.get(name); 331 | 332 | if (!apiKey) { 333 | // prompt for api key 334 | const result = await password({ 335 | message: `Paste your ${name} API key:`, 336 | validate(value) { 337 | if (value.trim() === '') return 'Please provide an API key'; 338 | }, 339 | }); 340 | 341 | if (isCancel(result) || !result) { 342 | cancel('Canceled!'); 343 | process.exit(0); 344 | } 345 | 346 | apiKey = result; 347 | } 348 | 349 | storage.set(name, apiKey); 350 | 351 | return apiKey; 352 | } 353 | 354 | export { models }; 355 | -------------------------------------------------------------------------------- /src/utils/ascii.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import isUnicodeSupported from 'is-unicode-supported'; 3 | 4 | const unicode = isUnicodeSupported(); 5 | 6 | const s = (c: string, fallback: string) => (unicode ? c : fallback); 7 | 8 | export const S_STEP_ACTIVE = s('◆', '*'); 9 | export const S_STEP_CANCEL = s('■', 'x'); 10 | export const S_STEP_ERROR = s('▲', 'x'); 11 | export const S_STEP_SUBMIT = s('◇', 'o'); 12 | export const S_INFO = s('●', '•'); 13 | export const S_SUCCESS = s('◆', '*'); 14 | export const S_WARN = s('▲', '!'); 15 | export const S_ERROR = s('■', 'x'); 16 | 17 | export const VERTICAL_LINE = color.gray(s('│', '|')); 18 | export const HORIZONTAL_LINE = color.gray(s('─', '-')); 19 | export const TOP_RIGHT_CORNER = color.gray(s('┐', '+')); 20 | export const BOTTOM_RIGHT_CORNER = color.gray(s('┘', '+')); 21 | export const JUNCTION_RIGHT = color.gray(s('├', '+')); 22 | export const JUNCTION_TOP = color.gray(s('┬', '+')); 23 | export const TOP_LEFT_CORNER = color.gray(s('┌', 'T')); 24 | export const BOTTOM_LEFT_CORNER = color.gray(s('└', '-')); 25 | 26 | export const WARN = color.bgRgb(245, 149, 66).black(' WARN '); 27 | export const INFO = color.bgBlueBright.white(' INFO '); 28 | export const ERROR = color.bgRedBright.white(' ERROR '); 29 | 30 | export const JSREPO = color.hex('#f7df1e')('jsrepo'); 31 | export const JSREPO_DOT_COM = color.hex('#f7df1e').bold('jsrepo.com'); 32 | -------------------------------------------------------------------------------- /src/utils/blocks.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import color from 'chalk'; 3 | import { program } from 'commander'; 4 | import path from 'pathe'; 5 | import type { Block } from '../types'; 6 | import * as array from './blocks/ts/array'; 7 | import { Err, Ok, type Result } from './blocks/ts/result'; 8 | import * as url from './blocks/ts/url'; 9 | import { isTestFile } from './build'; 10 | import { type Paths, type ProjectConfig, getPathForBlock, resolvePaths } from './config'; 11 | import * as registry from './registry-providers/internal'; 12 | 13 | export async function resolveTree( 14 | blockSpecifiers: string[], 15 | blocksMap: Map, 16 | repoPaths: registry.RegistryProviderState[], 17 | installed: Map = new Map() 18 | ): Promise> { 19 | const blocks = new Map(); 20 | 21 | for (const blockSpecifier of blockSpecifiers) { 22 | let block: registry.RemoteBlock | undefined = undefined; 23 | 24 | const provider = registry.selectProvider(blockSpecifier); 25 | 26 | // if the block starts with github (or another provider) we know it has been resolved 27 | if (!provider) { 28 | if (repoPaths.length === 0) { 29 | return Err( 30 | color.red( 31 | `If your config doesn't contain repos then you must provide the repo in the block specifier ex: \`${color.bold( 32 | `github/ieedan/std/${blockSpecifier}` 33 | )}\`!` 34 | ) 35 | ); 36 | } 37 | 38 | // check every repo for the block and return the first block found 39 | for (const providerState of repoPaths) { 40 | const { url: repoIdent, specifier } = providerState.provider.parse( 41 | url.join(providerState.url, blockSpecifier), 42 | { fullyQualified: true } 43 | ); 44 | 45 | const tempBlock = blocksMap.get(url.join(repoIdent, specifier!)); 46 | 47 | if (tempBlock === undefined) continue; 48 | 49 | block = tempBlock; 50 | 51 | break; 52 | } 53 | } else { 54 | // get shortened name 55 | const { url: repoIdent, specifier } = provider.parse(blockSpecifier, { 56 | fullyQualified: true, 57 | }); 58 | 59 | // just beautifies name a bit 60 | block = blocksMap.get(url.join(repoIdent, specifier!)); 61 | } 62 | 63 | if (!block) { 64 | return Err(`Invalid block! ${color.bold(blockSpecifier)} does not exist!`); 65 | } 66 | 67 | const specifier = `${block.category}/${block.name}`; 68 | 69 | blocks.set(specifier, block); 70 | 71 | if (block.localDependencies && block.localDependencies.length > 0) { 72 | const subDeps = await resolveTree( 73 | block.localDependencies.filter((dep) => !blocks.has(dep) && !installed.has(dep)), 74 | blocksMap, 75 | repoPaths, 76 | blocks 77 | ); 78 | 79 | if (subDeps.isErr()) return Err(subDeps.unwrapErr()); 80 | 81 | for (const dep of subDeps.unwrap()) { 82 | blocks.set(`${dep.category}/${dep.name}`, dep); 83 | } 84 | } 85 | } 86 | 87 | return Ok(array.fromMap(blocks, (_, val) => val)); 88 | } 89 | 90 | type InstalledBlock = { 91 | specifier: `${string}/${string}`; 92 | path: string; 93 | block: Block; 94 | }; 95 | 96 | /** Finds installed blocks and returns them as `/` 97 | * 98 | * @param blocks 99 | * @param config 100 | * @returns 101 | */ 102 | export function getInstalled( 103 | blocks: Map, 104 | config: ProjectConfig, 105 | cwd: string 106 | ): InstalledBlock[] { 107 | const installedBlocks: InstalledBlock[] = []; 108 | 109 | const resolvedPaths = resolvePaths(config.paths, cwd).match( 110 | (v) => v, 111 | (err) => program.error(color.red(err)) 112 | ); 113 | 114 | for (const [_, block] of blocks) { 115 | const baseDir = getPathForBlock(block, resolvedPaths, cwd); 116 | 117 | let blockPath = path.join(baseDir, block.files[0]); 118 | if (block.subdirectory) { 119 | blockPath = path.join(baseDir, block.name); 120 | } 121 | 122 | if (fs.existsSync(blockPath)) 123 | installedBlocks.push({ 124 | specifier: `${block.category}/${block.name}`, 125 | path: blockPath, 126 | block, 127 | }); 128 | } 129 | 130 | return installedBlocks; 131 | } 132 | 133 | export type RegistryFile = { 134 | name: string; 135 | content: Result; 136 | }; 137 | 138 | type PreloadedBlock = { 139 | block: registry.RemoteBlock; 140 | files: Promise; 141 | }; 142 | 143 | /** Starts loading the content of the files for each block and 144 | * returns the blocks mapped to a promise that contains their files and their contents. 145 | * 146 | * @param blocks 147 | * @returns 148 | */ 149 | export function preloadBlocks( 150 | blocks: registry.RemoteBlock[], 151 | config: Pick 152 | ): PreloadedBlock[] { 153 | const preloaded: PreloadedBlock[] = []; 154 | 155 | for (const block of blocks) { 156 | // filters out test files if they are not supposed to be included 157 | const includedFiles = block.files.filter((file) => 158 | isTestFile(file) ? config.includeTests : true 159 | ); 160 | 161 | const files = Promise.all( 162 | includedFiles.map(async (file) => { 163 | const content = await registry.fetchRaw( 164 | block.sourceRepo, 165 | path.join(block.directory, file) 166 | ); 167 | 168 | return { name: file, content }; 169 | }) 170 | ); 171 | 172 | preloaded.push({ 173 | block, 174 | files, 175 | }); 176 | } 177 | 178 | return preloaded; 179 | } 180 | 181 | /** Gets the path for the file belonging to the provided block 182 | * 183 | * @param fileName 184 | * @param block 185 | * @param resolvedPaths 186 | * @param cwd 187 | * @returns 188 | */ 189 | export function getBlockFilePath( 190 | fileName: string, 191 | block: registry.RemoteBlock, 192 | resolvedPaths: Paths, 193 | cwd: string 194 | ) { 195 | const directory = getPathForBlock(block, resolvedPaths, cwd); 196 | 197 | if (block.subdirectory) { 198 | return path.join(directory, block.name, fileName); 199 | } 200 | 201 | return path.join(directory, fileName); 202 | } 203 | -------------------------------------------------------------------------------- /src/utils/blocks/commander/parsers.ts: -------------------------------------------------------------------------------- 1 | import { InvalidArgumentError } from 'commander'; 2 | 3 | /** Handles `--x foo=bar,bar=foo` 4 | * 5 | * @param value 6 | * @returns 7 | */ 8 | export function parseRecord(value: string | undefined): Record | undefined { 9 | if (value === undefined) return undefined; 10 | 11 | const result: Record = {}; 12 | 13 | for (const pair of value.split(',')) { 14 | const [key, value] = pair.split('='); 15 | 16 | if (key === undefined || value === undefined) { 17 | throw new InvalidArgumentError( 18 | 'Expected map to be provided in the following format: `--option key=value,key=value`' 19 | ); 20 | } 21 | 22 | result[key] = value; 23 | } 24 | 25 | return result; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/blocks/package-managers/flags.ts: -------------------------------------------------------------------------------- 1 | import type { Agent } from 'package-manager-detector'; 2 | 3 | export type Flags = { 4 | 'no-workspace'?: string; 5 | 'install-as-dev-dependency': string; 6 | }; 7 | 8 | export const bun: Flags = { 9 | 'no-workspace': '--no-workspace', 10 | 'install-as-dev-dependency': '-D', 11 | }; 12 | 13 | export const deno: Flags = { 14 | 'install-as-dev-dependency': '-D', 15 | }; 16 | 17 | export const npm: Flags = { 18 | 'no-workspace': '--workspaces=false', 19 | 'install-as-dev-dependency': '-D', 20 | }; 21 | 22 | export const pnpm: Flags = { 23 | 'no-workspace': '--ignore-workspace', 24 | 'install-as-dev-dependency': '-D', 25 | }; 26 | 27 | export const yarn: Flags = { 28 | 'no-workspace': '--focus', 29 | 'install-as-dev-dependency': '-D', 30 | }; 31 | 32 | export const flags: Record = { 33 | bun, 34 | npm, 35 | pnpm, 36 | deno, 37 | yarn, 38 | 'yarn@berry': yarn, 39 | 'pnpm@6': pnpm, 40 | }; 41 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/array.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from github/ieedan/std 3 | */ 4 | 5 | import { stripVTControlCharacters } from 'node:util'; 6 | 7 | /** Maps the provided map into an array using the provided mapping function. 8 | * 9 | * @param map Map to be entered into an array 10 | * @param fn A mapping function to transform each pair into an item 11 | * @returns 12 | * 13 | * ## Usage 14 | * ```ts 15 | * console.log(map); // Map(5) { 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1 } 16 | * 17 | * const arr = fromMap(map, (_, value) => value); 18 | * 19 | * console.log(arr); // [5, 4, 3, 2, 1] 20 | * ``` 21 | */ 22 | export function fromMap(map: Map, fn: (key: K, value: V) => T): T[] { 23 | const items: T[] = []; 24 | 25 | for (const [key, value] of map) { 26 | items.push(fn(key, value)); 27 | } 28 | 29 | return items; 30 | } 31 | 32 | /** Calculates the sum of all elements in the array based on the provided function. 33 | * 34 | * @param arr Array of items to be summed. 35 | * @param fn Summing function 36 | * @returns 37 | * 38 | * ## Usage 39 | * 40 | * ```ts 41 | * const total = sum([1, 2, 3, 4, 5], (num) => num); 42 | * 43 | * console.log(total); // 15 44 | * ``` 45 | */ 46 | export function sum(arr: T[], fn: (item: T) => number): number { 47 | let total = 0; 48 | 49 | for (const item of arr) { 50 | total = total + fn(item); 51 | } 52 | 53 | return total; 54 | } 55 | 56 | /** Maps the provided array into a map 57 | * 58 | * @param arr Array of items to be entered into a map 59 | * @param fn A mapping function to transform each item into a key value pair 60 | * @returns 61 | * 62 | * ## Usage 63 | * ```ts 64 | * const map = toMap([5, 4, 3, 2, 1], (item, i) => [i, item]); 65 | * 66 | * console.log(map); // Map(5) { 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1 } 67 | * ``` 68 | */ 69 | export function toMap( 70 | arr: T[], 71 | fn: (item: T, index: number) => [key: K, value: V] 72 | ): Map { 73 | const map = new Map(); 74 | 75 | for (let i = 0; i < arr.length; i++) { 76 | const [key, value] = fn(arr[i], i); 77 | 78 | map.set(key, value); 79 | } 80 | 81 | return map; 82 | } 83 | 84 | export function maxLength(arr: string[]): number { 85 | let max = 0; 86 | 87 | for (const item of arr) { 88 | const str = stripVTControlCharacters(item); 89 | if (str.length > max) { 90 | max = str.length; 91 | } 92 | } 93 | 94 | return max; 95 | } 96 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/lines.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from github/ieedan/std 3 | */ 4 | 5 | import os from 'node:os'; 6 | import { leftPadMin } from './pad'; 7 | 8 | /** Regex used to split on new lines 9 | * 10 | * ``` 11 | * /\n|\r\n/g 12 | * ``` 13 | */ 14 | export const NEW_LINE_REGEX = /\n|\r\n/g; 15 | 16 | /** Splits str into an array of lines. 17 | * 18 | * @param str 19 | * @returns 20 | * 21 | * ## Usage 22 | * 23 | * ```ts 24 | * lines.split("hello\\nhello\nhello"); // ["hello\\nhello", "hello"] 25 | * ``` 26 | */ 27 | export function get(str: string): string[] { 28 | return str.split(NEW_LINE_REGEX); 29 | } 30 | 31 | export type Options = { 32 | lineNumbers: boolean; 33 | prefix: (line: number, lineCount: number) => string; 34 | }; 35 | 36 | /** Joins the array of lines back into a string using the platform specific EOL. 37 | * 38 | * @param lines 39 | * @returns 40 | * 41 | * ## Usage 42 | * 43 | * ```ts 44 | * lines.join(["1", "2", "3"]); // "1\n2\n3" or on windows "1\r\n2\r\n3" 45 | * 46 | * // add line numbers 47 | * lines.join(["import { } from '.'", "console.log('test')"], { lineNumbers: true }); 48 | * // 1 import { } from '.' 49 | * // 2 console.log('test') 50 | * 51 | * // add a custom prefix 52 | * lines.join(["import { } from '.'", "console.log('test')"], { prefix: () => " + " }); 53 | * // + import { } from '.' 54 | * // + console.log('test') 55 | * ``` 56 | */ 57 | export function join( 58 | lines: string[], 59 | { lineNumbers = false, prefix }: Partial = {} 60 | ): string { 61 | let transformed = lines; 62 | 63 | if (lineNumbers) { 64 | const length = lines.length.toString().length + 1; 65 | 66 | transformed = transformed.map((line, i) => `${leftPadMin(`${i + 1}`, length)} ${line}`); 67 | } 68 | 69 | if (prefix !== undefined) { 70 | transformed = transformed.map((line, i) => `${prefix(i, lines.length)}${line}`); 71 | } 72 | 73 | return transformed.join(os.EOL); 74 | } 75 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/pad.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from github/ieedan/std 3 | */ 4 | 5 | import { stripVTControlCharacters as stripAsni } from 'node:util'; 6 | 7 | /** Adds the `padWith` (default `' '`) to the string the amount of times specified by the `space` argument 8 | * 9 | * @param str String to add padding to 10 | * @param space Whitespace to add 11 | * @param padWith Character to use to pad the string 12 | * @returns 13 | * 14 | * ## Usage 15 | * ```ts 16 | * const padded = leftPad("Hello", 3, "."); 17 | * 18 | * console.log(padded); // '...Hello' 19 | * ``` 20 | */ 21 | export function leftPad(str: string, space: number, padWith = ' '): string { 22 | return padWith.repeat(space) + str; 23 | } 24 | 25 | /** Adds the `padWith` until the string length matches the `length` 26 | * 27 | * @param str 28 | * @param length 29 | * @param padWith 30 | * 31 | * ## Usage 32 | * ```ts 33 | * const padded = leftPadMin("1", 3, "."); 34 | * 35 | * console.log(padded); // '..1' 36 | * ``` 37 | */ 38 | export function leftPadMin(str: string, length: number, padWith = ' '): string { 39 | const strippedLength = stripAsni(str).length; 40 | 41 | if (strippedLength > length) 42 | throw new Error('String length is greater than the length provided.'); 43 | 44 | return padWith.repeat(length - strippedLength) + str; 45 | } 46 | 47 | /** Adds the `padWith` (default `' '`) to the string the amount of times specified by the `space` argument 48 | * 49 | * @param str String to add padding to 50 | * @param space Whitespace to add 51 | * @param padWith Character to use to pad the string 52 | * @returns 53 | * 54 | * ## Usage 55 | * ```ts 56 | * const padded = rightPad("Hello", 3, "."); 57 | * 58 | * console.log(padded); // 'Hello...' 59 | * ``` 60 | */ 61 | export function rightPad(str: string, space: number, padWith = ' '): string { 62 | return str + padWith.repeat(space); 63 | } 64 | 65 | /** Adds the `padWith` until the string length matches the `length` 66 | * 67 | * @param str 68 | * @param length 69 | * @param padWith 70 | * 71 | * ## Usage 72 | * ```ts 73 | * const padded = rightPadMin("1", 3, "."); 74 | * 75 | * console.log(padded); // '1..' 76 | * ``` 77 | */ 78 | export function rightPadMin(str: string, length: number, padWith = ' '): string { 79 | const strippedLength = stripAsni(str).length; 80 | 81 | if (strippedLength > length) 82 | throw new Error('String length is greater than the length provided.'); 83 | 84 | return str + padWith.repeat(length - strippedLength); 85 | } 86 | 87 | /** Pads the string with the `padWith` so that it appears in the center of a new string with the provided length. 88 | * 89 | * @param str 90 | * @param length 91 | * @param padWith 92 | * @returns 93 | * 94 | * ## Usage 95 | * ```ts 96 | * const str = "Hello, World!"; 97 | * 98 | * const padded = centerPad(str, str.length + 4); 99 | * 100 | * console.log(padded); // ' Hello, World! ' 101 | * ``` 102 | */ 103 | export function centerPad(str: string, length: number, padWith = ' '): string { 104 | const strippedLength = stripAsni(str).length; 105 | 106 | if (strippedLength > length) { 107 | throw new Error('String length is greater than the length provided.'); 108 | } 109 | 110 | const overflow = length - strippedLength; 111 | 112 | const paddingLeft = Math.floor(overflow / 2); 113 | 114 | const paddingRight = Math.ceil(overflow / 2); 115 | 116 | return padWith.repeat(paddingLeft) + str + padWith.repeat(paddingRight); 117 | } 118 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/promises.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from github/ieedan/std 3 | */ 4 | 5 | /** Returns a promise that immediately resolves to `T`, useful when you need to mix sync and async code. 6 | * 7 | * @param val 8 | * 9 | * ### Usage 10 | * ```ts 11 | * const promises: Promise[] = []; 12 | * 13 | * promises.push(immediate(10)); 14 | * ``` 15 | */ 16 | export function immediate(val: T): Promise { 17 | return new Promise((res) => res(val)); 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/sleep.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from @ieedan/std 3 | */ 4 | 5 | /** Await this to pause execution until the duration has passed. 6 | * 7 | * @param durationMs The duration in ms until the sleep in over 8 | * @returns 9 | * 10 | * ## Usage 11 | * ```ts 12 | * console.log(Date.now()) // 1725739228744 13 | * 14 | * await sleep(1000); 15 | * 16 | * console.log(Date.now()) // 1725739229744 17 | * ``` 18 | */ 19 | export function sleep(durationMs: number): Promise { 20 | return new Promise((res) => setTimeout(res, durationMs)); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/strings.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from @ieedan/std 3 | */ 4 | 5 | /** Returns the string that matches the string (if it exits). Great for matching string union types. 6 | * 7 | * @param str 8 | * @param strings 9 | * @returns 10 | * 11 | * ## Usage 12 | * ```ts 13 | * const methods = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE'] as const; 14 | * 15 | * const methodStr: string = 'GET'; 16 | * 17 | * const method = equalsOneOf(methodStr, methods); 18 | * 19 | * if (method) { 20 | * // if method was just a string this would be a type error 21 | * methods.includes(method) 22 | * } 23 | * ``` 24 | */ 25 | export function equalsOneOf(str: string, strings: readonly T[]): T | undefined { 26 | for (const s of strings) { 27 | if (s === str) return s; 28 | } 29 | 30 | return undefined; 31 | } 32 | 33 | /** Returns the matched prefix for the string (if it exists). Great for matching string union types. 34 | * 35 | * @param str 36 | * @param strings 37 | * @returns 38 | * 39 | * ## Usage 40 | * ```ts 41 | * startsWithOneOf('ab', 'a', 'c'); // 'a' 42 | * startsWithOneOf('cc', 'a', 'b'); // undefined 43 | * ``` 44 | */ 45 | export function startsWithOneOf( 46 | str: string, 47 | strings: readonly T[] 48 | ): T | undefined { 49 | for (const s of strings) { 50 | if (str.startsWith(s)) return s; 51 | } 52 | 53 | return undefined; 54 | } 55 | 56 | /** Returns the matched suffix for the string (if it exists). Great for matching string union types. 57 | * 58 | * @param str 59 | * @param strings 60 | * @returns 61 | * 62 | * ## Usage 63 | * ```ts 64 | * endsWithOneOf('cb', 'a', 'b'); // 'b' 65 | * endsWithOneOf('cc', 'a', 'b'); // undefined 66 | * ``` 67 | */ 68 | export function endsWithOneOf(str: string, strings: readonly T[]): T | undefined { 69 | for (const s of strings) { 70 | if (str.endsWith(s)) return s; 71 | } 72 | 73 | return undefined; 74 | } 75 | 76 | /** Case insensitive equality. Returns true if `left.toLowerCase()` and `right.toLowerCase()` are equal. 77 | * 78 | * @param left 79 | * @param right 80 | * @returns 81 | * 82 | * ## Usage 83 | * ```ts 84 | * iEqual('Hello, World!', 'hello, World!'); // true 85 | * ``` 86 | */ 87 | export function iEqual(left: string, right: string): boolean { 88 | return left.toLowerCase() === right.toLowerCase(); 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from @ieedan/std 3 | */ 4 | 5 | /** Milliseconds in a second */ 6 | export const SECOND = 1000; 7 | /** Milliseconds in a minute */ 8 | export const MINUTE = SECOND * 60; 9 | /** Milliseconds in an hour */ 10 | export const HOUR = MINUTE * 60; 11 | /** Milliseconds in a day */ 12 | export const DAY = HOUR * 24; 13 | 14 | /** Formats a time given in milliseconds with units. 15 | * 16 | * @param durationMs Time to be formatted in milliseconds 17 | * @param transform Runs before the num is formatted perfect place to put a `.toFixed()` 18 | * @returns 19 | * 20 | * ## Usage 21 | * ```ts 22 | * formatDuration(500); // 500ms 23 | * formatDuration(SECOND); // 1s 24 | * formatDuration(MINUTE); // 1min 25 | * formatDuration(HOUR); // 1h 26 | * ``` 27 | */ 28 | export function formatDuration( 29 | durationMs: number, 30 | transform: (num: number) => string = (num) => num.toString() 31 | ): string { 32 | if (durationMs < SECOND) return `${transform(durationMs)}ms`; 33 | 34 | if (durationMs < MINUTE) return `${transform(durationMs / SECOND)}s`; 35 | 36 | if (durationMs < HOUR) return `${transform(durationMs / MINUTE)}min`; 37 | 38 | return `${durationMs / HOUR}h`; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/blocks/ts/url.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Installed from github/ieedan/std 3 | */ 4 | 5 | /** Joins the segments into a single url correctly handling leading and trailing slashes in each segment. 6 | * 7 | * @param segments 8 | * @returns 9 | * 10 | * ## Usage 11 | * ```ts 12 | * const url = join('https://example.com', '', 'api/', '/examples/'); 13 | * 14 | * console.log(url); // https://example.com/api/examples 15 | * ``` 16 | */ 17 | export function join(...segments: string[]): string { 18 | return segments 19 | .map((s) => removeLeadingAndTrailingSlash(s)) 20 | .filter(Boolean) 21 | .join('/'); 22 | } 23 | 24 | /** Removes the leading and trailing slash from the segment (if they exist) 25 | * 26 | * @param segment 27 | * @returns 28 | * 29 | * ## Usage 30 | * ```ts 31 | * const segment = removeLeadingAndTrailingSlash('/example/'); 32 | * 33 | * console.log(segment); // 'example' 34 | * ``` 35 | */ 36 | export function removeLeadingAndTrailingSlash(segment: string): string { 37 | const newSegment = removeLeadingSlash(segment); 38 | return removeTrailingSlash(newSegment); 39 | } 40 | 41 | /** Adds a leading and trailing to the beginning and end of the segment (if it doesn't already exist) 42 | * 43 | * @param segment 44 | * @returns 45 | * 46 | * ## Usage 47 | * ```ts 48 | * const segment = addLeadingAndTrailingSlash('example'); 49 | * 50 | * console.log(segment); // '/example/' 51 | * ``` 52 | */ 53 | export function addLeadingAndTrailingSlash(segment: string): string { 54 | // this is a weird case so feel free to handle it however you think it makes the most sense 55 | if (segment === '') return '//'; 56 | 57 | const newSegment = addLeadingSlash(segment); 58 | return addTrailingSlash(newSegment); 59 | } 60 | 61 | /** Removes the leading slash from the beginning of the segment (if it exists) 62 | * 63 | * @param segment 64 | * @returns 65 | * 66 | * ## Usage 67 | * ```ts 68 | * const segment = removeLeadingSlash('/example'); 69 | * 70 | * console.log(segment); // 'example' 71 | * ``` 72 | */ 73 | export function removeLeadingSlash(segment: string): string { 74 | let newSegment = segment; 75 | if (newSegment.startsWith('/')) { 76 | newSegment = newSegment.slice(1); 77 | } 78 | 79 | return newSegment; 80 | } 81 | 82 | /** Adds a leading slash to the beginning of the segment (if it doesn't already exist) 83 | * 84 | * @param segment 85 | * @returns 86 | * 87 | * ## Usage 88 | * ```ts 89 | * const segment = addLeadingSlash('example'); 90 | * 91 | * console.log(segment); // '/example' 92 | * ``` 93 | */ 94 | export function addLeadingSlash(segment: string): string { 95 | let newSegment = segment; 96 | if (!newSegment.startsWith('/')) { 97 | newSegment = `/${newSegment}`; 98 | } 99 | 100 | return newSegment; 101 | } 102 | 103 | /** Removes the trailing slash from the end of the segment (if it exists) 104 | * 105 | * @param segment 106 | * @returns 107 | * 108 | * ## Usage 109 | * ```ts 110 | * const segment = removeTrailingSlash('example/'); 111 | * 112 | * console.log(segment); // 'example' 113 | * ``` 114 | */ 115 | export function removeTrailingSlash(segment: string): string { 116 | let newSegment = segment; 117 | if (newSegment.endsWith('/')) { 118 | newSegment = newSegment.slice(0, newSegment.length - 1); 119 | } 120 | 121 | return newSegment; 122 | } 123 | 124 | /** Adds a trailing slash to the end of the segment (if it doesn't already exist) 125 | * 126 | * @param segment 127 | * @returns 128 | * 129 | * ## Usage 130 | * ```ts 131 | * const segment = addTrailingSlash('example'); 132 | * 133 | * console.log(segment); // 'example/' 134 | * ``` 135 | */ 136 | export function addTrailingSlash(segment: string): string { 137 | let newSegment = segment; 138 | if (!newSegment.endsWith('/')) { 139 | newSegment = `${newSegment}/`; 140 | } 141 | 142 | return newSegment; 143 | } 144 | 145 | /** Removes the last segment of the url. 146 | * 147 | * @param url 148 | * 149 | * ## Usage 150 | * ```ts 151 | * const url = upOneLevel('/first/second'); 152 | * 153 | * console.log(url); // '/first' 154 | * ``` 155 | */ 156 | export function upOneLevel(url: string): string { 157 | if (url === '/') return url; 158 | 159 | const lastIndex = removeTrailingSlash(url).lastIndexOf('/'); 160 | 161 | return url.slice(0, url.length - lastIndex - 1); 162 | } 163 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import color from 'chalk'; 3 | import { createPathsMatcher } from 'get-tsconfig'; 4 | import path from 'pathe'; 5 | import * as v from 'valibot'; 6 | import { 7 | type Block, 8 | accessLevel, 9 | configFileSchema, 10 | manifestMeta, 11 | peerDependencySchema, 12 | } from '../types'; 13 | import { Err, Ok, type Result } from './blocks/ts/result'; 14 | import { ruleConfigSchema } from './build/check'; 15 | import { tryGetTsconfig } from './files'; 16 | 17 | /** Files and directories ignore by default during build/publish */ 18 | export const IGNORES = ['.git', 'node_modules', '.DS_Store'] as const; 19 | 20 | export const PROJECT_CONFIG_NAME = 'jsrepo.json'; 21 | export const REGISTRY_CONFIG_NAME = 'jsrepo-build-config.json'; 22 | 23 | export const formatterSchema = v.union([v.literal('prettier'), v.literal('biome')]); 24 | 25 | export const pathsSchema = v.objectWithRest( 26 | { 27 | '*': v.string(), 28 | }, 29 | v.string() 30 | ); 31 | 32 | export type Paths = v.InferInput; 33 | 34 | export const projectConfigSchema = v.object({ 35 | $schema: v.string(), 36 | repos: v.optional(v.array(v.string()), []), 37 | includeTests: v.boolean(), 38 | paths: pathsSchema, 39 | configFiles: v.optional(v.record(v.string(), v.string())), 40 | watermark: v.optional(v.boolean(), true), 41 | formatter: v.optional(formatterSchema), 42 | }); 43 | 44 | export function getProjectConfig(cwd: string): Result { 45 | if (!fs.existsSync(path.join(cwd, PROJECT_CONFIG_NAME))) { 46 | return Err('Could not find your configuration file! Please run `init`.'); 47 | } 48 | 49 | const config = v.safeParse( 50 | projectConfigSchema, 51 | JSON.parse(fs.readFileSync(path.join(cwd, PROJECT_CONFIG_NAME)).toString()) 52 | ); 53 | 54 | if (!config.success) { 55 | return Err(`There was an error reading your \`${PROJECT_CONFIG_NAME}\` file!`); 56 | } 57 | 58 | return Ok(config.output); 59 | } 60 | 61 | export type ProjectConfig = v.InferOutput; 62 | 63 | export type Formatter = v.InferOutput; 64 | 65 | export const registryConfigSchema = v.object({ 66 | $schema: v.string(), 67 | name: v.optional(v.string()), 68 | version: v.optional(v.string()), 69 | readme: v.optional(v.string(), 'README.md'), 70 | access: v.optional(accessLevel), 71 | meta: v.optional(manifestMeta), 72 | defaultPaths: v.optional(v.record(v.string(), v.string())), 73 | peerDependencies: v.optional(peerDependencySchema), 74 | configFiles: v.optional(v.array(configFileSchema)), 75 | dirs: v.array(v.string()), 76 | outputDir: v.optional(v.string()), 77 | includeBlocks: v.optional(v.array(v.string()), []), 78 | includeCategories: v.optional(v.array(v.string()), []), 79 | excludeBlocks: v.optional(v.array(v.string()), []), 80 | excludeCategories: v.optional(v.array(v.string()), []), 81 | doNotListBlocks: v.optional(v.array(v.string()), []), 82 | doNotListCategories: v.optional(v.array(v.string()), []), 83 | listBlocks: v.optional(v.array(v.string()), []), 84 | listCategories: v.optional(v.array(v.string()), []), 85 | excludeDeps: v.optional(v.array(v.string()), []), 86 | allowSubdirectories: v.optional(v.boolean()), 87 | preview: v.optional(v.boolean()), 88 | rules: v.optional(ruleConfigSchema), 89 | }); 90 | 91 | export function getRegistryConfig(cwd: string): Result { 92 | if (!fs.existsSync(path.join(cwd, REGISTRY_CONFIG_NAME))) { 93 | return Ok(null); 94 | } 95 | 96 | const config = v.safeParse( 97 | registryConfigSchema, 98 | JSON.parse(fs.readFileSync(path.join(cwd, REGISTRY_CONFIG_NAME)).toString()) 99 | ); 100 | 101 | if (!config.success) { 102 | return Err(`There was an error reading your \`${REGISTRY_CONFIG_NAME}\` file!`); 103 | } 104 | 105 | return Ok(config.output); 106 | } 107 | 108 | export type RegistryConfig = v.InferOutput; 109 | 110 | /** Resolves the paths relative to the cwd */ 111 | export function resolvePaths(paths: Paths, cwd: string): Result { 112 | const config = tryGetTsconfig(cwd).unwrapOr(null); 113 | 114 | const matcher = config ? createPathsMatcher(config) : null; 115 | 116 | const newPaths: Paths = { '*': '' }; 117 | 118 | for (const [category, p] of Object.entries(paths)) { 119 | if (p.startsWith('./')) { 120 | newPaths[category] = path.relative(cwd, path.join(path.resolve(cwd), p)); 121 | continue; 122 | } 123 | 124 | if (matcher === null) { 125 | return Err( 126 | `Cannot resolve ${color.bold(`\`"${category}": "${p}"\``)} from paths because we couldn't find a tsconfig! If you intended to use a relative path ensure that your path starts with ${color.bold('`./`')}.` 127 | ); 128 | } 129 | 130 | const resolved = tryResolvePath(p, matcher, cwd); 131 | 132 | if (!resolved) { 133 | return Err( 134 | `Cannot resolve ${color.bold(`\`"${category}": "${p}"\``)} from paths because we couldn't find a matching alias in the tsconfig. If you intended to use a relative path ensure that your path starts with ${color.bold('`./`')}.` 135 | ); 136 | } 137 | 138 | newPaths[category] = resolved; 139 | } 140 | 141 | return Ok(newPaths); 142 | } 143 | 144 | function tryResolvePath( 145 | unresolvedPath: string, 146 | matcher: (specifier: string) => string[], 147 | cwd: string 148 | ): string | undefined { 149 | const paths = matcher(unresolvedPath); 150 | 151 | return paths.length > 0 ? path.relative(cwd, paths[0]) : undefined; 152 | } 153 | 154 | /** Gets the path where the block should be installed. 155 | * 156 | * @param block 157 | * @param resolvedPaths 158 | * @param cwd 159 | * @returns 160 | */ 161 | export function getPathForBlock(block: Block, resolvedPaths: Paths, cwd: string): string { 162 | let directory: string; 163 | 164 | if (resolvedPaths[block.category] !== undefined) { 165 | directory = path.join(cwd, resolvedPaths[block.category]); 166 | } else { 167 | directory = path.join(cwd, resolvedPaths['*'], block.category); 168 | } 169 | 170 | return directory; 171 | } 172 | -------------------------------------------------------------------------------- /src/utils/context.ts: -------------------------------------------------------------------------------- 1 | import pkg from '../../package.json'; 2 | import type { PackageJson } from './package'; 3 | 4 | export const packageJson = pkg as PackageJson; 5 | -------------------------------------------------------------------------------- /src/utils/dependencies.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { program } from 'commander'; 3 | import { type Agent, resolveCommand } from 'package-manager-detector'; 4 | import path from 'pathe'; 5 | import { x } from 'tinyexec'; 6 | import { flags } from './blocks/package-managers/flags'; 7 | import type { ProjectConfig } from './config'; 8 | import { taskLog } from './prompts'; 9 | 10 | export type Options = { 11 | pm: Agent; 12 | deps: string[]; 13 | /** Install as devDependency */ 14 | dev: boolean; 15 | cwd: string; 16 | ignoreWorkspace?: boolean; 17 | }; 18 | 19 | /** Installs the provided dependencies using the provided package manager 20 | * 21 | * @param param0 22 | * @returns 23 | */ 24 | export async function installDependencies({ 25 | pm, 26 | deps, 27 | dev, 28 | cwd, 29 | ignoreWorkspace = false, 30 | }: Options) { 31 | const args = [...deps]; 32 | 33 | if (dev) { 34 | args.push(flags[pm]['install-as-dev-dependency']); 35 | } 36 | 37 | const noWorkspace = flags[pm]['no-workspace']; 38 | 39 | if (ignoreWorkspace && noWorkspace) { 40 | args.push(noWorkspace); 41 | } 42 | 43 | const add = resolveCommand(pm, 'add', args); 44 | 45 | if (add == null) { 46 | program.error(color.red(`Could not resolve add command for '${pm}'.`)); 47 | } 48 | 49 | const task = taskLog(`Installing dependencies with ${pm}...`); 50 | 51 | try { 52 | const proc = x(add.command, [...add.args], { nodeOptions: { cwd } }); 53 | 54 | for await (const line of proc) { 55 | task.text = `${line}\n`; 56 | } 57 | 58 | task.success(`Installed ${color.cyan(deps.join(', '))}`); 59 | } catch { 60 | task.fail('Failed to install dependencies'); 61 | process.exit(2); 62 | } 63 | } 64 | 65 | const templatePattern = /\{\{([^\/]+)\/([^\}]+)\}\}/g; 66 | 67 | export type ResolveOptions = { 68 | template: string; 69 | config: ProjectConfig; 70 | destPath: string; 71 | cwd: string; 72 | }; 73 | 74 | /** Takes a template and uses replaces it with an alias or relative path that resolves to the correct block 75 | * 76 | * @param param0 77 | * @returns 78 | */ 79 | export function resolveLocalDependencyTemplate({ 80 | template, 81 | config, 82 | destPath, 83 | cwd, 84 | }: ResolveOptions) { 85 | const destDir = path.join(destPath, '../'); 86 | 87 | return template.replace(templatePattern, (_, category, name) => { 88 | if (config.paths[category] === undefined) { 89 | // if relative make it relative 90 | if (config.paths['*'].startsWith('.')) { 91 | const relative = path.relative( 92 | destDir, 93 | path.join(cwd, config.paths['*'], category, name) 94 | ); 95 | 96 | return relative.startsWith('.') ? relative : `./${relative}`; 97 | } 98 | 99 | return path.join(config.paths['*'], category, name); 100 | } 101 | 102 | // if relative make it relative 103 | if (config.paths[category].startsWith('.')) { 104 | const relative = path.relative(destDir, path.join(cwd, config.paths[category], name)); 105 | 106 | return relative.startsWith('.') ? relative : `./${relative}`; 107 | } 108 | 109 | return path.join(config.paths[category], name); 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /src/utils/diff.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { type Change, diffChars } from 'diff'; 3 | import * as array from './blocks/ts/array'; 4 | import * as lines from './blocks/ts/lines'; 5 | import { leftPadMin } from './blocks/ts/pad'; 6 | 7 | type Options = { 8 | /** The source file */ 9 | from: string; 10 | /** The destination file */ 11 | to: string; 12 | /** The changes to the file */ 13 | changes: Change[]; 14 | /** Expands all lines to show the entire file */ 15 | expand: boolean; 16 | /** Maximum lines to show before collapsing */ 17 | maxUnchanged: number; 18 | /** Color the removed lines */ 19 | colorRemoved?: (line: string) => string; 20 | /** Color the added lines */ 21 | colorAdded?: (line: string) => string; 22 | /** Color the removed chars */ 23 | colorCharsRemoved?: (line: string) => string; 24 | /** Color the added chars */ 25 | colorCharsAdded?: (line: string) => string; 26 | /** Prefixes each line with the string returned from this function. */ 27 | prefix: () => string; 28 | intro: (options: Options) => string; 29 | onUnchanged: (options: Options) => string; 30 | }; 31 | 32 | /** Check if a character is whitespace 33 | * 34 | * @param str 35 | * @returns 36 | */ 37 | function isWhitespace(str: string) { 38 | return /^\s+$/g.test(str); 39 | } 40 | 41 | /** We need to add a newline at the end of each change to make sure 42 | * the next change can start correctly. So we take off just 1. 43 | * 44 | * @param str 45 | * @returns 46 | */ 47 | function trimSingleNewLine(str: string): string { 48 | let i = str.length - 1; 49 | while (isWhitespace(str[i]) && i >= 0) { 50 | if (str[i] === '\n') { 51 | if (str[i - 1] === '\r') { 52 | return str.slice(0, i - 1); 53 | } 54 | 55 | return str.slice(0, i); 56 | } 57 | 58 | i--; 59 | } 60 | 61 | return str; 62 | } 63 | 64 | function formatDiff({ 65 | from, 66 | to, 67 | changes, 68 | expand = false, 69 | maxUnchanged = 5, 70 | colorRemoved = color.redBright, 71 | colorAdded = color.greenBright, 72 | colorCharsRemoved = color.bgRedBright, 73 | colorCharsAdded = color.bgGreenBright, 74 | prefix, 75 | onUnchanged, 76 | intro, 77 | }: Options): string { 78 | let result = ''; 79 | 80 | const length = array.sum(changes, (change) => change.count ?? 0).toString().length + 1; 81 | 82 | let lineOffset = 0; 83 | 84 | if (changes.length === 1 && !changes[0].added && !changes[0].removed) { 85 | return onUnchanged({ 86 | from, 87 | to, 88 | changes, 89 | expand, 90 | maxUnchanged, 91 | colorAdded, 92 | colorRemoved, 93 | prefix, 94 | onUnchanged, 95 | intro, 96 | }); 97 | } 98 | 99 | result += intro({ 100 | from, 101 | to, 102 | changes, 103 | expand, 104 | maxUnchanged, 105 | colorAdded, 106 | colorRemoved, 107 | prefix, 108 | onUnchanged, 109 | intro, 110 | }); 111 | 112 | /** Provides the line number prefix */ 113 | const linePrefix = (line: number): string => 114 | color.gray(`${prefix?.() ?? ''}${leftPadMin(`${line + 1 + lineOffset} `, length)} `); 115 | 116 | for (let i = 0; i < changes.length; i++) { 117 | const change = changes[i]; 118 | 119 | const hasPreviousChange = changes[i - 1]?.added || changes[i - 1]?.removed; 120 | const hasNextChange = changes[i + 1]?.added || changes[i + 1]?.removed; 121 | 122 | if (!change.added && !change.removed) { 123 | // show collapsed 124 | if (!expand && change.count !== undefined && change.count > maxUnchanged) { 125 | const prevLineOffset = lineOffset; 126 | const ls = lines.get(trimSingleNewLine(change.value)); 127 | 128 | let shownLines = 0; 129 | 130 | if (hasNextChange) shownLines += maxUnchanged; 131 | if (hasPreviousChange) shownLines += maxUnchanged; 132 | 133 | // just show all if we are going to show more than we have 134 | if (shownLines >= ls.length) { 135 | result += `${lines.join(ls, { 136 | prefix: linePrefix, 137 | })}\n`; 138 | lineOffset += ls.length; 139 | continue; 140 | } 141 | 142 | // this writes the top few lines 143 | if (hasPreviousChange) { 144 | result += `${lines.join(ls.slice(0, maxUnchanged), { 145 | prefix: linePrefix, 146 | })}\n`; 147 | } 148 | 149 | if (ls.length > shownLines) { 150 | const count = ls.length - shownLines; 151 | result += `${lines.join( 152 | lines.get( 153 | color.gray( 154 | `+ ${count} more unchanged (${color.italic('-E to expand')})` 155 | ) 156 | ), 157 | { 158 | prefix: () => `${prefix?.() ?? ''}${leftPadMin(' ', length)} `, 159 | } 160 | )}\n`; 161 | } 162 | 163 | if (hasNextChange) { 164 | lineOffset = lineOffset + ls.length - maxUnchanged; 165 | result += `${lines.join(ls.slice(ls.length - maxUnchanged), { 166 | prefix: linePrefix, 167 | })}\n`; 168 | } 169 | 170 | // resets the line offset 171 | lineOffset = prevLineOffset + change.count; 172 | continue; 173 | } 174 | 175 | // show expanded 176 | result += `${lines.join(lines.get(trimSingleNewLine(change.value)), { 177 | prefix: linePrefix, 178 | })}\n`; 179 | lineOffset += change.count ?? 0; 180 | 181 | continue; 182 | } 183 | 184 | const colorLineChange = (change: Change) => { 185 | if (change.added) { 186 | return colorAdded(trimSingleNewLine(change.value)); 187 | } 188 | 189 | if (change.removed) { 190 | return colorRemoved(trimSingleNewLine(change.value)); 191 | } 192 | 193 | return change.value; 194 | }; 195 | 196 | const colorCharChange = (change: Change) => { 197 | if (change.added) { 198 | return colorCharsAdded(trimSingleNewLine(change.value)); 199 | } 200 | 201 | if (change.removed) { 202 | return colorCharsRemoved(trimSingleNewLine(change.value)); 203 | } 204 | 205 | return change.value; 206 | }; 207 | 208 | if ( 209 | change.removed && 210 | change.count === 1 && 211 | changes[i + 1]?.added && 212 | changes[i + 1]?.count === 1 213 | ) { 214 | // single line change 215 | const diffedChars = diffChars(change.value, changes[i + 1].value); 216 | 217 | const sentence = diffedChars.map((chg) => colorCharChange(chg)).join(''); 218 | 219 | result += `${linePrefix(0)}${sentence}`; 220 | 221 | lineOffset += 1; 222 | 223 | i++; 224 | } else { 225 | if (isWhitespace(change.value)) { 226 | // adds some spaces to make sure that you can see the change 227 | result += `${lines.join(lines.get(colorCharChange(change)), { 228 | prefix: (line) => 229 | `${linePrefix(line)}${colorCharChange({ 230 | removed: true, 231 | value: ' ', 232 | added: false, 233 | count: 1, 234 | })}`, 235 | })}\n`; 236 | 237 | if (!change.removed) { 238 | lineOffset += change.count ?? 0; 239 | } 240 | } else { 241 | result += `${lines.join(lines.get(colorLineChange(change)), { 242 | prefix: linePrefix, 243 | })}\n`; 244 | 245 | if (!change.removed) { 246 | lineOffset += change.count ?? 0; 247 | } 248 | } 249 | } 250 | } 251 | 252 | if (!result.endsWith('\n')) { 253 | result = result += '\n'; 254 | } 255 | 256 | return result; 257 | } 258 | 259 | export { formatDiff }; 260 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import mfFetch from 'make-fetch-happen'; 2 | import path from 'pathe'; 3 | 4 | /** Fetch method used for (i)nternal consumption */ 5 | export const iFetch = mfFetch.defaults({ cachePath: path.join(import.meta.dirname, 'cache') }); 6 | -------------------------------------------------------------------------------- /src/utils/files.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import type { PartialConfiguration } from '@biomejs/wasm-nodejs'; 3 | import color from 'chalk'; 4 | import escapeStringRegexp from 'escape-string-regexp'; 5 | import { type TsConfigResult, getTsconfig } from 'get-tsconfig'; 6 | import path from 'pathe'; 7 | import type * as prettier from 'prettier'; 8 | import { Err, Ok, type Result } from './blocks/ts/result'; 9 | import { endsWithOneOf } from './blocks/ts/strings'; 10 | import type { ProjectConfig } from './config'; 11 | import { resolveLocalDependencyTemplate } from './dependencies'; 12 | import { languages } from './language-support'; 13 | 14 | type TransformRemoteContentOptions = { 15 | file: { 16 | /** The content of the file */ 17 | content: string; 18 | /** The dest path of the file used to determine the language */ 19 | destPath: string; 20 | }; 21 | config: ProjectConfig; 22 | watermark: string; 23 | imports: Record; 24 | prettierOptions: prettier.Options | null; 25 | biomeOptions: PartialConfiguration | null; 26 | cwd: string; 27 | verbose?: (msg: string) => void; 28 | }; 29 | 30 | /** Makes the necessary modifications to the content of the file to ensure it works properly in the users project 31 | * 32 | * @param param0 33 | * @returns 34 | */ 35 | export async function transformRemoteContent({ 36 | file, 37 | config, 38 | imports, 39 | watermark, 40 | prettierOptions, 41 | biomeOptions, 42 | cwd, 43 | verbose, 44 | }: TransformRemoteContentOptions): Promise> { 45 | const lang = languages.find((lang) => lang.matches(file.destPath)); 46 | 47 | let content: string = file.content; 48 | 49 | if (lang) { 50 | if (config.watermark) { 51 | const comment = lang.comment(watermark); 52 | 53 | content = `${comment}\n\n${content}`; 54 | } 55 | 56 | verbose?.(`Formatting ${color.bold(file.destPath)}`); 57 | 58 | try { 59 | content = await lang.format(content, { 60 | filePath: file.destPath, 61 | formatter: config.formatter, 62 | prettierOptions, 63 | biomeOptions, 64 | }); 65 | } catch (err) { 66 | return Err(`Error formatting ${color.bold(file.destPath)} ${err}`); 67 | } 68 | } 69 | 70 | // transform imports 71 | for (const [literal, template] of Object.entries(imports)) { 72 | const resolvedImport = resolveLocalDependencyTemplate({ 73 | template, 74 | config, 75 | destPath: file.destPath, 76 | cwd, 77 | }); 78 | 79 | // this way we only replace the exact import since it will be surrounded in quotes 80 | const literalRegex = new RegExp(`(['"])${escapeStringRegexp(literal)}\\1`, 'g'); 81 | 82 | content = content.replaceAll(literalRegex, `$1${resolvedImport}$1`); 83 | } 84 | 85 | return Ok(content); 86 | } 87 | 88 | type FormatOptions = { 89 | file: { 90 | /** The content of the file */ 91 | content: string; 92 | /** The dest path of the file used to determine the language */ 93 | destPath: string; 94 | }; 95 | formatter: ProjectConfig['formatter']; 96 | prettierOptions: prettier.Options | null; 97 | biomeOptions: PartialConfiguration | null; 98 | }; 99 | 100 | /** Auto detects the language and formats the file content. 101 | * 102 | * @param param0 103 | * @returns 104 | */ 105 | export async function formatFile({ 106 | file, 107 | formatter, 108 | prettierOptions, 109 | biomeOptions, 110 | }: FormatOptions): Promise { 111 | const lang = languages.find((lang) => lang.matches(file.destPath)); 112 | 113 | let newContent = file.content; 114 | 115 | if (lang) { 116 | try { 117 | newContent = await lang.format(file.content, { 118 | filePath: file.destPath, 119 | formatter, 120 | prettierOptions, 121 | biomeOptions, 122 | }); 123 | } catch { 124 | return newContent; 125 | } 126 | } 127 | 128 | return newContent; 129 | } 130 | 131 | export function matchJSDescendant(searchFilePath: string): string | undefined { 132 | const MATCH_EXTENSIONS = ['.js', '.ts', '.cjs', '.mjs']; 133 | 134 | if (!endsWithOneOf(searchFilePath, MATCH_EXTENSIONS)) return undefined; 135 | 136 | const dir = path.dirname(searchFilePath); 137 | 138 | const files = fs.readdirSync(dir); 139 | 140 | const parsedSearch = path.parse(searchFilePath); 141 | 142 | for (const file of files) { 143 | if (!endsWithOneOf(file, MATCH_EXTENSIONS)) continue; 144 | 145 | if (path.parse(file).name === parsedSearch.name) return path.join(dir, file); 146 | } 147 | 148 | return undefined; 149 | } 150 | 151 | /** Attempts to get the js/tsconfig file for the searched path 152 | * 153 | * @param searchPath 154 | * @returns 155 | */ 156 | export function tryGetTsconfig(searchPath?: string): Result { 157 | let config: TsConfigResult | null; 158 | 159 | try { 160 | config = getTsconfig(searchPath, 'tsconfig.json'); 161 | 162 | if (!config) { 163 | // if we don't find the config at first check for a jsconfig 164 | config = getTsconfig(searchPath, 'jsconfig.json'); 165 | 166 | if (!config) { 167 | return Ok(null); 168 | } 169 | } 170 | } catch (err) { 171 | return Err(`Error while trying to get ${color.bold('tsconfig.json')}: ${err}`); 172 | } 173 | 174 | return Ok(config); 175 | } 176 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import type { PartialConfiguration } from '@biomejs/wasm-nodejs'; 3 | import path from 'pathe'; 4 | import * as prettier from 'prettier'; 5 | import type { Formatter } from './config'; 6 | 7 | export type FormatterConfig = { 8 | prettierOptions: prettier.Options | null; 9 | biomeOptions: PartialConfiguration | null; 10 | }; 11 | 12 | export async function loadFormatterConfig({ 13 | formatter, 14 | cwd, 15 | }: { formatter?: Formatter; cwd: string }): Promise { 16 | let prettierOptions: prettier.Options | null = null; 17 | if (formatter === 'prettier') { 18 | prettierOptions = await prettier.resolveConfig(path.join(cwd, '.prettierrc')); 19 | } 20 | 21 | let biomeOptions: PartialConfiguration | null = null; 22 | if (formatter === 'biome') { 23 | const configPath = path.join(cwd, 'biome.json'); 24 | if (fs.existsSync(configPath)) { 25 | biomeOptions = JSON.parse(fs.readFileSync(configPath).toString()); 26 | } 27 | } 28 | 29 | return { 30 | biomeOptions, 31 | prettierOptions, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/get-latest-version.ts: -------------------------------------------------------------------------------- 1 | import { Err, Ok, type Result } from './blocks/ts/result'; 2 | import { iFetch } from './fetch'; 3 | import type { Package } from './parse-package-name'; 4 | import * as persisted from './persisted'; 5 | 6 | const LATEST_VERSION_KEY = 'latest-version'; 7 | const EXPIRATION_TIME = 60 * 60 * 1000; // 1 hour 8 | 9 | type LatestVersion = { 10 | expiration: number; 11 | version: string; 12 | }; 13 | 14 | /** Checks for the latest version from the github repository. Will cache results for up to 1 hour. */ 15 | export async function getLatestVersion({ 16 | noCache = false, 17 | }: { noCache?: boolean } = {}): Promise> { 18 | try { 19 | // handle caching 20 | const storage = persisted.get(); 21 | 22 | let version: string; 23 | 24 | if (!noCache) { 25 | const latestVersion = storage.get(LATEST_VERSION_KEY) as LatestVersion | null; 26 | 27 | if (latestVersion) { 28 | if (latestVersion.expiration > Date.now()) { 29 | version = latestVersion.version; 30 | 31 | return Ok(version); 32 | } 33 | 34 | storage.delete(LATEST_VERSION_KEY); 35 | } 36 | } 37 | 38 | const response = await iFetch( 39 | 'https://raw.githubusercontent.com/jsrepojs/jsrepo/refs/heads/main/packages/cli/package.json', 40 | { 41 | timeout: 1000, 42 | } 43 | ); 44 | 45 | if (!response.ok) { 46 | return Err('Error getting version'); 47 | } 48 | 49 | const { version: ver } = (await response.json()) as Package; 50 | 51 | version = ver; 52 | 53 | storage.set(LATEST_VERSION_KEY, { 54 | expiration: Date.now() + EXPIRATION_TIME, 55 | version, 56 | } satisfies LatestVersion); 57 | 58 | return Ok(version); 59 | } catch (err) { 60 | return Err(`Error getting version: ${err}`); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/get-watermark.ts: -------------------------------------------------------------------------------- 1 | export function getWatermark(repoUrl: string): string { 2 | return `Installed from ${repoUrl}`; 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/language-support/css.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { Biome, Distribution } from '@biomejs/js-api'; 3 | import * as cssDependency from 'css-dependency'; 4 | import * as prettier from 'prettier'; 5 | import { type Lang, formatError, resolveImports } from '.'; 6 | import * as lines from '../blocks/ts/lines'; 7 | import { Err, Ok } from '../blocks/ts/result'; 8 | 9 | /** Language support for `*.css` files. */ 10 | export const css: Lang = { 11 | matches: (fileName) => fileName.endsWith('.css'), 12 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 13 | const sourceCode = fs.readFileSync(filePath).toString(); 14 | 15 | const parseResult = cssDependency.parse(sourceCode, { allowTailwindDirectives: true }); 16 | 17 | if (parseResult.isErr()) { 18 | return Err(parseResult.unwrapErr().message); 19 | } 20 | 21 | const imports = parseResult.unwrap(); 22 | 23 | const resolveResult = resolveImports({ 24 | moduleSpecifiers: imports.map((imp) => imp.module), 25 | filePath, 26 | isSubDir, 27 | dirs, 28 | cwd, 29 | containingDir, 30 | doNotInstall: excludeDeps, 31 | }); 32 | 33 | if (resolveResult.isErr()) { 34 | return Err( 35 | resolveResult 36 | .unwrapErr() 37 | .map((err) => formatError(err)) 38 | .join('\n') 39 | ); 40 | } 41 | 42 | return Ok(resolveResult.unwrap()); 43 | }, 44 | comment: (content) => `/*\n${lines.join(lines.get(content), { prefix: () => '\t' })}\n*/`, 45 | format: async (code, { formatter, prettierOptions, biomeOptions, filePath }) => { 46 | if (!formatter) return code; 47 | 48 | if (formatter === 'prettier') { 49 | return await prettier.format(code, { filepath: filePath, ...prettierOptions }); 50 | } 51 | 52 | const biome = await Biome.create({ 53 | distribution: Distribution.NODE, 54 | }); 55 | 56 | if (biomeOptions) { 57 | biome.applyConfiguration(biomeOptions); 58 | } 59 | 60 | return biome.formatContent(code, { filePath }).content; 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/utils/language-support/html.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import * as parse5 from 'parse5'; 3 | import * as prettier from 'prettier'; 4 | import { type Lang, formatError, resolveImports } from '.'; 5 | import * as lines from '../blocks/ts/lines'; 6 | import { Err, Ok } from '../blocks/ts/result'; 7 | 8 | /** Language support for `*.html` files. */ 9 | export const html: Lang = { 10 | matches: (fileName) => fileName.endsWith('.html'), 11 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 12 | const sourceCode = fs.readFileSync(filePath).toString(); 13 | 14 | const ast = parse5.parse(sourceCode); 15 | 16 | const imports: string[] = []; 17 | 18 | // @ts-ignore yeah I know 19 | const walk = (node, enter: (node) => void) => { 20 | if (!node) return; 21 | 22 | enter(node); 23 | 24 | if (node.childNodes && node.childNodes.length > 0) { 25 | for (const n of node.childNodes) { 26 | walk(n, enter); 27 | } 28 | } 29 | }; 30 | 31 | for (const node of ast.childNodes) { 32 | walk(node, (n) => { 33 | if (n.tagName === 'script') { 34 | for (const attr of n.attrs) { 35 | if (attr.name === 'src') { 36 | imports.push(attr.value); 37 | } 38 | } 39 | } 40 | 41 | if ( 42 | n.tagName === 'link' && 43 | // @ts-ignore yeah I know 44 | n.attrs.find((attr) => attr.name === 'rel' && attr.value === 'stylesheet') 45 | ) { 46 | for (const attr of n.attrs) { 47 | if (attr.name === 'href' && !attr.value.startsWith('http')) { 48 | imports.push(attr.value); 49 | } 50 | } 51 | } 52 | }); 53 | } 54 | 55 | const resolveResult = resolveImports({ 56 | moduleSpecifiers: imports, 57 | filePath, 58 | isSubDir, 59 | dirs, 60 | cwd, 61 | containingDir, 62 | doNotInstall: ['svelte', '@sveltejs/kit', ...excludeDeps], 63 | }); 64 | 65 | if (resolveResult.isErr()) { 66 | return Err( 67 | resolveResult 68 | .unwrapErr() 69 | .map((err) => formatError(err)) 70 | .join('\n') 71 | ); 72 | } 73 | 74 | return Ok(resolveResult.unwrap()); 75 | }, 76 | comment: (content) => ``, 77 | format: async (code, { formatter, prettierOptions }) => { 78 | if (!formatter) return code; 79 | 80 | if (formatter === 'prettier') { 81 | return await prettier.format(code, { parser: 'html', ...prettierOptions }); 82 | } 83 | 84 | // biome is in progress for formatting html 85 | 86 | return code; 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /src/utils/language-support/javascript.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { Biome, Distribution } from '@biomejs/js-api'; 3 | import oxc from 'oxc-parser'; 4 | import * as prettier from 'prettier'; 5 | import { type Lang, formatError, resolveImports } from '.'; 6 | import * as lines from '../blocks/ts/lines'; 7 | import { Err, Ok } from '../blocks/ts/result'; 8 | 9 | /** Parses the provided code and returns the names of any other modules required by the module. 10 | * 11 | * @param fileName This must be provided for oxc to infer the dialect i.e. (jsx, tsx, js, ts) 12 | * @param code The code to be parsed 13 | * @returns 14 | */ 15 | export function getJavascriptImports(fileName: string, code: string): string[] { 16 | const result = oxc.parseSync(fileName, code); 17 | 18 | const modules: string[] = []; 19 | 20 | // handle static imports 21 | for (const imp of result.module.staticImports) { 22 | modules.push(imp.moduleRequest.value); 23 | } 24 | 25 | // handle dynamic imports 26 | for (const imp of result.module.dynamicImports) { 27 | // trims the codes and gets the module 28 | const mod = code.slice(imp.moduleRequest.start + 1, imp.moduleRequest.end - 1); 29 | 30 | modules.push(mod); 31 | } 32 | 33 | // handle `export x from y` syntax 34 | for (const exp of result.module.staticExports) { 35 | for (const entry of exp.entries) { 36 | if (entry.moduleRequest) { 37 | modules.push(entry.moduleRequest.value); 38 | } 39 | } 40 | } 41 | 42 | return modules; 43 | } 44 | 45 | /** Language support for `*.(js|ts|jsx|tsx)` files. */ 46 | export const typescript: Lang = { 47 | matches: (fileName) => 48 | fileName.endsWith('.ts') || 49 | fileName.endsWith('.js') || 50 | fileName.endsWith('.tsx') || 51 | fileName.endsWith('.jsx'), 52 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 53 | const code = fs.readFileSync(filePath).toString(); 54 | 55 | const modules = getJavascriptImports(filePath, code); 56 | 57 | const resolveResult = resolveImports({ 58 | moduleSpecifiers: modules, 59 | filePath, 60 | isSubDir, 61 | dirs, 62 | cwd, 63 | containingDir, 64 | doNotInstall: excludeDeps, 65 | }); 66 | 67 | if (resolveResult.isErr()) { 68 | return Err( 69 | resolveResult 70 | .unwrapErr() 71 | .map((err) => formatError(err)) 72 | .join('\n') 73 | ); 74 | } 75 | 76 | return Ok(resolveResult.unwrap()); 77 | }, 78 | comment: (content) => `/*\n${lines.join(lines.get(content), { prefix: () => '\t' })}\n*/`, 79 | format: async (code, { formatter, filePath, prettierOptions, biomeOptions }) => { 80 | if (!formatter) return code; 81 | 82 | if (formatter === 'prettier') { 83 | return await prettier.format(code, { filepath: filePath, ...prettierOptions }); 84 | } 85 | 86 | const biome = await Biome.create({ 87 | distribution: Distribution.NODE, 88 | }); 89 | 90 | if (biomeOptions) { 91 | biome.applyConfiguration(biomeOptions); 92 | } 93 | 94 | return biome.formatContent(code, { filePath }).content; 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /src/utils/language-support/json.ts: -------------------------------------------------------------------------------- 1 | import { Biome, Distribution } from '@biomejs/js-api'; 2 | import * as prettier from 'prettier'; 3 | import type { Lang } from '.'; 4 | import * as lines from '../blocks/ts/lines'; 5 | import { Ok } from '../blocks/ts/result'; 6 | 7 | const format: Lang['format'] = async ( 8 | code, 9 | { formatter, prettierOptions, biomeOptions, filePath } 10 | ) => { 11 | if (!formatter) return code; 12 | 13 | if (formatter === 'prettier') { 14 | return await prettier.format(code, { filepath: filePath, ...prettierOptions }); 15 | } 16 | 17 | const biome = await Biome.create({ 18 | distribution: Distribution.NODE, 19 | }); 20 | 21 | if (biomeOptions) { 22 | biome.applyConfiguration({ 23 | ...biomeOptions, 24 | json: { parser: { allowComments: true } }, 25 | }); 26 | } 27 | 28 | return biome.formatContent(code, { filePath }).content; 29 | }; 30 | 31 | /** Language support for `*.(json)` files. */ 32 | export const json: Lang = { 33 | matches: (fileName) => fileName.endsWith('.json'), 34 | resolveDependencies: () => 35 | Ok({ dependencies: [], local: [], devDependencies: [], imports: {} }), 36 | // json doesn't support comments 37 | comment: (content: string) => content, 38 | format, 39 | }; 40 | 41 | /** Language support for `*.(jsonc)` files. */ 42 | export const jsonc: Lang = { 43 | matches: (fileName) => fileName.endsWith('.jsonc'), 44 | resolveDependencies: () => 45 | Ok({ dependencies: [], local: [], devDependencies: [], imports: {} }), 46 | comment: (content) => `/*\n${lines.join(lines.get(content), { prefix: () => '\t' })}\n*/`, 47 | format, 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/language-support/sass.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import * as cssDependency from 'css-dependency'; 3 | import * as prettier from 'prettier'; 4 | import { type Lang, formatError, resolveImports } from '.'; 5 | import * as lines from '../blocks/ts/lines'; 6 | import { Err, Ok } from '../blocks/ts/result'; 7 | 8 | /** Language support for `*.(sass|scss)` files. */ 9 | export const sass: Lang = { 10 | matches: (fileName) => fileName.endsWith('.sass') || fileName.endsWith('.scss'), 11 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 12 | const sourceCode = fs.readFileSync(filePath).toString(); 13 | 14 | const parseResult = cssDependency.parse(sourceCode); 15 | 16 | if (parseResult.isErr()) { 17 | return Err(parseResult.unwrapErr().message); 18 | } 19 | 20 | const imports = parseResult.unwrap(); 21 | 22 | const resolveResult = resolveImports({ 23 | moduleSpecifiers: imports.map((imp) => imp.module), 24 | filePath, 25 | isSubDir, 26 | dirs, 27 | cwd, 28 | containingDir, 29 | doNotInstall: excludeDeps, 30 | }); 31 | 32 | if (resolveResult.isErr()) { 33 | return Err( 34 | resolveResult 35 | .unwrapErr() 36 | .map((err) => formatError(err)) 37 | .join('\n') 38 | ); 39 | } 40 | 41 | return Ok(resolveResult.unwrap()); 42 | }, 43 | comment: (content) => `/*\n${lines.join(lines.get(content), { prefix: () => '\t' })}\n*/`, 44 | format: async (code, { formatter, prettierOptions }) => { 45 | if (!formatter) return code; 46 | 47 | if (formatter === 'prettier') { 48 | return await prettier.format(code, { parser: 'scss', ...prettierOptions }); 49 | } 50 | 51 | return code; 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /src/utils/language-support/svelte.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { type Node, walk } from 'estree-walker'; 3 | import * as prettier from 'prettier'; 4 | import * as sv from 'svelte/compiler'; 5 | import { type Lang, formatError, resolveImports } from '.'; 6 | import * as lines from '../blocks/ts/lines'; 7 | import { Err, Ok } from '../blocks/ts/result'; 8 | 9 | /** Language support for `*.svelte` files. */ 10 | export const svelte: Lang = { 11 | matches: (fileName) => fileName.endsWith('.svelte'), 12 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 13 | const sourceCode = fs.readFileSync(filePath).toString(); 14 | 15 | const root = sv.parse(sourceCode, { modern: true, filename: filePath }); 16 | 17 | // if no script tag then no dependencies 18 | if (!root.instance && !root.module) 19 | return Ok({ dependencies: [], devDependencies: [], local: [], imports: {} }); 20 | 21 | const modules: string[] = []; 22 | 23 | const enter = (node: Node) => { 24 | if ( 25 | node.type === 'ImportDeclaration' || 26 | node.type === 'ExportAllDeclaration' || 27 | node.type === 'ExportNamedDeclaration' 28 | ) { 29 | if (typeof node.source?.value === 'string') { 30 | modules.push(node.source.value); 31 | } 32 | } 33 | 34 | if (node.type === 'ImportExpression') { 35 | if (node.source.type === 'Literal' && typeof node.source.value === 'string') { 36 | modules.push(node.source.value); 37 | } 38 | } 39 | }; 40 | 41 | if (root.instance) { 42 | // biome-ignore lint/suspicious/noExplicitAny: The root instance is just missing the `id` prop 43 | walk(root.instance as any, { enter }); 44 | } 45 | 46 | if (root.module) { 47 | // biome-ignore lint/suspicious/noExplicitAny: The root instance is just missing the `id` prop 48 | walk(root.module as any, { enter }); 49 | } 50 | 51 | const resolveResult = resolveImports({ 52 | moduleSpecifiers: modules, 53 | filePath, 54 | isSubDir, 55 | dirs, 56 | cwd, 57 | containingDir, 58 | doNotInstall: ['svelte', '@sveltejs/kit', ...excludeDeps], 59 | }); 60 | 61 | if (resolveResult.isErr()) { 62 | return Err( 63 | resolveResult 64 | .unwrapErr() 65 | .map((err) => formatError(err)) 66 | .join('\n') 67 | ); 68 | } 69 | 70 | return Ok(resolveResult.unwrap()); 71 | }, 72 | comment: (content) => ``, 73 | format: async (code, { formatter, filePath, prettierOptions }) => { 74 | if (!formatter) return code; 75 | 76 | // only attempt to format if svelte plugin is included in the config. 77 | if ( 78 | formatter === 'prettier' && 79 | prettierOptions && 80 | prettierOptions.plugins?.find((plugin) => plugin === 'prettier-plugin-svelte') 81 | ) { 82 | return await prettier.format(code, { 83 | filepath: filePath, 84 | ...prettierOptions, 85 | }); 86 | } 87 | 88 | return code; 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/utils/language-support/svg.ts: -------------------------------------------------------------------------------- 1 | import type { Lang } from '.'; 2 | import * as lines from '../blocks/ts/lines'; 3 | import { Ok } from '../blocks/ts/result'; 4 | 5 | /** Language support for `*.svg` files. */ 6 | export const svg: Lang = { 7 | matches: (fileName) => fileName.endsWith('.svg'), 8 | resolveDependencies: () => 9 | Ok({ dependencies: [], local: [], devDependencies: [], imports: {} }), 10 | comment: (content) => ``, 11 | format: async (code) => code, 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/language-support/vue.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import * as prettier from 'prettier'; 3 | import * as v from 'vue/compiler-sfc'; 4 | import { type Lang, formatError, resolveImports } from '.'; 5 | import * as lines from '../blocks/ts/lines'; 6 | import { Err, Ok } from '../blocks/ts/result'; 7 | import { getJavascriptImports } from './javascript'; 8 | 9 | /** Language support for `*.vue` files. */ 10 | export const vue: Lang = { 11 | matches: (fileName) => fileName.endsWith('.vue'), 12 | resolveDependencies: ({ filePath, isSubDir, excludeDeps, dirs, cwd, containingDir }) => { 13 | const code = fs.readFileSync(filePath).toString(); 14 | 15 | const parsed = v.parse(code, { filename: filePath }); 16 | 17 | const modules: string[] = []; 18 | 19 | if (parsed.descriptor.script?.content) { 20 | const mods = getJavascriptImports('noop.ts', parsed.descriptor.script.content); 21 | 22 | modules.push(...mods); 23 | } 24 | 25 | if (parsed.descriptor.scriptSetup?.content) { 26 | const mods = getJavascriptImports('noop.ts', parsed.descriptor.scriptSetup.content); 27 | 28 | modules.push(...mods); 29 | } 30 | 31 | if (modules.length === 0) 32 | return Ok({ dependencies: [], devDependencies: [], local: [], imports: {} }); 33 | 34 | const resolveResult = resolveImports({ 35 | moduleSpecifiers: modules, 36 | filePath, 37 | isSubDir, 38 | dirs, 39 | cwd, 40 | containingDir, 41 | doNotInstall: ['vue', 'nuxt', ...excludeDeps], 42 | }); 43 | 44 | if (resolveResult.isErr()) { 45 | return Err( 46 | resolveResult 47 | .unwrapErr() 48 | .map((err) => formatError(err)) 49 | .join('\n') 50 | ); 51 | } 52 | 53 | return Ok(resolveResult.unwrap()); 54 | }, 55 | comment: (content) => ``, 56 | format: async (code, { formatter, prettierOptions }) => { 57 | if (!formatter) return code; 58 | 59 | if (formatter === 'prettier') { 60 | return await prettier.format(code, { parser: 'vue', ...prettierOptions }); 61 | } 62 | 63 | // biome has issues with vue support 64 | return code; 65 | }, 66 | }; 67 | -------------------------------------------------------------------------------- /src/utils/language-support/yaml.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from 'prettier'; 2 | import type { Lang } from '.'; 3 | import * as lines from '../blocks/ts/lines'; 4 | import { Ok } from '../blocks/ts/result'; 5 | 6 | /** Language support for `*.(yaml|yml)` files. */ 7 | export const yaml: Lang = { 8 | matches: (fileName) => fileName.endsWith('.yml') || fileName.endsWith('.yaml'), 9 | resolveDependencies: () => 10 | Ok({ dependencies: [], local: [], devDependencies: [], imports: {} }), 11 | comment: (content: string) => lines.join(lines.get(content), { prefix: () => '# ' }), 12 | format: async (code, { formatter, prettierOptions }) => { 13 | if (!formatter) return code; 14 | 15 | if (formatter === 'prettier') { 16 | return await prettier.format(code, { parser: 'yaml', ...prettierOptions }); 17 | } 18 | 19 | return code; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/manifest.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | import { type Category, type Manifest, categorySchema, manifestSchema } from '../types'; 3 | import { Err, Ok, type Result } from './blocks/ts/result'; 4 | import type { RegistryConfig } from './config'; 5 | 6 | /** Parses the json string (if it can be) into a manifest. 7 | * 8 | * @param json 9 | */ 10 | export function parseManifest(json: string): Result { 11 | let parsed: unknown; 12 | 13 | try { 14 | parsed = JSON.parse(json); 15 | } catch (err) { 16 | return Err(`Error parsing manifest json ${err}`); 17 | } 18 | 19 | // first gen array-based config 20 | if (Array.isArray(parsed)) { 21 | const validated = v.safeParse(v.array(categorySchema), parsed); 22 | 23 | if (!validated.success) { 24 | return Err( 25 | `Error parsing categories (array-based config) ${validated.issues.join(' ')}` 26 | ); 27 | } 28 | 29 | return Ok({ 30 | private: false, 31 | categories: validated.output, 32 | }); 33 | } 34 | 35 | const validated = v.safeParse(manifestSchema, parsed); 36 | 37 | if (!validated.success) { 38 | return Err(`Error parsing manifest ${validated.issues.join(' ')}`); 39 | } 40 | 41 | return Ok(validated.output); 42 | } 43 | 44 | export function createManifest( 45 | categories: Category[], 46 | configFiles: Manifest['configFiles'], 47 | config: RegistryConfig 48 | ) { 49 | const manifest: Manifest = { 50 | name: config.name, 51 | version: config.version, 52 | meta: config.meta, 53 | access: config.access, 54 | defaultPaths: config.defaultPaths, 55 | peerDependencies: config.peerDependencies, 56 | configFiles, 57 | categories, 58 | }; 59 | 60 | return manifest; 61 | } 62 | -------------------------------------------------------------------------------- /src/utils/package.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'pathe'; 3 | import semver from 'semver'; 4 | import { Err, Ok, type Result } from './blocks/ts/result'; 5 | import { parsePackageName } from './parse-package-name'; 6 | 7 | function findNearestPackageJson(startDir: string, until: string): string | undefined { 8 | const packagePath = path.join(startDir, 'package.json'); 9 | 10 | if (fs.existsSync(packagePath)) return packagePath; 11 | 12 | if (startDir === until) return undefined; 13 | 14 | const segments = startDir.split(/[\/\\]/); 15 | 16 | return findNearestPackageJson(segments.slice(0, segments.length - 1).join('/'), until); 17 | } 18 | 19 | export type PackageJson = { 20 | name: string; 21 | version: string; 22 | description: string; 23 | scripts: Record; 24 | dependencies: Record; 25 | devDependencies: Record; 26 | }; 27 | 28 | function getPackage(path: string): Result, string> { 29 | if (!fs.existsSync(path)) return Err(`${path} doesn't exist`); 30 | 31 | const contents = fs.readFileSync(path).toString(); 32 | 33 | try { 34 | return Ok(JSON.parse(contents)); 35 | } catch (err) { 36 | return Err(`Error reading package.json: ${err}`); 37 | } 38 | } 39 | 40 | export function cleanVersion(version: string) { 41 | if (version[0] === '^') { 42 | return version.slice(1); 43 | } 44 | 45 | return version; 46 | } 47 | 48 | /** Returns only the dependencies that should be installed based on what is already in the package.json */ 49 | function returnShouldInstall( 50 | dependencies: Set, 51 | devDependencies: Set, 52 | { cwd }: { cwd: string } 53 | ): { devDependencies: Set; dependencies: Set } { 54 | // don't mutate originals 55 | const tempDeps = dependencies; 56 | const tempDevDeps = devDependencies; 57 | 58 | const packageResult = getPackage(path.join(cwd, 'package.json')); 59 | 60 | if (!packageResult.isErr()) { 61 | const pkg = packageResult.unwrap(); 62 | 63 | if (pkg.dependencies) { 64 | for (const dep of tempDeps) { 65 | // this was already parsed when building 66 | const { name, version } = parsePackageName(dep).unwrap(); 67 | 68 | const foundDep = pkg.dependencies[name]; 69 | 70 | // if version isn't pinned and dep exists delete 71 | if (version === undefined && foundDep) { 72 | tempDeps.delete(dep); 73 | continue; 74 | } 75 | 76 | // if the version installed satisfies the requested version remove the dep 77 | if (foundDep && semver.satisfies(cleanVersion(foundDep), version!)) { 78 | tempDeps.delete(dep); 79 | } 80 | } 81 | } 82 | 83 | if (pkg.devDependencies) { 84 | for (const dep of tempDevDeps) { 85 | // this was already parsed when building 86 | const { name, version } = parsePackageName(dep).unwrap(); 87 | 88 | const foundDep = pkg.devDependencies[name]; 89 | 90 | // if version isn't pinned and dep exists delete 91 | if (version === undefined && foundDep) { 92 | tempDevDeps.delete(dep); 93 | continue; 94 | } 95 | 96 | // if the version installed satisfies the requested version remove the dep 97 | if (foundDep && semver.satisfies(cleanVersion(foundDep), version!)) { 98 | tempDevDeps.delete(dep); 99 | } 100 | } 101 | } 102 | } 103 | 104 | return { dependencies: tempDeps, devDependencies: tempDevDeps }; 105 | } 106 | 107 | export { findNearestPackageJson, getPackage, returnShouldInstall }; 108 | -------------------------------------------------------------------------------- /src/utils/parse-package-name.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from https://github.com/egoist/parse-package-name/blob/main/src/index.ts 3 | * @module 4 | */ 5 | 6 | import { Err, Ok } from './blocks/ts/result'; 7 | 8 | // Parsed a scoped package name into name, version, and path. 9 | const RE_SCOPED = /^(@[^\/]+\/[^@\/]+)(?:@([^\/]+))?(\/.*)?$/; 10 | // Parsed a non-scoped package name into name, version, path 11 | const RE_NON_SCOPED = /^([^@\/]+)(?:@([^\/]+))?(\/.*)?$/; 12 | 13 | export type Package = { 14 | /** Name of the package as it would be installed from npm */ 15 | name: string; 16 | /** Version of the package */ 17 | version: string; 18 | path: string; 19 | }; 20 | 21 | export function parsePackageName(input: string) { 22 | const m = RE_SCOPED.exec(input) || RE_NON_SCOPED.exec(input); 23 | 24 | if (!m) return Err(`invalid package name: ${input}`); 25 | 26 | return Ok({ 27 | name: m[1] || '', 28 | version: m[2] || undefined, 29 | path: m[3] || '', 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/persisted.ts: -------------------------------------------------------------------------------- 1 | import Conf from 'conf'; 2 | 3 | function get() { 4 | return new Conf({ projectName: 'jsrepo' }); 5 | } 6 | 7 | export { get }; 8 | -------------------------------------------------------------------------------- /src/utils/preconditions.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { program } from 'commander'; 3 | import path from 'pathe'; 4 | import semver from 'semver'; 5 | import type { Manifest } from '../types'; 6 | import * as ASCII from './ascii'; 7 | import { cleanVersion, getPackage } from './package'; 8 | import type { RegistryProviderState } from './registry-providers'; 9 | 10 | /** Checks if there are any reasons that the CLI should not proceed and logs warnings or stops execution accordingly. 11 | * 12 | * @param providerState 13 | * @param manifest 14 | * @returns 15 | */ 16 | export function checkPreconditions( 17 | providerState: RegistryProviderState, 18 | manifest: Manifest, 19 | cwd: string 20 | ) { 21 | if (!manifest.peerDependencies) return; 22 | 23 | const pkg = getPackage(path.join(cwd, 'package.json')).match( 24 | (v) => v, 25 | (err) => { 26 | if (err.endsWith("doesn't exist")) { 27 | program.error( 28 | `Couldn't find your ${color.bold('package.json')}. Please create one.` 29 | ); 30 | } 31 | 32 | program.error(color.red(err)); 33 | } 34 | ); 35 | 36 | const dependencies = { ...pkg.dependencies, ...pkg.devDependencies }; 37 | 38 | const incompatible: { 39 | name: string; 40 | version: string; 41 | exists: boolean; 42 | expected: string; 43 | message?: string; 44 | }[] = []; 45 | 46 | for (const [name, options] of Object.entries(manifest.peerDependencies)) { 47 | let expected: string; 48 | let message: string | undefined = undefined; 49 | 50 | if (typeof options === 'string') { 51 | expected = options; 52 | } else { 53 | expected = options.version; 54 | message = options.message; 55 | } 56 | 57 | const version = dependencies[name]; 58 | 59 | if (!version) { 60 | incompatible.push({ 61 | name, 62 | expected, 63 | message, 64 | version, 65 | exists: false, 66 | }); 67 | continue; 68 | } 69 | 70 | if (!semver.satisfies(cleanVersion(version), expected)) { 71 | incompatible.push({ 72 | name, 73 | expected, 74 | message, 75 | version, 76 | exists: true, 77 | }); 78 | } 79 | } 80 | 81 | if (incompatible.length > 0) { 82 | process.stdout.write( 83 | `${ASCII.VERTICAL_LINE}\n${color.yellow('▲')} ${ASCII.JUNCTION_TOP} Issues with ${color.bold(providerState.url)} peer dependencies\n` 84 | ); 85 | const msgs = incompatible 86 | .map((dep, i) => { 87 | const last = incompatible.length - 1 === i; 88 | 89 | let message: string; 90 | 91 | if (dep.exists) { 92 | message = `${color.yellowBright('x unmet peer')} need ${color.bold(`${dep.name}@`)}${color.greenBright.bold(dep.expected)} >> found ${color.yellowBright.bold(dep.version)}`; 93 | } else { 94 | message = `${color.red('x missing peer')} need ${color.bold(`${dep.name}@`)}${color.greenBright.bold(dep.expected)}`; 95 | } 96 | 97 | const versionMessage = `${ASCII.VERTICAL_LINE} ${last ? ASCII.BOTTOM_LEFT_CORNER : ASCII.JUNCTION_RIGHT}${ASCII.HORIZONTAL_LINE} ${message}`; 98 | 99 | if (!dep.message) { 100 | return versionMessage; 101 | } 102 | 103 | return `${versionMessage}\n${ASCII.VERTICAL_LINE} ${!last ? ASCII.VERTICAL_LINE : ''} ${color.gray(dep.message)}`; 104 | }) 105 | .join('\n'); 106 | 107 | process.stdout.write(`${msgs}\n`); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/utils/registry-providers/azure.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 3 | 4 | const DEFAULT_BRANCH = 'main'; 5 | 6 | export interface AzureProviderState extends RegistryProviderState { 7 | owner: string; 8 | repoName: string; 9 | project: string; 10 | refs: 'heads' | 'tags'; 11 | ref: string; 12 | } 13 | 14 | /** Valid paths 15 | * 16 | * `azure////(tags|heads)/` 17 | */ 18 | export const azure: RegistryProvider = { 19 | name: 'azure', 20 | 21 | matches: (url) => url.toLowerCase().startsWith('azure'), 22 | 23 | parse: (url, opts) => { 24 | const parsed = parseUrl(url, opts); 25 | 26 | return { 27 | url: parsed.url, 28 | specifier: parsed.specifier, 29 | }; 30 | }, 31 | 32 | baseUrl: (url) => { 33 | const { owner, repoName } = parseUrl(url, { fullyQualified: false }); 34 | 35 | return `https://dev.azure.com/${owner}/_git/${repoName}`; 36 | }, 37 | 38 | state: async (url) => { 39 | const { 40 | url: normalizedUrl, 41 | owner, 42 | project, 43 | repoName, 44 | ref, 45 | refs, 46 | } = parseUrl(url, { fullyQualified: false }); 47 | 48 | return { 49 | owner, 50 | repoName, 51 | ref, 52 | refs, 53 | project, 54 | url: normalizedUrl, 55 | provider: azure, 56 | } satisfies AzureProviderState; 57 | }, 58 | 59 | resolveRaw: async (state, resourcePath) => { 60 | // essentially assert that we are using the correct state 61 | if (state.provider.name !== azure.name) { 62 | throw new Error( 63 | `You passed the incorrect state object (${state.provider.name}) to the ${azure.name} provider.` 64 | ); 65 | } 66 | 67 | const { owner, repoName, project, ref, refs } = state as AzureProviderState; 68 | 69 | const versionType = refs === 'tags' ? 'tag' : 'branch'; 70 | 71 | return new URL( 72 | `https://dev.azure.com/${owner}/${project}/_apis/git/repositories/${repoName}/items?path=${resourcePath}&api-version=7.2-preview.1&versionDescriptor.version=${ref}&versionDescriptor.versionType=${versionType}` 73 | ); 74 | }, 75 | 76 | authHeader: (token) => ['Authorization', `Bearer ${token}`], 77 | 78 | formatFetchError: (state, filePath) => { 79 | return `There was an error fetching \`${color.bold(filePath)}\` from ${color.bold(state.url)}. 80 | 81 | ${color.bold('This may be for one of the following reasons:')} 82 | 1. Either \`${color.bold(filePath)}\` or the containing repository doesn't exist 83 | 2. Your repository path is incorrect (wrong branch, wrong tag) 84 | 3. You are using an expired access token or a token that doesn't have access to this repository 85 | 4. The cached state for this git provider is incorrect (try using ${color.bold('--no-cache')}) 86 | `; 87 | }, 88 | }; 89 | 90 | function parseUrl( 91 | url: string, 92 | { fullyQualified }: ParseOptions 93 | ): { 94 | url: string; 95 | owner: string; 96 | project: string; 97 | repoName: string; 98 | ref: string; 99 | refs: 'tags' | 'heads'; 100 | specifier?: string; 101 | } { 102 | const repo = url.replaceAll(/(azure\/)/g, ''); 103 | 104 | let [owner, project, repoName, ...rest] = repo.split('/'); 105 | 106 | let specifier: string | undefined = undefined; 107 | 108 | if (fullyQualified) { 109 | specifier = rest.slice(rest.length - 2).join('/'); 110 | 111 | rest = rest.slice(0, rest.length - 2); 112 | } 113 | 114 | let ref: string = DEFAULT_BRANCH; 115 | 116 | // checks if the type of the ref is tags or heads 117 | let refs: 'heads' | 'tags' = 'heads'; 118 | 119 | if (['tags', 'heads'].includes(rest[0])) { 120 | refs = rest[0] as 'heads' | 'tags'; 121 | 122 | if (rest[1] && rest[1] !== '') { 123 | ref = rest[1]; 124 | } 125 | } 126 | 127 | return { 128 | url: `azure/${owner}/${project}/${repoName}${ref ? `/${refs}/${ref}` : ''}`, 129 | owner: owner, 130 | repoName: repoName, 131 | project, 132 | ref, 133 | refs, 134 | specifier, 135 | }; 136 | } 137 | -------------------------------------------------------------------------------- /src/utils/registry-providers/bitbucket.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { startsWithOneOf } from '../blocks/ts/strings'; 3 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 4 | 5 | const DEFAULT_BRANCH = 'master'; 6 | 7 | export interface BitBucketProviderState extends RegistryProviderState { 8 | owner: string; 9 | repoName: string; 10 | ref: string; 11 | } 12 | 13 | /** Valid paths 14 | * 15 | * `bitbucket/ieedan/std` 16 | * 17 | * `https://bitbucket.org/ieedan/std/src/main/` 18 | * 19 | * `https://bitbucket.org/ieedan/std/src/next/` 20 | * 21 | * `https://bitbucket.org/ieedan/std/src/v2.0.0/` 22 | * 23 | */ 24 | export const bitbucket: RegistryProvider = { 25 | name: 'bitbucket', 26 | 27 | matches: (url) => 28 | startsWithOneOf(url.toLowerCase(), ['bitbucket', 'https://bitbucket.org']) !== undefined, 29 | 30 | parse: (url, opts) => { 31 | const parsed = parseUrl(url, opts); 32 | 33 | return { 34 | url: parsed.url, 35 | specifier: parsed.specifier, 36 | }; 37 | }, 38 | 39 | baseUrl: (url) => { 40 | const { owner, repoName } = parseUrl(url, { fullyQualified: false }); 41 | 42 | return `https://bitbucket.org/${owner}/${repoName}`; 43 | }, 44 | 45 | state: async (url, { token, fetch: f = fetch } = {}) => { 46 | let { url: normalizedUrl, owner, repoName, ref } = parseUrl(url, { fullyQualified: false }); 47 | 48 | // fetch default branch if ref was not provided 49 | if (ref === undefined) { 50 | try { 51 | const headers = new Headers(); 52 | 53 | if (token !== undefined) { 54 | const [key, value] = bitbucket.authHeader!(token); 55 | 56 | headers.append(key, value); 57 | } 58 | 59 | const response = await f( 60 | `https://api.bitbucket.org/2.0/repositories/${owner}/${repoName}`, 61 | { 62 | headers, 63 | } 64 | ); 65 | 66 | if (response.ok) { 67 | const data = await response.json(); 68 | 69 | // @ts-ignore yes but we know 70 | ref = data.mainbranch.name as string; 71 | } else { 72 | ref = DEFAULT_BRANCH; 73 | } 74 | } catch { 75 | // well find out it isn't correct later with a better error 76 | ref = DEFAULT_BRANCH; 77 | } 78 | } 79 | 80 | return { 81 | owner, 82 | ref, 83 | repoName, 84 | url: normalizedUrl, 85 | provider: bitbucket, 86 | } satisfies BitBucketProviderState; 87 | }, 88 | 89 | resolveRaw: async (state, resourcePath) => { 90 | // essentially assert that we are using the correct state 91 | if (state.provider.name !== bitbucket.name) { 92 | throw new Error( 93 | `You passed the incorrect state object (${state.provider.name}) to the ${bitbucket.name} provider.` 94 | ); 95 | } 96 | 97 | const { owner, repoName, ref } = state as BitBucketProviderState; 98 | 99 | return new URL( 100 | resourcePath, 101 | `https://api.bitbucket.org/2.0/repositories/${owner}/${repoName}/src/${ref}/` 102 | ); 103 | }, 104 | 105 | authHeader: (token) => ['Authorization', `Bearer ${token}`], 106 | 107 | formatFetchError: (state, filePath) => { 108 | return `There was an error fetching \`${color.bold(filePath)}\` from ${color.bold(state.url)}. 109 | 110 | ${color.bold('This may be for one of the following reasons:')} 111 | 1. Either \`${color.bold(filePath)}\` or the containing repository doesn't exist 112 | 2. Your repository path is incorrect (wrong branch, wrong tag) 113 | 3. You are using an expired access token or a token that doesn't have access to this repository 114 | 4. The cached state for this git provider is incorrect (try using ${color.bold('--no-cache')}) 115 | `; 116 | }, 117 | }; 118 | 119 | function parseUrl( 120 | url: string, 121 | { fullyQualified = false }: ParseOptions 122 | ): { url: string; owner: string; repoName: string; ref?: string; specifier?: string } { 123 | const repo = url.replaceAll(/(https:\/\/bitbucket.org\/)|(bitbucket\/)/g, ''); 124 | 125 | let [owner, repoName, ...rest] = repo.split('/'); 126 | 127 | let specifier: string | undefined; 128 | 129 | if (fullyQualified) { 130 | specifier = rest.slice(rest.length - 2).join('/'); 131 | 132 | rest = rest.slice(0, rest.length - 2); 133 | } 134 | 135 | let ref: string | undefined; 136 | 137 | if (rest[0] === 'src') { 138 | ref = rest[1]; 139 | } 140 | 141 | return { 142 | url: `bitbucket/${owner}/${repoName}${ref ? `/src/${ref}` : ''}`, 143 | specifier, 144 | owner, 145 | repoName: repoName, 146 | ref, 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /src/utils/registry-providers/github.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { startsWithOneOf } from '../blocks/ts/strings'; 3 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 4 | 5 | const DEFAULT_BRANCH = 'main'; 6 | 7 | export interface GitHubProviderState extends RegistryProviderState { 8 | owner: string; 9 | repoName: string; 10 | ref: string; 11 | } 12 | 13 | /** Valid paths 14 | * 15 | * `https://github.com//` 16 | * 17 | * `github//` 18 | * 19 | * `github///tree/` 20 | */ 21 | export const github: RegistryProvider = { 22 | name: 'github', 23 | 24 | matches: (url) => 25 | startsWithOneOf(url.toLowerCase(), ['github', 'https://github.com']) !== undefined, 26 | 27 | parse: (url, opts) => { 28 | const parsed = parseUrl(url, opts); 29 | 30 | return { 31 | url: parsed.url, 32 | specifier: parsed.specifier, 33 | }; 34 | }, 35 | 36 | baseUrl: (url) => { 37 | const { owner, repoName } = parseUrl(url, { fullyQualified: false }); 38 | 39 | return `https://github.com/${owner}/${repoName}`; 40 | }, 41 | 42 | state: async (url, { token } = {}) => { 43 | let { url: normalizedUrl, owner, repoName, ref } = parseUrl(url, { fullyQualified: false }); 44 | 45 | // fetch default branch if ref was not provided 46 | if (ref === undefined) { 47 | try { 48 | const response = await fetch(`https://api.github.com/repos/${owner}/${repoName}`, { 49 | headers: { Authorization: `Bearer ${token}` }, 50 | }); 51 | 52 | if (response.ok) { 53 | const res = await response.json(); 54 | 55 | ref = res.default_branch as string; 56 | } else { 57 | ref = DEFAULT_BRANCH; 58 | } 59 | } catch { 60 | // we just want to continue on blissfully unaware the user will get an error later 61 | ref = DEFAULT_BRANCH; 62 | } 63 | } 64 | 65 | return { 66 | owner, 67 | ref, 68 | repoName, 69 | url: normalizedUrl, 70 | provider: github, 71 | } satisfies GitHubProviderState; 72 | }, 73 | 74 | resolveRaw: async (state, resourcePath) => { 75 | // essentially assert that we are using the correct state 76 | if (state.provider.name !== github.name) { 77 | throw new Error( 78 | `You passed the incorrect state object (${state.provider.name}) to the ${github.name} provider.` 79 | ); 80 | } 81 | 82 | const { owner, repoName, ref } = state as GitHubProviderState; 83 | 84 | return new URL(resourcePath, `https://ungh.cc/repos/${owner}/${repoName}/files/${ref}/`); 85 | }, 86 | 87 | authHeader: (token) => ['Authorization', `token ${token}`], 88 | 89 | formatFetchError: (state, filePath) => { 90 | return `There was an error fetching \`${color.bold(filePath)}\` from ${color.bold(state.url)}. 91 | 92 | ${color.bold('This may be for one of the following reasons:')} 93 | 1. Either \`${color.bold(filePath)}\` or the containing repository doesn't exist 94 | 2. Your repository path is incorrect (wrong branch, wrong tag) 95 | 3. You are using an expired access token or a token that doesn't have access to this repository 96 | 4. The cached state for this git provider is incorrect (try using ${color.bold('--no-cache')}) 97 | `; 98 | }, 99 | }; 100 | 101 | function parseUrl( 102 | url: string, 103 | { fullyQualified = false }: ParseOptions 104 | ): { url: string; owner: string; repoName: string; ref?: string; specifier?: string } { 105 | const repo = url.replaceAll(/(https:\/\/github.com\/)|(github\/)/g, ''); 106 | 107 | let [owner, repoName, ...rest] = repo.split('/'); 108 | 109 | let specifier: string | undefined; 110 | 111 | if (fullyQualified) { 112 | specifier = rest.slice(rest.length - 2).join('/'); 113 | 114 | rest = rest.slice(0, rest.length - 2); 115 | } 116 | 117 | let ref: string | undefined; 118 | 119 | if (rest.length > 0) { 120 | if (rest[0] === 'tree') { 121 | ref = rest[1]; 122 | } 123 | } 124 | 125 | return { 126 | url: `github/${owner}/${repoName}${ref ? `/tree/${ref}` : ''}`, 127 | specifier, 128 | owner, 129 | repoName: repoName, 130 | ref, 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/registry-providers/gitlab.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { startsWithOneOf } from '../blocks/ts/strings'; 3 | import * as u from '../blocks/ts/url'; 4 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 5 | 6 | const DEFAULT_BRANCH = 'main'; 7 | const BASE_URL = 'https://gitlab.com'; 8 | 9 | export interface GitLabProviderState extends RegistryProviderState { 10 | baseUrl: string; 11 | owner: string; 12 | repoName: string; 13 | ref: string; 14 | } 15 | 16 | /** Valid paths 17 | * 18 | * `https://gitlab.com/ieedan/std` 19 | * 20 | * `https://gitlab.com/ieedan/std/-/tree/next` 21 | * 22 | * `https://gitlab.com/ieedan/std/-/tree/v2.0.0` 23 | * 24 | * `https://gitlab.com/ieedan/std/-/tree/v2.0.0?ref_type=tags` 25 | * 26 | * Self hosted: 27 | * 28 | * `gitlab:https://example.com/ieedan/std` 29 | */ 30 | export const gitlab: RegistryProvider = { 31 | name: 'gitlab', 32 | 33 | matches: (url) => 34 | startsWithOneOf(url.toLowerCase(), ['gitlab/', 'gitlab:', 'https://gitlab.com']) !== 35 | undefined, 36 | 37 | parse: (url, opts) => { 38 | const parsed = parseUrl(url, opts); 39 | 40 | return { 41 | url: parsed.url, 42 | specifier: parsed.specifier, 43 | }; 44 | }, 45 | 46 | baseUrl: (url) => { 47 | const { baseUrl, owner, repoName } = parseUrl(url, { fullyQualified: false }); 48 | 49 | return u.join(baseUrl, owner, repoName); 50 | }, 51 | 52 | state: async (url, { token, fetch: f = fetch } = {}) => { 53 | let { 54 | baseUrl, 55 | url: normalizedUrl, 56 | owner, 57 | repoName, 58 | ref, 59 | } = parseUrl(url, { fullyQualified: false }); 60 | 61 | // fetch default branch if ref was not provided 62 | if (ref === undefined) { 63 | try { 64 | const headers = new Headers(); 65 | 66 | if (token !== undefined) { 67 | const [key, value] = gitlab.authHeader!(token); 68 | 69 | headers.append(key, value); 70 | } 71 | 72 | const response = await f( 73 | u.join( 74 | baseUrl, 75 | `api/v4/projects/${encodeURIComponent(`${owner}/${repoName}`)}` 76 | ), 77 | { 78 | headers, 79 | } 80 | ); 81 | 82 | if (response.ok) { 83 | const data = await response.json(); 84 | 85 | // @ts-ignore yes but we know 86 | ref = data.default_branch as string; 87 | } else { 88 | ref = DEFAULT_BRANCH; 89 | } 90 | } catch { 91 | // well find out it isn't correct later with a better error 92 | ref = DEFAULT_BRANCH; 93 | } 94 | } 95 | 96 | return { 97 | owner, 98 | repoName, 99 | ref, 100 | baseUrl, 101 | url: normalizedUrl, 102 | provider: gitlab, 103 | } satisfies GitLabProviderState; 104 | }, 105 | 106 | resolveRaw: async (state, resourcePath) => { 107 | // essentially assert that we are using the correct state 108 | if (state.provider.name !== gitlab.name) { 109 | throw new Error( 110 | `You passed the incorrect state object (${state.provider.name}) to the ${gitlab.name} provider.` 111 | ); 112 | } 113 | 114 | const { baseUrl, owner, repoName, ref } = state as GitLabProviderState; 115 | 116 | return new URL( 117 | u.join( 118 | baseUrl, 119 | `api/v4/projects/${encodeURIComponent(`${owner}/${repoName}`)}`, 120 | `repository/files/${encodeURIComponent(resourcePath)}/raw?ref=${ref}` 121 | ) 122 | ); 123 | }, 124 | 125 | authHeader: (token) => ['PRIVATE-TOKEN', token], 126 | 127 | formatFetchError: (state, filePath, error) => { 128 | return `There was an error fetching \`${color.bold(filePath)}\` from ${color.bold(state.url)}: ${error}. 129 | 130 | ${color.bold('This may be for one of the following reasons:')} 131 | 1. Either \`${color.bold(filePath)}\` or the containing repository doesn't exist 132 | 2. Your repository path is incorrect (wrong branch, wrong tag) 133 | 3. You are using an expired access token or a token that doesn't have access to this repository 134 | 4. The cached state for this git provider is incorrect (try using ${color.bold('--no-cache')}) 135 | `; 136 | }, 137 | }; 138 | 139 | function parseUrl( 140 | url: string, 141 | { fullyQualified }: ParseOptions 142 | ): { 143 | url: string; 144 | baseUrl: string; 145 | owner: string; 146 | repoName: string; 147 | ref?: string; 148 | specifier?: string; 149 | } { 150 | let baseUrl = BASE_URL; 151 | 152 | if (url.startsWith('gitlab:')) { 153 | baseUrl = new URL(url.slice(7)).origin; 154 | } 155 | 156 | const repo = url.replaceAll(/gitlab\/|https:\/\/gitlab\.com\/|gitlab:https?:\/\/[^/]+\//g, ''); 157 | 158 | let [owner, repoName, ...rest] = repo.split('/'); 159 | 160 | let specifier: string | undefined; 161 | 162 | if (fullyQualified) { 163 | specifier = rest.slice(rest.length - 2).join('/'); 164 | 165 | rest = rest.slice(0, rest.length - 2); 166 | } 167 | 168 | let ref: string | undefined; 169 | 170 | if (rest[0] === '-' && rest[1] === 'tree') { 171 | if (rest[2].includes('?')) { 172 | const [tempRef] = rest[2].split('?'); 173 | 174 | ref = tempRef; 175 | } else { 176 | ref = rest[2]; 177 | } 178 | } 179 | 180 | const isCustom = baseUrl !== BASE_URL; 181 | 182 | return { 183 | // if the url is custom instance of gitlab we must append gitlab: so that the url can be parsed correctly 184 | url: u.join( 185 | isCustom ? `gitlab:${baseUrl}` : baseUrl, 186 | `${owner}/${repoName}${ref ? `/-/tree/${ref}` : ''}` 187 | ), 188 | baseUrl, 189 | owner: owner, 190 | repoName: repoName, 191 | ref, 192 | specifier, 193 | }; 194 | } 195 | -------------------------------------------------------------------------------- /src/utils/registry-providers/http.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import * as u from '../blocks/ts/url'; 3 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 4 | 5 | export interface HttpProviderState extends RegistryProviderState {} 6 | 7 | /** Valid paths 8 | * 9 | * `(https|http)://example.com` 10 | */ 11 | export const http: RegistryProvider = { 12 | name: 'http', 13 | 14 | matches: (url) => { 15 | // if parsing is a success then it's a match 16 | try { 17 | new URL(url); 18 | 19 | return true; 20 | } catch { 21 | return false; 22 | } 23 | }, 24 | 25 | parse: (url, opts) => { 26 | const parsed = parseUrl(url, opts); 27 | 28 | return { 29 | url: parsed.url, 30 | specifier: parsed.specifier, 31 | }; 32 | }, 33 | 34 | baseUrl: (url) => { 35 | const { url: u } = parseUrl(url, { fullyQualified: false }); 36 | 37 | return new URL(u).origin; 38 | }, 39 | 40 | state: async (url) => { 41 | const { url: normalizedUrl } = parseUrl(url, { fullyQualified: false }); 42 | 43 | return { 44 | url: normalizedUrl, 45 | provider: http, 46 | } satisfies HttpProviderState; 47 | }, 48 | 49 | resolveRaw: async (state, resourcePath) => { 50 | // essentially assert that we are using the correct state 51 | if (state.provider.name !== http.name) { 52 | throw new Error( 53 | `You passed the incorrect state object (${state.provider.name}) to the ${http.name} provider.` 54 | ); 55 | } 56 | 57 | return new URL(resourcePath, state.url); 58 | }, 59 | 60 | authHeader: (token) => ['Authorization', `Bearer ${token}`], 61 | 62 | formatFetchError: (state, filePath, error) => { 63 | return `There was an error fetching ${color.bold(new URL(filePath, state.url).toString())} 64 | 65 | ${color.bold(error)}`; 66 | }, 67 | }; 68 | 69 | function parseUrl( 70 | url: string, 71 | { fullyQualified }: ParseOptions 72 | ): { 73 | url: string; 74 | specifier?: string; 75 | } { 76 | const parsedUrl = new URL(url); 77 | 78 | let segments = parsedUrl.pathname.split('/'); 79 | 80 | let specifier: string | undefined; 81 | 82 | if (fullyQualified) { 83 | specifier = segments.slice(segments.length - 2).join('/'); 84 | 85 | segments = segments.slice(0, segments.length - 2); 86 | } 87 | 88 | return { 89 | url: u.addTrailingSlash(u.join(parsedUrl.origin, ...segments)), 90 | specifier, 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/registry-providers/index.ts: -------------------------------------------------------------------------------- 1 | import { MANIFEST_FILE } from '../../constants'; 2 | import type { Manifest } from '../../types'; 3 | import { Err, Ok, type Result } from '../blocks/ts/result'; 4 | import { parseManifest } from '../manifest'; 5 | import { type AzureProviderState, azure } from './azure'; 6 | import { type BitBucketProviderState, bitbucket } from './bitbucket'; 7 | import { type GitHubProviderState, github } from './github'; 8 | import { type GitLabProviderState, gitlab } from './gitlab'; 9 | import { http } from './http'; 10 | import { type JsrepoProviderState, jsrepo } from './jsrepo'; 11 | import type { RegistryProvider, RegistryProviderState } from './types'; 12 | 13 | export const providers = [jsrepo, github, gitlab, bitbucket, azure, http]; 14 | 15 | export function selectProvider(url: string): RegistryProvider | undefined { 16 | const provider = providers.find((p) => p.matches(url)); 17 | 18 | return provider; 19 | } 20 | 21 | export type FetchOptions = { 22 | token: string; 23 | /** Override the fetch method. */ 24 | fetch?: typeof fetch; 25 | verbose: (str: string) => void; 26 | }; 27 | 28 | export async function fetchRaw( 29 | state: RegistryProviderState, 30 | resourcePath: string, 31 | { verbose, fetch: f = fetch, token }: Partial = {} 32 | ): Promise> { 33 | const url = await state.provider.resolveRaw(state, resourcePath); 34 | 35 | verbose?.(`Trying to fetch from ${url}`); 36 | 37 | try { 38 | // having headers as a record covers more fetch implementations 39 | const headers: Record = {}; 40 | 41 | if (token !== undefined && state.provider.authHeader) { 42 | const [key, value] = state.provider.authHeader(token); 43 | 44 | headers[key] = value; 45 | } 46 | 47 | const response = await f(url.toString(), { headers }); 48 | 49 | verbose?.(`Got a response from ${url} ${response.status} ${response.statusText}`); 50 | 51 | if (!response.ok) { 52 | return Err( 53 | state.provider.formatFetchError( 54 | state, 55 | resourcePath, 56 | `${response.status} ${response.statusText}` 57 | ) 58 | ); 59 | } 60 | 61 | return Ok(await response.text()); 62 | } catch (err) { 63 | return Err(state.provider.formatFetchError(state, resourcePath, err)); 64 | } 65 | } 66 | 67 | export async function fetchManifest( 68 | state: RegistryProviderState, 69 | { fetch: f = fetch, ...rest }: Partial = {} 70 | ): Promise> { 71 | const manifest = await fetchRaw(state, MANIFEST_FILE, { fetch: f, ...rest }); 72 | 73 | if (manifest.isErr()) return Err(manifest.unwrapErr()); 74 | 75 | return parseManifest(manifest.unwrap()); 76 | } 77 | 78 | export * from './types'; 79 | 80 | export { 81 | jsrepo, 82 | github, 83 | gitlab, 84 | bitbucket, 85 | azure, 86 | http, 87 | type JsrepoProviderState, 88 | type AzureProviderState, 89 | type GitHubProviderState, 90 | type GitLabProviderState, 91 | type BitBucketProviderState, 92 | }; 93 | -------------------------------------------------------------------------------- /src/utils/registry-providers/internal.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import { 3 | http, 4 | azure, 5 | bitbucket, 6 | fetchManifest, 7 | fetchRaw, 8 | github, 9 | gitlab, 10 | jsrepo, 11 | providers, 12 | selectProvider, 13 | } from '.'; 14 | import type { Block, Manifest } from '../../types'; 15 | import { Err, Ok, type Result } from '../blocks/ts/result'; 16 | import * as u from '../blocks/ts/url'; 17 | import { iFetch } from '../fetch'; 18 | import * as persisted from '../persisted'; 19 | import { TokenManager } from '../token-manager'; 20 | import type { RegistryProvider, RegistryProviderState } from './types'; 21 | 22 | export type RemoteBlock = Block & { sourceRepo: RegistryProviderState }; 23 | 24 | /** Wraps the basic implementation to inject our internal fetch method and the correct token. */ 25 | export async function internalFetchRaw( 26 | state: RegistryProviderState, 27 | resourcePath: string, 28 | { verbose }: { verbose?: (msg: string) => void } = {} 29 | ) { 30 | return await fetchRaw(state, resourcePath, { 31 | verbose, 32 | // @ts-expect-error it's fine 33 | fetch: iFetch, 34 | token: getProviderToken(state.provider, state.url), 35 | }); 36 | } 37 | 38 | /** Wraps the basic implementation to inject internal fetch method and the correct token. */ 39 | export async function internalFetchManifest( 40 | state: RegistryProviderState, 41 | { verbose }: { verbose?: (msg: string) => void } = {} 42 | ) { 43 | return await fetchManifest(state, { 44 | verbose, 45 | // @ts-expect-error it's fine 46 | fetch: iFetch, 47 | token: getProviderToken(state.provider, state.url), 48 | }); 49 | } 50 | 51 | /** Gets the locally stored token for the given provider */ 52 | export function getProviderToken(provider: RegistryProvider, url: string): string | undefined { 53 | const storage = new TokenManager(); 54 | 55 | // there isn't an auth implementation for http 56 | if (provider.name === 'http') { 57 | return storage.get(`http-${new URL(url).origin}`); 58 | } 59 | 60 | return storage.get(provider.name); 61 | } 62 | 63 | /** Parses the provided url and returns the state. 64 | * 65 | * @param repo 66 | * @returns 67 | */ 68 | export async function getProviderState( 69 | repo: string, 70 | { noCache = false }: { noCache?: boolean } = {} 71 | ): Promise> { 72 | const provider = selectProvider(repo); 73 | 74 | if (provider) { 75 | const storage = persisted.get(); 76 | 77 | // only git providers are cached 78 | if (provider.name !== http.name && !noCache) { 79 | if (noCache) { 80 | // remove the outdated cache if it exists 81 | storage.delete(`${repo}-state`); 82 | } else { 83 | const cached = storage.get(`${repo}-state`); 84 | 85 | if (cached) return Ok({ ...(cached as RegistryProviderState), provider }); 86 | } 87 | } 88 | 89 | const parsed = provider.parse(repo, { fullyQualified: false }); 90 | 91 | const state = await provider.state(repo, { 92 | token: getProviderToken(provider, parsed.url), 93 | // @ts-expect-error but it does work 94 | fetch: iFetch, 95 | }); 96 | 97 | // only cache git providers 98 | if (provider.name !== http.name && !noCache) { 99 | storage.set(`${repo}-state`, state); 100 | } 101 | 102 | return Ok(state); 103 | } 104 | 105 | return Err( 106 | `Only ${providers.map((p, i) => `${i === providers.length - 1 ? 'and ' : ''}${color.bold(p.name)}`).join(', ')} registries are supported at this time!` 107 | ); 108 | } 109 | 110 | /** Gets the provider state for each provided repo url 111 | * 112 | * @param repos 113 | * @returns 114 | */ 115 | export async function forEachPathGetProviderState( 116 | repos: string[], 117 | { noCache = false }: { noCache?: boolean } = {} 118 | ): Promise> { 119 | const resolvedPaths: RegistryProviderState[] = []; 120 | 121 | const errors = await Promise.all( 122 | repos.map(async (repo) => { 123 | const getProviderResult = await getProviderState(repo, { noCache }); 124 | 125 | if (getProviderResult.isErr()) 126 | return Err({ message: getProviderResult.unwrapErr(), repo }); 127 | 128 | const providerState = getProviderResult.unwrap(); 129 | 130 | resolvedPaths.push(providerState); 131 | }) 132 | ); 133 | 134 | const err = errors.find((err) => err !== undefined); 135 | 136 | if (err) return err; 137 | 138 | return Ok(resolvedPaths); 139 | } 140 | 141 | /** Fetches blocks for each registry and stores them in a map by their repo as well as category and block name. 142 | * 143 | * Example Key: 144 | * `github/ieedan/std/utils/math` 145 | * 146 | * @param repos 147 | * @returns 148 | */ 149 | export async function fetchBlocks( 150 | repos: RegistryProviderState[], 151 | { verbose }: { verbose?: (msg: string) => void } = {} 152 | ): Promise, { message: string; repo: string }>> { 153 | const blocksMap = new Map(); 154 | 155 | const errors = await Promise.all( 156 | repos.map(async (state) => { 157 | const getManifestResult = await internalFetchManifest(state, { verbose }); 158 | 159 | if (getManifestResult.isErr()) { 160 | return Err({ message: getManifestResult.unwrapErr(), repo: state.url }); 161 | } 162 | 163 | const manifest = getManifestResult.unwrap(); 164 | 165 | for (const category of manifest.categories) { 166 | for (const block of category.blocks) { 167 | blocksMap.set(u.join(state.url, `${block.category}/${block.name}`), { 168 | ...block, 169 | sourceRepo: state, 170 | }); 171 | } 172 | } 173 | }) 174 | ); 175 | 176 | const err = errors.find((err) => err !== undefined); 177 | 178 | if (err) return err; 179 | 180 | return Ok(blocksMap); 181 | } 182 | 183 | /** Maps the result of fetchManifests into a map of remote blocks 184 | * 185 | * @param manifests 186 | */ 187 | export function getRemoteBlocks(manifests: FetchManifestResult[]) { 188 | const blocksMap = new Map(); 189 | 190 | for (const manifest of manifests) { 191 | for (const category of manifest.manifest.categories) { 192 | for (const block of category.blocks) { 193 | blocksMap.set(u.join(manifest.state.url, `${block.category}/${block.name}`), { 194 | ...block, 195 | sourceRepo: manifest.state, 196 | }); 197 | } 198 | } 199 | } 200 | 201 | return blocksMap; 202 | } 203 | 204 | export type FetchManifestResult = { 205 | state: RegistryProviderState; 206 | manifest: Manifest; 207 | }; 208 | 209 | /** Fetches the manifests for each provider 210 | * 211 | * @param repos 212 | * @returns 213 | */ 214 | export async function fetchManifests( 215 | repos: RegistryProviderState[], 216 | { verbose }: { verbose?: (msg: string) => void } = {} 217 | ): Promise> { 218 | const manifests: FetchManifestResult[] = []; 219 | 220 | const errors = await Promise.all( 221 | repos.map(async (state) => { 222 | const getManifestResult = await internalFetchManifest(state, { verbose }); 223 | 224 | if (getManifestResult.isErr()) { 225 | return Err({ message: getManifestResult.unwrapErr(), repo: state.url }); 226 | } 227 | 228 | const manifest = getManifestResult.unwrap(); 229 | 230 | manifests.push({ state, manifest }); 231 | }) 232 | ); 233 | 234 | const err = errors.find((err) => err !== undefined); 235 | 236 | if (err) return err; 237 | 238 | return Ok(manifests); 239 | } 240 | 241 | export * from './types'; 242 | 243 | export { 244 | azure, 245 | bitbucket, 246 | github, 247 | gitlab, 248 | http, 249 | jsrepo, 250 | providers, 251 | internalFetchManifest as fetchManifest, 252 | internalFetchRaw as fetchRaw, 253 | selectProvider, 254 | }; 255 | -------------------------------------------------------------------------------- /src/utils/registry-providers/jsrepo.ts: -------------------------------------------------------------------------------- 1 | import color from 'chalk'; 2 | import type { ParseOptions, RegistryProvider, RegistryProviderState } from './types'; 3 | 4 | /** Regex for scopes and registry names. 5 | * Names that don't match this regex will be rejected. 6 | * 7 | * ### Valid 8 | * ```txt 9 | * console 10 | * console0 11 | * console-0 12 | * ``` 13 | * 14 | * ### Invalid 15 | * ```txt 16 | * Console 17 | * 0console 18 | * -console 19 | * console- 20 | * console--0 21 | * ``` 22 | */ 23 | export const NAME_REGEX = /^(?![-0-9])(?!.*--)[a-z0-9]*(?:-[a-z0-9]+)*$/gi; 24 | 25 | export const BASE_URL = 'https://www.jsrepo.com'; 26 | 27 | export interface JsrepoProviderState extends RegistryProviderState { 28 | scope: string; 29 | registryName: string; 30 | version: string; 31 | } 32 | 33 | /** Valid paths 34 | * 35 | * `@ieedan/std` 36 | * `@ieedan/std@latest` 37 | * `@ieedan/std@1.0.0` 38 | * `@ieedan/std@1.0.0/ts/math` 39 | */ 40 | export const jsrepo: RegistryProvider = { 41 | name: 'jsrepo', 42 | 43 | matches: (url) => url.startsWith('@'), 44 | 45 | parse: (url, opts) => { 46 | const parsed = parseUrl(url, opts); 47 | 48 | return { 49 | url: parsed.url, 50 | specifier: parsed.specifier, 51 | }; 52 | }, 53 | 54 | baseUrl: (url) => { 55 | const { scope, registryName, version } = parseUrl(url, { fullyQualified: false }); 56 | 57 | return `${BASE_URL}/${scope}/${registryName}/v/${version}`; 58 | }, 59 | 60 | state: async (url) => { 61 | const parsed = parseUrl(url, { fullyQualified: false }); 62 | 63 | return { 64 | ...parsed, 65 | provider: jsrepo, 66 | } satisfies JsrepoProviderState; 67 | }, 68 | 69 | resolveRaw: async (state, resourcePath) => { 70 | // essentially assert that we are using the correct state 71 | if (state.provider.name !== jsrepo.name) { 72 | throw new Error( 73 | `You passed the incorrect state object (${state.provider.name}) to the ${jsrepo.name} provider.` 74 | ); 75 | } 76 | 77 | const { scope, registryName, version } = state as JsrepoProviderState; 78 | 79 | return new URL( 80 | `${BASE_URL}/api/scopes/${scope}/${registryName}/v/${version}/files/${resourcePath}` 81 | ); 82 | }, 83 | 84 | authHeader: (token) => ['x-api-key', token], 85 | 86 | formatFetchError: (state, filePath, error) => { 87 | const { scope, registryName, version } = state as JsrepoProviderState; 88 | 89 | return `There was an error fetching ${filePath} from ${scope}/${registryName}@${version} 90 | 91 | ${color.bold(error)}`; 92 | }, 93 | }; 94 | 95 | export function parseUrl( 96 | url: string, 97 | { fullyQualified }: ParseOptions 98 | ): { 99 | url: string; 100 | specifier?: string; 101 | scope: string; 102 | registryName: string; 103 | version: string; 104 | } { 105 | const [scope, name, ...rest] = url.split('/'); 106 | 107 | const [registryName, version] = name.split('@'); 108 | 109 | let specifier: string | undefined = undefined; 110 | 111 | if (fullyQualified) { 112 | specifier = rest.slice(rest.length - 2).join('/'); 113 | } 114 | 115 | const parsedUrl = `${scope}/${name}`; 116 | 117 | return { 118 | url: parsedUrl, 119 | specifier, 120 | scope, 121 | registryName, 122 | version: version ?? 'latest', 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/registry-providers/types.ts: -------------------------------------------------------------------------------- 1 | export interface RegistryProvider { 2 | /** Short name for the provider that will be used when it is displayed to the user */ 3 | name: string; 4 | /** Used to determine if the provided url belongs to this provider 5 | * 6 | * @param url 7 | * @returns 8 | */ 9 | matches: (url: string) => boolean; 10 | /** Parse a URL that belongs to the provider 11 | * 12 | * @param url 13 | * @param opts 14 | * @returns 15 | */ 16 | parse: (url: string, opts: ParseOptions) => ParseResult; 17 | /** Parses the url and returns the origin of the url. 18 | * 19 | * `github/ieedan/std/tree/next -> github/ieedan/std` 20 | * 21 | * `https://example.com/new-york -> https://example.com` 22 | * 23 | * @param url 24 | * @returns 25 | */ 26 | baseUrl: (url: string) => string; 27 | /** Gets the provider state by parsing the url and taking care of any loose ends 28 | * 29 | * @param url 30 | * @returns 31 | */ 32 | state: (url: string, opts?: StateOptions) => Promise; 33 | /** Returns a URL to the raw path of the resource provided in the resourcePath 34 | * 35 | * @param repoPath 36 | * @param resourcePath 37 | * @returns 38 | */ 39 | resolveRaw: (state: RegistryProviderState, resourcePath: string) => Promise; 40 | /** Different providers use different authorization schemes. 41 | * Provide this method with a token to get the key value pair for the authorization header. 42 | * 43 | * @param token 44 | * @returns 45 | */ 46 | authHeader?: (token: string) => [string, string]; 47 | /** Returns a formatted error for a fetch error giving possible reasons for failure */ 48 | formatFetchError: (state: RegistryProviderState, filePath: string, error: unknown) => string; 49 | } 50 | 51 | export type ParseOptions = { 52 | /** Set true when the provided path ends with `/` */ 53 | fullyQualified?: boolean; 54 | }; 55 | 56 | export type ParseResult = { 57 | /** a universal url ex: `https://github.com/ieedan/std -> github/ieedan/std` */ 58 | url: string; 59 | /** The block specifier `/` */ 60 | specifier?: string; 61 | }; 62 | 63 | export type StateOptions = { 64 | token?: string; 65 | /** Override the fetch method. */ 66 | fetch?: typeof fetch; 67 | }; 68 | 69 | /** Pass this to the `.provider` property of this to access the methods for this provider */ 70 | export interface RegistryProviderState { 71 | url: string; 72 | provider: RegistryProvider; 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/token-manager.ts: -------------------------------------------------------------------------------- 1 | import type Conf from 'conf'; 2 | import * as persisted from './persisted'; 3 | 4 | const HTTP_REGISTRY_LIST_KEY = 'http-registries-w-tokens'; 5 | 6 | export class TokenManager { 7 | #storage: Conf; 8 | 9 | constructor(storage?: Conf) { 10 | this.#storage = storage ?? persisted.get(); 11 | } 12 | 13 | private getKey(name: string) { 14 | return `${name}-token`.toLowerCase(); 15 | } 16 | 17 | get(name: string): string | undefined { 18 | const key = this.getKey(name); 19 | 20 | const token = this.#storage.get(key, undefined) as string | undefined; 21 | 22 | if (name === 'jsrepo') { 23 | return token ?? process.env.JSREPO_TOKEN; 24 | } 25 | 26 | return token; 27 | } 28 | 29 | set(name: string, secret: string) { 30 | if (name.startsWith('http')) { 31 | let registries = this.getHttpRegistriesWithTokens(); 32 | 33 | const registry = name.slice(5); 34 | 35 | if (!registries) { 36 | registries = []; 37 | } 38 | 39 | if (!registries.includes(registry)) registries.push(registry); 40 | 41 | this.#storage.set(HTTP_REGISTRY_LIST_KEY, registries); 42 | } 43 | 44 | const key = this.getKey(name); 45 | 46 | this.#storage.set(key, secret); 47 | } 48 | 49 | delete(name: string) { 50 | if (name.startsWith('http')) { 51 | let registries = this.getHttpRegistriesWithTokens(); 52 | 53 | const registry = name.slice(5); 54 | 55 | const index = registries.indexOf(registry); 56 | 57 | if (index !== -1) { 58 | registries = [...registries.slice(0, index), ...registries.slice(index + 1)]; 59 | } 60 | 61 | this.#storage.set(HTTP_REGISTRY_LIST_KEY, registries); 62 | } 63 | 64 | const key = this.getKey(name); 65 | 66 | this.#storage.delete(key); 67 | } 68 | 69 | getHttpRegistriesWithTokens(): string[] { 70 | const registries = this.#storage.get(HTTP_REGISTRY_LIST_KEY); 71 | 72 | if (!registries) return []; 73 | 74 | return registries as string[]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/add.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'pathe'; 3 | import { x } from 'tinyexec'; 4 | import { afterAll, beforeAll, describe, it } from 'vitest'; 5 | import { cli } from '../src/cli'; 6 | import * as u from '../src/utils/blocks/ts/url'; 7 | import type { ProjectConfig } from '../src/utils/config'; 8 | import { assertFilesExist } from './utils'; 9 | 10 | describe('add', () => { 11 | const testDir = path.join(__dirname, '../temp-test/add'); 12 | 13 | const addBlock = async (registry: string, block: `${string}/${string}`) => { 14 | const config: ProjectConfig = { 15 | $schema: '', 16 | includeTests: false, 17 | paths: { 18 | '*': './src', 19 | types: './types', 20 | }, 21 | repos: [registry], 22 | watermark: true, 23 | }; 24 | 25 | fs.writeFileSync('jsrepo.json', JSON.stringify(config)); 26 | 27 | await cli.parseAsync(['node', 'jsrepo', 'add', block, '-y', '--verbose', '--cwd', testDir]); 28 | }; 29 | 30 | const addBlockZeroConfig = async (registry: string, block: `${string}/${string}`) => { 31 | await cli.parseAsync([ 32 | 'node', 33 | 'jsrepo', 34 | 'add', 35 | u.join(registry, block), 36 | '--formatter', 37 | 'none', 38 | '--watermark', 39 | 'true', 40 | '--tests', 41 | 'false', 42 | '--paths', 43 | "'*=./src'", 44 | '-y', 45 | '--verbose', 46 | '--cwd', 47 | testDir, 48 | ]); 49 | }; 50 | 51 | beforeAll(async () => { 52 | if (fs.existsSync(testDir)) { 53 | fs.rmSync(testDir, { recursive: true }); 54 | } 55 | 56 | fs.mkdirSync(testDir, { recursive: true }); 57 | // cd into testDir 58 | process.chdir(testDir); 59 | 60 | // create package.json 61 | await x('pnpm', ['init']); 62 | }); 63 | 64 | afterAll(() => { 65 | process.chdir(__dirname); // unlock directory 66 | 67 | fs.rmSync(testDir, { recursive: true }); 68 | }); 69 | 70 | it('adds from github', async () => { 71 | await addBlock('github/ieedan/std/tree/v2.2.0', 'utils/math'); 72 | 73 | const blockBaseDir = './src/utils/math'; 74 | 75 | const expectedFiles = [ 76 | 'circle.ts', 77 | 'conversions.ts', 78 | 'fractions.ts', 79 | 'gcf.ts', 80 | 'index.ts', 81 | 'triangles.ts', 82 | 'types.ts', 83 | ]; 84 | 85 | assertFilesExist(blockBaseDir, ...expectedFiles); 86 | }); 87 | 88 | it('adds block to correct directory', async () => { 89 | await addBlock('github/ieedan/std/tree/v2.2.0', 'types/result'); 90 | 91 | const blockBaseDir = './types'; 92 | 93 | const expectedFiles = ['result.ts']; 94 | 95 | assertFilesExist(blockBaseDir, ...expectedFiles); 96 | }); 97 | 98 | it('adds from gitlab', async () => { 99 | await addBlock('gitlab/ieedan/std', 'utils/math'); 100 | 101 | const blockBaseDir = './src/utils/math'; 102 | 103 | const expectedFiles = [ 104 | 'circle.ts', 105 | 'conversions.ts', 106 | 'fractions.ts', 107 | 'gcf.ts', 108 | 'index.ts', 109 | 'triangles.ts', 110 | 'types.ts', 111 | ]; 112 | 113 | assertFilesExist(blockBaseDir, ...expectedFiles); 114 | }); 115 | 116 | it('adds from azure', async () => { 117 | await addBlock('azure/ieedan/std/std', 'utils/math'); 118 | 119 | const blockBaseDir = './src/utils/math'; 120 | 121 | const expectedFiles = [ 122 | 'circle.ts', 123 | 'conversions.ts', 124 | 'fractions.ts', 125 | 'gcf.ts', 126 | 'index.ts', 127 | 'triangles.ts', 128 | 'types.ts', 129 | ]; 130 | 131 | assertFilesExist(blockBaseDir, ...expectedFiles); 132 | }); 133 | 134 | it('adds from http', async () => { 135 | await addBlock('https://jsrepo-http.vercel.app/', 'utils/math'); 136 | 137 | const blockBaseDir = './src/utils/math'; 138 | 139 | const expectedFiles = [ 140 | 'circle.ts', 141 | 'conversions.ts', 142 | 'fractions.ts', 143 | 'gcf.ts', 144 | 'index.ts', 145 | 'triangles.ts', 146 | 'types.ts', 147 | ]; 148 | 149 | assertFilesExist(blockBaseDir, ...expectedFiles); 150 | }); 151 | 152 | it('adds from jsrepo.com', async () => { 153 | await addBlock('@ieedan/std', 'ts/math'); 154 | 155 | const blockBaseDir = './src/ts/math'; 156 | 157 | const expectedFiles = [ 158 | 'circle.ts', 159 | 'conversions.ts', 160 | 'fractions.ts', 161 | 'gcf.ts', 162 | 'index.ts', 163 | 'triangles.ts', 164 | 'types.ts', 165 | ]; 166 | 167 | assertFilesExist(blockBaseDir, ...expectedFiles); 168 | }); 169 | 170 | it('adds with zero-config without interaction', async () => { 171 | await addBlockZeroConfig('github/ieedan/std', 'ts/is-letter'); 172 | 173 | const blockBaseDir = './src/ts'; 174 | 175 | assertFilesExist(blockBaseDir, 'is-letter.ts'); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /tests/language-support.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { resolutionEquality } from '../src/utils/language-support'; 3 | 4 | describe('resolutionEquality', () => { 5 | it('returns true for a .js and .ts extension that are equal', () => { 6 | expect(resolutionEquality('test.ts', 'test.js')).toBe(true); 7 | }); 8 | 9 | it('returns true for a no extension and .ts extension that are equal', () => { 10 | expect(resolutionEquality('test.ts', 'test')).toBe(true); 11 | }); 12 | 13 | it('returns true for a no extension and .js extension that are equal', () => { 14 | expect(resolutionEquality('test.js', 'test')).toBe(true); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/pre-release.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { BASE_URL as JSREPO_BASE_URL } from '../src/utils/registry-providers/jsrepo'; 3 | 4 | // just here to prevent me from shooting myself in the foot 5 | describe('JSREPO_BASE_URL', () => { 6 | it('is the correct url', () => { 7 | expect(JSREPO_BASE_URL).toBe('https://www.jsrepo.com'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/unwrap-code.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { unwrapCodeFromQuotes } from '../src/utils/ai'; 3 | 4 | describe('unwrapCodeFromQuotes', () => { 5 | it('unwraps quoted code', () => { 6 | const code = `\`\`\` 7 | const thing = () => "hi"; 8 | \`\`\``; 9 | 10 | expect(unwrapCodeFromQuotes(code)).toBe('const thing = () => "hi";'); 11 | }); 12 | 13 | it('unwraps quoted code with language', () => { 14 | const code = `\`\`\`typescript 15 | const thing = () => "hi"; 16 | \`\`\``; 17 | 18 | expect(unwrapCodeFromQuotes(code)).toBe('const thing = () => "hi";'); 19 | }); 20 | 21 | it('unwraps only pre-quoted code', () => { 22 | const code = `\`\`\` 23 | const thing = () => "hi";`; 24 | 25 | expect(unwrapCodeFromQuotes(code)).toBe('const thing = () => "hi";'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { expect } from 'vitest'; 4 | 5 | export function assertFilesExist(dir: string, ...files: string[]) { 6 | for (const f of files) { 7 | expect(fs.existsSync(path.join(dir, f)), `Expected ${path.join(dir, f)} to exist.`).toBe( 8 | true 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "isolatedModules": true, 6 | "moduleResolution": "Bundler", 7 | "module": "ES2022", 8 | "target": "ES2022", 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true 12 | }, 13 | "include": ["src/**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts', 'src/api/index.ts'], 5 | format: ['esm'], 6 | platform: 'node', 7 | target: 'es2022', 8 | outDir: 'dist', 9 | clean: true, 10 | minify: true, 11 | treeshake: true, 12 | splitting: true, 13 | sourcemap: true, 14 | dts: true, 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | // sometimes they take a while so we'll do this to prevent them from being flakey 8 | testTimeout: 10000, 9 | exclude: ['**/temp-test/**', 'dist/**', 'coverage/**', 'node_modules'], 10 | }, 11 | server: { 12 | watch: { 13 | ignored: ['**/temp-test/**', 'dist/**', 'coverage/**', 'node_modules'], 14 | }, 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------