@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,254 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { writeFileAtomic } from '@moxxy/sdk/server';
5
+
6
+ /**
7
+ * Transactional bookkeeping for self-update. Every change to a user-scope
8
+ * artifact (a plugin directory or a skill file) is bracketed by a transaction:
9
+ * the target is snapshotted before edits, a persisted journal tracks state and
10
+ * failed attempts, and a rollback restores the snapshot (or deletes a freshly
11
+ * created artifact). This module is filesystem-only and imports no moxxy core,
12
+ * so it is trivially unit-testable against a temp directory.
13
+ */
14
+
15
+ export type TargetKind = 'plugin' | 'skill';
16
+
17
+ export type TxnState = 'open' | 'verified' | 'committed' | 'rolled_back' | 'escalated';
18
+
19
+ export interface TxnTarget {
20
+ readonly kind: TargetKind;
21
+ readonly name: string;
22
+ /** Absolute path to the artifact (a directory for plugins, a file for skills). */
23
+ readonly path: string;
24
+ }
25
+
26
+ export interface TxnAttempt {
27
+ readonly at: string;
28
+ readonly stage: string;
29
+ readonly ok: boolean;
30
+ readonly message: string;
31
+ }
32
+
33
+ /** Registered contribution names per kind — used to diff what a change added. */
34
+ export type RegistrySnapshot = Record<string, ReadonlyArray<string>>;
35
+
36
+ export interface Journal {
37
+ readonly txnId: string;
38
+ readonly createdAt: string;
39
+ updatedAt: string;
40
+ readonly target: TxnTarget;
41
+ /** Whether the artifact existed before this transaction. false ⇒ rollback deletes it. */
42
+ readonly existedBefore: boolean;
43
+ state: TxnState;
44
+ attempts: TxnAttempt[];
45
+ /** Registry contributions captured at begin time, for an added-since diff. */
46
+ registryBefore?: RegistrySnapshot;
47
+ }
48
+
49
+ /** Names present in `after` but not in `before`, per kind. Empty keys dropped. */
50
+ export function diffSnapshot(before: RegistrySnapshot, after: RegistrySnapshot): RegistrySnapshot {
51
+ const out: Record<string, ReadonlyArray<string>> = {};
52
+ for (const key of Object.keys(after)) {
53
+ const had = new Set(before[key] ?? []);
54
+ const added = (after[key] ?? []).filter((n) => !had.has(n));
55
+ if (added.length > 0) out[key] = added;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** Max failed verify cycles for one transaction before we force escalation. */
61
+ export const MAX_FAILED_ATTEMPTS = 2;
62
+
63
+ const NAME_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
64
+
65
+ /**
66
+ * Paths excluded from a transaction snapshot. `node_modules` is reproducible
67
+ * from package.json (verify re-runs `npm install`), `.git` would balloon the
68
+ * copy, and `dist` is regenerated by the build — copying any of them turns a
69
+ * source snapshot into a multi-hundred-MB recursive copy of the dependency tree
70
+ * on every begin and every restore, which is slow and can exhaust disk.
71
+ */
72
+ const SNAPSHOT_EXCLUDE = new Set(['node_modules', '.git', 'dist']);
73
+
74
+ /**
75
+ * Build an `fs.cp` filter that skips excluded directory basenames so snapshots
76
+ * stay source-only — but never excludes the copy root itself (a plugin could,
77
+ * per NAME_SEGMENT_RE, legitimately be named e.g. `dist`).
78
+ */
79
+ function snapshotFilter(root: string): (src: string) => boolean {
80
+ return (src) => src === root || !SNAPSHOT_EXCLUDE.has(path.basename(src));
81
+ }
82
+
83
+ export function selfUpdateRoot(moxxyDir: string): string {
84
+ return path.join(moxxyDir, 'self-update', 'txns');
85
+ }
86
+
87
+ export function txnDir(moxxyDir: string, txnId: string): string {
88
+ return path.join(selfUpdateRoot(moxxyDir), txnId);
89
+ }
90
+
91
+ /**
92
+ * Resolve the on-disk target for a (kind, name). Throws on unsafe names so a
93
+ * model-supplied name can never escape the user artifact directories.
94
+ */
95
+ export function resolveTarget(moxxyDir: string, kind: TargetKind, name: string): TxnTarget {
96
+ if (!NAME_SEGMENT_RE.test(name)) {
97
+ throw new Error(
98
+ `invalid ${kind} name "${name}": only letters, digits, dot, dash and underscore are allowed`,
99
+ );
100
+ }
101
+ const p =
102
+ kind === 'plugin'
103
+ ? path.join(moxxyDir, 'plugins', name)
104
+ : path.join(moxxyDir, 'skills', `${name}.md`);
105
+ return { kind, name, path: p };
106
+ }
107
+
108
+ export function newTxnId(now: Date = new Date()): string {
109
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
110
+ // crypto UUID (not Math.random): two begins in the same second must never
111
+ // collide on the txn dir, which beginTransaction's recursive mkdir would
112
+ // silently share — clobbering an existing txn's journal + before snapshot.
113
+ const rand = randomUUID().slice(0, 8);
114
+ return `${stamp}-${rand}`;
115
+ }
116
+
117
+ async function pathExists(p: string): Promise<boolean> {
118
+ try {
119
+ await fs.access(p);
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Open a transaction: snapshot the current target (if any) into the txn dir
128
+ * and write the initial journal. Safe to call for a not-yet-existing target.
129
+ */
130
+ export async function beginTransaction(opts: {
131
+ readonly moxxyDir: string;
132
+ readonly kind: TargetKind;
133
+ readonly name: string;
134
+ readonly now?: Date;
135
+ }): Promise<Journal> {
136
+ const now = opts.now ?? new Date();
137
+ const target = resolveTarget(opts.moxxyDir, opts.kind, opts.name);
138
+ const txnId = newTxnId(now);
139
+ const dir = txnDir(opts.moxxyDir, txnId);
140
+ await fs.mkdir(dir, { recursive: true });
141
+
142
+ const existedBefore = await pathExists(target.path);
143
+ if (existedBefore) {
144
+ const before = path.join(dir, 'before');
145
+ await fs.cp(target.path, before, { recursive: true, filter: snapshotFilter(target.path) });
146
+ }
147
+
148
+ const journal: Journal = {
149
+ txnId,
150
+ createdAt: now.toISOString(),
151
+ updatedAt: now.toISOString(),
152
+ target,
153
+ existedBefore,
154
+ state: 'open',
155
+ attempts: [],
156
+ };
157
+ await writeJournal(opts.moxxyDir, journal);
158
+ return journal;
159
+ }
160
+
161
+ export async function writeJournal(moxxyDir: string, journal: Journal): Promise<void> {
162
+ journal.updatedAt = new Date().toISOString();
163
+ const file = path.join(txnDir(moxxyDir, journal.txnId), 'journal.json');
164
+ await writeFileAtomic(file, JSON.stringify(journal, null, 2) + '\n');
165
+ }
166
+
167
+ export async function readJournal(moxxyDir: string, txnId: string): Promise<Journal> {
168
+ const file = path.join(txnDir(moxxyDir, txnId), 'journal.json');
169
+ const raw = await fs.readFile(file, 'utf8');
170
+ return JSON.parse(raw) as Journal;
171
+ }
172
+
173
+ export async function listTransactions(moxxyDir: string): Promise<ReadonlyArray<Journal>> {
174
+ const root = selfUpdateRoot(moxxyDir);
175
+ let ids: string[];
176
+ try {
177
+ ids = (await fs.readdir(root, { withFileTypes: true }))
178
+ .filter((e) => e.isDirectory())
179
+ .map((e) => e.name);
180
+ } catch {
181
+ return [];
182
+ }
183
+ const out: Journal[] = [];
184
+ for (const id of ids) {
185
+ try {
186
+ out.push(await readJournal(moxxyDir, id));
187
+ } catch {
188
+ // ignore half-written / corrupt txn dirs
189
+ }
190
+ }
191
+ return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
192
+ }
193
+
194
+ export function recordAttempt(journal: Journal, attempt: Omit<TxnAttempt, 'at'>): void {
195
+ journal.attempts.push({ at: new Date().toISOString(), ...attempt });
196
+ }
197
+
198
+ export function failedAttemptCount(journal: Journal): number {
199
+ return journal.attempts.filter((a) => !a.ok).length;
200
+ }
201
+
202
+ /**
203
+ * Restore the target to its pre-transaction state: copy the snapshot back, or
204
+ * delete the artifact if it was newly created. Idempotent.
205
+ *
206
+ * The snapshot is source-only (SNAPSHOT_EXCLUDE drops node_modules/.git/dist),
207
+ * so a blanket `rm -rf` of the target before copying would wipe the installed
208
+ * runtime deps — and nothing re-installs them, leaving a plugin that fails to
209
+ * load on the very next deps.reload(). To keep the working `node_modules` (and
210
+ * the other reproducible-but-uncaptured dirs) across a MODIFICATION rollback, we
211
+ * remove only the captured source entries and overlay the snapshot back on top,
212
+ * never touching the excluded dirs. A newly-created target (no snapshot) is still
213
+ * deleted wholesale.
214
+ */
215
+ export async function restoreSnapshot(moxxyDir: string, journal: Journal): Promise<void> {
216
+ const { target, existedBefore } = journal;
217
+ if (!existedBefore) {
218
+ await fs.rm(target.path, { recursive: true, force: true });
219
+ return;
220
+ }
221
+ const before = path.join(txnDir(moxxyDir, journal.txnId), 'before');
222
+ // A skill target is a single file, not a directory — replace it outright.
223
+ if (target.kind !== 'plugin') {
224
+ await fs.rm(target.path, { recursive: true, force: true });
225
+ await fs.cp(before, target.path, { recursive: true, filter: snapshotFilter(before) });
226
+ return;
227
+ }
228
+ // Plugin directory: clear only the non-excluded entries (the source the
229
+ // snapshot captured) so the live node_modules/.git/dist survive intact, then
230
+ // copy the source snapshot back over them.
231
+ let entries: string[] = [];
232
+ try {
233
+ entries = (await fs.readdir(target.path, { withFileTypes: true })).map((e) => e.name);
234
+ } catch {
235
+ // Target dir vanished — recreate it from the snapshot below.
236
+ }
237
+ for (const name of entries) {
238
+ if (SNAPSHOT_EXCLUDE.has(name)) continue;
239
+ await fs.rm(path.join(target.path, name), { recursive: true, force: true });
240
+ }
241
+ await fs.mkdir(target.path, { recursive: true });
242
+ await fs.cp(before, target.path, { recursive: true, filter: snapshotFilter(before) });
243
+ }
244
+
245
+ /** Keep the most recent `keep` terminal transactions; delete older ones. */
246
+ export async function gcTransactions(moxxyDir: string, keep: number): Promise<void> {
247
+ const all = await listTransactions(moxxyDir);
248
+ const terminal = all.filter(
249
+ (j) => j.state === 'committed' || j.state === 'rolled_back' || j.state === 'escalated',
250
+ );
251
+ for (const j of terminal.slice(keep)) {
252
+ await fs.rm(txnDir(moxxyDir, j.txnId), { recursive: true, force: true });
253
+ }
254
+ }
@@ -0,0 +1,82 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { verifyPluginBuild, verifySkillFile } from './verify.js';
6
+ import { resolveTarget } from './transaction.js';
7
+
8
+ const tempDirs: string[] = [];
9
+ afterEach(async () => {
10
+ await Promise.all(tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
11
+ });
12
+
13
+ async function tempPluginDir(): Promise<string> {
14
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-verify-'));
15
+ tempDirs.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ describe('verifyPluginBuild', () => {
20
+ it('is a no-op for a bare plugin with no package.json', async () => {
21
+ const dir = await tempPluginDir();
22
+ await fs.writeFile(path.join(dir, 'index.mjs'), 'export default {};\n', 'utf8');
23
+ const target = { kind: 'plugin' as const, name: 'bare', path: dir };
24
+ expect(await verifyPluginBuild(target)).toEqual([]);
25
+ });
26
+
27
+ it('reports a failing build script', async () => {
28
+ const dir = await tempPluginDir();
29
+ await fs.writeFile(
30
+ path.join(dir, 'package.json'),
31
+ JSON.stringify({ name: 'p', scripts: { build: 'exit 1' } }),
32
+ 'utf8',
33
+ );
34
+ const stages = await verifyPluginBuild({ kind: 'plugin', name: 'p', path: dir });
35
+ expect(stages.at(-1)?.stage).toBe('build');
36
+ expect(stages.at(-1)?.ok).toBe(false);
37
+ }, 30_000);
38
+ });
39
+
40
+ describe('verifySkillFile', () => {
41
+ it('accepts a well-formed skill', async () => {
42
+ const dir = await tempPluginDir();
43
+ const moxxy = dir;
44
+ await fs.mkdir(path.join(moxxy, 'skills'), { recursive: true });
45
+ const target = resolveTarget(moxxy, 'skill', 'good');
46
+ await fs.writeFile(target.path, '---\nname: good\ndescription: does a thing\n---\n\nbody\n', 'utf8');
47
+ expect((await verifySkillFile(target)).ok).toBe(true);
48
+ });
49
+
50
+ it('rejects missing frontmatter', async () => {
51
+ const dir = await tempPluginDir();
52
+ await fs.mkdir(path.join(dir, 'skills'), { recursive: true });
53
+ const target = resolveTarget(dir, 'skill', 'bad');
54
+ await fs.writeFile(target.path, 'no frontmatter here\n', 'utf8');
55
+ const r = await verifySkillFile(target);
56
+ expect(r.ok).toBe(false);
57
+ });
58
+
59
+ it('rejects frontmatter missing name/description', async () => {
60
+ const dir = await tempPluginDir();
61
+ await fs.mkdir(path.join(dir, 'skills'), { recursive: true });
62
+ const target = resolveTarget(dir, 'skill', 'partial');
63
+ await fs.writeFile(target.path, '---\nname: x\n---\nbody\n', 'utf8');
64
+ expect((await verifySkillFile(target)).ok).toBe(false);
65
+ });
66
+
67
+ it('accepts a CRLF (Windows-authored) skill', async () => {
68
+ const dir = await tempPluginDir();
69
+ await fs.mkdir(path.join(dir, 'skills'), { recursive: true });
70
+ const target = resolveTarget(dir, 'skill', 'crlf');
71
+ await fs.writeFile(target.path, '---\r\nname: good\r\ndescription: does a thing\r\n---\r\n\r\nbody\r\n', 'utf8');
72
+ expect((await verifySkillFile(target)).ok).toBe(true);
73
+ });
74
+
75
+ it('rejects an empty quoted value that the old \\S+ check accepted', async () => {
76
+ const dir = await tempPluginDir();
77
+ await fs.mkdir(path.join(dir, 'skills'), { recursive: true });
78
+ const target = resolveTarget(dir, 'skill', 'emptyname');
79
+ await fs.writeFile(target.path, '---\nname: ""\ndescription: ok\n---\nbody\n', 'utf8');
80
+ expect((await verifySkillFile(target)).ok).toBe(false);
81
+ });
82
+ });
package/src/verify.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import type { TxnTarget } from './transaction.js';
5
+
6
+ /**
7
+ * Build/test verification for a transaction target, run as child processes so a
8
+ * failing build can never crash the host. Pure I/O — no moxxy core imports.
9
+ */
10
+
11
+ export interface StageResult {
12
+ readonly stage: string;
13
+ readonly ok: boolean;
14
+ readonly message: string;
15
+ }
16
+
17
+ export interface RunCmdResult {
18
+ readonly exitCode: number;
19
+ readonly output: string;
20
+ }
21
+
22
+ const CMD_TIMEOUT_MS = 5 * 60_000;
23
+ /**
24
+ * Cap on retained child output. A model-triggered `npm run build/test` can emit
25
+ * unbounded output; holding it all in the live process risks OOM. Callers only
26
+ * read the trailing ~1.2KB, so a sliding tail is lossless for them.
27
+ */
28
+ const MAX_CMD_OUTPUT_BYTES = 512 * 1024;
29
+
30
+ function runCmd(cmd: string, args: ReadonlyArray<string>, cwd: string): Promise<RunCmdResult> {
31
+ return new Promise((resolve) => {
32
+ // `detached` lets a timeout SIGKILL the whole process group (npm fans out to
33
+ // node/esbuild/vitest workers that an immediate-child kill would orphan).
34
+ const child = spawn(cmd, [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
35
+ let buf = '';
36
+ const onData = (d: Buffer): void => {
37
+ buf += d.toString('utf8');
38
+ if (buf.length > MAX_CMD_OUTPUT_BYTES) buf = buf.slice(buf.length - MAX_CMD_OUTPUT_BYTES);
39
+ };
40
+ child.stdout.on('data', onData);
41
+ child.stderr.on('data', onData);
42
+ const timer = setTimeout(() => killTree(child), CMD_TIMEOUT_MS);
43
+ let settled = false;
44
+ const finish = (r: RunCmdResult): void => {
45
+ if (settled) return;
46
+ settled = true;
47
+ clearTimeout(timer);
48
+ child.stdout.off('data', onData);
49
+ child.stderr.off('data', onData);
50
+ resolve(r);
51
+ };
52
+ child.on('error', (err) => finish({ exitCode: -1, output: `${cmd} failed to start: ${err.message}` }));
53
+ child.on('close', (code) => finish({ exitCode: code ?? -1, output: buf }));
54
+ });
55
+ }
56
+
57
+ /** SIGKILL a detached child's whole process group, tolerating an already-dead group. */
58
+ function killTree(child: { pid?: number; kill: (s: NodeJS.Signals) => boolean }): void {
59
+ try {
60
+ if (child.pid != null) process.kill(-child.pid, 'SIGKILL');
61
+ else child.kill('SIGKILL');
62
+ } catch {
63
+ try {
64
+ child.kill('SIGKILL');
65
+ } catch {
66
+ /* already gone */
67
+ }
68
+ }
69
+ }
70
+
71
+ function truncate(s: string, n = 1200): string {
72
+ return s.length <= n ? s : `…${s.slice(s.length - n)}`;
73
+ }
74
+
75
+ interface PkgJson {
76
+ readonly scripts?: Record<string, string>;
77
+ readonly dependencies?: Record<string, string>;
78
+ /** npm-standard `packageManager` field, e.g. "pnpm@9.0.0" — selects the verifier toolchain. */
79
+ readonly packageManager?: string;
80
+ }
81
+
82
+ async function readPkg(dir: string): Promise<PkgJson | null> {
83
+ try {
84
+ return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) as PkgJson;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ /** Per-package-manager invocations for the install / run-script / test steps. */
91
+ interface PmCommands {
92
+ readonly install: ReadonlyArray<string>;
93
+ readonly run: (script: string) => ReadonlyArray<string>;
94
+ readonly test: ReadonlyArray<string>;
95
+ readonly bin: string;
96
+ }
97
+
98
+ const PM_COMMANDS: Record<string, PmCommands> = {
99
+ npm: { bin: 'npm', install: ['install', '--no-fund', '--no-audit'], run: (s) => ['run', s], test: ['test'] },
100
+ pnpm: { bin: 'pnpm', install: ['install'], run: (s) => ['run', s], test: ['test'] },
101
+ yarn: { bin: 'yarn', install: ['install'], run: (s) => ['run', s], test: ['test'] },
102
+ bun: { bin: 'bun', install: ['install'], run: (s) => ['run', s], test: ['test'] },
103
+ };
104
+
105
+ /**
106
+ * Pick the package manager from the npm-standard `packageManager` field
107
+ * ("pnpm@9", "yarn@4", …) so a non-npm plugin can declare its toolchain. The
108
+ * default is npm, preserving prior behavior for plugins that omit the field or
109
+ * name an unknown manager.
110
+ */
111
+ function pmFor(pkg: PkgJson): PmCommands {
112
+ const name = (pkg.packageManager ?? '').split('@')[0]?.trim().toLowerCase();
113
+ return (name && PM_COMMANDS[name]) || PM_COMMANDS.npm!;
114
+ }
115
+
116
+ /**
117
+ * Run a plugin's own `build` and `test` npm scripts when present. A plugin
118
+ * scaffolded as a zero-build `.mjs` (no scripts) passes straight through —
119
+ * jiti / dynamic-import handles it at reload time, which is the real load test.
120
+ */
121
+ export async function verifyPluginBuild(target: TxnTarget): Promise<ReadonlyArray<StageResult>> {
122
+ const results: StageResult[] = [];
123
+ const pkg = await readPkg(target.path);
124
+ if (!pkg) return results; // no package.json (e.g. bare .mjs) → nothing to build
125
+
126
+ const pm = pmFor(pkg);
127
+
128
+ // Install deps only if the plugin declares any and they aren't present yet.
129
+ if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
130
+ const hasModules = await fs
131
+ .access(path.join(target.path, 'node_modules'))
132
+ .then(() => true)
133
+ .catch(() => false);
134
+ if (!hasModules) {
135
+ const r = await runCmd(pm.bin, pm.install, target.path);
136
+ results.push({
137
+ stage: 'install',
138
+ ok: r.exitCode === 0,
139
+ message: r.exitCode === 0 ? 'deps installed' : truncate(r.output),
140
+ });
141
+ if (r.exitCode !== 0) return results;
142
+ }
143
+ }
144
+
145
+ if (pkg.scripts?.build) {
146
+ const r = await runCmd(pm.bin, pm.run('build'), target.path);
147
+ results.push({
148
+ stage: 'build',
149
+ ok: r.exitCode === 0,
150
+ message: r.exitCode === 0 ? 'build ok' : truncate(r.output),
151
+ });
152
+ if (r.exitCode !== 0) return results;
153
+ }
154
+
155
+ if (pkg.scripts?.test) {
156
+ const r = await runCmd(pm.bin, pm.test, target.path);
157
+ results.push({
158
+ stage: 'test',
159
+ ok: r.exitCode === 0,
160
+ message: r.exitCode === 0 ? 'tests ok' : truncate(r.output),
161
+ });
162
+ }
163
+
164
+ return results;
165
+ }
166
+
167
+ /**
168
+ * Require a real (non-empty, non-blank-quoted) value after a frontmatter key.
169
+ * Rejects `name:`, `name: ""`, `name: ' '` — a quote alone satisfied the old
170
+ * `\S+`, accepting a structurally-present-but-empty field.
171
+ */
172
+ const FRONTMATTER_FIELD = (key: string): RegExp =>
173
+ new RegExp(`^${key}:[ \\t]*(?:"[ \\t]*[^"\\s][^"]*"|'[ \\t]*[^'\\s][^']*'|[^"'\\s].*)$`, 'm');
174
+
175
+ /** Minimal structural check that a skill file has the required frontmatter. */
176
+ export async function verifySkillFile(target: TxnTarget): Promise<StageResult> {
177
+ let raw: string;
178
+ try {
179
+ raw = await fs.readFile(target.path, 'utf8');
180
+ } catch {
181
+ return { stage: 'parse', ok: false, message: `skill file not found: ${target.path}` };
182
+ }
183
+ // Normalize CRLF so a Windows-authored skill isn't rejected by the \n anchors.
184
+ const normalized = raw.replace(/\r\n/g, '\n');
185
+ const fm = normalized.match(/^---\n([\s\S]*?)\n---/);
186
+ if (!fm) {
187
+ return { stage: 'parse', ok: false, message: 'missing YAML frontmatter block (--- … ---)' };
188
+ }
189
+ const body = fm[1] ?? '';
190
+ const hasName = FRONTMATTER_FIELD('name').test(body);
191
+ const hasDesc = FRONTMATTER_FIELD('description').test(body);
192
+ if (!hasName || !hasDesc) {
193
+ return {
194
+ stage: 'parse',
195
+ ok: false,
196
+ message: 'frontmatter must declare both a non-empty `name:` and `description:`',
197
+ };
198
+ }
199
+ return { stage: 'parse', ok: true, message: 'frontmatter ok' };
200
+ }