├── .editorconfig ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ └── main.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── build.config.ts ├── docs └── firebase.md ├── package.json ├── pnpm-lock.yaml ├── src ├── index.ts ├── isolate-bin.ts ├── isolate.ts ├── lib │ ├── config.ts │ ├── lockfile │ │ ├── helpers │ │ │ ├── generate-npm-lockfile.ts │ │ │ ├── generate-pnpm-lockfile.ts │ │ │ ├── generate-yarn-lockfile.ts │ │ │ ├── index.ts │ │ │ ├── load-npm-config.ts │ │ │ └── pnpm-map-importer.ts │ │ ├── index.ts │ │ └── process-lockfile.ts │ ├── logger.ts │ ├── manifest │ │ ├── adapt-target-package-manifest.ts │ │ ├── helpers │ │ │ ├── adapt-internal-package-manifests.ts │ │ │ ├── adapt-manifest-internal-deps.ts │ │ │ ├── adopt-pnpm-fields-from-root.ts │ │ │ ├── index.ts │ │ │ └── patch-internal-entries.ts │ │ ├── index.ts │ │ └── io.ts │ ├── output │ │ ├── get-build-output-dir.ts │ │ ├── index.ts │ │ ├── pack-dependencies.ts │ │ ├── process-build-output-files.ts │ │ └── unpack-dependencies.ts │ ├── package-manager │ │ ├── helpers │ │ │ ├── index.ts │ │ │ ├── infer-from-files.ts │ │ │ └── infer-from-manifest.ts │ │ ├── index.ts │ │ └── names.ts │ ├── registry │ │ ├── create-packages-registry.ts │ │ ├── helpers │ │ │ ├── find-packages-globs.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── list-internal-packages.ts │ ├── types.ts │ └── utils │ │ ├── filter-object-undefined.test.ts │ │ ├── filter-object-undefined.ts │ │ ├── get-dirname.ts │ │ ├── get-error-message.ts │ │ ├── get-major-version.ts │ │ ├── index.ts │ │ ├── inspect-value.ts │ │ ├── is-present.ts │ │ ├── is-rush-workspace.ts │ │ ├── json.ts │ │ ├── log-paths.ts │ │ ├── pack.ts │ │ ├── unpack.ts │ │ └── yaml.ts └── vendor.d.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # Defines the coding style for different editors and IDEs. 2 | # https://editorconfig.org/ 3 | 4 | # top-most EditorConfig file 5 | root = true 6 | 7 | # Rules for source code. 8 | [*] 9 | charset = utf-8 10 | end_of_line = lf 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | max_line_length = 80 16 | 17 | [*.{py,pyi}] 18 | indent_size = 4 19 | 20 | # Documentation. 21 | [*.md] 22 | max_line_length = 0 23 | trim_trailing_whitespace = false 24 | 25 | # Git commit messages. 26 | [COMMIT_EDITMSG] 27 | max_line_length = 0 28 | trim_trailing_whitespace = false 29 | 30 | # Makefiles require tabs. 31 | [Makefile] 32 | indent_style = tab 33 | indent_size = 8 34 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["0x80"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | 0x80 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: "0x80" 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | everything: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v3 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: "pnpm" 17 | - name: Install dependencies 18 | run: pnpm install 19 | - name: Lint syntax 20 | run: pnpm lint 21 | - name: Lint formatting 22 | run: pnpm lint:format 23 | - name: Check types 24 | run: pnpm compile 25 | - name: Test 26 | run: pnpm test 27 | - name: Build 28 | run: pnpm build 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | .vscode 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "proseWrap": "always", 4 | "plugins": ["./node_modules/prettier-plugin-jsdoc/dist/index.js"] 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Thijs Koerselman 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 | # Isolate Package 2 | 3 | - [Quickstart](#quickstart) 4 | - [Features](#features) 5 | - [Installation](#installation) 6 | - [Usage](#usage) 7 | - [Troubleshooting](#troubleshooting) 8 | - [Prerequisites](#prerequisites) 9 | - [Configuration Options](#configuration-options) 10 | - [API](#api) 11 | - [The internal packages strategy](#the-internal-packages-strategy) 12 | - [Firebase](#firebase) 13 | 14 | ## Quickstart 15 | 16 | Run `npx isolate-package isolate` from the monorepo package you would like to 17 | isolate. 18 | 19 | If you would like to see an example of a modern monorepo with this tool 20 | integrated, check out [mono-ts](https://github.com/0x80/mono-ts) 21 | 22 | ## Features 23 | 24 | - Isolate a monorepo workspace package to form a self-contained package that 25 | includes internal dependencies and an adapted lockfile for deterministic 26 | deployments. 27 | - Preserve packages file structure, without code bundling 28 | - Should work with any package manager, and tested with NPM, PNPM, and Yarn 29 | (both classic and modern). Bun is partially supported; the output will 30 | generate an NPM lockfile. 31 | - Zero-config for the vast majority of use-cases 32 | - Isolates dependencies recursively. If package A depends on internal package B 33 | which depends on internal package C, all of them will be included 34 | - Optionally force output to use NPM with matching versions 35 | - Optionally include devDependencies in the isolated output 36 | - Optionally pick or omit scripts from the manifest 37 | - Compatible with the Firebase tools CLI, including 1st and 2nd generation 38 | Firebase Functions. For more information see 39 | [the Firebase instructions](./docs/firebase.md). 40 | - Available in a 41 | [forked version of firebase-tools](https://github.com/0x80/firebase-tools-with-isolate) 42 | to preserve live code updates when running the emulators 43 | 44 | ## Installation 45 | 46 | Run `pnpm install isolate-package -D` or the equivalent for `npm` or `yarn`. 47 | 48 | I recommended using `pnpm` over `npm` or `yarn`. Besides being fast and 49 | efficient, PNPM has better support for monorepos. 50 | 51 | ## Usage 52 | 53 | > !! If you plan use this for Firebase deployments, and you want to preserve 54 | > live code updates when running the local emulators, you will want to use 55 | > [firebase-tools-with-isolate](https://github.com/0x80/firebase-tools-with-isolate) 56 | > instead. 57 | 58 | This package exposes a binary called `isolate`. 59 | 60 | Run `npx isolate` from the root of the package you want to isolate. Make sure 61 | you build the package first. 62 | 63 | The `isolate` binary will try to infer your build output location from a 64 | `tsconfig` file, but see the [buildDirName configuration](#builddirname) if you 65 | are not using Typescript. 66 | 67 | By default the isolated output will become available at `./isolate`. 68 | 69 | If you are here to improve your Firebase deployments check out the 70 | [Firebase quick start guide](./docs/firebase.md#a-quick-start). 71 | 72 | ## Troubleshooting 73 | 74 | If something is not working as expected, add an `isolate.config.json` file, and 75 | set `"logLevel"` to `"debug"`. This should give you detailed feedback in the 76 | console. 77 | 78 | In addition define an environment variable to debug the configuration being used 79 | by setting `DEBUG_ISOLATE_CONFIG=true` before you execute `isolate`. 80 | 81 | When debugging Firebase deployment issues it might be convenient to trigger the 82 | isolate process manually with `npx isolate` and possibly 83 | `DEBUG_ISOLATE_CONFIG=true npx isolate`. 84 | 85 | ## Prerequisites 86 | 87 | Because historically many different approaches to monorepos exist, we need to 88 | establish some basic rules for the isolate process to work. 89 | 90 | ### Define shared dependencies in the package manifest 91 | 92 | This one might sound obvious, but if the `package.json` from the package you are 93 | targeting does not list the other monorepo packages it depends on, in either the 94 | `dependencies` or `devDependencies` list, then the isolate process will not 95 | include them in the output. 96 | 97 | How dependencies are listed with regards to versioning is not important, because 98 | packages are matched based on their name. For example the following flavors all 99 | work (some depending on your package manager): 100 | 101 | ```cjson 102 | // package.json 103 | { 104 | "dependencies": { 105 | "shared-package": "0.0.0" 106 | "shared-package": "*", 107 | "shared-package": "workspace:*", 108 | "shared-package": "../shared-package", 109 | } 110 | } 111 | ``` 112 | 113 | So if the a package name can be found as part of the workspace definition, it 114 | will be processed regardless of its version specifier. 115 | 116 | ### Define "version" field in each package manifest 117 | 118 | The `version` field is required for `pack` to execute, because it is use to 119 | generate part of the packed filename. A personal preference is to set it to 120 | `"0.0.0"` to indicate that the version does not have any real meaning. 121 | 122 | ### Define "files" field in each package manifest 123 | 124 | > NOTE: This step is not required if you use the 125 | > [internal packages strategy](#the-internal-packages-strategy) but you could 126 | > set it to `["src"]` instead of `["dist"]`. 127 | 128 | The isolate process uses (p)npm `pack` to extract files from package 129 | directories, just like publishing a package would. 130 | 131 | For this to work it is required that you define the `files` property in each 132 | package manifest, as it declares what files should be included in the published 133 | output. 134 | 135 | Typically, the value contains an array with only the name of the build output 136 | directory. For example: 137 | 138 | ```cjson 139 | // package.json 140 | { 141 | "files": ["dist"] 142 | } 143 | ``` 144 | 145 | A few additional files from the root of your package will be included 146 | automatically, like the `package.json`, `LICENSE` and `README` files. 147 | 148 | **Tip** If you deploy to Firebase 149 | [2nd generation](https://firebase.google.com/docs/firestore/extend-with-functions-2nd-gen) 150 | functions, you might want to include some env files in the `files` list, so they 151 | are packaged and deployed together with your build output (as 1st gen functions 152 | config is no longer supported). 153 | 154 | ### Use a flat structure inside your packages folders 155 | 156 | At the moment, nesting packages inside packages is not supported. 157 | 158 | When building the registry of all internal packages, `isolate` doesn't drill 159 | down into the folders. So if you declare your packages to live in `packages/*` 160 | it will only find the packages directly in that folder and not at 161 | `packages/nested/more-packages`. 162 | 163 | You can, however, declare multiple workspace packages directories. Personally, I 164 | prefer to use `["packages/*", "apps/*", "services/*"]`. It is only the structure 165 | inside them that should be flat. 166 | 167 | ## Configuration Options 168 | 169 | For most users no configuration should be necessary. 170 | 171 | You can configure the isolate process by placing a `isolate.config.json` file in 172 | the package that you want to isolate, except when you're 173 | [deploying to Firebase from the root of the workspace](#deploying-firebase-from-the-root). 174 | 175 | For the config file to be picked up, you will have to execute `isolate` from the 176 | same location, as it uses the current working directory. 177 | 178 | Below you will find a description of every available option. 179 | 180 | ### logLevel 181 | 182 | Type: `"info" | "debug" | "warn" | "error"`, default: `"info"`. 183 | 184 | Because the configuration loader depends on this setting, its output is not 185 | affected by this setting. If you want to debug the configuration set 186 | `DEBUG_ISOLATE_CONFIG=true` before you run `isolate` 187 | 188 | ### forceNpm 189 | 190 | Type: `boolean`, default: `false` 191 | 192 | By default the isolate process will generate output based on the package manager 193 | that you are using for your monorepo, but your deployment target might not be 194 | compatible with that package manager. 195 | 196 | It should not really matter what package manager is used in de deployment as 197 | long as the versions match your original lockfile. 198 | 199 | By setting this option to `true` you are forcing the isolate output to use NPM. 200 | A package-lock file will be generated based on the contents of node_modules and 201 | therefore should match the versions in your original lockfile. 202 | 203 | This way you can enjoy using PNPM or Yarn for your monorepo, while your 204 | deployment requires NPM. 205 | 206 | ### buildDirName 207 | 208 | Type: `string | undefined`, default: `undefined` 209 | 210 | The name of the build output directory name. When undefined it is automatically 211 | detected via `tsconfig.json`. When you are not using Typescript you can use this 212 | setting to specify where the build output files are located. 213 | 214 | ### includeDevDependencies 215 | 216 | Type: `boolean`, default: `false` 217 | 218 | By default devDependencies are ignored and stripped from the isolated output 219 | `package.json` files. If you enable this the devDependencies will be included 220 | and isolated just like the production dependencies. 221 | 222 | ### pickFromScripts 223 | 224 | Type: `string[]`, default: `undefined` 225 | 226 | Select which scripts to include in the output manifest `scripts` field. For 227 | example if you want your test script included set it to `["test"]`. 228 | 229 | By default, all scripts are omitted. 230 | 231 | ### omitFromScripts 232 | 233 | Type: `string[]`, default: `undefined` 234 | 235 | Select which scripts to omit from the output manifest `scripts` field. For 236 | example if you want the build script interferes with your deployment target, but 237 | you want to preserve all of the other scripts, set it to `["build"]`. 238 | 239 | By default, all scripts are omitted, and the [pickFromScripts](#pickfromscripts) 240 | configuration overrules this configuration. 241 | 242 | ### omitPackageManager 243 | 244 | Type: `boolean`, default: `false` 245 | 246 | By default the packageManager field from the root manifest is copied to the 247 | target manifest. I have found that some platforms (Cloud Run, April 2024) can 248 | fail on this for some reason. This option allows you to omit the field from the 249 | isolated package manifest. 250 | 251 | ### isolateDirName 252 | 253 | Type: `string`, default: `"isolate"` 254 | 255 | The name of the isolate output directory. 256 | 257 | ### targetPackagePath 258 | 259 | Type: `string`, default: `undefined` 260 | 261 | Only when you decide to place the isolate configuration in the root of the 262 | monorepo, you use this setting to point it to the target you want to isolate, 263 | e.g. `./packages/my-firebase-package`. 264 | 265 | If this option is used the `workspaceRoot` setting will be ignored and assumed 266 | to be the current working directory. 267 | 268 | ### tsconfigPath 269 | 270 | Type: `string`, default: `"./tsconfig.json"` 271 | 272 | The path to the `tsconfig.json` file relative to the package you want to 273 | isolate. The tsconfig is only used for reading the `compilerOptions.outDir` 274 | setting. If no tsconfig is found, possibly because you are not using Typescript 275 | in your project, the process will fall back to the `buildDirName` setting. 276 | 277 | ### workspacePackages 278 | 279 | Type: `string[] | undefined`, default: `undefined` 280 | 281 | When workspacePackages is not defined, `isolate` will try to find the packages 282 | in the workspace by looking up the settings in `pnpm-workspace.yaml` or 283 | `package.json` files depending on the detected package manager. 284 | 285 | In case this fails, you can override this process by specifying globs manually. 286 | For example `"workspacePackages": ["packages/*", "apps/*"]`. Paths are relative 287 | from the root of the workspace. 288 | 289 | ### workspaceRoot 290 | 291 | Type: `string`, default: `"../.."` 292 | 293 | The relative path to the root of the workspace / monorepo. In a typical setup 294 | you will have a `packages` directory and possibly also an `apps` and a 295 | `services` directory, all of which contain packages. So any package you would 296 | want to isolate is located 2 levels up from the root. 297 | 298 | For example 299 | 300 | ``` 301 | packages 302 | ├─ backend 303 | │ └─ package.json 304 | └─ ui 305 | └─ package.json 306 | apps 307 | ├─ admin 308 | │ └─ package.json 309 | └─ web 310 | └─ package.json 311 | services 312 | └─ api 313 | └─ package.json 314 | 315 | ``` 316 | 317 | When you use the `targetPackagePath` option, this setting will be ignored. 318 | 319 | ## API 320 | 321 | Alternatively, `isolate` can be integrated in other programs by importing it as 322 | a function. You optionally pass it a some user configuration and possibly a 323 | logger to handle any output messages should you need to write them to a 324 | different location as the standard `node:console`. 325 | 326 | ```ts 327 | import { isolate } from "isolate-package"; 328 | 329 | await isolate({ 330 | config: { logLevel: "debug" }, 331 | logger: customLogger, 332 | }); 333 | ``` 334 | 335 | If no configuration is passed in, the process will try to read 336 | `isolate.config.json` from the current working directory. 337 | 338 | ## The internal packages strategy 339 | 340 | An alternative approach to using internal dependencies in a Typescript monorepo 341 | is 342 | [the internal packages strategy](https://turbo.build/blog/you-might-not-need-typescript-project-references), 343 | in which the package manifest entries point directly to Typescript source files, 344 | to omit intermediate build steps. The approach is compatible with 345 | isolate-package and showcased in 346 | [my example monorepo setup](https://github.com/0x80/mono-ts) 347 | 348 | In summary this is how it works: 349 | 350 | 1. The package to be deployed lists its internal dependencies as usual, but the 351 | package manifests of those dependencies point directly to the Typescript 352 | source (and types). 353 | 2. You configure the bundler of your target package to include the source code 354 | for those internal packages in its output bundle. In the case of TSUP for the 355 | [API service in the mono-ts](https://github.com/0x80/mono-ts/blob/main/services/api/tsup.config.ts) 356 | that configuration is: `noExternal: ["@mono/common"]` 357 | 3. When `isolate` runs, it does the same thing as always. It detects the 358 | internal packages, copies them to the isolate output folder and adjusts any 359 | links. 360 | 4. When deploying to Firebase, the cloud pipeline will treat the package 361 | manifest as usual, which installs the listed dependencies and any 362 | dependencies listed in the linked internal package manifests. 363 | 364 | Steps 3 and 4 are no different from a traditional setup. 365 | 366 | Note that the manifests for the internal packages in the output will still point 367 | to the Typescript source files, but since the shared code was embedded in the 368 | bundle, they will never be referenced via import statements. So the manifest the 369 | entry declarations are never used. The reason the packages are included in the 370 | isolated output is to instruct package manager to install their dependencies. 371 | 372 | ## Firebase 373 | 374 | For detailed information on how to use isolate-package in combination with 375 | Firebase [see this documentation](./docs/firebase.md#firebase) 376 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from "unbuild"; 2 | 3 | export default defineBuildConfig({ 4 | entries: ["./src/index", "./src/isolate-bin"], 5 | declaration: true, 6 | }); 7 | -------------------------------------------------------------------------------- /docs/firebase.md: -------------------------------------------------------------------------------- 1 | # Firebase 2 | 3 | 4 | 5 | - [Motivation](#motivation) 6 | - [Example](#example) 7 | - [Quick Start](#quick-start) 8 | - [Firebase Tools With Isolate](#firebase-tools-with-isolate) 9 | - [Deploying from multiple packages](#deploying-from-multiple-packages) 10 | - [Deploying from the root](#deploying-from-the-root) 11 | 12 | 13 | 14 | > !! There is 15 | > [a fork of firebase-tools](https://github.com/0x80/firebase-tools-with-isolate), 16 | > where isolate-package is integrated. 17 | 18 | ## Motivation 19 | 20 | This solution was born from a desire to deploy to 21 | [Firebase](https://firebase.google.com/) from a monorepo without resorting to 22 | custom shell scripts and other hacks. Here is 23 | [an article](https://thijs-koerselman.medium.com/deploy-to-firebase-without-the-hacks-e685de39025e) 24 | explaining the issue in more detail. 25 | 26 | There is nothing Firebase-specific to this solution and there should be other 27 | use-cases for it, but that is why this documentation contains some instructions 28 | related to Firebase. 29 | 30 | ## Example 31 | 32 | If you are not completely confident that your monorepo setup is solid, I advise 33 | you to check out my in-dept boilerplate at 34 | [mono-ts](https://github.com/0x80/mono-ts) where many different aspects are 35 | discussed and `isolate-package` is used to demonstrate Firebase deployments. 36 | 37 | ## Quick Start 38 | 39 | This section describes the steps required for Firebase deployment, assuming: 40 | 41 | - You use a fairly typical monorepo setup 42 | - Your `firebase.json` config lives in the root of the package that you like to 43 | deploy to Firebase, hereafter referred to as the "target package". 44 | 45 | If your setup diverges from a traditional one, please continue reading the 46 | [Prerequisites](../README.md#prerequisites) section. 47 | 48 | 1. In the target package, install `isolate-package` and `firebase-tools` by 49 | running `pnpm add isolate-package firebase-tools -D` or the Yarn / NPM 50 | equivalent. I tend to install firebase-tools as a devDependency in every 51 | Firebase package, but you could also use a global install if you prefer that. 52 | 2. In the `firebase.json` config set `"source"` to `"./isolate"` and 53 | `"predeploy"` to `["turbo build", "isolate"]` or whatever suits your build 54 | tool. The important part here is that isolate is being executed after the 55 | build stage. 56 | 3. From the target package folder, you should now be able to deploy with 57 | `npx firebase deploy`. 58 | 59 | I recommend keeping a `firebase.json` file inside each Firebase package (as 60 | opposed to the monorepo root), because it allows you to deploy from multiple 61 | independent packages. It makes it easy to deploy 1st gen functions next to 2nd 62 | gen functions, deploy different node versions, and decrease the built output 63 | size and dependency lists for each package, improving deployment and cold-start 64 | times. 65 | 66 | ## Firebase Tools With Isolate 67 | 68 | I recommend using 69 | [the fork](https://github.com/0x80/firebase-tools-with-isolate) for monorepos 70 | until it is officially integrated. It not only simplifies the setup but more 71 | importantly allows `isolate` to run as an integral part of the deployment 72 | process, so it doesn't affect anything prior to deployment. Because of this, you 73 | preserve live code updates when running the local Firebase emulators, which I 74 | think is highly desirable. 75 | 76 | The fork is pretty much identical, and the integration with isolate-package does 77 | not affect any existing functionality, so I do not think there is a reason to 78 | worry about things breaking. I will sync the fork with the upstream 79 | firebase-tools on a regular basis. The fork versions will match the 80 | firebase-tools versions for clarity. 81 | 82 | ## Deploying from multiple packages 83 | 84 | You can deploy to Firebase from multiple packages in your monorepo, in which 85 | case you co-locate your `firebase.json` file with the source code, and not in 86 | the root of the monorepo. If you do want to keep the firebase config in the 87 | root, read the instructions for 88 | [deploying to Firebase from the root](#deploying-to-firebase-from-the-root). 89 | 90 | In order to deploy to Firebase, the `functions.source` setting in 91 | `firebase.json` needs to point to the isolated output folder, which would be 92 | `./isolate` when using the default configuration. 93 | 94 | The `predeploy` phase should first build and then isolate the output. 95 | 96 | Here's an example using [Turborepo](https://turbo.build/): 97 | 98 | ```cjson 99 | // firebase.json 100 | { 101 | "functions": { 102 | "source": "./isolate", 103 | "predeploy": ["turbo build", "isolate"] 104 | } 105 | } 106 | ``` 107 | 108 | With this configuration you can then run `npx firebase deploy --only functions` 109 | from the package. 110 | 111 | If you like to deploy to Firebase Functions from multiple packages you will also 112 | need to configure a unique `codebase` identifier for each of them. For more 113 | information, 114 | [read this](https://firebase.google.com/docs/functions/beta/organize-functions). 115 | 116 | Make sure your Firebase package adheres to the things mentioned in 117 | [prerequisites](../README.md#prerequisites) and its package manifest contains 118 | the field `"main"`, or `"module"` if you set `"type": "module"`, so Firebase 119 | knows the entry point to your source code. 120 | 121 | ## Deploying from the root 122 | 123 | If, for some reason, you choose to keep the `firebase.json` file in the root of 124 | the monorepo you will have to place a configuration file called 125 | `isolate.config.json` in the root with the following content: 126 | 127 | ```cjson 128 | // isolate.config.json 129 | { 130 | "targetPackagePath": "./packages/your-firebase-package" 131 | } 132 | ``` 133 | 134 | The Firebase configuration should then look something like this: 135 | 136 | ```cjson 137 | // firebase.json 138 | { 139 | "functions": { 140 | "source": "./packages/your-firebase-package/isolate", 141 | "predeploy": ["turbo build", "isolate"] 142 | } 143 | } 144 | ``` 145 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isolate-package", 3 | "version": "1.23.0", 4 | "description": "Isolate a monorepo package with its shared dependencies to form a self-contained directory, compatible with Firebase deploy", 5 | "author": "Thijs Koerselman", 6 | "license": "MIT", 7 | "keywords": [ 8 | "monorepo", 9 | "turborepo", 10 | "workspaces", 11 | "workspace", 12 | "isolate", 13 | "package", 14 | "deploy", 15 | "firebase", 16 | "ci", 17 | "docker", 18 | "prune", 19 | "lockfile" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/0x80/isolate-package.git" 24 | }, 25 | "type": "module", 26 | "types": "./dist/index.d.ts", 27 | "exports": "./dist/index.mjs", 28 | "files": [ 29 | "dist", 30 | "docs" 31 | ], 32 | "bin": { 33 | "isolate": "dist/isolate-bin.mjs" 34 | }, 35 | "scripts": { 36 | "build": "tsup-node", 37 | "dev": "tsup-node --watch", 38 | "test": "vitest", 39 | "format": "prettier --write .", 40 | "lint": "eslint . --max-warnings 0", 41 | "lint:format": "prettier --check .", 42 | "compile": "tsc --noEmit", 43 | "prepare": "pnpm run compile && pnpm run build" 44 | }, 45 | "dependencies": { 46 | "@npmcli/arborist": "^7.5.4", 47 | "@npmcli/config": "^9.0.0", 48 | "@pnpm/logger": "^5.2.0", 49 | "@pnpm/types": "^9.4.2", 50 | "chalk": "^5.3.0", 51 | "fs-extra": "^11.2.0", 52 | "get-tsconfig": "^4.8.1", 53 | "glob": "^10.4.5", 54 | "outdent": "^0.8.0", 55 | "pnpm_lockfile_file_v8": "npm:@pnpm/lockfile-file@8", 56 | "pnpm_lockfile_file_v9": "npm:@pnpm/lockfile-file@9", 57 | "pnpm_prune_lockfile_v8": "npm:@pnpm/prune-lockfile@5", 58 | "pnpm_prune_lockfile_v9": "npm:@pnpm/prune-lockfile@6", 59 | "remeda": "^2.17.3", 60 | "rename-overwrite": "^5.0.4", 61 | "source-map-support": "^0.5.21", 62 | "strip-json-comments": "^5.0.1", 63 | "tar-fs": "^3.0.6", 64 | "type-fest": "^4.27.0", 65 | "yaml": "^2.6.1" 66 | }, 67 | "devDependencies": { 68 | "@types/fs-extra": "^11.0.4", 69 | "@types/node": "^22.9.1", 70 | "@types/npmcli__config": "^6.0.3", 71 | "@types/source-map-support": "^0.5.10", 72 | "@types/tar-fs": "^2.0.4", 73 | "eslint": "^8.57.1", 74 | "eslint-config-0x80": "^0.0.0", 75 | "prettier": "^3.3.3", 76 | "prettier-plugin-jsdoc": "^1.3.0", 77 | "tsup": "^8.3.5", 78 | "typescript": "^5.6.3", 79 | "vitest": "^1.6.0" 80 | }, 81 | "packageManager": "pnpm@9.0.0+sha256.bdfc9a7b372b5c462176993e586492603e20da5864d2f8881edc2462482c76fa" 82 | } 83 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { isolate } from "./isolate"; 2 | export { isolate } from "./isolate"; 3 | export type { Logger } from "./lib/logger"; 4 | 5 | export type IsolateExports = { 6 | isolate: typeof isolate; 7 | }; 8 | -------------------------------------------------------------------------------- /src/isolate-bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import console from "node:console"; 3 | import sourceMaps from "source-map-support"; 4 | import { isolate } from "./isolate"; 5 | 6 | sourceMaps.install(); 7 | 8 | async function run() { 9 | await isolate(); 10 | } 11 | 12 | run().catch((err) => { 13 | if (err instanceof Error) { 14 | console.error(err.stack); 15 | process.exit(1); 16 | } else { 17 | console.error(err); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/isolate.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import assert from "node:assert"; 3 | import path from "node:path"; 4 | import { unique } from "remeda"; 5 | import type { IsolateConfig } from "./lib/config"; 6 | import { resolveConfig } from "./lib/config"; 7 | import { processLockfile } from "./lib/lockfile"; 8 | import { setLogLevel, useLogger } from "./lib/logger"; 9 | import { 10 | adaptInternalPackageManifests, 11 | adaptTargetPackageManifest, 12 | readManifest, 13 | writeManifest, 14 | } from "./lib/manifest"; 15 | import { 16 | getBuildOutputDir, 17 | packDependencies, 18 | processBuildOutputFiles, 19 | unpackDependencies, 20 | } from "./lib/output"; 21 | import { detectPackageManager, shouldUsePnpmPack } from "./lib/package-manager"; 22 | import { getVersion } from "./lib/package-manager/helpers/infer-from-files"; 23 | import { createPackagesRegistry, listInternalPackages } from "./lib/registry"; 24 | import type { PackageManifest } from "./lib/types"; 25 | import { 26 | getDirname, 27 | getRootRelativeLogPath, 28 | isRushWorkspace, 29 | readTypedJson, 30 | writeTypedYamlSync, 31 | } from "./lib/utils"; 32 | 33 | const __dirname = getDirname(import.meta.url); 34 | 35 | export function createIsolator(config?: IsolateConfig) { 36 | const resolvedConfig = resolveConfig(config); 37 | 38 | return async function isolate(): Promise { 39 | const config = resolvedConfig; 40 | setLogLevel(config.logLevel); 41 | const log = useLogger(); 42 | 43 | const { version: libraryVersion } = await readTypedJson( 44 | path.join(path.join(__dirname, "..", "package.json")) 45 | ); 46 | 47 | log.debug("Using isolate-package version", libraryVersion); 48 | 49 | /** 50 | * If a targetPackagePath is set, we assume the configuration lives in the 51 | * root of the workspace. If targetPackagePath is undefined (the default), 52 | * we assume that the configuration lives in the target package directory. 53 | */ 54 | const targetPackageDir = config.targetPackagePath 55 | ? path.join(process.cwd(), config.targetPackagePath) 56 | : process.cwd(); 57 | 58 | const workspaceRootDir = config.targetPackagePath 59 | ? process.cwd() 60 | : path.join(targetPackageDir, config.workspaceRoot); 61 | 62 | const buildOutputDir = await getBuildOutputDir({ 63 | targetPackageDir, 64 | buildDirName: config.buildDirName, 65 | tsconfigPath: config.tsconfigPath, 66 | }); 67 | 68 | assert( 69 | fs.existsSync(buildOutputDir), 70 | `Failed to find build output path at ${buildOutputDir}. Please make sure you build the source before isolating it.` 71 | ); 72 | 73 | log.debug("Workspace root resolved to", workspaceRootDir); 74 | log.debug( 75 | "Isolate target package", 76 | getRootRelativeLogPath(targetPackageDir, workspaceRootDir) 77 | ); 78 | 79 | const isolateDir = path.join(targetPackageDir, config.isolateDirName); 80 | 81 | log.debug( 82 | "Isolate output directory", 83 | getRootRelativeLogPath(isolateDir, workspaceRootDir) 84 | ); 85 | 86 | if (fs.existsSync(isolateDir)) { 87 | await fs.remove(isolateDir); 88 | log.debug("Cleaned the existing isolate output directory"); 89 | } 90 | 91 | await fs.ensureDir(isolateDir); 92 | 93 | const tmpDir = path.join(isolateDir, "__tmp"); 94 | await fs.ensureDir(tmpDir); 95 | 96 | const targetPackageManifest = await readTypedJson( 97 | path.join(targetPackageDir, "package.json") 98 | ); 99 | 100 | const packageManager = detectPackageManager(workspaceRootDir); 101 | 102 | log.debug( 103 | "Detected package manager", 104 | packageManager.name, 105 | packageManager.version 106 | ); 107 | 108 | if (shouldUsePnpmPack()) { 109 | log.debug("Use PNPM pack instead of NPM pack"); 110 | } 111 | 112 | /** 113 | * Build a packages registry so we can find the workspace packages by name 114 | * and have access to their manifest files and relative paths. 115 | */ 116 | const packagesRegistry = await createPackagesRegistry( 117 | workspaceRootDir, 118 | config.workspacePackages 119 | ); 120 | 121 | const internalPackageNames = listInternalPackages( 122 | targetPackageManifest, 123 | packagesRegistry, 124 | { 125 | includeDevDependencies: config.includeDevDependencies, 126 | } 127 | ); 128 | 129 | const packedFilesByName = await packDependencies({ 130 | internalPackageNames, 131 | packagesRegistry, 132 | packDestinationDir: tmpDir, 133 | }); 134 | 135 | await unpackDependencies( 136 | packedFilesByName, 137 | packagesRegistry, 138 | tmpDir, 139 | isolateDir 140 | ); 141 | 142 | /** Adapt the manifest files for all the unpacked local dependencies */ 143 | await adaptInternalPackageManifests({ 144 | internalPackageNames, 145 | packagesRegistry, 146 | isolateDir, 147 | forceNpm: config.forceNpm, 148 | }); 149 | 150 | /** Pack the target package directory, and unpack it in the isolate location */ 151 | await processBuildOutputFiles({ 152 | targetPackageDir, 153 | tmpDir, 154 | isolateDir, 155 | }); 156 | 157 | /** 158 | * Copy the target manifest file to the isolate location and adapt its 159 | * workspace dependencies to point to the isolated packages. 160 | */ 161 | const outputManifest = await adaptTargetPackageManifest({ 162 | manifest: targetPackageManifest, 163 | packagesRegistry, 164 | workspaceRootDir, 165 | config, 166 | }); 167 | 168 | await writeManifest(isolateDir, outputManifest); 169 | 170 | /** Generate an isolated lockfile based on the original one */ 171 | const usedFallbackToNpm = await processLockfile({ 172 | workspaceRootDir, 173 | isolateDir, 174 | packagesRegistry, 175 | internalDepPackageNames: internalPackageNames, 176 | targetPackageDir, 177 | targetPackageName: targetPackageManifest.name, 178 | targetPackageManifest: outputManifest, 179 | config, 180 | }); 181 | 182 | if (usedFallbackToNpm) { 183 | /** 184 | * When we fall back to NPM, we set the manifest package manager to the 185 | * available NPM version. 186 | */ 187 | const manifest = await readManifest(isolateDir); 188 | 189 | const npmVersion = getVersion("npm"); 190 | manifest.packageManager = `npm@${npmVersion}`; 191 | 192 | await writeManifest(isolateDir, manifest); 193 | } 194 | 195 | if (packageManager.name === "pnpm" && !config.forceNpm) { 196 | /** 197 | * PNPM doesn't install dependencies of packages that are linked via link: 198 | * or file: specifiers. It requires the directory to be configured as a 199 | * workspace, so we copy the workspace config file to the isolate output. 200 | * 201 | * Rush doesn't have a pnpm-workspace.yaml file, so we generate one. 202 | */ 203 | if (isRushWorkspace(workspaceRootDir)) { 204 | const packagesFolderNames = unique( 205 | internalPackageNames.map( 206 | (name) => path.parse(packagesRegistry[name].rootRelativeDir).dir 207 | ) 208 | ); 209 | 210 | log.debug("Generating pnpm-workspace.yaml for Rush workspace"); 211 | log.debug("Packages folder names:", packagesFolderNames); 212 | 213 | const packages = packagesFolderNames.map((x) => path.join(x, "/*")); 214 | 215 | await writeTypedYamlSync(path.join(isolateDir, "pnpm-workspace.yaml"), { 216 | packages, 217 | }); 218 | } else { 219 | fs.copyFileSync( 220 | path.join(workspaceRootDir, "pnpm-workspace.yaml"), 221 | path.join(isolateDir, "pnpm-workspace.yaml") 222 | ); 223 | } 224 | } 225 | 226 | /** 227 | * If there is an .npmrc file in the workspace root, copy it to the isolate 228 | * because the settings there could affect how the lockfile is resolved. 229 | * Note that .npmrc is used by both NPM and PNPM for configuration. 230 | * 231 | * See also: https://pnpm.io/npmrc 232 | */ 233 | const npmrcPath = path.join(workspaceRootDir, ".npmrc"); 234 | 235 | if (fs.existsSync(npmrcPath)) { 236 | fs.copyFileSync(npmrcPath, path.join(isolateDir, ".npmrc")); 237 | log.debug("Copied .npmrc file to the isolate output"); 238 | } 239 | 240 | /** 241 | * Clean up. Only do this when things succeed, so we can look at the temp 242 | * folder in case something goes wrong. 243 | */ 244 | log.debug( 245 | "Deleting temp directory", 246 | getRootRelativeLogPath(tmpDir, workspaceRootDir) 247 | ); 248 | await fs.remove(tmpDir); 249 | 250 | log.debug("Isolate completed at", isolateDir); 251 | 252 | return isolateDir; 253 | }; 254 | } 255 | 256 | // Keep the original function for backward compatibility 257 | export async function isolate(config?: IsolateConfig): Promise { 258 | return createIsolator(config)(); 259 | } 260 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "node:path"; 3 | import { isEmpty } from "remeda"; 4 | import { setLogLevel, useLogger } from "./logger"; 5 | import { inspectValue, readTypedJsonSync } from "./utils"; 6 | 7 | export type IsolateConfigResolved = { 8 | buildDirName?: string; 9 | includeDevDependencies: boolean; 10 | includePatchedDependencies: boolean; 11 | isolateDirName: string; 12 | logLevel: "info" | "debug" | "warn" | "error"; 13 | targetPackagePath?: string; 14 | tsconfigPath: string; 15 | workspacePackages?: string[]; 16 | workspaceRoot: string; 17 | forceNpm: boolean; 18 | pickFromScripts?: string[]; 19 | omitFromScripts?: string[]; 20 | omitPackageManager?: boolean; 21 | }; 22 | 23 | export type IsolateConfig = Partial; 24 | 25 | const configDefaults: IsolateConfigResolved = { 26 | buildDirName: undefined, 27 | includeDevDependencies: false, 28 | includePatchedDependencies: false, 29 | isolateDirName: "isolate", 30 | logLevel: "info", 31 | targetPackagePath: undefined, 32 | tsconfigPath: "./tsconfig.json", 33 | workspacePackages: undefined, 34 | workspaceRoot: "../..", 35 | forceNpm: false, 36 | pickFromScripts: undefined, 37 | omitFromScripts: undefined, 38 | omitPackageManager: false, 39 | }; 40 | 41 | const validConfigKeys = Object.keys(configDefaults); 42 | const CONFIG_FILE_NAME = "isolate.config.json"; 43 | 44 | export type LogLevel = IsolateConfigResolved["logLevel"]; 45 | 46 | function loadConfigFromFile(): IsolateConfig { 47 | const configFilePath = path.join(process.cwd(), CONFIG_FILE_NAME); 48 | return fs.existsSync(configFilePath) 49 | ? readTypedJsonSync(configFilePath) 50 | : {}; 51 | } 52 | 53 | function validateConfig(config: IsolateConfig) { 54 | const log = useLogger(); 55 | const foreignKeys = Object.keys(config).filter( 56 | (key) => !validConfigKeys.includes(key) 57 | ); 58 | 59 | if (!isEmpty(foreignKeys)) { 60 | log.warn(`Found invalid config settings:`, foreignKeys.join(", ")); 61 | } 62 | } 63 | 64 | export function resolveConfig( 65 | initialConfig?: IsolateConfig 66 | ): IsolateConfigResolved { 67 | setLogLevel(process.env.DEBUG_ISOLATE_CONFIG ? "debug" : "info"); 68 | const log = useLogger(); 69 | 70 | const userConfig = initialConfig ?? loadConfigFromFile(); 71 | 72 | if (initialConfig) { 73 | log.debug(`Using user defined config:`, inspectValue(initialConfig)); 74 | } else { 75 | log.debug(`Loaded config from ${CONFIG_FILE_NAME}`); 76 | } 77 | 78 | validateConfig(userConfig); 79 | 80 | if (userConfig.logLevel) { 81 | setLogLevel(userConfig.logLevel); 82 | } 83 | 84 | const config = { 85 | ...configDefaults, 86 | ...userConfig, 87 | } satisfies IsolateConfigResolved; 88 | 89 | log.debug("Using configuration:", inspectValue(config)); 90 | 91 | return config; 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/generate-npm-lockfile.ts: -------------------------------------------------------------------------------- 1 | import Arborist from "@npmcli/arborist"; 2 | import fs from "fs-extra"; 3 | import path from "node:path"; 4 | import { useLogger } from "~/lib/logger"; 5 | import { getErrorMessage } from "~/lib/utils"; 6 | import { loadNpmConfig } from "./load-npm-config"; 7 | 8 | /** 9 | * Generate an isolated / pruned lockfile, based on the contents of installed 10 | * node_modules from the monorepo root plus the adapted package manifest in the 11 | * isolate directory. 12 | */ 13 | export async function generateNpmLockfile({ 14 | workspaceRootDir, 15 | isolateDir, 16 | }: { 17 | workspaceRootDir: string; 18 | isolateDir: string; 19 | }) { 20 | const log = useLogger(); 21 | 22 | log.debug("Generating NPM lockfile..."); 23 | 24 | const nodeModulesPath = path.join(workspaceRootDir, "node_modules"); 25 | 26 | try { 27 | if (!fs.existsSync(nodeModulesPath)) { 28 | throw new Error(`Failed to find node_modules at ${nodeModulesPath}`); 29 | } 30 | 31 | const config = await loadNpmConfig({ npmPath: workspaceRootDir }); 32 | 33 | const arborist = new Arborist({ 34 | path: isolateDir, 35 | ...config.flat, 36 | }); 37 | 38 | const { meta } = await arborist.buildIdealTree(); 39 | 40 | meta?.commit(); 41 | 42 | const lockfilePath = path.join(isolateDir, "package-lock.json"); 43 | 44 | await fs.writeFile(lockfilePath, String(meta)); 45 | 46 | log.debug("Created lockfile at", lockfilePath); 47 | } catch (err) { 48 | log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`); 49 | throw err; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/generate-pnpm-lockfile.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import path from "node:path"; 3 | import { 4 | getLockfileImporterId as getLockfileImporterId_v8, 5 | readWantedLockfile as readWantedLockfile_v8, 6 | writeWantedLockfile as writeWantedLockfile_v8, 7 | } from "pnpm_lockfile_file_v8"; 8 | import { 9 | getLockfileImporterId as getLockfileImporterId_v9, 10 | readWantedLockfile as readWantedLockfile_v9, 11 | writeWantedLockfile as writeWantedLockfile_v9, 12 | } from "pnpm_lockfile_file_v9"; 13 | import { pruneLockfile as pruneLockfile_v8 } from "pnpm_prune_lockfile_v8"; 14 | import { pruneLockfile as pruneLockfile_v9 } from "pnpm_prune_lockfile_v9"; 15 | import { pick } from "remeda"; 16 | import { useLogger } from "~/lib/logger"; 17 | import type { PackageManifest, PackagesRegistry } from "~/lib/types"; 18 | import { getErrorMessage, isRushWorkspace } from "~/lib/utils"; 19 | import { pnpmMapImporter } from "./pnpm-map-importer"; 20 | 21 | export async function generatePnpmLockfile({ 22 | workspaceRootDir, 23 | targetPackageDir, 24 | isolateDir, 25 | internalDepPackageNames, 26 | packagesRegistry, 27 | targetPackageManifest, 28 | majorVersion, 29 | includeDevDependencies, 30 | includePatchedDependencies, 31 | }: { 32 | workspaceRootDir: string; 33 | targetPackageDir: string; 34 | isolateDir: string; 35 | internalDepPackageNames: string[]; 36 | packagesRegistry: PackagesRegistry; 37 | targetPackageManifest: PackageManifest; 38 | majorVersion: number; 39 | includeDevDependencies: boolean; 40 | includePatchedDependencies: boolean; 41 | }) { 42 | /** 43 | * For now we will assume that the lockfile format might not change in the 44 | * versions after 9, because we might get lucky. If it does change, things 45 | * would break either way. 46 | */ 47 | const useVersion9 = majorVersion >= 9; 48 | 49 | const log = useLogger(); 50 | 51 | log.debug("Generating PNPM lockfile..."); 52 | 53 | try { 54 | const isRush = isRushWorkspace(workspaceRootDir); 55 | 56 | const lockfile = useVersion9 57 | ? await readWantedLockfile_v9( 58 | isRush 59 | ? path.join(workspaceRootDir, "common/config/rush") 60 | : workspaceRootDir, 61 | { 62 | ignoreIncompatible: false, 63 | } 64 | ) 65 | : await readWantedLockfile_v8( 66 | isRush 67 | ? path.join(workspaceRootDir, "common/config/rush") 68 | : workspaceRootDir, 69 | { 70 | ignoreIncompatible: false, 71 | } 72 | ); 73 | 74 | assert(lockfile, `No input lockfile found at ${workspaceRootDir}`); 75 | 76 | const targetImporterId = useVersion9 77 | ? getLockfileImporterId_v9(workspaceRootDir, targetPackageDir) 78 | : getLockfileImporterId_v8(workspaceRootDir, targetPackageDir); 79 | 80 | const directoryByPackageName = Object.fromEntries( 81 | internalDepPackageNames.map((name) => { 82 | const pkg = packagesRegistry[name]; 83 | assert(pkg, `Package ${name} not found in packages registry`); 84 | 85 | return [name, pkg.rootRelativeDir]; 86 | }) 87 | ); 88 | 89 | const relevantImporterIds = [ 90 | targetImporterId, 91 | /** 92 | * The directory paths happen to correspond with what PNPM calls the 93 | * importer ids in the context of a lockfile. 94 | */ 95 | ...Object.values(directoryByPackageName), 96 | /** 97 | * Split the path by the OS separator and join it back with the POSIX 98 | * separator. 99 | * 100 | * The importerIds are built from directory names, so Windows Git Bash 101 | * environments will have double backslashes in their ids: 102 | * "packages\common" vs. "packages/common". Without this split & join, any 103 | * packages not on the top-level will have ill-formatted importerIds and 104 | * their entries will be missing from the lockfile.importers list. 105 | */ 106 | ].map((x) => x.split(path.sep).join(path.posix.sep)); 107 | 108 | log.debug("Relevant importer ids:", relevantImporterIds); 109 | 110 | /** 111 | * In a Rush workspace the original lockfile is not in the root, so the 112 | * importerIds have to be prefixed with `../../`, but that's not how they 113 | * should be stored in the isolated lockfile, so we use the prefixed ids 114 | * only for parsing. 115 | */ 116 | const relevantImporterIdsWithPrefix = relevantImporterIds.map((x) => 117 | isRush ? `../../${x}` : x 118 | ); 119 | 120 | lockfile.importers = Object.fromEntries( 121 | Object.entries( 122 | pick(lockfile.importers, relevantImporterIdsWithPrefix) 123 | ).map(([prefixedImporterId, importer]) => { 124 | const importerId = isRush 125 | ? prefixedImporterId.replace("../../", "") 126 | : prefixedImporterId; 127 | 128 | if (importerId === targetImporterId) { 129 | log.debug("Setting target package importer on root"); 130 | 131 | return [ 132 | ".", 133 | pnpmMapImporter(".", importer!, { 134 | includeDevDependencies, 135 | includePatchedDependencies, 136 | directoryByPackageName, 137 | }), 138 | ]; 139 | } 140 | 141 | log.debug("Setting internal package importer:", importerId); 142 | 143 | return [ 144 | importerId, 145 | pnpmMapImporter(importerId, importer!, { 146 | includeDevDependencies, 147 | includePatchedDependencies, 148 | directoryByPackageName, 149 | }), 150 | ]; 151 | }) 152 | ); 153 | 154 | log.debug("Pruning the lockfile"); 155 | 156 | const prunedLockfile = useVersion9 157 | ? await pruneLockfile_v9(lockfile, targetPackageManifest, ".") 158 | : await pruneLockfile_v8(lockfile, targetPackageManifest, "."); 159 | 160 | /** Pruning seems to remove the overrides from the lockfile */ 161 | if (lockfile.overrides) { 162 | prunedLockfile.overrides = lockfile.overrides; 163 | } 164 | 165 | /** 166 | * Don't know how to map the patched dependencies yet, so we just include 167 | * them but I don't think it would work like this. The important thing for 168 | * now is that they are omitted by default, because that is the most common 169 | * use case. 170 | */ 171 | const patchedDependencies = includePatchedDependencies 172 | ? lockfile.patchedDependencies 173 | : undefined; 174 | 175 | useVersion9 176 | ? await writeWantedLockfile_v9(isolateDir, { 177 | ...prunedLockfile, 178 | patchedDependencies, 179 | }) 180 | : await writeWantedLockfile_v8(isolateDir, { 181 | ...prunedLockfile, 182 | patchedDependencies, 183 | }); 184 | 185 | log.debug("Created lockfile at", path.join(isolateDir, "pnpm-lock.yaml")); 186 | } catch (err) { 187 | log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`); 188 | throw err; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/generate-yarn-lockfile.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { execSync } from "node:child_process"; 3 | import path from "node:path"; 4 | import { useLogger } from "~/lib/logger"; 5 | import { getErrorMessage, isRushWorkspace } from "~/lib/utils"; 6 | 7 | /** 8 | * Generate an isolated / pruned lockfile, based on the existing lockfile from 9 | * the monorepo root plus the adapted package manifest in the isolate 10 | * directory. 11 | */ 12 | export async function generateYarnLockfile({ 13 | workspaceRootDir, 14 | isolateDir, 15 | }: { 16 | workspaceRootDir: string; 17 | isolateDir: string; 18 | }) { 19 | const log = useLogger(); 20 | 21 | log.debug("Generating Yarn lockfile..."); 22 | 23 | const origLockfilePath = isRushWorkspace(workspaceRootDir) 24 | ? path.join(workspaceRootDir, "common/config/rush", "yarn.lock") 25 | : path.join(workspaceRootDir, "yarn.lock"); 26 | 27 | const newLockfilePath = path.join(isolateDir, "yarn.lock"); 28 | 29 | if (!fs.existsSync(origLockfilePath)) { 30 | throw new Error(`Failed to find lockfile at ${origLockfilePath}`); 31 | } 32 | 33 | log.debug(`Copy original yarn.lock to the isolate output`); 34 | 35 | try { 36 | await fs.copyFile(origLockfilePath, newLockfilePath); 37 | 38 | /** 39 | * Running install with the original lockfile in the same directory will 40 | * generate a pruned version of the lockfile. 41 | */ 42 | log.debug(`Running local install`); 43 | execSync(`yarn install --cwd ${isolateDir}`); 44 | 45 | log.debug("Generated lockfile at", newLockfilePath); 46 | } catch (err) { 47 | log.error(`Failed to generate lockfile: ${getErrorMessage(err)}`); 48 | throw err; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./generate-npm-lockfile"; 2 | export * from "./generate-pnpm-lockfile"; 3 | export * from "./generate-yarn-lockfile"; 4 | export * from "./pnpm-map-importer"; 5 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/load-npm-config.ts: -------------------------------------------------------------------------------- 1 | import Config from "@npmcli/config"; 2 | import defaults from "@npmcli/config/lib/definitions/index.js"; 3 | 4 | export async function loadNpmConfig({ npmPath }: { npmPath: string }) { 5 | const config = new Config({ 6 | npmPath, 7 | definitions: defaults.definitions, 8 | shorthands: defaults.shorthands, 9 | flatten: defaults.flatten, 10 | }); 11 | 12 | await config.load(); 13 | 14 | return config; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/lockfile/helpers/pnpm-map-importer.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import type { 3 | ProjectSnapshot, 4 | ResolvedDependencies, 5 | } from "pnpm_lockfile_file_v8"; 6 | 7 | import { mapValues } from "remeda"; 8 | 9 | /** Convert dependency links */ 10 | export function pnpmMapImporter( 11 | importerPath: string, 12 | { dependencies, devDependencies, ...rest }: ProjectSnapshot, 13 | { 14 | includeDevDependencies, 15 | directoryByPackageName, 16 | }: { 17 | includeDevDependencies: boolean; 18 | includePatchedDependencies: boolean; 19 | directoryByPackageName: { [packageName: string]: string }; 20 | } 21 | ): ProjectSnapshot { 22 | return { 23 | dependencies: dependencies 24 | ? pnpmMapDependenciesLinks( 25 | importerPath, 26 | dependencies, 27 | directoryByPackageName 28 | ) 29 | : undefined, 30 | devDependencies: 31 | includeDevDependencies && devDependencies 32 | ? pnpmMapDependenciesLinks( 33 | importerPath, 34 | devDependencies, 35 | directoryByPackageName 36 | ) 37 | : undefined, 38 | ...rest, 39 | }; 40 | } 41 | 42 | function pnpmMapDependenciesLinks( 43 | importerPath: string, 44 | def: ResolvedDependencies, 45 | directoryByPackageName: { [packageName: string]: string } 46 | ): ResolvedDependencies { 47 | return mapValues(def, (value, key) => { 48 | if (!value.startsWith("link:")) { 49 | return value; 50 | } 51 | 52 | // Replace backslashes with forward slashes to support Windows Git Bash 53 | const relativePath = path 54 | .relative(importerPath, directoryByPackageName[key]) 55 | .replace(path.sep, path.posix.sep); 56 | 57 | return relativePath.startsWith(".") 58 | ? `link:${relativePath}` 59 | : `link:./${relativePath}`; 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/lockfile/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./process-lockfile"; 2 | -------------------------------------------------------------------------------- /src/lib/lockfile/process-lockfile.ts: -------------------------------------------------------------------------------- 1 | import type { IsolateConfigResolved } from "../config"; 2 | import { useLogger } from "../logger"; 3 | import { usePackageManager } from "../package-manager"; 4 | import type { PackageManifest, PackagesRegistry } from "../types"; 5 | import { 6 | generateNpmLockfile, 7 | generatePnpmLockfile, 8 | generateYarnLockfile, 9 | } from "./helpers"; 10 | 11 | /** 12 | * Adapt the lockfile and write it to the isolate directory. Because we keep the 13 | * structure of packages in the isolate directory the same as they were in the 14 | * monorepo, the lockfile is largely still correct. The only things that need to 15 | * be done is to remove the root dependencies and devDependencies, and rename 16 | * the path to the target package to act as the new root. 17 | */ 18 | export async function processLockfile({ 19 | workspaceRootDir, 20 | packagesRegistry, 21 | isolateDir, 22 | internalDepPackageNames, 23 | targetPackageDir, 24 | targetPackageManifest, 25 | config, 26 | }: { 27 | workspaceRootDir: string; 28 | packagesRegistry: PackagesRegistry; 29 | isolateDir: string; 30 | internalDepPackageNames: string[]; 31 | targetPackageDir: string; 32 | targetPackageName: string; 33 | targetPackageManifest: PackageManifest; 34 | config: IsolateConfigResolved; 35 | }) { 36 | const log = useLogger(); 37 | 38 | if (config.forceNpm) { 39 | log.debug("Forcing to use NPM for isolate output"); 40 | 41 | await generateNpmLockfile({ 42 | workspaceRootDir, 43 | isolateDir, 44 | }); 45 | 46 | return true; 47 | } 48 | 49 | const { name, majorVersion } = usePackageManager(); 50 | let usedFallbackToNpm = false; 51 | 52 | switch (name) { 53 | case "npm": { 54 | await generateNpmLockfile({ 55 | workspaceRootDir, 56 | isolateDir, 57 | }); 58 | 59 | break; 60 | } 61 | case "yarn": { 62 | if (majorVersion === 1) { 63 | await generateYarnLockfile({ 64 | workspaceRootDir, 65 | isolateDir, 66 | }); 67 | } else { 68 | log.warn( 69 | "Detected modern version of Yarn. Using NPM lockfile fallback." 70 | ); 71 | 72 | await generateNpmLockfile({ 73 | workspaceRootDir, 74 | isolateDir, 75 | }); 76 | 77 | usedFallbackToNpm = true; 78 | } 79 | 80 | break; 81 | } 82 | case "pnpm": { 83 | await generatePnpmLockfile({ 84 | workspaceRootDir, 85 | targetPackageDir, 86 | isolateDir, 87 | internalDepPackageNames, 88 | packagesRegistry, 89 | targetPackageManifest, 90 | majorVersion, 91 | includeDevDependencies: config.includeDevDependencies, 92 | includePatchedDependencies: config.includePatchedDependencies, 93 | }); 94 | break; 95 | } 96 | case "bun": { 97 | log.warn( 98 | `Ouput lockfiles for Bun are not yet supported. Using NPM for output` 99 | ); 100 | await generateNpmLockfile({ 101 | workspaceRootDir, 102 | isolateDir, 103 | }); 104 | 105 | usedFallbackToNpm = true; 106 | break; 107 | } 108 | default: 109 | log.warn(`Unexpected package manager ${name}. Using NPM for output`); 110 | await generateNpmLockfile({ 111 | workspaceRootDir, 112 | isolateDir, 113 | }); 114 | 115 | usedFallbackToNpm = true; 116 | } 117 | 118 | return usedFallbackToNpm; 119 | } 120 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import type { IsolateConfigResolved, LogLevel } from "./config"; 3 | /** 4 | * The Logger defines an interface that can be used to pass in a different 5 | * logger object in order to intercept all the logging output. We keep the 6 | * handlers separate from the logger object itself, so that we can change the 7 | * handlers but do not bother the user with having to handle logLevel. 8 | */ 9 | export type Logger = { 10 | debug(...args: unknown[]): void; 11 | info(...args: unknown[]): void; 12 | warn(...args: unknown[]): void; 13 | error(...args: unknown[]): void; 14 | }; 15 | 16 | let _loggerHandlers: Logger = { 17 | debug(...args: unknown[]) { 18 | console.log(chalk.blue("debug"), ...args); 19 | }, 20 | info(...args: unknown[]) { 21 | console.log(chalk.green("info"), ...args); 22 | }, 23 | warn(...args: unknown[]) { 24 | console.log(chalk.yellow("warning"), ...args); 25 | }, 26 | error(...args: unknown[]) { 27 | console.log(chalk.red("error"), ...args); 28 | }, 29 | }; 30 | 31 | const _logger: Logger = { 32 | debug(...args: unknown[]) { 33 | if (_logLevel === "debug") { 34 | _loggerHandlers.debug(...args); 35 | } 36 | }, 37 | info(...args: unknown[]) { 38 | if (_logLevel === "debug" || _logLevel === "info") { 39 | _loggerHandlers.info(...args); 40 | } 41 | }, 42 | warn(...args: unknown[]) { 43 | if (_logLevel === "debug" || _logLevel === "info" || _logLevel === "warn") { 44 | _loggerHandlers.warn(...args); 45 | } 46 | }, 47 | error(...args: unknown[]) { 48 | _loggerHandlers.error(...args); 49 | }, 50 | }; 51 | 52 | let _logLevel: LogLevel = "info"; 53 | 54 | export function setLogger(logger: Logger) { 55 | _loggerHandlers = logger; 56 | return _logger; 57 | } 58 | 59 | export function setLogLevel( 60 | logLevel: IsolateConfigResolved["logLevel"] 61 | ): Logger { 62 | _logLevel = logLevel; 63 | return _logger; 64 | } 65 | 66 | export function useLogger() { 67 | return _logger; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/manifest/adapt-target-package-manifest.ts: -------------------------------------------------------------------------------- 1 | import type { PackageScripts } from "@pnpm/types"; 2 | import { omit, pick } from "remeda"; 3 | import type { IsolateConfigResolved } from "../config"; 4 | import { usePackageManager } from "../package-manager"; 5 | import type { PackageManifest, PackagesRegistry } from "../types"; 6 | import { adaptManifestInternalDeps, adoptPnpmFieldsFromRoot } from "./helpers"; 7 | 8 | /** 9 | * Adapt the output package manifest, so that: 10 | * 11 | * - Its internal dependencies point to the isolated ./packages/* directory. 12 | * - The devDependencies are possibly removed 13 | * - Scripts are picked or omitted and otherwise removed 14 | */ 15 | export async function adaptTargetPackageManifest({ 16 | manifest, 17 | packagesRegistry, 18 | workspaceRootDir, 19 | config, 20 | }: { 21 | manifest: PackageManifest; 22 | packagesRegistry: PackagesRegistry; 23 | workspaceRootDir: string; 24 | config: IsolateConfigResolved; 25 | }): Promise { 26 | const packageManager = usePackageManager(); 27 | const { 28 | includeDevDependencies, 29 | pickFromScripts, 30 | omitFromScripts, 31 | omitPackageManager, 32 | forceNpm, 33 | } = config; 34 | 35 | /** Dev dependencies are omitted by default */ 36 | const inputManifest = includeDevDependencies 37 | ? manifest 38 | : omit(manifest, ["devDependencies"]); 39 | 40 | const adaptedManifest = 41 | packageManager.name === "pnpm" && !forceNpm 42 | ? /** 43 | * For PNPM the output itself is a workspace so we can preserve the specifiers 44 | * with "workspace:*" in the output manifest, but we do want to adopt the 45 | * pnpm.overrides field from the root package.json. 46 | */ 47 | await adoptPnpmFieldsFromRoot(inputManifest, workspaceRootDir) 48 | : /** For other package managers we replace the links to internal dependencies */ 49 | adaptManifestInternalDeps({ 50 | manifest: inputManifest, 51 | packagesRegistry, 52 | }); 53 | 54 | return { 55 | ...adaptedManifest, 56 | /** 57 | * Adopt the package manager definition from the root manifest if available. 58 | * The option to omit is there because some platforms might not handle it 59 | * properly (Cloud Run, April 24th 2024, does not handle pnpm v9) 60 | */ 61 | packageManager: omitPackageManager 62 | ? undefined 63 | : packageManager.packageManagerString, 64 | /** 65 | * Scripts are removed by default if not explicitly picked or omitted via 66 | * config. 67 | */ 68 | scripts: pickFromScripts 69 | ? (pick(manifest.scripts ?? {}, pickFromScripts) as PackageScripts) 70 | : omitFromScripts 71 | ? (omit(manifest.scripts ?? {}, omitFromScripts) as PackageScripts) 72 | : {}, 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/lib/manifest/helpers/adapt-internal-package-manifests.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { omit } from "remeda"; 3 | import { usePackageManager } from "~/lib/package-manager"; 4 | import type { PackagesRegistry } from "~/lib/types"; 5 | import { writeManifest } from "../io"; 6 | import { adaptManifestInternalDeps } from "./adapt-manifest-internal-deps"; 7 | 8 | /** 9 | * Adapt the manifest files of all the isolated internal packages (excluding the 10 | * target package), so that their dependencies point to the other isolated 11 | * packages in the same folder. 12 | */ 13 | export async function adaptInternalPackageManifests({ 14 | internalPackageNames, 15 | packagesRegistry, 16 | isolateDir, 17 | forceNpm, 18 | }: { 19 | internalPackageNames: string[]; 20 | packagesRegistry: PackagesRegistry; 21 | isolateDir: string; 22 | forceNpm: boolean; 23 | }) { 24 | const packageManager = usePackageManager(); 25 | 26 | await Promise.all( 27 | internalPackageNames.map(async (packageName) => { 28 | const { manifest, rootRelativeDir } = packagesRegistry[packageName]; 29 | 30 | /** Dev dependencies and scripts are never included for internal deps */ 31 | const strippedManifest = omit(manifest, ["scripts", "devDependencies"]); 32 | 33 | const outputManifest = 34 | packageManager.name === "pnpm" && !forceNpm 35 | ? /** 36 | * For PNPM the output itself is a workspace so we can preserve the specifiers 37 | * with "workspace:*" in the output manifest. 38 | */ 39 | strippedManifest 40 | : /** For other package managers we replace the links to internal dependencies */ 41 | adaptManifestInternalDeps({ 42 | manifest: strippedManifest, 43 | packagesRegistry, 44 | parentRootRelativeDir: rootRelativeDir, 45 | }); 46 | 47 | await writeManifest( 48 | path.join(isolateDir, rootRelativeDir), 49 | outputManifest 50 | ); 51 | }) 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/manifest/helpers/adapt-manifest-internal-deps.ts: -------------------------------------------------------------------------------- 1 | import type { PackageManifest, PackagesRegistry } from "~/lib/types"; 2 | import { patchInternalEntries } from "./patch-internal-entries"; 3 | 4 | /** 5 | * Replace the workspace version specifiers for internal dependency with file: 6 | * paths. Not needed for PNPM (because we configure the isolated output as a 7 | * workspace), but maybe still for NPM and Yarn. 8 | */ 9 | export function adaptManifestInternalDeps({ 10 | manifest, 11 | packagesRegistry, 12 | parentRootRelativeDir, 13 | }: { 14 | manifest: PackageManifest; 15 | packagesRegistry: PackagesRegistry; 16 | parentRootRelativeDir?: string; 17 | }): PackageManifest { 18 | const { dependencies, devDependencies } = manifest; 19 | 20 | return { 21 | ...manifest, 22 | dependencies: dependencies 23 | ? patchInternalEntries( 24 | dependencies, 25 | packagesRegistry, 26 | parentRootRelativeDir 27 | ) 28 | : undefined, 29 | devDependencies: devDependencies 30 | ? patchInternalEntries( 31 | devDependencies, 32 | packagesRegistry, 33 | parentRootRelativeDir 34 | ) 35 | : undefined, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectManifest } from "@pnpm/types"; 2 | import path from "path"; 3 | import type { PackageManifest } from "~/lib/types"; 4 | import { isRushWorkspace, readTypedJson } from "~/lib/utils"; 5 | 6 | /** 7 | * Adopts the `pnpm` fields from the root package manifest. Currently it only 8 | * takes overrides, because I don't know if any of the others are useful or 9 | * desired. 10 | */ 11 | export async function adoptPnpmFieldsFromRoot( 12 | targetPackageManifest: PackageManifest, 13 | workspaceRootDir: string 14 | ) { 15 | if (isRushWorkspace(workspaceRootDir)) { 16 | return targetPackageManifest; 17 | } 18 | 19 | const rootPackageManifest = await readTypedJson( 20 | path.join(workspaceRootDir, "package.json") 21 | ); 22 | 23 | const overrides = rootPackageManifest.pnpm?.overrides; 24 | 25 | if (!overrides) { 26 | return targetPackageManifest; 27 | } 28 | 29 | return { 30 | ...targetPackageManifest, 31 | pnpm: { 32 | overrides, 33 | }, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/manifest/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./adapt-internal-package-manifests"; 2 | export * from "./adapt-manifest-internal-deps"; 3 | export * from "./adopt-pnpm-fields-from-root"; 4 | export * from "./patch-internal-entries"; 5 | -------------------------------------------------------------------------------- /src/lib/manifest/helpers/patch-internal-entries.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { useLogger } from "../../logger"; 3 | import type { PackagesRegistry } from "../../types"; 4 | 5 | export function patchInternalEntries( 6 | dependencies: Record, 7 | packagesRegistry: PackagesRegistry, 8 | parentRootRelativeDir?: string 9 | ) { 10 | const log = useLogger(); 11 | const allWorkspacePackageNames = Object.keys(packagesRegistry); 12 | 13 | return Object.fromEntries( 14 | Object.entries(dependencies).map(([key, value]) => { 15 | if (allWorkspacePackageNames.includes(key)) { 16 | const def = packagesRegistry[key]; 17 | 18 | /** 19 | * When nested internal dependencies are used (internal packages linking 20 | * to other internal packages), the parentRootRelativeDir will be passed 21 | * in, and we store the relative path to the isolate/packages 22 | * directory. 23 | * 24 | * For consistency we also write the other file paths starting with ./, 25 | * but it doesn't seem to be necessary for any package manager. 26 | */ 27 | const relativePath = parentRootRelativeDir 28 | ? path.relative(parentRootRelativeDir, `./${def.rootRelativeDir}`) 29 | : `./${def.rootRelativeDir}`; 30 | 31 | const linkPath = `file:${relativePath}`; 32 | 33 | log.debug(`Linking dependency ${key} to ${linkPath}`); 34 | 35 | return [key, linkPath]; 36 | } else { 37 | return [key, value]; 38 | } 39 | }) 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/manifest/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./adapt-target-package-manifest"; 2 | export * from "./helpers"; 3 | export * from "./io"; 4 | -------------------------------------------------------------------------------- /src/lib/manifest/io.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "node:path"; 3 | import type { PackageManifest } from "../types"; 4 | import { readTypedJson } from "../utils"; 5 | 6 | export async function readManifest(packageDir: string) { 7 | return readTypedJson(path.join(packageDir, "package.json")); 8 | } 9 | 10 | export async function writeManifest( 11 | outputDir: string, 12 | manifest: PackageManifest 13 | ) { 14 | await fs.writeFile( 15 | path.join(outputDir, "package.json"), 16 | JSON.stringify(manifest, null, 2) 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/output/get-build-output-dir.ts: -------------------------------------------------------------------------------- 1 | import { getTsconfig } from "get-tsconfig"; 2 | import path from "node:path"; 3 | import outdent from "outdent"; 4 | import { useLogger } from "../logger"; 5 | 6 | export async function getBuildOutputDir({ 7 | targetPackageDir, 8 | buildDirName, 9 | tsconfigPath, 10 | }: { 11 | targetPackageDir: string; 12 | buildDirName?: string; 13 | tsconfigPath: string; 14 | }) { 15 | const log = useLogger(); 16 | 17 | if (buildDirName) { 18 | log.debug("Using buildDirName from config:", buildDirName); 19 | return path.join(targetPackageDir, buildDirName); 20 | } 21 | 22 | const fullTsconfigPath = path.join(targetPackageDir, tsconfigPath); 23 | 24 | const tsconfig = getTsconfig(fullTsconfigPath); 25 | 26 | if (tsconfig) { 27 | log.debug("Found tsconfig at:", tsconfig.path); 28 | 29 | const outDir = tsconfig.config.compilerOptions?.outDir; 30 | 31 | if (outDir) { 32 | return path.join(targetPackageDir, outDir); 33 | } else { 34 | throw new Error(outdent` 35 | Failed to find outDir in tsconfig. If you are executing isolate from the root of a monorepo you should specify the buildDirName in isolate.config.json. 36 | `); 37 | } 38 | } else { 39 | log.warn("Failed to find tsconfig at:", fullTsconfigPath); 40 | 41 | throw new Error(outdent` 42 | Failed to infer the build output directory from either the isolate config buildDirName or a Typescript config file. See the documentation on how to configure one of these options. 43 | `); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/output/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./get-build-output-dir"; 2 | export * from "./pack-dependencies"; 3 | export * from "./process-build-output-files"; 4 | export * from "./unpack-dependencies"; 5 | -------------------------------------------------------------------------------- /src/lib/output/pack-dependencies.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { useLogger } from "../logger"; 3 | import type { PackagesRegistry } from "../types"; 4 | import { pack } from "../utils"; 5 | 6 | /** 7 | * Pack dependencies so that we extract only the files that are supposed to be 8 | * published by the packages. 9 | * 10 | * @returns A map of package names to the path of the packed file 11 | */ 12 | export async function packDependencies({ 13 | /** All packages found in the monorepo by workspaces declaration */ 14 | packagesRegistry, 15 | /** The dependencies that appear to be internal packages */ 16 | internalPackageNames, 17 | /** 18 | * The directory where the isolated package and all its dependencies will end 19 | * up. This is also the directory from where the package will be deployed. By 20 | * default it is a subfolder in targetPackageDir called "isolate" but you can 21 | * configure it. 22 | */ 23 | packDestinationDir, 24 | }: { 25 | packagesRegistry: PackagesRegistry; 26 | internalPackageNames: string[]; 27 | packDestinationDir: string; 28 | }) { 29 | const log = useLogger(); 30 | 31 | const packedFileByName: Record = {}; 32 | 33 | for (const dependency of internalPackageNames) { 34 | const def = packagesRegistry[dependency]; 35 | 36 | assert(dependency, `Failed to find package definition for ${dependency}`); 37 | 38 | const { name } = def.manifest; 39 | 40 | /** 41 | * If this dependency has already been packed, we skip it. It could happen 42 | * because we are packing workspace dependencies recursively. 43 | */ 44 | if (packedFileByName[name]) { 45 | log.debug(`Skipping ${name} because it has already been packed`); 46 | continue; 47 | } 48 | 49 | packedFileByName[name] = await pack(def.absoluteDir, packDestinationDir); 50 | } 51 | 52 | return packedFileByName; 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/output/process-build-output-files.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "node:path"; 3 | import { useLogger } from "../logger"; 4 | import { pack, unpack } from "../utils"; 5 | 6 | const TIMEOUT_MS = 5000; 7 | 8 | export async function processBuildOutputFiles({ 9 | targetPackageDir, 10 | tmpDir, 11 | isolateDir, 12 | }: { 13 | targetPackageDir: string; 14 | tmpDir: string; 15 | isolateDir: string; 16 | }) { 17 | const log = useLogger(); 18 | 19 | const packedFilePath = await pack(targetPackageDir, tmpDir); 20 | const unpackDir = path.join(tmpDir, "target"); 21 | 22 | const now = Date.now(); 23 | let isWaitingYet = false; 24 | 25 | while (!fs.existsSync(packedFilePath) && Date.now() - now < TIMEOUT_MS) { 26 | if (!isWaitingYet) { 27 | log.debug(`Waiting for ${packedFilePath} to become available...`); 28 | } 29 | isWaitingYet = true; 30 | await new Promise((resolve) => setTimeout(resolve, 100)); 31 | } 32 | 33 | await unpack(packedFilePath, unpackDir); 34 | await fs.copy(path.join(unpackDir, "package"), isolateDir); 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/output/unpack-dependencies.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path, { join } from "node:path"; 3 | import { useLogger } from "../logger"; 4 | import type { PackagesRegistry } from "../types"; 5 | import { getIsolateRelativeLogPath, unpack } from "../utils"; 6 | 7 | export async function unpackDependencies( 8 | packedFilesByName: Record, 9 | packagesRegistry: PackagesRegistry, 10 | tmpDir: string, 11 | isolateDir: string 12 | ) { 13 | const log = useLogger(); 14 | 15 | await Promise.all( 16 | Object.entries(packedFilesByName).map(async ([packageName, filePath]) => { 17 | const dir = packagesRegistry[packageName].rootRelativeDir; 18 | const unpackDir = join(tmpDir, dir); 19 | 20 | log.debug("Unpacking", `(temp)/${path.basename(filePath)}`); 21 | 22 | await unpack(filePath, unpackDir); 23 | 24 | const destinationDir = join(isolateDir, dir); 25 | 26 | await fs.ensureDir(destinationDir); 27 | 28 | await fs.move(join(unpackDir, "package"), destinationDir, { 29 | overwrite: true, 30 | }); 31 | 32 | log.debug( 33 | `Moved package files to ${getIsolateRelativeLogPath( 34 | destinationDir, 35 | isolateDir 36 | )}` 37 | ); 38 | }) 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/package-manager/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./infer-from-files"; 2 | export * from "./infer-from-manifest"; 3 | -------------------------------------------------------------------------------- /src/lib/package-manager/helpers/infer-from-files.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { execSync } from "node:child_process"; 3 | import path from "node:path"; 4 | import { getErrorMessage } from "~/lib/utils"; 5 | import { getMajorVersion } from "~/lib/utils/get-major-version"; 6 | import type { PackageManager, PackageManagerName } from "../names"; 7 | import { getLockfileFileName, supportedPackageManagerNames } from "../names"; 8 | 9 | export function inferFromFiles(workspaceRoot: string): PackageManager { 10 | for (const name of supportedPackageManagerNames) { 11 | const lockfileName = getLockfileFileName(name); 12 | 13 | if (fs.existsSync(path.join(workspaceRoot, lockfileName))) { 14 | try { 15 | const version = getVersion(name); 16 | 17 | return { name, version, majorVersion: getMajorVersion(version) }; 18 | } catch (err) { 19 | throw new Error( 20 | `Failed to find package manager version for ${name}: ${getErrorMessage(err)}` 21 | ); 22 | } 23 | } 24 | } 25 | 26 | /** If no lockfile was found, it could be that there is an npm shrinkwrap file. */ 27 | if (fs.existsSync(path.join(workspaceRoot, "npm-shrinkwrap.json"))) { 28 | const version = getVersion("npm"); 29 | 30 | return { name: "npm", version, majorVersion: getMajorVersion(version) }; 31 | } 32 | 33 | throw new Error(`Failed to detect package manager`); 34 | } 35 | 36 | export function getVersion(packageManagerName: PackageManagerName): string { 37 | const buffer = execSync(`${packageManagerName} --version`); 38 | return buffer.toString().trim(); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/package-manager/helpers/infer-from-manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import assert from "node:assert"; 3 | import path from "node:path"; 4 | import { useLogger } from "~/lib/logger"; 5 | import { getMajorVersion } from "~/lib/utils/get-major-version"; 6 | import type { PackageManifest } from "../../types"; 7 | import { readTypedJsonSync } from "../../utils"; 8 | import type { PackageManagerName } from "../names"; 9 | import { getLockfileFileName, supportedPackageManagerNames } from "../names"; 10 | 11 | export function inferFromManifest(workspaceRoot: string) { 12 | const log = useLogger(); 13 | 14 | const { packageManager: packageManagerString } = 15 | readTypedJsonSync( 16 | path.join(workspaceRoot, "package.json") 17 | ); 18 | 19 | if (!packageManagerString) { 20 | log.debug("No packageManager field found in root manifest"); 21 | return; 22 | } 23 | 24 | const [name, version = "*"] = packageManagerString.split("@") as [ 25 | PackageManagerName, 26 | string, 27 | ]; 28 | 29 | assert( 30 | supportedPackageManagerNames.includes(name), 31 | `Package manager "${name}" is not currently supported` 32 | ); 33 | 34 | const lockfileName = getLockfileFileName(name); 35 | 36 | assert( 37 | fs.existsSync(path.join(workspaceRoot, lockfileName)), 38 | `Manifest declares ${name} to be the packageManager, but failed to find ${lockfileName} in workspace root` 39 | ); 40 | 41 | return { 42 | name, 43 | version, 44 | majorVersion: getMajorVersion(version), 45 | packageManagerString, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/lib/package-manager/index.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { isRushWorkspace } from "../utils/is-rush-workspace"; 3 | import { inferFromFiles, inferFromManifest } from "./helpers"; 4 | import type { PackageManager } from "./names"; 5 | 6 | export * from "./names"; 7 | 8 | let packageManager: PackageManager | undefined; 9 | 10 | export function usePackageManager() { 11 | if (!packageManager) { 12 | throw Error( 13 | "No package manager detected. Make sure to call detectPackageManager() before usePackageManager()" 14 | ); 15 | } 16 | 17 | return packageManager; 18 | } 19 | 20 | /** 21 | * First we check if the package manager is declared in the manifest. If it is, 22 | * we get the name and version from there. Otherwise we'll search for the 23 | * different lockfiles and ask the OS to report the installed version. 24 | */ 25 | export function detectPackageManager(workspaceRootDir: string): PackageManager { 26 | if (isRushWorkspace(workspaceRootDir)) { 27 | packageManager = inferFromFiles( 28 | path.join(workspaceRootDir, "common/config/rush") 29 | ); 30 | } else { 31 | /** 32 | * Disable infer from manifest for now. I doubt it is useful after all but 33 | * I'll keep the code as a reminder. 34 | */ 35 | packageManager = 36 | inferFromManifest(workspaceRootDir) ?? inferFromFiles(workspaceRootDir); 37 | } 38 | 39 | return packageManager; 40 | } 41 | 42 | export function shouldUsePnpmPack() { 43 | const { name, majorVersion } = usePackageManager(); 44 | 45 | return name === "pnpm" && majorVersion >= 8; 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/package-manager/names.ts: -------------------------------------------------------------------------------- 1 | export const supportedPackageManagerNames = [ 2 | "pnpm", 3 | "yarn", 4 | "npm", 5 | "bun", 6 | ] as const; 7 | 8 | export type PackageManagerName = (typeof supportedPackageManagerNames)[number]; 9 | 10 | export type PackageManager = { 11 | name: PackageManagerName; 12 | version: string; 13 | majorVersion: number; 14 | packageManagerString?: string; 15 | }; 16 | 17 | export function getLockfileFileName(name: PackageManagerName) { 18 | switch (name) { 19 | case "bun": 20 | return "bun.lockb"; 21 | case "pnpm": 22 | return "pnpm-lock.yaml"; 23 | case "yarn": 24 | return "yarn.lock"; 25 | case "npm": 26 | return "package-lock.json"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/registry/create-packages-registry.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import { globSync } from "glob"; 3 | import path from "node:path"; 4 | import { useLogger } from "../logger"; 5 | import type { PackageManifest, PackagesRegistry } from "../types"; 6 | import { isRushWorkspace, readTypedJson, readTypedJsonSync } from "../utils"; 7 | import { findPackagesGlobs } from "./helpers"; 8 | 9 | /** 10 | * Build a list of all packages in the workspace, depending on the package 11 | * manager used, with a possible override from the config file. The list 12 | * contains the manifest with some directory info mapped by module name. 13 | */ 14 | export async function createPackagesRegistry( 15 | workspaceRootDir: string, 16 | workspacePackagesOverride: string[] | undefined 17 | ): Promise { 18 | const log = useLogger(); 19 | 20 | if (workspacePackagesOverride) { 21 | log.debug( 22 | `Override workspace packages via config: ${workspacePackagesOverride}` 23 | ); 24 | } 25 | 26 | const allPackages = listWorkspacePackages( 27 | workspacePackagesOverride, 28 | workspaceRootDir 29 | ); 30 | 31 | const registry: PackagesRegistry = ( 32 | await Promise.all( 33 | allPackages.map(async (rootRelativeDir) => { 34 | const absoluteDir = path.join(workspaceRootDir, rootRelativeDir); 35 | const manifestPath = path.join(absoluteDir, "package.json"); 36 | 37 | if (!fs.existsSync(manifestPath)) { 38 | log.warn( 39 | `Ignoring directory ${rootRelativeDir} because it does not contain a package.json file` 40 | ); 41 | return; 42 | } else { 43 | log.debug(`Registering package ${rootRelativeDir}`); 44 | 45 | const manifest = await readTypedJson( 46 | path.join(absoluteDir, "package.json") 47 | ); 48 | 49 | return { 50 | manifest, 51 | rootRelativeDir, 52 | absoluteDir, 53 | }; 54 | } 55 | }) 56 | ) 57 | ).reduce((acc, info) => { 58 | if (info) { 59 | acc[info.manifest.name] = info; 60 | } 61 | return acc; 62 | }, {}); 63 | 64 | return registry; 65 | } 66 | 67 | type RushConfig = { 68 | projects: { packageName: string; projectFolder: string }[]; 69 | }; 70 | 71 | function listWorkspacePackages( 72 | workspacePackagesOverride: string[] | undefined, 73 | workspaceRootDir: string 74 | ) { 75 | if (isRushWorkspace(workspaceRootDir)) { 76 | const rushConfig = readTypedJsonSync( 77 | path.join(workspaceRootDir, "rush.json") 78 | ); 79 | 80 | return rushConfig.projects.map(({ projectFolder }) => projectFolder); 81 | } else { 82 | const currentDir = process.cwd(); 83 | process.chdir(workspaceRootDir); 84 | 85 | const packagesGlobs = 86 | workspacePackagesOverride ?? findPackagesGlobs(workspaceRootDir); 87 | 88 | const allPackages = packagesGlobs 89 | .flatMap((glob) => globSync(glob)) 90 | /** Make sure to filter any loose files that might hang around. */ 91 | .filter((dir) => fs.lstatSync(dir).isDirectory()); 92 | 93 | process.chdir(currentDir); 94 | return allPackages; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/registry/helpers/find-packages-globs.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import path from "node:path"; 3 | import { useLogger } from "../../logger"; 4 | import { usePackageManager } from "../../package-manager"; 5 | import { 6 | inspectValue, 7 | readTypedJsonSync, 8 | readTypedYamlSync, 9 | } from "../../utils"; 10 | 11 | /** 12 | * Find the globs that define where the packages are located within the 13 | * monorepo. This configuration is dependent on the package manager used, and I 14 | * don't know if we're covering all cases yet... 15 | */ 16 | export function findPackagesGlobs(workspaceRootDir: string) { 17 | const log = useLogger(); 18 | 19 | const packageManager = usePackageManager(); 20 | 21 | switch (packageManager.name) { 22 | case "pnpm": { 23 | const { packages: globs } = readTypedYamlSync<{ packages: string[] }>( 24 | path.join(workspaceRootDir, "pnpm-workspace.yaml") 25 | ); 26 | 27 | log.debug("Detected pnpm packages globs:", inspectValue(globs)); 28 | return globs; 29 | } 30 | case "bun": 31 | case "yarn": 32 | case "npm": { 33 | const workspaceRootManifestPath = path.join( 34 | workspaceRootDir, 35 | "package.json" 36 | ); 37 | 38 | const { workspaces } = readTypedJsonSync<{ workspaces: string[] }>( 39 | workspaceRootManifestPath 40 | ); 41 | 42 | if (!workspaces) { 43 | throw new Error( 44 | `No workspaces field found in ${workspaceRootManifestPath}` 45 | ); 46 | } 47 | 48 | if (Array.isArray(workspaces)) { 49 | return workspaces; 50 | } else { 51 | /** 52 | * For Yarn, workspaces could be defined as an object with { packages: 53 | * [], nohoist: [] }. See 54 | * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/ 55 | */ 56 | const workspacesObject = workspaces as { packages?: string[] }; 57 | 58 | assert( 59 | workspacesObject.packages, 60 | "workspaces.packages must be an array" 61 | ); 62 | 63 | return workspacesObject.packages; 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/registry/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./find-packages-globs"; 2 | -------------------------------------------------------------------------------- /src/lib/registry/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./create-packages-registry"; 2 | export * from "./list-internal-packages"; 3 | -------------------------------------------------------------------------------- /src/lib/registry/list-internal-packages.ts: -------------------------------------------------------------------------------- 1 | import { unique } from "remeda"; 2 | import type { PackageManifest, PackagesRegistry } from "../types"; 3 | 4 | /** 5 | * Recursively list all the packages from dependencies (and optionally 6 | * devDependencies) that are found in the monorepo. 7 | * 8 | * Here we do not need to rely on packages being declared with "workspace:" in 9 | * the package manifest. We can simply compare the package names with the list 10 | * of packages that were found via the workspace glob patterns and add them to 11 | * the registry. 12 | */ 13 | export function listInternalPackages( 14 | manifest: PackageManifest, 15 | packagesRegistry: PackagesRegistry, 16 | { includeDevDependencies = false } = {} 17 | ): string[] { 18 | const allWorkspacePackageNames = Object.keys(packagesRegistry); 19 | 20 | const internalPackageNames = ( 21 | includeDevDependencies 22 | ? [ 23 | ...Object.keys(manifest.dependencies ?? {}), 24 | ...Object.keys(manifest.devDependencies ?? {}), 25 | ] 26 | : Object.keys(manifest.dependencies ?? {}) 27 | ).filter((name) => allWorkspacePackageNames.includes(name)); 28 | 29 | const nestedInternalPackageNames = internalPackageNames.flatMap( 30 | (packageName) => 31 | listInternalPackages( 32 | packagesRegistry[packageName].manifest, 33 | packagesRegistry, 34 | { includeDevDependencies } 35 | ) 36 | ); 37 | 38 | return unique(internalPackageNames.concat(nestedInternalPackageNames)); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { PackageManifest as PnpmPackageManifest } from "@pnpm/types"; 2 | 3 | export type PackageManifest = PnpmPackageManifest & { 4 | packageManager?: string; 5 | }; 6 | 7 | export type WorkspacePackageInfo = { 8 | absoluteDir: string; 9 | /** 10 | * The path of the package relative to the workspace root. This is the path 11 | * referenced in the lock file. 12 | */ 13 | rootRelativeDir: string; 14 | /** The package.json file contents */ 15 | manifest: PackageManifest; 16 | }; 17 | 18 | export type PackagesRegistry = Record; 19 | 20 | export type FirebaseFunctionsConfig = { 21 | source: string; 22 | runtime?: string; 23 | predeploy?: string[]; 24 | codebase?: string; 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/utils/filter-object-undefined.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { filterObjectUndefined } from "./filter-object-undefined"; 3 | 4 | describe("filterObjectUndefined", () => { 5 | it("should filter out undefined values", () => { 6 | expect( 7 | filterObjectUndefined({ 8 | a: "a", 9 | b: undefined, 10 | c: "c", 11 | }) 12 | ).toEqual({ 13 | a: "a", 14 | c: "c", 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/lib/utils/filter-object-undefined.ts: -------------------------------------------------------------------------------- 1 | export function filterObjectUndefined(object: Record) { 2 | return Object.fromEntries( 3 | Object.entries(object).filter(([_, value]) => value !== undefined) 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils/get-dirname.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from "url"; 2 | 3 | /** 4 | * Calling context should pass in import.meta.url and the function will return 5 | * the equivalent of __dirname in Node/CommonJs. 6 | */ 7 | export function getDirname(importMetaUrl: string) { 8 | return fileURLToPath(new URL(".", importMetaUrl)); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/utils/get-error-message.ts: -------------------------------------------------------------------------------- 1 | type ErrorWithMessage = { 2 | message: string; 3 | }; 4 | 5 | export function getErrorMessage(error: unknown) { 6 | return toErrorWithMessage(error).message; 7 | } 8 | 9 | function isErrorWithMessage(error: unknown): error is ErrorWithMessage { 10 | return typeof error === "object" && error !== null && "message" in error; 11 | } 12 | 13 | function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { 14 | if (isErrorWithMessage(maybeError)) return maybeError; 15 | 16 | try { 17 | return new Error(JSON.stringify(maybeError)); 18 | } catch { 19 | /** 20 | * Fallback in case there’s an error in stringify which can happen with 21 | * circular references. 22 | */ 23 | return new Error(String(maybeError)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/utils/get-major-version.ts: -------------------------------------------------------------------------------- 1 | export function getMajorVersion(version: string) { 2 | return parseInt(version.split(".")[0], 10); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./filter-object-undefined"; 2 | export * from "./get-dirname"; 3 | export * from "./get-error-message"; 4 | export * from "./inspect-value"; 5 | export * from "./is-present"; 6 | export * from "./is-rush-workspace"; 7 | export * from "./json"; 8 | export * from "./log-paths"; 9 | export * from "./pack"; 10 | export * from "./unpack"; 11 | export * from "./yaml"; 12 | -------------------------------------------------------------------------------- /src/lib/utils/inspect-value.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from "node:util"; 2 | 3 | export function inspectValue(value: unknown) { 4 | return inspect(value, false, 16, true); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/utils/is-present.ts: -------------------------------------------------------------------------------- 1 | /** Copied from ts-is-present */ 2 | export function isPresent(t: T | undefined | null | void): t is T { 3 | return t !== undefined && t !== null; 4 | } 5 | 6 | export function isDefined(t: T | undefined): t is T { 7 | return t !== undefined; 8 | } 9 | 10 | export function isFilled(t: T | null): t is T { 11 | return t !== null; 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/utils/is-rush-workspace.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | /** 5 | * Detect if this is a Rush monorepo. They use a very different structure so 6 | * there are multiple places where we need to make exceptions based on this. 7 | */ 8 | export function isRushWorkspace(workspaceRootDir: string) { 9 | return fs.existsSync(path.join(workspaceRootDir, "rush.json")); 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/utils/json.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import stripJsonComments from "strip-json-comments"; 3 | import { getErrorMessage } from "./get-error-message"; 4 | 5 | /** @todo Pass in zod schema and validate */ 6 | export function readTypedJsonSync(filePath: string) { 7 | try { 8 | const rawContent = fs.readFileSync(filePath, "utf-8"); 9 | const data = JSON.parse( 10 | stripJsonComments(rawContent, { trailingCommas: true }) 11 | ) as T; 12 | return data; 13 | } catch (err) { 14 | throw new Error( 15 | `Failed to read JSON from ${filePath}: ${getErrorMessage(err)}` 16 | ); 17 | } 18 | } 19 | 20 | export async function readTypedJson(filePath: string) { 21 | try { 22 | const rawContent = await fs.readFile(filePath, "utf-8"); 23 | const data = JSON.parse( 24 | stripJsonComments(rawContent, { trailingCommas: true }) 25 | ) as T; 26 | return data; 27 | } catch (err) { 28 | throw new Error( 29 | `Failed to read JSON from ${filePath}: ${getErrorMessage(err)}` 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/utils/log-paths.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path"; 2 | 3 | export function getRootRelativeLogPath(path: string, rootPath: string) { 4 | const strippedPath = path.replace(rootPath, ""); 5 | 6 | return join("(root)", strippedPath); 7 | } 8 | 9 | export function getIsolateRelativeLogPath(path: string, isolatePath: string) { 10 | const strippedPath = path.replace(isolatePath, ""); 11 | 12 | return join("(isolate)", strippedPath); 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils/pack.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import { exec } from "node:child_process"; 3 | import fs from "node:fs"; 4 | import path from "node:path"; 5 | import { useLogger } from "../logger"; 6 | import { shouldUsePnpmPack } from "../package-manager"; 7 | import { getErrorMessage } from "./get-error-message"; 8 | 9 | export async function pack(srcDir: string, dstDir: string) { 10 | const log = useLogger(); 11 | 12 | const execOptions = { 13 | maxBuffer: 10 * 1024 * 1024, 14 | }; 15 | 16 | const previousCwd = process.cwd(); 17 | process.chdir(srcDir); 18 | 19 | /** 20 | * PNPM pack seems to be a lot faster than NPM pack, so when PNPM is detected 21 | * we use that instead. 22 | */ 23 | const stdout = shouldUsePnpmPack() 24 | ? await new Promise((resolve, reject) => { 25 | exec( 26 | `pnpm pack --pack-destination "${dstDir}"`, 27 | execOptions, 28 | (err, stdout) => { 29 | if (err) { 30 | log.error(getErrorMessage(err)); 31 | return reject(err); 32 | } 33 | 34 | resolve(stdout); 35 | } 36 | ); 37 | }) 38 | : await new Promise((resolve, reject) => { 39 | exec( 40 | `npm pack --pack-destination "${dstDir}"`, 41 | execOptions, 42 | (err, stdout) => { 43 | if (err) { 44 | return reject(err); 45 | } 46 | 47 | resolve(stdout); 48 | } 49 | ); 50 | }); 51 | 52 | const lastLine = stdout.trim().split("\n").at(-1); 53 | 54 | assert(lastLine, `Failed to parse last line from stdout: ${stdout.trim()}`); 55 | 56 | const fileName = path.basename(lastLine); 57 | 58 | assert(fileName, `Failed to parse file name from: ${lastLine}`); 59 | 60 | const filePath = path.join(dstDir, fileName); 61 | 62 | if (!fs.existsSync(filePath)) { 63 | log.error( 64 | `The response from pack could not be resolved to an existing file: ${filePath}` 65 | ); 66 | } else { 67 | log.debug(`Packed (temp)/${fileName}`); 68 | } 69 | 70 | process.chdir(previousCwd); 71 | 72 | /** 73 | * Return the path anyway even if it doesn't validate. A later stage will wait 74 | * for the file to occur still. Not sure if this makes sense. Maybe we should 75 | * stop at the validation error... 76 | */ 77 | return filePath; 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/utils/unpack.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import tar from "tar-fs"; 3 | import { createGunzip } from "zlib"; 4 | 5 | export async function unpack(filePath: string, unpackDir: string) { 6 | await new Promise((resolve, reject) => { 7 | fs.createReadStream(filePath) 8 | .pipe(createGunzip()) 9 | .pipe(tar.extract(unpackDir)) 10 | .on("finish", () => resolve()) 11 | .on("error", (err) => reject(err)); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/utils/yaml.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import yaml from "yaml"; 3 | import { getErrorMessage } from "./get-error-message"; 4 | 5 | export function readTypedYamlSync(filePath: string) { 6 | try { 7 | const rawContent = fs.readFileSync(filePath, "utf-8"); 8 | const data = yaml.parse(rawContent); 9 | /** @todo Add some zod validation maybe */ 10 | return data as T; 11 | } catch (err) { 12 | throw new Error( 13 | `Failed to read YAML from ${filePath}: ${getErrorMessage(err)}` 14 | ); 15 | } 16 | } 17 | 18 | export function writeTypedYamlSync(filePath: string, content: T) { 19 | /** @todo Add some zod validation maybe */ 20 | fs.writeFileSync(filePath, yaml.stringify(content), "utf-8"); 21 | } 22 | -------------------------------------------------------------------------------- /src/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@npmcli/arborist"; 2 | 3 | declare module "@npmcli/config/lib/definitions/index.js"; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "strict": true, 7 | "isolatedModules": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "rootDir": "src", 12 | "outDir": "dist", 13 | "baseUrl": ".", 14 | "paths": { 15 | "~/*": ["src/*"] 16 | } 17 | }, 18 | "include": ["src"], 19 | "exclude": ["dist", "node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: { 5 | index: "src/index.ts", 6 | "isolate-bin": "src/isolate-bin.ts", 7 | }, 8 | format: ["esm"], 9 | target: "node18", 10 | sourcemap: true, 11 | splitting: false, 12 | dts: true, 13 | clean: true, 14 | // shims: true, // replaces use of import.meta 15 | /** 16 | * The `isolate` binary is an ES module. The file is required to have the 17 | * `.mjs` file extension, otherwise a non-ESM workspace will try to execute it 18 | * as commonJS. 19 | * 20 | * For details see [this article from Alex 21 | * Rauschmayer](https://exploringjs.com/nodejs-shell-scripting/ch_creating-shell-scripts.html 22 | */ 23 | outExtension() { 24 | return { 25 | js: `.mjs`, 26 | }; 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { configDefaults, defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | exclude: [...configDefaults.exclude], 7 | }, 8 | resolve: { 9 | alias: { 10 | "~": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | --------------------------------------------------------------------------------