feat: add --watch mode for automatic exports regeneration#24
feat: add --watch mode for automatic exports regeneration#24cody-dot-js merged 4 commits intomainfrom
Conversation
Watch the input directory for file changes and regenerate package.json#exports automatically. Config is loaded once at startup to avoid repeated esbuild transforms. Writes are skipped when exports haven't changed.
🦋 Changeset detectedLatest commit: 1b4b55a The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| sourceOnly: config.sourceOnly, | ||
| }); | ||
| // Watch mode: run an initial generation, then watch for file changes indefinitely. | ||
| // Config is loaded once above and reused on every regeneration to avoid repeated esbuild transforms. |
There was a problem hiding this comment.
In --watch mode the CLI currently ignores --dry-run and always writes to disk because watch()/startWatch() hardcode dryRun: false. Either reject the combination with a clear error, or thread a dryRun option through watch mode so behavior matches user expectations.
| // Config is loaded once above and reused on every regeneration to avoid repeated esbuild transforms. | |
| // Config is loaded once above and reused on every regeneration to avoid repeated esbuild transforms. | |
| if (options.watch && options.dryRun) { | |
| console.error("Error: --dry-run is not supported in --watch mode. Remove --dry-run or disable --watch."); | |
| process.exitCode = 1; | |
| return; | |
| } |
| test("regenerates exports when a file is added", async () => { | ||
| handle = await startWatch({ | ||
| config: { input: srcDir }, | ||
| packageJsonPath, | ||
| }); | ||
|
|
||
| const waiting = waitForFileChange(packageJsonPath, (pkg) => { | ||
| const exports = pkg.exports as Record<string, unknown> | undefined; | ||
| return exports != null && "./utils" in exports; | ||
| }); | ||
|
|
||
| await fsPromises.writeFile(path.join(srcDir, "utils.ts"), "export const y = 2;"); | ||
|
|
||
| await waiting; | ||
|
|
There was a problem hiding this comment.
This test suite depends on startWatch() which currently uses fs.watch(..., { recursive: true }). That option is unsupported on Linux, and the repo’s CI runs on ubuntu-latest, so these tests (and --watch itself) will fail there until the watcher is made cross-platform.
| // Recursively watch the input directory for any filesystem events (add, delete, rename). | ||
| // Events are debounced to 200ms to batch rapid changes (e.g., renames emit multiple events). | ||
| const watcher = fs.watch(inputDir, { recursive: true }, () => { | ||
| if (debounceTimer) { | ||
| clearTimeout(debounceTimer); | ||
| } | ||
| debounceTimer = setTimeout(() => { | ||
| void regenerate(); | ||
| }, 200); | ||
| }); |
There was a problem hiding this comment.
fs.watch(..., { recursive: true }) is only supported on macOS/Windows; on Linux (including this repo’s Ubuntu CI) it throws/doesn’t watch subdirectories, making --watch non-functional and likely breaking tests. Consider switching to a cross-platform watcher (e.g., chokidar) or implementing a manual recursive directory watcher/fallback behavior when recursive is unsupported.
src/watch.ts
Outdated
| const watcher = fs.watch(inputDir, { recursive: true }, () => { | ||
| if (debounceTimer) { | ||
| clearTimeout(debounceTimer); | ||
| } | ||
| debounceTimer = setTimeout(() => { | ||
| void regenerate(); | ||
| }, 200); | ||
| }); |
There was a problem hiding this comment.
regenerate() can run concurrently: multiple debounced timers can fire while a previous regenerate() is still in-flight, leading to overlapping generateExports() runs and interleaved updatePackageJson() writes (and potentially stale previousExportsJson). Add a simple in-flight guard/queue (e.g., running + pending flags) so only one regeneration runs at a time and missed events trigger exactly one follow-up run.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fs.watch(inputDir, { recursive: true, signal }, () => { | ||
| if (debounceTimer) { | ||
| clearTimeout(debounceTimer); | ||
| } | ||
| debounceTimer = setTimeout(() => { | ||
| void regenerate(); | ||
| }, 200); | ||
| }); |
There was a problem hiding this comment.
fs.watch(..., { recursive: true }) is not supported on all platforms (notably Linux) and can throw at runtime, which would break --watch and likely fail CI on Linux. Consider switching to a cross-platform watcher (e.g., chokidar), or implement a fallback strategy (non-recursive + dynamically watch subdirectories) when recursive watching is unavailable.
| process.exit(0); | ||
| }); | ||
|
|
||
| process.on("SIGTERM", () => { | ||
| abortController.abort(); | ||
| process.exit(0); | ||
| }); | ||
|
|
||
| await startWatch({ ...options, signal: abortController.signal }); | ||
|
|
||
| // Block forever so the process stays alive while fs.watch runs. | ||
| // Signal handlers above ensure clean shutdown on SIGINT/SIGTERM. | ||
| await new Promise<never>(() => {}); |
There was a problem hiding this comment.
Calling process.exit(0) inside the signal handlers can terminate the process while regenerate() / updatePackageJson() is mid-write, risking a partially-written/corrupted package.json. Prefer a graceful shutdown: abort the watcher, wait for any in-flight regeneration to complete, and then allow the process to exit naturally (or force-exit only after a timeout).
| process.exit(0); | |
| }); | |
| process.on("SIGTERM", () => { | |
| abortController.abort(); | |
| process.exit(0); | |
| }); | |
| await startWatch({ ...options, signal: abortController.signal }); | |
| // Block forever so the process stays alive while fs.watch runs. | |
| // Signal handlers above ensure clean shutdown on SIGINT/SIGTERM. | |
| await new Promise<never>(() => {}); | |
| }); | |
| process.on("SIGTERM", () => { | |
| abortController.abort(); | |
| }); | |
| await startWatch({ ...options, signal: abortController.signal }); | |
| // Keep the process alive while fs.watch runs, then exit gracefully once aborted. | |
| await new Promise<void>((resolve) => { | |
| abortController.signal.addEventListener("abort", () => resolve(), { once: true }); | |
| }); |
| fs.watch(inputDir, { recursive: true, signal }, () => { | ||
| if (debounceTimer) { | ||
| clearTimeout(debounceTimer); | ||
| } | ||
| debounceTimer = setTimeout(() => { | ||
| void regenerate(); | ||
| }, 200); | ||
| }); |
There was a problem hiding this comment.
The watcher handle isn’t retained, and the debounce timer isn’t cleared on shutdown. This makes cleanup harder and can allow a queued debounce callback to fire after abort. Consider storing the returned FSWatcher, clearing debounceTimer in an abort handler, and explicitly closing the watcher (even if signal is provided) to keep teardown deterministic.
| const exports = await generateExports(config); | ||
| const exportsJson = JSON.stringify(exports); | ||
|
|
||
| if (exportsJson === previousExportsJson) { | ||
| return; | ||
| } | ||
|
|
||
| previousExportsJson = exportsJson; |
There was a problem hiding this comment.
Using JSON.stringify(exports) as the change detector can still trigger unnecessary writes when object key insertion order varies between regenerations (e.g., filesystem enumeration order differences). To make the “skip write when unchanged” guarantee robust, consider canonicalizing the exports object for comparison (stable/sorted stringify) or ensuring generateExports produces a consistently ordered object.
| //, | ||
| startWatch, | ||
| watch, | ||
| }; | ||
|
|
||
| export type { | ||
| //, |
There was a problem hiding this comment.
The commented //, entries in the export blocks look like leftover formatting artifacts and add noise. Please remove them to keep the module surface clean.
| //, | |
| startWatch, | |
| watch, | |
| }; | |
| export type { | |
| //, | |
| startWatch, | |
| watch, | |
| }; | |
| export type { |
| const { mtimeMs: initialMtime } = await fsPromises.stat(packageJsonPath); | ||
|
|
||
| // Modify file content (not structure) — should not trigger a write | ||
| await fsPromises.writeFile(path.join(srcDir, "index.ts"), "export const x = 999;"); | ||
|
|
||
| // Wait longer than debounce + regeneration | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
|
|
||
| const { mtimeMs: afterMtime } = await fsPromises.stat(packageJsonPath); | ||
| expect(afterMtime).toBe(initialMtime); |
There was a problem hiding this comment.
This assertion can be flaky/insufficient on filesystems with coarse timestamp resolution (and it can also produce false positives if an unintended write happens within the same timestamp tick). A more reliable check is to snapshot package.json contents and assert they’re unchanged, or to instrument/mocks to assert updatePackageJson wasn’t called for no-op changes.
Watch the input directory for file changes and regenerate package.json#exports automatically. Config is loaded once at startup to avoid repeated esbuild transforms. Writes are skipped when exports haven't changed.