Wave/2 sweet updates#3
Conversation
Add an interactive dashboard (run `devlink`) and refactor CLI to support new commands (publish:watch, store, git ignore|show, update-all, etc.). Introduce store management (store.json constant and new store module), increment installation counts on add, and wire publish watch flow. Update copy/publish logic (use npm-packlist without custom ignore handling) and add helpful fuzzy-matching for unknown commands. Add new support files (SUGGESTIONS.md, src/git.ts, src/interactive.ts, src/store.ts), add several dependencies (e.g. @clack/prompts, fast-glob, chokidar, figlet, picocolors, string-similarity) and update package.json/pnpm lock accordingly.
Add release notes for 0.0.2 documenting major enhancements (interactive CLI/dashboard, persistent store, fast-glob discovery, watch mode, git integration, CLI suggestions) and infrastructure cleanups (ESM migration, npm-packlist, removed legacy commands and deprecated ignore). Also bump package.json version to 0.0.2 and fix minor changelog formatting/link issues.
There was a problem hiding this comment.
Pull request overview
This PR bumps @mayrlabs/devlink to 0.0.2 and modernizes the CLI and underlying tooling by adding an interactive dashboard, introducing persistent store metadata, improving file discovery performance, and expanding documentation.
Changes:
- Added an interactive CLI mode (default when running
devlinkwith no args), plus “did you mean?” suggestions and newgit/storeCLI flows. - Introduced a persistent
store.jsonmetadata file (publish/install tracking) and a publish watch mode usingchokidar. - Switched directory sync discovery to
fast-glob, updated docs/changelog, and added several new dependencies.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| SUGGESTIONS.md | Adds a forward-looking roadmap document. |
| src/update.ts | Adds updateAllPackages() wrapper for updating all devlinked packages. |
| src/sync-dir.ts | Replaces glob with fast-glob for faster directory enumeration during sync. |
| src/store.ts | Introduces store.json read/write and basic package/version metadata operations. |
| src/remove.ts | Hooks uninstall flow into store installation count tracking (but currently has a removal logic bug). |
| src/publish.ts | Updates store metadata on publish and adds watch mode (publishPackageWatch). |
| src/interactive.ts | New interactive dashboard for publish/add/update/installations/store/remove workflows. |
| src/index.ts | Adjusts global store path logic and removes legacy ignore file reader. |
| src/git.ts | Adds .gitignore management commands (git ignore / git show). |
| src/devlink.ts | Adds interactive default behavior, new commands, and command-suggestion handling. |
| src/copy.ts | Uses npm-packlist directly for publish file selection; removes .devlinkignore filtering. |
| src/constants.ts | Adds storeFileName constant and removes ignore filename constant. |
| src/add.ts | Increments store installation counter when installing from the store. |
| README.md | Major rewrite for quick-start, interactive mode, and new command reference. |
| pnpm-lock.yaml | Updates lockfile for new dependencies and versions. |
| package.json | Bumps version to 0.0.2 and adds new runtime/dev dependencies. |
| CHANGELOG.md | Adds 0.0.2 changelog entry and minor formatting tweaks. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (const name of packagesToRemove) { | ||
| if (!options.retreat) { | ||
| if (fs.existsSync(join(devlinkFolder, name))) { | ||
| const lockedPackage = lockFileConfig.packages[name]; | ||
| if (lockedPackage?.version && fs.existsSync(join(devlinkFolder, name))) { | ||
| fs.rmSync(join(devlinkFolder, name), { recursive: true, force: true }); | ||
| decrementInstallation(name, lockedPackage.version); | ||
| } |
There was a problem hiding this comment.
Bug: this loop reads lockedPackage from lockFileConfig.packages after entries were potentially deleted earlier (when !options.retreat). As a result lockedPackage will usually be undefined here, so the .mayrlabs/devlink/<pkg> folder won’t be removed and decrementInstallation won’t run. Capture the package version before deleting it from the lockfile (e.g., build a map of {name -> version} from the original lockfile), or perform the filesystem removal/decrement before mutating lockFileConfig.packages.
| watcher.on('all', async (event, path) => { | ||
| console.log(`File ${path} ${event}, republishing...`); | ||
| try { | ||
| await publishPackage(options); | ||
| } catch (e) { | ||
| console.error('Error during republish:', e); | ||
| } | ||
| }); |
There was a problem hiding this comment.
publishPackageWatch can trigger overlapping publishPackage() runs when multiple file events fire quickly (chokidar often emits bursts). This can lead to concurrent writes/copies and inconsistent store state. Serialize republish calls (e.g., with an in-flight flag + rerun, or a debounce/throttle queue) and consider closing the watcher on SIGINT/SIGTERM so watch mode exits cleanly.
| watcher.on('all', async (event, path) => { | |
| console.log(`File ${path} ${event}, republishing...`); | |
| try { | |
| await publishPackage(options); | |
| } catch (e) { | |
| console.error('Error during republish:', e); | |
| } | |
| }); | |
| let isPublishing = false; | |
| let rerunRequested = false; | |
| const republish = async () => { | |
| if (isPublishing) { | |
| // A publish is already in progress; schedule a rerun after it finishes. | |
| rerunRequested = true; | |
| return; | |
| } | |
| do { | |
| rerunRequested = false; | |
| isPublishing = true; | |
| try { | |
| await publishPackage(options); | |
| } catch (e) { | |
| console.error('Error during republish:', e); | |
| } finally { | |
| isPublishing = false; | |
| } | |
| // If rerunRequested was set to true while we were publishing, | |
| // the loop will run again to process one more publish. | |
| } while (rerunRequested); | |
| }; | |
| watcher.on('all', (event, path) => { | |
| console.log(`File ${path} ${event}, republishing...`); | |
| void republish(); | |
| }); | |
| const stopWatching = () => { | |
| console.log('Stopping watch...'); | |
| watcher | |
| .close() | |
| .then(() => { | |
| process.exit(0); | |
| }) | |
| .catch((err) => { | |
| console.error('Error while closing watcher:', err); | |
| process.exit(1); | |
| }); | |
| }; | |
| process.on('SIGINT', stopWatching); | |
| process.on('SIGTERM', stopWatching); |
| if (vAction === 'delete') { | ||
| const sure = await confirm({ message: 'Are you sure?' }); | ||
| if (sure) { | ||
| removePackageVersionFromStore( | ||
| selectedPackage as string, | ||
| selectedVersion as string, | ||
| ); | ||
| note(`Deleted ${selectedPackage}@${selectedVersion}`); |
There was a problem hiding this comment.
The “Delete version” action only removes metadata from store.json via removePackageVersionFromStore, but it does not delete the corresponding package files from the global store directory. This will orphan data on disk while the UI reports it deleted. Consider removing the on-disk folder (e.g., getPackageStoreDir(name, version) with fs.rmSync(..., { recursive:true, force:true })) in the same flow, and drop the now-unused getPackageStoreDir import if not needed.
| const pkgData = store.packages[selectedPackage as string]; | ||
| const versions = Object.keys(pkgData.versions).sort().reverse(); | ||
|
|
||
| const selectedVersion = await select({ | ||
| message: `Select version for ${pc.cyan(selectedPackage as string)}:`, | ||
| options: versions.map((v) => ({ value: v, label: v })), | ||
| }); |
There was a problem hiding this comment.
Version selection is sorted lexicographically (Object.keys(...).sort().reverse()), which will misorder semver versions (e.g., 10.0.0 before 2.0.0) and any +sig metadata versions. Sort by publishedAt from the store data or use a semver-aware comparator so the “latest” versions appear first.
| "@clack/prompts": "^1.2.0", | ||
| "chalk": "^4.1.0", | ||
| "chokidar": "^5.0.0", | ||
| "detect-indent": "^6.0.0", | ||
| "fast-glob": "^3.3.3", | ||
| "figlet": "^1.11.0", | ||
| "glob": "^7.1.4", | ||
| "ignore": "^5.0.4", | ||
| "ini": "^2.0.0", | ||
| "npm-packlist": "^5.0.2", | ||
| "picocolors": "^1.1.1", | ||
| "string-similarity": "^4.0.4", | ||
| "yargs": "^16.1.1" | ||
| }, |
There was a problem hiding this comment.
New deps introduce maintenance/compatibility risk: string-similarity is marked deprecated in the lockfile, and chokidar@5.0.0 requires Node >= 20.19.0 (see pnpm-lock), which may break installs on commonly used Node versions unless you also declare engines. Also glob/ignore appear unused in src/ after the fast-glob/npm-packlist migration; consider removing unused deps (and @types/glob) or updating usage accordingly.
|
|
||
| chokidar@5.0.0: | ||
| resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} | ||
| engines: {node: '>= 20.19.0'} |
There was a problem hiding this comment.
chokidar@5.0.0 declares engines: { node: '>= 20.19.0' }, which can cause installation failures if users run earlier Node 20.x/18.x. Either pin to a chokidar version compatible with your supported Node range, or add an explicit engines field in package.json to match this requirement and document it.
| engines: {node: '>= 20.19.0'} | |
| engines: {node: '>=18'} |
| export const publishPackageWatch = async (options: PublishPackageOptions) => { | ||
| const { workingDir } = options; | ||
| console.log(`Watching for changes in ${workingDir}...`); | ||
|
|
||
| await publishPackage(options); | ||
|
|
||
| const watcher = chokidar.watch(workingDir, { | ||
| ignored: [ | ||
| '**/node_modules/**', | ||
| '**/.git/**', | ||
| join(workingDir, 'package.json'), // Avoid loop if version changes? Actually devlink doesn't change version in src usually. | ||
| ], | ||
| persistent: true, | ||
| ignoreInitial: true, | ||
| }); | ||
|
|
||
| watcher.on('all', async (event, path) => { | ||
| console.log(`File ${path} ${event}, republishing...`); | ||
| try { | ||
| await publishPackage(options); | ||
| } catch (e) { | ||
| console.error('Error during republish:', e); | ||
| } | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Test coverage: the new watch mode (publish --watch / publishPackageWatch) isn’t covered by the existing publish tests. Add at least an integration test that verifies the watcher triggers a republish on file change (and that publishes are serialized/debounced to avoid concurrent runs).
Append signature suffix to stored package versions and propagate the resulting finalVersion through copy/publish flows. copy.ts now computes a signature-based version suffix, writes manifests to a versioned store dir, and returns finalVersion instead of just a signature. publish.ts consumes finalVersion, uses it for published paths, and returns it. add.ts reads lockfile when restoring and uses the resolved versionToInstall. remove.ts preserves removed versions to correctly remove/decrement installations. Tests updated to expect finalVersion (async hooks, signature-aware version checks) and a new modern.test.ts with store/git integration checks. package.json test script broadened to run all test/*.ts files.
Make the publish watcher reentrant and robust (queue concurrent publishes, add cleanup on SIGINT/SIGTERM, return watcher). Replace string-similarity with a local Levenshtein-based suggestion (add src/suggest.ts) and wire it into command suggestions. Use picocolors for console coloring. Fix store handling: decrement previous installation when adding a new version, sort package versions by publishedAt, and remove package files/dirs when deleting from the store. Harden gitShow regex by escaping entries. Update package.json (engine >=20.19.0) and remove unused dependencies/devDependencies. Add tests and fixtures for watch mode (test/watch.test.ts and tmp-watch store files).
Regenerate pnpm lockfile to remove obsolete/unused package entries and snapshots. The update drops several older or deduplicated packages (examples: chalk, glob@7, string-similarity, concat-map, path-is-absolute, multiple brace-expansion/balanced-match/minimatch versions, and associated @types entries). This is a lockfile-only change resulting from dependency dedupe/cleanup—no source code changes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 23 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| cwd: srcDir, | ||
| ignore: ['**/node_modules/**'], | ||
| dot: true, | ||
| absolute: false, |
There was a problem hiding this comment.
copyDirSafe previously used glob with nodir: false (i.e., included directories), but the fast-glob call doesn’t set an equivalent option. The rest of the function expects directories to be present in srcList/destList (e.g., to create new directories before copying nested files). Please set onlyFiles: false (and/or an explicit directory-including pattern) so nested directory copies don’t fail with ENOENT when new folders appear.
| absolute: false, | |
| absolute: false, | |
| // Include directories as well as files so directory creation logic works correctly. | |
| onlyFiles: false, |
| const cleanup = async () => { | ||
| console.log('Closing watcher...'); | ||
| await watcher.close(); | ||
| process.exit(0); | ||
| }; | ||
|
|
||
| process.on('SIGINT', cleanup); | ||
| process.on('SIGTERM', cleanup); | ||
|
|
There was a problem hiding this comment.
publishPackageWatch registers SIGINT/SIGTERM handlers and calls process.exit(0) from within the library function. This can unexpectedly terminate callers (including tests/interactive flows) and the signal listeners aren’t removed when the watcher is closed, which can leak listeners across repeated invocations. Consider moving signal handling + exit behavior to the CLI layer, or have publishPackageWatch return a cleanup function / register listeners behind an option and unregister them on watcher.close().
| const cleanup = async () => { | |
| console.log('Closing watcher...'); | |
| await watcher.close(); | |
| process.exit(0); | |
| }; | |
| process.on('SIGINT', cleanup); | |
| process.on('SIGTERM', cleanup); | |
| const originalClose = watcher.close.bind(watcher); | |
| const removeSignalHandlers = (handler: () => void) => { | |
| process.off('SIGINT', handler); | |
| process.off('SIGTERM', handler); | |
| }; | |
| const cleanup = async () => { | |
| console.log('Closing watcher...'); | |
| await watcher.close(); | |
| }; | |
| const handleSignal = () => { | |
| void cleanup(); | |
| }; | |
| watcher.close = (async () => { | |
| removeSignalHandlers(handleSignal); | |
| await originalClose(); | |
| }) as typeof watcher.close; | |
| process.on('SIGINT', handleSignal); | |
| process.on('SIGTERM', handleSignal); |
| @@ -181,52 +164,54 @@ export const copyPackageToStore = async (options: { | |||
| filesToCopy | |||
| .sort() | |||
| .map((relPath) => | |||
| copyFile( | |||
| join(copyFromDir, relPath), | |||
| join(storePackageStoreDir, relPath), | |||
| relPath, | |||
| ), | |||
| copyFile(join(copyFromDir, relPath), join(destDir, relPath), relPath), | |||
| ), | |||
| ); | |||
There was a problem hiding this comment.
copyPackageToStore computes file hashes up-front to build the signature, but copyFilesToStore then calls copyFile, which hashes each file again (its return value isn’t used). This doubles IO on publish. Consider making copyFile not compute a hash, or reuse the hash results from copyFilesToStore to build the signature so each file is read once.
Introduce 'publish:watch' to devlink commands and enable workspaceResolve in interactive publish options. Replace direct readFileSync import with fs.readFileSync and remove several unused imports (exec, cancel, equal). Minor formatting change in copy.ts and update tests to remove an unused assertion import.
Remove relPath handling and the getFileHash call from copyFile: it now ensures the destination directory and returns the fsPromises.cp() promise directly (callers updated). Also add onlyFiles: false to fast-glob options in copyDirSafe so directories are included in glob results. These changes avoid redundant hashing during file copy and ensure directory entries are considered by the glob.
This pull request introduces Devlink version 0.0.2, featuring a major overhaul with a modernized, interactive developer experience, significant performance improvements, and enhanced documentation. The update includes a new interactive CLI dashboard, a persistent local package store, faster file discovery, improved git integration, and smarter CLI suggestions. The documentation has been completely rewritten for clarity, and several new dependencies have been added to support these features.
Major Feature Additions and Enhancements:
devlinkwithout arguments launches this new interface, making workflows more intuitive and user-friendly. [1] [2]globtofast-globfor significantly faster file discovery, especially in large projects. [1] [2] [3]npm-packlistfor more reliable file inclusion, respecting.gitignoreand.npmignore.chokidarfor watch mode, enablingdevlink publish --watchto automatically sync changes. [1] [2] [3]~/.mayrlabs/devlink, with commands to manage and browse packages. [1] [2]devlink git ignore/show) help manage devlink files in.gitignore. [1] [2]package.jsonscripts..devlinkignoreand redundant binaries.Documentation and Developer Experience:
README.mdhas been fully rewritten with a new quick start, feature highlights, security notes, and a command reference for easier onboarding.SUGGESTIONS.mdfile outlining future improvements and ideas for the project.Dependency Updates:
@clack/prompts,fast-glob,chokidar,figlet,picocolors, andstring-similarity(with corresponding type packages). [1] [2] [3] [4] [5]0.0.2.These changes collectively deliver a much more robust, user-friendly, and high-performance local package development tool.