@codyswann/lisa 4.66.5 → 4.67.0

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 (70) hide show
  1. package/all/copy-overwrite/scripts/lisa-self-update.mjs +636 -0
  2. package/all/create-only/.github/workflows/lisa-update.yml +93 -0
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +14 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +4 -4
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa/hooks/enforcement-vintage-npm.mjs +320 -0
  14. package/plugins/lisa/hooks/enforcement-vintage.mjs +22 -3
  15. package/plugins/lisa-agy/plugin.json +1 -1
  16. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/hooks/enforcement-vintage-npm.mjs +320 -0
  23. package/plugins/lisa-copilot/hooks/enforcement-vintage.mjs +22 -3
  24. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/hooks/enforcement-vintage-npm.mjs +320 -0
  26. package/plugins/lisa-cursor/hooks/enforcement-vintage.mjs +22 -3
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/src/base/hooks/enforcement-vintage-npm.mjs +320 -0
  68. package/plugins/src/base/hooks/enforcement-vintage.mjs +22 -3
  69. package/scripts/two-channel-couplings.json +1 -1
  70. package/typescript/copy-overwrite/.prettierignore +1 -0
@@ -0,0 +1,636 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * Open this project's own Lisa update pull request (CodySwannGT/lisa#4325).
7
+ *
8
+ * A host project never moved forward on its own: the only update path was a
9
+ * person running the fleet update from the Lisa checkout, so projects sat many
10
+ * releases behind with nothing saying so. This script is the pull half. The
11
+ * scheduled `.github/workflows/lisa-update.yml` runs it; it can also be run by
12
+ * hand with `--dry-run` to see what it would do.
13
+ *
14
+ * What it does, in order:
15
+ *
16
+ * 1. Compares the installed `@codyswann/lisa` with npm latest. Current → exit 0.
17
+ * 2. Bumps the dependency with the package manager the project's `engines`
18
+ * field allows (a `please-use-*` sentinel is authoritative; lockfiles only
19
+ * break a tie), and commits that.
20
+ * 3. Runs the FULL explicit apply. Installs never apply templates
21
+ * (CodySwannGT/lisa#4135), so a bump without this step would land a version
22
+ * whose templates, guardrails and migrations never arrived (#2763).
23
+ * 4. Proves the apply ran — `.lisa/apply-receipt.json` must name the new
24
+ * version and `"apply_mode": "full"` — and commits the template diff.
25
+ * 5. Pushes `lisa/update-<version>`, opens (or reuses) the pull request,
26
+ * closes older `lisa/update-*` pull requests as superseded, and arms
27
+ * auto-merge with a merge method the repository allows.
28
+ *
29
+ * The pull request goes through every gate a human change does. Nothing here
30
+ * merges, approves, or skips a check.
31
+ * @module scripts/lisa-self-update
32
+ */
33
+ import { execFile } from "node:child_process";
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import path from "node:path";
36
+ import { pathToFileURL } from "node:url";
37
+
38
+ /** The package this script keeps current. */
39
+ export const LISA_PACKAGE = "@codyswann/lisa";
40
+
41
+ /** Branch prefix every update pull request uses; supersession keys on it. */
42
+ export const BRANCH_PREFIX = "lisa/update-";
43
+
44
+ /** Flags this script accepts. Anything else is refused, never ignored. */
45
+ const KNOWN_FLAGS = new Set(["--dry-run", "--help"]);
46
+
47
+ /** Package managers in lockfile-tiebreak order. */
48
+ const LOCKFILES = [
49
+ ["bun.lock", "bun"],
50
+ ["bun.lockb", "bun"],
51
+ ["pnpm-lock.yaml", "pnpm"],
52
+ ["yarn.lock", "yarn"],
53
+ ["package-lock.json", "npm"],
54
+ ];
55
+
56
+ /** Merge methods in preference order, with the REST setting that allows each. */
57
+ const MERGE_METHODS = [
58
+ ["--merge", "allow_merge_commit"],
59
+ ["--squash", "allow_squash_merge"],
60
+ ["--rebase", "allow_rebase_merge"],
61
+ ];
62
+
63
+ /**
64
+ * Parse argv strictly: an unknown flag is an error, because a typo that
65
+ * silently dropped `--dry-run` would push a real branch.
66
+ * @param {readonly string[]} argv Arguments after the script path.
67
+ * @returns {{dryRun: boolean, help: boolean}} Parsed options.
68
+ */
69
+ export function parseFlags(argv) {
70
+ const unknown = argv.filter(arg => !KNOWN_FLAGS.has(arg));
71
+ if (unknown.length > 0) {
72
+ throw new Error(
73
+ `lisa-self-update: unknown argument(s) ${unknown.join(", ")}. Accepted: ${[...KNOWN_FLAGS].join(", ")}.`
74
+ );
75
+ }
76
+ return { dryRun: argv.includes("--dry-run"), help: argv.includes("--help") };
77
+ }
78
+
79
+ /**
80
+ * Whether a project declares Lisa as a dependency it can update.
81
+ * @param {Record<string, any> | null} manifest Parsed package.json.
82
+ * @returns {boolean} True when the project consumes Lisa from npm.
83
+ */
84
+ export function declaresLisa(manifest) {
85
+ if (!manifest || manifest.name === LISA_PACKAGE) return false;
86
+ return Boolean(
87
+ manifest.devDependencies?.[LISA_PACKAGE] ??
88
+ manifest.dependencies?.[LISA_PACKAGE]
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Choose the package manager. `engines` is authoritative: a value of
94
+ * `please-use-<pm>` names the only allowed manager, and any manager whose own
95
+ * entry is such a sentinel is forbidden. Lockfiles break the remaining tie.
96
+ * @param {Record<string, string> | undefined} engines The `engines` field.
97
+ * @param {readonly string[]} lockfiles Lockfile names present at the root.
98
+ * @returns {"bun" | "npm" | "pnpm" | "yarn"} The manager to run.
99
+ */
100
+ export function choosePackageManager(engines, lockfiles) {
101
+ const entries = Object.entries(engines ?? {});
102
+ const named = entries
103
+ .map(([, value]) => /^please-use-(bun|npm|pnpm|yarn)$/u.exec(String(value)))
104
+ .find(Boolean);
105
+ if (named) return /** @type {any} */ (named[1]);
106
+ const forbidden = new Set(
107
+ entries
108
+ .filter(([, value]) => String(value).startsWith("please-use-"))
109
+ .map(([key]) => key)
110
+ );
111
+ const fromLock = LOCKFILES.find(
112
+ ([file, manager]) => lockfiles.includes(file) && !forbidden.has(manager)
113
+ );
114
+ return /** @type {any} */ (fromLock ? fromLock[1] : "npm");
115
+ }
116
+
117
+ /**
118
+ * The commands that install the current lockfile and bump Lisa.
119
+ * @param {"bun" | "npm" | "pnpm" | "yarn"} manager Package manager.
120
+ * @param {string} version Target version.
121
+ * @returns {{install: string[], bump: string[]}} argv arrays.
122
+ */
123
+ export function packageCommands(manager, version) {
124
+ const spec = `${LISA_PACKAGE}@${version}`;
125
+ const table = {
126
+ bun: {
127
+ install: ["bun", "install", "--frozen-lockfile"],
128
+ bump: ["bun", "add", "-D", spec],
129
+ },
130
+ npm: { install: ["npm", "ci"], bump: ["npm", "install", "-D", spec] },
131
+ pnpm: {
132
+ install: ["pnpm", "install", "--frozen-lockfile"],
133
+ bump: ["pnpm", "add", "-D", spec],
134
+ },
135
+ yarn: {
136
+ install: ["yarn", "install", "--immutable"],
137
+ bump: ["yarn", "add", "-D", spec],
138
+ },
139
+ };
140
+ return table[manager];
141
+ }
142
+
143
+ /**
144
+ * Numeric `major.minor.patch` of a version; anything unparseable is 0, so an
145
+ * unreadable version can never look newer than a readable one.
146
+ * @param {string} version Version string.
147
+ * @returns {number[]} Three numbers.
148
+ */
149
+ function release(version) {
150
+ const parts = String(version).split(/[-+]/u)[0].split(".");
151
+ return [0, 1, 2].map(index =>
152
+ /^\d+$/u.test(parts[index] ?? "") ? Number(parts[index]) : 0
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Whether `a` is an older release than `b`.
158
+ * @param {string} a Candidate older version.
159
+ * @param {string} b Candidate newer version.
160
+ * @returns {boolean} True when `a` precedes `b`.
161
+ */
162
+ export function isOlder(a, b) {
163
+ const left = release(a);
164
+ const right = release(b);
165
+ const index = left.findIndex((value, i) => value !== right[i]);
166
+ if (index !== -1) return left[index] < right[index];
167
+ // Same release: a prerelease of it is older than the release itself
168
+ // (`4.66.5-rc.1` → `4.66.5`). npm `latest` is never a prerelease, so two
169
+ // prereleases are not compared here.
170
+ return prerelease(a) !== "" && prerelease(b) === "";
171
+ }
172
+
173
+ /**
174
+ * The prerelease suffix of a version, without build metadata.
175
+ * @param {string} version Version string.
176
+ * @returns {string} The suffix, or "" for a release.
177
+ */
178
+ function prerelease(version) {
179
+ const core = String(version).split("+")[0];
180
+ const dash = core.indexOf("-");
181
+ return dash === -1 ? "" : core.slice(dash + 1);
182
+ }
183
+
184
+ /**
185
+ * Decide what this run does.
186
+ * @param {{declares: boolean, installed: string | null, latest: string | null}} input Facts.
187
+ * @returns {{action: "skip", reason: string} | {action: "update", from: string, to: string, branch: string}} The plan.
188
+ */
189
+ export function planUpdate(input) {
190
+ if (!input.declares) {
191
+ return {
192
+ action: "skip",
193
+ reason:
194
+ "this project does not install @codyswann/lisa from npm, so there is nothing to update.",
195
+ };
196
+ }
197
+ if (!input.installed) {
198
+ return {
199
+ action: "skip",
200
+ reason:
201
+ "the installed Lisa version could not be read after install; refusing to guess.",
202
+ };
203
+ }
204
+ if (!input.latest) {
205
+ return {
206
+ action: "skip",
207
+ reason: "npm did not answer with a latest version; try again later.",
208
+ };
209
+ }
210
+ if (!isOlder(input.installed, input.latest)) {
211
+ return {
212
+ action: "skip",
213
+ reason: `Lisa is current (${input.installed}).`,
214
+ };
215
+ }
216
+ return {
217
+ action: "update",
218
+ from: input.installed,
219
+ to: input.latest,
220
+ branch: `${BRANCH_PREFIX}${input.latest}`,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * The base branch the update targets: the lowest environment in
226
+ * `deploy.order`, else the only configured deploy branch, else the default.
227
+ * @param {Record<string, any> | null} config Parsed `.lisa.config.json`.
228
+ * @param {string} defaultBranch Repository default branch.
229
+ * @returns {string} Base branch name.
230
+ */
231
+ export function chooseBase(config, defaultBranch) {
232
+ const branches = config?.deploy?.branches;
233
+ if (!branches || typeof branches !== "object") return defaultBranch;
234
+ const lowest = Array.isArray(config.deploy.order)
235
+ ? config.deploy.order.find(env => typeof branches[env] === "string")
236
+ : undefined;
237
+ if (lowest) return branches[lowest];
238
+ const values = Object.values(branches).filter(v => typeof v === "string");
239
+ return values.length === 1 ? values[0] : defaultBranch;
240
+ }
241
+
242
+ /**
243
+ * The `gh pr merge` flag for the first method the repository allows.
244
+ * @param {Record<string, unknown>} settings REST repository settings.
245
+ * @returns {string | null} Flag, or null when auto-merge cannot be armed.
246
+ */
247
+ export function chooseMergeFlag(settings) {
248
+ if (settings.allow_auto_merge !== true) return null;
249
+ const allowed = MERGE_METHODS.find(([, key]) => settings[key] === true);
250
+ return allowed ? allowed[0] : null;
251
+ }
252
+
253
+ /**
254
+ * Open update pull requests other than the one this run owns.
255
+ * @param {readonly {number: number, headRefName: string}[]} open Open PRs.
256
+ * @param {string} branch This run's branch.
257
+ * @returns {number[]} PR numbers to close as superseded.
258
+ */
259
+ export function supersededPullRequests(open, branch) {
260
+ return open
261
+ .filter(
262
+ pr =>
263
+ pr.headRefName.startsWith(BRANCH_PREFIX) && pr.headRefName !== branch
264
+ )
265
+ .map(pr => pr.number);
266
+ }
267
+
268
+ /**
269
+ * Commit message, with the optional standing work-item trailer.
270
+ * @param {string} subject Conventional subject.
271
+ * @param {string} body Paragraph explaining the change.
272
+ * @param {string | undefined} workItem `LISA_UPDATE_WORK_ITEM`, if set.
273
+ * @returns {string} Full message.
274
+ */
275
+ export function commitMessage(subject, body, workItem) {
276
+ const trailer = workItem ? `\n\nWork-Item: ${workItem}` : "";
277
+ return `${subject}\n\n${body}${trailer}\n`;
278
+ }
279
+
280
+ /**
281
+ * Pull request body, written for a non-technical reader.
282
+ * @param {{from: string, to: string}} plan The update.
283
+ * @param {string | undefined} workItem Standing work item, if configured.
284
+ * @returns {string} Markdown body.
285
+ */
286
+ export function pullRequestBody(plan, workItem) {
287
+ const lines = [
288
+ `This updates Lisa, the project's engineering guardrails, from **${plan.from}** to **${plan.to}**.`,
289
+ "",
290
+ "It was opened automatically by the scheduled Lisa Update workflow. It contains two commits: the dependency bump, and the template changes Lisa applies for the new version. It goes through every check a normal change does and merges itself only when they all pass.",
291
+ "",
292
+ `Release notes: https://github.com/CodySwannGT/lisa/releases/tag/v${plan.to}`,
293
+ ];
294
+ if (!workItem) {
295
+ lines.push(
296
+ "",
297
+ 'If this project requires every change to reference a work item, set the repository variable `LISA_UPDATE_WORK_ITEM` to a standing work item (for example a "Keep Lisa current" ticket) so future update pull requests carry it.'
298
+ );
299
+ }
300
+ return `${lines.join("\n")}\n`;
301
+ }
302
+
303
+ /**
304
+ * Proof the full apply ran for the target version.
305
+ * @param {Record<string, any> | null} receipt Parsed `.lisa/apply-receipt.json`.
306
+ * @param {string} version Target version.
307
+ * @returns {string | null} A failure reason, or null when proven.
308
+ */
309
+ export function receiptProblem(receipt, version) {
310
+ if (!receipt) return "the apply wrote no .lisa/apply-receipt.json";
311
+ if (receipt.lisa_version !== version) {
312
+ return `the apply receipt names ${receipt.lisa_version}, not ${version}`;
313
+ }
314
+ if (receipt.apply_mode !== "full") {
315
+ return `the apply ran in "${receipt.apply_mode}" mode, not "full", so agent emits and migrations were skipped`;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * Read and parse a JSON file, or null.
322
+ * @param {string} file Absolute path.
323
+ * @returns {any} Parsed value or null.
324
+ */
325
+ function readJson(file) {
326
+ try {
327
+ return JSON.parse(readFileSync(file, "utf8"));
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Run a command, inheriting nothing but the environment given.
335
+ * @param {string[]} argv Command and arguments.
336
+ * @param {{cwd: string, env?: NodeJS.ProcessEnv}} options Spawn options.
337
+ * @returns {Promise<string>} Trimmed stdout.
338
+ */
339
+ export function runCommand(argv, options) {
340
+ return new Promise((resolve, reject) => {
341
+ execFile(
342
+ argv[0],
343
+ argv.slice(1),
344
+ {
345
+ cwd: options.cwd,
346
+ env: options.env ?? process.env,
347
+ maxBuffer: 64 * 1024 * 1024,
348
+ },
349
+ (error, stdout, stderr) => {
350
+ if (error) {
351
+ reject(
352
+ new Error(
353
+ `\`${argv.join(" ")}\` failed: ${String(stderr || error.message).trim()}`
354
+ )
355
+ );
356
+ return;
357
+ }
358
+ resolve(String(stdout).trim());
359
+ }
360
+ );
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Gather the facts a plan needs, installing the base branch first.
366
+ * @param {{root: string, run: typeof runCommand, log: (line: string) => void}} ctx Context.
367
+ * @returns {Promise<object>} Facts.
368
+ */
369
+ async function gatherFacts(ctx) {
370
+ const manifest = readJson(path.join(ctx.root, "package.json"));
371
+ const declares = declaresLisa(manifest);
372
+ if (!declares) return { declares, manifest };
373
+ const manager = choosePackageManager(
374
+ manifest.engines,
375
+ LOCKFILES.map(([file]) => file).filter(file =>
376
+ existsSync(path.join(ctx.root, file))
377
+ )
378
+ );
379
+ const latest = await ctx
380
+ .run(["npm", "view", LISA_PACKAGE, "version"], { cwd: ctx.root })
381
+ .catch(() => null);
382
+ if (!ctx.dryRun) {
383
+ ctx.log(`Installing the current lockfile with ${manager}…`);
384
+ await ctx.run(packageCommands(manager, latest ?? "latest").install, {
385
+ cwd: ctx.root,
386
+ });
387
+ }
388
+ const installed =
389
+ readJson(
390
+ path.join(ctx.root, "node_modules", "@codyswann", "lisa", "package.json")
391
+ )?.version ?? null;
392
+ return { declares, manifest, manager, latest, installed };
393
+ }
394
+
395
+ /**
396
+ * Bump, apply, prove, and commit on the update branch.
397
+ * @param {object} ctx Context.
398
+ * @param {{from: string, to: string, branch: string}} plan The update.
399
+ * @param {string} manager Package manager.
400
+ * @returns {Promise<void>} Resolves when both commits exist.
401
+ */
402
+ async function buildBranch(ctx, plan, manager) {
403
+ const { root, run, workItem } = ctx;
404
+ await run(["git", "checkout", "-B", plan.branch], { cwd: root });
405
+ await run(packageCommands(manager, plan.to).bump, { cwd: root });
406
+ await run(["git", "add", "-A"], { cwd: root });
407
+ await run(
408
+ [
409
+ "git",
410
+ "commit",
411
+ "-m",
412
+ commitMessage(
413
+ `chore(deps): update ${LISA_PACKAGE} to ${plan.to}`,
414
+ `Bumps Lisa from ${plan.from} to ${plan.to}.`,
415
+ workItem
416
+ ),
417
+ ],
418
+ { cwd: root }
419
+ );
420
+ await run(
421
+ ["node", "node_modules/@codyswann/lisa/dist/index.js", "--yes", "."],
422
+ { cwd: root, env: { ...process.env, LISA_BOOTSTRAP: "1" } }
423
+ );
424
+ const problem = receiptProblem(
425
+ readJson(path.join(root, ".lisa", "apply-receipt.json")),
426
+ plan.to
427
+ );
428
+ if (problem) throw new Error(`lisa-self-update: ${problem}.`);
429
+ await run(["git", "add", "-A"], { cwd: root });
430
+ const staged = await run(["git", "diff", "--cached", "--name-only"], {
431
+ cwd: root,
432
+ });
433
+ if (staged) {
434
+ await run(
435
+ [
436
+ "git",
437
+ "commit",
438
+ "-m",
439
+ commitMessage(
440
+ `chore: apply Lisa ${plan.to} templates`,
441
+ `Template, guardrail and migration changes from \`lisa apply\` at ${plan.to}.`,
442
+ workItem
443
+ ),
444
+ ],
445
+ { cwd: root }
446
+ );
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Push, open or reuse the PR, close superseded ones, and arm auto-merge.
452
+ * @param {object} ctx Context.
453
+ * @param {{from: string, to: string, branch: string}} plan The update.
454
+ * @param {string} base Base branch.
455
+ * @returns {Promise<string>} The pull request URL.
456
+ */
457
+ async function publish(ctx, plan, base) {
458
+ const { root, run, repo, log, workItem } = ctx;
459
+ await run(["git", "push", "--force", "-u", "origin", plan.branch], {
460
+ cwd: root,
461
+ });
462
+ const existing = await run(
463
+ [
464
+ "gh",
465
+ "pr",
466
+ "list",
467
+ "--repo",
468
+ repo,
469
+ "--head",
470
+ plan.branch,
471
+ "--state",
472
+ "open",
473
+ "--json",
474
+ "url",
475
+ "--jq",
476
+ '.[0].url // ""',
477
+ ],
478
+ { cwd: root }
479
+ );
480
+ const url =
481
+ existing ||
482
+ (await run(
483
+ [
484
+ "gh",
485
+ "pr",
486
+ "create",
487
+ "--repo",
488
+ repo,
489
+ "--base",
490
+ base,
491
+ "--head",
492
+ plan.branch,
493
+ "--title",
494
+ `chore(deps): update Lisa to ${plan.to}`,
495
+ "--body",
496
+ pullRequestBody(plan, workItem),
497
+ ],
498
+ { cwd: root }
499
+ ));
500
+ const open = JSON.parse(
501
+ await run(
502
+ [
503
+ "gh",
504
+ "pr",
505
+ "list",
506
+ "--repo",
507
+ repo,
508
+ "--state",
509
+ "open",
510
+ "--search",
511
+ `head:${BRANCH_PREFIX}`,
512
+ "--json",
513
+ "number,headRefName",
514
+ ],
515
+ { cwd: root }
516
+ )
517
+ );
518
+ for (const number of supersededPullRequests(open, plan.branch)) {
519
+ await run(
520
+ [
521
+ "gh",
522
+ "pr",
523
+ "close",
524
+ String(number),
525
+ "--repo",
526
+ repo,
527
+ "--delete-branch",
528
+ "--comment",
529
+ `Superseded by ${url} (Lisa ${plan.to}).`,
530
+ ],
531
+ { cwd: root }
532
+ );
533
+ log(`Closed superseded pull request #${number}.`);
534
+ }
535
+ const settings = JSON.parse(
536
+ await run(["gh", "api", `repos/${repo}`], { cwd: root })
537
+ );
538
+ const flag = chooseMergeFlag(settings);
539
+ if (flag) {
540
+ await run(["gh", "pr", "merge", url, "--auto", flag], { cwd: root });
541
+ log(
542
+ `Auto-merge armed (${flag}); the pull request merges when its checks pass.`
543
+ );
544
+ } else {
545
+ log(
546
+ "Auto-merge is not enabled for this repository, so the pull request stays open for someone to merge once its checks pass."
547
+ );
548
+ }
549
+ return url;
550
+ }
551
+
552
+ /**
553
+ * Start from the tip of the base branch, whatever the workflow checked out.
554
+ * @param {object} ctx Context.
555
+ * @param {string} base Base branch.
556
+ * @returns {Promise<void>} Resolves when HEAD is the base tip.
557
+ */
558
+ async function checkoutBase(ctx, base) {
559
+ await ctx.run(["git", "fetch", "origin", base], { cwd: ctx.root });
560
+ await ctx.run(["git", "checkout", "-B", base, `origin/${base}`], {
561
+ cwd: ctx.root,
562
+ });
563
+ }
564
+
565
+ /**
566
+ * Run the whole flow.
567
+ * @param {{root: string, repo: string, dryRun: boolean, workItem?: string, run?: typeof runCommand, log?: (line: string) => void, defaultBranch?: string}} options Options.
568
+ * @returns {Promise<number>} Exit code.
569
+ */
570
+ export async function main(options) {
571
+ const ctx = {
572
+ run: runCommand,
573
+ log: line => console.log(line),
574
+ ...options,
575
+ };
576
+ const base = chooseBase(
577
+ readJson(path.join(ctx.root, ".lisa.config.json")),
578
+ ctx.defaultBranch ?? "main"
579
+ );
580
+ if (!ctx.dryRun) await checkoutBase(ctx, base);
581
+ const facts = await gatherFacts(ctx);
582
+ const plan = planUpdate(facts);
583
+ if (plan.action === "skip") {
584
+ ctx.log(`lisa-self-update: nothing to do — ${plan.reason}`);
585
+ return 0;
586
+ }
587
+ ctx.log(
588
+ `lisa-self-update: Lisa ${plan.from} → ${plan.to}; branch ${plan.branch} into ${base}.`
589
+ );
590
+ if (ctx.dryRun) {
591
+ ctx.log(
592
+ `Dry run: would bump with ${facts.manager}, run the full apply, commit, push ${plan.branch}, open a pull request into ${base}, close older ${BRANCH_PREFIX}* pull requests and arm auto-merge.`
593
+ );
594
+ return 0;
595
+ }
596
+ await buildBranch(ctx, plan, facts.manager);
597
+ const url = await publish(ctx, plan, base);
598
+ ctx.log(`lisa-self-update: update pull request ${url}`);
599
+ return 0;
600
+ }
601
+
602
+ /** CLI entry: reads flags and the workflow's environment. */
603
+ export async function runCli() {
604
+ try {
605
+ const flags = parseFlags(process.argv.slice(2));
606
+ if (flags.help) {
607
+ console.log(
608
+ "Usage: node scripts/lisa-self-update.mjs [--dry-run]\nOpens a pull request updating @codyswann/lisa to npm latest. Needs GITHUB_REPOSITORY and an authenticated gh."
609
+ );
610
+ return;
611
+ }
612
+ const repo = process.env.GITHUB_REPOSITORY ?? "";
613
+ if (!flags.dryRun && !/^[\w.-]+\/[\w.-]+$/u.test(repo)) {
614
+ throw new Error(
615
+ "lisa-self-update: set GITHUB_REPOSITORY to owner/repository."
616
+ );
617
+ }
618
+ process.exitCode = await main({
619
+ root: process.cwd(),
620
+ repo,
621
+ dryRun: flags.dryRun,
622
+ workItem: process.env.LISA_UPDATE_WORK_ITEM || undefined,
623
+ defaultBranch: process.env.LISA_DEFAULT_BRANCH || undefined,
624
+ });
625
+ } catch (error) {
626
+ console.error(error instanceof Error ? error.message : String(error));
627
+ process.exitCode = 1;
628
+ }
629
+ }
630
+
631
+ if (
632
+ process.argv[1] &&
633
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
634
+ ) {
635
+ runCli();
636
+ }