@codyswann/lisa 1.85.3 → 1.85.5

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.
Files changed (97) hide show
  1. package/cdk/copy-overwrite/tsconfig.cdk.json +1 -1
  2. package/dist/configs/eslint/base.d.ts +0 -1
  3. package/dist/configs/eslint/base.d.ts.map +1 -1
  4. package/dist/configs/eslint/base.js +0 -1
  5. package/dist/configs/eslint/base.js.map +1 -1
  6. package/dist/configs/eslint/cdk.d.ts +0 -2
  7. package/dist/configs/eslint/cdk.d.ts.map +1 -1
  8. package/dist/configs/eslint/cdk.js +0 -2
  9. package/dist/configs/eslint/cdk.js.map +1 -1
  10. package/dist/configs/eslint/expo.d.ts.map +1 -1
  11. package/dist/configs/eslint/expo.js +0 -1
  12. package/dist/configs/eslint/expo.js.map +1 -1
  13. package/dist/configs/eslint/index.d.ts +0 -1
  14. package/dist/configs/eslint/index.d.ts.map +1 -1
  15. package/dist/configs/eslint/index.js +0 -1
  16. package/dist/configs/eslint/index.js.map +1 -1
  17. package/dist/configs/eslint/nestjs.d.ts +0 -2
  18. package/dist/configs/eslint/nestjs.d.ts.map +1 -1
  19. package/dist/configs/eslint/nestjs.js +0 -2
  20. package/dist/configs/eslint/nestjs.js.map +1 -1
  21. package/dist/configs/eslint/slow.d.ts +0 -1
  22. package/dist/configs/eslint/slow.d.ts.map +1 -1
  23. package/dist/configs/eslint/slow.js +0 -2
  24. package/dist/configs/eslint/slow.js.map +1 -1
  25. package/dist/configs/eslint/typescript.d.ts +0 -1
  26. package/dist/configs/eslint/typescript.d.ts.map +1 -1
  27. package/dist/configs/eslint/typescript.js +0 -2
  28. package/dist/configs/eslint/typescript.js.map +1 -1
  29. package/dist/configs/jest/base.d.ts +0 -3
  30. package/dist/configs/jest/base.d.ts.map +1 -1
  31. package/dist/configs/jest/base.js +0 -2
  32. package/dist/configs/jest/base.js.map +1 -1
  33. package/dist/configs/jest/cdk.d.ts +0 -2
  34. package/dist/configs/jest/cdk.d.ts.map +1 -1
  35. package/dist/configs/jest/cdk.js +0 -1
  36. package/dist/configs/jest/cdk.js.map +1 -1
  37. package/dist/configs/jest/expo.d.ts +0 -2
  38. package/dist/configs/jest/expo.d.ts.map +1 -1
  39. package/dist/configs/jest/expo.js +0 -1
  40. package/dist/configs/jest/expo.js.map +1 -1
  41. package/dist/configs/jest/index.d.ts +0 -1
  42. package/dist/configs/jest/index.d.ts.map +1 -1
  43. package/dist/configs/jest/index.js +0 -1
  44. package/dist/configs/jest/index.js.map +1 -1
  45. package/dist/configs/jest/nestjs.d.ts +0 -2
  46. package/dist/configs/jest/nestjs.d.ts.map +1 -1
  47. package/dist/configs/jest/nestjs.js +0 -1
  48. package/dist/configs/jest/nestjs.js.map +1 -1
  49. package/dist/configs/jest/typescript.d.ts +0 -2
  50. package/dist/configs/jest/typescript.d.ts.map +1 -1
  51. package/dist/configs/jest/typescript.js +0 -1
  52. package/dist/configs/jest/typescript.js.map +1 -1
  53. package/dist/configs/vitest/base.d.ts +0 -4
  54. package/dist/configs/vitest/base.d.ts.map +1 -1
  55. package/dist/configs/vitest/base.js +0 -3
  56. package/dist/configs/vitest/base.js.map +1 -1
  57. package/dist/configs/vitest/cdk.d.ts +0 -2
  58. package/dist/configs/vitest/cdk.d.ts.map +1 -1
  59. package/dist/configs/vitest/cdk.js +0 -1
  60. package/dist/configs/vitest/cdk.js.map +1 -1
  61. package/dist/configs/vitest/index.d.ts +0 -1
  62. package/dist/configs/vitest/index.d.ts.map +1 -1
  63. package/dist/configs/vitest/index.js +0 -1
  64. package/dist/configs/vitest/index.js.map +1 -1
  65. package/dist/configs/vitest/nestjs.d.ts +0 -2
  66. package/dist/configs/vitest/nestjs.d.ts.map +1 -1
  67. package/dist/configs/vitest/nestjs.js +0 -1
  68. package/dist/configs/vitest/nestjs.js.map +1 -1
  69. package/dist/configs/vitest/typescript.d.ts +0 -2
  70. package/dist/configs/vitest/typescript.d.ts.map +1 -1
  71. package/dist/configs/vitest/typescript.js +0 -1
  72. package/dist/configs/vitest/typescript.js.map +1 -1
  73. package/dist/core/lisa.d.ts +34 -0
  74. package/dist/core/lisa.d.ts.map +1 -1
  75. package/dist/core/lisa.js +75 -1
  76. package/dist/core/lisa.js.map +1 -1
  77. package/dist/detection/detectors/rails.d.ts.map +1 -1
  78. package/dist/detection/detectors/rails.js +0 -1
  79. package/dist/detection/detectors/rails.js.map +1 -1
  80. package/dist/utils/fibonacci.d.ts +0 -3
  81. package/dist/utils/fibonacci.d.ts.map +1 -1
  82. package/dist/utils/fibonacci.js +0 -3
  83. package/dist/utils/fibonacci.js.map +1 -1
  84. package/dist/utils/postinstall-trampoline.d.ts +40 -0
  85. package/dist/utils/postinstall-trampoline.d.ts.map +1 -1
  86. package/dist/utils/postinstall-trampoline.js +216 -27
  87. package/dist/utils/postinstall-trampoline.js.map +1 -1
  88. package/expo/copy-overwrite/tsconfig.expo.json +1 -1
  89. package/nestjs/copy-overwrite/tsconfig.nestjs.json +1 -1
  90. package/package.json +1 -1
  91. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  97. package/scripts/install-claude-plugins.sh +60 -8
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Known package managers whose lockfiles must be regenerated when Lisa's apply
3
+ * mutates package.json (e.g., adds/updates resolutions or overrides entries).
4
+ */
5
+ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
6
+ /**
7
+ * Description of a package manager's lockfile file + the command Lisa should run
8
+ * to rebuild the lockfile without running install scripts.
9
+ */
10
+ export interface LockfileRegenPlan {
11
+ readonly pm: PackageManager;
12
+ readonly lockfile: string;
13
+ readonly command: string;
14
+ readonly args: readonly string[];
15
+ }
1
16
  /**
2
17
  * Determine whether this Lisa invocation is running as a package-manager lifecycle
3
18
  * script (postinstall, prepare, etc.). Works across npm, bun, yarn, and pnpm since
@@ -44,6 +59,31 @@ export declare function shouldSchedulePostinstallReconciliation(dryRun: boolean)
44
59
  * @returns Absolute path to the Lisa dist directory
45
60
  */
46
61
  export declare function getLisaDistDir(moduleUrl: string): string;
62
+ /**
63
+ * Detect which package managers the project uses based on lockfile presence.
64
+ *
65
+ * A project may have more than one lockfile (the CDK dual-lockfile pattern
66
+ * keeps `bun.lock` for local dev while publishing `package-lock.json` for
67
+ * consumers), in which case every present lockfile must be regenerated so both
68
+ * stay in sync with package.json.
69
+ * @param projectDir - Absolute path to the project directory
70
+ * @returns Ordered list of detected package managers (possibly empty)
71
+ */
72
+ export declare function detectPackageManagers(projectDir: string): readonly PackageManager[];
73
+ /**
74
+ * Get the regen plan (command/args/lockfile) for a given package manager.
75
+ * @param pm - Package manager to look up
76
+ * @returns Regen plan describing which command to spawn
77
+ */
78
+ export declare function getLockfileRegenPlan(pm: PackageManager): LockfileRegenPlan;
79
+ /**
80
+ * Hash a file's contents (sha256, hex-encoded). Returns null if the file
81
+ * does not exist or cannot be read. Used to detect whether Lisa mutated
82
+ * package.json during its apply so we only regenerate lockfiles when needed.
83
+ * @param filePath - Absolute path to the file
84
+ * @returns Hex-encoded sha256 hash, or null if the file is unavailable
85
+ */
86
+ export declare function hashFile(filePath: string): string | null;
47
87
  /**
48
88
  * Spawn a fully detached child process that waits for the parent package manager
49
89
  * to exit, then re-runs Lisa to reconcile package.json. The child is detached
@@ -1 +1 @@
1
- {"version":3,"file":"postinstall-trampoline.d.ts","sourceRoot":"","sources":["../../src/utils/postinstall-trampoline.ts"],"names":[],"mappings":"AAiDA;;;;;GAKG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uCAAuC,CACrD,MAAM,EAAE,OAAO,GACd,OAAO,CAIT;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,IAAI,CA4BN"}
1
+ {"version":3,"file":"postinstall-trampoline.d.ts","sourceRoot":"","sources":["../../src/utils/postinstall-trampoline.ts"],"names":[],"mappings":"AAmCA;;;GAGG;AACH,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;AAE7D;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CAClC;AA6DD;;;;;GAKG;AACH,wBAAgB,0BAA0B,IAAI,OAAO,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uCAAuC,CACrD,MAAM,EAAE,OAAO,GACd,OAAO,CAIT;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,GACjB,SAAS,cAAc,EAAE,CAI3B;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,EAAE,cAAc,GAAG,iBAAiB,CAE1E;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOxD;AAED;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,IAAI,CA6BN"}
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
2
4
  import * as path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  /**
@@ -25,6 +27,45 @@ const POLL_INTERVAL_MS = 100;
25
27
  * writes a moment to quiesce before Lisa re-applies its changes.
26
28
  */
27
29
  const SETTLE_DELAY_MS = 250;
30
+ /**
31
+ * Per-PM lockfile + regen command mapping. The regen commands are all
32
+ * "sync lockfile without running scripts" variants — we do NOT want to
33
+ * re-run lifecycle scripts (that would re-trigger the trampoline).
34
+ *
35
+ * bun: `bun install` is the canonical way to sync bun.lock after package.json
36
+ * changes. As of bun 1.x there is no `--lockfile-only` flag; `bun install`
37
+ * is already fast when node_modules is up-to-date and will simply update
38
+ * bun.lock to match package.json. We pass `--ignore-scripts` to avoid re-running
39
+ * the parent PM's lifecycle hooks (which triggered this trampoline to begin with).
40
+ */
41
+ const INSTALL = "install";
42
+ const IGNORE_SCRIPTS = "--ignore-scripts";
43
+ const LOCKFILE_REGEN_PLANS = {
44
+ bun: {
45
+ pm: "bun",
46
+ lockfile: "bun.lock",
47
+ command: "bun",
48
+ args: [INSTALL, IGNORE_SCRIPTS],
49
+ },
50
+ npm: {
51
+ pm: "npm",
52
+ lockfile: "package-lock.json",
53
+ command: "npm",
54
+ args: [INSTALL, "--package-lock-only", IGNORE_SCRIPTS],
55
+ },
56
+ pnpm: {
57
+ pm: "pnpm",
58
+ lockfile: "pnpm-lock.yaml",
59
+ command: "pnpm",
60
+ args: [INSTALL, "--lockfile-only", IGNORE_SCRIPTS],
61
+ },
62
+ yarn: {
63
+ pm: "yarn",
64
+ lockfile: "yarn.lock",
65
+ command: "yarn",
66
+ args: [INSTALL, "--mode", "update-lockfile"],
67
+ },
68
+ };
28
69
  /**
29
70
  * Read an env var by name without widening the project-wide process.env ban.
30
71
  * Lisa's CLI isn't a Lambda handler or Nest service; it has to introspect its
@@ -100,6 +141,45 @@ export function getLisaDistDir(moduleUrl) {
100
141
  // Walk from <dist>/utils/postinstall-trampoline.js → <dist>
101
142
  return path.resolve(path.dirname(filename), "..");
102
143
  }
144
+ /**
145
+ * Detect which package managers the project uses based on lockfile presence.
146
+ *
147
+ * A project may have more than one lockfile (the CDK dual-lockfile pattern
148
+ * keeps `bun.lock` for local dev while publishing `package-lock.json` for
149
+ * consumers), in which case every present lockfile must be regenerated so both
150
+ * stay in sync with package.json.
151
+ * @param projectDir - Absolute path to the project directory
152
+ * @returns Ordered list of detected package managers (possibly empty)
153
+ */
154
+ export function detectPackageManagers(projectDir) {
155
+ return Object.values(LOCKFILE_REGEN_PLANS)
156
+ .filter(plan => existsSync(path.join(projectDir, plan.lockfile)))
157
+ .map(plan => plan.pm);
158
+ }
159
+ /**
160
+ * Get the regen plan (command/args/lockfile) for a given package manager.
161
+ * @param pm - Package manager to look up
162
+ * @returns Regen plan describing which command to spawn
163
+ */
164
+ export function getLockfileRegenPlan(pm) {
165
+ return LOCKFILE_REGEN_PLANS[pm];
166
+ }
167
+ /**
168
+ * Hash a file's contents (sha256, hex-encoded). Returns null if the file
169
+ * does not exist or cannot be read. Used to detect whether Lisa mutated
170
+ * package.json during its apply so we only regenerate lockfiles when needed.
171
+ * @param filePath - Absolute path to the file
172
+ * @returns Hex-encoded sha256 hash, or null if the file is unavailable
173
+ */
174
+ export function hashFile(filePath) {
175
+ try {
176
+ const contents = readFileSync(filePath);
177
+ return createHash("sha256").update(contents).digest("hex");
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
103
183
  /**
104
184
  * Spawn a fully detached child process that waits for the parent package manager
105
185
  * to exit, then re-runs Lisa to reconcile package.json. The child is detached
@@ -121,6 +201,7 @@ export function scheduleReconciliationChild(projectDir, lisaDistDir, parentPid)
121
201
  projectDir,
122
202
  nodeBin,
123
203
  trampolineEnvVar: TRAMPOLINE_ENV_VAR,
204
+ lockfileRegenPlans: LOCKFILE_REGEN_PLANS,
124
205
  });
125
206
  const child = spawn(nodeBin, ["-e", trampolineSource], {
126
207
  cwd: projectDir,
@@ -151,58 +232,166 @@ function inheritedEnv() {
151
232
  * Build the inline JS source that runs inside the detached trampoline child.
152
233
  * The source is passed to `node -e` so it must be self-contained (no imports that
153
234
  * require resolution via package.json, which is exactly the file we're racing).
235
+ *
236
+ * The trampoline now runs in two phases inside the child:
237
+ * 1. Wait for the parent PM to exit, then re-invoke Lisa to reconcile package.json.
238
+ * 2. If Lisa's re-invocation mutated package.json, regenerate whichever lockfiles
239
+ * are present in the project so `bun install --frozen-lockfile` / `npm ci` in
240
+ * downstream CI jobs do not fail with "lockfile had changes, but lockfile is
241
+ * frozen." Lockfile regen runs with `--ignore-scripts` so the parent PM's
242
+ * lifecycle hooks are not re-invoked (which would retrigger this trampoline).
154
243
  * @param params - Embedded constants to inline into the trampoline source
155
244
  * @returns JS source suitable for `node -e`
156
245
  */
157
246
  function buildTrampolineSource(params) {
158
247
  // JSON.stringify gives us safe inline literals for all primitive types.
159
- const parentPid = JSON.stringify(params.parentPid);
160
- const pollIntervalMs = JSON.stringify(params.pollIntervalMs);
161
- const maxWaitMs = JSON.stringify(params.maxWaitMs);
162
- const settleDelayMs = JSON.stringify(params.settleDelayMs);
163
- const lisaEntry = JSON.stringify(params.lisaEntry);
164
- const projectDir = JSON.stringify(params.projectDir);
165
- const nodeBin = JSON.stringify(params.nodeBin);
166
- const trampolineEnvVar = JSON.stringify(params.trampolineEnvVar);
248
+ const literals = {
249
+ parentPid: JSON.stringify(params.parentPid),
250
+ pollIntervalMs: JSON.stringify(params.pollIntervalMs),
251
+ maxWaitMs: JSON.stringify(params.maxWaitMs),
252
+ settleDelayMs: JSON.stringify(params.settleDelayMs),
253
+ lisaEntry: JSON.stringify(params.lisaEntry),
254
+ projectDir: JSON.stringify(params.projectDir),
255
+ nodeBin: JSON.stringify(params.nodeBin),
256
+ trampolineEnvVar: JSON.stringify(params.trampolineEnvVar),
257
+ lockfilePlans: JSON.stringify(params.lockfileRegenPlans),
258
+ };
259
+ return [
260
+ buildTrampolinePrelude(literals),
261
+ buildTrampolineHelpers(literals),
262
+ buildTrampolineMain(literals),
263
+ ].join("\n");
264
+ }
265
+ /**
266
+ * Inline `require` prelude + the lockfile plan table. Kept separate so each
267
+ * chunk of the trampoline source stays under the 75-line max-lines-per-function
268
+ * cap enforced by eslint.
269
+ * @param literals - Inlined JSON-safe literals
270
+ * @param literals.lockfilePlans - JSON-serialized lockfile plan table
271
+ * @returns JS source fragment
272
+ */
273
+ function buildTrampolinePrelude(literals) {
167
274
  return `
168
275
  const { spawn } = require("node:child_process");
276
+ const { createHash } = require("node:crypto");
277
+ const { existsSync, readFileSync } = require("node:fs");
278
+ const path = require("node:path");
169
279
 
280
+ const LOCKFILE_PLANS = ${literals.lockfilePlans};
281
+ `;
282
+ }
283
+ /**
284
+ * Helper functions inlined into the trampoline child: parent-liveness probe,
285
+ * file hasher, package-manager detector, Lisa re-invoker, and best-effort
286
+ * lockfile regenerator. Each mirrors an exported TS helper in this module so
287
+ * the logic stays test-covered via the exported versions.
288
+ * @param literals - Inlined JSON-safe literals
289
+ * @param literals.parentPid - Parent package-manager PID for liveness probe
290
+ * @param literals.pollIntervalMs - Poll interval for parent-liveness checks
291
+ * @param literals.maxWaitMs - Max wait deadline before bailing out
292
+ * @param literals.nodeBin - Node binary path to re-invoke Lisa with
293
+ * @param literals.lisaEntry - Absolute path to Lisa's dist/index.js
294
+ * @param literals.projectDir - Project directory Lisa will reconcile
295
+ * @param literals.trampolineEnvVar - Env var name used to mark child as trampoline
296
+ * @returns JS source fragment
297
+ */
298
+ function buildTrampolineHelpers(literals) {
299
+ return `
170
300
  function isAlive(pid) {
171
301
  if (!pid || pid <= 1) return false;
172
302
  try { process.kill(pid, 0); return true; } catch { return false; }
173
303
  }
174
304
 
175
- // Returns true when the parent has exited, false when the deadline elapsed
176
- // while the parent was still alive. Timing out MUST NOT re-run Lisa —
177
- // that would reintroduce the package.json race the trampoline is designed
178
- // to avoid (parent PM still writing).
305
+ function hashFile(p) {
306
+ try { return createHash("sha256").update(readFileSync(p)).digest("hex"); }
307
+ catch { return null; }
308
+ }
309
+
310
+ function detectPackageManagers(dir) {
311
+ return Object.values(LOCKFILE_PLANS)
312
+ .filter((plan) => existsSync(path.join(dir, plan.lockfile)))
313
+ .map((plan) => plan.pm);
314
+ }
315
+
179
316
  async function waitForParent() {
180
- const deadline = Date.now() + ${maxWaitMs};
317
+ const deadline = Date.now() + ${literals.maxWaitMs};
181
318
  while (Date.now() < deadline) {
182
- if (!isAlive(${parentPid})) return true;
183
- await new Promise((r) => setTimeout(r, ${pollIntervalMs}));
319
+ if (!isAlive(${literals.parentPid})) return true;
320
+ await new Promise((r) => setTimeout(r, ${literals.pollIntervalMs}));
184
321
  }
185
322
  return false;
186
323
  }
187
324
 
325
+ function spawnChild(command, args) {
326
+ return new Promise((resolve) => {
327
+ try {
328
+ const child = spawn(command, args, {
329
+ cwd: ${literals.projectDir},
330
+ stdio: "ignore",
331
+ env: Object.assign({}, process.env, { [${literals.trampolineEnvVar}]: "1" }),
332
+ });
333
+ child.on("exit", (code) => resolve(code === 0));
334
+ child.on("error", () => resolve(false));
335
+ } catch {
336
+ resolve(false);
337
+ }
338
+ });
339
+ }
340
+
341
+ function runLisa() {
342
+ return spawnChild(${literals.nodeBin}, [${literals.lisaEntry}, "--yes", "--skip-git-check", ${literals.projectDir}]);
343
+ }
344
+
345
+ async function regenerateLockfiles() {
346
+ for (const pm of detectPackageManagers(${literals.projectDir})) {
347
+ const plan = LOCKFILE_PLANS[pm];
348
+ if (!plan) continue;
349
+ // Best-effort: failures are intentionally swallowed so a missing PM
350
+ // binary (e.g., no global bun on the PATH) does not cascade into an
351
+ // install failure.
352
+ await spawnChild(plan.command, plan.args);
353
+ }
354
+ }
355
+ `;
356
+ }
357
+ /**
358
+ * Top-level async IIFE that orchestrates the trampoline child:
359
+ * 1) wait for parent PM to exit,
360
+ * 2) hash package.json,
361
+ * 3) re-run Lisa,
362
+ * 4) if Lisa changed package.json, regenerate lockfiles.
363
+ *
364
+ * Timing out MUST NOT re-run Lisa — that would reintroduce the package.json
365
+ * race the trampoline is designed to avoid (parent PM still writing).
366
+ * @param literals - Inlined JSON-safe literals
367
+ * @param literals.settleDelayMs - Settle delay before re-invoking Lisa
368
+ * @param literals.projectDir - Project directory Lisa will reconcile
369
+ * @returns JS source fragment
370
+ */
371
+ function buildTrampolineMain(literals) {
372
+ return `
188
373
  (async () => {
189
374
  try {
190
375
  const parentExited = await waitForParent();
191
376
  if (!parentExited) {
192
- // Parent still running after max wait — bail rather than race the PM.
193
377
  process.exit(0);
194
378
  }
195
- await new Promise((r) => setTimeout(r, ${settleDelayMs}));
196
- const child = spawn(
197
- ${nodeBin},
198
- [${lisaEntry}, "--yes", "--skip-git-check", ${projectDir}],
199
- {
200
- cwd: ${projectDir},
201
- stdio: "ignore",
202
- env: Object.assign({}, process.env, { [${trampolineEnvVar}]: "1" }),
203
- }
204
- );
205
- child.on("exit", () => process.exit(0));
379
+ await new Promise((r) => setTimeout(r, ${literals.settleDelayMs}));
380
+
381
+ const pkgPath = path.join(${literals.projectDir}, "package.json");
382
+ const preHash = hashFile(pkgPath);
383
+
384
+ const lisaOk = await runLisa();
385
+
386
+ const postHash = hashFile(pkgPath);
387
+ const packageJsonChanged =
388
+ lisaOk && preHash !== null && postHash !== null && preHash !== postHash;
389
+
390
+ if (packageJsonChanged) {
391
+ await regenerateLockfiles();
392
+ }
393
+
394
+ process.exit(0);
206
395
  } catch {
207
396
  process.exit(0);
208
397
  }
@@ -1 +1 @@
1
- {"version":3,"file":"postinstall-trampoline.js","sourceRoot":"","sources":["../../src/utils/postinstall-trampoline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC;;;GAGG;AACH,MAAM,iBAAiB,GAAG,kBAAkB,CAAC;AAE7C;;;GAGG;AACH,MAAM,kBAAkB,GAAG,6BAA6B,CAAC;AAEzD;;;GAGG;AACH,MAAM,WAAW,GAAG,OAAO,CAAC;AAE5B;;GAEG;AACH,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;GAGG;AACH,MAAM,eAAe,GAAG,GAAG,CAAC;AAE5B;;;;;;;;;;GAUG;AACH,SAAS,OAAO,CAAC,IAAY;IAC3B,4IAA4I;IAC5I,OAAO,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB;IACnC,OAAO,OAAO,CAAC,kBAAkB,CAAC,KAAK,GAAG,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,UAAU,uCAAuC,CACrD,MAAe;IAEf,IAAI,MAAM;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,qBAAqB,EAAE;QAAE,OAAO,KAAK,CAAC;IAC1C,OAAO,0BAA0B,EAAE,CAAC;AACtC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,SAAiB;IAC9C,MAAM,QAAQ,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;IAC1C,4DAA4D;IAC5D,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,2BAA2B,CACzC,UAAkB,EAClB,WAAmB,EACnB,SAAiB;IAEjB,MAAM,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IACjC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,UAAU,CAAC,CAAC;IAErD,MAAM,gBAAgB,GAAG,qBAAqB,CAAC;QAC7C,SAAS;QACT,cAAc,EAAE,gBAAgB;QAChC,SAAS,EAAE,WAAW;QACtB,aAAa,EAAE,eAAe;QAC9B,SAAS;QACT,UAAU;QACV,OAAO;QACP,gBAAgB,EAAE,kBAAkB;KACrC,CAAC,CAAC;IAEH,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,gBAAgB,CAAC,EAAE;QACrD,GAAG,EAAE,UAAU;QACf,QAAQ,EAAE,IAAI;QACd,KAAK,EAAE,QAAQ;QACf,GAAG,EAAE;YACH,GAAG,YAAY,EAAE;YACjB,8EAA8E;YAC9E,6EAA6E;YAC7E,CAAC,iBAAiB,CAAC,EAAE,EAAE;YACvB,CAAC,kBAAkB,CAAC,EAAE,GAAG;SAC1B;KACF,CAAC,CAAC;IACH,KAAK,CAAC,KAAK,EAAE,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,SAAS,YAAY;IACnB,8JAA8J;IAC9J,OAAO,EAAE,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;AAC5B,CAAC;AAiBD;;;;;;GAMG;AACH,SAAS,qBAAqB,CAAC,MAA8B;IAC3D,wEAAwE;IACxE,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACnD,MAAM,cAAc,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;IAC7D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACnD,MAAM,aAAa,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACnD,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IACrD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;IAEjE,OAAO;;;;;;;;;;;;;sCAa6B,SAAS;;uBAExB,SAAS;iDACiB,cAAc;;;;;;;;;;;;iDAYd,aAAa;;YAElD,OAAO;aACN,SAAS,kCAAkC,UAAU;;mBAE/C,UAAU;;qDAEwB,gBAAgB;;;;;;;;GAQlE,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"postinstall-trampoline.js","sourceRoot":"","sources":["../../src/utils/postinstall-trampoline.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": ["expo/tsconfig.base", "./tsconfig.base.json"],
2
+ "extends": ["expo/tsconfig.base", "@codyswann/lisa/tsconfig/base"],
3
3
  "compilerOptions": {
4
4
  "strict": true,
5
5
  "jsx": "react-native",
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "./tsconfig.base.json",
2
+ "extends": "@codyswann/lisa/tsconfig/base",
3
3
  "compilerOptions": {
4
4
  "module": "commonjs",
5
5
  "target": "ES2021",
package/package.json CHANGED
@@ -78,7 +78,7 @@
78
78
  "lodash": ">=4.18.1"
79
79
  },
80
80
  "name": "@codyswann/lisa",
81
- "version": "1.85.3",
81
+ "version": "1.85.5",
82
82
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
83
83
  "main": "dist/index.js",
84
84
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-cdk",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "AWS CDK-specific plugin",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-expo",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "Expo/React Native-specific skills, agents, rules, and MCP servers",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-nestjs",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "NestJS-specific skills (GraphQL, TypeORM)",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "1.85.3",
3
+ "version": "1.85.5",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -24,24 +24,76 @@ cd "$PROJECT_ROOT"
24
24
  # explicit `lisa:update` (npx @codyswann/lisa@latest .) or the project's own
25
25
  # postinstall script (defaults.scripts.postinstall in package.lisa.json).
26
26
 
27
- # Strip the hooks key from .claude/settings.json if .claude/hooks/ is now empty/absent
28
- # (hooks moved to plugin.json; all .claude/hooks/*.sh scripts are deleted by lisa update)
27
+ # Strip only hook entries that reference deleted .claude/hooks/*.sh scripts
28
+ # (hooks moved to plugin.json; file-path hooks would produce "No such file or directory" errors).
29
+ # Preserve inline command hooks (e.g. `command -v entire ...`, `echo ...`) and stack-template hooks
30
+ # from rails/merge/.claude/settings.json.
29
31
  SETTINGS_FILE="$PROJECT_ROOT/.claude/settings.json"
30
- HOOKS_DIR="$PROJECT_ROOT/.claude/hooks"
31
32
  if [ -f "$SETTINGS_FILE" ] && command -v python3 >/dev/null 2>&1; then
32
- if [ ! -d "$HOOKS_DIR" ] || [ -z "$(ls -A "$HOOKS_DIR" 2>/dev/null)" ]; then
33
- python3 - "$SETTINGS_FILE" <<'PYEOF'
33
+ python3 - "$SETTINGS_FILE" <<'PYEOF'
34
34
  import json, sys
35
35
  path = sys.argv[1]
36
36
  with open(path) as f:
37
37
  d = json.load(f)
38
- if "hooks" in d:
39
- del d["hooks"]
38
+
39
+ hooks = d.get("hooks")
40
+ if not isinstance(hooks, dict):
41
+ sys.exit(0)
42
+
43
+ def is_stale(entry):
44
+ # Stale = hook entry whose command references the deleted .claude/hooks/ dir.
45
+ if not isinstance(entry, dict):
46
+ return False
47
+ cmd = entry.get("command", "")
48
+ return isinstance(cmd, str) and "$CLAUDE_PROJECT_DIR/.claude/hooks/" in cmd
49
+
50
+ changed = False
51
+ new_hooks = {}
52
+ for category, matchers in hooks.items():
53
+ if not isinstance(matchers, list):
54
+ new_hooks[category] = matchers
55
+ continue
56
+ new_matchers = []
57
+ for matcher in matchers:
58
+ if not isinstance(matcher, dict):
59
+ new_matchers.append(matcher)
60
+ continue
61
+ if "hooks" not in matcher:
62
+ new_matchers.append(matcher)
63
+ continue
64
+
65
+ entries = matcher.get("hooks")
66
+ if isinstance(entries, list):
67
+ kept = [e for e in entries if not is_stale(e)]
68
+ if len(kept) != len(entries):
69
+ changed = True
70
+ if kept:
71
+ new_matcher = dict(matcher)
72
+ new_matcher["hooks"] = kept
73
+ new_matchers.append(new_matcher)
74
+ elif entries:
75
+ # drop matcher only when pruning emptied a previously non-empty hooks list
76
+ changed = True
77
+ else:
78
+ # preserve pre-existing empty matcher blocks unchanged
79
+ new_matchers.append(matcher)
80
+ else:
81
+ new_matchers.append(matcher)
82
+ if new_matchers:
83
+ new_hooks[category] = new_matchers
84
+ else:
85
+ # drop empty category
86
+ changed = True
87
+
88
+ if changed:
89
+ if new_hooks:
90
+ d["hooks"] = new_hooks
91
+ else:
92
+ del d["hooks"]
40
93
  with open(path, "w") as f:
41
94
  json.dump(d, f, indent=2)
42
95
  f.write("\n")
43
96
  PYEOF
44
- fi
45
97
  fi
46
98
 
47
99
  # Install plugins only when claude CLI is available