├── .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 |
--------------------------------------------------------------------------------
]