// Routes "/ADMIN/CONFIG/*", "/ADMIN/WARP/CONFIG", "/ADMIN/PLAYLISTS/*" (+ quelques correctifs players / comptes) // de backend/src/server/Admin.js, appelées par un faux socket. Configuration.js, Database.js et WarpProxy.js sont les // vrais modules, mais TOUS les chemins de __glob qui pointent vers backend/data sont redirigés vers un dossier // temporaire (config.json synthétique, jamais backend/data). Aucun appel réseau (Spotify simulé, WARP jamais activé). const Module = require('module'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { BACKEND, SCRATCH, __glob, isolateData, check, done, req } = require('./_setup'); // --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module --- const DATA_DIR = path.join(BACKEND, 'data'); const tmp = fs.mkdtempSync(path.join(SCRATCH, 'admin-config-')); for (const [key, value] of Object.entries(__glob)) { if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) { __glob[key] = path.join(tmp, path.relative(DATA_DIR, value)); } } __glob.DATA = tmp; isolateData('admin-config-yt'); const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase())); if (!safePaths) { console.error('Chemins non isolés : arrêt'); process.exit(1); } process.env.YTDLP_AUTO_UPDATE = 'false'; delete process.env.WARP_PROXY; delete process.env.WARP_FALLBACK_DIRECT; // Secrets SYNTHÉTIQUES : aucun ne doit apparaître dans une réponse, une poussée ou un log const SECRETS = { token: 'FAKETOKEN.abcdefghijklmnop.qrstuvwxyz0123', client_secret: 'FAKECLIENTSECRET_9876543210', youtube: 'FAKEYTSECRET_aaaabbbbcccc', spotify: 'FAKESPOTIFYSECRET_ddddeeee', refresh: 'FAKEREFRESHTOKEN_ffffgggg', proxyPass: 'FAKEPROXYPASS_hhhh' }; fs.writeFileSync(path.join(tmp, 'config.json'), JSON.stringify({ token: SECRETS.token, client_secret: SECRETS.client_secret, report: { channel: '123456789012345678', contact: '' }, api: { youtube: { clientId: 'yt-client-id.apps.googleusercontent.com', clientSecret: SECRETS.youtube }, spotify: { clientId: 'spotifyclientid', clientSecret: SECRETS.spotify } }, website: 'https://chopin.example.org', server_port: 5000, media: { guildId: '', channelId: '' } }, null, 2)); const SRC = path.join(BACKEND, 'src'); const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, ''); // --- Stubs --- const admin = { identity: { id: '111111', username: 'testadmin' }, labels: ['ADMIN'], isAdmin: () => true, isFullBanned: () => false }; const owner = { identity: { id: '333333', username: 'proprio', global_name: 'Propriétaire', avatar: null }, labels: [], isAdmin: () => false, isFullBanned: () => false }; const guilds = new Map([['222222', { id: '222222', name: 'Serveur Test' }]]); // Player simulé : loop / shuffle comptés pour vérifier l'idempotence const playerCalls = { setLoop: 0, setShuffle: 0 }; const fakePlayer = { guildId: '222222', loop: true, queue: { shuffle: false, clearNext() {} }, isConnected: () => true, async setLoop() { playerCalls.setLoop++; this.loop = !this.loop; }, async setShuffle() { playerCalls.setShuffle++; this.queue.shuffle = !this.queue.shuffle; }, getState() { return { guildId: '222222', status: 'playing', loop: this.loop, shuffle: this.queue.shuffle, current: { id: 'song-b', title: 'B' }, lastError: { code: 'SOURCE_FAILED', message: 'x', songId: 'song-a', at: Date.now() }, next: [], previous: [] }; } }; const AllPlayers = new Map([['222222', fakePlayer]]); const removed = []; const stubs = { [norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === '111111' ? admin : id === '333333' ? owner : null, getUsers: () => [admin, owner] }, [norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 }, [norm(SRC + '/player/Player')]: { getAllPlayers: () => [...AllPlayers.values()], AllPlayers }, [norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false, getChannel: () => null }, [norm(SRC + '/discord/ServerSettings')]: {}, [norm(SRC + '/discord/MediaBase')]: {}, [norm(SRC + '/playlists/PlaylistManager')]: { removePlaylist(userId, playlistId) { const list = getDatabase('Playlists').data[userId] || []; const index = list.findIndex(p => String(p.playlistId) === String(playlistId)); if (index === -1) return false; list.splice(index, 1); removed.push({ userId, playlistId }); return true; } }, [norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 }, [norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false }, [norm(SRC + '/utils/Maintenance')]: {}, [norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() }, }; // Spotify simulé : refus avec le secret recopié dans le message (il doit être masqué) let spotifyMode = 'reject'; class FakeSpotify { constructor({ clientId, clientSecret }) { this.clientId = clientId; this.clientSecret = clientSecret; } async clientCredentialsGrant() { if (spotifyMode === 'ok') return { body: { access_token: 'FAKEACCESS', expires_in: 3600 } }; throw Object.assign(new Error('Bad Request'), { statusCode: 400, body: { error: 'invalid_client', error_description: 'Invalid client secret ' + this.clientSecret } }); } } const origLoad = Module._load; Module._load = function (request, parent) { if (request === 'spotify-web-api-node') return FakeSpotify; if (request.startsWith('.') && parent) { const resolved = norm(path.resolve(path.dirname(parent.filename), request)); if (stubs[resolved]) return stubs[resolved]; } return origLoad.apply(this, arguments); }; const { Database, getDatabase } = req('src/utils/Database/Database.js'); // Bases réelles dans le dossier temporaire const playlistsDb = new Database('Playlists', path.join(tmp, 'playlists.json'), {}); playlistsDb.data = { '333333': [ { playlistId: '1', title: 'Perso', type: 'playlist', songs: [{ id: 'a' }, { id: 'b' }], duration: 300 }, { playlistId: '2', title: 'Synchro', type: 'youtube', source: 'google', url: 'https://www.youtube.com/playlist?list=PLx', songs: [{ id: 'c' }], syncedAt: '2026-10-01T10:00:00.000Z' }, { playlistId: '3', title: 'Spotify', type: 'spotify', url: 'https://open.spotify.com/playlist/x', songs: [] } ], '444444': [] }; const googleDb = new Database('google', path.join(tmp, 'google.json'), {}); googleDb.data = { '333333': { tokens: { refresh_token: SECRETS.refresh, access_token: SECRETS.refresh + '_a' }, linkedAt: '2026-10-01' }, '555555': { tokens: { refresh_token: SECRETS.refresh + '_2' }, invalid: true } }; const configuration = req('src/utils/Database/Configuration.js'); const warp = req('src/utils/WarpProxy.js'); const Admin = req('src/server/Admin.js'); // --- Faux socket / io --- const handlers = new Map(); const socket = { id: 'sock1', data: { userId: '111111' }, on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} }; const emitted = []; const rooms = new Map([['ADMIN', new Set(['sock1'])]]); const fakeIo = { sockets: { adapter: { rooms }, sockets: new Map() }, to: (room) => ({ emit: (ev, data) => emitted.push({ room, ev, data }) }), in: () => ({ socketsLeave() {} }), emit() {}, of: () => ({ adapter: { on() {} } }) }; Admin.attach({ io: fakeIo, socket, socketUser: () => admin }); Admin.init(fakeIo); function call(name, payload) { return new Promise((resolve) => handlers.get(name)(payload, resolve)); } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const leaks = (value) => Object.values(SECRETS).filter(s => JSON.stringify(value).includes(s)); const configEvents = []; process.on('CONFIG_UPDATE', (payload) => configEvents.push(payload)); const playlistEvents = []; process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId)); (async () => { const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE']; check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r))); console.log('# Configuration : validateur pur'); const bad = (key, value) => { try { configuration.validateValue(key, value); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } }; check('clé inconnue refusée', bad('api.discord.secret', 'x')); check('port non numérique refusé', bad('server_port', 'abc')); check('port hors limites refusé', bad('server_port', 70000)); check('port en texte accepté', configuration.validateValue('server_port', ' 4000 ') === 4000); check('site javascript: refusé', bad('website', 'javascript:alert(1)')); check('site avec identifiants refusé', bad('website', 'https://user:pass@example.org')); check('site avec paramètres refusé', bad('website', 'https://example.org/?a=1')); check('site normalisé sans / final', configuration.validateValue('website', ' https://Example.org/chopin/ ') === 'https://example.org/chopin'); check('snowflake trop court refusé', bad('report.channel', '123')); check('snowflake vide accepté (facultatif)', configuration.validateValue('report.channel', '') === ''); check('token vide refusé', bad('token', ' ')); check('secret avec espace refusé', bad('api.spotify.clientSecret', 'abc def')); check('retour à la ligne refusé', bad('api.spotify.clientId', 'abc\ndef')); check('type non texte refusé', bad('api.spotify.clientId', 42)); console.log('# CONFIG/GET'); let r = await call('/ADMIN/CONFIG/GET', {}); check('GET ok', r.ok, r); check('aucun secret dans la réponse', leaks(r).length === 0, leaks(r)); const cfg = r.data.config; check('toutes les clés de la liste blanche', configuration.getKeys().every(k => cfg[k]), Object.keys(cfg)); check('token masqué {set,length}', cfg.token.secret && cfg.token.set === true && cfg.token.length === SECRETS.token.length && !('value' in cfg.token), cfg.token); check('secret Spotify masqué', cfg['api.spotify.clientSecret'].set === true && !('value' in cfg['api.spotify.clientSecret'])); check('valeur non secrète renvoyée', cfg.website.value === 'https://chopin.example.org' && cfg['api.spotify.clientId'].value === 'spotifyclientid'); check('redémarrage requis signalé', cfg.server_port.requiresRestart === true && cfg.server_port.pendingRestart === false && r.data.restartRequired === false); const integ = r.data.integrations; check('URI de redirection Google exacte', integ.google.redirectUri === 'https://chopin.example.org/oauth2callback', integ.google); check('Google configuré + comptes liés', integ.google.configured === true && integ.google.linkedUsers === 2 && integ.google.invalidUsers === 1, integ.google); check('Spotify configuré', integ.spotify.configured === true && integ.spotify.lastTest === null); check('WARP non configuré', integ.warp.configured === false); console.log('# CONFIG/SET'); r = await call('/ADMIN/CONFIG/SET', { key: 'token.inconnu', value: 'x' }); check('clé inconnue -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r); r = await call('/ADMIN/CONFIG/SET', { key: '__proto__', value: 'x' }); check('__proto__ refusé', !r.ok && r.error === 'INVALID_PARAMS', r); r = await call('/ADMIN/CONFIG/SET', { key: 'website' }); check('valeur absente -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS', r); r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'ftp://example.org' }); check('site ftp -> INVALID_PARAMS + message FR', !r.ok && r.error === 'INVALID_PARAMS' && /site/i.test(r.message), r); check('aucun CONFIG_UPDATE sur refus', configEvents.length === 0, configEvents); emitted.length = 0; r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: ' NEWSPOTIFYSECRET_zz ' }); check('secret Spotify modifié', r.ok && r.data.result.changed === true && r.data.config['api.spotify.clientSecret'].length === 'NEWSPOTIFYSECRET_zz'.length, r); check('nouveau secret absent de la réponse', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz')); check('CONFIG_UPDATE émis avec la clé seule', configEvents.length === 1 && configEvents[0].key === 'api.spotify.clientSecret' && Object.keys(configEvents[0]).length === 1, configEvents); const onDisk = JSON.parse(fs.readFileSync(path.join(tmp, 'config.json'), 'utf-8')); check('config.json (temporaire) enregistré', onDisk.api.spotify.clientSecret === 'NEWSPOTIFYSECRET_zz' && onDisk.token === SECRETS.token); check('getter relu à chaud', configuration.getSpotifyClientSecret() === 'NEWSPOTIFYSECRET_zz'); await sleep(450); const pushes = emitted.filter(e => e.ev === '/ADMIN/CONFIG/UPDATE'); check('poussée /ADMIN/CONFIG/UPDATE vers ADMIN', pushes.length === 1 && pushes[0].room === 'ADMIN', emitted.map(e => e.ev)); check('poussée sans secret', pushes.length === 1 && leaks(pushes[0].data).length === 0 && !JSON.stringify(pushes[0].data).includes('NEWSPOTIFYSECRET_zz')); r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientSecret', value: 'NEWSPOTIFYSECRET_zz' }); check('même valeur : changed=false, pas d\'événement', r.ok && r.data.result.changed === false && configEvents.length === 1); r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: 4000 }); check('port : redémarrage en attente', r.ok && r.data.result.requiresRestart && r.data.result.pendingRestart && r.data.restartRequired === true, r.data?.result); r = await call('/ADMIN/CONFIG/SET', { key: 'server_port', value: '5000' }); check('port remis : plus d\'attente', r.ok && r.data.result.pendingRestart === false && r.data.restartRequired === false); r = await call('/ADMIN/CONFIG/SET', { key: 'website', value: 'https://new.example.org/' }); check('site normalisé + URI Google mise à jour', r.ok && r.data.config.website.value === 'https://new.example.org' && r.data.integrations.google.redirectUri === 'https://new.example.org/oauth2callback', r.data?.integrations?.google); r = await call('/ADMIN/CONFIG/SET', { key: 'report.contact', value: '' }); check('champ facultatif vidé', r.ok && r.data.config['report.contact'].value === ''); r = await call('/ADMIN/CONFIG/SET', { key: 'token', value: '' }); check('token vide refusé', !r.ok && r.error === 'INVALID_PARAMS'); console.log('# CONFIG/SPOTIFY/TEST'); r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {}); check('test refusé : ok=false, message FR', r.ok && r.data.result.ok === false && /refusés/.test(r.data.result.message), r); check('secret masqué dans le message', !JSON.stringify(r).includes('NEWSPOTIFYSECRET_zz') && r.data.result.message.includes('***')); check('dernier test mémorisé', r.data.integrations.spotify.lastTest?.ok === false); spotifyMode = 'ok'; r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {}); check('test réussi', r.ok && r.data.result.ok === true && r.data.result.expiresIn === 3600, r); r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: 'autreid' }); check('dernier test oublié après modification', r.ok && r.data.integrations.spotify.lastTest === null); r = await call('/ADMIN/CONFIG/SET', { key: 'api.spotify.clientId', value: '' }); r = await call('/ADMIN/CONFIG/SPOTIFY/TEST', {}); check('sans identifiants -> UNAVAILABLE', !r.ok && r.error === 'UNAVAILABLE', r); console.log('# WARP/CONFIG'); const badPatch = (patch, locks) => { try { warp.validateConfigPatch(patch, locks); return false; } catch (e) { return e.code === 'INVALID_PARAMS'; } }; const noLocks = { url: false, enabled: false, fallbackDirect: false }; check('ftp refusé', badPatch({ url: 'ftp://127.0.0.1:21' }, noLocks)); check('socks5 sans port refusé', badPatch({ url: 'socks5://127.0.0.1' }, noLocks)); check('chemin refusé', badPatch({ url: 'http://127.0.0.1:1080/x' }, noLocks)); check('booléen attendu', badPatch({ fallbackDirect: 'oui' }, noLocks)); check('champ inconnu refusé', badPatch({ host: 'x' }, noLocks)); check('socks5h accepté', warp.validateConfigPatch({ url: 'socks5h://warp:1080' }, noLocks).url === 'socks5h://warp:1080'); check('URL vide = suppression', warp.validateConfigPatch({ url: ' ' }, noLocks).url === null); check('verrou env respecté (pur)', badPatch({ url: 'http://127.0.0.1:1080' }, { ...noLocks, url: true })); r = await call('/ADMIN/WARP/CONFIG', {}); check('aucune modification -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS'); r = await call('/ADMIN/WARP/CONFIG', { url: 'gopher://x' }); check('URL invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS' && /http/.test(r.message), r); r = await call('/ADMIN/WARP/CONFIG', { enabled: true }); check('activer sans URL refusé', !r.ok && r.error === 'INVALID_PARAMS', r); // Désactivé : aucun contrôle réseau r = await call('/ADMIN/WARP/CONFIG', { enabled: false, url: `http://user:${SECRETS.proxyPass}@127.0.0.1:9`, fallbackDirect: false }); check('configuration enregistrée', r.ok && r.data.changes.length >= 2, r); check('identifiants jamais renvoyés', r.ok && !JSON.stringify(r).includes(SECRETS.proxyPass) && r.data.status.file.url === 'http://***@127.0.0.1:9' && r.data.status.file.hasCredentials === true, r.data?.status?.file); check('proxy désactivé : non configuré', r.data.status.configured === false && r.data.status.fallbackDirect === false); const proxyFile = JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')); check('proxy.json (temporaire) écrit', proxyFile.enabled === false && proxyFile.url.includes('127.0.0.1:9') && path.dirname(__glob.PROXY) === tmp); r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: true }); check('modifier une option garde l\'URL', r.ok && JSON.parse(fs.readFileSync(__glob.PROXY, 'utf-8')).url.includes(SECRETS.proxyPass) && r.data.status.fallbackDirect === true); process.env.WARP_PROXY = 'http://127.0.0.1:9'; process.env.WARP_FALLBACK_DIRECT = 'true'; r = await call('/ADMIN/WARP/STATUS', {}); check('verrous exposés', r.ok && r.data.locked.url && r.data.locked.enabled && r.data.locked.fallbackDirect && r.data.source === 'env', r.data?.locked); r = await call('/ADMIN/WARP/CONFIG', { url: 'http://127.0.0.1:1080' }); check('URL verrouillée par WARP_PROXY', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_PROXY'), r); r = await call('/ADMIN/WARP/CONFIG', { fallbackDirect: false }); check('repli verrouillé par WARP_FALLBACK_DIRECT', !r.ok && r.error === 'INVALID_PARAMS' && r.message.includes('WARP_FALLBACK_DIRECT'), r); delete process.env.WARP_PROXY; delete process.env.WARP_FALLBACK_DIRECT; r = await call('/ADMIN/WARP/CONFIG', { url: '' }); check('URL effacée', r.ok && r.data.status.file.url === null && r.data.status.configured === false, r.data?.status); console.log('# PLAYLISTS'); r = await call('/ADMIN/PLAYLISTS/LIST', {}); check('LIST ok', r.ok && Array.isArray(r.data), r); check('propriétaires sans playlist ignorés', r.data.length === 1 && r.data[0].userId === '333333'); const entry = r.data[0]; check('forme propriétaire', entry.username === 'proprio' && entry.owner?.id === '333333' && entry.owner.global_name === 'Propriétaire' && entry.owner.isDeleted === false, entry.owner); const lists = entry.playlists; check('forme playlist', lists.length === 3 && lists.every(p => typeof p.playlistId === 'string' && 'title' in p && 'type' in p && 'source' in p && typeof p.songCount === 'number'), lists); check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0); check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z'); check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p))); r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' }); check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0); r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' }); check('userId invalide -> INVALID_PARAMS', !r.ok && r.error === 'INVALID_PARAMS'); r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '99' }); check('playlist inconnue -> NOT_FOUND', !r.ok && r.error === 'NOT_FOUND'); r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: '333333', playlistId: '2' }); check('suppression ok', r.ok && r.data.deleted === true && removed.length === 1 && removed[0].playlistId === '2', r); check('PLAYLISTS_UPDATE émis pour le propriétaire', playlistEvents.includes('333333'), playlistEvents); console.log('# Players / comptes (correctifs)'); r = await call('/ADMIN/PLAYERS/LIST', {}); check('erreur d\'une autre musique marquée résolue', r.ok && r.data[0]?.lastErrorStale === true, r.data?.[0]); r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: true }); check('loop=true déjà actif : aucune bascule', r.ok && playerCalls.setLoop === 0 && fakePlayer.loop === true, r); r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: false }); check('loop=false : une bascule', r.ok && playerCalls.setLoop === 1 && fakePlayer.loop === false); r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle', value: false }); check('shuffle=false déjà inactif : aucune bascule', r.ok && playerCalls.setShuffle === 0); r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'shuffle' }); check('shuffle sans valeur : bascule (ancien client)', r.ok && playerCalls.setShuffle === 1); r = await call('/ADMIN/PLAYERS/ACTION', { guildId: '222222', action: 'loop', value: 'oui' }); check('valeur non booléenne refusée', !r.ok && r.error === 'INVALID_PARAMS'); r = await call('/ADMIN/USERS/SET_GUILD_MOD', { userId: '111111', guildId: '222222', value: true }); check('SET_GUILD_MOD sur soi-même -> SELF_ACTION', !r.ok && r.error === 'SELF_ACTION', r); console.log('# Journaux'); await sleep(300); const logDir = path.join(__dirname, 'logs'); const logText = fs.readdirSync(logDir).map(f => { try { return fs.readFileSync(path.join(logDir, f), 'utf-8'); } catch (e) { return ''; } }).join('\n'); const logged = [...Object.values(SECRETS), 'NEWSPOTIFYSECRET_zz'].filter(s => logText.includes(s)); check('aucun secret dans les logs', logged.length === 0, logged); check('backend/data jamais utilisé', !fs.existsSync(path.join(DATA_DIR, 'config.json.tmp'))); fs.rmSync(tmp, { recursive: true, force: true }); done(); setTimeout(() => process.exit(process.exitCode || 0), 50); })().catch((e) => { console.error(e); process.exit(1); });