@lenne.tech/cli 1.50.2 → 1.50.4

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.
@@ -252,22 +252,19 @@ const UpCommand = {
252
252
  process.exit(1);
253
253
  return 'dev up: port in use';
254
254
  }
255
- // The CLAUDE.md URL block is written HERE, after the internal ports are
256
- // resolved, so it can name both addresses: the `*.localhost` name a browser
257
- // opens and the loopback address a script must use. It used to run before
258
- // allocation, where only the name existed — which is why an agent following
259
- // the block had nothing but a hostname that does not resolve for Node on
260
- // Windows. NEVER for a ticket worktree: that CLAUDE.md is git-tracked and
261
- // would carry per-ticket URLs into a commit.
255
+ // The CLAUDE.md URL block names both addresses: the `*.localhost` name a
256
+ // browser opens and, for a script, the env var that carries the loopback
257
+ // address. Not the port itself: the base CLAUDE.md is git-tracked too, and the
258
+ // port differs per machine, so writing it here dirtied every checkout on each
259
+ // `lt dev up` (see `patchClaudeMd`). NEVER for a ticket worktree: that
260
+ // CLAUDE.md would carry per-ticket URLs into a commit.
262
261
  if (!ticket) {
263
262
  const claudeCandidates = [
264
263
  (0, path_1.join)(layout.root, 'CLAUDE.md'),
265
264
  ...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
266
265
  ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
267
266
  ];
268
- const patched = claudeCandidates
269
- .map((f) => (0, dev_patches_1.patchClaudeMd)(f, { apiPort, appPort, dbName, identity }))
270
- .filter((r) => r.patched);
267
+ const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
271
268
  if (patched.length > 0) {
272
269
  info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
273
270
  }
@@ -125,7 +125,7 @@ function patchApiConfig(file) {
125
125
  * in place.
126
126
  */
127
127
  function patchClaudeMd(file, options) {
128
- const { apiPort, appPort, dbName, identity } = options;
128
+ const { dbName, identity } = options;
129
129
  const startMarker = '<!-- lt-dev:url-block:start -->';
130
130
  const endMarker = '<!-- lt-dev:url-block:end -->';
131
131
  const apiSub = identity.subdomains.api;
@@ -155,14 +155,22 @@ function patchClaudeMd(file, options) {
155
155
  // agent following it would `curl` a name that does not resolve for Node on
156
156
  // Windows, and fail silently. The browser address stays first: it is the one a
157
157
  // human opens.
158
- if (appSub) {
159
- lines.push(`- App: \`https://${appSub.hostname}\`${appPort ? ` — from a script: \`http://127.0.0.1:${appPort}\`` : ''}`);
160
- }
161
- if (apiSub) {
162
- lines.push(`- API: \`https://${apiSub.hostname}\`${apiPort ? ` — from a script: \`http://127.0.0.1:${apiPort}\`` : ''}`);
163
- }
164
- if (appPort || apiPort) {
165
- lines.push('- The `*.localhost` names are resolved by BROWSERS. Node, `curl` and other tools may not resolve them (they do not on Windows) — use the loopback address from a script.');
158
+ //
159
+ // The loopback address is named by its env var, never by its port. This file is
160
+ // committed, and the internal port is allocated per machine (4000 + whatever the
161
+ // other registered projects took), so writing `127.0.0.1:4031` here left every
162
+ // checkout modified after `lt dev up`, and committing it only moved the diff to
163
+ // the next developer. The env var names are the same everywhere.
164
+ if (appSub)
165
+ lines.push(`- App: \`https://${appSub.hostname}\``);
166
+ if (apiSub)
167
+ lines.push(`- API: \`https://${apiSub.hostname}\``);
168
+ const loopbackVars = [
169
+ ...(appSub ? ['`LT_DEV_APP_INTERNAL_URL`'] : []),
170
+ ...(apiSub ? ['`LT_DEV_API_INTERNAL_URL`'] : []),
171
+ ];
172
+ if (loopbackVars.length > 0) {
173
+ lines.push(`- The \`*.localhost\` names are resolved by BROWSERS. Node, \`curl\` and other tools may not resolve them (they do not on Windows), so a script uses the loopback address: ${loopbackVars.join(' / ')} (set in the processes and in \`.lt-dev/.env\`), or the \`→ 127.0.0.1:<port>\` that \`lt dev status\` prints. The port differs per machine, which is why it is not written here.`);
166
174
  }
167
175
  if (dbName)
168
176
  lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
@@ -422,11 +430,14 @@ function patchPlaywrightConfig(file) {
422
430
  * identically to a live one: the block would silently never load
423
431
  * `.lt-dev/.env` again and Playwright would fall back to `localhost:3001`.
424
432
  *
425
- * Quote style and intra-line whitespace are normalised away because the
426
- * consumer's formatter owns this file (which direction it flips is
427
- * project-specific — do not assume). Blind spot to keep in mind: a change
428
- * that differs ONLY in quotes or whitespace is invisible here, which is
429
- * what `BRIDGE_VERSION` is for.
433
+ * Quote style, intra-line whitespace, the padding inside brackets and
434
+ * trailing commas are normalised away because the consumer's formatter owns
435
+ * this file (which direction it flips is project-specific — do not assume).
436
+ * oxfmt at printWidth 80 wraps the bridge imports and adds a trailing comma;
437
+ * read as a code change, that rewrote playwright.config.ts on every run.
438
+ * Blind spot to keep in mind: a change that differs ONLY in quotes,
439
+ * whitespace or trailing commas is invisible here, which is what
440
+ * `BRIDGE_VERSION` is for.
430
441
  */
431
442
  function normaliseBridgeBlock(s) {
432
443
  return s
@@ -435,6 +446,9 @@ function normaliseBridgeBlock(s) {
435
446
  .filter((l) => l !== '' && !l.startsWith('//'))
436
447
  .join(' ')
437
448
  .replace(/['"]/g, '"')
449
+ .replace(/,\s*([)\]}])/g, '$1')
450
+ .replace(/([([{])\s+/g, '$1')
451
+ .replace(/\s+([)\]}])/g, '$1')
438
452
  .replace(/\s+/g, ' ')
439
453
  .trim();
440
454
  }
@@ -4,7 +4,9 @@ exports.extractKeyComments = extractKeyComments;
4
4
  exports.reattachKeyComments = reattachKeyComments;
5
5
  exports.hoistPackageManager = hoistPackageManager;
6
6
  exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
7
+ const fs_1 = require("fs");
7
8
  const js_yaml_1 = require("js-yaml");
9
+ const path_1 = require("path");
8
10
  const fs_utils_1 = require("./fs-utils");
9
11
  /**
10
12
  * pnpm workspace-scoped fields that must live at the workspace root.
@@ -37,8 +39,16 @@ const fs_utils_1 = require("./fs-utils");
37
39
  * the same trap one step later: the starter scope-allows known-stale peer ranges
38
40
  * there, and without the hoist the root `check:peers` step fails the first
39
41
  * `pnpm run check` of every generated project.
42
+ *
43
+ * `patchedDependencies` (`{ "pkg@version": "patches/pkg@version.patch" }`) is the
44
+ * same trap once more, with a file attached. nuxt-base-starter pairs an override
45
+ * (`simple-git: 4.0.2`) with a patch that restores the default export its consumer
46
+ * imports. Hoisting the override without the patch installs the package unpatched,
47
+ * and `nuxt prepare` aborts the first `pnpm install` of every generated project.
48
+ * The value is a path relative to the workspace root, so the file moves with it —
49
+ * see `relocatePatches`.
40
50
  */
41
- const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
51
+ const OBJECT_FIELDS = ['overrides', 'allowBuilds', 'patchedDependencies'];
42
52
  const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
43
53
  /** Objects whose inner arrays are unioned and inner maps merged, not replaced. */
44
54
  const NESTED_ARRAY_FIELDS = ['auditConfig', 'peerDependencyRules'];
@@ -333,6 +343,7 @@ function hoistWorkspacePnpmConfig(options) {
333
343
  const conflicts = [];
334
344
  const rootOverrides = [];
335
345
  const auditWidenedBy = new Set();
346
+ const relocatedPatches = new Map();
336
347
  // Sub-projects that are present and eligible but hand over nothing, because an
337
348
  // earlier run already hoisted their settings and deleted the source. They are
338
349
  // what turns a root-vs-sub override into a sub-vs-sub disagreement.
@@ -349,15 +360,31 @@ function hoistWorkspacePnpmConfig(options) {
349
360
  // across its own two config sources is the same silent-winner bug one scope
350
361
  // down, and a shared label hides it.
351
362
  const fromPkg = hoistFromSubPackageJson({
352
- context: { auditWidenedBy, conflicts, provenance, rootOverrides, sourceLabel: `${subDir}/package.json#pnpm` },
363
+ context: {
364
+ auditWidenedBy,
365
+ conflicts,
366
+ provenance,
367
+ relocatedPatches,
368
+ rootOverrides,
369
+ sourceLabel: `${subDir}/package.json#pnpm`,
370
+ },
353
371
  filesystem,
372
+ projectDir,
354
373
  rootWs,
355
374
  subPath,
356
375
  });
357
376
  const fromYaml = hoistFromSubWorkspaceYaml({
358
377
  comments,
359
- context: { auditWidenedBy, conflicts, provenance, rootOverrides, sourceLabel: `${subDir}/pnpm-workspace.yaml` },
378
+ context: {
379
+ auditWidenedBy,
380
+ conflicts,
381
+ provenance,
382
+ relocatedPatches,
383
+ rootOverrides,
384
+ sourceLabel: `${subDir}/pnpm-workspace.yaml`,
385
+ },
360
386
  filesystem,
387
+ projectDir,
361
388
  rootWs,
362
389
  subPath,
363
390
  });
@@ -547,13 +574,14 @@ function hoistFields(rootWs, source, context) {
547
574
  }
548
575
  /** Source 1: the sub-project's package.json `pnpm` block. */
549
576
  function hoistFromSubPackageJson(options) {
550
- const { filesystem, rootWs, subPath } = options;
577
+ const { filesystem, projectDir, rootWs, subPath } = options;
551
578
  const subPkgPath = `${subPath}/package.json`;
552
579
  if (!filesystem.exists(subPkgPath))
553
580
  return false;
554
581
  const subPkg = filesystem.read(subPkgPath, 'json');
555
582
  if (!(subPkg === null || subPkg === void 0 ? void 0 : subPkg.pnpm))
556
583
  return false;
584
+ relocatePatches({ context: options.context, filesystem, projectDir, source: subPkg.pnpm, subPath });
557
585
  if (!hoistFields(rootWs, subPkg.pnpm, options.context))
558
586
  return false;
559
587
  // If the sub-project's pnpm section is now empty, drop it entirely.
@@ -566,7 +594,7 @@ function hoistFromSubPackageJson(options) {
566
594
  /** Source 2: the sub-project's pnpm-workspace.yaml. */
567
595
  function hoistFromSubWorkspaceYaml(options) {
568
596
  var _a;
569
- const { comments, filesystem, rootWs, subPath } = options;
597
+ const { comments, filesystem, projectDir, rootWs, subPath } = options;
570
598
  const subWsPath = `${subPath}/pnpm-workspace.yaml`;
571
599
  if (!filesystem.exists(subWsPath))
572
600
  return false;
@@ -581,6 +609,7 @@ function hoistFromSubWorkspaceYaml(options) {
581
609
  if (!comments.has(key))
582
610
  comments.set(key, block);
583
611
  }
612
+ relocatePatches({ context: options.context, filesystem, projectDir, source: ws, subPath });
584
613
  if (!hoistFields(rootWs, ws, options.context))
585
614
  return false;
586
615
  // A settings-only file (no `packages:`) exists solely to carry these
@@ -658,6 +687,21 @@ function pmVersion(pin) {
658
687
  const afterAt = pin.slice(pin.lastIndexOf('@') + 1);
659
688
  return afterAt.split('+')[0];
660
689
  }
690
+ /** Remove `dir` and every parent it empties, stopping below `stopAt`, which is never removed. */
691
+ function pruneEmptyDirs(filesystem, dir, stopAt) {
692
+ var _a;
693
+ const stop = (0, path_1.resolve)(stopAt);
694
+ let current = (0, path_1.resolve)(dir);
695
+ for (;;) {
696
+ const rel = (0, path_1.relative)(stop, current);
697
+ if (!rel || rel.startsWith('..') || (0, path_1.isAbsolute)(rel))
698
+ return;
699
+ if (((_a = filesystem.list(current)) !== null && _a !== void 0 ? _a : ['?']).length > 0)
700
+ return;
701
+ filesystem.remove(current);
702
+ current = (0, path_1.dirname)(current);
703
+ }
704
+ }
661
705
  /** Parse a YAML file into a plain object, or null on missing/malformed/non-object. */
662
706
  function readYaml(filesystem, path) {
663
707
  if (!filesystem.exists(path))
@@ -746,6 +790,90 @@ function recordRootOverrides(field, rootValue, subValue, context) {
746
790
  });
747
791
  }
748
792
  }
793
+ /**
794
+ * Make every `patchedDependencies` path in a sub-project's pnpm config valid at the
795
+ * workspace root, by moving the patch file to `<root>/patches/`.
796
+ *
797
+ * Why the file moves instead of the path just being rewritten to
798
+ * `projects/app/patches/…`: pnpm would find either, but the Dockerfiles copy only the
799
+ * build-context-root `patches/` into their deps stage, and in a monorepo the context IS
800
+ * the root. A path into `projects/app/` installs locally and breaks the image build.
801
+ *
802
+ * Never overwrites, never writes through a symlink, never deletes a patch it could not
803
+ * place. Where the move is impossible the entry points at the file where it is (a local
804
+ * install keeps working) and the reason is reported. A clash with ANOTHER sub-project's
805
+ * entry for the same key is left to `recordObjectFieldProvenance`, which names both
806
+ * sources; reporting it here as well would say the same thing twice.
807
+ */
808
+ function relocatePatches(options) {
809
+ var _a, _b;
810
+ const { context, filesystem, projectDir, source, subPath } = options;
811
+ const entries = source.patchedDependencies;
812
+ if (!entries || typeof entries !== 'object' || Array.isArray(entries))
813
+ return;
814
+ const rootPatchesDir = (0, path_1.resolve)(projectDir, 'patches');
815
+ const toRootPath = (abs) => (0, path_1.relative)((0, path_1.resolve)(projectDir), abs).split(path_1.sep).join('/');
816
+ const isInside = (dir, path) => {
817
+ const rel = (0, path_1.relative)(dir, path);
818
+ return !!rel && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel);
819
+ };
820
+ const relocated = {};
821
+ const moved = (_a = context === null || context === void 0 ? void 0 : context.relocatedPatches) !== null && _a !== void 0 ? _a : new Map();
822
+ for (const [key, value] of Object.entries(entries)) {
823
+ relocated[key] = value;
824
+ if (typeof value !== 'string')
825
+ continue;
826
+ const from = (0, path_1.resolve)(subPath, value);
827
+ const fromPath = toRootPath(from);
828
+ relocated[key] = (_b = moved.get(from)) !== null && _b !== void 0 ? _b : fromPath;
829
+ if (moved.has(from))
830
+ continue;
831
+ const leftInPlace = (reason) => {
832
+ context === null || context === void 0 ? void 0 : context.conflicts.push(`patchedDependencies: ${JSON.stringify(key)} — ${reason}, so ${fromPath} was left in place. ` +
833
+ `A local install still finds it, but the Dockerfiles copy only the root patches/ and the image build will not.`);
834
+ };
835
+ if (isInside(rootPatchesDir, from))
836
+ continue;
837
+ if (filesystem.exists(from) !== 'file') {
838
+ context === null || context === void 0 ? void 0 : context.conflicts.push(`patchedDependencies: ${JSON.stringify(key)} points at ${fromPath}, which does not exist. ` +
839
+ `pnpm install fails until the patch file is there.`);
840
+ continue;
841
+ }
842
+ if (!isInside((0, path_1.resolve)(subPath), from)) {
843
+ leftInPlace('the patch lives outside its sub-project');
844
+ continue;
845
+ }
846
+ if ((0, fs_utils_1.isSymlink)(from)) {
847
+ leftInPlace('the patch file is a symlink');
848
+ continue;
849
+ }
850
+ const dirState = filesystem.exists(rootPatchesDir);
851
+ if ((0, fs_utils_1.isSymlink)(rootPatchesDir) || (dirState && dirState !== 'dir')) {
852
+ leftInPlace('patches/ at the workspace root is a symlink or not a directory');
853
+ continue;
854
+ }
855
+ const to = (0, path_1.join)(rootPatchesDir, (0, path_1.basename)(from));
856
+ if (filesystem.exists(to)) {
857
+ const identical = filesystem.exists(to) === 'file' && !(0, fs_utils_1.isSymlink)(to) && (0, fs_1.readFileSync)(to).equals((0, fs_1.readFileSync)(from));
858
+ if (identical) {
859
+ filesystem.remove(from);
860
+ pruneEmptyDirs(filesystem, (0, path_1.dirname)(from), subPath);
861
+ relocated[key] = toRootPath(to);
862
+ moved.set(from, toRootPath(to));
863
+ }
864
+ else if (!(context === null || context === void 0 ? void 0 : context.provenance.has(`patchedDependencies.${key}`))) {
865
+ leftInPlace(`${toRootPath(to)} already exists with different content`);
866
+ }
867
+ continue;
868
+ }
869
+ filesystem.dir(rootPatchesDir);
870
+ filesystem.move(from, to);
871
+ pruneEmptyDirs(filesystem, (0, path_1.dirname)(from), subPath);
872
+ relocated[key] = toRootPath(to);
873
+ moved.set(from, toRootPath(to));
874
+ }
875
+ source.patchedDependencies = relocated;
876
+ }
749
877
  /**
750
878
  * One line, not one per advisory: say that hoisting widened the audit
751
879
  * suppressions to the whole workspace, and name who brought them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.50.2",
3
+ "version": "1.50.4",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",
@@ -113,6 +113,7 @@
113
113
  "//minimatch-note": "Why the minimatch raises exist: when they were added, brace-expansion 1.x and 2.x were END OF LINE for GHSA-mh99-v99m-4gvg (advisory range <=5.0.7 across ALL majors, patched only 5.0.8+), so the only fix was to raise each CONSUMER off minimatch 3.x/5.x/9.x. That premise no longer holds: upstream has since backported every fix (GHSA-mh99 now reads <1.1.17 / >=2.0.0 <2.1.3, and 1.1.21 / 2.1.7 patch all five current advisories). Verified 2026-10-03 with fresh lockfile resolves: without `minimatch@>=4 <10` and `fs-jetpack > minimatch@<10` npm audit is still clean, the tree only gains minimatch 3.1.5 + 5.1.9 and brace-expansion 1.1.21 + 2.1.7 (7 packages). So both are now a DEDUPE measure (one minimatch 10.2.6, one brace-expansion 5.0.12), not a security fix - dropping them is a judgment call, it reopens no advisory. Forcing brace-expansion 5.x globally is still NOT an option: 5.x is ESM/tshy and exports an object ({ expand, ... }) while 1.x/2.x export the function itself, so minimatch 3.x/5.x would die on `expand is not a function`. Each scoped entry was verified against the consumer's actual call site before being added. The eslint / @eslint/eslintrc / @eslint/config-array entries were REMOVED in 1.42.0: eslint 10 requests minimatch ^10.2.5 itself, config-array ^10.2.4, and eslintrc is no longer in the tree at all.",
114
114
  "minimatch@>=4 <10": "Bounded raise of every minimatch 4.x-9.x install to 10.2.6, whose brace-expansion ^5.0.8 is patched. As of 2026-10-03 the only consumer it still raises is filelist (gluegun > ejs 3.1.10 > jake > filelist, PRODUCTION-reachable, minimatch ^5.0.1): @typescript-eslint/typescript-estree 8.65 requests ^10.2.2 itself and the jest 30.5 packages no longer depend on minimatch. Verified safe: filelist calls only the static helper `minimatch.match(files, pat, opts)`. Floored at >=4 so the 3.x line is NOT swept in: minimatch 3.x is callable while 9.x/10.x export an object with __esModule but no `default` key, so sweeping it would break any consumer that calls the default export. Dedupe, not security (see //minimatch-note). Drop once filelist requests minimatch >=10.",
115
115
  "babel-plugin-istanbul > test-exclude@<8": "Dedupe override, no longer a security fix. Added when babel-plugin-istanbul requested test-exclude ^6.0.0, whose 6.0.0 pins minimatch ^3.0.4 and glob ^7. Since jest 30.5 (babel-plugin-istanbul 8.0.0) the request is ^7.0.1, and test-exclude 7.0.2 already uses minimatch ^10.2.2: a fresh resolve without this entry audits clean (2026-10-03) but adds 23 packages (glob 10.5.0 and its subtree). 8.0.0 uses glob ^13, the major this project already ships, and is still CJS with the same `module.exports = TestExclude` class shape, so babel-plugin-istanbul 8's _interopRequireDefault + `new _testExclude.default(opts)` keeps working. Drop once babel-plugin-istanbul requests test-exclude ^8.",
116
+ "@istanbuljs/load-nyc-config > js-yaml": "Security fix for GHSA-hp3w-g68c-fv3c (sprintf-js, DoS via unbounded precision specifiers, moderate). Advisory range is ALL versions, so there is no patched sprintf-js to raise to. Dev-only path: ts-jest > @jest/transform > babel-plugin-istanbul 8.0.0 > @istanbuljs/load-nyc-config 1.1.0 (js-yaml ^3.13.1) > js-yaml 3.15.2 > argparse 1.0.10 > sprintf-js. Every link is already at its latest release (2026-10-06), and only js-yaml 4.x leaves the chain: it depends on argparse ^2, which has no sprintf-js. `$js-yaml` pins it to this project's own direct js-yaml, so the two never drift. Verified safe: load-nyc-config only calls `require('js-yaml').load(...)` (index.js:80), which 4.x has; 4.x `load` behaves like 3.x `safeLoad`. Drop once load-nyc-config requests js-yaml >=4 or argparse 1.x drops sprintf-js.",
116
117
  "fs-jetpack > minimatch@<10": "Same chain, PRODUCTION-reachable (gluegun > fs-jetpack). Safe: lib/utils/matcher.js does `require('minimatch').Minimatch` and uses only `new Minimatch(pattern, { matchBase, nocomment, nocase, dot })`, `.negate` and `.match()` - all verified against 10.x. Not fixable by upgrading fs-jetpack: gluegun@5.2.2 is the latest release and fs-jetpack@5.1.0 still requests minimatch ^5.1.0 (brace-expansion ^2.0.1, also unpatched)."
117
118
  },
118
119
  "overrides": {
@@ -126,6 +127,9 @@
126
127
  "babel-plugin-istanbul": {
127
128
  "test-exclude@<8": "8.0.0"
128
129
  },
130
+ "@istanbuljs/load-nyc-config": {
131
+ "js-yaml": "$js-yaml"
132
+ },
129
133
  "minimatch@>=4 <10": "10.2.6"
130
134
  },
131
135
  "//jest.workerIdleMemoryLimit": "Recycle a ts-jest worker once its heap passes this. Guards against an intermittent `A jest worker process was terminated by another process: signal=SIGSEGV` that kills ONE suite while every other test passes (seen twice in marketplace*, both under `npm run check`, never reproducible on demand - 0 in 9 targeted runs incl. --maxWorkers=16). It is a worker crash, NOT an assertion failure: the signature is `Test suite failed to run` with the remaining count still green. All versions are current and in-range (node 24 / jest 30 / ts-jest 29 / ts 6), so this is a resilience measure against unbounded worker heap growth, not a proven root-cause fix - if it recurs, capture the suite name and re-open.",