diff --git a/.eslintignore b/.eslintignore index 9b1c8b1..2102478 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,3 @@ /dist +*.md +.nvmrc diff --git a/.nvmrc b/.nvmrc index 6f7f377..3f430af 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16 +v18 diff --git a/package-lock.json b/package-lock.json index 2fce44c..9623ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@npmcli/arborist": "^6.1.5", - "@oclif/core": "^1.22.0", + "@oclif/core": "^1.23.0", "@oclif/plugin-autocomplete": "^1.3.8", "@oclif/plugin-help": "^5", "@oclif/plugin-not-found": "^2.3.11", @@ -23,6 +23,7 @@ "log-symbols": "5.1.0", "node-notifier": "^10.0.1", "npm-packlist": "^7.0.4", + "promisify-child-process": "^4.1.1", "tsconfig-paths": "^4.1.1", "zod": "^3.20.2" }, @@ -39,7 +40,7 @@ "@types/node-notifier": "^8.0.2", "@types/npm-packlist": "^3.0.0", "@types/npmcli__arborist": "^5.6.0", - "chetzof-lint-config": "^1.0.8", + "chetzof-lint-config": "^1.0.9", "eslint-config-oclif": "^4", "eslint-config-oclif-typescript": "^1.0.3", "eslint-define-config": "^1.12.0", @@ -53,10 +54,11 @@ "tslib": "^2.4.1", "typescript": "^4.9.4", "vite-tsconfig-paths": "^4.0.3", - "vitest": "0.26.2" + "vitest": "0.26.2", + "vitest-mock-process": "^1.0.4" }, "engines": { - "node": ">=14.17.0" + "node": ">=16" } }, "node_modules/@ampproject/remapping": { @@ -1642,9 +1644,9 @@ } }, "node_modules/@oclif/core": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/@oclif/core/-/core-1.22.0.tgz", - "integrity": "sha512-Bvyi6uFbmpkFl9XUATsGMlqEDGfqMKWL0Mu5VQTuPg7/NIyfygYkaburn11uGkOp0a8yG6fPpyVBfGmztjNPGA==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-1.23.0.tgz", + "integrity": "sha512-LnQoRtyQLQCsEHQsY7Ju0Z+g84XIVTxtVWr9hq81Juzj0o2f4zaFZ3f39VfnXvxI4m+QmROaoUJvr417eSEuhg==", "dependencies": { "@oclif/linewrap": "^1.0.0", "@oclif/screen": "^3.0.3", @@ -3649,9 +3651,9 @@ } }, "node_modules/chetzof-lint-config": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/chetzof-lint-config/-/chetzof-lint-config-1.0.8.tgz", - "integrity": "sha512-69SbFowlUIxF3NFmscZ8vX6iNVZgW6ij02W9bSclb593W0H12v+2QdpBa69D0cOFpoTqdj6y7v1x9bFQMzqnNQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/chetzof-lint-config/-/chetzof-lint-config-1.0.9.tgz", + "integrity": "sha512-1ciRJ41z2Fs8ptMrdb6tG3n+t2r5avSi7d5hPb1akeEG/52n36+hy++KRnDE06a537HCZwmiw+5N3x39vJafSA==", "dev": true, "peerDependencies": { "@typescript-eslint/eslint-plugin": ">=5", @@ -4468,6 +4470,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-clone-fn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/deep-clone-fn/-/deep-clone-fn-1.1.0.tgz", + "integrity": "sha512-9EOa4OcoQhpBknAVdyVjlCFEtHD8lyC/P84NUy+FiJumMs9AQ39vNPvq8IySYjTqTqgO5ZAd2Bm+ATSjNKA/gw==", + "dev": true, + "dependencies": { + "rfdc": "^1.3.0" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -15099,6 +15110,14 @@ "node": ">=10" } }, + "node_modules/promisify-child-process": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/promisify-child-process/-/promisify-child-process-4.1.1.tgz", + "integrity": "sha512-/sRjHZwoXf1rJ+8s4oWjYjGRVKNK1DUnqfRC1Zek18pl0cN6k3yJ1cCbqd0tWNe4h0Gr+SY4vR42N33+T82WkA==", + "engines": { + "node": ">=8" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -17817,6 +17836,18 @@ } } }, + "node_modules/vitest-mock-process": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/vitest-mock-process/-/vitest-mock-process-1.0.4.tgz", + "integrity": "sha512-WFoSE8MLTanQJkZUZSEd2/9+O1RJKqYn5tUNh3mW/SAh1VL7D7cfcxkn2F7DlhsFI0ZPILxto0OI6XEmGYFyRA==", + "dev": true, + "dependencies": { + "deep-clone-fn": "^1.1.0" + }, + "peerDependencies": { + "vitest": "<1" + } + }, "node_modules/vue-eslint-parser": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.1.0.tgz", diff --git a/package.json b/package.json index dc412e1..0f4a396 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "dependencies": { "@npmcli/arborist": "^6.1.5", - "@oclif/core": "^1.22.0", + "@oclif/core": "^1.23.0", "@oclif/plugin-autocomplete": "^1.3.8", "@oclif/plugin-help": "^5", "@oclif/plugin-not-found": "^2.3.11", @@ -33,6 +33,7 @@ "log-symbols": "5.1.0", "node-notifier": "^10.0.1", "npm-packlist": "^7.0.4", + "promisify-child-process": "^4.1.1", "tsconfig-paths": "^4.1.1", "zod": "^3.20.2" }, @@ -45,7 +46,7 @@ "@types/node-notifier": "^8.0.2", "@types/npm-packlist": "^3.0.0", "@types/npmcli__arborist": "^5.6.0", - "chetzof-lint-config": "^1.0.8", + "chetzof-lint-config": "^1.0.9", "eslint-config-oclif": "^4", "eslint-config-oclif-typescript": "^1.0.3", "eslint-define-config": "^1.12.0", @@ -59,7 +60,8 @@ "tslib": "^2.4.1", "typescript": "^4.9.4", "vite-tsconfig-paths": "^4.0.3", - "vitest": "0.26.2" + "vitest": "0.26.2", + "vitest-mock-process": "^1.0.4" }, "oclif": { "bin": "linktink", @@ -90,7 +92,7 @@ "semantic-release": "semantic-release" }, "engines": { - "node": ">=14.17.0" + "node": ">=16" }, "bugs": "https://github.com/chetzof/linktink/issues", "keywords": [ diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 9f0d7bd..9e45885 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -27,5 +27,7 @@ export default class Sync extends Command { syncPaths: path.resolve(inputArguments.from), }, }) + // eslint-disable-next-line no-process-exit,unicorn/no-process-exit + process.exit(0) } } diff --git a/src/lib/child-process.ts b/src/lib/child-process.ts index 32a4208..a2265f0 100644 --- a/src/lib/child-process.ts +++ b/src/lib/child-process.ts @@ -1,7 +1,4 @@ -import * as child_process from 'node:child_process' -import * as util from 'node:util' - -const execAsync = util.promisify(child_process.exec) +import { exec } from 'promisify-child-process' export async function execNpm( command: string, @@ -20,6 +17,7 @@ export async function execNpm( .join(' ') const compiledCommand = `npm ${compiledOptions} ${command} ` // console.log(compiledCommand) - const output = await execAsync(compiledCommand, { cwd }) + const output = await exec(compiledCommand, { cwd }) + // console.log(output) return output.stdout } diff --git a/src/lib/misc.ts b/src/lib/misc.ts index a42ae31..8800ae1 100644 --- a/src/lib/misc.ts +++ b/src/lib/misc.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { copy } from 'fs-extra' -import { read } from 'fs-jetpack' +import jetpack from 'fs-jetpack' import { execNpm } from '@/lib/child-process' @@ -12,10 +12,18 @@ interface PackageJSON { } async function readPackageJson(packageDirectory: string): Promise { - return (await read( - path.join(packageDirectory, 'package.json'), - 'json', - )) as Promise + const cwd = jetpack.cwd(packageDirectory) + const contents = (await cwd.readAsync('package.json', 'json')) as + | PackageJSON + | undefined + + if (!contents) { + throw new Error( + `Could not find a package.json file in the directory '${packageDirectory}'`, + ) + } + + return contents } export async function getPackageName( diff --git a/src/lib/stdin.ts b/src/lib/stdin.ts index 60e1725..5851de3 100644 --- a/src/lib/stdin.ts +++ b/src/lib/stdin.ts @@ -7,7 +7,7 @@ export function listenToQuitKey(callback: () => void): void { return } - if (key.name === 'q') { + if (key.name === 'q' || key.name === 'ę') { process.stdin.removeListener('keypress', handler) callback() } diff --git a/src/lib/sync/subtasks/graceful-exit-task.ts b/src/lib/sync/subtasks/graceful-exit-task.ts new file mode 100644 index 0000000..2d78e68 --- /dev/null +++ b/src/lib/sync/subtasks/graceful-exit-task.ts @@ -0,0 +1,22 @@ +import { execNpm } from '@/lib/child-process' +import type { Context } from '@/lib/sync/tasks' + +import type { ListrTask } from 'listr2' + +export function gracefulExitTask(): ListrTask { + return { + task: (context, task) => { + task.title = 'Graceful exit' + return task.newListr([ + { + title: 'Reverting to the previous package version', + task: async (_context) => { + await execNpm('install', { + cwd: context.targetPackagePath, + }) + }, + }, + ]) + }, + } +} diff --git a/src/lib/sync/subtasks/start-watcher-task.ts b/src/lib/sync/subtasks/start-watcher-task.ts index d768f26..8af9047 100644 --- a/src/lib/sync/subtasks/start-watcher-task.ts +++ b/src/lib/sync/subtasks/start-watcher-task.ts @@ -40,24 +40,10 @@ export function startWatcherTask(): ListrTask { title: 'Starting watching the files', task: (context, task) => { const newList = task.newListr([], { exitOnError: false }) - let intermediateTask = createIntermediateTask() newList.add(intermediateTask.task) - listenToQuitKey(() => { - intermediateTask.resolve('Triggered graceful exit') - newList.add([ - { - title: 'Graceful', - task: () => { - // eslint-disable-next-line no-process-exit,unicorn/no-process-exit - process.exit(1) - }, - }, - ]) - }) - - watch(context.sourcePackagePath, { + const watcher = watch(context.sourcePackagePath, { ignoreInitial: true, persistent: true, ignored: ['**/.git/**', '**/node_modules/**'], @@ -134,6 +120,17 @@ export function startWatcherTask(): ListrTask { }) }) + listenToQuitKey(() => { + newList.add({ + title: 'Close watcher', + task: async (_context) => { + await watcher.close() + }, + }) + + intermediateTask.resolve('Quitting') + }) + return newList }, } diff --git a/src/lib/sync/tasks.ts b/src/lib/sync/tasks.ts index f19b042..4a59742 100644 --- a/src/lib/sync/tasks.ts +++ b/src/lib/sync/tasks.ts @@ -5,6 +5,7 @@ import { checkIfSourcePackageInstalledTask } from '@/lib/sync/subtasks/check-if- import { checkIfThePathExistsTask } from '@/lib/sync/subtasks/check-if-the-path-exists-task' import { getFallbackPackList } from '@/lib/sync/subtasks/get-fallback-packlist-task' import { getPackListTask } from '@/lib/sync/subtasks/get-pack-list-task' +import { gracefulExitTask } from '@/lib/sync/subtasks/graceful-exit-task' import { installTheDependentPackageTask } from '@/lib/sync/subtasks/install-dependent-package-task' import { startWatcherTask } from '@/lib/sync/subtasks/start-watcher-task' @@ -69,6 +70,7 @@ function getTasks(): Array> { }), }, startWatcherTask(), + gracefulExitTask(), ] } @@ -77,7 +79,7 @@ export async function runTasks< >(override: O): Promise>> { const manager = new Manager({ concurrent: false, - registerSignalListeners: false, + // registerSignalListeners: false, rendererOptions: { collapse: false, collapseSkips: false, diff --git a/tests/integration/tasks.test.ts b/tests/integration/tasks.test.ts index b00873f..14ede3f 100644 --- a/tests/integration/tasks.test.ts +++ b/tests/integration/tasks.test.ts @@ -1,100 +1,71 @@ import { read } from 'fs-jetpack' -import { isEqual } from 'lodash' -import { it, vi, afterEach } from 'vitest' +import { it, vi, expect } from 'vitest' +import { mockProcessExit } from 'vitest-mock-process' -const spy = vi.spyOn(console, 'log') import Sync from '../../src/commands/sync' import { execNpm } from '../../src/lib/child-process' -import { getTestTemporaryDirectory } from '../unit/helpers' -import { waitUntiltoHaveBeenCalledWith, waitUntilTrue } from '../util' - -import type { FSJetpack } from 'fs-jetpack/types' - -const testTemporaryDirectory = getTestTemporaryDirectory() -afterEach(() => { - testTemporaryDirectory.dir(testTemporaryDirectory.path(), { empty: true }) -}) - -const packageOriginalContent = { - name: 'secondary', - version: '1.0.0', -} - -const packageSyncContent = { - name: 'secondary', - version: '1.1.0', -} - -function addPackageContent( - path: T, - content: object | string, -): T { - return path.file('package.json', { - content, - }) as T -} - -function addFileContent( - path: T, - content: object | string, -): T { - return path.file('content.js', { - content, - }) as T -} +import { getFsHelpers } from '../unit/helpers' +import { waitUntiltoHaveBeenCalledWith } from '../util' +const spy = vi.spyOn(console, 'log') +const { createPackage } = getFsHelpers() +const mockExit = mockProcessExit() it('run full process', async () => { - const secondaryDirectoryOriginal = - testTemporaryDirectory.cwd('secondary-original') - - addPackageContent(secondaryDirectoryOriginal, packageOriginalContent) - addFileContent(secondaryDirectoryOriginal, '0') - - const secondaryDirectorySynced = testTemporaryDirectory.cwd('secondary') - - addPackageContent(secondaryDirectorySynced, packageSyncContent) - addFileContent(secondaryDirectorySynced, '1') + const contentFilePath = 'node_modules/secondary/content.js' + const secondaryDirectoryOriginal = createPackage({ + directoryName: 'secondary-original', + packageName: 'secondary', + files: { + 'content.js': '0', + }, + }) - const primaryDirectory = testTemporaryDirectory.cwd('primary') + const secondaryDirectorySynced = createPackage({ + packageName: 'secondary', + files: { + 'content.js': '1', + }, + }) - addPackageContent(primaryDirectory, { - name: 'primary', - version: '1.0.0', - dependencies: { - secondary: secondaryDirectoryOriginal.path(), + const masterDirectory = createPackage({ + files: { + 'package.json': { + name: 'master', + dependencies: { + secondary: secondaryDirectoryOriginal.cwd.path(), + }, + }, }, }) - await execNpm('install', { cwd: primaryDirectory.path() }) - await waitUntilTrue(() => - isEqual( - read( - primaryDirectory.path('node_modules/secondary/package.json'), - 'json', - ), - packageOriginalContent, - ), - ) + await execNpm('install', { cwd: masterDirectory.cwd.path() }) + + expect(read(masterDirectory.cwd.path(contentFilePath))).toBe('0') + // eslint-disable-next-line no-void - void Sync.run([secondaryDirectorySynced.path(), primaryDirectory.path()]) + void Sync.run([ + secondaryDirectorySynced.cwd.path(), + masterDirectory.cwd.path(), + ]) await waitUntiltoHaveBeenCalledWith(spy, [ '[STARTED] Waiting for changes (press q to exit and restore the original package contents)', ]) - await waitUntilTrue(() => - isEqual( - read( - primaryDirectory.path('node_modules/secondary/package.json'), - 'json', - ), - packageSyncContent, - ), - ) + expect(read(masterDirectory.cwd.path(contentFilePath))).toBe('1') + + secondaryDirectorySynced.cwd.file('content.js', { content: '2' }) + + await waitUntiltoHaveBeenCalledWith(spy, [ + '[SUCCESS] Copied from ./secondary/content.js to ./master/node_modules/secondary/content.js', + ]) - secondaryDirectorySynced.file('content.js', { content: '2' }) + expect(read(masterDirectory.cwd.path(contentFilePath))).toBe('2') + process.stdin.emit('keypress', undefined, { name: 'ę' }) await waitUntiltoHaveBeenCalledWith(spy, [ - '[SUCCESS] Copied from ./secondary/content.js to ./primary/node_modules/secondary/content.js', + '[SUCCESS] Reverting to the previous package version', ]) + expect(read(masterDirectory.cwd.path(contentFilePath))).toBe('0') + expect(mockExit).toHaveBeenCalledWith(0) }, 10_000) diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 0f900b3..9e5129c 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -1,7 +1,57 @@ import jetpack from 'fs-jetpack' +import { afterEach } from 'vitest' import type { FSJetpack } from 'fs-jetpack/types' export function getTestTemporaryDirectory(): FSJetpack { return jetpack.tmpDir() } + +const packageJson = 'package.json' + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export function getFsHelpers() { + const testTemporaryDirectory = getTestTemporaryDirectory() + afterEach(() => { + testTemporaryDirectory.dir(testTemporaryDirectory.path(), { empty: true }) + }) + + return { + createPackage: ({ + directoryName, + packageName = 'foo-dependency', + files = {}, + }: { + directoryName?: string + packageName?: string + files?: Record + } = {}) => { + let rootDirectory = directoryName ?? packageName + + if (!files[packageJson]) { + files[packageJson] = { + name: packageName, + } + } else if ( + typeof files[packageJson] === 'object' && + 'name' in files[packageJson] + ) { + rootDirectory = files[packageJson].name as string + } + + const cwd = testTemporaryDirectory.cwd(rootDirectory) + + for (const [file, content] of Object.entries(files)) { + cwd.file(file, { + content, + }) + } + + return { + packageName, + cwd, + } + }, + temporaryCwd: testTemporaryDirectory, + } +} diff --git a/tests/unit/misc.test.ts b/tests/unit/misc.test.ts index 3b99c9a..c57a241 100644 --- a/tests/unit/misc.test.ts +++ b/tests/unit/misc.test.ts @@ -1,28 +1,32 @@ import path from 'node:path' -import { read } from 'fs-jetpack' -import { expect, it, vi } from 'vitest' +import { expect, it, describe } from 'vitest' -import { getTargetPath } from '@/lib/misc' +import { getTargetPath, getPackageName } from '@/lib/misc' import { getPackList } from '@/lib/packlist' -vi.mock('fs-jetpack', () => ({ - read: vi.fn(), -})) +import { getFsHelpers } from './helpers' -it('should return a path to a package in node_modules', async () => { - const sourcePackageName = 'source-package' - - const sourcePath = '/path/to/source-root/src/file.ts' - const sourceRoot = '/path/to/source-root' +const { createPackage, temporaryCwd } = getFsHelpers() - const targetRoot = '/path/to/target-root' - const targetPath = `/path/to/target-root/node_modules/${sourcePackageName}/src/file.ts` - - vi.mocked(read).mockResolvedValue({ name: sourcePackageName }) - await expect(getTargetPath(sourcePath, sourceRoot, targetRoot)).resolves.toBe( - targetPath, +it('should return a path to a package in node_modules', async () => { + const { cwd, packageName } = createPackage() + const relativeDepencencyFilePath = 'dist/file.ts' + const dependencyFilePath = cwd.path(relativeDepencencyFilePath) + + const primaryRoot = temporaryCwd.cwd('primary') + const actualResult = await getTargetPath( + dependencyFilePath, + cwd.path(), + primaryRoot.path(), ) + const expectedResult = primaryRoot.path( + 'node_modules', + packageName, + relativeDepencencyFilePath, + ) + + expect(actualResult).toBe(expectedResult) }) it('should return a pack list', async () => { @@ -30,3 +34,29 @@ it('should return a pack list', async () => { expect(result).toBeInstanceOf(Array) expect(result).includes('package.json') }) + +describe('getPackageName', () => { + it('should return a name if the name is set in package.json', async () => { + const { cwd, packageName } = createPackage() + + await expect(getPackageName(cwd.path())).resolves.toBe(packageName) + }) + + it('should throw if the name is not set in package.json', async () => { + const { cwd } = createPackage({ + files: { + 'package.json': {}, + }, + }) + + await expect(getPackageName(cwd.path())).rejects.toThrow( + 'Could not find a package name', + ) + }) + + it('should throw if there is no package.json', async () => { + await expect(getPackageName(temporaryCwd.path())).rejects.toThrowError( + 'Could not find a package.json', + ) + }) +}) diff --git a/tests/util.ts b/tests/util.ts index 985d5da..df7b839 100644 --- a/tests/util.ts +++ b/tests/util.ts @@ -1,7 +1,9 @@ import { clearTimeout, clearInterval } from 'node:timers' import isEqual from 'lodash/isEqual' -import { expect, SpyInstance } from 'vitest' +import { expect } from 'vitest' + +import type { SpyInstance } from 'vitest' interface Options { checkInterval?: number @@ -40,7 +42,7 @@ export async function waitUntiltoHaveBeenCalledWith( options, ) } catch { - console.log(spy) + expect(spy).toHaveBeenCalledWith(...expectedCallArguments) } finally { expect(spy).toHaveBeenCalledWith(...expectedCallArguments) } diff --git a/todo.md b/todo.md index 84ca7bf..3b2cad0 100644 --- a/todo.md +++ b/todo.md @@ -4,3 +4,7 @@ - Support yarn, etc - Naive for initials launch and smart strategy for package.json update - Detect when npm intall is run on target package (and deletes the linked package) +- Remember previous runs +- Automatically trigger watcher on dependent package +- Add debug flag +- Skip specifying destination if current cwd is a package diff --git a/vitest.config.ts b/vitest.config.ts index 29bace2..b509d1c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// eslint-disable-next-line import/no-unused-modules export default defineConfig({ plugins: [ tsconfigPaths({ @@ -14,6 +13,7 @@ export default defineConfig({ test: { deps: { interopDefault: true, + inline: ['vitest-mock-process'], }, clearMocks: true, globals: true,