@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
|
@@ -0,0 +1,542 @@
|
|
|
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 {
|
|
7
|
+
countCorruptCoreTxns,
|
|
8
|
+
coreTxnDir,
|
|
9
|
+
detectCoreInstall,
|
|
10
|
+
detectNewDeps,
|
|
11
|
+
finalizeStagedCoreUpdate,
|
|
12
|
+
findRepoPkgDir,
|
|
13
|
+
gcCoreTxns,
|
|
14
|
+
listCoreTxns,
|
|
15
|
+
overlayPackages,
|
|
16
|
+
provisionWorkspace,
|
|
17
|
+
readCoreJournal,
|
|
18
|
+
reconcileOverlay,
|
|
19
|
+
repoDir,
|
|
20
|
+
restoreOverlay,
|
|
21
|
+
run,
|
|
22
|
+
safeRepoPath,
|
|
23
|
+
shortName,
|
|
24
|
+
writeCoreJournal,
|
|
25
|
+
type CoreInstallInfo,
|
|
26
|
+
type CoreJournal,
|
|
27
|
+
} from './core-update.js';
|
|
28
|
+
|
|
29
|
+
const tempDirs: string[] = [];
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await Promise.all(tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function tmp(): Promise<string> {
|
|
35
|
+
const d = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-core-'));
|
|
36
|
+
tempDirs.push(d);
|
|
37
|
+
return d;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build a fake clone with packages/core and packages/cli. */
|
|
41
|
+
async function fakeRepo(root: string): Promise<void> {
|
|
42
|
+
for (const [dir, name] of [
|
|
43
|
+
['core', '@moxxy/core'],
|
|
44
|
+
['cli', '@moxxy/cli'],
|
|
45
|
+
] as const) {
|
|
46
|
+
const pdir = path.join(root, 'packages', dir);
|
|
47
|
+
await fs.mkdir(path.join(pdir, 'dist'), { recursive: true });
|
|
48
|
+
await fs.writeFile(path.join(pdir, 'package.json'), JSON.stringify({ name }), 'utf8');
|
|
49
|
+
await fs.writeFile(path.join(pdir, 'dist', 'index.js'), `// NEW ${name}\n`, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build a fake live install: node_modules/@moxxy/<short>/dist. */
|
|
54
|
+
async function fakeInstall(root: string): Promise<CoreInstallInfo> {
|
|
55
|
+
const scopeDir = path.join(root, 'node_modules', '@moxxy');
|
|
56
|
+
await fs.mkdir(path.join(scopeDir, 'core', 'dist'), { recursive: true });
|
|
57
|
+
await fs.writeFile(path.join(scopeDir, 'core', 'dist', 'index.js'), '// OLD core\n', 'utf8');
|
|
58
|
+
return { version: '1.0.0', gitHead: 'abc123', scopeDir };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('shortName', () => {
|
|
62
|
+
it('strips the @moxxy scope', () => {
|
|
63
|
+
expect(shortName('@moxxy/core')).toBe('core');
|
|
64
|
+
expect(shortName('plain')).toBe('plain');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('findRepoPkgDir', () => {
|
|
69
|
+
it('locates a package dir by its package.json name', async () => {
|
|
70
|
+
const repo = await tmp();
|
|
71
|
+
await fakeRepo(repo);
|
|
72
|
+
expect(await findRepoPkgDir(repo, '@moxxy/cli')).toBe(path.join(repo, 'packages', 'cli'));
|
|
73
|
+
expect(await findRepoPkgDir(repo, '@moxxy/nope')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('safeRepoPath', () => {
|
|
78
|
+
it('rejects paths escaping the repo', () => {
|
|
79
|
+
expect(() => safeRepoPath('/repo', '../etc/passwd')).toThrow(/escapes/);
|
|
80
|
+
expect(safeRepoPath('/repo', 'packages/core/src/a.ts')).toBe(path.resolve('/repo', 'packages/core/src/a.ts'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('refuses a path that traverses a symlink out of the repo', async () => {
|
|
84
|
+
const repo = await tmp();
|
|
85
|
+
const outside = await tmp();
|
|
86
|
+
await fs.writeFile(path.join(outside, 'secret'), 'top secret\n', 'utf8');
|
|
87
|
+
// A symlinked subdir inside the repo pointing at an external directory.
|
|
88
|
+
await fs.symlink(outside, path.join(repo, 'escape'), 'dir');
|
|
89
|
+
// Textually `escape/secret` stays inside the repo, but it dereferences out.
|
|
90
|
+
expect(() => safeRepoPath(repo, 'escape/secret')).toThrow(/symlink/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('allows a normal file inside a provisioned repo', async () => {
|
|
94
|
+
const repo = await tmp();
|
|
95
|
+
await fs.mkdir(path.join(repo, 'packages', 'core', 'src'), { recursive: true });
|
|
96
|
+
const p = safeRepoPath(repo, 'packages/core/src/a.ts');
|
|
97
|
+
expect(p.endsWith(path.join('packages', 'core', 'src', 'a.ts'))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('accepts an ABSOLUTE in-repo path even when the repo root traverses a symlink', async () => {
|
|
101
|
+
// Regression: when the repo root resolves through a symlink (the norm on
|
|
102
|
+
// macOS, where the tmp dir lives under /var→/private/var, and anywhere
|
|
103
|
+
// $HOME/.moxxy is symlinked), a legitimate absolute path *inside* the repo
|
|
104
|
+
// must not be misread as escaping. The realpath re-check must anchor the
|
|
105
|
+
// already-validated in-repo segments onto realRoot, not re-resolve the raw
|
|
106
|
+
// (possibly absolute) input against it.
|
|
107
|
+
const base = await tmp();
|
|
108
|
+
const realTarget = path.join(base, 'real-repo');
|
|
109
|
+
await fs.mkdir(path.join(realTarget, 'packages', 'core', 'src'), { recursive: true });
|
|
110
|
+
// A symlink to the repo root: the raw path differs from its realpath on
|
|
111
|
+
// every platform, deterministically exercising the symlinked-root case.
|
|
112
|
+
const repo = path.join(base, 'repo-link');
|
|
113
|
+
await fs.symlink(realTarget, repo, 'dir');
|
|
114
|
+
const real = await fs.realpath(repo);
|
|
115
|
+
expect(repo === real).toBe(false);
|
|
116
|
+
|
|
117
|
+
const abs = path.join(repo, 'packages', 'core', 'src', 'a.ts');
|
|
118
|
+
const p = safeRepoPath(repo, abs);
|
|
119
|
+
expect(p).toBe(path.join(real, 'packages', 'core', 'src', 'a.ts'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('still rejects an absolute path OUTSIDE the repo', () => {
|
|
123
|
+
expect(() => safeRepoPath('/repo', '/etc/passwd')).toThrow(/escapes/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('run output cap', () => {
|
|
128
|
+
it('bounds retained output to the trailing window (no unbounded buffer)', async () => {
|
|
129
|
+
// Emit ~4MB; the retained tail must stay at/under the 512KB cap.
|
|
130
|
+
const node = process.execPath;
|
|
131
|
+
const script = "const c='x'.repeat(1024)+'\\n';for(let i=0;i<4096;i++)process.stdout.write(c);";
|
|
132
|
+
const res = await run(node, ['-e', script], process.cwd(), 30_000);
|
|
133
|
+
expect(res.code).toBe(0);
|
|
134
|
+
expect(res.output.length).toBeLessThanOrEqual(512 * 1024);
|
|
135
|
+
// It kept the most recent bytes, not the first.
|
|
136
|
+
expect(res.output.endsWith('x'.repeat(10) + '\n') || res.output.endsWith('x\n')).toBe(true);
|
|
137
|
+
}, 30_000);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('overlay / restore', () => {
|
|
141
|
+
it('overlays new dist and snapshots the old, then restores', async () => {
|
|
142
|
+
const repo = await tmp();
|
|
143
|
+
await fakeRepo(repo);
|
|
144
|
+
const installRoot = await tmp();
|
|
145
|
+
const install = await fakeInstall(installRoot);
|
|
146
|
+
const snapshotDir = path.join(await tmp(), 'snap');
|
|
147
|
+
|
|
148
|
+
const res = await overlayPackages({ repo, install, pkgNames: ['@moxxy/core'], snapshotDir });
|
|
149
|
+
expect(res.ok).toBe(true);
|
|
150
|
+
const liveDist = path.join(install.scopeDir, 'core', 'dist', 'index.js');
|
|
151
|
+
expect(await fs.readFile(liveDist, 'utf8')).toBe('// NEW @moxxy/core\n');
|
|
152
|
+
// applied marker written
|
|
153
|
+
expect(JSON.parse(await fs.readFile(path.join(snapshotDir, 'applied.json'), 'utf8')).packages).toEqual(['@moxxy/core']);
|
|
154
|
+
|
|
155
|
+
await restoreOverlay({ install, pkgNames: ['@moxxy/core'], snapshotDir });
|
|
156
|
+
expect(await fs.readFile(liveDist, 'utf8')).toBe('// OLD core\n');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('fails cleanly when a package is missing from the clone', async () => {
|
|
160
|
+
const repo = await tmp();
|
|
161
|
+
await fakeRepo(repo);
|
|
162
|
+
const install = await fakeInstall(await tmp());
|
|
163
|
+
const res = await overlayPackages({
|
|
164
|
+
repo,
|
|
165
|
+
install,
|
|
166
|
+
pkgNames: ['@moxxy/does-not-exist'],
|
|
167
|
+
snapshotDir: path.join(await tmp(), 'snap'),
|
|
168
|
+
});
|
|
169
|
+
expect(res.ok).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('clears the pending intent marker after a clean multi-package overlay', async () => {
|
|
173
|
+
const repo = await tmp();
|
|
174
|
+
await fakeRepo(repo);
|
|
175
|
+
const install = await fakeInstall(await tmp());
|
|
176
|
+
const snap = path.join(await tmp(), 'snap');
|
|
177
|
+
const res = await overlayPackages({ repo, install, pkgNames: ['@moxxy/core', '@moxxy/cli'], snapshotDir: snap });
|
|
178
|
+
expect(res.ok).toBe(true);
|
|
179
|
+
// pending.json is removed once applied.json lands; reconcile is a no-op.
|
|
180
|
+
await expect(fs.access(path.join(snap, 'pending.json'))).rejects.toBeTruthy();
|
|
181
|
+
expect((await reconcileOverlay({ install, pkgNames: ['@moxxy/core', '@moxxy/cli'], snapshotDir: snap })).reconciled).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('reconcileOverlay restores from snapshot when an overlay was interrupted mid-swap', async () => {
|
|
185
|
+
const repo = await tmp();
|
|
186
|
+
await fakeRepo(repo);
|
|
187
|
+
const install = await fakeInstall(await tmp());
|
|
188
|
+
const snap = path.join(await tmp(), 'snap');
|
|
189
|
+
|
|
190
|
+
// Simulate a crash AFTER snapshotting + writing the intent but BEFORE applied.json:
|
|
191
|
+
// @moxxy/core's live dist already swapped to NEW, @moxxy/cli still OLD.
|
|
192
|
+
await fs.mkdir(path.join(snap, 'core'), { recursive: true });
|
|
193
|
+
await fs.writeFile(path.join(snap, 'core', 'index.js'), '// OLD core\n', 'utf8'); // rollback snapshot
|
|
194
|
+
await fs.writeFile(path.join(install.scopeDir, 'core', 'dist', 'index.js'), '// NEW @moxxy/core\n', 'utf8'); // already swapped
|
|
195
|
+
await fs.writeFile(path.join(snap, 'pending.json'), JSON.stringify({ packages: ['@moxxy/core'] }), 'utf8');
|
|
196
|
+
|
|
197
|
+
const res = await reconcileOverlay({ install, pkgNames: ['@moxxy/core'], snapshotDir: snap });
|
|
198
|
+
expect(res.reconciled).toBe(true);
|
|
199
|
+
// Live dist is back to the pre-overlay snapshot — no mixed core.
|
|
200
|
+
expect(await fs.readFile(path.join(install.scopeDir, 'core', 'dist', 'index.js'), 'utf8')).toBe('// OLD core\n');
|
|
201
|
+
await expect(fs.access(path.join(snap, 'pending.json'))).rejects.toBeTruthy();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('finalizeStagedCoreUpdate (overlay validation)', () => {
|
|
206
|
+
it('rolls back instead of committing when the overlay never recorded a full apply', async () => {
|
|
207
|
+
const moxxy = await tmp();
|
|
208
|
+
const install = await fakeInstall(await tmp());
|
|
209
|
+
const txnId = 'core-incomplete';
|
|
210
|
+
await writeCoreJournal(moxxy, {
|
|
211
|
+
txnId,
|
|
212
|
+
createdAt: new Date().toISOString(),
|
|
213
|
+
updatedAt: new Date().toISOString(),
|
|
214
|
+
packages: ['@moxxy/core'],
|
|
215
|
+
version: '1.0.0',
|
|
216
|
+
gitHead: 'abc',
|
|
217
|
+
repoDir: '/repo',
|
|
218
|
+
state: 'staged_restart',
|
|
219
|
+
attempts: [],
|
|
220
|
+
});
|
|
221
|
+
// Snapshot exists with a rollback dist, but NO applied.json → inconsistent.
|
|
222
|
+
const snap = path.join(coreTxnDir(moxxy, txnId), 'snapshot');
|
|
223
|
+
await fs.mkdir(path.join(snap, 'core'), { recursive: true });
|
|
224
|
+
await fs.writeFile(path.join(snap, 'core', 'index.js'), '// OLD core\n', 'utf8');
|
|
225
|
+
|
|
226
|
+
const committed = await finalizeStagedCoreUpdate(moxxy, install);
|
|
227
|
+
expect(committed).toEqual([]); // not committed
|
|
228
|
+
expect((await readCoreJournal(moxxy, txnId)).state).toBe('rolled_back');
|
|
229
|
+
// Live dist restored from the snapshot.
|
|
230
|
+
expect(await fs.readFile(path.join(install.scopeDir, 'core', 'dist', 'index.js'), 'utf8')).toBe('// OLD core\n');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('commits when applied.json records the full package set', async () => {
|
|
234
|
+
const moxxy = await tmp();
|
|
235
|
+
const install = await fakeInstall(await tmp());
|
|
236
|
+
const txnId = 'core-clean';
|
|
237
|
+
await writeCoreJournal(moxxy, {
|
|
238
|
+
txnId,
|
|
239
|
+
createdAt: new Date().toISOString(),
|
|
240
|
+
updatedAt: new Date().toISOString(),
|
|
241
|
+
packages: ['@moxxy/core'],
|
|
242
|
+
version: '1.0.0',
|
|
243
|
+
gitHead: 'abc',
|
|
244
|
+
repoDir: '/repo',
|
|
245
|
+
state: 'staged_restart',
|
|
246
|
+
attempts: [],
|
|
247
|
+
});
|
|
248
|
+
const snap = path.join(coreTxnDir(moxxy, txnId), 'snapshot');
|
|
249
|
+
await fs.mkdir(snap, { recursive: true });
|
|
250
|
+
await fs.writeFile(path.join(snap, 'applied.json'), JSON.stringify({ packages: ['@moxxy/core'] }), 'utf8');
|
|
251
|
+
|
|
252
|
+
const committed = await finalizeStagedCoreUpdate(moxxy, install);
|
|
253
|
+
expect(committed).toEqual([txnId]);
|
|
254
|
+
expect((await readCoreJournal(moxxy, txnId)).state).toBe('committed');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('without install it stays best-effort and commits any staged txn', async () => {
|
|
258
|
+
const moxxy = await tmp();
|
|
259
|
+
const txnId = 'core-besteffort';
|
|
260
|
+
await writeCoreJournal(moxxy, {
|
|
261
|
+
txnId,
|
|
262
|
+
createdAt: new Date().toISOString(),
|
|
263
|
+
updatedAt: new Date().toISOString(),
|
|
264
|
+
packages: ['@moxxy/core'],
|
|
265
|
+
version: '1.0.0',
|
|
266
|
+
repoDir: '/repo',
|
|
267
|
+
state: 'staged_restart',
|
|
268
|
+
attempts: [],
|
|
269
|
+
});
|
|
270
|
+
expect(await finalizeStagedCoreUpdate(moxxy)).toEqual([txnId]);
|
|
271
|
+
expect((await readCoreJournal(moxxy, txnId)).state).toBe('committed');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('countCorruptCoreTxns', () => {
|
|
276
|
+
it('counts a present-but-unparseable journal and ignores a missing one', async () => {
|
|
277
|
+
const moxxy = await tmp();
|
|
278
|
+
// A valid txn.
|
|
279
|
+
await writeCoreJournal(moxxy, {
|
|
280
|
+
txnId: 'ok',
|
|
281
|
+
createdAt: new Date().toISOString(),
|
|
282
|
+
updatedAt: new Date().toISOString(),
|
|
283
|
+
packages: ['@moxxy/core'],
|
|
284
|
+
version: '1.0.0',
|
|
285
|
+
repoDir: '/repo',
|
|
286
|
+
state: 'provisioned',
|
|
287
|
+
attempts: [],
|
|
288
|
+
});
|
|
289
|
+
// A corrupt journal.
|
|
290
|
+
const badDir = coreTxnDir(moxxy, 'bad');
|
|
291
|
+
await fs.mkdir(badDir, { recursive: true });
|
|
292
|
+
await fs.writeFile(path.join(badDir, 'journal.json'), '{ not json', 'utf8');
|
|
293
|
+
// A dir with no journal yet (half-created begin) — must NOT count.
|
|
294
|
+
await fs.mkdir(coreTxnDir(moxxy, 'half'), { recursive: true });
|
|
295
|
+
|
|
296
|
+
expect(await countCorruptCoreTxns(moxxy)).toBe(1);
|
|
297
|
+
// listCoreTxns silently drops the corrupt one (the guard's blind spot).
|
|
298
|
+
expect((await listCoreTxns(moxxy)).map((j) => j.txnId)).toEqual(['ok']);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('detectNewDeps', () => {
|
|
303
|
+
it('flags a runtime dep absent from the live install, ignoring workspace + present deps', async () => {
|
|
304
|
+
const repo = await tmp();
|
|
305
|
+
await fakeRepo(repo);
|
|
306
|
+
// Give @moxxy/core deps in the clone.
|
|
307
|
+
await fs.writeFile(
|
|
308
|
+
path.join(repo, 'packages', 'core', 'package.json'),
|
|
309
|
+
JSON.stringify({
|
|
310
|
+
name: '@moxxy/core',
|
|
311
|
+
dependencies: { present: '^1', missing: '^2', '@moxxy/sdk': 'workspace:*' },
|
|
312
|
+
}),
|
|
313
|
+
'utf8',
|
|
314
|
+
);
|
|
315
|
+
const installRoot = await tmp();
|
|
316
|
+
const install = await fakeInstall(installRoot);
|
|
317
|
+
await fs.mkdir(path.join(installRoot, 'node_modules', 'present'), { recursive: true });
|
|
318
|
+
|
|
319
|
+
const news = await detectNewDeps(repo, install, ['@moxxy/core']);
|
|
320
|
+
expect(news).toEqual(['missing']);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('detectCoreInstall', () => {
|
|
325
|
+
/** Write a @moxxy/core/package.json at `scopeDir/core`. */
|
|
326
|
+
async function writeCorePkg(
|
|
327
|
+
scopeDir: string,
|
|
328
|
+
pkg: Record<string, unknown>,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
await fs.mkdir(path.join(scopeDir, 'core'), { recursive: true });
|
|
331
|
+
await fs.writeFile(path.join(scopeDir, 'core', 'package.json'), JSON.stringify(pkg), 'utf8');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
it('resolves the global-install layout (a parent IS the @moxxy scope dir)', async () => {
|
|
335
|
+
// node_modules/@moxxy/<pkg>/dist/x.js → the scope dir is an ancestor named @moxxy.
|
|
336
|
+
const root = await tmp();
|
|
337
|
+
const scopeDir = path.join(root, 'node_modules', '@moxxy');
|
|
338
|
+
await writeCorePkg(scopeDir, {
|
|
339
|
+
version: '2.3.4',
|
|
340
|
+
gitHead: 'deadbeef',
|
|
341
|
+
repository: { url: 'git+https://github.com/acme/moxxy.git' },
|
|
342
|
+
});
|
|
343
|
+
const fromUrl = pathToFileURL(path.join(scopeDir, 'cli', 'dist', 'index.js')).href;
|
|
344
|
+
|
|
345
|
+
const info = detectCoreInstall(fromUrl);
|
|
346
|
+
expect(info).not.toBeNull();
|
|
347
|
+
expect(info?.scopeDir).toBe(scopeDir);
|
|
348
|
+
expect(info?.version).toBe('2.3.4');
|
|
349
|
+
expect(info?.gitHead).toBe('deadbeef');
|
|
350
|
+
// git+https:// is normalized to https://
|
|
351
|
+
expect(info?.repoUrl).toBe('https://github.com/acme/moxxy.git');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('resolves the workspace layout (an ancestor has node_modules/@moxxy)', async () => {
|
|
355
|
+
const root = await tmp();
|
|
356
|
+
const scopeDir = path.join(root, 'node_modules', '@moxxy');
|
|
357
|
+
await writeCorePkg(scopeDir, { version: '0.1.0', repository: 'git://example.com/x.git' });
|
|
358
|
+
// Caller lives deep in the workspace, not under node_modules.
|
|
359
|
+
const fromUrl = pathToFileURL(path.join(root, 'apps', 'cli', 'dist', 'main.js')).href;
|
|
360
|
+
|
|
361
|
+
const info = detectCoreInstall(fromUrl);
|
|
362
|
+
expect(info?.scopeDir).toBe(scopeDir);
|
|
363
|
+
expect(info?.version).toBe('0.1.0');
|
|
364
|
+
expect(info?.gitHead).toBeUndefined();
|
|
365
|
+
// string `repository` + git:// normalization
|
|
366
|
+
expect(info?.repoUrl).toBe('https://example.com/x.git');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('returns null when no @moxxy/core can be located', async () => {
|
|
370
|
+
const root = await tmp();
|
|
371
|
+
const fromUrl = pathToFileURL(path.join(root, 'nowhere', 'index.js')).href;
|
|
372
|
+
expect(detectCoreInstall(fromUrl)).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('returns null on a corrupt core/package.json', async () => {
|
|
376
|
+
const root = await tmp();
|
|
377
|
+
const scopeDir = path.join(root, 'node_modules', '@moxxy');
|
|
378
|
+
await fs.mkdir(path.join(scopeDir, 'core'), { recursive: true });
|
|
379
|
+
await fs.writeFile(path.join(scopeDir, 'core', 'package.json'), '{ not json', 'utf8');
|
|
380
|
+
const fromUrl = pathToFileURL(path.join(root, 'index.js')).href;
|
|
381
|
+
expect(detectCoreInstall(fromUrl)).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('defaults version to 0.0.0 when package.json omits it', async () => {
|
|
385
|
+
const root = await tmp();
|
|
386
|
+
const scopeDir = path.join(root, 'node_modules', '@moxxy');
|
|
387
|
+
await writeCorePkg(scopeDir, {});
|
|
388
|
+
const fromUrl = pathToFileURL(path.join(root, 'index.js')).href;
|
|
389
|
+
expect(detectCoreInstall(fromUrl)?.version).toBe('0.0.0');
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
describe('provisionWorkspace HEAD pin', () => {
|
|
394
|
+
/** Init a throwaway git repo with one commit; return its dir + commit sha. */
|
|
395
|
+
async function gitFixture(): Promise<{ dir: string; head: string }> {
|
|
396
|
+
const dir = await tmp();
|
|
397
|
+
await run('git', ['init', '-q'], dir);
|
|
398
|
+
await run('git', ['config', 'user.email', 't@t'], dir);
|
|
399
|
+
await run('git', ['config', 'user.name', 't'], dir);
|
|
400
|
+
await fs.writeFile(path.join(dir, 'README.md'), 'hi\n', 'utf8');
|
|
401
|
+
await run('git', ['add', '.'], dir);
|
|
402
|
+
await run('git', ['commit', '-qm', 'init'], dir);
|
|
403
|
+
const head = (await run('git', ['rev-parse', 'HEAD'], dir)).output.trim();
|
|
404
|
+
return { dir, head };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Seed an existing clone at moxxyDir/repo from a local source repo. */
|
|
408
|
+
async function seedClone(moxxyDir: string, srcDir: string): Promise<void> {
|
|
409
|
+
const target = repoDir(moxxyDir);
|
|
410
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
411
|
+
const clone = await run('git', ['clone', '-q', srcDir, target], path.dirname(target));
|
|
412
|
+
expect(clone.code).toBe(0);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
it('rejects with a source-mismatch message when the installed gitHead is not the clone HEAD', async () => {
|
|
416
|
+
const { dir: src } = await gitFixture();
|
|
417
|
+
const moxxy = await tmp();
|
|
418
|
+
await seedClone(moxxy, src);
|
|
419
|
+
|
|
420
|
+
const install: CoreInstallInfo = {
|
|
421
|
+
version: '1.0.0',
|
|
422
|
+
gitHead: '0000000000000000000000000000000000000000', // a commit the clone can't reach
|
|
423
|
+
repoUrl: src,
|
|
424
|
+
scopeDir: path.join(moxxy, 'node_modules', '@moxxy'),
|
|
425
|
+
};
|
|
426
|
+
const res = await provisionWorkspace({ moxxyDir: moxxy, install, repoUrlOverride: src });
|
|
427
|
+
expect(res.ok).toBe(false);
|
|
428
|
+
expect(res.message).toContain('source mismatch');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('rejects an abbreviated/unresolvable gitHead rather than prefix-matching a different commit', async () => {
|
|
432
|
+
const { dir: src } = await gitFixture();
|
|
433
|
+
const moxxy = await tmp();
|
|
434
|
+
await seedClone(moxxy, src);
|
|
435
|
+
const install: CoreInstallInfo = {
|
|
436
|
+
version: '1.0.0',
|
|
437
|
+
gitHead: 'abc1234', // short, not a full 40-hex sha → must not satisfy the exact pin
|
|
438
|
+
repoUrl: src,
|
|
439
|
+
scopeDir: path.join(moxxy, 'node_modules', '@moxxy'),
|
|
440
|
+
};
|
|
441
|
+
const res = await provisionWorkspace({ moxxyDir: moxxy, install, repoUrlOverride: src });
|
|
442
|
+
expect(res.ok).toBe(false);
|
|
443
|
+
expect(res.message).toContain('source mismatch');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('fails fast with no gitHead to pin to', async () => {
|
|
447
|
+
const moxxy = await tmp();
|
|
448
|
+
const install: CoreInstallInfo = {
|
|
449
|
+
version: '1.0.0',
|
|
450
|
+
repoUrl: 'https://example.com/x.git',
|
|
451
|
+
scopeDir: path.join(moxxy, 'node_modules', '@moxxy'),
|
|
452
|
+
};
|
|
453
|
+
const res = await provisionWorkspace({ moxxyDir: moxxy, install });
|
|
454
|
+
expect(res.ok).toBe(false);
|
|
455
|
+
expect(res.message).toContain('gitHead');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('fails fast with no repository url', async () => {
|
|
459
|
+
const moxxy = await tmp();
|
|
460
|
+
const install: CoreInstallInfo = {
|
|
461
|
+
version: '1.0.0',
|
|
462
|
+
gitHead: 'abc',
|
|
463
|
+
scopeDir: path.join(moxxy, 'node_modules', '@moxxy'),
|
|
464
|
+
};
|
|
465
|
+
const res = await provisionWorkspace({ moxxyDir: moxxy, install });
|
|
466
|
+
expect(res.ok).toBe(false);
|
|
467
|
+
expect(res.message).toContain('repository url');
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('core journal', () => {
|
|
472
|
+
it('writes, reads and lists', async () => {
|
|
473
|
+
const moxxy = await tmp();
|
|
474
|
+
const j: CoreJournal = {
|
|
475
|
+
txnId: 'core-1',
|
|
476
|
+
createdAt: new Date().toISOString(),
|
|
477
|
+
updatedAt: new Date().toISOString(),
|
|
478
|
+
packages: ['@moxxy/core'],
|
|
479
|
+
version: '1.0.0',
|
|
480
|
+
gitHead: 'abc',
|
|
481
|
+
repoDir: '/repo',
|
|
482
|
+
state: 'provisioned',
|
|
483
|
+
attempts: [],
|
|
484
|
+
};
|
|
485
|
+
await writeCoreJournal(moxxy, j);
|
|
486
|
+
expect((await readCoreJournal(moxxy, 'core-1')).state).toBe('provisioned');
|
|
487
|
+
expect((await listCoreTxns(moxxy)).length).toBe(1);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe('gcCoreTxns', () => {
|
|
492
|
+
/** Write a core journal with a given id/state and a snapshot file. */
|
|
493
|
+
async function seedTxn(moxxy: string, id: string, state: CoreJournal['state']): Promise<void> {
|
|
494
|
+
await writeCoreJournal(moxxy, {
|
|
495
|
+
txnId: id,
|
|
496
|
+
createdAt: new Date(2026, 0, parseInt(id.replace(/\D/g, ''), 10) || 1).toISOString(),
|
|
497
|
+
updatedAt: new Date().toISOString(),
|
|
498
|
+
packages: ['@moxxy/core'],
|
|
499
|
+
version: '1.0.0',
|
|
500
|
+
repoDir: '/repo',
|
|
501
|
+
state,
|
|
502
|
+
attempts: [],
|
|
503
|
+
});
|
|
504
|
+
// A dist snapshot — the thing GC must reclaim.
|
|
505
|
+
const snap = path.join(coreTxnDir(moxxy, id), 'snapshot', 'core');
|
|
506
|
+
await fs.mkdir(snap, { recursive: true });
|
|
507
|
+
await fs.writeFile(path.join(snap, 'index.js'), '// snapshot\n', 'utf8');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
it('prunes old terminal txns (snapshot dirs) but keeps non-terminal ones', async () => {
|
|
511
|
+
const moxxy = await tmp();
|
|
512
|
+
await seedTxn(moxxy, 'core-1', 'committed');
|
|
513
|
+
await seedTxn(moxxy, 'core-2', 'rolled_back');
|
|
514
|
+
await seedTxn(moxxy, 'core-3', 'committed');
|
|
515
|
+
// A non-terminal txn whose snapshot finalize/rollback may still need.
|
|
516
|
+
await seedTxn(moxxy, 'core-9', 'staged_restart');
|
|
517
|
+
|
|
518
|
+
await gcCoreTxns(moxxy, 1);
|
|
519
|
+
|
|
520
|
+
const remaining = (await listCoreTxns(moxxy)).map((j) => j.txnId).sort();
|
|
521
|
+
// keep=1 terminal (the newest, core-3) + the non-terminal staged one survives.
|
|
522
|
+
expect(remaining).toEqual(['core-3', 'core-9']);
|
|
523
|
+
// The pruned txn's snapshot is gone from disk.
|
|
524
|
+
await expect(fs.access(coreTxnDir(moxxy, 'core-1'))).rejects.toBeTruthy();
|
|
525
|
+
await expect(fs.access(coreTxnDir(moxxy, 'core-9'))).resolves.toBeUndefined();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('finalizeStagedCoreUpdate prunes terminal history after committing', async () => {
|
|
529
|
+
const moxxy = await tmp();
|
|
530
|
+
// Two already-committed (terminal) txns + one staged that will commit now.
|
|
531
|
+
await seedTxn(moxxy, 'core-1', 'committed');
|
|
532
|
+
await seedTxn(moxxy, 'core-2', 'committed');
|
|
533
|
+
await seedTxn(moxxy, 'core-3', 'staged_restart');
|
|
534
|
+
|
|
535
|
+
// keepTerminal=1 → after committing core-3, only the newest terminal survives.
|
|
536
|
+
await finalizeStagedCoreUpdate(moxxy, null, 1);
|
|
537
|
+
|
|
538
|
+
const ids = (await listCoreTxns(moxxy)).map((j) => j.txnId);
|
|
539
|
+
expect(ids).toContain('core-3'); // just committed, newest
|
|
540
|
+
expect(ids.length).toBe(1);
|
|
541
|
+
});
|
|
542
|
+
});
|