@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/deps.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { SessionId, ToolContext, TurnId } from '@moxxy/sdk';
|
|
4
|
+
import type { RegistrySnapshot } from './transaction.js';
|
|
5
|
+
import { detectCoreInstall, type CoreInstallInfo } from './core-update.js';
|
|
6
|
+
|
|
7
|
+
export const PLUGIN_ID = '@moxxy/plugin-self-update';
|
|
8
|
+
|
|
9
|
+
export interface SkipInfo {
|
|
10
|
+
readonly pluginName: string;
|
|
11
|
+
readonly packageName?: string;
|
|
12
|
+
readonly message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SelfUpdateEmit {
|
|
16
|
+
readonly subtype: string;
|
|
17
|
+
readonly payload: unknown;
|
|
18
|
+
readonly sessionId: SessionId;
|
|
19
|
+
readonly turnId: TurnId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SelfUpdateDeps {
|
|
23
|
+
/** Base user dir, normally `~/.moxxy`. Overridable for tests. */
|
|
24
|
+
readonly moxxyDir: string;
|
|
25
|
+
/** Hot-reload the plugin host (rescans user plugin dirs). */
|
|
26
|
+
readonly reload: () => Promise<void>;
|
|
27
|
+
/** Unload a plugin by name so a modified version re-imports fresh on reload. */
|
|
28
|
+
readonly unload: (name: string) => Promise<void>;
|
|
29
|
+
/** Current registered contribution names per kind. */
|
|
30
|
+
readonly snapshot: () => RegistrySnapshot;
|
|
31
|
+
/** Plugins the host failed to load (so we can surface a load error). */
|
|
32
|
+
readonly skipped: () => ReadonlyArray<SkipInfo>;
|
|
33
|
+
/** Append a `plugin_event` audit record. Best-effort. */
|
|
34
|
+
readonly emit: (e: SelfUpdateEmit) => Promise<void>;
|
|
35
|
+
/** How many terminal transactions to keep on GC. Default 5. */
|
|
36
|
+
readonly maxTxnRetained?: number;
|
|
37
|
+
/** Tier-2 core-patching config. Omit or set enabled:false to hide core tools. */
|
|
38
|
+
readonly coreUpdate?: {
|
|
39
|
+
/** Enable the Tier-2 core-update tools. Default true. */
|
|
40
|
+
readonly enabled?: boolean;
|
|
41
|
+
/** Module URL used to resolve the live @moxxy/core install. Default import.meta.url. */
|
|
42
|
+
readonly fromUrl?: string;
|
|
43
|
+
/** Override the git repository URL (else read from @moxxy/core package.json). */
|
|
44
|
+
readonly repoUrlOverride?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readJsonName(dir: string): Promise<string | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8')) as {
|
|
51
|
+
name?: string;
|
|
52
|
+
};
|
|
53
|
+
return pkg.name;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function findSkip(
|
|
60
|
+
skipped: ReadonlyArray<SkipInfo>,
|
|
61
|
+
names: ReadonlyArray<string>,
|
|
62
|
+
): SkipInfo | undefined {
|
|
63
|
+
const want = new Set(names.filter(Boolean));
|
|
64
|
+
return skipped.find((s) => want.has(s.pluginName) || (s.packageName ? want.has(s.packageName) : false));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function emitSafe(
|
|
68
|
+
deps: SelfUpdateDeps,
|
|
69
|
+
ctx: ToolContext,
|
|
70
|
+
subtype: string,
|
|
71
|
+
payload: unknown,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
await deps
|
|
74
|
+
.emit({ subtype, payload, sessionId: ctx.sessionId, turnId: ctx.turnId })
|
|
75
|
+
.catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The fromUrl default is provided by the caller — `detectCoreInstall` resolves
|
|
80
|
+
* the live `@moxxy/core` install relative to that module URL.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveCoreInstall(deps: SelfUpdateDeps, fromUrlDefault: string): CoreInstallInfo | null {
|
|
83
|
+
return detectCoreInstall(deps.coreUpdate?.fromUrl ?? fromUrlDefault);
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { ServiceRegistry } from '@moxxy/sdk';
|
|
3
|
+
import { selfUpdatePlugin } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The discovery-loadable default export resolves pluginHost + registrySnapshot +
|
|
7
|
+
* the writable appendEvent + its config options from the service registry in
|
|
8
|
+
* onInit, instead of the host SelfUpdateDeps closure.
|
|
9
|
+
*/
|
|
10
|
+
describe('selfUpdatePlugin (discovery-loadable)', () => {
|
|
11
|
+
it('exposes the self_update tools + an onInit hook', () => {
|
|
12
|
+
const names = selfUpdatePlugin.tools?.map((t) => t.name) ?? [];
|
|
13
|
+
expect(names).toContain('self_update_classify');
|
|
14
|
+
expect(names).toContain('self_update_begin');
|
|
15
|
+
expect(typeof selfUpdatePlugin.hooks?.onInit).toBe('function');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('onInit resolves pluginHost/registrySnapshot/appendEvent/getPluginOptions', () => {
|
|
19
|
+
const get = vi.fn((name: string) => {
|
|
20
|
+
switch (name) {
|
|
21
|
+
case 'pluginHost':
|
|
22
|
+
return { reload: async () => {}, unload: async () => {}, listSkipped: () => [] };
|
|
23
|
+
case 'registrySnapshot':
|
|
24
|
+
return () => ({});
|
|
25
|
+
case 'appendEvent':
|
|
26
|
+
return async () => {};
|
|
27
|
+
case 'getPluginOptions':
|
|
28
|
+
return () => ({});
|
|
29
|
+
default:
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
const services = { get, register: () => {}, require: () => undefined, has: () => true } as unknown as ServiceRegistry;
|
|
34
|
+
selfUpdatePlugin.hooks!.onInit!({ services } as never);
|
|
35
|
+
expect(get).toHaveBeenCalledWith('pluginHost');
|
|
36
|
+
expect(get).toHaveBeenCalledWith('registrySnapshot');
|
|
37
|
+
expect(get).toHaveBeenCalledWith('appendEvent');
|
|
38
|
+
expect(get).toHaveBeenCalledWith('getPluginOptions');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { asSessionId, asTurnId, type ToolContext, type ToolDef } from '@moxxy/sdk';
|
|
7
|
+
import { buildSelfUpdatePlugin, type SelfUpdateDeps, type SkipInfo } from './index.js';
|
|
8
|
+
import { readJournal } from './transaction.js';
|
|
9
|
+
import { writeCoreJournal } from './core-update.js';
|
|
10
|
+
|
|
11
|
+
const tempDirs: string[] = [];
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await Promise.all(tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* In-memory stand-in for the plugin host: a reload rescans the on-disk plugin
|
|
18
|
+
* dirs and registers a `<name>_tool` for each loadable entry, or records a skip
|
|
19
|
+
* for one whose entry contains the marker `BROKEN`.
|
|
20
|
+
*/
|
|
21
|
+
class FakeHost {
|
|
22
|
+
tools = new Set<string>();
|
|
23
|
+
skips: SkipInfo[] = [];
|
|
24
|
+
reloads = 0;
|
|
25
|
+
constructor(readonly moxxyDir: string) {}
|
|
26
|
+
|
|
27
|
+
reload = async (): Promise<void> => {
|
|
28
|
+
this.reloads++;
|
|
29
|
+
this.tools = new Set();
|
|
30
|
+
this.skips = [];
|
|
31
|
+
const pluginsDir = path.join(this.moxxyDir, 'plugins');
|
|
32
|
+
const names = await fs.readdir(pluginsDir).catch(() => [] as string[]);
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const content = await fs
|
|
35
|
+
.readFile(path.join(pluginsDir, name, 'index.mjs'), 'utf8')
|
|
36
|
+
.catch(() => null);
|
|
37
|
+
if (content == null) continue;
|
|
38
|
+
if (content.includes('BROKEN')) this.skips.push({ pluginName: name, message: 'SyntaxError: broken entry' });
|
|
39
|
+
else this.tools.add(`${name}_tool`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
unload = async (name: string): Promise<void> => {
|
|
44
|
+
this.tools.delete(`${name}_tool`);
|
|
45
|
+
this.skips = this.skips.filter((s) => s.pluginName !== name);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
deps(): SelfUpdateDeps {
|
|
49
|
+
return {
|
|
50
|
+
moxxyDir: this.moxxyDir,
|
|
51
|
+
reload: this.reload,
|
|
52
|
+
unload: this.unload,
|
|
53
|
+
snapshot: () => ({
|
|
54
|
+
tools: [...this.tools],
|
|
55
|
+
agents: [],
|
|
56
|
+
providers: [],
|
|
57
|
+
modes: [],
|
|
58
|
+
compactors: [],
|
|
59
|
+
channels: [],
|
|
60
|
+
}),
|
|
61
|
+
skipped: () => this.skips,
|
|
62
|
+
emit: async () => undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeCtx(): ToolContext {
|
|
68
|
+
return {
|
|
69
|
+
sessionId: asSessionId('s'),
|
|
70
|
+
turnId: asTurnId('t'),
|
|
71
|
+
callId: 't:0' as never,
|
|
72
|
+
cwd: '/tmp',
|
|
73
|
+
signal: new AbortController().signal,
|
|
74
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
75
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
76
|
+
} as unknown as ToolContext;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function makeMoxxyDir(): Promise<string> {
|
|
80
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-su-int-'));
|
|
81
|
+
tempDirs.push(dir);
|
|
82
|
+
await fs.mkdir(path.join(dir, 'plugins'), { recursive: true });
|
|
83
|
+
return dir;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tools(deps: SelfUpdateDeps): Record<string, ToolDef> {
|
|
87
|
+
const plugin = buildSelfUpdatePlugin(deps);
|
|
88
|
+
const out: Record<string, ToolDef> = {};
|
|
89
|
+
for (const t of plugin.tools ?? []) out[t.name] = t;
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writePlugin(moxxy: string, name: string, content: string): Promise<void> {
|
|
94
|
+
const dir = path.join(moxxy, 'plugins', name);
|
|
95
|
+
await fs.mkdir(dir, { recursive: true });
|
|
96
|
+
await fs.writeFile(path.join(dir, 'index.mjs'), content, 'utf8');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('self-update happy path', () => {
|
|
100
|
+
it('begins, verifies a new plugin, and applies', async () => {
|
|
101
|
+
const moxxy = await makeMoxxyDir();
|
|
102
|
+
const host = new FakeHost(moxxy);
|
|
103
|
+
const t = tools(host.deps());
|
|
104
|
+
const ctx = makeCtx();
|
|
105
|
+
|
|
106
|
+
const begun = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'greeter' }, ctx)) as {
|
|
107
|
+
txnId: string;
|
|
108
|
+
existedBefore: boolean;
|
|
109
|
+
};
|
|
110
|
+
expect(begun.existedBefore).toBe(false);
|
|
111
|
+
|
|
112
|
+
await writePlugin(moxxy, 'greeter', 'export default { name: "greeter" };\n');
|
|
113
|
+
|
|
114
|
+
const verified = (await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx)) as {
|
|
115
|
+
ok: boolean;
|
|
116
|
+
registered: Record<string, string[]>;
|
|
117
|
+
};
|
|
118
|
+
expect(verified.ok).toBe(true);
|
|
119
|
+
expect(verified.registered.tools).toEqual(['greeter_tool']);
|
|
120
|
+
|
|
121
|
+
await t.self_update_apply!.handler({ txnId: begun.txnId }, ctx);
|
|
122
|
+
expect((await readJournal(moxxy, begun.txnId)).state).toBe('committed');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('failed build / load auto-rollback + escalation', () => {
|
|
127
|
+
it('fails a broken new plugin, leaves files for retry, escalates after 2 cycles', async () => {
|
|
128
|
+
const moxxy = await makeMoxxyDir();
|
|
129
|
+
const host = new FakeHost(moxxy);
|
|
130
|
+
const t = tools(host.deps());
|
|
131
|
+
const ctx = makeCtx();
|
|
132
|
+
|
|
133
|
+
const begun = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'oops' }, ctx)) as {
|
|
134
|
+
txnId: string;
|
|
135
|
+
};
|
|
136
|
+
await writePlugin(moxxy, 'oops', 'BROKEN syntax (((\n');
|
|
137
|
+
|
|
138
|
+
const first = (await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx)) as {
|
|
139
|
+
ok: boolean;
|
|
140
|
+
escalate: boolean;
|
|
141
|
+
};
|
|
142
|
+
expect(first.ok).toBe(false);
|
|
143
|
+
expect(first.escalate).toBe(false);
|
|
144
|
+
// New artifact is left in place so the model can fix it and retry.
|
|
145
|
+
await expect(fs.access(path.join(moxxy, 'plugins', 'oops'))).resolves.toBeUndefined();
|
|
146
|
+
|
|
147
|
+
const second = (await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx)) as { ok: boolean };
|
|
148
|
+
expect(second.ok).toBe(false);
|
|
149
|
+
|
|
150
|
+
// Third call hits the cap → escalate + clean up.
|
|
151
|
+
const third = (await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx)) as {
|
|
152
|
+
ok: boolean;
|
|
153
|
+
escalate: boolean;
|
|
154
|
+
};
|
|
155
|
+
expect(third.escalate).toBe(true);
|
|
156
|
+
expect((await readJournal(moxxy, begun.txnId)).state).toBe('escalated');
|
|
157
|
+
await expect(fs.access(path.join(moxxy, 'plugins', 'oops'))).rejects.toBeTruthy();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('does not restore + reload twice on the capping MODIFY cycle', async () => {
|
|
161
|
+
// u106-5: when a MODIFY verify both fails AND hits the attempt cap, the
|
|
162
|
+
// snapshot restore + plugin reload must run ONCE, not be repeated by
|
|
163
|
+
// escalate().
|
|
164
|
+
const moxxy = await makeMoxxyDir();
|
|
165
|
+
const host = new FakeHost(moxxy);
|
|
166
|
+
const t = tools(host.deps());
|
|
167
|
+
const ctx = makeCtx();
|
|
168
|
+
|
|
169
|
+
await writePlugin(moxxy, 'mod', 'export default { name: "mod" };\n');
|
|
170
|
+
const begun = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'mod' }, ctx)) as {
|
|
171
|
+
txnId: string;
|
|
172
|
+
existedBefore: boolean;
|
|
173
|
+
};
|
|
174
|
+
expect(begun.existedBefore).toBe(true);
|
|
175
|
+
|
|
176
|
+
// Break it, then verify three times. Each failure restores the good entry,
|
|
177
|
+
// so subsequent verifies still re-break it first.
|
|
178
|
+
const breakAndVerify = async (): Promise<number> => {
|
|
179
|
+
await writePlugin(moxxy, 'mod', 'BROKEN now\n');
|
|
180
|
+
const before = host.reloads;
|
|
181
|
+
await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx);
|
|
182
|
+
return host.reloads - before;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Cycle 1 (below the cap) reloads twice: once for the load-check inside
|
|
186
|
+
// verifyPlugin, once for the snapshot restore on failure.
|
|
187
|
+
expect(await breakAndVerify()).toBe(2);
|
|
188
|
+
// Cycle 2 records the 2nd failure → hits the cap → escalates. Pre-fix this
|
|
189
|
+
// ran THREE reloads (load-check + verify-restore + escalate-restore);
|
|
190
|
+
// post-fix escalate() skips its restore (journal marked rolled_back), so it
|
|
191
|
+
// reloads exactly twice.
|
|
192
|
+
expect(await breakAndVerify()).toBe(2);
|
|
193
|
+
expect((await readJournal(moxxy, begun.txnId)).state).toBe('escalated');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('restores the previous working version when a MODIFY fails to load', async () => {
|
|
197
|
+
const moxxy = await makeMoxxyDir();
|
|
198
|
+
const host = new FakeHost(moxxy);
|
|
199
|
+
const t = tools(host.deps());
|
|
200
|
+
const ctx = makeCtx();
|
|
201
|
+
|
|
202
|
+
const good = 'export default { name: "bar" };\n';
|
|
203
|
+
await writePlugin(moxxy, 'bar', good);
|
|
204
|
+
|
|
205
|
+
const begun = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'bar' }, ctx)) as {
|
|
206
|
+
txnId: string;
|
|
207
|
+
existedBefore: boolean;
|
|
208
|
+
};
|
|
209
|
+
expect(begun.existedBefore).toBe(true);
|
|
210
|
+
|
|
211
|
+
await writePlugin(moxxy, 'bar', 'BROKEN now\n');
|
|
212
|
+
const res = (await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx)) as {
|
|
213
|
+
ok: boolean;
|
|
214
|
+
recovered: boolean;
|
|
215
|
+
};
|
|
216
|
+
expect(res.ok).toBe(false);
|
|
217
|
+
expect(res.recovered).toBe(true);
|
|
218
|
+
// Previous working entry is back, and it's loadable again.
|
|
219
|
+
expect(await fs.readFile(path.join(moxxy, 'plugins', 'bar', 'index.mjs'), 'utf8')).toBe(good);
|
|
220
|
+
expect(host.tools.has('bar_tool')).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('tier-1 serialization', () => {
|
|
225
|
+
it('refuses a second begin while a non-terminal txn for the same target is open', async () => {
|
|
226
|
+
const moxxy = await makeMoxxyDir();
|
|
227
|
+
const host = new FakeHost(moxxy);
|
|
228
|
+
const t = tools(host.deps());
|
|
229
|
+
const ctx = makeCtx();
|
|
230
|
+
|
|
231
|
+
const first = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'dup' }, ctx)) as {
|
|
232
|
+
txnId: string;
|
|
233
|
+
};
|
|
234
|
+
// Second begin on the SAME target must refuse (state is still 'open').
|
|
235
|
+
await expect(t.self_update_begin!.handler({ kind: 'plugin', name: 'dup' }, ctx)).rejects.toThrow(
|
|
236
|
+
/already in progress/,
|
|
237
|
+
);
|
|
238
|
+
// A different target is unaffected.
|
|
239
|
+
await expect(t.self_update_begin!.handler({ kind: 'plugin', name: 'other' }, ctx)).resolves.toBeTruthy();
|
|
240
|
+
|
|
241
|
+
// After the first is rolled back, a fresh begin on the same target is allowed.
|
|
242
|
+
await t.self_update_rollback!.handler({ txnId: first.txnId }, ctx);
|
|
243
|
+
await expect(t.self_update_begin!.handler({ kind: 'plugin', name: 'dup' }, ctx)).resolves.toBeTruthy();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('rollback', () => {
|
|
248
|
+
it('deletes a committed new plugin and reloads', async () => {
|
|
249
|
+
const moxxy = await makeMoxxyDir();
|
|
250
|
+
const host = new FakeHost(moxxy);
|
|
251
|
+
const t = tools(host.deps());
|
|
252
|
+
const ctx = makeCtx();
|
|
253
|
+
|
|
254
|
+
const begun = (await t.self_update_begin!.handler({ kind: 'plugin', name: 'temp' }, ctx)) as {
|
|
255
|
+
txnId: string;
|
|
256
|
+
};
|
|
257
|
+
await writePlugin(moxxy, 'temp', 'export default { name: "temp" };\n');
|
|
258
|
+
await t.self_update_verify!.handler({ txnId: begun.txnId }, ctx);
|
|
259
|
+
expect(host.tools.has('temp_tool')).toBe(true);
|
|
260
|
+
|
|
261
|
+
await t.self_update_rollback!.handler({ txnId: begun.txnId, reason: 'not needed' }, ctx);
|
|
262
|
+
await expect(fs.access(path.join(moxxy, 'plugins', 'temp'))).rejects.toBeTruthy();
|
|
263
|
+
expect(host.tools.has('temp_tool')).toBe(false);
|
|
264
|
+
expect((await readJournal(moxxy, begun.txnId)).state).toBe('rolled_back');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('serializes core txns: begin refuses while another core txn is in flight', async () => {
|
|
268
|
+
const moxxy = await makeMoxxyDir();
|
|
269
|
+
// Fake a resolvable @moxxy/core install so begin gets past resolveCoreInstall
|
|
270
|
+
// and reaches the serialization guard (before any git clone).
|
|
271
|
+
const coreDir = path.join(moxxy, 'node_modules', '@moxxy', 'core');
|
|
272
|
+
await fs.mkdir(coreDir, { recursive: true });
|
|
273
|
+
await fs.writeFile(
|
|
274
|
+
path.join(coreDir, 'package.json'),
|
|
275
|
+
JSON.stringify({ version: '1.0.0', gitHead: 'abc123' }),
|
|
276
|
+
'utf8',
|
|
277
|
+
);
|
|
278
|
+
const fromUrl = pathToFileURL(path.join(moxxy, 'caller.js')).href;
|
|
279
|
+
|
|
280
|
+
// An active (provisioned = non-terminal) core txn already exists on disk.
|
|
281
|
+
await writeCoreJournal(moxxy, {
|
|
282
|
+
txnId: 'CTXN_ACTIVE',
|
|
283
|
+
createdAt: new Date().toISOString(),
|
|
284
|
+
updatedAt: new Date().toISOString(),
|
|
285
|
+
packages: ['@moxxy/core'],
|
|
286
|
+
version: '1.0.0',
|
|
287
|
+
gitHead: 'abc123',
|
|
288
|
+
repoDir: path.join(moxxy, 'self-update', 'repo'),
|
|
289
|
+
state: 'provisioned',
|
|
290
|
+
attempts: [],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const deps: SelfUpdateDeps = { ...new FakeHost(moxxy).deps(), coreUpdate: { fromUrl } };
|
|
294
|
+
const begin = tools(deps)['self_update_core_begin']!;
|
|
295
|
+
await expect(begin.handler({ packages: ['@moxxy/core'] }, makeCtx())).rejects.toThrow(
|
|
296
|
+
/already in progress/,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
});
|