diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 11f10bf859b..38e767e924f 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -35,6 +35,7 @@ import { logError, } from '../utils/command-helpers.js' import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' +import { guardGlobalConfigFile, guardLocalStateFile } from '../utils/config-guard.js' import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -653,6 +654,8 @@ export default class BaseCommand extends Command { // ================================================== // Retrieve Site id and build state from the state.json // ================================================== + guardGlobalConfigFile() + await guardLocalStateFile(this.workingDir) const state = new LocalState(this.workingDir) const [token] = await getToken(flags.auth) diff --git a/src/commands/main.ts b/src/commands/main.ts index f7167573f08..9aae52a5776 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -18,6 +18,7 @@ import { warn, logError, } from '../utils/command-helpers.js' +import { guardGlobalConfigFile } from '../utils/config-guard.js' import execa from '../utils/execa.js' import getCLIPackageJson from '../utils/get-cli-package-json.js' import { didEnableCompileCache } from '../utils/nodejs-compile-cache.js' @@ -155,6 +156,7 @@ ${USER_AGENT} */ // @ts-expect-error TS(7006) FIXME: Parameter 'options' implicitly has an 'any' type. const mainCommand = async function (options, command) { + guardGlobalConfigFile() const globalConfig = await getGlobalConfigStore() if (options.telemetryDisable) { diff --git a/src/utils/config-guard.ts b/src/utils/config-guard.ts new file mode 100644 index 00000000000..e90aad30a10 --- /dev/null +++ b/src/utils/config-guard.ts @@ -0,0 +1,64 @@ +import { copyFileSync, existsSync, readFileSync, statSync } from 'fs' +import { join } from 'path' + +import { findUp } from 'find-up' + +import { getPathInHome } from '../lib/settings.js' + +import { BANG, chalk } from './command-helpers.js' + +const STATE_RELATIVE_PATH = join('.netlify', 'state.json') + +const writeCorruptFileWarning = (filePath: string, backupPath: string, recoveryHint: string) => { + const bang = chalk.yellow(BANG) + process.stderr.write(` ${bang} Warning: ${filePath} contains malformed JSON and will be reset.\n`) + process.stderr.write(` ${bang} A backup of the corrupt file was saved to ${backupPath}.\n`) + process.stderr.write(` ${bang} Repair and restore the backup, or ${recoveryHint}.\n`) +} + +/** + * Detects an existing-but-unparseable JSON config file before the underlying store + * silently resets it, copies it to `.corrupt.` and warns on stderr. + * Returns the backup path when a corrupt file was found, `undefined` otherwise. + */ +export const backUpCorruptJsonFile = (filePath: string, recoveryHint: string): string | undefined => { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch { + return undefined + } + if (raw.trim() === '') { + return undefined + } + try { + JSON.parse(raw) + return undefined + } catch { + const backupPath = `${filePath}.corrupt.${String(Math.round(statSync(filePath).mtimeMs))}` + if (!existsSync(backupPath)) { + copyFileSync(filePath, backupPath) + } + writeCorruptFileWarning(filePath, backupPath, recoveryHint) + return backupPath + } +} + +/** + * Guards the project-local `.netlify/state.json` (resolved with the same find-up + * semantics as `LocalState` in `@netlify/dev-utils`). + */ +export const guardLocalStateFile = async (workingDir: string): Promise => { + const statePath = (await findUp(STATE_RELATIVE_PATH, { cwd: workingDir })) ?? join(workingDir, STATE_RELATIVE_PATH) + return backUpCorruptJsonFile(statePath, `delete it and re-run ${chalk.cyanBright('netlify link')}`) +} + +/** + * Guards the global config store file (`~/.config/netlify/config.json` or platform + * equivalent) which holds auth tokens, before `getGlobalConfigStore` resets it. + */ +export const guardGlobalConfigFile = (): string | undefined => + backUpCorruptJsonFile( + getPathInHome(['config.json']), + `delete it and re-run ${chalk.cyanBright('netlify login')} to restore your auth tokens`, + ) diff --git a/tests/unit/utils/config-guard.test.ts b/tests/unit/utils/config-guard.test.ts new file mode 100644 index 00000000000..e74a8bc5d6c --- /dev/null +++ b/tests/unit/utils/config-guard.test.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { backUpCorruptJsonFile, guardLocalStateFile } from '../../../src/utils/config-guard.js' + +const mockStderr = () => vi.spyOn(process.stderr, 'write').mockReturnValue(true) + +describe('config corruption guard', () => { + let dir: string + let stderrSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'netlify-config-guard-')) + stderrSpy = mockStderr() + }) + + afterEach(() => { + stderrSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + }) + + const stderrOutput = () => stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join('') + + test('returns undefined for a missing file', () => { + expect(backUpCorruptJsonFile(join(dir, 'missing.json'), 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for valid JSON and writes no backup', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{"siteId":"123"}') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for an empty file', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + }) + + test('backs up a corrupt file and warns on stderr naming both paths', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{ totally not json') + + const backupPath = backUpCorruptJsonFile(filePath, 'run netlify link') + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + expect(readFileSync(backupPath ?? '', 'utf8')).toBe('{ totally not json') + + const output = stderrOutput() + expect(output).toContain(filePath) + expect(output).toContain(backupPath) + expect(output).toContain('run netlify link') + }) + + test('does not overwrite an existing backup for the same mtime', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, 'garbage') + + const first = backUpCorruptJsonFile(filePath, 'fix it') + const second = backUpCorruptJsonFile(filePath, 'fix it') + + expect(first).toBe(second) + expect(readFileSync(first ?? '', 'utf8')).toBe('garbage') + }) + + test('guardLocalStateFile resolves .netlify/state.json from the working directory', async () => { + const projectDir = join(dir, 'project') + writeFileSync(join(dir, 'unrelated.txt'), '', { flag: 'w' }) + const stateDir = join(projectDir, '.netlify') + const { mkdirSync } = await import('fs') + mkdirSync(stateDir, { recursive: true }) + writeFileSync(join(stateDir, 'state.json'), 'not json at all') + + const backupPath = await guardLocalStateFile(projectDir) + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + }) + + test('guardLocalStateFile is a no-op when no state file exists', async () => { + const projectDir = join(dir, 'empty-project') + const { mkdirSync } = await import('fs') + mkdirSync(projectDir, { recursive: true }) + await expect(guardLocalStateFile(projectDir)).resolves.toBeUndefined() + }) +})