@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.
- package/LICENSE +21 -0
- package/dist/classify.d.ts +33 -0
- package/dist/classify.d.ts.map +1 -0
- package/dist/classify.js +177 -0
- package/dist/classify.js.map +1 -0
- package/dist/core-tools/apply.d.ts +4 -0
- package/dist/core-tools/apply.d.ts.map +1 -0
- package/dist/core-tools/apply.js +43 -0
- package/dist/core-tools/apply.js.map +1 -0
- package/dist/core-tools/begin.d.ts +4 -0
- package/dist/core-tools/begin.d.ts.map +1 -0
- package/dist/core-tools/begin.js +76 -0
- package/dist/core-tools/begin.js.map +1 -0
- package/dist/core-tools/edit.d.ts +4 -0
- package/dist/core-tools/edit.d.ts.map +1 -0
- package/dist/core-tools/edit.js +33 -0
- package/dist/core-tools/edit.js.map +1 -0
- package/dist/core-tools/index.d.ts +11 -0
- package/dist/core-tools/index.d.ts.map +1 -0
- package/dist/core-tools/index.js +28 -0
- package/dist/core-tools/index.js.map +1 -0
- package/dist/core-tools/preflight.d.ts +4 -0
- package/dist/core-tools/preflight.d.ts.map +1 -0
- package/dist/core-tools/preflight.js +14 -0
- package/dist/core-tools/preflight.js.map +1 -0
- package/dist/core-tools/rollback.d.ts +4 -0
- package/dist/core-tools/rollback.d.ts.map +1 -0
- package/dist/core-tools/rollback.js +30 -0
- package/dist/core-tools/rollback.js.map +1 -0
- package/dist/core-tools/shared.d.ts +16 -0
- package/dist/core-tools/shared.d.ts.map +1 -0
- package/dist/core-tools/shared.js +12 -0
- package/dist/core-tools/shared.js.map +1 -0
- package/dist/core-tools/status.d.ts +4 -0
- package/dist/core-tools/status.d.ts.map +1 -0
- package/dist/core-tools/status.js +25 -0
- package/dist/core-tools/status.js.map +1 -0
- package/dist/core-tools/verify.d.ts +4 -0
- package/dist/core-tools/verify.d.ts.map +1 -0
- package/dist/core-tools/verify.js +39 -0
- package/dist/core-tools/verify.js.map +1 -0
- package/dist/core-tools/write.d.ts +4 -0
- package/dist/core-tools/write.d.ts.map +1 -0
- package/dist/core-tools/write.js +28 -0
- package/dist/core-tools/write.js.map +1 -0
- package/dist/core-update.d.ts +173 -0
- package/dist/core-update.d.ts.map +1 -0
- package/dist/core-update.js +626 -0
- package/dist/core-update.js.map +1 -0
- package/dist/deps.d.ts +49 -0
- package/dist/deps.d.ts.map +1 -0
- package/dist/deps.js +30 -0
- package/dist/deps.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/transaction.d.ts +80 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +188 -0
- package/dist/transaction.js.map +1 -0
- package/dist/verify.d.ts +23 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +164 -0
- package/dist/verify.js.map +1 -0
- package/package.json +61 -0
- package/src/classify.test.ts +176 -0
- package/src/classify.ts +221 -0
- package/src/core-tools/apply.ts +44 -0
- package/src/core-tools/begin.ts +94 -0
- package/src/core-tools/edit.ts +33 -0
- package/src/core-tools/index.ts +33 -0
- package/src/core-tools/preflight.ts +15 -0
- package/src/core-tools/rollback.ts +30 -0
- package/src/core-tools/shared.test.ts +16 -0
- package/src/core-tools/shared.ts +24 -0
- package/src/core-tools/status.ts +26 -0
- package/src/core-tools/verify.ts +39 -0
- package/src/core-tools/write.ts +30 -0
- package/src/core-update.test.ts +542 -0
- package/src/core-update.ts +744 -0
- package/src/deps.ts +84 -0
- package/src/discovery.test.ts +40 -0
- package/src/index.test.ts +299 -0
- package/src/index.ts +441 -0
- package/src/transaction.test.ts +143 -0
- package/src/transaction.ts +254 -0
- package/src/verify.test.ts +82 -0
- package/src/verify.ts +200 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
asPluginId,
|
|
5
|
+
definePlugin,
|
|
6
|
+
defineTool,
|
|
7
|
+
z,
|
|
8
|
+
type EmittedEvent,
|
|
9
|
+
type LifecycleHooks,
|
|
10
|
+
type Plugin,
|
|
11
|
+
type ToolContext,
|
|
12
|
+
type ToolDef,
|
|
13
|
+
} from '@moxxy/sdk';
|
|
14
|
+
import {
|
|
15
|
+
MAX_FAILED_ATTEMPTS,
|
|
16
|
+
beginTransaction,
|
|
17
|
+
diffSnapshot,
|
|
18
|
+
failedAttemptCount,
|
|
19
|
+
gcTransactions,
|
|
20
|
+
listTransactions,
|
|
21
|
+
readJournal,
|
|
22
|
+
recordAttempt,
|
|
23
|
+
resolveTarget,
|
|
24
|
+
restoreSnapshot,
|
|
25
|
+
writeJournal,
|
|
26
|
+
type Journal,
|
|
27
|
+
type RegistrySnapshot,
|
|
28
|
+
} from './transaction.js';
|
|
29
|
+
import { classify, gatherSignals } from './classify.js';
|
|
30
|
+
import { verifyPluginBuild, verifySkillFile, type StageResult } from './verify.js';
|
|
31
|
+
import { PLUGIN_ID, emitSafe, findSkip, readJsonName, type SelfUpdateDeps } from './deps.js';
|
|
32
|
+
import { coreTools } from './core-tools/index.js';
|
|
33
|
+
|
|
34
|
+
export type { SelfUpdateDeps, SelfUpdateEmit, SkipInfo } from './deps.js';
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
beginTransaction,
|
|
38
|
+
restoreSnapshot,
|
|
39
|
+
listTransactions,
|
|
40
|
+
gcTransactions,
|
|
41
|
+
type Journal,
|
|
42
|
+
type RegistrySnapshot,
|
|
43
|
+
} from './transaction.js';
|
|
44
|
+
export { classify, gatherSignals, type ClassifyResult } from './classify.js';
|
|
45
|
+
export {
|
|
46
|
+
finalizeStagedCoreUpdate,
|
|
47
|
+
gcCoreTxns,
|
|
48
|
+
listCoreTxns,
|
|
49
|
+
detectCoreInstall,
|
|
50
|
+
readCoreJournal,
|
|
51
|
+
restoreOverlay,
|
|
52
|
+
reconcileOverlay,
|
|
53
|
+
corePreflight,
|
|
54
|
+
type CoreJournal,
|
|
55
|
+
type CoreInstallInfo,
|
|
56
|
+
} from './core-update.js';
|
|
57
|
+
|
|
58
|
+
export function buildSelfUpdatePlugin(deps: SelfUpdateDeps, hooks?: LifecycleHooks): Plugin {
|
|
59
|
+
const tools: ToolDef[] = [
|
|
60
|
+
classifyTool(deps),
|
|
61
|
+
beginTool(deps),
|
|
62
|
+
verifyTool(deps),
|
|
63
|
+
applyTool(deps),
|
|
64
|
+
rollbackTool(deps),
|
|
65
|
+
statusTool(deps),
|
|
66
|
+
];
|
|
67
|
+
if (deps.coreUpdate?.enabled !== false) tools.push(...coreTools(deps, import.meta.url));
|
|
68
|
+
return definePlugin({ name: PLUGIN_ID, version: '0.0.0', tools, ...(hooks ? { hooks } : {}) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PluginHostLike {
|
|
72
|
+
reload(): Promise<void>;
|
|
73
|
+
unload(name: string): Promise<void>;
|
|
74
|
+
listSkipped(): ReadonlyArray<{ pluginName: string; packageName?: string; message: string }>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Discovery-loadable default export. Resolves the plugin host (`'pluginHost'`),
|
|
79
|
+
* the registry-name snapshot (`'registrySnapshot'`), the writable event-append
|
|
80
|
+
* (`'appendEvent'` — the counterpart to the read-only `ctx.log`) and this
|
|
81
|
+
* plugin's config options (`'getPluginOptions'`) from the inter-plugin service
|
|
82
|
+
* registry in `onInit`, instead of the host `SelfUpdateDeps` closure.
|
|
83
|
+
*
|
|
84
|
+
* The Tier-2 core-update tools are gated at BUILD time on
|
|
85
|
+
* `MOXXY_NO_CORE_UPDATE` (the env the desktop sets to hide them); the softer
|
|
86
|
+
* `allowCoreUpdate`/`repoUrl` config prefs are resolved in `onInit` and applied
|
|
87
|
+
* to `coreUpdate.repoUrlOverride` + the GC retention.
|
|
88
|
+
*/
|
|
89
|
+
export const selfUpdatePlugin: Plugin = (() => {
|
|
90
|
+
let host: PluginHostLike | null = null;
|
|
91
|
+
let snapshotFn: (() => RegistrySnapshot) | null = null;
|
|
92
|
+
let appendEvent: ((e: EmittedEvent) => Promise<void>) | null = null;
|
|
93
|
+
let options: Record<string, unknown> = {};
|
|
94
|
+
|
|
95
|
+
const deps: SelfUpdateDeps = {
|
|
96
|
+
moxxyDir: path.join(os.homedir(), '.moxxy'),
|
|
97
|
+
reload: () => host?.reload() ?? Promise.resolve(),
|
|
98
|
+
unload: (name) => host?.unload(name) ?? Promise.resolve(),
|
|
99
|
+
snapshot: () => (snapshotFn ? snapshotFn() : {}),
|
|
100
|
+
skipped: () =>
|
|
101
|
+
(host?.listSkipped() ?? []).map((s) => ({
|
|
102
|
+
pluginName: s.pluginName,
|
|
103
|
+
...(s.packageName ? { packageName: s.packageName } : {}),
|
|
104
|
+
message: s.message,
|
|
105
|
+
})),
|
|
106
|
+
emit: (e) =>
|
|
107
|
+
appendEvent
|
|
108
|
+
? appendEvent({
|
|
109
|
+
type: 'plugin_event',
|
|
110
|
+
pluginId: asPluginId(PLUGIN_ID),
|
|
111
|
+
subtype: e.subtype,
|
|
112
|
+
payload: e.payload,
|
|
113
|
+
sessionId: e.sessionId,
|
|
114
|
+
turnId: e.turnId,
|
|
115
|
+
source: 'plugin',
|
|
116
|
+
})
|
|
117
|
+
: Promise.resolve(),
|
|
118
|
+
get maxTxnRetained(): number | undefined {
|
|
119
|
+
return typeof options.maxTxnRetained === 'number' ? options.maxTxnRetained : undefined;
|
|
120
|
+
},
|
|
121
|
+
coreUpdate: {
|
|
122
|
+
// Gated at build on the env (the desktop sets MOXXY_NO_CORE_UPDATE=1 to
|
|
123
|
+
// hide the tools); the config `allowCoreUpdate` pref can't gate build-time
|
|
124
|
+
// tool inclusion under discovery-loading, but repoUrl is honored at run.
|
|
125
|
+
enabled: process.env.MOXXY_NO_CORE_UPDATE !== '1',
|
|
126
|
+
fromUrl: import.meta.url,
|
|
127
|
+
get repoUrlOverride(): string | undefined {
|
|
128
|
+
return typeof options.repoUrl === 'string' ? options.repoUrl : undefined;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const hooks: LifecycleHooks = {
|
|
134
|
+
onInit: (ctx) => {
|
|
135
|
+
host = ctx.services.get<PluginHostLike>('pluginHost') ?? null;
|
|
136
|
+
snapshotFn = ctx.services.get<() => RegistrySnapshot>('registrySnapshot') ?? null;
|
|
137
|
+
appendEvent = ctx.services.get<(e: EmittedEvent) => Promise<void>>('appendEvent') ?? null;
|
|
138
|
+
const getOptions = ctx.services.get<(pkg: string) => Record<string, unknown> | undefined>(
|
|
139
|
+
'getPluginOptions',
|
|
140
|
+
);
|
|
141
|
+
options = getOptions?.(PLUGIN_ID) ?? {};
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return buildSelfUpdatePlugin(deps, hooks);
|
|
146
|
+
})();
|
|
147
|
+
|
|
148
|
+
// ── self_update_classify ────────────────────────────────────────────────────
|
|
149
|
+
function classifyTool(deps: SelfUpdateDeps): ToolDef {
|
|
150
|
+
return defineTool({
|
|
151
|
+
name: 'self_update_classify',
|
|
152
|
+
description:
|
|
153
|
+
'Read-only. Inspect recent errors / failed tool calls and the registered tool set, then recommend the LOWEST-risk way to satisfy a self-update: a skill (instructions), a plugin (new tool / behavior override), or a core patch (escalate). Advisory only — you make the final call. Call this first when asked to add a capability or to fix a recurring failure.',
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
trigger: z.enum(['error', 'request']).describe('Whether this follows an error or a user request.'),
|
|
156
|
+
text: z.string().optional().describe('The user request or a short description of the problem.'),
|
|
157
|
+
}),
|
|
158
|
+
permission: { action: 'allow' },
|
|
159
|
+
handler: (input, ctx: ToolContext) => {
|
|
160
|
+
const signals = gatherSignals(ctx.log, deps.snapshot().tools ?? []);
|
|
161
|
+
return classify(input, signals);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── self_update_begin ─────────────────────────────────────────────────────────
|
|
167
|
+
function beginTool(deps: SelfUpdateDeps): ToolDef {
|
|
168
|
+
return defineTool({
|
|
169
|
+
name: 'self_update_begin',
|
|
170
|
+
description:
|
|
171
|
+
'Open a self-update transaction for a target plugin (dir under ~/.moxxy/plugins) or skill (~/.moxxy/skills/<name>.md). Snapshots the current state so the change can be rolled back. Returns a txnId. Make your edits with Write/Edit AFTER this, then call self_update_verify.',
|
|
172
|
+
inputSchema: z.object({
|
|
173
|
+
kind: z.enum(['plugin', 'skill']),
|
|
174
|
+
name: z
|
|
175
|
+
.string()
|
|
176
|
+
.min(1)
|
|
177
|
+
.describe('Plugin directory name or skill slug (no path separators).'),
|
|
178
|
+
}),
|
|
179
|
+
permission: { action: 'allow' },
|
|
180
|
+
handler: async (input, ctx: ToolContext) => {
|
|
181
|
+
// Single-flight per target (mirrors the Tier-2 core_begin guard): two
|
|
182
|
+
// concurrent begins on the same plugin/skill snapshot independently, so a
|
|
183
|
+
// second begin started after the model already overwrote the artifact
|
|
184
|
+
// captures the BROKEN state into its own `before/`, and a later rollback
|
|
185
|
+
// of that txn "restores" garbage. Refuse a new begin while a non-terminal
|
|
186
|
+
// txn for the same target is open. resolveTarget also validates the name.
|
|
187
|
+
const target = resolveTarget(deps.moxxyDir, input.kind, input.name);
|
|
188
|
+
const active = (await listTransactions(deps.moxxyDir)).find(
|
|
189
|
+
(j) => j.target.path === target.path && (j.state === 'open' || j.state === 'verified'),
|
|
190
|
+
);
|
|
191
|
+
if (active) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`a self-update is already in progress for ${input.kind} "${input.name}" ` +
|
|
194
|
+
`(txn ${active.txnId}, state "${active.state}"). Finish it (self_update_verify → ` +
|
|
195
|
+
`self_update_apply) or discard it with self_update_rollback({ txnId: "${active.txnId}" }) ` +
|
|
196
|
+
`before starting another.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const journal = await beginTransaction({ moxxyDir: deps.moxxyDir, kind: input.kind, name: input.name });
|
|
201
|
+
if (input.kind === 'plugin') {
|
|
202
|
+
journal.registryBefore = deps.snapshot();
|
|
203
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
204
|
+
}
|
|
205
|
+
await emitSafe(deps, ctx, 'begin', {
|
|
206
|
+
txnId: journal.txnId,
|
|
207
|
+
target: journal.target,
|
|
208
|
+
existedBefore: journal.existedBefore,
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
txnId: journal.txnId,
|
|
212
|
+
target: journal.target,
|
|
213
|
+
existedBefore: journal.existedBefore,
|
|
214
|
+
next: 'Write/Edit the files, then call self_update_verify with this txnId.',
|
|
215
|
+
};
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── self_update_verify ──────────────────────────────────────────────────────
|
|
221
|
+
function verifyTool(deps: SelfUpdateDeps): ToolDef {
|
|
222
|
+
return defineTool({
|
|
223
|
+
name: 'self_update_verify',
|
|
224
|
+
description:
|
|
225
|
+
'Build, test and load-check the change in a transaction, then hot-reload it into the live session. Returns the stage results and what registered. On failure of a modify, the previous working version is automatically restored. Refuses after 2 failed cycles (escalate to the user). Run AFTER your edits; if it passes, call self_update_apply.',
|
|
226
|
+
inputSchema: z.object({ txnId: z.string().min(1) }),
|
|
227
|
+
permission: { action: 'prompt' },
|
|
228
|
+
handler: async (input, ctx: ToolContext) => {
|
|
229
|
+
const journal = await readJournal(deps.moxxyDir, input.txnId);
|
|
230
|
+
|
|
231
|
+
if (failedAttemptCount(journal) >= MAX_FAILED_ATTEMPTS) {
|
|
232
|
+
await escalate(deps, ctx, journal, 'too many failed verify cycles');
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
escalate: true,
|
|
236
|
+
message: `Already failed ${MAX_FAILED_ATTEMPTS} times — stopping. Rolled back to a clean state; tell the user what you tried and the errors.`,
|
|
237
|
+
attempts: journal.attempts,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result =
|
|
242
|
+
journal.target.kind === 'plugin'
|
|
243
|
+
? await verifyPlugin(deps, journal)
|
|
244
|
+
: await verifySkill(journal);
|
|
245
|
+
|
|
246
|
+
if (!result.ok) {
|
|
247
|
+
recordAttempt(journal, { stage: result.failedStage ?? 'verify', ok: false, message: result.message });
|
|
248
|
+
let recovered = false;
|
|
249
|
+
if (journal.existedBefore) {
|
|
250
|
+
await restoreSnapshot(deps.moxxyDir, journal);
|
|
251
|
+
if (journal.target.kind === 'plugin') {
|
|
252
|
+
await deps.unload(journal.target.name).catch(() => undefined);
|
|
253
|
+
await deps.reload();
|
|
254
|
+
}
|
|
255
|
+
recovered = true;
|
|
256
|
+
}
|
|
257
|
+
const cap = failedAttemptCount(journal) >= MAX_FAILED_ATTEMPTS;
|
|
258
|
+
if (cap) {
|
|
259
|
+
// We may have just restored the snapshot above. Tell escalate() the
|
|
260
|
+
// rollback is done so it doesn't repeat the recursive filesystem copy
|
|
261
|
+
// + plugin-host rescan (escalate skips its restore when already
|
|
262
|
+
// rolled_back). It still flips state to 'escalated' and persists.
|
|
263
|
+
if (recovered) journal.state = 'rolled_back';
|
|
264
|
+
await escalate(deps, ctx, journal, result.message);
|
|
265
|
+
} else {
|
|
266
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
267
|
+
}
|
|
268
|
+
await emitSafe(deps, ctx, 'verify_failed', { txnId: journal.txnId, message: result.message });
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
escalate: cap,
|
|
272
|
+
recovered,
|
|
273
|
+
stages: result.stages,
|
|
274
|
+
message: result.message,
|
|
275
|
+
remainingAttempts: Math.max(0, MAX_FAILED_ATTEMPTS - failedAttemptCount(journal)),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
recordAttempt(journal, { stage: 'verify', ok: true, message: 'all stages passed' });
|
|
280
|
+
journal.state = 'verified';
|
|
281
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
282
|
+
await emitSafe(deps, ctx, 'verify_ok', { txnId: journal.txnId, registered: result.registered });
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
stages: result.stages,
|
|
286
|
+
registered: result.registered,
|
|
287
|
+
next: 'Looks good — call self_update_apply to keep it, or self_update_rollback to discard.',
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
interface VerifyOutcome {
|
|
294
|
+
readonly ok: boolean;
|
|
295
|
+
readonly failedStage?: string;
|
|
296
|
+
readonly message: string;
|
|
297
|
+
readonly stages: ReadonlyArray<StageResult>;
|
|
298
|
+
readonly registered?: RegistrySnapshot;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function verifyPlugin(deps: SelfUpdateDeps, journal: Journal): Promise<VerifyOutcome> {
|
|
302
|
+
const target = journal.target;
|
|
303
|
+
const stages = [...(await verifyPluginBuild(target))];
|
|
304
|
+
const failed = stages.find((s) => !s.ok);
|
|
305
|
+
if (failed) {
|
|
306
|
+
return { ok: false, failedStage: failed.stage, message: `${failed.stage} failed: ${failed.message}`, stages };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Activate: drop the old instance (if any) so a modified entry re-imports
|
|
310
|
+
// fresh, then rescan from disk.
|
|
311
|
+
await deps.unload(target.name).catch(() => undefined);
|
|
312
|
+
const pkgName = await readJsonName(target.path);
|
|
313
|
+
await deps.reload();
|
|
314
|
+
|
|
315
|
+
const skip = findSkip(deps.skipped(), [target.name, pkgName ?? '']);
|
|
316
|
+
if (skip) {
|
|
317
|
+
stages.push({ stage: 'load', ok: false, message: skip.message });
|
|
318
|
+
return { ok: false, failedStage: 'load', message: `plugin failed to load: ${skip.message}`, stages };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const after = deps.snapshot();
|
|
322
|
+
const registered = diffSnapshot(journal.registryBefore ?? {}, after);
|
|
323
|
+
if (!journal.existedBefore && Object.keys(registered).length === 0) {
|
|
324
|
+
stages.push({
|
|
325
|
+
stage: 'load',
|
|
326
|
+
ok: false,
|
|
327
|
+
message: 'plugin loaded but registered no tools/agents/etc. — check the entry exports a valid plugin',
|
|
328
|
+
});
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
failedStage: 'load',
|
|
332
|
+
message: 'new plugin registered nothing (no valid contributions found)',
|
|
333
|
+
stages,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
stages.push({ stage: 'load', ok: true, message: 'loaded and registered' });
|
|
338
|
+
return { ok: true, message: 'ok', stages, registered };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function verifySkill(journal: Journal): Promise<VerifyOutcome> {
|
|
342
|
+
const r = await verifySkillFile(journal.target);
|
|
343
|
+
if (!r.ok) return { ok: false, failedStage: r.stage, message: r.message, stages: [r] };
|
|
344
|
+
return {
|
|
345
|
+
ok: true,
|
|
346
|
+
message: 'skill frontmatter valid (activate with reload_skills or on next launch)',
|
|
347
|
+
stages: [r],
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── self_update_apply ───────────────────────────────────────────────────────
|
|
352
|
+
function applyTool(deps: SelfUpdateDeps): ToolDef {
|
|
353
|
+
return defineTool({
|
|
354
|
+
name: 'self_update_apply',
|
|
355
|
+
description:
|
|
356
|
+
'Finalize a verified self-update transaction: mark it committed and prune old snapshots. The change is already live (loaded during verify); this is the keep-it confirmation. Requires a prior successful self_update_verify.',
|
|
357
|
+
inputSchema: z.object({ txnId: z.string().min(1) }),
|
|
358
|
+
permission: { action: 'prompt' },
|
|
359
|
+
handler: async (input, ctx: ToolContext) => {
|
|
360
|
+
const journal = await readJournal(deps.moxxyDir, input.txnId);
|
|
361
|
+
if (journal.state !== 'verified') {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`transaction ${input.txnId} is "${journal.state}", not "verified" — run self_update_verify first`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
journal.state = 'committed';
|
|
367
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
368
|
+
await emitSafe(deps, ctx, 'apply', { txnId: journal.txnId, target: journal.target });
|
|
369
|
+
await gcTransactions(deps.moxxyDir, deps.maxTxnRetained ?? 5).catch(() => undefined);
|
|
370
|
+
return { ok: true, committed: journal.target };
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── self_update_rollback ────────────────────────────────────────────────────
|
|
376
|
+
function rollbackTool(deps: SelfUpdateDeps): ToolDef {
|
|
377
|
+
return defineTool({
|
|
378
|
+
name: 'self_update_rollback',
|
|
379
|
+
description:
|
|
380
|
+
'Undo a self-update transaction: restore the pre-change snapshot (or delete a newly-created artifact) and hot-reload. Use when a change built and loaded cleanly but behaves wrongly at runtime.',
|
|
381
|
+
inputSchema: z.object({
|
|
382
|
+
txnId: z.string().min(1),
|
|
383
|
+
reason: z.string().optional(),
|
|
384
|
+
}),
|
|
385
|
+
permission: { action: 'allow' },
|
|
386
|
+
handler: async (input, ctx: ToolContext) => {
|
|
387
|
+
const journal = await readJournal(deps.moxxyDir, input.txnId);
|
|
388
|
+
await restoreSnapshot(deps.moxxyDir, journal);
|
|
389
|
+
if (journal.target.kind === 'plugin') {
|
|
390
|
+
await deps.unload(journal.target.name).catch(() => undefined);
|
|
391
|
+
await deps.reload();
|
|
392
|
+
}
|
|
393
|
+
journal.state = 'rolled_back';
|
|
394
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
395
|
+
await emitSafe(deps, ctx, 'rollback', { txnId: journal.txnId, reason: input.reason ?? null });
|
|
396
|
+
return { ok: true, restored: journal.target, existedBefore: journal.existedBefore };
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── self_update_status ──────────────────────────────────────────────────────
|
|
402
|
+
function statusTool(deps: SelfUpdateDeps): ToolDef {
|
|
403
|
+
return defineTool({
|
|
404
|
+
name: 'self_update_status',
|
|
405
|
+
description: 'List self-update transactions and their state (open / verified / committed / rolled_back / escalated).',
|
|
406
|
+
inputSchema: z.object({ txnId: z.string().optional() }),
|
|
407
|
+
permission: { action: 'allow' },
|
|
408
|
+
handler: async (input) => {
|
|
409
|
+
const all = await listTransactions(deps.moxxyDir);
|
|
410
|
+
const rows = (input.txnId ? all.filter((j) => j.txnId === input.txnId) : all).map((j) => ({
|
|
411
|
+
txnId: j.txnId,
|
|
412
|
+
state: j.state,
|
|
413
|
+
target: j.target,
|
|
414
|
+
existedBefore: j.existedBefore,
|
|
415
|
+
failedAttempts: failedAttemptCount(j),
|
|
416
|
+
updatedAt: j.updatedAt,
|
|
417
|
+
}));
|
|
418
|
+
return { transactions: rows };
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function escalate(deps: SelfUpdateDeps, ctx: ToolContext, journal: Journal, reason: string): Promise<void> {
|
|
424
|
+
if (journal.existedBefore && journal.state !== 'rolled_back') {
|
|
425
|
+
await restoreSnapshot(deps.moxxyDir, journal);
|
|
426
|
+
if (journal.target.kind === 'plugin') {
|
|
427
|
+
await deps.unload(journal.target.name).catch(() => undefined);
|
|
428
|
+
await deps.reload();
|
|
429
|
+
}
|
|
430
|
+
} else if (!journal.existedBefore) {
|
|
431
|
+
// Clean up the failed new artifact so it doesn't linger half-built.
|
|
432
|
+
await restoreSnapshot(deps.moxxyDir, journal);
|
|
433
|
+
if (journal.target.kind === 'plugin') await deps.reload();
|
|
434
|
+
}
|
|
435
|
+
journal.state = 'escalated';
|
|
436
|
+
await writeJournal(deps.moxxyDir, journal);
|
|
437
|
+
await emitSafe(deps, ctx, 'escalated', { txnId: journal.txnId, reason });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
441
|
+
export default selfUpdatePlugin;
|
|
@@ -0,0 +1,143 @@
|
|
|
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 {
|
|
6
|
+
beginTransaction,
|
|
7
|
+
diffSnapshot,
|
|
8
|
+
failedAttemptCount,
|
|
9
|
+
gcTransactions,
|
|
10
|
+
listTransactions,
|
|
11
|
+
newTxnId,
|
|
12
|
+
readJournal,
|
|
13
|
+
recordAttempt,
|
|
14
|
+
resolveTarget,
|
|
15
|
+
restoreSnapshot,
|
|
16
|
+
writeJournal,
|
|
17
|
+
} from './transaction.js';
|
|
18
|
+
|
|
19
|
+
const tempDirs: string[] = [];
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all(tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function makeMoxxyDir(): Promise<string> {
|
|
25
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-su-'));
|
|
26
|
+
tempDirs.push(dir);
|
|
27
|
+
await fs.mkdir(path.join(dir, 'plugins'), { recursive: true });
|
|
28
|
+
await fs.mkdir(path.join(dir, 'skills'), { recursive: true });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('resolveTarget', () => {
|
|
33
|
+
it('rejects path-traversal names', () => {
|
|
34
|
+
expect(() => resolveTarget('/m', 'plugin', '../evil')).toThrow(/invalid plugin name/);
|
|
35
|
+
expect(() => resolveTarget('/m', 'skill', 'a/b')).toThrow(/invalid skill name/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('maps kinds to the right paths', () => {
|
|
39
|
+
expect(resolveTarget('/m', 'plugin', 'foo').path).toBe(path.join('/m', 'plugins', 'foo'));
|
|
40
|
+
expect(resolveTarget('/m', 'skill', 'foo').path).toBe(path.join('/m', 'skills', 'foo.md'));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('snapshot / restore round-trip', () => {
|
|
45
|
+
it('restores a modified plugin byte-for-byte', async () => {
|
|
46
|
+
const moxxy = await makeMoxxyDir();
|
|
47
|
+
const dir = path.join(moxxy, 'plugins', 'foo');
|
|
48
|
+
await fs.mkdir(dir, { recursive: true });
|
|
49
|
+
await fs.writeFile(path.join(dir, 'index.mjs'), 'export default 1;\n', 'utf8');
|
|
50
|
+
|
|
51
|
+
const journal = await beginTransaction({ moxxyDir: moxxy, kind: 'plugin', name: 'foo' });
|
|
52
|
+
expect(journal.existedBefore).toBe(true);
|
|
53
|
+
|
|
54
|
+
// Mutate after snapshot, then restore.
|
|
55
|
+
await fs.writeFile(path.join(dir, 'index.mjs'), 'BROKEN\n', 'utf8');
|
|
56
|
+
await fs.writeFile(path.join(dir, 'extra.txt'), 'junk\n', 'utf8');
|
|
57
|
+
await restoreSnapshot(moxxy, journal);
|
|
58
|
+
|
|
59
|
+
expect(await fs.readFile(path.join(dir, 'index.mjs'), 'utf8')).toBe('export default 1;\n');
|
|
60
|
+
await expect(fs.access(path.join(dir, 'extra.txt'))).rejects.toBeTruthy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('excludes node_modules / .git / dist from the snapshot (no full dep-tree copy)', async () => {
|
|
64
|
+
const moxxy = await makeMoxxyDir();
|
|
65
|
+
const dir = path.join(moxxy, 'plugins', 'heavy');
|
|
66
|
+
await fs.mkdir(path.join(dir, 'node_modules', 'left-pad'), { recursive: true });
|
|
67
|
+
await fs.writeFile(path.join(dir, 'node_modules', 'left-pad', 'index.js'), 'module.exports=1\n', 'utf8');
|
|
68
|
+
await fs.mkdir(path.join(dir, '.git'), { recursive: true });
|
|
69
|
+
await fs.writeFile(path.join(dir, '.git', 'HEAD'), 'ref: x\n', 'utf8');
|
|
70
|
+
await fs.mkdir(path.join(dir, 'dist'), { recursive: true });
|
|
71
|
+
await fs.writeFile(path.join(dir, 'dist', 'out.js'), '// built\n', 'utf8');
|
|
72
|
+
await fs.writeFile(path.join(dir, 'index.mjs'), 'export default 1;\n', 'utf8');
|
|
73
|
+
|
|
74
|
+
const journal = await beginTransaction({ moxxyDir: moxxy, kind: 'plugin', name: 'heavy' });
|
|
75
|
+
const before = path.join(moxxy, 'self-update', 'txns', journal.txnId, 'before');
|
|
76
|
+
// Source files snapshotted, heavy/reproducible dirs skipped.
|
|
77
|
+
expect(await fs.readFile(path.join(before, 'index.mjs'), 'utf8')).toBe('export default 1;\n');
|
|
78
|
+
await expect(fs.access(path.join(before, 'node_modules'))).rejects.toBeTruthy();
|
|
79
|
+
await expect(fs.access(path.join(before, '.git'))).rejects.toBeTruthy();
|
|
80
|
+
await expect(fs.access(path.join(before, 'dist'))).rejects.toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('deletes a newly-created artifact on restore (tombstone)', async () => {
|
|
84
|
+
const moxxy = await makeMoxxyDir();
|
|
85
|
+
const journal = await beginTransaction({ moxxyDir: moxxy, kind: 'plugin', name: 'newp' });
|
|
86
|
+
expect(journal.existedBefore).toBe(false);
|
|
87
|
+
|
|
88
|
+
const dir = path.join(moxxy, 'plugins', 'newp');
|
|
89
|
+
await fs.mkdir(dir, { recursive: true });
|
|
90
|
+
await fs.writeFile(path.join(dir, 'index.mjs'), 'x\n', 'utf8');
|
|
91
|
+
|
|
92
|
+
await restoreSnapshot(moxxy, journal);
|
|
93
|
+
await expect(fs.access(dir)).rejects.toBeTruthy();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('journal persistence', () => {
|
|
98
|
+
it('round-trips and tracks failed attempts', async () => {
|
|
99
|
+
const moxxy = await makeMoxxyDir();
|
|
100
|
+
const journal = await beginTransaction({ moxxyDir: moxxy, kind: 'skill', name: 'note' });
|
|
101
|
+
recordAttempt(journal, { stage: 'parse', ok: false, message: 'boom' });
|
|
102
|
+
journal.state = 'open';
|
|
103
|
+
await writeJournal(moxxy, journal);
|
|
104
|
+
|
|
105
|
+
const again = await readJournal(moxxy, journal.txnId);
|
|
106
|
+
expect(failedAttemptCount(again)).toBe(1);
|
|
107
|
+
expect(again.target.kind).toBe('skill');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('lists newest-first and gc keeps N most recent terminal txns', async () => {
|
|
111
|
+
const moxxy = await makeMoxxyDir();
|
|
112
|
+
for (let i = 0; i < 4; i++) {
|
|
113
|
+
const j = await beginTransaction({ moxxyDir: moxxy, kind: 'plugin', name: `p${i}` });
|
|
114
|
+
j.state = 'committed';
|
|
115
|
+
await writeJournal(moxxy, j);
|
|
116
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
117
|
+
}
|
|
118
|
+
expect((await listTransactions(moxxy)).length).toBe(4);
|
|
119
|
+
await gcTransactions(moxxy, 2);
|
|
120
|
+
expect((await listTransactions(moxxy)).length).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('newTxnId', () => {
|
|
125
|
+
it('is unique even for ids minted at the same timestamp', () => {
|
|
126
|
+
const at = new Date('2026-06-19T12:00:00.000Z');
|
|
127
|
+
const ids = new Set(Array.from({ length: 500 }, () => newTxnId(at)));
|
|
128
|
+
// A second-resolution stamp would collide; the crypto suffix must not.
|
|
129
|
+
expect(ids.size).toBe(500);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('diffSnapshot', () => {
|
|
134
|
+
it('returns only the added names per kind', () => {
|
|
135
|
+
const before = { tools: ['a'], agents: [] };
|
|
136
|
+
const after = { tools: ['a', 'b'], agents: ['x'] };
|
|
137
|
+
expect(diffSnapshot(before, after)).toEqual({ tools: ['b'], agents: ['x'] });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('is empty when nothing was added', () => {
|
|
141
|
+
expect(diffSnapshot({ tools: ['a'] }, { tools: ['a'] })).toEqual({});
|
|
142
|
+
});
|
|
143
|
+
});
|