diff --git a/packages/core/integration-tests/test/parcel-link.js b/packages/core/integration-tests/test/parcel-link.js new file mode 100644 index 00000000000..c348d201a50 --- /dev/null +++ b/packages/core/integration-tests/test/parcel-link.js @@ -0,0 +1,555 @@ +// @flow strict-local + +import type {ProgramOptions} from '@parcel/link'; + +import {createProgram as _createProgram} from '@parcel/link'; +import {overlayFS, fsFixture} from '@parcel/test-utils'; + +import assert from 'assert'; +import path from 'path'; +import sinon from 'sinon'; + +function createProgram(opts: ProgramOptions) { + let program = _createProgram(opts).exitOverride(); + + function cli(command: string = ''): Promise { + return program.parseAsync(command.split(/\s+/), {from: 'user'}); + } + + return cli; +} + +describe('@parcel/link', () => { + let _cwd; + let _stdout; + + beforeEach(async function () { + await overlayFS.mkdirp('/app'); + overlayFS.chdir('/app'); + + // $FlowFixMe[incompatible-call] + _cwd = sinon.stub(process, 'cwd').callsFake(() => overlayFS.cwd()); + _stdout = sinon.stub(process.stdout, 'write'); + }); + + afterEach(function () { + _cwd?.restore(); + _stdout?.restore(); + _cwd = null; + _stdout = null; + }); + + it('prints help text', async () => { + let cli = createProgram({fs: overlayFS}); + await assert.throws(() => cli('--help'), /\(outputHelp\)/); + }); + + it('links by default', async () => { + let link = sinon.stub(); + let cli = createProgram({fs: overlayFS, link}); + await cli(); + assert(link.called); + }); + + describe('link', () => { + it('errors for invalid app root', async () => { + let cli = createProgram({fs: overlayFS}); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('link'), /Not a project root/); + }); + + it('errors for invalid package root', async () => { + await fsFixture(overlayFS)`yarn.lock:`; + + assert(overlayFS.existsSync('/app/yarn.lock')); + + let cli = createProgram({fs: overlayFS}); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('link /fake'), /Not a package root/); + }); + + it('errors when a link exists', async () => { + await fsFixture(overlayFS)`yarn.lock:`; + + let cli = createProgram({fs: overlayFS}); + await cli(`link`); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('link'), /link already exists/); + }); + + it('links with the default options', async () => { + await fsFixture(overlayFS)` + yarn.lock: + node_modules + parcel + @parcel/core`; + + let cli = createProgram({fs: overlayFS}); + await cli('link'); + + assert(overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve(__dirname, '../../core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve(__dirname, '../../parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(__dirname, '../../parcel/src/bin.js'), + ); + }); + + it('links from a custom package root', async () => { + await fsFixture(overlayFS, '/')` + app + yarn.lock: + node_modules + parcel + @parcel/core + package-root + core + core/package.json: ${{name: '@parcel/core'}} + parcel + package.json: ${{name: 'parcel'}} + src/bin.js:`; + + overlayFS.chdir('/app'); + + let cli = createProgram({fs: overlayFS}); + await cli(`link ../package-root`); + + assert(overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve(overlayFS.cwd(), '../package-root/core/core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve(overlayFS.cwd(), '../package-root/core/parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(overlayFS.cwd(), '../package-root/core/parcel/src/bin.js'), + ); + }); + + it('links with a custom namespace', async () => { + await fsFixture(overlayFS)` + yarn.lock: + node_modules + .bin/parcel: + @namespace + parcel + parcel-core`; + + let cli = createProgram({fs: overlayFS}); + await cli('link --namespace @namespace'); + + assert(overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(__dirname, '../../parcel/src/bin.js'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/@namespace/parcel-core'), + path.resolve(__dirname, '../../core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve(__dirname, '../../core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/@namespace/parcel'), + path.resolve(__dirname, '../../parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve(__dirname, '../../parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(__dirname, '../../parcel/src/bin.js'), + ); + }); + + // FIXME: this test fails on windows + it.skip('updates config for custom namespace', async () => { + await fsFixture(overlayFS, '/')` + ${path.resolve( + path.join(__dirname, '../../../configs/namespace/package.json'), + )}: ${{ + name: '@parcel/config-namespace', + }} + app + yarn.lock: + .parcelrc: ${{ + extends: '@namespace/parcel-config-namespace', + transformers: { + '*': [ + '@namespace/parcel-transformer-js', + '@namespace/parcel-transformer-local', + ], + }, + }} + package.json: ${{ + ['@namespace/parcel-transformer-js']: {}, + ['@namespace/parcel-transformer-local']: {}, + }}`; + + overlayFS.chdir('/app'); + + let cli = createProgram({fs: overlayFS}); + await cli('link --namespace @namespace'); + + assert(overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.readFileSync('.parcelrc', 'utf8'), + JSON.stringify({ + extends: '@parcel/config-namespace', + transformers: { + '*': [ + '@parcel/transformer-js', + '@namespace/parcel-transformer-local', + ], + }, + }), + ); + + assert.equal( + overlayFS.readFileSync('package.json', 'utf8'), + JSON.stringify({ + ['@parcel/transformer-js']: {}, + ['@namespace/parcel-transformer-local']: {}, + }), + ); + }); + + it('links with custom node modules glob', async () => { + await fsFixture(overlayFS)` + yarn.lock: + tools + test/node_modules/parcel + test2/node_modules/@parcel/core`; + + let cli = createProgram({fs: overlayFS}); + await cli('link --node-modules-glob "tools/*/node_modules"'); + + assert(overlayFS.existsSync('.parcel-link')); + + assert(overlayFS.existsSync('tools/test/node_modules')); + assert(!overlayFS.existsSync('tools/test/node_modules/parcel')); + + assert(overlayFS.existsSync('tools/test2/node_modules')); + assert(!overlayFS.existsSync('tools/test2/node_modules/@parcel/core')); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve(__dirname, '../../parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(__dirname, '../../parcel/src/bin.js'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve(__dirname, '../../core'), + ); + }); + + it('does not do anything with dry run', async () => { + await fsFixture(overlayFS)` + yarn.lock: + node_modules + parcel + @parcel/core`; + + let cli = createProgram({fs: overlayFS}); + await cli('link --dry-run'); + + assert(!overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve('/app/node_modules/@parcel/core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve('/app/node_modules/parcel'), + ); + + assert(!overlayFS.existsSync('node_modules/.bin/parcel')); + }); + }); + + describe('unlink', () => { + it('errors without a link config', async () => { + await fsFixture(overlayFS)`yarn.lock:`; + + let cli = createProgram({fs: overlayFS}); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('unlink'), /link could not be found/); + }); + + it('errors for invalid app root', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcel-link: ${{ + appRoot: '/app2', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules'], + namespace: '@parcel', + }}`; + + let cli = createProgram({fs: overlayFS}); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('unlink'), /Not a project root/); + }); + + it('errors for invalid package root', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..') + '2', + nodeModulesGlobs: ['node_modules'], + namespace: '@parcel', + }}`; + + let cli = createProgram({fs: overlayFS}); + + // $FlowFixMe[prop-missing] + await assert.rejects(() => cli('unlink'), /Not a package root/); + }); + + it('unlinks with the default options', async () => { + await fsFixture(overlayFS)` + yarn.lock: + node_modules + .bin/parcel -> ${path.resolve(__dirname, '../../parcel/src/bin.js')} + parcel -> ${path.resolve(__dirname, '../../parcel')} + @parcel/core -> ${path.resolve(__dirname, '../../core')} + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules'], + namespace: '@parcel', + }}`; + + assert(overlayFS.existsSync('.parcel-link')); + assert(overlayFS.existsSync('node_modules/@parcel/core')); + assert(overlayFS.existsSync('node_modules/parcel')); + assert(overlayFS.existsSync('node_modules/.bin/parcel')); + + let cli = createProgram({fs: overlayFS}); + await cli('unlink'); + + assert(!overlayFS.existsSync('.parcel-link')); + assert(!overlayFS.existsSync('node_modules/@parcel/core')); + assert(!overlayFS.existsSync('node_modules/parcel')); + assert(!overlayFS.existsSync('node_modules/.bin/parcel')); + }); + + it('unlinks from a custom package root', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcel-link: ${{ + appRoot: '/app', + packageRoot: '/package-root', + nodeModulesGlobs: ['node_modules'], + namespace: '@parcel', + }} + node_modules/parcel -> package-root/core/parcel + node_modules/@parcel/core -> package-root/core/core + node_modules/.bin/parcel -> package-root/core/parcel/src/bin.js`; + + await fsFixture(overlayFS, '/')` + package-root/core/core/package.json: ${{name: '@parcel/core'}} + package-root/core/parcel/package.json: ${{name: 'parcel'}} + package-root/core/parcel/src/bin.js:`; + + let cli = createProgram({fs: overlayFS}); + await cli('unlink'); + + assert(!overlayFS.existsSync('.parcel-link')); + assert(!overlayFS.existsSync('node_modules/@parcel/core')); + assert(!overlayFS.existsSync('node_modules/parcel')); + assert(!overlayFS.existsSync('node_modules/.bin/parcel')); + }); + + it('unlinks with a custom namespace', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules'], + namespace: '@namespace', + }} + node_modules + .bin/parcel -> ${path.resolve(__dirname, '../../parcel/src/bin.js')} + parcel -> ${path.resolve(__dirname, '../../parcel')} + @namespace/parcel -> ${path.resolve(__dirname, '../../parcel')} + parcel/core -> ${path.resolve(__dirname, '../../core')} + @namespace/parcel-core -> ${path.resolve(__dirname, '../../core')}`; + + let cli = createProgram({fs: overlayFS}); + await cli('unlink'); + + assert(!overlayFS.existsSync('.parcel-link')); + assert(!overlayFS.existsSync('node_modules/@parcel/core')); + assert(!overlayFS.existsSync('node_modules/parcel')); + assert(!overlayFS.existsSync('node_modules/.bin/parcel')); + assert(!overlayFS.existsSync('node_modules/@namespace/parcel-core')); + assert(!overlayFS.existsSync('node_modules/@namespace/parcel')); + }); + + // FIXME: this test fails on windows + it.skip('updates config for custom namespace', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcelrc: ${{ + extends: '@parcel/config-namespace', + transformers: { + '*': [ + '@parcel/transformer-js', + '@namespace/parcel-transformer-local', + ], + }, + }} + package.json: ${{ + ['@parcel/transformer-js']: {}, + ['@namespace/parcel-transformer-local']: {}, + }} + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules'], + namespace: '@namespace', + }}`; + + await fsFixture(overlayFS, '/')` + ${path.resolve( + path.join(__dirname, '../../../configs/namespace/package.json'), + )}: ${{ + name: '@parcel/config-namespace', + }}`; + + let cli = createProgram({fs: overlayFS}); + await cli('unlink'); + + assert(!overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.readFileSync('.parcelrc', 'utf8'), + JSON.stringify({ + extends: '@namespace/parcel-config-namespace', + transformers: { + '*': [ + '@namespace/parcel-transformer-js', + '@namespace/parcel-transformer-local', + ], + }, + }), + ); + + assert.equal( + overlayFS.readFileSync('package.json', 'utf8'), + JSON.stringify({ + ['@namespace/parcel-transformer-js']: {}, + ['@namespace/parcel-transformer-local']: {}, + }), + ); + }); + + it('unlinks with custom node modules glob', async () => { + await fsFixture(overlayFS)` + yarn.lock: + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules', 'tools/*/node_modules'], + namespace: '@parcel', + }} + node_modules + parcel -> ${path.resolve(__dirname, '../../parcel')} + @parcel/core -> ${path.resolve(__dirname, '../../core')} + .bin/parcel -> ${path.resolve(__dirname, '../../parcel/src/bin.js')} + tools + test/node_modules/parcel -> ${path.resolve(__dirname, '../../parcel')} + test2/node_modules/@parcel/core -> ${path.resolve( + __dirname, + '../../core', + )}`; + + let cli = createProgram({fs: overlayFS}); + await cli('unlink'); + + assert(!overlayFS.existsSync('.parcel-link')); + assert(!overlayFS.existsSync('node_modules/@parcel/core')); + assert(!overlayFS.existsSync('node_modules/parcel')); + assert(!overlayFS.existsSync('node_modules/.bin/parcel')); + assert(!overlayFS.existsSync('tools/test/node_modules/parcel')); + assert(!overlayFS.existsSync('tools/test2/node_modules/@parcel/core')); + }); + + it('does not do anything with dry run', async () => { + await fsFixture(overlayFS)` + yarn.lock: + node_modules + .bin/parcel -> ${path.resolve(__dirname, '../../parcel/src/bin.js')} + parcel -> ${path.resolve(__dirname, '../../parcel')} + @parcel/core -> ${path.resolve(__dirname, '../../core')} + .parcel-link: ${{ + appRoot: '/app', + packageRoot: path.resolve(__dirname, '../../..'), + nodeModulesGlobs: ['node_modules'], + namespace: '@parcel', + }} + `; + + let cli = createProgram({fs: overlayFS}); + await cli('unlink --dry-run'); + + assert(overlayFS.existsSync('.parcel-link')); + + assert.equal( + overlayFS.realpathSync('node_modules/@parcel/core'), + path.resolve(__dirname, '../../core'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/parcel'), + path.resolve(__dirname, '../../parcel'), + ); + + assert.equal( + overlayFS.realpathSync('node_modules/.bin/parcel'), + path.resolve(__dirname, '../../parcel/src/bin.js'), + ); + }); + }); +}); diff --git a/packages/dev/parcel-link/README.md b/packages/dev/parcel-link/README.md new file mode 100644 index 00000000000..c8f27656794 --- /dev/null +++ b/packages/dev/parcel-link/README.md @@ -0,0 +1,72 @@ +# `parcel-link` + +A CLI for linking a dev version of Parcel into a project. + +## Installation + +Clone and run `yarn`, then `cd packages/dev/parcel-link && yarn link` +to make the `parcel-link` binary globally available. + +## Usage + +In an Parcel project root: + +```sh +$ parcel-link [options] [packageRoot] +``` + +### Specifying `packageRoot` + +```sh +$ parcel-link /path/to/parcel/packages +``` + +By default, `parcel-link` will link to packages in the same +location where `parcel-link` is found. But it is common +to want to link other worktrees of Parcel, and it's not fun +to have to first re-link `parcel-link` to a new location. + +For this reason, `parcel-link` accepts a `packageRoot` argument, +which specifies a path to a Parcel `packages` directory. +Links will then be made to packages in that location instead +of the default. + +### Specifying a `namespace` + +```sh +$ parcel-link --namespace @my-parcel-fork +``` + +When linking into a project that uses a fork of Parcel, +the published packages may have a different namespace from +Parcel, so `parcel-link` allows specifying a namespace. + +If defined to someting other than `"@parcel"`, +`parcel-link` will do some extra work to adjust +namespaced packages to reference linked packages instead. + +### Linking into a monorepo + +```sh +$ parcel-link --node-modules-globs build-tools/*/node_modules build-tools/parcel/*/node_modules +``` + +In a monorepo, there may be multiple locations where +Parcel packages are installed. For this, `parcel-link` +allows specifying globs of locations where packages should be linked. + +Note that specifying any value here will override the default of `node_modules`, +so if you want to preserve the default behavior, be sure to include `node_modules` +in the list of globs: + +```sh +$ parcel-link -g build-tools/*/node_modules -g build-tools/parcel/*/node_modules -g node_modules +``` + +## Cleanup + +To restore the project to its default Parcel install: + +```sh +$ parcel-link unlink [options] [packageRoot] +``` diff --git a/packages/dev/parcel-link/bin.js b/packages/dev/parcel-link/bin.js new file mode 100755 index 00000000000..f07eb9aab3a --- /dev/null +++ b/packages/dev/parcel-link/bin.js @@ -0,0 +1,20 @@ +#! /usr/bin/env node + +// @flow strict-local +/* eslint-disable no-console */ + +'use strict'; + +// $FlowFixMe[untyped-import] +require('@parcel/babel-register'); + +let program = require('./src/cli').createProgram(); + +(async function main() { + try { + await program.parseAsync(); + } catch (e) { + console.error(e); + process.exit(1); + } +})(); diff --git a/packages/dev/parcel-link/package.json b/packages/dev/parcel-link/package.json new file mode 100644 index 00000000000..da93ef8c8a2 --- /dev/null +++ b/packages/dev/parcel-link/package.json @@ -0,0 +1,21 @@ +{ + "name": "@parcel/link", + "description": "A CLI for linking a dev version of Parcel into a project", + "version": "2.10.2", + "private": true, + "bin": { + "parcel-link": "bin.js" + }, + "scripts": { + "test": "cd ../../.. && yarn test:integration --grep @parcel/link" + }, + "main": "src/index.js", + "dependencies": { + "@babel/core": "^7.22.11", + "@parcel/babel-register": "2.10.2", + "@parcel/fs": "2.10.2", + "@parcel/utils": "2.10.2", + "commander": "^7.0.0", + "nullthrows": "^1.1.1" + } +} diff --git a/packages/dev/parcel-link/src/ParcelLinkConfig.js b/packages/dev/parcel-link/src/ParcelLinkConfig.js new file mode 100644 index 00000000000..c4ebf58e468 --- /dev/null +++ b/packages/dev/parcel-link/src/ParcelLinkConfig.js @@ -0,0 +1,106 @@ +// @flow + +import type {FileSystem} from '@parcel/fs'; + +import {globSync} from '@parcel/utils'; + +import assert from 'assert'; +import nullthrows from 'nullthrows'; +import path from 'path'; + +const LOCK_FILE_NAMES = ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml']; +const SCM_FILE_NAMES = ['.git', '.hg']; + +export class ParcelLinkConfig { + fs: FileSystem; + appRoot: string; + packageRoot: string; + namespace: string = '@parcel'; + nodeModulesGlobs: string[] = ['node_modules']; + filename: string = '.parcel-link'; + + static load( + appRoot: string, + {fs, filename = '.parcel-link'}: {|fs: FileSystem, filename?: string|}, + ): ParcelLinkConfig { + let manifest = JSON.parse( + fs.readFileSync(path.join(appRoot, filename), 'utf8'), + ); + return new ParcelLinkConfig({...manifest, fs}); + } + + constructor(options: {| + fs: FileSystem, + appRoot: string, + packageRoot: string, + namespace?: string, + nodeModulesGlobs?: string[], + filename?: string, + |}) { + this.fs = nullthrows(options.fs, 'fs is required'); + this.appRoot = nullthrows(options.appRoot, 'appRoot is required'); + this.packageRoot = nullthrows( + options.packageRoot, + 'packageRoot is required', + ); + this.namespace = options.namespace ?? this.namespace; + this.nodeModulesGlobs = options.nodeModulesGlobs ?? this.nodeModulesGlobs; + this.filename = options.filename ?? this.filename; + } + + save(): Promise { + return this.fs.writeFile( + path.join(this.appRoot, this.filename), + JSON.stringify(this, null, 2), + ); + } + + delete(): Promise { + return this.fs.rimraf(path.join(this.appRoot, this.filename)); + } + + validateAppRoot() { + assert( + [...LOCK_FILE_NAMES, ...SCM_FILE_NAMES].some(filename => + this.fs.existsSync(path.join(this.appRoot, filename)), + ), + `Not a project root: '${this.appRoot}'`, + ); + } + + validatePackageRoot() { + assert( + this.fs.existsSync(path.join(this.packageRoot, 'core/core')), + `Not a package root: '${this.packageRoot}'`, + ); + } + + validate(): void { + this.validateAppRoot(); + this.validatePackageRoot(); + } + + getNodeModulesPaths(): string[] { + return this.nodeModulesGlobs.reduce( + (matches, pattern) => [ + ...matches, + ...globSync(pattern, this.fs, {cwd: this.appRoot, onlyFiles: false}), + ], + [], + ); + } + + toJSON(): {| + appRoot: string, + packageRoot: string, + namespace: string, + nodeModulesGlobs: string[], + |} { + return { + appRoot: this.appRoot, + packageRoot: this.packageRoot, + namespace: this.namespace, + nodeModulesGlobs: this.nodeModulesGlobs, + }; + } +} diff --git a/packages/dev/parcel-link/src/cli.js b/packages/dev/parcel-link/src/cli.js new file mode 100644 index 00000000000..a2b37d4dfbb --- /dev/null +++ b/packages/dev/parcel-link/src/cli.js @@ -0,0 +1,25 @@ +// @flow strict-local +/* eslint-disable no-console */ + +import type {LinkCommandOptions} from './link'; +import type {UnlinkCommandOptions} from './unlink'; + +// $FlowFixMe[untyped-import] +import {version} from '../package.json'; +import {createLinkCommand} from './link'; +import {createUnlinkCommand} from './unlink'; + +import commander from 'commander'; + +export type ProgramOptions = {|...LinkCommandOptions, ...UnlinkCommandOptions|}; + +// $FlowFixMe[invalid-exported-annotation] +export function createProgram(opts?: ProgramOptions): commander.Command { + let {fs, log = console.log, link, unlink} = opts ?? {}; + return new commander.Command() + .version(version, '-V, --version') + .description('A tool for linking a dev copy of Parcel into an app') + .addHelpText('after', `\nThe link command is the default command.`) + .addCommand(createLinkCommand({fs, log, link}), {isDefault: true}) + .addCommand(createUnlinkCommand({fs, log, unlink})); +} diff --git a/packages/dev/parcel-link/src/index.js b/packages/dev/parcel-link/src/index.js new file mode 100644 index 00000000000..96002da8651 --- /dev/null +++ b/packages/dev/parcel-link/src/index.js @@ -0,0 +1,10 @@ +// @flow strict-local + +export type {ProgramOptions} from './cli'; +export type {LinkOptions} from './link'; +export type {UnlinkOptions} from './unlink'; + +export {createProgram} from './cli'; +export {link} from './link'; +export {unlink} from './unlink'; +export {ParcelLinkConfig} from './ParcelLinkConfig'; diff --git a/packages/dev/parcel-link/src/link.js b/packages/dev/parcel-link/src/link.js new file mode 100644 index 00000000000..b1fde68c90f --- /dev/null +++ b/packages/dev/parcel-link/src/link.js @@ -0,0 +1,197 @@ +// @flow strict-local + +import type {CmdOptions} from './utils'; +import type {FileSystem} from '@parcel/fs'; + +import {ParcelLinkConfig} from './ParcelLinkConfig'; +import { + findParcelPackages, + mapNamespacePackageAliases, + cleanupBin, + cleanupNodeModules, + fsWrite, + fsSymlink, +} from './utils'; + +import nullthrows from 'nullthrows'; +import path from 'path'; +import {NodeFS} from '@parcel/fs'; +import commander from 'commander'; + +export type LinkOptions = {| + dryRun?: boolean, + log?: (...data: mixed[]) => void, +|}; + +export type LinkCommandOptions = {| + +link?: typeof link, + +fs?: FileSystem, + +log?: (...data: mixed[]) => void, +|}; + +const NOOP: (...data: mixed[]) => void = () => {}; + +export async function link( + config: ParcelLinkConfig, + {dryRun = false, log = NOOP}: LinkOptions, +): Promise { + config.validate(); + + let {appRoot, packageRoot, namespace} = config; + + let nodeModulesPaths = config.getNodeModulesPaths(); + + let opts: CmdOptions = {appRoot, packageRoot, dryRun, log, fs: config.fs}; + + // Step 1: Determine all Parcel packages to link + // -------------------------------------------------------------------------------- + + let parcelPackages = await findParcelPackages(config.fs, packageRoot); + + // Step 2: Delete all official packages (`@parcel/*`) from node_modules + // -------------------------------------------------------------------------------- + + for (let nodeModules of nodeModulesPaths) { + await cleanupBin(nodeModules, opts); + await cleanupNodeModules( + nodeModules, + packageName => parcelPackages.has(packageName), + opts, + ); + } + + // Step 3: Link the Parcel packages into node_modules + // -------------------------------------------------------------------------------- + + for (let [packageName, p] of parcelPackages) { + await fsSymlink(p, path.join(appRoot, 'node_modules', packageName), opts); + } + + // Step 4: Point `parcel` bin symlink to linked `packages/core/parcel/src/bin.js` + // -------------------------------------------------------------------------------- + + await fsSymlink( + path.join(packageRoot, 'core/parcel/src/bin.js'), + path.join(appRoot, 'node_modules/.bin/parcel'), + opts, + ); + + // Step 5 (optional): If a namespace is not "@parcel", map namespaced package aliases. + // -------------------------------------------------------------------------------- + + if (namespace != null && namespace !== '@parcel') { + let namespacePackages = mapNamespacePackageAliases( + namespace, + parcelPackages, + ); + + // Step 5.1: In .parcelrc, rewrite all references to official plugins to `@parcel/*` + // -------------------------------------------------------------------------------- + + let parcelConfigPath = path.join(appRoot, '.parcelrc'); + if (config.fs.existsSync(parcelConfigPath)) { + let parcelConfig = config.fs.readFileSync(parcelConfigPath, 'utf8'); + await fsWrite( + parcelConfigPath, + parcelConfig.replace( + new RegExp(`"(${namespace}/parcel-[^"]*)"`, 'g'), + (_, match) => `"${namespacePackages.get(match) ?? match}"`, + ), + opts, + ); + } + + // Step 5.2: In the root package.json, rewrite all references to official plugins to @parcel/... + // For configs like "@namespace/parcel-bundler-default":{"maxParallelRequests": 10} + // -------------------------------------------------------------------------------- + + let rootPkgPath = path.join(appRoot, 'package.json'); + if (config.fs.existsSync(rootPkgPath)) { + let rootPkg = config.fs.readFileSync(rootPkgPath, 'utf8'); + await fsWrite( + rootPkgPath, + rootPkg.replace( + new RegExp(`"(${namespace}/parcel-[^"]*)"(\\s*:\\s*{)`, 'g'), + (_, match, suffix) => + `"${namespacePackages.get(match) ?? match}"${suffix}`, + ), + opts, + ); + } + + // Step 5.3: Delete namespaced packages (`@namespace/parcel-*`) from node_modules + // -------------------------------------------------------------------------------- + + for (let nodeModules of nodeModulesPaths) { + await cleanupNodeModules( + nodeModules, + packageName => namespacePackages.has(packageName), + opts, + ); + } + + // Step 5.4: Link the Parcel packages into node_modules as `@namespace/parcel-*` + // -------------------------------------------------------------------------------- + + for (let [alias, parcelName] of namespacePackages) { + let p = nullthrows(parcelPackages.get(parcelName)); + await fsSymlink(p, path.join(appRoot, 'node_modules', alias), opts); + } + } +} + +export function createLinkCommand( + opts?: LinkCommandOptions, + // $FlowFixMe[invalid-exported-annotation] +): commander.Command { + let action = opts?.link ?? link; + let log = opts?.log ?? NOOP; + let fs = opts?.fs ?? new NodeFS(); + + return new commander.Command('link') + .arguments('[packageRoot]') + .description('Link a dev copy of Parcel into an app', { + packageRoot: + 'Path to the Parcel package root\nDefaults to the package root containing this package', + }) + .option('-d, --dry-run', 'Do not write any changes') + .option('-n, --namespace ', 'Namespace for packages', '@parcel') + .option( + '-g, --node-modules-glob ', + 'Location where node_modules should be linked in the app.\nCan be repeated with multiple globs.', + (glob, globs) => globs.concat([glob.replace(/["']/g, '')]), + ['node_modules'], + ) + .action(async (packageRoot, options) => { + if (options.dryRun) log('Dry run...'); + let appRoot = process.cwd(); + + let parcelLinkConfig; + + try { + parcelLinkConfig = await ParcelLinkConfig.load(appRoot, {fs}); + } catch (e) { + // boop! + } + + if (parcelLinkConfig) { + throw new Error( + 'A Parcel link already exists! Try `parcel-link unlink` to re-link.', + ); + } + + parcelLinkConfig = new ParcelLinkConfig({ + fs, + appRoot, + packageRoot: packageRoot ?? path.join(__dirname, '../../../'), + namespace: options.namespace, + nodeModulesGlobs: options.nodeModulesGlob, + }); + + await action(parcelLinkConfig, {dryRun: options.dryRun, log}); + + if (!options.dryRun) await parcelLinkConfig.save(); + + log('🎉 Linking successful'); + }); +} diff --git a/packages/dev/parcel-link/src/unlink.js b/packages/dev/parcel-link/src/unlink.js new file mode 100644 index 00000000000..75f79b3837f --- /dev/null +++ b/packages/dev/parcel-link/src/unlink.js @@ -0,0 +1,169 @@ +// @flow strict-local + +import type {CmdOptions} from './utils'; +import type {FileSystem} from '@parcel/fs'; + +import {ParcelLinkConfig} from './ParcelLinkConfig'; +import { + cleanupBin, + cleanupNodeModules, + execSync, + findParcelPackages, + fsWrite, + mapNamespacePackageAliases, +} from './utils'; + +import path from 'path'; +import {NodeFS} from '@parcel/fs'; +import commander from 'commander'; + +export type UnlinkOptions = {| + dryRun?: boolean, + forceInstall?: boolean, + log?: (...data: mixed[]) => void, +|}; + +export type UnlinkCommandOptions = {| + +unlink?: typeof unlink, + +fs?: FileSystem, + +log?: (...data: mixed[]) => void, +|}; + +const NOOP: (...data: mixed[]) => void = () => {}; + +export async function unlink( + config: ParcelLinkConfig, + {dryRun = false, forceInstall = false, log = NOOP}: UnlinkOptions, +) { + config.validate(); + + let {appRoot, packageRoot, namespace} = config; + + let nodeModulesPaths = config.getNodeModulesPaths(); + + let opts: CmdOptions = {appRoot, packageRoot, dryRun, log, fs: config.fs}; + + // Step 1: Determine all Parcel packages that could be linked + // -------------------------------------------------------------------------------- + + let parcelPackages = await findParcelPackages(config.fs, packageRoot); + + // Step 2: Delete all official packages (`@parcel/*`) from node_modules + // This is very brute-force, but should ensure that we catch all linked packages. + // -------------------------------------------------------------------------------- + + for (let nodeModules of nodeModulesPaths) { + await cleanupBin(nodeModules, opts); + await cleanupNodeModules( + nodeModules, + packageName => parcelPackages.has(packageName), + opts, + ); + } + + // Step 3 (optional): If a namespace is not "@parcel", restore all aliased references. + // -------------------------------------------------------------------------------- + + if (namespace != null && namespace !== '@parcel') { + // Step 3.1: Determine all namespace packages that could be aliased + // -------------------------------------------------------------------------------- + + let namespacePackages = mapNamespacePackageAliases( + namespace, + parcelPackages, + ); + + // Step 3.2: In .parcelrc, restore all references to namespaced plugins. + // -------------------------------------------------------------------------------- + + let parcelConfigPath = path.join(appRoot, '.parcelrc'); + if (config.fs.existsSync(parcelConfigPath)) { + let parcelConfig = config.fs.readFileSync(parcelConfigPath, 'utf8'); + for (let [alias, parcel] of namespacePackages) { + parcelConfig = parcelConfig.replace( + new RegExp(`"${parcel}"`, 'g'), + `"${alias}"`, + ); + } + await fsWrite(parcelConfigPath, parcelConfig, opts); + } + + // Step 3.3: In the root package.json, restore all references to namespaced plugins + // For configs like "@namespace/parcel-bundler-default":{"maxParallelRequests": 10} + // -------------------------------------------------------------------------------- + + let rootPkgPath = path.join(appRoot, 'package.json'); + if (config.fs.existsSync(rootPkgPath)) { + let rootPkg = config.fs.readFileSync(rootPkgPath, 'utf8'); + for (let [alias, parcel] of namespacePackages) { + rootPkg = rootPkg.replace( + new RegExp(`"${parcel}"(\\s*:\\s*{)`, 'g'), + `"${alias}"$1`, + ); + } + await fsWrite(rootPkgPath, rootPkg, opts); + } + + // Step 3.4: Delete all namespaced packages (`@namespace/parcel-*`) from node_modules + // This is very brute-force, but should ensure that we catch all linked packages. + // -------------------------------------------------------------------------------- + + for (let nodeModules of nodeModulesPaths) { + await cleanupNodeModules( + nodeModules, + packageName => namespacePackages.has(packageName), + opts, + ); + } + } + + // Step 4 (optional): Run `yarn` to restore all dependencies. + // -------------------------------------------------------------------------------- + + if (forceInstall) { + // FIXME: This should detect the package manager in use. + log('Running `yarn` to restore dependencies'); + execSync('yarn install --force', opts); + } else { + log('Run `yarn install --force` (or similar) to restore dependencies'); + } +} + +export function createUnlinkCommand( + opts?: UnlinkCommandOptions, + // $FlowFixMe[invalid-exported-annotation] +): commander.Command { + let action = opts?.unlink ?? unlink; + let log = opts?.log ?? NOOP; + let fs = opts?.fs ?? new NodeFS(); + + return new commander.Command('unlink') + .description('Unlink a dev copy of Parcel from an app') + .option('-d, --dry-run', 'Do not write any changes') + .option('-f, --force-install', 'Force a reinstall after unlinking') + .action(async options => { + if (options.dryRun) log('Dry run...'); + let appRoot = process.cwd(); + + let parcelLinkConfig; + try { + parcelLinkConfig = await ParcelLinkConfig.load(appRoot, {fs}); + } catch (e) { + // boop! + } + + if (parcelLinkConfig) { + await action(parcelLinkConfig, { + dryRun: options.dryRun, + forceInstall: options.forceInstall, + log, + }); + + if (!options.dryRun) await parcelLinkConfig.delete(); + } else { + throw new Error('A Parcel link could not be found!'); + } + + log('🎉 Unlinking successful'); + }); +} diff --git a/packages/dev/parcel-link/src/utils.js b/packages/dev/parcel-link/src/utils.js new file mode 100644 index 00000000000..d4fc9238c50 --- /dev/null +++ b/packages/dev/parcel-link/src/utils.js @@ -0,0 +1,164 @@ +// @flow strict-local + +import assert from 'assert'; +import child_process from 'child_process'; +import path from 'path'; + +import type {FileSystem} from '@parcel/fs'; + +export type CmdOptions = {| + appRoot: string, + packageRoot: string, + dryRun: boolean, + fs: FileSystem, + log: (...data: mixed[]) => void, +|}; + +export async function fsWrite( + f: string, + content: string, + {appRoot, log, dryRun, fs}: CmdOptions, +): Promise { + if (!dryRun) await fs.writeFile(f, content); + log('Wrote', path.join('', path.relative(appRoot, f))); +} + +export async function fsDelete( + f: string, + {appRoot, log, dryRun, fs}: CmdOptions, +): Promise { + if (await fs.exists(f)) { + if (!dryRun) await fs.rimraf(f); + log('Deleted', path.join('', path.relative(appRoot, f))); + } +} + +export async function fsSymlink( + source: string, + target: string, + {appRoot, packageRoot, log, dryRun, fs}: CmdOptions, +): Promise { + if (!dryRun) { + assert( + await fs.exists(source), + `Can't link from ${source}; it doesn't exist!`, + ); + assert( + !(await fs.exists(target)), + `Can't link to ${target}; it already exists!`, + ); + await fs.symlink(source, target); + } + log( + 'Linked', + path.join('', path.relative(appRoot, target)), + '->', + path.join('', path.relative(packageRoot, source)), + ); +} + +export async function findParcelPackages( + fs: FileSystem, + rootDir: string, + files: Map = new Map(), +): Promise> { + for (let file of fs.readdirSync(rootDir, {withFileTypes: true})) { + if (file.name === 'node_modules') continue; + let projectPath = path.join(rootDir, file.name); + if (file.isDirectory()) { + let packagePath = path.join(projectPath, 'package.json'); + if (fs.existsSync(packagePath)) { + let pack = JSON.parse(await fs.readFile(packagePath, 'utf8')); + if (!pack.private) { + files.set(pack.name, projectPath); + } + } else { + await findParcelPackages(fs, projectPath, files); + } + } + } + return files; +} + +export function mapNamespacePackageAliases( + ns: string, + parcelPackages: Map, +): Map { + let aliasesToParcelPackages = new Map(); + for (let packageName of parcelPackages.keys()) { + if (packageName.startsWith(ns)) { + continue; + } + aliasesToParcelPackages.set( + packageName === 'parcel' + ? `${ns}/parcel` + : packageName === 'parcelforvscode' + ? `${ns}/parcelforvscode` + : packageName.replace(/^@parcel\//, `${ns}/parcel-`), + packageName, + ); + } + return aliasesToParcelPackages; +} + +export async function cleanupBin(root: string, opts: CmdOptions) { + let binSymlink = path.join(root, '.bin/parcel'); + try { + await fsDelete(binSymlink, opts); + } catch (e) { + // noop + } +} + +export async function cleanupNodeModules( + root: string, + predicate: (filepath: string) => boolean, + opts: CmdOptions, +): Promise { + let {fs} = opts; + for (let dirName of fs.readdirSync(root)) { + if (dirName === '.bin') continue; + let dirPath = path.join(root, dirName); + if (dirName[0].startsWith('@')) { + await cleanupNodeModules(dirPath, predicate, opts); + continue; + } + + let packageName; + let parts = dirPath.split(path.sep).slice(-2); + if (parts[0].startsWith('@')) { + packageName = parts.join('/'); + } else { + packageName = parts[1]; + } + + // ------- + + if (predicate(packageName)) { + await fsDelete(dirPath, opts); + } + + // ------- + + let packageNodeModules = path.join(root, dirName, 'node_modules'); + let stat; + try { + stat = fs.statSync(packageNodeModules); + } catch (e) { + // noop + } + if (stat?.isDirectory()) { + await cleanupNodeModules(packageNodeModules, predicate, opts); + } + } +} + +export function execSync( + cmd: string, + {appRoot, log, dryRun}: CmdOptions, +): void { + log('Executing', cmd); + if (!dryRun) { + child_process.execSync(cmd, {cwd: appRoot, stdio: 'inherit'}); + } +}