@planu/cli 4.1.2 → 4.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## [4.1.3] - 2026-05-21
2
+
3
+ ### Bug Fixes
4
+ - Make the TypeScript file watcher fallback portable across operating systems by avoiding non-portable recursive `fs.watch` mode.
5
+ - Keep the native-core fallback path non-fatal when a platform-specific binary is unavailable.
6
+
7
+ ### Tests
8
+ - Add coverage to prevent reintroducing recursive watcher mode in the portable fallback.
9
+
10
+
1
11
  ## [4.1.2] - 2026-05-21
2
12
 
3
13
  ### Bug Fixes
@@ -180,6 +180,7 @@ const getNative = () => process.env.DISABLE_NATIVE_CORE === '1' ? null : nativeM
180
180
  const PROJECT_ID_RE = /^[a-f0-9]{16,64}$/;
181
181
  const SPEC_ANNOTATION_RE = /@spec\s+(SPEC-\d+)(?:\s+AC:([\d,]+))?(?:\s+(.*))?/;
182
182
  const SCANNABLE_EXTS = new Set(['.ts', '.js', '.tsx', '.jsx', '.rs', '.py', '.go']);
183
+ const MAX_TS_WATCHERS = 128;
183
184
  const IGNORED_DIRS = new Set([
184
185
  'node_modules',
185
186
  '.git',
@@ -671,11 +672,41 @@ export function startNativeWatcher(rootPath, callback) {
671
672
  n.startProjectWatcher(rootPath, callback);
672
673
  return;
673
674
  }
674
- // TS fallback: chokidar would be heavy; use fs.watch lazily.
675
+ // TS fallback: watch each existing directory. Node's `recursive: true` is not
676
+ // portable across all OS/libc combinations, and unsupported platforms must not
677
+ // crash the MCP server when the native watcher is unavailable.
675
678
  const fs = nodeFs;
676
- fs.watch(rootPath, { recursive: true }, (event, filename) => {
677
- callback(null, `${event}:${filename ?? ''}`);
678
- });
679
+ let watcherCount = 0;
680
+ const watchDir = (absDir, relDir) => {
681
+ if (watcherCount >= MAX_TS_WATCHERS) {
682
+ return;
683
+ }
684
+ const watcher = fs.watch(absDir, {}, (event, filename) => {
685
+ callback(null, `${event}:${join(relDir, filename?.toString() ?? '')}`);
686
+ });
687
+ watcherCount++;
688
+ watcher.on('error', (err) => {
689
+ callback(err, '');
690
+ });
691
+ };
692
+ const walkDirs = (absDir, relDir) => {
693
+ watchDir(absDir, relDir);
694
+ let entries;
695
+ try {
696
+ entries = readdirSync(absDir, { withFileTypes: true });
697
+ }
698
+ catch {
699
+ return;
700
+ }
701
+ for (const entry of entries) {
702
+ if (!entry.isDirectory() || IGNORED_DIRS.has(entry.name)) {
703
+ continue;
704
+ }
705
+ const childRel = join(relDir, entry.name);
706
+ walkDirs(join(absDir, entry.name), childRel);
707
+ }
708
+ };
709
+ walkDirs(rootPath, '');
679
710
  }
680
711
  export const isNativeActive = () => nativeModule !== null && process.env.DISABLE_NATIVE_CORE !== '1';
681
712
  export const nativeLoadDiagnostic = () => loadDiagnostic;
@@ -18,9 +18,11 @@ export declare function matchesGlobPattern(filePath: string, pattern: string): b
18
18
  export declare function matchesPatterns(filePath: string, include?: string[], exclude?: string[]): boolean;
19
19
  export declare class FileWatcher extends EventEmitter {
20
20
  private watcher;
21
+ private readonly watchers;
21
22
  private readonly rootDir;
22
23
  private readonly include;
23
24
  private readonly exclude;
25
+ private readonly maxWatchers;
24
26
  /** Track known files to distinguish create vs modify */
25
27
  private readonly knownFiles;
26
28
  private closed;
@@ -42,6 +44,10 @@ export declare class FileWatcher extends EventEmitter {
42
44
  * Graceful shutdown: release all handles.
43
45
  */
44
46
  close(): void;
47
+ private startTypeScriptWatcher;
48
+ private watchDirectory;
49
+ private watchSubdirectories;
50
+ private emitWatcherError;
45
51
  private handleFsEvent;
46
52
  private determineEventType;
47
53
  }
@@ -1,6 +1,6 @@
1
1
  // engine/hooks/file-watcher.ts — File system watcher with normalized events (SPEC-129)
2
2
  import { EventEmitter } from 'node:events';
3
- import { watch } from 'node:fs';
3
+ import { readdirSync, watch } from 'node:fs';
4
4
  import { stat } from 'node:fs/promises';
5
5
  import { normalize, join } from 'node:path';
6
6
  // ---------------------------------------------------------------------------
@@ -73,15 +73,22 @@ export function matchesPatterns(filePath, include, exclude) {
73
73
  // Default excludes
74
74
  // ---------------------------------------------------------------------------
75
75
  const DEFAULT_EXCLUDES = ['data/**', 'node_modules/**', '.git/**'];
76
+ const DEFAULT_MAX_TS_WATCHERS = 128;
77
+ function maxTypeScriptWatchers() {
78
+ const raw = Number(process.env.PLANU_MAX_TS_WATCHERS ?? DEFAULT_MAX_TS_WATCHERS);
79
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_TS_WATCHERS;
80
+ }
76
81
  // ---------------------------------------------------------------------------
77
82
  // FileWatcher
78
83
  // ---------------------------------------------------------------------------
79
84
  import { isNativeActive, startNativeWatcher } from '../core-bridge.js';
80
85
  export class FileWatcher extends EventEmitter {
81
86
  watcher = null;
87
+ watchers = new Set();
82
88
  rootDir;
83
89
  include;
84
90
  exclude;
91
+ maxWatchers = maxTypeScriptWatchers();
85
92
  /** Track known files to distinguish create vs modify */
86
93
  knownFiles = new Set();
87
94
  closed = false;
@@ -112,21 +119,11 @@ export class FileWatcher extends EventEmitter {
112
119
  });
113
120
  }
114
121
  else {
115
- this.watcher = watch(this.rootDir, { recursive: true }, (_eventType, filename) => {
116
- if (!filename || this.closed) {
117
- return;
118
- }
119
- // Normalize path separator
120
- const relPath = filename.replace(/\\/g, '/');
121
- void this.handleFsEvent(relPath);
122
- });
123
- this.watcher.on('error', (err) => {
124
- this.emit('error', err);
125
- });
122
+ this.startTypeScriptWatcher();
126
123
  }
127
124
  }
128
125
  catch (err) {
129
- this.emit('error', err);
126
+ this.emitWatcherError(err);
130
127
  }
131
128
  }
132
129
  /**
@@ -148,15 +145,71 @@ export class FileWatcher extends EventEmitter {
148
145
  */
149
146
  close() {
150
147
  this.closed = true;
151
- if (this.watcher) {
152
- this.watcher.close();
153
- this.watcher = null;
148
+ for (const watcher of this.watchers) {
149
+ watcher.close();
154
150
  }
151
+ this.watchers.clear();
152
+ this.watcher = null;
155
153
  this.removeAllListeners();
156
154
  }
157
155
  // ---------------------------------------------------------------------------
158
156
  // Private
159
157
  // ---------------------------------------------------------------------------
158
+ startTypeScriptWatcher() {
159
+ this.watchDirectory(this.rootDir, '');
160
+ this.watchSubdirectories(this.rootDir, '');
161
+ }
162
+ watchDirectory(absDir, relDir) {
163
+ const watcher = watch(absDir, {}, (_eventType, filename) => {
164
+ if (!filename || this.closed) {
165
+ return;
166
+ }
167
+ const relPath = join(relDir, filename).replace(/\\/g, '/');
168
+ void this.handleFsEvent(relPath);
169
+ });
170
+ watcher.on('error', (err) => {
171
+ this.emitWatcherError(err);
172
+ });
173
+ this.watchers.add(watcher);
174
+ this.watcher ??= watcher;
175
+ }
176
+ watchSubdirectories(absDir, relDir) {
177
+ if (this.watchers.size >= this.maxWatchers) {
178
+ return;
179
+ }
180
+ let entries;
181
+ try {
182
+ entries = readdirSync(absDir, { withFileTypes: true });
183
+ }
184
+ catch {
185
+ return;
186
+ }
187
+ for (const entry of entries) {
188
+ if (!entry.isDirectory()) {
189
+ continue;
190
+ }
191
+ const childRel = join(relDir, entry.name).replace(/\\/g, '/');
192
+ if (this.exclude.some((p) => matchesGlobPattern(childRel, p))) {
193
+ continue;
194
+ }
195
+ const childAbs = join(absDir, entry.name);
196
+ try {
197
+ this.watchDirectory(childAbs, childRel);
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ if (this.watchers.size >= this.maxWatchers) {
203
+ return;
204
+ }
205
+ this.watchSubdirectories(childAbs, childRel);
206
+ }
207
+ }
208
+ emitWatcherError(err) {
209
+ if (this.listenerCount('error') > 0) {
210
+ this.emit('error', err);
211
+ }
212
+ }
160
213
  async handleFsEvent(relPath) {
161
214
  try {
162
215
  // Check exclude patterns first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.1.2",
3
+ "version": "4.1.3",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,12 +32,12 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.1.2",
36
- "@planu/core-darwin-x64": "4.1.2",
37
- "@planu/core-linux-arm64-gnu": "4.1.2",
38
- "@planu/core-linux-arm64-musl": "4.1.2",
39
- "@planu/core-linux-x64-gnu": "4.1.2",
40
- "@planu/core-linux-x64-musl": "4.1.2"
35
+ "@planu/core-darwin-arm64": "4.1.3",
36
+ "@planu/core-darwin-x64": "4.1.3",
37
+ "@planu/core-linux-arm64-gnu": "4.1.3",
38
+ "@planu/core-linux-arm64-musl": "4.1.3",
39
+ "@planu/core-linux-x64-gnu": "4.1.3",
40
+ "@planu/core-linux-x64-musl": "4.1.3"
41
41
  },
42
42
  "engines": {
43
43
  "node": ">=24.0.0"