@codyswann/lisa 4.68.1 → 4.69.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 (85) hide show
  1. package/all/copy-overwrite/scripts/lisa-work-item.mjs +96 -5
  2. package/all/deletions.json +4 -2
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +8 -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/project-config.d.ts +6 -0
  8. package/dist/core/project-config.d.ts.map +1 -1
  9. package/dist/core/project-config.js.map +1 -1
  10. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  11. package/dist/core/upstream-evidence-manifest.js +20 -14
  12. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  13. package/dist/opencode/plugin-templates/lisa-session-bootstrap.ts +41 -1
  14. package/package.json +4 -4
  15. package/plugins/lisa/.claude-plugin/plugin.json +11 -1
  16. package/plugins/lisa/.codex-plugin/hooks.json +10 -0
  17. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa/hooks/auto-update.mjs +756 -0
  19. package/plugins/lisa/hooks/auto-update.sh +31 -0
  20. package/plugins/lisa/hooks/enforcement-vintage-npm.mjs +2 -2
  21. package/plugins/lisa-agy/plugin.json +1 -1
  22. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  23. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  25. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-copilot/.claude-plugin/plugin.json +11 -1
  28. package/plugins/lisa-copilot/hooks/auto-update.mjs +756 -0
  29. package/plugins/lisa-copilot/hooks/auto-update.sh +31 -0
  30. package/plugins/lisa-copilot/hooks/enforcement-vintage-npm.mjs +2 -2
  31. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/hooks/auto-update.mjs +756 -0
  33. package/plugins/lisa-cursor/hooks/auto-update.sh +31 -0
  34. package/plugins/lisa-cursor/hooks/enforcement-vintage-npm.mjs +2 -2
  35. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  36. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-agy/plugin.json +1 -1
  39. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  59. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-agy/plugin.json +1 -1
  64. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  69. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  74. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/src/base/.claude-plugin/plugin.json +10 -0
  77. package/plugins/src/base/hooks/auto-update.mjs +756 -0
  78. package/plugins/src/base/hooks/auto-update.sh +31 -0
  79. package/plugins/src/base/hooks/enforcement-vintage-npm.mjs +2 -2
  80. package/scripts/lib/per-agent-hook-filter.mjs +13 -0
  81. package/scripts/two-channel-couplings.json +1 -1
  82. package/typescript/copy-overwrite/.prettierignore +0 -1
  83. package/all/copy-overwrite/scripts/lisa-self-update.mjs +0 -1047
  84. package/all/create-only/.github/workflows/lisa-update.yml +0 -99
  85. package/scripts/lisa-self-update.mjs +0 -14
@@ -1,1047 +0,0 @@
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. A project that tracks work in GitHub
31
- * Issues gets a fresh work item per update (trailer, PR line and backlink), so
32
- * a traceability gate is satisfied rather than bypassed; elsewhere,
33
- * `LISA_UPDATE_WORK_ITEM` names one.
34
- *
35
- * In Lisa's own repository (self mode, CodySwannGT/lisa#4331) the same flow
36
- * moves Lisa's dependency on its published self after each release: no template
37
- * apply, a caret floor, and a loop guard so a self-bump never chases the release
38
- * its own merge cuts.
39
- * @module scripts/lisa-self-update
40
- */
41
- import { execFile } from "node:child_process";
42
- import { existsSync, readFileSync } from "node:fs";
43
- import path from "node:path";
44
- import { pathToFileURL } from "node:url";
45
-
46
- /** The package this script keeps current. */
47
- export const LISA_PACKAGE = "@codyswann/lisa";
48
-
49
- /** Branch prefix every update pull request uses; supersession keys on it. */
50
- export const BRANCH_PREFIX = "lisa/update-";
51
-
52
- /** The manifest every package-manager decision reads. */
53
- const MANIFEST = "package.json";
54
-
55
- /** Flags this script accepts. Anything else is refused, never ignored. */
56
- const KNOWN_FLAGS = new Set(["--dry-run", "--help"]);
57
-
58
- /** Package managers in lockfile-tiebreak order. */
59
- const LOCKFILES = [
60
- ["bun.lock", "bun"],
61
- ["bun.lockb", "bun"],
62
- ["pnpm-lock.yaml", "pnpm"],
63
- ["yarn.lock", "yarn"],
64
- ["package-lock.json", "npm"],
65
- ];
66
-
67
- /** Merge methods in preference order, with the REST setting that allows each. */
68
- const MERGE_METHODS = [
69
- ["--merge", "allow_merge_commit"],
70
- ["--squash", "allow_squash_merge"],
71
- ["--rebase", "allow_rebase_merge"],
72
- ];
73
-
74
- /**
75
- * Parse argv strictly: an unknown flag is an error, because a typo that
76
- * silently dropped `--dry-run` would push a real branch.
77
- * @param {readonly string[]} argv Arguments after the script path.
78
- * @returns {{dryRun: boolean, help: boolean}} Parsed options.
79
- */
80
- export function parseFlags(argv) {
81
- const unknown = argv.filter(arg => !KNOWN_FLAGS.has(arg));
82
- if (unknown.length > 0) {
83
- throw new Error(
84
- `lisa-self-update: unknown argument(s) ${unknown.join(", ")}. Accepted: ${[...KNOWN_FLAGS].join(", ")}.`
85
- );
86
- }
87
- return { dryRun: argv.includes("--dry-run"), help: argv.includes("--help") };
88
- }
89
-
90
- /**
91
- * Whether a project declares Lisa as a dependency it can update.
92
- * @param {Record<string, any> | null} manifest Parsed package.json.
93
- * @returns {boolean} True when the project consumes Lisa from npm.
94
- */
95
- export function declaresLisa(manifest) {
96
- if (!manifest || manifest.name === LISA_PACKAGE) return false;
97
- return Boolean(
98
- manifest.devDependencies?.[LISA_PACKAGE] ??
99
- manifest.dependencies?.[LISA_PACKAGE]
100
- );
101
- }
102
-
103
- /**
104
- * Whether this manifest IS Lisa — the repository that publishes the package.
105
- *
106
- * Lisa depends on a published copy of itself, and that pin is the one no other
107
- * automation corrects: `lisa apply` deliberately skips the self-pin phase on
108
- * its own manifest (CodySwannGT/lisa#3768). Self mode updates it the same way
109
- * a host is updated, minus the template apply Lisa never runs on itself.
110
- * @param {Record<string, any> | null} manifest Parsed package.json.
111
- * @returns {boolean} True for Lisa's own repository.
112
- */
113
- export function isSelfManifest(manifest) {
114
- return manifest?.name === LISA_PACKAGE;
115
- }
116
-
117
- /** Subject of the commit a self-update makes; the loop guard keys on it. */
118
- export function selfBumpSubject(version) {
119
- return `chore(deps): update ${LISA_PACKAGE} self-dependency to ${version}`;
120
- }
121
-
122
- /** A release-bot commit, in either form Lisa's release workflow has emitted. */
123
- const RELEASE_SUBJECT = /^chore\(release\): \S+ \[skip ci\](?: \[skip-cd\])?$/u;
124
-
125
- /** A self-update commit made by {@link selfBumpSubject}. */
126
- const SELF_BUMP_SUBJECT =
127
- /^chore\(deps\): update @codyswann\/lisa self-dependency to \S+$/u;
128
-
129
- /**
130
- * The loop guard. Every merge to Lisa's `main` cuts a release, so merging a
131
- * self-bump publishes a NEWER version than the one it pinned — and an updater
132
- * that chased it would open a bump, merge it, release, and bump again forever.
133
- * When everything a release added since the pinned one is release commits and
134
- * self-bumps, the two differ only by the pin itself, and the pin is current.
135
- * @param {readonly string[]} subjects Non-merge commit subjects between the tags.
136
- * @returns {boolean} True when the newer release carries nothing else.
137
- */
138
- export function onlySelfBumps(subjects) {
139
- return subjects.every(
140
- subject => RELEASE_SUBJECT.test(subject) || SELF_BUMP_SUBJECT.test(subject)
141
- );
142
- }
143
-
144
- /**
145
- * The version Lisa should pin itself to. Its own `package.json` names the
146
- * release `main` just cut; once npm serves that exact version it is the
147
- * target, which beats waiting minutes for the cached `latest` pointer to move
148
- * (CodySwannGT/lisa#3685). Until then, npm latest.
149
- * @param {string | undefined} own Version in Lisa's own manifest.
150
- * @param {boolean} ownPublished Whether npm serves that exact version.
151
- * @param {string | null} npmLatest npm's `latest`.
152
- * @returns {string | null} Target version.
153
- */
154
- export function chooseSelfTarget(own, ownPublished, npmLatest) {
155
- if (!own || !ownPublished) return npmLatest;
156
- if (!npmLatest) return own;
157
- return isOlder(own, npmLatest) ? npmLatest : own;
158
- }
159
-
160
- /**
161
- * Choose the package manager. `engines` is authoritative: a value of
162
- * `please-use-<pm>` names the only allowed manager, and any manager whose own
163
- * entry is such a sentinel is forbidden. Lockfiles break the remaining tie.
164
- * @param {Record<string, string> | undefined} engines The `engines` field.
165
- * @param {readonly string[]} lockfiles Lockfile names present at the root.
166
- * @returns {"bun" | "npm" | "pnpm" | "yarn"} The manager to run.
167
- */
168
- export function choosePackageManager(engines, lockfiles) {
169
- const entries = Object.entries(engines ?? {});
170
- const named = entries
171
- .map(([, value]) => /^please-use-(bun|npm|pnpm|yarn)$/u.exec(String(value)))
172
- .find(Boolean);
173
- if (named) return /** @type {any} */ (named[1]);
174
- const forbidden = new Set(
175
- entries
176
- .filter(([, value]) => String(value).startsWith("please-use-"))
177
- .map(([key]) => key)
178
- );
179
- const fromLock = LOCKFILES.find(
180
- ([file, manager]) => lockfiles.includes(file) && !forbidden.has(manager)
181
- );
182
- return /** @type {any} */ (fromLock ? fromLock[1] : "npm");
183
- }
184
-
185
- /**
186
- * The commands that install the current lockfile and bump Lisa.
187
- * @param {"bun" | "npm" | "pnpm" | "yarn"} manager Package manager.
188
- * @param {string} version Target version.
189
- * @param {string} [range] Range operator to write, e.g. `^` for Lisa's own
190
- * caret floor; a host gets the exact version its manager records.
191
- * @returns {{install: string[], bump: string[]}} argv arrays.
192
- */
193
- export function packageCommands(manager, version, range = "") {
194
- const spec = `${LISA_PACKAGE}@${range}${version}`;
195
- const table = {
196
- bun: {
197
- install: ["bun", "install", "--frozen-lockfile"],
198
- bump: ["bun", "add", "-D", spec],
199
- },
200
- npm: { install: ["npm", "ci"], bump: ["npm", "install", "-D", spec] },
201
- pnpm: {
202
- install: ["pnpm", "install", "--frozen-lockfile"],
203
- bump: ["pnpm", "add", "-D", spec],
204
- },
205
- yarn: {
206
- install: ["yarn", "install", "--immutable"],
207
- bump: ["yarn", "add", "-D", spec],
208
- },
209
- };
210
- return table[manager];
211
- }
212
-
213
- /**
214
- * Numeric `major.minor.patch` of a version; anything unparseable is 0, so an
215
- * unreadable version can never look newer than a readable one.
216
- * @param {string} version Version string.
217
- * @returns {number[]} Three numbers.
218
- */
219
- function release(version) {
220
- const parts = String(version).split(/[-+]/u)[0].split(".");
221
- return [0, 1, 2].map(index =>
222
- /^\d+$/u.test(parts[index] ?? "") ? Number(parts[index]) : 0
223
- );
224
- }
225
-
226
- /**
227
- * Whether `a` is an older release than `b`.
228
- * @param {string} a Candidate older version.
229
- * @param {string} b Candidate newer version.
230
- * @returns {boolean} True when `a` precedes `b`.
231
- */
232
- export function isOlder(a, b) {
233
- const left = release(a);
234
- const right = release(b);
235
- const index = left.findIndex((value, i) => value !== right[i]);
236
- if (index !== -1) return left[index] < right[index];
237
- // Same release: a prerelease of it is older than the release itself
238
- // (`4.66.5-rc.1` → `4.66.5`). npm `latest` is never a prerelease, so two
239
- // prereleases are not compared here.
240
- return prerelease(a) !== "" && prerelease(b) === "";
241
- }
242
-
243
- /**
244
- * The prerelease suffix of a version, without build metadata.
245
- * @param {string} version Version string.
246
- * @returns {string} The suffix, or "" for a release.
247
- */
248
- function prerelease(version) {
249
- const core = String(version).split("+")[0];
250
- const dash = core.indexOf("-");
251
- return dash === -1 ? "" : core.slice(dash + 1);
252
- }
253
-
254
- /**
255
- * Decide what this run does.
256
- * @param {{declares: boolean, installed: string | null, latest: string | null}} input Facts.
257
- * @returns {{action: "skip", reason: string} | {action: "update", from: string, to: string, branch: string}} The plan.
258
- */
259
- export function planUpdate(input) {
260
- if (!input.declares) {
261
- return {
262
- action: "skip",
263
- reason:
264
- "this project does not install @codyswann/lisa from npm, so there is nothing to update.",
265
- };
266
- }
267
- if (!input.installed) {
268
- return {
269
- action: "skip",
270
- reason:
271
- "the installed Lisa version could not be read after install; refusing to guess.",
272
- };
273
- }
274
- if (!input.latest) {
275
- return {
276
- action: "skip",
277
- reason: "npm did not answer with a latest version; try again later.",
278
- };
279
- }
280
- if (!isOlder(input.installed, input.latest)) {
281
- return {
282
- action: "skip",
283
- reason: `Lisa is current (${input.installed}).`,
284
- };
285
- }
286
- return {
287
- action: "update",
288
- from: input.installed,
289
- to: input.latest,
290
- branch: `${BRANCH_PREFIX}${input.latest}`,
291
- };
292
- }
293
-
294
- /**
295
- * The base branch the update targets: the lowest environment in
296
- * `deploy.order`, else the only configured deploy branch, else the default.
297
- * @param {Record<string, any> | null} config Parsed `.lisa.config.json`.
298
- * @param {string} defaultBranch Repository default branch.
299
- * @returns {string} Base branch name.
300
- */
301
- export function chooseBase(config, defaultBranch) {
302
- const branches = config?.deploy?.branches;
303
- if (!branches || typeof branches !== "object") return defaultBranch;
304
- const lowest = Array.isArray(config.deploy.order)
305
- ? config.deploy.order.find(env => typeof branches[env] === "string")
306
- : undefined;
307
- if (lowest) return branches[lowest];
308
- const values = Object.values(branches).filter(v => typeof v === "string");
309
- return values.length === 1 ? values[0] : defaultBranch;
310
- }
311
-
312
- /**
313
- * The `gh pr merge` flag for the first method the repository allows.
314
- * @param {Record<string, unknown>} settings REST repository settings.
315
- * @returns {string | null} Flag, or null when auto-merge cannot be armed.
316
- */
317
- export function chooseMergeFlag(settings) {
318
- if (settings.allow_auto_merge !== true) return null;
319
- const allowed = MERGE_METHODS.find(([, key]) => settings[key] === true);
320
- return allowed ? allowed[0] : null;
321
- }
322
-
323
- /**
324
- * Open update pull requests other than the one this run owns.
325
- * @param {readonly {number: number, headRefName: string}[]} open Open PRs.
326
- * @param {string} branch This run's branch.
327
- * @returns {number[]} PR numbers to close as superseded.
328
- */
329
- export function supersededPullRequests(open, branch) {
330
- return open
331
- .filter(
332
- pr =>
333
- pr.headRefName.startsWith(BRANCH_PREFIX) && pr.headRefName !== branch
334
- )
335
- .map(pr => pr.number);
336
- }
337
-
338
- /**
339
- * Commit message, with the optional standing work-item trailer.
340
- * @param {string} subject Conventional subject.
341
- * @param {string} body Paragraph explaining the change.
342
- * @param {string | undefined} workItem The update's work item, if any.
343
- * @returns {string} Full message.
344
- */
345
- export function commitMessage(subject, body, workItem) {
346
- const trailer = workItem ? `\n\nWork-Item: ${workItem}` : "";
347
- return `${subject}\n\n${body}${trailer}\n`;
348
- }
349
-
350
- /**
351
- * Pull request body, written for a non-technical reader.
352
- * @param {{from: string, to: string}} plan The update.
353
- * @param {string | undefined} workItem The update's work item, if any.
354
- * @param {boolean} [selfMode] Whether this is Lisa updating itself.
355
- * @returns {string} Markdown body.
356
- */
357
- export function pullRequestBody(plan, workItem, selfMode = false) {
358
- const what = selfMode
359
- ? `This updates the copy of Lisa that Lisa itself is built and checked with, from **${plan.from}** to **${plan.to}**, so its own checks run the guardrails it ships.`
360
- : `This updates Lisa, the project's engineering guardrails, from **${plan.from}** to **${plan.to}**.`;
361
- const contents = selfMode
362
- ? "It was opened automatically after a release. It contains one commit, the dependency bump; Lisa does not apply its own templates to itself."
363
- : "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.";
364
- const lines = [
365
- what,
366
- "",
367
- `${contents} It goes through every check a normal change does and merges itself only when they all pass.`,
368
- "",
369
- `Release notes: https://github.com/CodySwannGT/lisa/releases/tag/v${plan.to}`,
370
- ];
371
- if (workItem) {
372
- lines.push("", `Work-Item: ${workItem}`);
373
- } else {
374
- lines.push(
375
- "",
376
- "If this project requires every change to reference a work item and does not track work in GitHub Issues, set the repository variable `LISA_UPDATE_WORK_ITEM` to an open work item for the next update. Projects that track work in GitHub Issues get a fresh work item for each update automatically."
377
- );
378
- }
379
- return `${lines.join("\n")}\n`;
380
- }
381
-
382
- /**
383
- * Labels for a per-update work item, from the project's own lifecycle config.
384
- *
385
- * A fresh item per update, never a standing one: merging into the production
386
- * branch completes the item it names, so a standing item is closed by the first
387
- * update and refuses every later one.
388
- * @param {Record<string, any> | null} config Parsed `.lisa.config.json`.
389
- * @returns {{ready: string, claimed: string, repo: string} | null} Labels, or
390
- * null when the project does not track work in GitHub Issues.
391
- */
392
- export function workItemLabels(config) {
393
- if (config?.tracker !== "github" || !config.github?.repo) return null;
394
- const build = config.github.labels?.build ?? {};
395
- return {
396
- ready: build.ready ?? "status:ready",
397
- claimed: build.claimed ?? "status:in-progress",
398
- repo: `repo:${config.github.repo}`,
399
- };
400
- }
401
-
402
- /**
403
- * Body of a per-update work item, written for a non-technical reader.
404
- * @param {{from: string, to: string}} plan The update.
405
- * @param {boolean} selfMode Whether this is Lisa updating itself.
406
- * @returns {string} Markdown body.
407
- */
408
- export function workItemBody(plan, selfMode) {
409
- const subject = selfMode
410
- ? "the copy of Lisa this repository builds with"
411
- : "Lisa";
412
- return [
413
- "Filed by: lisa-self-update (automated).",
414
- "",
415
- "## Context / Business Value",
416
- "",
417
- `Lisa ${plan.to} is published and this repository uses ${plan.from}. Keeping ${subject} current means the newest guardrails and fixes apply here.`,
418
- "",
419
- "## Acceptance Criteria",
420
- "",
421
- "```gherkin",
422
- "Scenario: the update lands",
423
- ` Given ${subject} is ${plan.from}`,
424
- " When the update pull request merges after every required check passes",
425
- ` Then ${subject} is ${plan.to}`,
426
- "```",
427
- "",
428
- "## Target Backend Environment",
429
- "",
430
- "None — no runtime behavior change: config-only",
431
- "",
432
- ].join("\n");
433
- }
434
-
435
- /**
436
- * Proof the full apply ran for the target version.
437
- * @param {Record<string, any> | null} receipt Parsed `.lisa/apply-receipt.json`.
438
- * @param {string} version Target version.
439
- * @returns {string | null} A failure reason, or null when proven.
440
- */
441
- export function receiptProblem(receipt, version) {
442
- if (!receipt) return "the apply wrote no .lisa/apply-receipt.json";
443
- if (receipt.lisa_version !== version) {
444
- return `the apply receipt names ${receipt.lisa_version}, not ${version}`;
445
- }
446
- if (receipt.apply_mode !== "full") {
447
- return `the apply ran in "${receipt.apply_mode}" mode, not "full", so agent emits and migrations were skipped`;
448
- }
449
- return null;
450
- }
451
-
452
- /**
453
- * Read and parse a JSON file, or null.
454
- * @param {string} file Absolute path.
455
- * @returns {any} Parsed value or null.
456
- */
457
- function readJson(file) {
458
- try {
459
- return JSON.parse(readFileSync(file, "utf8"));
460
- } catch {
461
- return null;
462
- }
463
- }
464
-
465
- /** Variables that can carry the GitHub credential; never inherited by default. */
466
- const TOKEN_VARS = ["LISA_UPDATE_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
467
-
468
- /**
469
- * An environment with every GitHub credential removed.
470
- * @param {NodeJS.ProcessEnv} env Source environment.
471
- * @returns {NodeJS.ProcessEnv} A copy without credentials.
472
- */
473
- export function withoutToken(env) {
474
- return Object.fromEntries(
475
- Object.entries(env).filter(([key]) => !TOKEN_VARS.includes(key))
476
- );
477
- }
478
-
479
- /**
480
- * The environment one command runs with: the credential reaches only the
481
- * commands that talk to GitHub. Installs, the bump and the apply run without
482
- * it, because they execute dependency lifecycle scripts, and a compromised
483
- * release must not be able to read a token that can push and merge. `gh` gets
484
- * `GH_TOKEN`; git fetch and push get an auth header through git's environment
485
- * config (never argv, which other processes can read); the work-item backlink
486
- * shells out to `gh`.
487
- * @param {readonly string[]} argv Command and arguments.
488
- * @param {NodeJS.ProcessEnv | undefined} env Environment the caller supplied.
489
- * @param {string | undefined} token GitHub credential, if any.
490
- * @returns {NodeJS.ProcessEnv} The environment to spawn with.
491
- */
492
- export function commandEnv(argv, env, token) {
493
- const base = withoutToken(env ?? process.env);
494
- if (!token) return base;
495
- const talksToGithub =
496
- argv[0] === "gh" || (argv[0] === "node" && argv.includes("backlink"));
497
- if (talksToGithub) return { ...base, GH_TOKEN: token };
498
- if (argv[0] === "git" && (argv[1] === "fetch" || argv[1] === "push")) {
499
- const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
500
- return {
501
- ...base,
502
- GIT_CONFIG_COUNT: "1",
503
- GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
504
- GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`,
505
- };
506
- }
507
- return base;
508
- }
509
-
510
- /**
511
- * Run a command, inheriting nothing but the environment given.
512
- * @param {string[]} argv Command and arguments.
513
- * @param {{cwd: string, env?: NodeJS.ProcessEnv}} options Spawn options.
514
- * @returns {Promise<string>} Trimmed stdout.
515
- */
516
- export function runCommand(argv, options) {
517
- return new Promise((resolve, reject) => {
518
- execFile(
519
- argv[0],
520
- argv.slice(1),
521
- {
522
- cwd: options.cwd,
523
- env: options.env ?? withoutToken(process.env),
524
- maxBuffer: 64 * 1024 * 1024,
525
- },
526
- (error, stdout, stderr) => {
527
- if (error) {
528
- reject(
529
- new Error(
530
- `\`${argv.join(" ")}\` failed: ${String(stderr || error.message).trim()}`
531
- )
532
- );
533
- return;
534
- }
535
- resolve(String(stdout).trim());
536
- }
537
- );
538
- });
539
- }
540
-
541
- /**
542
- * Gather the facts a plan needs, installing the base branch first.
543
- * @param {{root: string, run: typeof runCommand, log: (line: string) => void}} ctx Context.
544
- * @returns {Promise<object>} Facts.
545
- */
546
- async function gatherFacts(ctx) {
547
- const manifest = readJson(path.join(ctx.root, MANIFEST));
548
- const selfMode = isSelfManifest(manifest);
549
- const declares = selfMode || declaresLisa(manifest);
550
- if (!declares) return { declares, manifest, selfMode };
551
- const manager = choosePackageManager(
552
- manifest.engines,
553
- LOCKFILES.map(([file]) => file).filter(file =>
554
- existsSync(path.join(ctx.root, file))
555
- )
556
- );
557
- // probe-direction: neutral — an unanswered registry query yields no target,
558
- // and planUpdate turns that into a reported skip; nothing is gated on it.
559
- const view = spec =>
560
- ctx
561
- .run(["npm", "view", spec, "version"], { cwd: ctx.root })
562
- .catch(() => null);
563
- const npmLatest = await view(LISA_PACKAGE);
564
- const latest = selfMode
565
- ? chooseSelfTarget(
566
- manifest.version,
567
- (await view(`${LISA_PACKAGE}@${manifest.version}`)) ===
568
- manifest.version,
569
- npmLatest
570
- )
571
- : npmLatest;
572
- if (!ctx.dryRun) {
573
- ctx.log(`Installing the current lockfile with ${manager}…`);
574
- await ctx.run(packageCommands(manager, latest ?? "latest").install, {
575
- cwd: ctx.root,
576
- });
577
- }
578
- const installed =
579
- readJson(
580
- path.join(ctx.root, "node_modules", "@codyswann", "lisa", MANIFEST)
581
- )?.version ?? null;
582
- return { declares, manifest, manager, latest, installed, selfMode };
583
- }
584
-
585
- /**
586
- * Apply the loop guard to a self-update plan (see {@link onlySelfBumps}).
587
- *
588
- * A missing tag is an error, never a pass: an unreadable range cannot show the
589
- * newer release is only a self-bump, and treating it as one would stop Lisa
590
- * updating itself with nothing saying so.
591
- * @param {object} ctx Context.
592
- * @param {{from: string, to: string}} plan The planned update.
593
- * @returns {Promise<boolean>} True when the pin is effectively current.
594
- */
595
- async function pinIsEffectivelyCurrent(ctx, plan) {
596
- await ctx.run(["git", "fetch", "--tags", "--quiet", "origin"], {
597
- cwd: ctx.root,
598
- });
599
- const log = await ctx.run(
600
- ["git", "log", "--no-merges", "--format=%s", `v${plan.from}..v${plan.to}`],
601
- { cwd: ctx.root }
602
- );
603
- return onlySelfBumps(log.split("\n").filter(Boolean));
604
- }
605
-
606
- /**
607
- * Create a fresh work item for this update when the project tracks work in
608
- * GitHub Issues and none was supplied. Filed into the ready role, then claimed,
609
- * as every work item is. Jira and Linear projects use `LISA_UPDATE_WORK_ITEM`.
610
- * @param {object} ctx Context.
611
- * @param {{from: string, to: string}} plan The update.
612
- * @param {boolean} selfMode Whether this is Lisa updating itself.
613
- * @returns {Promise<string | undefined>} `owner/repo#N`, or undefined.
614
- */
615
- async function resolveWorkItem(ctx, plan, selfMode) {
616
- if (ctx.workItem) return ctx.workItem;
617
- const labels = workItemLabels(
618
- readJson(path.join(ctx.root, ".lisa.config.json"))
619
- );
620
- if (!labels) return undefined;
621
- const title = selfMode
622
- ? `Update Lisa's own self-dependency to ${plan.to}`
623
- : `Update Lisa to ${plan.to}`;
624
- const url = await ctx.run(
625
- [
626
- "gh",
627
- "issue",
628
- "create",
629
- "--repo",
630
- ctx.repo,
631
- "--title",
632
- title,
633
- "--body",
634
- workItemBody(plan, selfMode),
635
- "--label",
636
- "type:Task",
637
- "--label",
638
- labels.repo,
639
- "--label",
640
- labels.ready,
641
- ],
642
- { cwd: ctx.root }
643
- );
644
- const number = /\/issues\/(\d+)$/u.exec(url)?.[1];
645
- if (!number)
646
- throw new Error(
647
- `lisa-self-update: could not read the work item created at ${url}.`
648
- );
649
- await ctx.run(
650
- [
651
- "gh",
652
- "issue",
653
- "edit",
654
- number,
655
- "--repo",
656
- ctx.repo,
657
- "--remove-label",
658
- labels.ready,
659
- "--add-label",
660
- labels.claimed,
661
- ],
662
- { cwd: ctx.root }
663
- );
664
- ctx.log(`Filed work item ${ctx.repo}#${number} for this update.`);
665
- return `${ctx.repo}#${number}`;
666
- }
667
-
668
- /**
669
- * Post the managed `[lisa-pr-link]` backlink from the work item to the PR,
670
- * through the project's own work-item tool so its format never drifts.
671
- * @param {object} ctx Context.
672
- * @param {string} url Pull request URL.
673
- * @returns {Promise<void>} Resolves once the backlink is posted.
674
- */
675
- async function backlink(ctx, url) {
676
- const tool = [
677
- "scripts/lisa-work-item.mjs",
678
- "all/copy-overwrite/scripts/lisa-work-item.mjs",
679
- ].find(candidate => existsSync(path.join(ctx.root, candidate)));
680
- if (!tool) {
681
- ctx.log(
682
- "No lisa-work-item.mjs here, so the work item was not backlinked to the pull request."
683
- );
684
- return;
685
- }
686
- await ctx.run(
687
- ["node", tool, "backlink", "--ref", ctx.workItem, "--pr-url", url],
688
- { cwd: ctx.root }
689
- );
690
- }
691
-
692
- /**
693
- * The open pull request already carrying this update, if any. Checked before
694
- * anything is filed or built, so a re-run reuses it instead of duplicating it.
695
- * @param {object} ctx Context.
696
- * @param {string} branch Update branch.
697
- * @returns {Promise<string>} Its URL, or "".
698
- */
699
- function openUpdatePullRequest(ctx, branch) {
700
- return ctx.run(
701
- [
702
- "gh",
703
- "pr",
704
- "list",
705
- "--repo",
706
- ctx.repo,
707
- "--head",
708
- branch,
709
- "--state",
710
- "open",
711
- "--json",
712
- "url",
713
- "--jq",
714
- '.[0].url // ""',
715
- ],
716
- { cwd: ctx.root }
717
- );
718
- }
719
-
720
- /**
721
- * Self mode's single commit: bump the caret floor, prove the install, commit.
722
- * Lisa never applies its own templates to itself, so there is no apply step.
723
- * @param {object} ctx Context.
724
- * @param {{from: string, to: string}} plan The update.
725
- * @param {string} manager Package manager.
726
- * @returns {Promise<void>} Resolves when the commit exists.
727
- */
728
- async function buildSelfBump(ctx, plan, manager) {
729
- const { root, run, workItem } = ctx;
730
- await run(packageCommands(manager, plan.to, "^").bump, { cwd: root });
731
- const installed = readJson(
732
- path.join(root, "node_modules", "@codyswann", "lisa", MANIFEST)
733
- )?.version;
734
- if (installed !== plan.to) {
735
- throw new Error(
736
- `lisa-self-update: the bump installed ${installed ?? "nothing"}, not ${plan.to}.`
737
- );
738
- }
739
- await run(
740
- [
741
- "git",
742
- "add",
743
- MANIFEST,
744
- ...LOCKFILES.map(([file]) => file).filter(file =>
745
- existsSync(path.join(root, file))
746
- ),
747
- ],
748
- { cwd: root }
749
- );
750
- await run(
751
- [
752
- "git",
753
- "commit",
754
- "-m",
755
- commitMessage(
756
- selfBumpSubject(plan.to),
757
- `Moves the copy of Lisa this repository builds and checks with from ${plan.from} to ${plan.to}.`,
758
- workItem
759
- ),
760
- ],
761
- { cwd: root }
762
- );
763
- }
764
-
765
- /**
766
- * Bump, apply, prove, and commit on the update branch.
767
- * @param {object} ctx Context.
768
- * @param {{from: string, to: string, branch: string}} plan The update.
769
- * @param {string} manager Package manager.
770
- * @param {boolean} [selfMode] Whether this is Lisa updating itself.
771
- * @returns {Promise<void>} Resolves when the commits exist.
772
- */
773
- async function buildBranch(ctx, plan, manager, selfMode = false) {
774
- const { root, run, workItem } = ctx;
775
- await run(["git", "checkout", "-B", plan.branch], { cwd: root });
776
- if (selfMode) {
777
- await buildSelfBump(ctx, plan, manager);
778
- return;
779
- }
780
- await run(packageCommands(manager, plan.to).bump, { cwd: root });
781
- await run(["git", "add", "-A"], { cwd: root });
782
- await run(
783
- [
784
- "git",
785
- "commit",
786
- "-m",
787
- commitMessage(
788
- `chore(deps): update ${LISA_PACKAGE} to ${plan.to}`,
789
- `Bumps Lisa from ${plan.from} to ${plan.to}.`,
790
- workItem
791
- ),
792
- ],
793
- { cwd: root }
794
- );
795
- await run(
796
- ["node", "node_modules/@codyswann/lisa/dist/index.js", "--yes", "."],
797
- { cwd: root, env: { ...process.env, LISA_BOOTSTRAP: "1" } }
798
- );
799
- const problem = receiptProblem(
800
- readJson(path.join(root, ".lisa", "apply-receipt.json")),
801
- plan.to
802
- );
803
- if (problem) throw new Error(`lisa-self-update: ${problem}.`);
804
- await run(["git", "add", "-A"], { cwd: root });
805
- const staged = await run(["git", "diff", "--cached", "--name-only"], {
806
- cwd: root,
807
- });
808
- if (staged) {
809
- await run(
810
- [
811
- "git",
812
- "commit",
813
- "-m",
814
- commitMessage(
815
- `chore: apply Lisa ${plan.to} templates`,
816
- `Template, guardrail and migration changes from \`lisa apply\` at ${plan.to}.`,
817
- workItem
818
- ),
819
- ],
820
- { cwd: root }
821
- );
822
- }
823
- }
824
-
825
- /**
826
- * Push, open or reuse the PR, close superseded ones, and arm auto-merge.
827
- * @param {object} ctx Context.
828
- * @param {{from: string, to: string, branch: string}} plan The update.
829
- * @param {string} base Base branch.
830
- * @returns {Promise<string>} The pull request URL.
831
- */
832
- async function publish(ctx, plan, base, selfMode = false) {
833
- const { root, run, repo, workItem } = ctx;
834
- await run(["git", "push", "--force", "-u", "origin", plan.branch], {
835
- cwd: root,
836
- });
837
- const title = selfMode
838
- ? selfBumpSubject(plan.to)
839
- : `chore(deps): update Lisa to ${plan.to}`;
840
- const url = await run(
841
- [
842
- "gh",
843
- "pr",
844
- "create",
845
- "--repo",
846
- repo,
847
- "--base",
848
- base,
849
- "--head",
850
- plan.branch,
851
- "--title",
852
- title,
853
- "--body",
854
- pullRequestBody(plan, workItem, selfMode),
855
- ],
856
- { cwd: root }
857
- );
858
- if (workItem) await backlink(ctx, url);
859
- await closeSupersededPullRequests(ctx, plan, url);
860
- await armAutoMerge(ctx, url);
861
- return url;
862
- }
863
-
864
- /**
865
- * Close every other open update pull request as superseded by `url`.
866
- *
867
- * Runs for a newly opened AND a reused pull request: a run that stopped after
868
- * `gh pr create` would otherwise leave older update PRs open forever, because
869
- * every later run takes the reuse path.
870
- * @param {object} ctx Context.
871
- * @param {{to: string, branch: string}} plan The update.
872
- * @param {string} url The pull request carrying this update.
873
- * @returns {Promise<void>} Resolves once every superseded PR is closed.
874
- */
875
- async function closeSupersededPullRequests(ctx, plan, url) {
876
- const { root, run, repo, log } = ctx;
877
- const open = JSON.parse(
878
- await run(
879
- [
880
- "gh",
881
- "pr",
882
- "list",
883
- "--repo",
884
- repo,
885
- "--state",
886
- "open",
887
- "--search",
888
- `head:${BRANCH_PREFIX}`,
889
- "--json",
890
- "number,headRefName",
891
- ],
892
- { cwd: root }
893
- )
894
- );
895
- for (const number of supersededPullRequests(open, plan.branch)) {
896
- await run(
897
- [
898
- "gh",
899
- "pr",
900
- "close",
901
- String(number),
902
- "--repo",
903
- repo,
904
- "--delete-branch",
905
- "--comment",
906
- `Superseded by ${url} (Lisa ${plan.to}).`,
907
- ],
908
- { cwd: root }
909
- );
910
- log(`Closed superseded pull request #${number}.`);
911
- }
912
- }
913
-
914
- /**
915
- * Arm auto-merge with the first method the repository allows, or say why not.
916
- * @param {object} ctx Context.
917
- * @param {string} url Pull request URL.
918
- * @returns {Promise<void>} Resolves once armed or reported.
919
- */
920
- async function armAutoMerge(ctx, url) {
921
- const settings = JSON.parse(
922
- await ctx.run(["gh", "api", `repos/${ctx.repo}`], { cwd: ctx.root })
923
- );
924
- const flag = chooseMergeFlag(settings);
925
- if (flag) {
926
- await ctx.run(["gh", "pr", "merge", url, "--auto", flag], {
927
- cwd: ctx.root,
928
- });
929
- ctx.log(
930
- `Auto-merge armed (${flag}); the pull request merges when its checks pass.`
931
- );
932
- } else {
933
- ctx.log(
934
- "Auto-merge is not enabled for this repository, so the pull request stays open for someone to merge once its checks pass."
935
- );
936
- }
937
- }
938
-
939
- /**
940
- * Start from the tip of the base branch, whatever the workflow checked out.
941
- * @param {object} ctx Context.
942
- * @param {string} base Base branch.
943
- * @returns {Promise<void>} Resolves when HEAD is the base tip.
944
- */
945
- async function checkoutBase(ctx, base) {
946
- await ctx.run(["git", "fetch", "origin", base], { cwd: ctx.root });
947
- await ctx.run(["git", "checkout", "-B", base, `origin/${base}`], {
948
- cwd: ctx.root,
949
- });
950
- }
951
-
952
- /**
953
- * Run the whole flow.
954
- * @param {{root: string, repo: string, dryRun: boolean, workItem?: string, token?: string, run?: typeof runCommand, log?: (line: string) => void, defaultBranch?: string}} options Options.
955
- * @returns {Promise<number>} Exit code.
956
- */
957
- export async function main(options) {
958
- const ctx = {
959
- run: (argv, opts) =>
960
- runCommand(argv, {
961
- ...opts,
962
- env: commandEnv(argv, opts?.env, options.token),
963
- }),
964
- log: line => console.log(line),
965
- ...options,
966
- };
967
- const base = chooseBase(
968
- readJson(path.join(ctx.root, ".lisa.config.json")),
969
- ctx.defaultBranch ?? "main"
970
- );
971
- if (!ctx.dryRun) await checkoutBase(ctx, base);
972
- const facts = await gatherFacts(ctx);
973
- const plan = planUpdate(facts);
974
- if (plan.action === "skip") {
975
- ctx.log(`lisa-self-update: nothing to do — ${plan.reason}`);
976
- return 0;
977
- }
978
- if (facts.selfMode && (await pinIsEffectivelyCurrent(ctx, plan))) {
979
- ctx.log(
980
- `lisa-self-update: nothing to do — the self-dependency is effectively current: ${plan.to} differs from ${plan.from} only by release and self-bump commits.`
981
- );
982
- return 0;
983
- }
984
- ctx.log(
985
- `lisa-self-update: Lisa ${plan.from} → ${plan.to}; branch ${plan.branch} into ${base}.`
986
- );
987
- if (ctx.dryRun) {
988
- const steps = facts.selfMode
989
- ? "bump the self-dependency (no template apply)"
990
- : "run the full apply";
991
- ctx.log(
992
- `Dry run: would bump with ${facts.manager}, ${steps}, commit, push ${plan.branch}, open a pull request into ${base}, close older ${BRANCH_PREFIX}* pull requests and arm auto-merge.`
993
- );
994
- return 0;
995
- }
996
- const existing = await openUpdatePullRequest(ctx, plan.branch);
997
- if (existing) {
998
- ctx.log(`lisa-self-update: ${existing} already carries this update.`);
999
- await closeSupersededPullRequests(ctx, plan, existing);
1000
- await armAutoMerge(ctx, existing);
1001
- return 0;
1002
- }
1003
- ctx.workItem = await resolveWorkItem(ctx, plan, facts.selfMode);
1004
- await buildBranch(ctx, plan, facts.manager, facts.selfMode);
1005
- const url = await publish(ctx, plan, base, facts.selfMode);
1006
- ctx.log(`lisa-self-update: update pull request ${url}`);
1007
- return 0;
1008
- }
1009
-
1010
- /** CLI entry: reads flags and the workflow's environment. */
1011
- export async function runCli() {
1012
- try {
1013
- const flags = parseFlags(process.argv.slice(2));
1014
- if (flags.help) {
1015
- console.log(
1016
- "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."
1017
- );
1018
- return;
1019
- }
1020
- const repo = process.env.GITHUB_REPOSITORY ?? "";
1021
- if (!flags.dryRun && !/^[\w.-]+\/[\w.-]+$/u.test(repo)) {
1022
- throw new Error(
1023
- "lisa-self-update: set GITHUB_REPOSITORY to owner/repository."
1024
- );
1025
- }
1026
- process.exitCode = await main({
1027
- root: process.cwd(),
1028
- repo,
1029
- dryRun: flags.dryRun,
1030
- workItem: process.env.LISA_UPDATE_WORK_ITEM || undefined,
1031
- // The workflow passes LISA_UPDATE_TOKEN; a hand run may rely on GH_TOKEN,
1032
- // or on neither, in which case gh and git use their own stored login.
1033
- token: process.env.LISA_UPDATE_TOKEN || process.env.GH_TOKEN || undefined,
1034
- defaultBranch: process.env.LISA_DEFAULT_BRANCH || undefined,
1035
- });
1036
- } catch (error) {
1037
- console.error(error instanceof Error ? error.message : String(error));
1038
- process.exitCode = 1;
1039
- }
1040
- }
1041
-
1042
- if (
1043
- process.argv[1] &&
1044
- import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
1045
- ) {
1046
- runCli();
1047
- }