Skip to content

feat: add --watch mode for automatic exports regeneration#24

Merged
cody-dot-js merged 4 commits intomainfrom
watch-mode
Feb 10, 2026
Merged

feat: add --watch mode for automatic exports regeneration#24
cody-dot-js merged 4 commits intomainfrom
watch-mode

Conversation

@cody-dot-js
Copy link
Collaborator

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.

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.
@cody-dot-js cody-dot-js requested a review from Copilot February 10, 2026 14:07
@cody-dot-js cody-dot-js self-assigned this Feb 10, 2026
@changeset-bot
Copy link

changeset-bot bot commented Feb 10, 2026

🦋 Changeset detected

Latest 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

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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;
}

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +86
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;

Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +76
// 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);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
src/watch.ts Outdated
Comment on lines +69 to +76
const watcher = fs.watch(inputDir, { recursive: true }, () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void regenerate();
}, 200);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@cody-dot-js cody-dot-js requested a review from Copilot February 10, 2026 14:34
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +92
fs.watch(inputDir, { recursive: true, signal }, () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void regenerate();
}, 200);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +120
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>(() => {});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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 });
});

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +92
fs.watch(inputDir, { recursive: true, signal }, () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
void regenerate();
}, 200);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +62
const exports = await generateExports(config);
const exportsJson = JSON.stringify(exports);

if (exportsJson === previousExportsJson) {
return;
}

previousExportsJson = exportsJson;
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +130
//,
startWatch,
watch,
};

export type {
//,
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The commented //, entries in the export blocks look like leftover formatting artifacts and add noise. Please remove them to keep the module surface clean.

Suggested change
//,
startWatch,
watch,
};
export type {
//,
startWatch,
watch,
};
export type {

Copilot uses AI. Check for mistakes.
Comment on lines +147 to +156
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);
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@cody-dot-js cody-dot-js merged commit 36cbeb4 into main Feb 10, 2026
11 of 12 checks passed
@cody-dot-js cody-dot-js deleted the watch-mode branch February 10, 2026 14:38
@github-actions github-actions bot mentioned this pull request Feb 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants