diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 7bc0680..0000000 --- a/.npmignore +++ /dev/null @@ -1,27 +0,0 @@ -# Ignore documentation markdown files -*.md - -# Ignore source code and tests (publish built artifacts only) -src/ -test/ -tests/ -__tests__/ -fixtures/ -examples/ - -# Ignore coverage and build reports -coverage/ -.nyc_output/ - -# Ignore editor and IDE settings -.vscode/ -.idea/ - -# Ignore logs and temporary files -*.log -*.tmp -*.swp - -# Ignore common configuration directories that shouldn't be published -config/ -configs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e31c7e5..014edba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Devlink changelog +## 0.0.3 + +### Minor Changes + +- Implement full interactive mode with guided workflows for all commands. +- Add premium welcome screen with "Graceful" font and "by MayR Labs" subtitle. +- Fix infinite publish loop in `publish:watch` mode with improved file ignoring and debouncing. +- Fixed versioning issue where `devlink --version` reported an incorrect version. +- Add new `update-all` command to sync all devlinked packages to their latest versions. +- Improved store browsing and version/flag selection for the `add` command. +- Integrated multi-select for cleaning installations. +- Guided `retreat`, `restore`, and `remove` commands with project-aware package selection. + ## 0.0.2 (2026-04-02) ### Major Enhancements diff --git a/package.json b/package.json index 54dc910..1f84bea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mayrlabs/devlink", - "version": "0.0.2", + "version": "0.0.3", "description": "Work with npm/pnpm/yarn packages locally like a boss.", "private": false, "author": { @@ -65,5 +65,6 @@ }, "publishConfig": { "access": "public" - } + }, + "files": ["dist", "README.md", "CHANGELOG.md", "package.json"] } diff --git a/src/devlink.ts b/src/devlink.ts index 6e2201f..faed37c 100644 --- a/src/devlink.ts +++ b/src/devlink.ts @@ -16,7 +16,17 @@ import { values, } from './index.js'; import { cleanInstallations, showInstallations } from './installations.js'; -import { startInteractive } from './interactive.js'; +import { + handleAdd, + handleInstallations, + handlePublish, + handleRemove, + handleRestore, + handleRetreat, + handleUpdate, + handleUpdateAll, + startInteractive, +} from './interactive.js'; import { publishPackageWatch } from './publish.js'; import type { PublishPackageOptions } from './publish.js'; import { readRcConfig } from './rc.js'; @@ -89,12 +99,25 @@ const commands = [ 'help', ]; +const isTTY = process.stdin.isTTY && process.stdout.isTTY; + +const shouldRunInteractive = (argv: any, positionalArgsCount = 0) => { + if (argv.interactive !== undefined) return !!argv.interactive; + return isTTY && argv._.length <= positionalArgsCount; +}; + if (process.argv.length <= 2) { await startInteractive(); } else { /* tslint:disable-next-line */ const argv = await yargs(process.argv.slice(2)) .usage(`${cliCommand} [command] [options] [package1 [package2...]]`) + .version(getVersionMessage()) + .alias('v', 'version') + .option('interactive', { + type: 'boolean', + describe: 'Run in interactive mode', + }) .coerce('store-folder', (folder: string) => { if (!devlinkGlobal.devlinkStoreMainDir) { devlinkGlobal.devlinkStoreMainDir = resolve(folder); @@ -119,12 +142,17 @@ if (process.argv.length <= 2) { .boolean(['push', 'watch'].concat(publishFlags)); }, handler: async (argv) => { - const options = getPublishOptions(argv); if (argv.watch) { - await publishPackageWatch(options); - } else { - await publishPackage(options); + await publishPackageWatch(getPublishOptions(argv)); + return; } + + if (shouldRunInteractive(argv, 1)) { + await handlePublish(); + return; + } + + await publishPackage(getPublishOptions(argv)); }, }) .command({ @@ -150,6 +178,10 @@ if (process.argv.length <= 2) { builder: (y) => y.boolean(['dry']), handler: async (argv) => { const action = argv._[1]; + if (shouldRunInteractive(argv, 1)) { + await handleInstallations(); + return; + } const packages = argv._.slice(2) as string[]; switch (action) { case 'show': @@ -179,7 +211,31 @@ if (process.argv.length <= 2) { .help(true); }, handler: async (argv) => { - await addPackages(argv._.slice(1) as string[], { + const packages = argv._.slice(1) as string[]; + const hasFlags = + argv.dev || + argv.link || + argv.restore || + argv.pure || + argv.workspace || + argv.update || + argv.upgrade; + + if (shouldRunInteractive(argv, 1) && !hasFlags) { + await handleAdd(); + return; + } + + if ( + packages.length === 1 && + !hasFlags && + shouldRunInteractive(argv, 2) + ) { + await handleAdd(packages[0]); + return; + } + + await addPackages(packages, { dev: !!argv.dev, linkDep: !!argv.link, restore: !!argv.restore, @@ -200,7 +256,12 @@ if (process.argv.length <= 2) { .help(true); }, handler: async (argv) => { - await updatePackages(argv._.slice(1) as string[], { + const packages = argv._.slice(1) as string[]; + if (packages.length === 0 && shouldRunInteractive(argv, 1)) { + await handleUpdate(); + return; + } + await updatePackages(packages, { update: !!(argv.update || argv.upgrade), restore: !!argv.restore, workingDir: process.cwd(), @@ -210,8 +271,9 @@ if (process.argv.length <= 2) { .command({ command: 'update-all', describe: 'Update all devlinked packages to latest version', - handler: async () => { - await updateAllPackages(process.cwd()); + handler: async (argv) => { + if (shouldRunInteractive(argv, 1)) await handleUpdateAll(); + else await updateAllPackages(process.cwd()); }, }) .command({ @@ -224,7 +286,12 @@ if (process.argv.length <= 2) { .help(true); }, handler: async (argv) => { - await updatePackages(argv._.slice(1) as string[], { + const packages = argv._.slice(1) as string[]; + if (packages.length === 0 && shouldRunInteractive(argv, 1)) { + await handleRestore(); + return; + } + await updatePackages(packages, { update: !!(argv.update || argv.upgrade), restore: true, workingDir: process.cwd(), @@ -241,7 +308,16 @@ if (process.argv.length <= 2) { .help(true); }, handler: async (argv) => { - await removePackages(argv._.slice(1) as string[], { + const packages = argv._.slice(1) as string[]; + if ( + packages.length === 0 && + !argv.all && + shouldRunInteractive(argv, 1) + ) { + await handleRemove(); + return; + } + await removePackages(packages, { retreat: !!argv.retreat, workingDir: process.cwd(), all: !!argv.all, @@ -254,7 +330,16 @@ if (process.argv.length <= 2) { 'Remove packages from project, but leave in lock file (to be restored later)', builder: (y) => y.boolean(['all']).help(true), handler: async (argv) => { - await removePackages(argv._.slice(1) as string[], { + const packages = argv._.slice(1) as string[]; + if ( + packages.length === 0 && + !argv.all && + shouldRunInteractive(argv, 1) + ) { + await handleRetreat(); + return; + } + await removePackages(packages, { all: !!argv.all, retreat: true, workingDir: process.cwd(), @@ -314,11 +399,6 @@ if (process.argv.length <= 2) { handler: (argv) => { const inputCommand = argv._[0] as string; if (!inputCommand) { - if (argv.version) { - console.log(getVersionMessage()); - } else { - console.log('Use `devlink --help` to see available commands.'); - } return; } diff --git a/src/interactive.ts b/src/interactive.ts index c96b9d3..84927b9 100644 --- a/src/interactive.ts +++ b/src/interactive.ts @@ -17,14 +17,17 @@ import pc from 'picocolors'; import { addPackages, getPackageStoreDir, + parsePackageName, publishPackage, readPackageManifest, removePackages, updatePackages, } from './index.js'; import { cleanInstallations, showInstallations } from './installations.js'; +import { readLockfile } from './lockfile.js'; import { publishPackageWatch } from './publish.js'; import { readStore, removePackageVersionFromStore } from './store.js'; +import { updateAllPackages } from './update.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -38,7 +41,7 @@ function getVersion() { } } -async function handlePublish() { +export async function handlePublish() { const workingDir = process.cwd(); const pkg = readPackageManifest(workingDir); @@ -56,7 +59,7 @@ async function handlePublish() { const useContent = await confirm({ message: 'Show published files list?', - initialValue: false, + initialValue: true, }); if (isCancel(useContent)) return; @@ -87,7 +90,7 @@ async function handlePublish() { } } -async function handleAdd() { +export async function handleAdd(packageName?: string) { const store = readStore(); const packageNames = Object.keys(store.packages); @@ -96,26 +99,58 @@ async function handleAdd() { return; } - const selectedPackage = await select({ - message: 'Select a package to add:', - options: packageNames.map((name) => ({ value: name, label: name })), - }); + let selectedPackage = ''; + let selectedVersion = ''; - if (isCancel(selectedPackage)) return; + if (packageName) { + const parsed = parsePackageName(packageName); + selectedPackage = parsed.name; + selectedVersion = parsed.version; + } + + if (!selectedPackage) { + const selected = await select({ + message: 'Select a package to add from store:', + options: packageNames.map((name) => ({ value: name, label: name })), + }); + + if (isCancel(selected)) return; + selectedPackage = selected as string; + } + + const pkgData = store.packages[selectedPackage]; + if (!pkgData) { + note(pc.red(`Package ${selectedPackage} not found in store.`), 'Error'); + return; + } - const pkgData = store.packages[selectedPackage as string]; const versions = Object.keys(pkgData.versions).sort((a, b) => { const timeA = new Date(pkgData.versions[a].publishedAt).getTime(); const timeB = new Date(pkgData.versions[b].publishedAt).getTime(); return timeB - timeA; }); - const selectedVersion = await select({ - message: `Select version for ${pc.cyan(selectedPackage as string)}:`, - options: versions.map((v) => ({ value: v, label: v })), - }); + if (!selectedVersion) { + const version = await select({ + message: `Select version for ${pc.cyan(selectedPackage)}:`, + options: versions.map((v) => ({ value: v, label: v })), + }); - if (isCancel(selectedVersion)) return; + if (isCancel(version)) return; + selectedVersion = version as string; + } else { + // Validate provided version + if (!pkgData.versions[selectedVersion]) { + note( + pc.red( + `Version ${selectedVersion} of ${selectedPackage} not found in store.\n` + + `Available versions: ${versions.join(', ')}`, + ), + 'Error', + ); + return; + } + } const flags = await multiselect({ message: 'Select installation flags:', @@ -145,10 +180,10 @@ async function handleAdd() { workspace: (flags as string[]).includes('workspace'), }); - s.stop(`${pc.green(selectedPackage as string)} added successfully!`); + s.stop(`${pc.green(selectedPackage)} added successfully!`); } -async function handleInstallations() { +export async function handleInstallations() { const action = await select({ message: 'Manage installations:', options: [ @@ -162,30 +197,163 @@ async function handleInstallations() { if (action === 'show') { showInstallations({ packages: [] }); } else { - // For clean, we could prompt for specific packages, but let's keep it simple for now or implement multi-select const dryRun = await confirm({ message: 'Dry run?', initialValue: true, }); if (isCancel(dryRun)) return; + const s = spinner(); + s.start('Cleaning installations...'); await cleanInstallations({ packages: [], dry: dryRun }); + s.stop('Clean completed.'); } } -async function handleUpdate() { - // Similar to add but only for packages already in the project - // Actually, devlink update usually updates all or specific ones +export async function handleUpdate() { const workingDir = process.cwd(); - // We should ideally read the devlink.lock or package.json to see what's devlinked - // For now, call the standard update which handles this + const lockfile = readLockfile({ workingDir }); + const linkedPackages = Object.keys(lockfile.packages); + + if (linkedPackages.length === 0) { + note(pc.yellow('No packages are devlinked in this project.'), 'Info'); + return; + } + + const selectedPackages = await multiselect({ + message: 'Select packages to update:', + options: [ + { value: 'all', label: 'All packages', hint: 'update everything' }, + ...linkedPackages.map((name) => ({ value: name, label: name })), + ], + }); + + if (isCancel(selectedPackages)) return; + + const toUpdate = (selectedPackages as string[]).includes('all') + ? linkedPackages + : (selectedPackages as string[]); + + const updates: string[] = []; + const store = readStore(); + + for (const pkgName of toUpdate) { + const pkgData = store.packages[pkgName]; + if (!pkgData) { + note( + pc.yellow(`Package ${pkgName} not found in store, skipping.`), + 'Warning', + ); + continue; + } + + const versions = Object.keys(pkgData.versions).sort((a, b) => { + const timeA = new Date(pkgData.versions[a].publishedAt).getTime(); + const timeB = new Date(pkgData.versions[b].publishedAt).getTime(); + return timeB - timeA; + }); + + const selectedVersion = await select({ + message: `Select version for ${pc.cyan(pkgName)} (currently ${lockfile.packages[pkgName].version}):`, + options: [ + { value: 'latest', label: 'latest', hint: versions[0] }, + ...versions.map((v) => ({ value: v, label: v })), + ], + }); + + if (isCancel(selectedVersion)) return; + updates.push( + `${pkgName}@${selectedVersion === 'latest' ? versions[0] : selectedVersion}`, + ); + } + + if (updates.length > 0) { + const s = spinner(); + s.start('Updating packages...'); + await updatePackages(updates, { workingDir, update: true }); + s.stop('Updates completed.'); + } +} + +export async function handleUpdateAll() { + const workingDir = process.cwd(); + const s = spinner(); + s.start('Updating all devlinked packages to latest...'); + await updateAllPackages(workingDir); + s.stop('All packages updated to latest.'); +} + +export async function handleRetreat() { + const workingDir = process.cwd(); + const lockfile = readLockfile({ workingDir }); + const linkedPackages = Object.keys(lockfile.packages); + + if (linkedPackages.length === 0) { + note(pc.yellow('No packages to retreat.'), 'Info'); + return; + } + + const selected = await multiselect({ + message: 'Select packages to retreat:', + options: [ + { value: 'all', label: 'All packages' }, + ...linkedPackages.map((name) => ({ value: name, label: name })), + ], + }); + + if (isCancel(selected)) return; + + const toRetreat = (selected as string[]).includes('all') + ? [] + : (selected as string[]); + const isAll = (selected as string[]).includes('all'); + const s = spinner(); - s.start('Updating devlinked packages...'); - await updatePackages([], { workingDir, update: true }); - s.stop('Updates completed.'); + s.start('Retreating packages...'); + await removePackages(toRetreat, { workingDir, retreat: true, all: isAll }); + s.stop('Retreat completed.'); } -async function handleStore() { +export async function handleRestore() { + const workingDir = process.cwd(); + const s = spinner(); + s.start('Restoring retreated packages...'); + await updatePackages([], { workingDir, restore: true }); + s.stop('Restore completed.'); +} + +export async function handleRemove() { + const workingDir = process.cwd(); + const lockfile = readLockfile({ workingDir }); + const linkedPackages = Object.keys(lockfile.packages); + + if (linkedPackages.length === 0) { + note(pc.yellow('No packages to remove.'), 'Info'); + return; + } + + const selected = await multiselect({ + message: 'Select packages to remove:', + options: [ + { value: 'all', label: 'All packages' }, + ...linkedPackages.map((name) => ({ value: name, label: name })), + ], + }); + + if (isCancel(selected)) return; + + const toRemove = (selected as string[]).includes('all') + ? [] + : (selected as string[]); + const isAll = (selected as string[]).includes('all'); + + const s = spinner(); + s.start('Removing packages...'); + await removePackages(toRemove, { workingDir, all: isAll }); + s.stop('Removal completed.'); +} + +export async function handleStore() { const store = readStore(); const packageNames = Object.keys(store.packages); @@ -277,9 +445,10 @@ async function handleStore() { export async function startInteractive() { console.clear(); - const welcome = figlet.textSync('DEVLINK', { font: 'Graceful' as any }); + const welcome = figlet.textSync('DEVLINK', { font: 'Graceful' }); console.log(pc.cyan(welcome)); - console.log(`${pc.dim(`v${getVersion()}`)}\n`); + console.log(pc.dim(' by MayR Labs')); + console.log(`${pc.dim(` v${getVersion()}`)}\n`); intro(pc.bgCyan(pc.black(' Welcome to Devlink Interactive '))); @@ -294,17 +463,20 @@ export async function startInteractive() { label: '🔄 Update', hint: 'Sync devlinked packages', }, + { + value: 'update-all', + label: '⚡ Update All', + hint: 'Update all to latest', + }, { value: 'installations', label: '🏘️ Installations', hint: 'Manage devlinked projects', }, + { value: 'retreat', label: '🏃 Retreat', hint: 'Temporarily remove' }, + { value: 'restore', label: '⏪ Restore', hint: 'Restore retreated' }, + { value: 'remove', label: '🗑️ Remove', hint: 'Uninstall package' }, { value: 'store', label: '📦 Store', hint: 'Browse local repository' }, - { - value: 'remove', - label: '🗑️ Remove', - hint: 'Uninstall devlinked package', - }, { value: 'exit', label: '🚪 Exit' }, ], }); @@ -325,23 +497,24 @@ export async function startInteractive() { case 'update': await handleUpdate(); break; + case 'update-all': + await handleUpdateAll(); + break; case 'installations': await handleInstallations(); break; case 'store': await handleStore(); break; - case 'remove': { - const pkgs = await text({ - message: 'Enter package names to remove (space separated):', - }); - if (!isCancel(pkgs)) { - await removePackages((pkgs as string).split(' '), { - workingDir: process.cwd(), - }); - } + case 'retreat': + await handleRetreat(); + break; + case 'restore': + await handleRestore(); + break; + case 'remove': + await handleRemove(); break; - } } } catch (e: any) { note(pc.red(e.message || 'An error occurred'), 'Error'); diff --git a/src/publish.ts b/src/publish.ts index 10eea6a..f7bcef4 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -1,17 +1,14 @@ import { execSync } from 'node:child_process'; import { join } from 'node:path'; import chokidar from 'chokidar'; - import { copyPackageToStore } from './copy.js'; import { - type PackageManifest, type PackageScripts, execLoudOptions, getPackageManager, getStorePackagesDir, readPackageManifest, updatePackages, - values, } from './index.js'; import { type PackageInstallation, @@ -140,6 +137,8 @@ export const publishPackageWatch = async (options: PublishPackageOptions) => { let isPublishing = false; let pendingPublish = false; + let debounceTimeout: NodeJS.Timeout | null = null; + const watchOptions = { ...options, changed: options.changed ?? true }; const runPublish = async () => { if (isPublishing) { @@ -149,7 +148,7 @@ export const publishPackageWatch = async (options: PublishPackageOptions) => { isPublishing = true; try { - await publishPackage(options); + await publishPackage(watchOptions); } catch (e) { console.error('Error during republish:', e); } finally { @@ -164,18 +163,52 @@ export const publishPackageWatch = async (options: PublishPackageOptions) => { await runPublish(); const watcher = chokidar.watch(workingDir, { - ignored: [ - '**/node_modules/**', - '**/.git/**', - join(workingDir, 'package.json'), - ], + ignored: (path: string) => { + // Always allow the working directory itself + if (path === workingDir || path === `${workingDir}/`) return false; + + const relativePath = path.startsWith(workingDir) + ? path.slice(workingDir.length).replace(/^[/\\]+/, '') + : path; + + const ignorePatterns = [ + /^node_modules[/\\]/, + /^\.git[/\\]/, + /^dist[/\\]/, + /^build[/\\]/, + /^out[/\\]/, + /^lib[/\\]/, + /^target[/\\]/, + /^vendor[/\\]/, + /^\.next[/\\]/, + /^\.nuxt[/\\]/, + /^\.output[/\\]/, + /^package-lock\.json$/, + /^pnpm-lock\.yaml$/, + /^yarn\.lock$/, + /^package\.json$/, + /^devlink\.lock$/, + /^devlink\.sig$/, + ]; + + return ignorePatterns.some((pattern) => pattern.test(relativePath)); + }, persistent: true, ignoreInitial: true, }); + const originalClose = watcher.close.bind(watcher); + watcher.close = async () => { + if (debounceTimeout) clearTimeout(debounceTimeout); + return originalClose(); + }; + watcher.on('all', async (event, path) => { - console.log(`File ${path} ${event}, republishing...`); - await runPublish(); + if (debounceTimeout) clearTimeout(debounceTimeout); + debounceTimeout = setTimeout(async () => { + console.log(`File ${path} ${event}, republishing...`); + await runPublish(); + }, 200); }); const cleanup = async () => { diff --git a/test/watch.test.ts b/test/watch.test.ts index 08dfd0a..4e0814c 100644 --- a/test/watch.test.ts +++ b/test/watch.test.ts @@ -22,8 +22,9 @@ describe('Watch Mode', function () { // Dummy index.js fs.writeFileSync(join(tmpDir, 'index.js'), 'console.log("hello")'); - // Set store in a separate tmp location - const storeDir = join(tmpDir, 'store'); + // Set store in a separate tmp location outside of the watched tmpDir + const storeDir = join(__dirname, 'tmp-watch-store'); + fs.rmSync(storeDir, { recursive: true, force: true }); fs.mkdirSync(storeDir, { recursive: true }); devlinkGlobal.devlinkStoreMainDir = storeDir; }); @@ -32,6 +33,7 @@ describe('Watch Mode', function () { const watcher = await publishPackageWatch({ workingDir: tmpDir, push: false, + changed: false, // Ensure it always publishes for testing }); try { @@ -39,9 +41,7 @@ describe('Watch Mode', function () { fs.writeFileSync(join(tmpDir, 'index.js'), 'console.log("changed")'); // Wait a bit for the watcher to trigger and publish to finish - // We can't easily wait for the internal `runPublish` to finish without more hooks, - // but we can check the store after some time. - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 3000)); const storePackagesDir = join( devlinkGlobal.devlinkStoreMainDir as string, @@ -55,4 +55,87 @@ describe('Watch Mode', function () { await watcher.close(); } }); + + it('should debounce multiple rapid changes', async () => { + // Note: We are testing that it doesn't crash or trigger dozens of times. + const watcher = await publishPackageWatch({ + workingDir: tmpDir, + push: false, + changed: false, + signature: true, // Use signatures so we get different version folders + }); + + try { + // Wait for initial publish + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const storePackagesDir = join( + devlinkGlobal.devlinkStoreMainDir as string, + 'packages', + 'watch-pkg', + ); + + // Rapid changes + for (let i = 0; i < 3; i++) { + fs.writeFileSync( + join(tmpDir, 'index.js'), + `console.log("change ${i}")`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + // Wait for debounce and publish + await new Promise((resolve) => setTimeout(resolve, 3000)); + const versionsBefore = fs.readdirSync(storePackagesDir); + + // Trigger one more change + fs.writeFileSync(join(tmpDir, 'index.js'), 'console.log("final")'); + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const versionsAfter = fs.readdirSync(storePackagesDir); + ok( + versionsAfter.length > versionsBefore.length, + `Expected more versions. Before: ${versionsBefore.length}, After: ${versionsAfter.length}`, + ); + } finally { + await watcher.close(); + } + }); + + it('should ignore changes in ignored directories like dist/', async () => { + const watcher = await publishPackageWatch({ + workingDir: tmpDir, + push: false, + changed: false, + signature: true, + }); + + try { + const distDir = join(tmpDir, 'dist'); + fs.mkdirSync(distDir, { recursive: true }); + + const storePackagesDir = join( + devlinkGlobal.devlinkStoreMainDir as string, + 'packages', + 'watch-pkg', + ); + // Wait for initial publish to finish + await new Promise((resolve) => setTimeout(resolve, 2000)); + const versionsBefore = fs.readdirSync(storePackagesDir).length; + + // Create file in dist/ + fs.writeFileSync(join(distDir, 'bundle.js'), 'console.log("built")'); + + // Wait to ensure NO republish happens + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const versionsAfter = fs.readdirSync(storePackagesDir).length; + ok( + versionsAfter === versionsBefore, + `Should NOT have published a new version for changes in dist/. Before: ${versionsBefore}, After: ${versionsAfter}`, + ); + } finally { + await watcher.close(); + } + }); });