@moxxy/plugin-self-update 0.26.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/dist/classify.d.ts +33 -0
  3. package/dist/classify.d.ts.map +1 -0
  4. package/dist/classify.js +177 -0
  5. package/dist/classify.js.map +1 -0
  6. package/dist/core-tools/apply.d.ts +4 -0
  7. package/dist/core-tools/apply.d.ts.map +1 -0
  8. package/dist/core-tools/apply.js +43 -0
  9. package/dist/core-tools/apply.js.map +1 -0
  10. package/dist/core-tools/begin.d.ts +4 -0
  11. package/dist/core-tools/begin.d.ts.map +1 -0
  12. package/dist/core-tools/begin.js +76 -0
  13. package/dist/core-tools/begin.js.map +1 -0
  14. package/dist/core-tools/edit.d.ts +4 -0
  15. package/dist/core-tools/edit.d.ts.map +1 -0
  16. package/dist/core-tools/edit.js +33 -0
  17. package/dist/core-tools/edit.js.map +1 -0
  18. package/dist/core-tools/index.d.ts +11 -0
  19. package/dist/core-tools/index.d.ts.map +1 -0
  20. package/dist/core-tools/index.js +28 -0
  21. package/dist/core-tools/index.js.map +1 -0
  22. package/dist/core-tools/preflight.d.ts +4 -0
  23. package/dist/core-tools/preflight.d.ts.map +1 -0
  24. package/dist/core-tools/preflight.js +14 -0
  25. package/dist/core-tools/preflight.js.map +1 -0
  26. package/dist/core-tools/rollback.d.ts +4 -0
  27. package/dist/core-tools/rollback.d.ts.map +1 -0
  28. package/dist/core-tools/rollback.js +30 -0
  29. package/dist/core-tools/rollback.js.map +1 -0
  30. package/dist/core-tools/shared.d.ts +16 -0
  31. package/dist/core-tools/shared.d.ts.map +1 -0
  32. package/dist/core-tools/shared.js +12 -0
  33. package/dist/core-tools/shared.js.map +1 -0
  34. package/dist/core-tools/status.d.ts +4 -0
  35. package/dist/core-tools/status.d.ts.map +1 -0
  36. package/dist/core-tools/status.js +25 -0
  37. package/dist/core-tools/status.js.map +1 -0
  38. package/dist/core-tools/verify.d.ts +4 -0
  39. package/dist/core-tools/verify.d.ts.map +1 -0
  40. package/dist/core-tools/verify.js +39 -0
  41. package/dist/core-tools/verify.js.map +1 -0
  42. package/dist/core-tools/write.d.ts +4 -0
  43. package/dist/core-tools/write.d.ts.map +1 -0
  44. package/dist/core-tools/write.js +28 -0
  45. package/dist/core-tools/write.js.map +1 -0
  46. package/dist/core-update.d.ts +173 -0
  47. package/dist/core-update.d.ts.map +1 -0
  48. package/dist/core-update.js +626 -0
  49. package/dist/core-update.js.map +1 -0
  50. package/dist/deps.d.ts +49 -0
  51. package/dist/deps.d.ts.map +1 -0
  52. package/dist/deps.js +30 -0
  53. package/dist/deps.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +349 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/transaction.d.ts +80 -0
  59. package/dist/transaction.d.ts.map +1 -0
  60. package/dist/transaction.js +188 -0
  61. package/dist/transaction.js.map +1 -0
  62. package/dist/verify.d.ts +23 -0
  63. package/dist/verify.d.ts.map +1 -0
  64. package/dist/verify.js +164 -0
  65. package/dist/verify.js.map +1 -0
  66. package/package.json +61 -0
  67. package/src/classify.test.ts +176 -0
  68. package/src/classify.ts +221 -0
  69. package/src/core-tools/apply.ts +44 -0
  70. package/src/core-tools/begin.ts +94 -0
  71. package/src/core-tools/edit.ts +33 -0
  72. package/src/core-tools/index.ts +33 -0
  73. package/src/core-tools/preflight.ts +15 -0
  74. package/src/core-tools/rollback.ts +30 -0
  75. package/src/core-tools/shared.test.ts +16 -0
  76. package/src/core-tools/shared.ts +24 -0
  77. package/src/core-tools/status.ts +26 -0
  78. package/src/core-tools/verify.ts +39 -0
  79. package/src/core-tools/write.ts +30 -0
  80. package/src/core-update.test.ts +542 -0
  81. package/src/core-update.ts +744 -0
  82. package/src/deps.ts +84 -0
  83. package/src/discovery.test.ts +40 -0
  84. package/src/index.test.ts +299 -0
  85. package/src/index.ts +441 -0
  86. package/src/transaction.test.ts +143 -0
  87. package/src/transaction.ts +254 -0
  88. package/src/verify.test.ts +82 -0
  89. package/src/verify.ts +200 -0
@@ -0,0 +1,744 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, lstatSync, promises as fs, readFileSync, realpathSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { writeFileAtomic } from '@moxxy/sdk/server';
6
+ import { newTxnId } from './transaction.js';
7
+
8
+ /**
9
+ * Tier-2: patch moxxy's OWN compiled framework packages in a global npm
10
+ * install. Unlike Tier 1 (plugins/skills, hot-reloaded), a core change cannot
11
+ * be hot-swapped — @moxxy/core is imported once into the live process — so the
12
+ * flow is: provision a source clone at the EXACT published commit, edit + build
13
+ * + test it there, then atomically overlay the built `dist/` into the live
14
+ * install (snapshotting the previous dist) and require a restart. A boot-time
15
+ * finalize hook commits or rolls back.
16
+ *
17
+ * Everything here is filesystem + child-process only; no moxxy core import.
18
+ */
19
+
20
+ const SCOPE = '@moxxy/';
21
+ const BUILD_TIMEOUT_MS = 12 * 60_000;
22
+ /**
23
+ * Cap on the child-process output we retain in memory. A long, model-triggered
24
+ * `pnpm build/typecheck/test` (turbo fan-out, a looping test, a tool dumping a
25
+ * large file) can emit hundreds of MB; holding it all in the LIVE process would
26
+ * OOM-kill the host mid-update. Callers only ever read the trailing ~1.5KB, so a
27
+ * sliding tail is lossless for them.
28
+ */
29
+ const MAX_RUN_OUTPUT_BYTES = 512 * 1024;
30
+ /** A full git commit sha — the source-pin must compare exact 40-hex shas, never a prefix. */
31
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
32
+
33
+ export function shortName(pkg: string): string {
34
+ return pkg.startsWith(SCOPE) ? pkg.slice(SCOPE.length) : pkg;
35
+ }
36
+
37
+ export interface RunResult {
38
+ readonly code: number;
39
+ readonly output: string;
40
+ }
41
+
42
+ export function run(
43
+ cmd: string,
44
+ args: ReadonlyArray<string>,
45
+ cwd: string,
46
+ timeoutMs = BUILD_TIMEOUT_MS,
47
+ ): Promise<RunResult> {
48
+ return new Promise((resolve) => {
49
+ // `detached` puts the child in its own process group so a timeout can kill
50
+ // the WHOLE subtree (pnpm/turbo/vitest fan out to many grandchildren that
51
+ // SIGKILL on the immediate child would orphan).
52
+ const child = spawn(cmd, [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
53
+ const tail = new TailBuffer(MAX_RUN_OUTPUT_BYTES);
54
+ const onData = (d: Buffer): void => tail.push(d);
55
+ child.stdout.on('data', onData);
56
+ child.stderr.on('data', onData);
57
+ const timer = setTimeout(() => killTree(child), timeoutMs);
58
+ let settled = false;
59
+ const finish = (r: RunResult): void => {
60
+ if (settled) return;
61
+ settled = true;
62
+ clearTimeout(timer);
63
+ child.stdout.off('data', onData);
64
+ child.stderr.off('data', onData);
65
+ resolve(r);
66
+ };
67
+ child.on('error', (err) => finish({ code: -1, output: `${cmd} failed to start: ${err.message}` }));
68
+ child.on('close', (code) => finish({ code: code ?? -1, output: tail.toString() }));
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Bounded sliding-tail accumulator: keeps at most `cap` bytes of the most recent
74
+ * output. Once the cap is exceeded it trims from the front so memory stays
75
+ * O(cap) regardless of how much the child emits.
76
+ */
77
+ class TailBuffer {
78
+ private buf = '';
79
+ constructor(private readonly cap: number) {}
80
+ push(d: Buffer): void {
81
+ this.buf += d.toString('utf8');
82
+ if (this.buf.length > this.cap) this.buf = this.buf.slice(this.buf.length - this.cap);
83
+ }
84
+ toString(): string {
85
+ return this.buf;
86
+ }
87
+ }
88
+
89
+ /** SIGKILL a detached child's whole process group, tolerating an already-dead group. */
90
+ function killTree(child: { pid?: number; kill: (s: NodeJS.Signals) => boolean }): void {
91
+ try {
92
+ if (child.pid != null) process.kill(-child.pid, 'SIGKILL');
93
+ else child.kill('SIGKILL');
94
+ } catch {
95
+ // ESRCH: the group already exited. Fall back to the direct kill just in case.
96
+ try {
97
+ child.kill('SIGKILL');
98
+ } catch {
99
+ /* already gone */
100
+ }
101
+ }
102
+ }
103
+
104
+ function trunc(s: string, n = 1500): string {
105
+ return s.length <= n ? s : `…${s.slice(s.length - n)}`;
106
+ }
107
+
108
+ // ── installed-version detection ───────────────────────────────────────────────
109
+ export interface CoreInstallInfo {
110
+ readonly version: string;
111
+ readonly gitHead?: string;
112
+ readonly repoUrl?: string;
113
+ /** The `@moxxy` scope dir under node_modules (where every @moxxy/* package sits). */
114
+ readonly scopeDir: string;
115
+ }
116
+
117
+ /**
118
+ * Memoize the resolution per `fromUrl`: the live install root cannot move
119
+ * underneath a running process, yet detectCoreInstall is called from many tool
120
+ * handlers (preflight/begin/verify/apply/rollback) and does synchronous blocking
121
+ * I/O (readFileSync + an existsSync walk). Caching keeps that off the hot path.
122
+ * `null` results are cached too so repeated misses stay cheap.
123
+ */
124
+ const coreInstallCache = new Map<string, CoreInstallInfo | null>();
125
+
126
+ /**
127
+ * Resolve the live `@moxxy/core` install to learn its version, the commit it
128
+ * was published from (npm writes `gitHead` on publish), and the on-disk root
129
+ * to overlay into.
130
+ */
131
+ export function detectCoreInstall(fromUrl: string): CoreInstallInfo | null {
132
+ if (coreInstallCache.has(fromUrl)) return coreInstallCache.get(fromUrl) ?? null;
133
+ const info = detectCoreInstallUncached(fromUrl);
134
+ coreInstallCache.set(fromUrl, info);
135
+ return info;
136
+ }
137
+
138
+ function detectCoreInstallUncached(fromUrl: string): CoreInstallInfo | null {
139
+ try {
140
+ // `require.resolve('@moxxy/core')` is unreliable — core's `exports` map has
141
+ // no "." main. Instead locate the `@moxxy` scope dir by walking up from the
142
+ // caller and reading core/package.json directly.
143
+ const scopeDir = findCoreScopeDir(fileURLToPath(fromUrl));
144
+ if (!scopeDir) return null;
145
+ const pkg = JSON.parse(readFileSync(path.join(scopeDir, 'core', 'package.json'), 'utf8')) as {
146
+ version?: string;
147
+ gitHead?: string;
148
+ repository?: { url?: string } | string;
149
+ };
150
+ const repoUrl = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
151
+ return {
152
+ version: pkg.version ?? '0.0.0',
153
+ ...(pkg.gitHead ? { gitHead: pkg.gitHead } : {}),
154
+ ...(repoUrl ? { repoUrl: normalizeGitUrl(repoUrl) } : {}),
155
+ scopeDir,
156
+ };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Find the `@moxxy` scope dir containing `core/package.json`, by walking up
164
+ * ancestors of `start`. Handles both the global install (the file lives under
165
+ * `node_modules/@moxxy/<pkg>`, so a parent IS the scope dir) and a workspace
166
+ * (an ancestor has `node_modules/@moxxy`).
167
+ */
168
+ function findCoreScopeDir(start: string): string | null {
169
+ let dir = start;
170
+ for (let i = 0; i < 16; i++) {
171
+ if (path.basename(dir) === '@moxxy' && existsSync(path.join(dir, 'core', 'package.json'))) {
172
+ return dir;
173
+ }
174
+ const nm = path.join(dir, 'node_modules', '@moxxy');
175
+ if (existsSync(path.join(nm, 'core', 'package.json'))) return nm;
176
+ const parent = path.dirname(dir);
177
+ if (parent === dir) break;
178
+ dir = parent;
179
+ }
180
+ return null;
181
+ }
182
+
183
+ function normalizeGitUrl(url: string): string {
184
+ return url.replace(/^git\+/, '').replace(/^git:\/\//, 'https://');
185
+ }
186
+
187
+ // ── preflight ─────────────────────────────────────────────────────────────────
188
+ export interface PreflightReport {
189
+ readonly ok: boolean;
190
+ readonly checks: ReadonlyArray<{ id: string; ok: boolean; detail: string }>;
191
+ }
192
+
193
+ export async function corePreflight(install: CoreInstallInfo | null): Promise<PreflightReport> {
194
+ const checks: { id: string; ok: boolean; detail: string }[] = [];
195
+ const git = await run('git', ['--version'], process.cwd(), 10_000);
196
+ checks.push({ id: 'git', ok: git.code === 0, detail: git.code === 0 ? git.output.trim() : 'git not found' });
197
+ const pnpm = await run('pnpm', ['--version'], process.cwd(), 10_000);
198
+ checks.push({ id: 'pnpm', ok: pnpm.code === 0, detail: pnpm.code === 0 ? `pnpm ${pnpm.output.trim()}` : 'pnpm not found' });
199
+ checks.push({ id: 'install', ok: Boolean(install), detail: install ? `@moxxy/core@${install.version}` : 'could not resolve @moxxy/core' });
200
+ checks.push({
201
+ id: 'provenance',
202
+ ok: Boolean(install?.gitHead),
203
+ detail: install?.gitHead ? `gitHead ${install.gitHead.slice(0, 10)}` : 'no gitHead in published metadata — cannot pin source',
204
+ });
205
+ checks.push({
206
+ id: 'repo',
207
+ ok: Boolean(install?.repoUrl),
208
+ detail: install?.repoUrl ?? 'no repository url in @moxxy/core package.json',
209
+ });
210
+ return { ok: checks.every((c) => c.ok), checks };
211
+ }
212
+
213
+ // ── core transaction journal ───────────────────────────────────────────────────
214
+ export type CoreState =
215
+ | 'open'
216
+ | 'provisioned'
217
+ | 'verified'
218
+ | 'staged_restart'
219
+ | 'committed'
220
+ | 'rolled_back'
221
+ | 'escalated';
222
+
223
+ export interface CoreJournal {
224
+ readonly txnId: string;
225
+ readonly createdAt: string;
226
+ updatedAt: string;
227
+ packages: string[];
228
+ readonly version: string;
229
+ readonly gitHead?: string;
230
+ readonly repoDir: string;
231
+ state: CoreState;
232
+ attempts: { at: string; stage: string; ok: boolean; message: string }[];
233
+ }
234
+
235
+ export function coreTxnRoot(moxxyDir: string): string {
236
+ return path.join(moxxyDir, 'self-update', 'core-txns');
237
+ }
238
+ export function coreTxnDir(moxxyDir: string, txnId: string): string {
239
+ return path.join(coreTxnRoot(moxxyDir), txnId);
240
+ }
241
+ export function repoDir(moxxyDir: string): string {
242
+ return path.join(moxxyDir, 'self-update', 'repo');
243
+ }
244
+
245
+ export async function writeCoreJournal(moxxyDir: string, j: CoreJournal): Promise<void> {
246
+ j.updatedAt = new Date().toISOString();
247
+ const file = path.join(coreTxnDir(moxxyDir, j.txnId), 'journal.json');
248
+ await writeFileAtomic(file, JSON.stringify(j, null, 2) + '\n');
249
+ }
250
+
251
+ export async function readCoreJournal(moxxyDir: string, txnId: string): Promise<CoreJournal> {
252
+ return JSON.parse(await fs.readFile(path.join(coreTxnDir(moxxyDir, txnId), 'journal.json'), 'utf8')) as CoreJournal;
253
+ }
254
+
255
+ export async function listCoreTxns(moxxyDir: string): Promise<ReadonlyArray<CoreJournal>> {
256
+ let ids: string[];
257
+ try {
258
+ ids = (await fs.readdir(coreTxnRoot(moxxyDir), { withFileTypes: true }))
259
+ .filter((e) => e.isDirectory())
260
+ .map((e) => e.name);
261
+ } catch {
262
+ return [];
263
+ }
264
+ const out: CoreJournal[] = [];
265
+ for (const id of ids) {
266
+ try {
267
+ out.push(await readCoreJournal(moxxyDir, id));
268
+ } catch {
269
+ /* skip corrupt */
270
+ }
271
+ }
272
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
273
+ }
274
+
275
+ /**
276
+ * Count core-txn dirs whose `journal.json` is present but unparseable. A corrupt
277
+ * (or hand-edited / half-written) journal would silently VANISH from
278
+ * listCoreTxns — making the begin serialization guard fail OPEN and let a second
279
+ * concurrent core txn clobber the shared repoDir. The guard uses this to instead
280
+ * fail CLOSED. A missing journal.json (a dir mid-create) is NOT corruption and
281
+ * is ignored.
282
+ */
283
+ export async function countCorruptCoreTxns(moxxyDir: string): Promise<number> {
284
+ let ids: string[];
285
+ try {
286
+ ids = (await fs.readdir(coreTxnRoot(moxxyDir), { withFileTypes: true }))
287
+ .filter((e) => e.isDirectory())
288
+ .map((e) => e.name);
289
+ } catch {
290
+ return 0;
291
+ }
292
+ let corrupt = 0;
293
+ for (const id of ids) {
294
+ const file = path.join(coreTxnDir(moxxyDir, id), 'journal.json');
295
+ let raw: string;
296
+ try {
297
+ raw = await fs.readFile(file, 'utf8');
298
+ } catch {
299
+ continue; // no journal yet — not corruption.
300
+ }
301
+ try {
302
+ JSON.parse(raw);
303
+ } catch {
304
+ corrupt++;
305
+ }
306
+ }
307
+ return corrupt;
308
+ }
309
+
310
+ // ── provisioning ────────────────────────────────────────────────────────────────
311
+ export interface ProvisionResult {
312
+ readonly ok: boolean;
313
+ readonly message: string;
314
+ readonly repoDir: string;
315
+ }
316
+
317
+ /**
318
+ * Ensure a buildable clone exists at exactly `install.gitHead`. Clones on first
319
+ * use, otherwise fetches; always checks out the pinned commit and verifies HEAD
320
+ * matches — this is the integrity guarantee that the source equals the binary.
321
+ */
322
+ export async function provisionWorkspace(opts: {
323
+ readonly moxxyDir: string;
324
+ readonly install: CoreInstallInfo;
325
+ readonly repoUrlOverride?: string;
326
+ }): Promise<ProvisionResult> {
327
+ const dir = repoDir(opts.moxxyDir);
328
+ const url = opts.repoUrlOverride ?? opts.install.repoUrl;
329
+ const ref = opts.install.gitHead;
330
+ if (!url) return { ok: false, message: 'no repository url to clone from', repoDir: dir };
331
+ if (!ref) return { ok: false, message: 'no gitHead — cannot pin source to the installed version', repoDir: dir };
332
+
333
+ const hasClone = await fs
334
+ .access(path.join(dir, '.git'))
335
+ .then(() => true)
336
+ .catch(() => false);
337
+ if (!hasClone) {
338
+ await fs.mkdir(path.dirname(dir), { recursive: true });
339
+ const clone = await run('git', ['clone', url, dir], path.dirname(dir));
340
+ if (clone.code !== 0) return { ok: false, message: `git clone failed: ${trunc(clone.output, 400)}`, repoDir: dir };
341
+ } else {
342
+ await run('git', ['fetch', '--all', '--tags'], dir);
343
+ }
344
+
345
+ const checkout = await run('git', ['checkout', '--force', ref], dir);
346
+ if (checkout.code !== 0) {
347
+ const fetched = await run('git', ['fetch', 'origin', ref], dir);
348
+ if (fetched.code === 0) await run('git', ['checkout', '--force', ref], dir);
349
+ }
350
+ // Resolve the pinned ref to a full sha so a short/abbreviated gitHead can't
351
+ // prefix-match a DIFFERENT commit and defeat the source-equals-binary pin.
352
+ const refFull = (await run('git', ['rev-parse', ref], dir)).output.trim();
353
+ const head = (await run('git', ['rev-parse', 'HEAD'], dir)).output.trim();
354
+ if (!FULL_SHA_RE.test(head) || !FULL_SHA_RE.test(refFull) || head !== refFull) {
355
+ return {
356
+ ok: false,
357
+ message: `source mismatch: clone HEAD ${head.slice(0, 10)} ≠ installed gitHead ${ref.slice(0, 10)}`,
358
+ repoDir: dir,
359
+ };
360
+ }
361
+ await run('git', ['clean', '-fdx', 'packages'], dir).catch(() => undefined);
362
+ await run('git', ['checkout', '--', '.'], dir).catch(() => undefined);
363
+
364
+ const install = await run('pnpm', ['install', '--frozen-lockfile'], dir);
365
+ if (install.code !== 0) {
366
+ const loose = await run('pnpm', ['install'], dir);
367
+ if (loose.code !== 0) return { ok: false, message: `pnpm install failed: ${trunc(loose.output, 400)}`, repoDir: dir };
368
+ }
369
+ return { ok: true, message: 'workspace provisioned', repoDir: dir };
370
+ }
371
+
372
+ // ── repo package layout ──────────────────────────────────────────────────────────
373
+ /** Find the clone's package directory whose package.json `name` === pkgName. */
374
+ export async function findRepoPkgDir(repo: string, pkgName: string): Promise<string | null> {
375
+ const pkgsRoot = path.join(repo, 'packages');
376
+ let entries: string[];
377
+ try {
378
+ entries = (await fs.readdir(pkgsRoot, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
379
+ } catch {
380
+ return null;
381
+ }
382
+ for (const name of entries) {
383
+ const dir = path.join(pkgsRoot, name);
384
+ try {
385
+ const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) as { name?: string };
386
+ if (pkg.name === pkgName) return dir;
387
+ } catch {
388
+ /* skip */
389
+ }
390
+ }
391
+ return null;
392
+ }
393
+
394
+ // ── verify (build / typecheck / test) ─────────────────────────────────────────────
395
+ export interface CoreVerifyResult {
396
+ readonly ok: boolean;
397
+ readonly stages: ReadonlyArray<{ stage: string; ok: boolean; message: string }>;
398
+ readonly newDeps: ReadonlyArray<string>;
399
+ }
400
+
401
+ export async function verifyCorePackages(
402
+ repo: string,
403
+ install: CoreInstallInfo,
404
+ pkgNames: ReadonlyArray<string>,
405
+ ): Promise<CoreVerifyResult> {
406
+ const stages: { stage: string; ok: boolean; message: string }[] = [];
407
+ // `...` expands the turbo filter to include dependents, so a change that
408
+ // breaks a downstream package is caught.
409
+ const filters = pkgNames.flatMap((p) => ['--filter', `${p}...`]);
410
+
411
+ for (const task of ['build', 'typecheck', 'test'] as const) {
412
+ const r = await run('pnpm', [...filters, task], repo);
413
+ stages.push({ stage: task, ok: r.code === 0, message: r.code === 0 ? `${task} ok` : trunc(r.output) });
414
+ if (r.code !== 0) return { ok: false, stages, newDeps: [] };
415
+ }
416
+
417
+ const newDeps = await detectNewDeps(repo, install, pkgNames);
418
+ if (newDeps.length > 0) {
419
+ stages.push({
420
+ stage: 'deps',
421
+ ok: false,
422
+ message: `patch adds runtime dependencies not in the live install: ${newDeps.join(', ')} — a dist overlay can't install these. Escalate.`,
423
+ });
424
+ return { ok: false, stages, newDeps };
425
+ }
426
+ return { ok: true, stages, newDeps: [] };
427
+ }
428
+
429
+ /** Runtime deps the patched clone declares that aren't present in the live install. */
430
+ export async function detectNewDeps(
431
+ repo: string,
432
+ install: CoreInstallInfo,
433
+ pkgNames: ReadonlyArray<string>,
434
+ ): Promise<ReadonlyArray<string>> {
435
+ const missing = new Set<string>();
436
+ for (const pkgName of pkgNames) {
437
+ const dir = await findRepoPkgDir(repo, pkgName);
438
+ if (!dir) continue;
439
+ let deps: Record<string, string> = {};
440
+ try {
441
+ const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) as {
442
+ dependencies?: Record<string, string>;
443
+ };
444
+ deps = pkg.dependencies ?? {};
445
+ } catch {
446
+ continue;
447
+ }
448
+ // The live node_modules root is the parent of the `@moxxy` scope dir.
449
+ const nmRoot = path.dirname(install.scopeDir);
450
+ for (const [dep, spec] of Object.entries(deps)) {
451
+ if (spec.startsWith('workspace:')) continue; // resolved internally at build
452
+ const present = await exists(path.join(nmRoot, dep));
453
+ if (!present) missing.add(dep);
454
+ }
455
+ }
456
+ return [...missing];
457
+ }
458
+
459
+ // ── overlay / restore ────────────────────────────────────────────────────────────
460
+ /**
461
+ * Swap each package's freshly-built `dist/` into the live install, keeping the
462
+ * previous dist as a snapshot for rollback. Renames are atomic on the same fs.
463
+ *
464
+ * Crash-atomicity across packages: a multi-package overlay must not leave the
465
+ * install with a NEW core dist but an OLD cli dist (a version-skewed, possibly
466
+ * non-bootable combo) if the process is killed mid-loop. So we work in phases:
467
+ * 1. validate every package + snapshot every old dist,
468
+ * 2. stage every new dist (`dist.new`) — nothing live is touched yet,
469
+ * 3. write a `pending.json` intent recording the full set + staged paths,
470
+ * 4. swap all dists in a tight loop, then write `applied.json`.
471
+ * If the process dies between 3 and the final `applied.json`, a leftover
472
+ * `pending.json` lets {@link reconcileOverlay} (run on boot) complete or undo
473
+ * the partial set instead of stranding a mixed core.
474
+ */
475
+ export async function overlayPackages(opts: {
476
+ readonly repo: string;
477
+ readonly install: CoreInstallInfo;
478
+ readonly pkgNames: ReadonlyArray<string>;
479
+ readonly snapshotDir: string;
480
+ }): Promise<{ ok: boolean; message: string; applied: string[] }> {
481
+ await fs.mkdir(opts.snapshotDir, { recursive: true });
482
+
483
+ // Phase 1 + 2: validate, snapshot old, stage new. No live dist is swapped yet,
484
+ // so a failure here leaves the install untouched (only orphaned dist.new dirs).
485
+ const planned: { pkgName: string; destDist: string; staged: string }[] = [];
486
+ for (const pkgName of opts.pkgNames) {
487
+ const repoPkg = await findRepoPkgDir(opts.repo, pkgName);
488
+ if (!repoPkg) {
489
+ await cleanupStaged(planned);
490
+ return { ok: false, message: `package not found in clone: ${pkgName}`, applied: [] };
491
+ }
492
+ const srcDist = path.join(repoPkg, 'dist');
493
+ if (!(await exists(srcDist))) {
494
+ await cleanupStaged(planned);
495
+ return { ok: false, message: `no built dist for ${pkgName}`, applied: [] };
496
+ }
497
+ const destPkg = path.join(opts.install.scopeDir, shortName(pkgName));
498
+ const destDist = path.join(destPkg, 'dist');
499
+ if (await exists(destDist)) {
500
+ await fs.cp(destDist, path.join(opts.snapshotDir, shortName(pkgName)), { recursive: true });
501
+ }
502
+ const staged = path.join(destPkg, 'dist.new');
503
+ await fs.rm(staged, { recursive: true, force: true });
504
+ await fs.cp(srcDist, staged, { recursive: true });
505
+ planned.push({ pkgName, destDist, staged });
506
+ }
507
+
508
+ // Phase 3: record the intent so an interrupted phase-4 swap is reconcilable.
509
+ await writeFileAtomic(
510
+ path.join(opts.snapshotDir, 'pending.json'),
511
+ JSON.stringify(
512
+ { packages: planned.map((p) => p.pkgName), at: new Date().toISOString() },
513
+ null,
514
+ 2,
515
+ ),
516
+ );
517
+
518
+ // Phase 4: swap each staged dist into place. Atomic per package; the pending
519
+ // marker covers the (tiny) window where the set is only partially swapped.
520
+ const applied: string[] = [];
521
+ for (const { pkgName, destDist, staged } of planned) {
522
+ const bak = `${destDist}.bak`;
523
+ await fs.rm(bak, { recursive: true, force: true });
524
+ if (await exists(destDist)) await fs.rename(destDist, bak);
525
+ await fs.rename(staged, destDist);
526
+ await fs.rm(bak, { recursive: true, force: true });
527
+ applied.push(pkgName);
528
+ }
529
+ await writeFileAtomic(
530
+ path.join(opts.snapshotDir, 'applied.json'),
531
+ JSON.stringify({ packages: applied, at: new Date().toISOString() }, null, 2),
532
+ );
533
+ // The swap completed; the pending intent is now satisfied.
534
+ await fs.rm(path.join(opts.snapshotDir, 'pending.json'), { force: true }).catch(() => undefined);
535
+ return { ok: true, message: `overlaid ${applied.length} package(s)`, applied };
536
+ }
537
+
538
+ /** Remove any staged `dist.new` dirs from an aborted overlay plan. */
539
+ async function cleanupStaged(
540
+ planned: ReadonlyArray<{ staged: string }>,
541
+ ): Promise<void> {
542
+ for (const { staged } of planned) {
543
+ await fs.rm(staged, { recursive: true, force: true }).catch(() => undefined);
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Reconcile an overlay that was interrupted between intent and completion. If a
549
+ * `pending.json` exists without a matching `applied.json`, the swap loop may
550
+ * have run partially (some live dists new, some old) and left orphaned
551
+ * `dist.new`/`dist.bak`. Restoring from the snapshot returns every package to
552
+ * its pre-overlay dist so the install is internally consistent again; the caller
553
+ * marks the txn rolled_back. No-op when the overlay completed cleanly.
554
+ */
555
+ export async function reconcileOverlay(opts: {
556
+ readonly install: CoreInstallInfo;
557
+ readonly pkgNames: ReadonlyArray<string>;
558
+ readonly snapshotDir: string;
559
+ }): Promise<{ reconciled: boolean }> {
560
+ const pendingFile = path.join(opts.snapshotDir, 'pending.json');
561
+ if (!(await exists(pendingFile))) return { reconciled: false };
562
+ if (await exists(path.join(opts.snapshotDir, 'applied.json'))) {
563
+ // The full set landed; the pending marker is just stale. Clear it.
564
+ await fs.rm(pendingFile, { force: true }).catch(() => undefined);
565
+ return { reconciled: false };
566
+ }
567
+ await restoreOverlay(opts);
568
+ // Drop any orphaned staging dirs from the interrupted swap.
569
+ for (const pkgName of opts.pkgNames) {
570
+ const destPkg = path.join(opts.install.scopeDir, shortName(pkgName));
571
+ await fs.rm(path.join(destPkg, 'dist.new'), { recursive: true, force: true }).catch(() => undefined);
572
+ await fs.rm(path.join(destPkg, 'dist.bak'), { recursive: true, force: true }).catch(() => undefined);
573
+ }
574
+ await fs.rm(pendingFile, { force: true }).catch(() => undefined);
575
+ return { reconciled: true };
576
+ }
577
+
578
+ /** Reverse an overlay: restore each package's dist from the snapshot. */
579
+ export async function restoreOverlay(opts: {
580
+ readonly install: CoreInstallInfo;
581
+ readonly pkgNames: ReadonlyArray<string>;
582
+ readonly snapshotDir: string;
583
+ }): Promise<void> {
584
+ for (const pkgName of opts.pkgNames) {
585
+ const snap = path.join(opts.snapshotDir, shortName(pkgName));
586
+ if (!(await exists(snap))) continue;
587
+ const destDist = path.join(opts.install.scopeDir, shortName(pkgName), 'dist');
588
+ await fs.rm(destDist, { recursive: true, force: true });
589
+ await fs.cp(snap, destDist, { recursive: true });
590
+ }
591
+ }
592
+
593
+ async function exists(p: string): Promise<boolean> {
594
+ return fs.access(p).then(() => true).catch(() => false);
595
+ }
596
+
597
+ // ── boot-time finalize ────────────────────────────────────────────────────────────
598
+ /** Whether the snapshot dir holds a consistent, fully-applied overlay for `pkgNames`. */
599
+ async function overlayApplied(
600
+ snapshotDir: string,
601
+ pkgNames: ReadonlyArray<string>,
602
+ ): Promise<boolean> {
603
+ try {
604
+ const applied = JSON.parse(
605
+ await fs.readFile(path.join(snapshotDir, 'applied.json'), 'utf8'),
606
+ ) as { packages?: string[] };
607
+ const got = new Set(applied.packages ?? []);
608
+ return pkgNames.every((p) => got.has(p));
609
+ } catch {
610
+ return false;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Called early on every CLI boot. Reaching this point means the (possibly
616
+ * overlaid) core code imported successfully, so a cleanly-applied
617
+ * `staged_restart` txn is committed. The primary defense against a bad patch is
618
+ * the pre-overlay build+typecheck+test in verify; this adds two boot-time
619
+ * safety nets when an `install` is supplied:
620
+ * - reconcile an overlay interrupted mid-swap (partial dist set) by restoring
621
+ * the snapshot, and mark that txn rolled_back rather than committing a mixed
622
+ * core that merely imported far enough to reach this hook;
623
+ * - refuse to commit unless `applied.json` records every journal package, so an
624
+ * inconsistent overlay keeps its rollback snapshot instead of GC-ing it.
625
+ * Without `install` it stays best-effort (commits any staged txn) for callers
626
+ * that cannot resolve the live install.
627
+ */
628
+ export async function finalizeStagedCoreUpdate(
629
+ moxxyDir: string,
630
+ install?: CoreInstallInfo | null,
631
+ keepTerminal = 5,
632
+ ): Promise<ReadonlyArray<string>> {
633
+ const committed: string[] = [];
634
+ for (const j of await listCoreTxns(moxxyDir)) {
635
+ if (j.state !== 'staged_restart') continue;
636
+ const snapDir = path.join(coreTxnDir(moxxyDir, j.txnId), 'snapshot');
637
+
638
+ if (install) {
639
+ const recon = await reconcileOverlay({
640
+ install,
641
+ pkgNames: j.packages,
642
+ snapshotDir: snapDir,
643
+ }).catch(() => ({ reconciled: false }));
644
+ if (recon.reconciled) {
645
+ j.state = 'rolled_back';
646
+ await writeCoreJournal(moxxyDir, j).catch(() => undefined);
647
+ continue;
648
+ }
649
+ if (!(await overlayApplied(snapDir, j.packages))) {
650
+ // The overlay never recorded a complete apply — restore and back out
651
+ // rather than commit (and then GC) a possibly-broken core.
652
+ await restoreOverlay({ install, pkgNames: j.packages, snapshotDir: snapDir }).catch(
653
+ () => undefined,
654
+ );
655
+ j.state = 'rolled_back';
656
+ await writeCoreJournal(moxxyDir, j).catch(() => undefined);
657
+ continue;
658
+ }
659
+ }
660
+
661
+ j.state = 'committed';
662
+ await writeCoreJournal(moxxyDir, j).catch(() => undefined);
663
+ committed.push(j.txnId);
664
+ }
665
+ // Reclaim disk: every core txn parks a full pre-overlay dist snapshot, so old
666
+ // terminal txns would otherwise accumulate unbounded. Boot is the safe, single
667
+ // place to prune (all staged txns above are now terminal). Best-effort.
668
+ await gcCoreTxns(moxxyDir, keepTerminal).catch(() => undefined);
669
+ return committed;
670
+ }
671
+
672
+ export function newCoreTxnId(now: Date = new Date()): string {
673
+ return `core-${newTxnId(now)}`;
674
+ }
675
+
676
+ /**
677
+ * Prune old terminal core transactions. Each core txn parks a full pre-overlay
678
+ * `dist` snapshot of every patched package (tens of MB), and nothing else ever
679
+ * deletes them — left unbounded they accumulate on disk across every
680
+ * apply/rollback cycle. Keep only the most recent `keep` terminal
681
+ * (committed / rolled_back) txns; never touch a non-terminal one (an open /
682
+ * provisioned / verified / staged_restart txn whose snapshot finalize or a
683
+ * rollback may still need). Best-effort: a failed delete is ignored.
684
+ */
685
+ export async function gcCoreTxns(moxxyDir: string, keep: number): Promise<void> {
686
+ const all = await listCoreTxns(moxxyDir);
687
+ const terminal = all.filter((j) => j.state === 'committed' || j.state === 'rolled_back');
688
+ for (const j of terminal.slice(Math.max(0, keep))) {
689
+ await fs.rm(coreTxnDir(moxxyDir, j.txnId), { recursive: true, force: true }).catch(() => undefined);
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Resolve a clone-relative path, refusing anything that escapes the repo —
695
+ * including via a symlink. `path.resolve` collapses literal `..`, but the clone
696
+ * is git-controlled and can contain symlinks, and core_write/core_edit follow
697
+ * them (fs.writeFile/readFile dereference), so a path that textually stays inside
698
+ * the repo but traverses a symlink could read or clobber files outside the clone
699
+ * (the live install, ~/.ssh, …). We therefore (1) keep the cheap textual gate,
700
+ * then (2) re-check against the realpath of the repo root and reject if any
701
+ * existing component of the target is a symlink.
702
+ */
703
+ export function safeRepoPath(repo: string, rel: string): string {
704
+ const root = path.resolve(repo);
705
+ const resolved = path.resolve(repo, rel);
706
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
707
+ throw new Error(`path escapes the provisioned repo: ${rel}`);
708
+ }
709
+
710
+ // Resolve the repo root through any symlinks once; everything must stay under it.
711
+ let realRoot: string;
712
+ try {
713
+ realRoot = realpathSync(root);
714
+ } catch {
715
+ // Repo root not provisioned yet — the textual gate is the best we can do.
716
+ return resolved;
717
+ }
718
+
719
+ // The portion *inside* the repo is the path relative to the (textual) root —
720
+ // computed from `resolved`, NOT by re-resolving `rel` against realRoot. An
721
+ // absolute `rel` (legitimately repo-prefixed) makes `path.resolve(realRoot,
722
+ // rel)` ignore realRoot, and when the root traverses a symlink (e.g. macOS
723
+ // /var→/private/var, or a symlinked $HOME) the relative diff is then all `..`
724
+ // and a valid in-repo absolute path is wrongly rejected. Anchoring the
725
+ // already-validated in-repo segments onto realRoot avoids that.
726
+ const relParts = path.relative(root, resolved).split(path.sep).filter(Boolean);
727
+ if (relParts.some((p) => p === '..')) {
728
+ throw new Error(`path escapes the provisioned repo: ${rel}`);
729
+ }
730
+ let cur = realRoot;
731
+ for (const part of relParts) {
732
+ cur = path.join(cur, part);
733
+ let st;
734
+ try {
735
+ st = lstatSync(cur);
736
+ } catch {
737
+ break; // component doesn't exist yet (the file we're about to create) — stop.
738
+ }
739
+ if (st.isSymbolicLink()) {
740
+ throw new Error(`path traverses a symlink, refusing: ${rel}`);
741
+ }
742
+ }
743
+ return path.join(realRoot, ...relParts);
744
+ }