@deeeed/metamask-harness 0.28.0 → 0.29.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/CHANGELOG.md +19 -0
- package/README.md +41 -0
- package/adapters/extension/build-lavamoat.sh +2 -1
- package/adapters/extension/ensure-browser.sh +82 -9
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +83 -1
- package/adapters/extension/lib/chrome-args.cjs +325 -1
- package/adapters/extension/lib/playwright-cdp.cjs +34 -0
- package/adapters/extension/lib/slot-title.cjs +2 -4
- package/adapters/extension/lib/validation-launch-supervisor.cjs +292 -0
- package/adapters/extension/lib/validation-process-ownership.cjs +69 -0
- package/adapters/extension/reattach.sh +2 -1
- package/adapters/extension/sidepanel-toggle.sh +14 -96
- package/adapters/extension/wallet-fixture-state.cjs +8 -31
- package/adapters/manifest.json +16 -0
- package/adapters/shared/private-atomic-write.cjs +47 -0
- package/adapters/shared/setup-base.sh +864 -0
- package/dist/adapters/extension/runtime.js +367 -24
- package/dist/adapters/extension/validation-process-ownership.js +10 -0
- package/dist/cli-commands.js +1 -0
- package/dist/command-contract.js +12 -0
- package/dist/commands/launch/extension.js +130 -19
- package/dist/commands/setup-base.js +24 -0
- package/dist/mm-harness-cli.js +28 -2
- package/library/actions/extension/analytics/consent.mjs +203 -0
- package/library/actions/extension/analytics/set_consent.mjs +19 -143
- package/library/actions/extension/perps/perps.mjs +2 -16
- package/library/actions/extension/perps/state.mjs +20 -0
- package/library/actions/extension/wallet/list_accounts.mjs +3 -25
- package/library/actions/extension/wallet/read_state.mjs +3 -23
- package/library/actions/extension/wallet/select_account.mjs +6 -33
- package/library/actions/extension/wallet/setup.mjs +2 -20
- package/library/actions/extension/wallet/state.mjs +111 -0
- package/library/recipes/runner/action-validation.extension.recipe.json +1 -1
- package/library/recipes/runner/action-validation.mobile.recipe.json +1 -1
- package/package.json +7 -4
- package/scripts/site-contrast.mjs +538 -0
- package/site/architecture.html +415 -0
- package/site/assets/progress.mjs +272 -0
- package/site/assets/style.css +808 -0
- package/site/cheatsheet.html +305 -0
- package/site/index.html +643 -0
- package/site/recipes.html +396 -0
- package/site/reviewers.html +374 -0
- package/site/tutorials/index.html +180 -0
- package/site/tutorials/v1.html +211 -0
- package/site/tutorials/v2.html +207 -0
- package/site/tutorials/v3.html +214 -0
- package/site/tutorials/v4.html +195 -0
- package/site/tutorials/v5.html +163 -0
- package/site/tutorials/v6.html +165 -0
- package/site/tutorials/v7.html +184 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
function commandHasExactProfile(command, profile) {
|
|
6
|
+
const expected = `--user-data-dir=${profile}`;
|
|
7
|
+
let offset = command.indexOf(expected);
|
|
8
|
+
while (offset !== -1) {
|
|
9
|
+
const before = offset === 0 ? '' : command[offset - 1];
|
|
10
|
+
const after = command[offset + expected.length] || '';
|
|
11
|
+
if ((!before || /\s/u.test(before)) && (!after || /\s/u.test(after))) return true;
|
|
12
|
+
offset = command.indexOf(expected, offset + 1);
|
|
13
|
+
}
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function profileProcessPids(profile) {
|
|
18
|
+
const output = execFileSync('ps', ['-ww', '-axo', 'pid=,command='], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
21
|
+
});
|
|
22
|
+
const pids = [];
|
|
23
|
+
for (const line of output.split('\n')) {
|
|
24
|
+
const match = line.match(/^\s*(\d+)\s+(.*)$/u);
|
|
25
|
+
if (!match || !commandHasExactProfile(match[2], profile)) continue;
|
|
26
|
+
const pid = Number(match[1]);
|
|
27
|
+
if (pid !== process.pid && pid !== process.ppid) pids.push(pid);
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(pids)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function signalPids(pids, signal) {
|
|
33
|
+
for (const pid of pids) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, signal);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code !== 'ESRCH') throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function delay(milliseconds) {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 } = {}) {
|
|
47
|
+
const deadline = Date.now() + timeoutMs;
|
|
48
|
+
let quietSince = Date.now();
|
|
49
|
+
let ownersSince = 0;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
const pids = profileProcessPids(profile);
|
|
52
|
+
if (pids.length === 0) {
|
|
53
|
+
ownersSince = 0;
|
|
54
|
+
if (Date.now() - quietSince >= quietMs) return;
|
|
55
|
+
} else {
|
|
56
|
+
if (ownersSince === 0) ownersSince = Date.now();
|
|
57
|
+
quietSince = Date.now();
|
|
58
|
+
signalPids(pids, Date.now() - ownersSince >= 500 ? 'SIGKILL' : 'SIGTERM');
|
|
59
|
+
}
|
|
60
|
+
await delay(50);
|
|
61
|
+
}
|
|
62
|
+
const remaining = profileProcessPids(profile);
|
|
63
|
+
if (remaining.length > 0) {
|
|
64
|
+
throw new Error(`Extension validation profile processes survived cleanup: ${remaining.join(', ')}.`);
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Extension validation profile did not remain quiescent for ${quietMs}ms.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { profileProcessPids, stopProfileProcesses };
|
|
@@ -126,6 +126,7 @@ const fs = require('node:fs');
|
|
|
126
126
|
const path = require('node:path');
|
|
127
127
|
const { extensionIdFromManifestFile } = require(path.join(process.env.SCRIPT_DIR, 'lib/extension-id.cjs'));
|
|
128
128
|
const { readSlotId, applyPersistentSlotTitle } = require(path.join(process.env.SCRIPT_DIR, 'lib/slot-title.cjs'));
|
|
129
|
+
const { evaluatePageViaCdp } = require(path.join(process.env.SCRIPT_DIR, 'lib/playwright-cdp.cjs'));
|
|
129
130
|
|
|
130
131
|
const port = process.env.CDP_PORT;
|
|
131
132
|
const target = process.env.TARGET;
|
|
@@ -198,7 +199,7 @@ async function connect() {
|
|
|
198
199
|
|
|
199
200
|
async function stampSlotTitle(page) {
|
|
200
201
|
if (!slotId || !page) return;
|
|
201
|
-
await page
|
|
202
|
+
await evaluatePageViaCdp(page, applyPersistentSlotTitle, slotId).catch(() => {
|
|
202
203
|
// Best-effort operator affordance; reload success is verified by CDP state.
|
|
203
204
|
});
|
|
204
205
|
}
|
|
@@ -239,7 +239,7 @@ open_sidepanel() {
|
|
|
239
239
|
exit 4
|
|
240
240
|
fi
|
|
241
241
|
|
|
242
|
-
CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" node <<'NODE'
|
|
242
|
+
CDP_PORT="$CDP_PORT" EXT_ID="$ext_id" SCRIPT_DIR="$SCRIPT_DIR" node <<'NODE'
|
|
243
243
|
const { chromium } = require('playwright');
|
|
244
244
|
|
|
245
245
|
(async () => {
|
|
@@ -247,105 +247,23 @@ const { chromium } = require('playwright');
|
|
|
247
247
|
const extId = process.env.EXT_ID;
|
|
248
248
|
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
|
249
249
|
const context = browser.contexts()[0];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
.
|
|
253
|
-
(candidate) =>
|
|
254
|
-
candidate.url().startsWith(`chrome-extension://${extId}/`) &&
|
|
255
|
-
!candidate.url().includes('/sidepanel.html'),
|
|
256
|
-
);
|
|
257
|
-
|
|
258
|
-
if (!page) {
|
|
259
|
-
page = await context.newPage();
|
|
260
|
-
await page.goto(`chrome-extension://${extId}/home.html`, {
|
|
250
|
+
const page = await context.newPage();
|
|
251
|
+
try {
|
|
252
|
+
await page.goto(`chrome-extension://${extId}/popup-init.html`, {
|
|
261
253
|
waitUntil: 'domcontentloaded',
|
|
262
254
|
timeout: 15000,
|
|
263
255
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
|
|
267
|
-
await page.bringToFront();
|
|
268
|
-
}
|
|
269
|
-
const result = await page.evaluate(async () => {
|
|
270
|
-
const currentWindow = await chrome.windows.getCurrent();
|
|
271
|
-
const id = '__recipe_open_sidepanel__';
|
|
272
|
-
document.getElementById(id)?.remove();
|
|
273
|
-
const button = document.createElement('button');
|
|
274
|
-
button.id = id;
|
|
275
|
-
button.textContent = 'open sidepanel';
|
|
276
|
-
button.style.cssText =
|
|
277
|
-
'position:fixed;left:8px;top:8px;z-index:2147483647';
|
|
278
|
-
button.onclick = async () => {
|
|
279
|
-
try {
|
|
280
|
-
await chrome.sidePanel.open({ windowId: currentWindow.id });
|
|
281
|
-
button.dataset.result = `ok:${currentWindow.id}`;
|
|
282
|
-
} catch (error) {
|
|
283
|
-
button.dataset.result = `error:${
|
|
284
|
-
error && error.message ? error.message : String(error)
|
|
285
|
-
}`;
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
document.documentElement.appendChild(button);
|
|
289
|
-
return { windowId: currentWindow.id };
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// This harness-owned button exists only to provide Chrome's trusted user
|
|
293
|
-
// gesture. Its visual layout is irrelevant and can be occluded by the app's
|
|
294
|
-
// full-screen loading surface, so do not wait on Playwright actionability.
|
|
295
|
-
await page.locator('#__recipe_open_sidepanel__').click({ timeout: 5000, force: true });
|
|
296
|
-
const clickResult = await page
|
|
297
|
-
.locator('#__recipe_open_sidepanel__')
|
|
298
|
-
.evaluate((button) => button.dataset.result || '');
|
|
299
|
-
if (!clickResult.startsWith('ok:')) {
|
|
300
|
-
throw new Error(
|
|
301
|
-
`chrome.sidePanel.open failed for window ${result.windowId}: ${clickResult}`,
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
await page.evaluate(() => {
|
|
305
|
-
document.getElementById('__recipe_open_sidepanel__')?.remove();
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
const deadline = Date.now() + 5000;
|
|
309
|
-
let sidepanelPage;
|
|
310
|
-
while (Date.now() < deadline) {
|
|
311
|
-
sidepanelPage = context
|
|
312
|
-
.pages()
|
|
313
|
-
.find((candidate) => candidate.url().startsWith(`chrome-extension://${extId}/`) && candidate.url().includes('/sidepanel.html'));
|
|
314
|
-
if (sidepanelPage) break;
|
|
315
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Only collapse fullscreen extension tabs after Chrome exposes the sidepanel
|
|
319
|
-
// target. chrome.sidePanel.open can return before the panel page appears; if we
|
|
320
|
-
// close home first, a slow/failed panel leaves the operator with no visible
|
|
321
|
-
// wallet page and the next readiness probe has to reopen from scratch.
|
|
322
|
-
if (sidepanelPage) {
|
|
323
|
-
for (const candidate of context.pages()) {
|
|
324
|
-
if (
|
|
325
|
-
candidate.url().startsWith(`chrome-extension://${extId}/`) &&
|
|
326
|
-
!candidate.url().includes('/sidepanel.html')
|
|
327
|
-
) {
|
|
328
|
-
try {
|
|
329
|
-
await candidate.close();
|
|
330
|
-
} catch (error) {
|
|
331
|
-
console.warn(
|
|
332
|
-
`[sidepanel] extension page close failed after successful open: ${
|
|
333
|
-
error && error.message ? error.message : error
|
|
334
|
-
}`,
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
const dappPage = context
|
|
341
|
-
.pages()
|
|
342
|
-
.find((candidate) => candidate.url().startsWith('http://') || candidate.url().startsWith('https://'));
|
|
343
|
-
if (dappPage && process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
|
|
344
|
-
try {
|
|
345
|
-
await dappPage.bringToFront();
|
|
346
|
-
} catch {
|
|
347
|
-
// focus is cosmetic; the panel is already open
|
|
256
|
+
if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') {
|
|
257
|
+
await page.bringToFront();
|
|
348
258
|
}
|
|
259
|
+
const menu = page.locator('[data-testid="account-options-menu-button"]');
|
|
260
|
+
await menu.waitFor({ state: 'visible', timeout: 15000 });
|
|
261
|
+
await menu.click({ timeout: 5000 });
|
|
262
|
+
const toggle = page.locator('[data-testid="global-menu-toggle-view"]');
|
|
263
|
+
await toggle.waitFor({ state: 'visible', timeout: 10000 });
|
|
264
|
+
await toggle.click({ timeout: 5000 });
|
|
265
|
+
} finally {
|
|
266
|
+
if (!page.isClosed()) await page.close();
|
|
349
267
|
}
|
|
350
268
|
if (typeof browser.disconnect === 'function') {
|
|
351
269
|
await browser.disconnect();
|
|
@@ -21,6 +21,7 @@ const fs = require('node:fs');
|
|
|
21
21
|
const http = require('node:http');
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
const { extensionIdFromManifestKey } = require('./lib/extension-id.cjs');
|
|
24
|
+
const { evaluatePageViaCdp } = require('./lib/playwright-cdp.cjs');
|
|
24
25
|
|
|
25
26
|
const EOA_METHODS = [
|
|
26
27
|
'personal_sign',
|
|
@@ -708,7 +709,7 @@ async function waitForWalletScreen(page) {
|
|
|
708
709
|
const unlockSelector = '[data-testid="unlock-password"]';
|
|
709
710
|
const deadline = Date.now() + 45000;
|
|
710
711
|
while (Date.now() < deadline) {
|
|
711
|
-
const fatalStartup = await
|
|
712
|
+
const fatalStartup = await evaluatePageViaCdp(page, () => {
|
|
712
713
|
const text = document.body?.innerText || '';
|
|
713
714
|
return text.includes('MetaMask had trouble starting') ? text.slice(0, 500) : null;
|
|
714
715
|
})
|
|
@@ -732,36 +733,12 @@ async function waitForWalletScreen(page) {
|
|
|
732
733
|
return { state: 'unknown', selector: null };
|
|
733
734
|
}
|
|
734
735
|
|
|
735
|
-
async function evaluateViaCdp(page, callback, argument) {
|
|
736
|
-
const session = await page.context().newCDPSession(page);
|
|
737
|
-
const invocation = argument === undefined
|
|
738
|
-
? `(${callback.toString()})()`
|
|
739
|
-
: `(${callback.toString()})(${JSON.stringify(argument)})`;
|
|
740
|
-
try {
|
|
741
|
-
const response = await session.send('Runtime.evaluate', {
|
|
742
|
-
expression: invocation,
|
|
743
|
-
awaitPromise: true,
|
|
744
|
-
returnByValue: true,
|
|
745
|
-
});
|
|
746
|
-
if (response.exceptionDetails) {
|
|
747
|
-
const detail =
|
|
748
|
-
response.exceptionDetails.exception?.description ??
|
|
749
|
-
response.exceptionDetails.text ??
|
|
750
|
-
'unknown evaluation failure';
|
|
751
|
-
throw new Error(`CDP evaluation failed: ${detail}`);
|
|
752
|
-
}
|
|
753
|
-
return response.result?.value;
|
|
754
|
-
} finally {
|
|
755
|
-
await session.detach().catch(() => {});
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
736
|
async function attemptUnlock(page, password) {
|
|
760
737
|
// Selector ladder: current testids first, then the generic selectors the CDP
|
|
761
738
|
// unlock action uses (proven against the same build). A freshly seeded vault
|
|
762
739
|
// can also still be initializing, so a single attempt is not conclusive —
|
|
763
740
|
// the caller retries.
|
|
764
|
-
const filled = await
|
|
741
|
+
const filled = await evaluatePageViaCdp(page, (pw) => {
|
|
765
742
|
const input =
|
|
766
743
|
document.querySelector('[data-testid="unlock-password"]') ??
|
|
767
744
|
document.querySelector('input[type="password"]');
|
|
@@ -776,7 +753,7 @@ async function attemptUnlock(page, password) {
|
|
|
776
753
|
return true;
|
|
777
754
|
}, password);
|
|
778
755
|
if (!filled) return false;
|
|
779
|
-
return
|
|
756
|
+
return evaluatePageViaCdp(page, () => {
|
|
780
757
|
const button =
|
|
781
758
|
document.querySelector('[data-testid="unlock-submit"]') ??
|
|
782
759
|
document.querySelector('button[type="submit"]') ??
|
|
@@ -807,7 +784,7 @@ async function unlockIfNeeded(page, password) {
|
|
|
807
784
|
}
|
|
808
785
|
|
|
809
786
|
async function readLiveAccounts(page) {
|
|
810
|
-
const raw = await
|
|
787
|
+
const raw = await evaluatePageViaCdp(page, async () => {
|
|
811
788
|
const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
|
|
812
789
|
const accts = metamask.internalAccounts || {};
|
|
813
790
|
const byId = accts.accounts || {};
|
|
@@ -931,7 +908,7 @@ async function applyAccountNames(page, expectedAccounts) {
|
|
|
931
908
|
if (!account.name || !account.address.startsWith('0x')) {
|
|
932
909
|
continue;
|
|
933
910
|
}
|
|
934
|
-
await
|
|
911
|
+
await evaluatePageViaCdp(
|
|
935
912
|
page,
|
|
936
913
|
async ({ address, name }) => {
|
|
937
914
|
const metamask = (await window.stateHooks?.getCleanAppState?.())?.metamask || {};
|
|
@@ -964,7 +941,7 @@ async function applySelectedAccount(page, expectedSelected) {
|
|
|
964
941
|
if (!expectedSelected?.address) {
|
|
965
942
|
return;
|
|
966
943
|
}
|
|
967
|
-
await
|
|
944
|
+
await evaluatePageViaCdp(
|
|
968
945
|
page,
|
|
969
946
|
async ({ address }) => {
|
|
970
947
|
const accounts =
|
|
@@ -1039,7 +1016,7 @@ async function seedCdp(args) {
|
|
|
1039
1016
|
}
|
|
1040
1017
|
|
|
1041
1018
|
try {
|
|
1042
|
-
await
|
|
1019
|
+
await evaluatePageViaCdp(page, async (state) => {
|
|
1043
1020
|
await chrome.storage.local.set(state);
|
|
1044
1021
|
}, versionedState);
|
|
1045
1022
|
} catch (error) {
|
package/adapters/manifest.json
CHANGED
|
@@ -534,6 +534,22 @@
|
|
|
534
534
|
"purpose": "Detached, silent registry probe used by the daily passive update notice.",
|
|
535
535
|
"inputs": "<cache-file> <timeout-ms> [registry-endpoint]",
|
|
536
536
|
"outputs": "atomic update-cache refresh; always silent and best-effort"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
"id": "lib/private-atomic-write",
|
|
540
|
+
"entry": "adapters/shared/private-atomic-write.cjs",
|
|
541
|
+
"kind": "lib",
|
|
542
|
+
"purpose": "Publish private setup state through a no-follow temporary file and atomic rename.",
|
|
543
|
+
"inputs": "target path and file bytes on stdin",
|
|
544
|
+
"outputs": "owner-only target file"
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
"id": "lib/setup-base",
|
|
548
|
+
"entry": "adapters/shared/setup-base.sh",
|
|
549
|
+
"kind": "lib",
|
|
550
|
+
"purpose": "Bootstrap numbered MetaMask checkouts through the setup-base command.",
|
|
551
|
+
"inputs": "setup-base command arguments",
|
|
552
|
+
"outputs": "numbered checkouts, saved preferences, and run summary"
|
|
537
553
|
}
|
|
538
554
|
]
|
|
539
555
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const target = path.resolve(process.argv[2] || '');
|
|
6
|
+
if (!process.argv[2]) {
|
|
7
|
+
process.stderr.write('private-atomic-write: target path is required\n');
|
|
8
|
+
process.exit(2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let bytes = '';
|
|
12
|
+
process.stdin.setEncoding('utf8');
|
|
13
|
+
process.stdin.on('data', (chunk) => {
|
|
14
|
+
bytes += chunk;
|
|
15
|
+
});
|
|
16
|
+
process.stdin.on('end', () => {
|
|
17
|
+
const parent = path.dirname(target);
|
|
18
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
19
|
+
|
|
20
|
+
const temporary = path.join(
|
|
21
|
+
parent,
|
|
22
|
+
`.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`,
|
|
23
|
+
);
|
|
24
|
+
let descriptor;
|
|
25
|
+
try {
|
|
26
|
+
descriptor = fs.openSync(
|
|
27
|
+
temporary,
|
|
28
|
+
fs.constants.O_CREAT |
|
|
29
|
+
fs.constants.O_EXCL |
|
|
30
|
+
fs.constants.O_WRONLY |
|
|
31
|
+
(fs.constants.O_NOFOLLOW || 0),
|
|
32
|
+
0o600,
|
|
33
|
+
);
|
|
34
|
+
fs.writeFileSync(descriptor, bytes);
|
|
35
|
+
fs.fsyncSync(descriptor);
|
|
36
|
+
fs.closeSync(descriptor);
|
|
37
|
+
descriptor = undefined;
|
|
38
|
+
fs.renameSync(temporary, target);
|
|
39
|
+
} finally {
|
|
40
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(temporary);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== 'ENOENT') throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|