diff --git a/package-lock.json b/package-lock.json index 41d323a89d2..459fbe42577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,8 @@ "uuid": "11.1.0", "wait-port": "1.1.0", "write-file-atomic": "5.0.1", - "ws": "8.18.1" + "ws": "8.18.1", + "zod": "^3.24.3" }, "bin": { "netlify": "bin/run.js", @@ -164,6 +165,7 @@ "temp-dir": "3.0.0", "tree-kill": "1.2.2", "tsx": "^4.19.3", + "type-fest": "4.40.0", "typescript": "5.8.3", "typescript-eslint": "^8.26.0", "verdaccio": "6.1.2", @@ -2166,6 +2168,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/build-info/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@netlify/build/node_modules/@bugsnag/browser": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/@bugsnag/browser/-/browser-7.25.0.tgz", @@ -2757,6 +2771,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@netlify/config/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@netlify/config/node_modules/yocto-queue": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", @@ -7637,18 +7663,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/boxen/node_modules/type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/boxen/node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", @@ -9560,18 +9574,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dot-prop/node_modules/type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -15192,18 +15194,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-json/node_modules/type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse-ms": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz", @@ -16192,18 +16182,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/read-pkg": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", @@ -16334,13 +16312,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=16" + "node": ">=12.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17657,6 +17635,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -18070,11 +18060,12 @@ } }, "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", + "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=12.20" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -19601,9 +19592,10 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21188,6 +21180,11 @@ "parse-json": "^5.2.0", "type-fest": "^2.0.0" } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" } } }, @@ -21342,6 +21339,11 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==" }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + }, "yocto-queue": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", @@ -24520,11 +24522,6 @@ "strip-ansi": "^7.1.0" } }, - "type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" - }, "wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", @@ -25805,13 +25802,6 @@ "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "requires": { "type-fest": "^4.18.2" - }, - "dependencies": { - "type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" - } } }, "dotenv": { @@ -29767,13 +29757,6 @@ "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" - }, - "dependencies": { - "type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" - } } }, "parse-ms": { @@ -30469,13 +30452,6 @@ "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" - }, - "dependencies": { - "type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" - } } }, "read-pkg": { @@ -30488,13 +30464,6 @@ "parse-json": "^8.0.0", "type-fest": "^4.6.0", "unicorn-magic": "^0.1.0" - }, - "dependencies": { - "type-fest": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", - "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" - } } }, "read-pkg-up": { @@ -30569,6 +30538,11 @@ "parse-json": "^5.2.0", "type-fest": "^2.0.0" } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" } } }, @@ -31533,6 +31507,11 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==" + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" } } }, @@ -31833,9 +31812,9 @@ } }, "type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.0.tgz", + "integrity": "sha512-ABHZ2/tS2JkvH1PEjxFDTUWC8dB5OsIGZP4IFLhR293GqT5Y5qB1WwL2kMPYhQW9DVgVD8Hd7I8gjwPIf5GFkw==" }, "type-is": { "version": "1.6.18", @@ -32813,9 +32792,9 @@ } }, "zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" } } } diff --git a/package.json b/package.json index c57510822ce..fb6f8aed102 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,8 @@ "uuid": "11.1.0", "wait-port": "1.1.0", "write-file-atomic": "5.0.1", - "ws": "8.18.1" + "ws": "8.18.1", + "zod": "^3.24.3" }, "devDependencies": { "@babel/preset-react": "7.26.3", @@ -208,6 +209,7 @@ "temp-dir": "3.0.0", "tree-kill": "1.2.2", "tsx": "^4.19.3", + "type-fest": "4.40.0", "typescript": "5.8.3", "typescript-eslint": "^8.26.0", "verdaccio": "6.1.2", diff --git a/src/commands/logs/build.ts b/src/commands/logs/build.ts index 36563aa55ce..4d1b219e767 100644 --- a/src/commands/logs/build.ts +++ b/src/commands/logs/build.ts @@ -39,6 +39,11 @@ export const logsBuild = async (options: OptionValues, command: BaseCommand) => const { id: siteId } = site const userId = command.netlify.globalConfig.get('userId') + if (!userId) { + log('You must authenticate before attempting to view deploy logs') + return + } + if (!siteId) { log('You must link a site before attempting to view deploy logs') return diff --git a/src/commands/status/status.ts b/src/commands/status/status.ts index b4734de8952..7dd7e7c8324 100644 --- a/src/commands/status/status.ts +++ b/src/commands/status/status.ts @@ -16,7 +16,7 @@ import type BaseCommand from '../base-command.js' export const status = async (options: OptionValues, command: BaseCommand) => { const { accounts, api, globalConfig, site, siteInfo } = command.netlify - const currentUserId = globalConfig.get('userId') as string | undefined + const currentUserId = globalConfig.get('userId') const [accessToken] = await getToken() if (!accessToken) { @@ -48,7 +48,7 @@ export const status = async (options: OptionValues, command: BaseCommand) => { const ghuser = currentUserId != null - ? (globalConfig.get(`users.${currentUserId}.auth.github.user`) as string | undefined) + ? (globalConfig.get(`users.${currentUserId}.auth.github.user`)) : undefined const accountData = { Name: user.full_name, diff --git a/src/commands/switch/switch.ts b/src/commands/switch/switch.ts index b1052278ab5..c8e63652efc 100644 --- a/src/commands/switch/switch.ts +++ b/src/commands/switch/switch.ts @@ -7,35 +7,28 @@ import { login } from '../login/login.js' const LOGIN_NEW = 'I would like to login to a new account' -export const switchCommand = async (options: OptionValues, command: BaseCommand) => { - const availableUsersChoices = Object.values(command.netlify.globalConfig.get('users') || {}).reduce( - (prev, current) => - // @ts-expect-error TS(2769) FIXME: No overload matches this call. - Object.assign(prev, { [current.id]: current.name ? `${current.name} (${current.email})` : current.email }), - {}, - ) +export const switchCommand = async (_options: OptionValues, command: BaseCommand) => { + const availableUsersChoices = Object.values(command.netlify.globalConfig.get('users')).map((user) => ({ + name: user.name ? `${user.name} (${user.email})` : user.email, + value: user.id, + })) - const { accountSwitchChoice } = await inquirer.prompt([ + const { accountSwitchChoice } = await inquirer.prompt<{ accountSwitchChoice: string }>([ { type: 'list', name: 'accountSwitchChoice', message: 'Please select the account you want to use:', - // @ts-expect-error TS(2769) FIXME: No overload matches this call. - choices: [...Object.entries(availableUsersChoices).map(([, val]) => val), LOGIN_NEW], + choices: [availableUsersChoices, { name: LOGIN_NEW, value: 'LOGIN_NEW' }], }, ]) - if (accountSwitchChoice === LOGIN_NEW) { + if (accountSwitchChoice === 'LOGIN_NEW') { await login({ new: true }, command) } else { - // @ts-expect-error TS(2769) FIXME: No overload matches this call. - const selectedAccount = Object.entries(availableUsersChoices).find( - ([, availableUsersChoice]) => availableUsersChoice === accountSwitchChoice, - ) - // @ts-expect-error TS(2532) FIXME: Object is possibly 'undefined'. - command.netlify.globalConfig.set('userId', selectedAccount[0]) + command.netlify.globalConfig.set('userId', accountSwitchChoice) log('') - // @ts-expect-error TS(2532) FIXME: Object is possibly 'undefined'. - log(`You're now using ${chalk.bold(selectedAccount[1])}.`) + const chosenUser = + availableUsersChoices.find(({ value }) => value === accountSwitchChoice)?.value ?? accountSwitchChoice + log(`You're now using ${chalk.bold(chosenUser)}.`) } } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a39b636453a..1e2a253ecc6 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -1,4 +1,3 @@ -import os from 'os' import path from 'path' import envPaths from 'env-paths' @@ -6,12 +5,6 @@ import envPaths from 'env-paths' const OSBasedPaths = envPaths('netlify', { suffix: '' }) const NETLIFY_HOME = '.netlify' -/** - * Deprecated method to get netlify's home config - ~/.netlify/... - * @deprecated - */ -export const getLegacyPathInHome = (paths: string[]) => path.join(os.homedir(), NETLIFY_HOME, ...paths) - /** * get a global path on the os base path */ diff --git a/src/utils/get-global-config-store.ts b/src/utils/get-global-config-store.ts index da1112ef6a1..bddbb565629 100644 --- a/src/utils/get-global-config-store.ts +++ b/src/utils/get-global-config-store.ts @@ -1,110 +1,6 @@ -import fs from 'node:fs/promises' -import fss from 'node:fs' -import path from 'node:path' -import * as dot from 'dot-prop' - -import { v4 as uuidv4 } from 'uuid' -import { sync as writeFileAtomicSync } from 'write-file-atomic' - -import { getLegacyPathInHome, getPathInHome } from '../lib/settings.js' - -type ConfigStoreOptions< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Record, -> = { - defaults?: T | undefined -} - -export class GlobalConfigStore< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Record = Record, -> { - #storagePath: string - - public constructor(options: ConfigStoreOptions = {}) { - this.#storagePath = getPathInHome(['config.json']) - - if (options.defaults) { - const config = this.getConfig() - this.writeConfig({ ...options.defaults, ...config }) - } - } - - public get all(): T { - return this.getConfig() - } - - public set(key: string, value: unknown): void { - const config = this.getConfig() - const updatedConfig = dot.setProperty(config, key, value) - this.writeConfig(updatedConfig) - } - - public get(key: string): T[typeof key] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return dot.getProperty(this.getConfig(), key) - } - - private getConfig(): T { - let raw: string - try { - raw = fss.readFileSync(this.#storagePath, 'utf8') - } catch (err) { - if (err instanceof Error && 'code' in err) { - if (err.code === 'ENOENT') { - // File or parent directory does not exist - return {} as T - } - } - throw err - } - - try { - return JSON.parse(raw) as T - } catch { - writeFileAtomicSync(this.#storagePath, '', { mode: 0o0600 }) - return {} as T - } - } - - private writeConfig(value: T) { - fss.mkdirSync(path.dirname(this.#storagePath), { mode: 0o0700, recursive: true }) - writeFileAtomicSync(this.#storagePath, JSON.stringify(value, undefined, '\t'), { mode: 0o0600 }) - } -} - -const globalConfigDefaults = { - /* disable stats from being sent to Netlify */ - telemetryDisabled: false, - /* cliId */ - cliId: uuidv4(), -} - -// Memoise config result so that we only load it once -let configStore: GlobalConfigStore | undefined - -const getGlobalConfigStore = async (): Promise => { - if (!configStore) { - // Legacy config file in home ~/.netlify/config.json - const legacyPath = getLegacyPathInHome(['config.json']) - // Read legacy config if exists - let legacyConfig: Record | undefined - try { - legacyConfig = JSON.parse(await fs.readFile(legacyPath, 'utf8')) - } catch { - // ignore error - } - // Use legacy config as default values - const defaults = { ...globalConfigDefaults, ...legacyConfig } - // The id param is only used when not passing `configPath` but the type def requires it - configStore = new GlobalConfigStore({ defaults }) - } - - return configStore -} +// TODO(ndhoule): Remove this file and point former consumers at './global-config/main.js' +import { getGlobalConfigStore } from './global-config/main.js' export default getGlobalConfigStore -export const resetConfigCache = () => { - configStore = undefined -} +export { type GlobalConfigStore, getGlobalConfigStore, resetConfigCache } from './global-config/main.js' diff --git a/src/utils/global-config/main.ts b/src/utils/global-config/main.ts new file mode 100644 index 00000000000..db505d268a8 --- /dev/null +++ b/src/utils/global-config/main.ts @@ -0,0 +1,17 @@ +import { GlobalConfigStore } from './store.js' + +export { type GlobalConfigStore } from './store.js' + +// Memoise config result so that we only load it once +let configStore: GlobalConfigStore | undefined + +export const getGlobalConfigStore = async (): Promise => { + if (!configStore) { + configStore = new GlobalConfigStore() + } + return Promise.resolve(configStore) +} + +export const resetConfigCache = () => { + configStore = undefined +} diff --git a/src/utils/global-config/schema.ts b/src/utils/global-config/schema.ts new file mode 100644 index 00000000000..90e3619c220 --- /dev/null +++ b/src/utils/global-config/schema.ts @@ -0,0 +1,80 @@ +import { v4 as uuidv4 } from 'uuid' +import { z } from 'zod' + +export type GlobalConfig = z.output + +export const GlobalConfigSchema = z.object( + { + cliId: z + .string({ description: 'An anonymous identifier used for telemetry information.' }) + .optional() + .default(uuidv4), + userId: z + .string({ + description: + "The current selected user's unique Netlify identifier. Consumers can change this using the `netlify {login,logout,switch}` commands.", + }) + .optional(), + telemetryDisabled: z + .boolean({ description: 'Prevents anonymous telemetry information from being sent to Netlify.' }) + .optional() + .default(false), + users: z + .record( + z.string({ description: "The user's unique Netlify identifier." }), + z.object({ + id: z.string({ description: "The user's unique Netlify identifier." }), + name: z.string({ description: "The user's full name (e.g. Johanna Smith)." }).optional(), + email: z.string({ description: "The user's email address." }).optional(), + auth: z + .object({ + token: z.string({ description: "The user's Netlify API token." }).optional(), + github: z + .object( + { + provider: z + .string({ + description: + "The token issuer. This schema is relaxed, but in practice it should always be 'github'.", + }) + .optional(), + token: z.string({ description: "The user's GitHub API token." }).optional(), + user: z.string({ description: "The user's GitHub username." }).optional(), + }, + { + description: + "The user's GitHub API credentials issued via the Netlify GitHub App. This is usually set in the `netlify init` flow. When not set, it will be an empty object.", + }, + ) + .optional() + .default(() => ({})), + }) + .optional() + .default(() => ({})), + }), + { + description: + 'A store of user profiles available to the consumer. Consumers can specify which profile is used to authenticate commands using `netlify switch` command.', + }, + ) + .optional() + .default(() => ({})), + }, + { + description: + "The Netlify CLI's persistent configuration state. This state includes information that should be persisted across CLI invocations, and is stored in the user's platform-specific configuration directory (e.g. `$XDG_CONFIG_HOME/netlify/config.json`, `$HOME/Library/Preferences/netlify/config.json`, etc.).", + }, +) + +export const parseGlobalConfig = ( + value: unknown, +): { data: GlobalConfig; error?: never; success: true } | { data?: never; error: Error; success: false } => + GlobalConfigSchema.safeParse(value) + +export const mustParseGlobalConfig = (value: unknown): GlobalConfig => { + const result = parseGlobalConfig(value) + if (!result.success) { + throw result.error + } + return result.data +} diff --git a/src/utils/global-config/storage-adapter-atomic-disk.ts b/src/utils/global-config/storage-adapter-atomic-disk.ts new file mode 100644 index 00000000000..099d657c60c --- /dev/null +++ b/src/utils/global-config/storage-adapter-atomic-disk.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs' +import path from 'node:path' +import { sync as writeFileAtomicSync } from 'write-file-atomic' +import { getPathInHome } from '../../lib/settings.js' +import type { JSONValue, StorageAdapter } from './storage-adapter.js' + +export class AtomicDiskStorageAdapter implements StorageAdapter { + #storagePath: string + + public constructor({ storagePath = getPathInHome(['config.json']) }: { storagePath?: string } = {}) { + this.#storagePath = storagePath + } + + public read(): JSONValue { + let raw: string + try { + raw = fs.readFileSync(this.#storagePath, 'utf8') + } catch (err) { + if (err instanceof Error && 'code' in err) { + if (err.code === 'ENOENT') { + return {} + } + } + throw err + } + + try { + return JSON.parse(raw) as JSONValue + } catch { + // The existing configuration is invalid and will always fail parse. Empty it out so the user + // can recover. + writeFileAtomicSync(this.#storagePath, '', { mode: 0o0600 }) + return {} + } + } + + public write(value: JSONValue) { + fs.mkdirSync(path.dirname(this.#storagePath), { mode: 0o0700, recursive: true }) + writeFileAtomicSync(this.#storagePath, JSON.stringify(value, undefined, '\t'), { mode: 0o0600 }) + } +} diff --git a/src/utils/global-config/storage-adapter-memory.ts b/src/utils/global-config/storage-adapter-memory.ts new file mode 100644 index 00000000000..666d15cd44a --- /dev/null +++ b/src/utils/global-config/storage-adapter-memory.ts @@ -0,0 +1,17 @@ +import type { JSONValue, StorageAdapter } from './storage-adapter.js' + +export class MemoryStorageAdapter implements StorageAdapter { + #data: JSONValue + + public constructor(initialData?: JSONValue) { + this.#data = structuredClone(initialData ?? {}) + } + + public read(): JSONValue { + return structuredClone(this.#data) + } + + public write(value: JSONValue) { + this.#data = structuredClone(value) + } +} diff --git a/src/utils/global-config/storage-adapter.ts b/src/utils/global-config/storage-adapter.ts new file mode 100644 index 00000000000..f97fb021cd3 --- /dev/null +++ b/src/utils/global-config/storage-adapter.ts @@ -0,0 +1,13 @@ +type JSONPrimitive = string | number | boolean | null | undefined + +export type JSONValue = + | JSONPrimitive + | JSONValue[] + | { + [key: string]: JSONValue + } + +export interface StorageAdapter { + read(): JSONValue + write(config: JSONValue): void +} diff --git a/src/utils/global-config/store.ts b/src/utils/global-config/store.ts new file mode 100644 index 00000000000..2f9096bccdd --- /dev/null +++ b/src/utils/global-config/store.ts @@ -0,0 +1,54 @@ +import { getProperty, setProperty } from 'dot-prop' +import type { Get, ReadonlyDeep, SimplifyDeep } from 'type-fest' +import type { StorageAdapter } from './storage-adapter.js' +import { AtomicDiskStorageAdapter } from './storage-adapter-atomic-disk.js' +import { type GlobalConfig as MutableGlobalConfig, mustParseGlobalConfig } from './schema.js' + +/** + * The Netlify CLI's persistent configuration state. This state includes information that should be + * persisted across CLI invocations. + * + * This type is read-only and represents the current state of the configuration on disk. To modify + * the configuration, use the GlobalConfigStore interface. + * + * This state is stored in the user's platform-specific configuration directory (e.g. + * `$XDG_CONFIG_HOME/netlify/config.json`, `$HOME/Library/Preferences/netlify/config.json`, etc.). + */ +export type GlobalConfig = SimplifyDeep> + +export class GlobalConfigStore { + #store: StorageAdapter + + public constructor({ store }: { store?: StorageAdapter } = {}) { + this.#store = store ?? new AtomicDiskStorageAdapter() + } + + public get all(): GlobalConfig { + return this.getConfig() + } + + public set(key: string, value: unknown): void { + const config = this.getMutableConfig() + const updatedConfig = setProperty(config, key, value) + this.writeConfig(updatedConfig) + } + + public get( + path: Path, + ): unknown extends Get ? undefined : Get { + return getProperty(this.getConfig(), path) + } + + private getConfig(): GlobalConfig { + // TODO(ndhoule): Use parseGlobalConfig instead and gracefully recover from failure + return mustParseGlobalConfig(this.#store.read()) + } + + private getMutableConfig(): MutableGlobalConfig { + return this.getConfig() as MutableGlobalConfig + } + + private writeConfig(value: GlobalConfig) { + this.#store.write(value) + } +} diff --git a/src/utils/init/config-github.ts b/src/utils/init/config-github.ts index c739eb886cd..f33ec47167d 100644 --- a/src/utils/init/config-github.ts +++ b/src/utils/init/config-github.ts @@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest' import type { NetlifyAPI } from 'netlify' import { chalk, logAndThrowError, log } from '../command-helpers.js' -import { getGitHubToken as ghauth, type Token } from '../gh-auth.js' +import { getGitHubToken as ghauth } from '../gh-auth.js' import type { GlobalConfigStore } from '../types.js' import type { BaseCommand } from '../../commands/index.js' @@ -19,9 +19,11 @@ const PAGE_SIZE = 100 * Get a valid GitHub token */ export const getGitHubToken = async ({ globalConfig }: { globalConfig: GlobalConfigStore }): Promise => { - const userId: string = globalConfig.get('userId') - - const githubToken: Token | undefined = globalConfig.get(`users.${userId}.auth.github`) + const userId = globalConfig.get('userId') + if (!userId) { + return logAndThrowError('You must be authentiated to access the GitHub API.') + } + const githubToken = globalConfig.get(`users.${userId}.auth.github`) if (githubToken?.user && githubToken.token) { try { diff --git a/tests/unit/utils/get-global-config-store.test.ts b/tests/unit/utils/get-global-config-store.test.ts deleted file mode 100644 index 589e8c313f3..00000000000 --- a/tests/unit/utils/get-global-config-store.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' - -import { describe, beforeEach, expect, it, vi } from 'vitest' -import { vol } from 'memfs' - -import { getLegacyPathInHome, getPathInHome } from '../../../src/lib/settings.js' -import getGlobalConfigStore, { - GlobalConfigStore, - resetConfigCache, -} from '../../../src/utils/get-global-config-store.js' - -// Mock filesystem calls -vi.mock('fs') -vi.mock('fs/promises') -vi.mock('write-file-atomic') - -const configFilePath = getPathInHome(['config.json']) -const configPath = path.dirname(configFilePath) -// eslint-disable-next-line @typescript-eslint/no-deprecated -const legacyConfigFilePath = getLegacyPathInHome(['config.json']) -const legacyConfigPath = path.dirname(legacyConfigFilePath) - -describe('getGlobalConfig', () => { - beforeEach(() => { - vol.reset() - - vol.mkdirSync(configPath, { recursive: true }) - vol.mkdirSync(legacyConfigPath, { recursive: true }) - - // reset the memoized config for the tests - resetConfigCache() - }) - - it('returns an empty object when the legacy configuration file is not valid JSON', async () => { - await fs.writeFile(legacyConfigFilePath, 'NotJson') - - await expect(getGlobalConfigStore()).resolves.not.toThrowError() - }) - - it('merges legacy configuration options with new configuration options (preferring new config options)', async () => { - const legacyConfig = { someOldKey: 'someOldValue', overrideMe: 'oldValue' } - const newConfig = { overrideMe: 'newValue' } - await fs.writeFile(legacyConfigFilePath, JSON.stringify(legacyConfig)) - await fs.writeFile(configFilePath, JSON.stringify(newConfig)) - - const globalConfig = await getGlobalConfigStore() - - expect(globalConfig.get('someOldKey')).toBe(legacyConfig.someOldKey) - expect(globalConfig.get('overrideMe')).toBe(newConfig.overrideMe) - }) - - it("creates a config store file in netlify's config dir if none exists and stores new values", async () => { - // Remove config dirs - await fs.rm(getPathInHome([]), { force: true, recursive: true }) - - // eslint-disable-next-line @typescript-eslint/no-deprecated - await fs.rm(getLegacyPathInHome([]), { force: true, recursive: true }) - const globalConfig = await getGlobalConfigStore() - globalConfig.set('newProp', 'newValue') - const configFile = JSON.parse(await fs.readFile(configFilePath, 'utf-8')) as Record - - expect(globalConfig.all).toEqual(configFile) - }) -}) - -describe('ConfigStore', () => { - beforeEach(() => { - vol.reset() - }) - - it('merges defaults into the configuration file, when provided', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const defaults = { someOldKey: 'someOldValue', overrideMe: 'oldValue' } - const config = { overrideMe: 'newValue' } - await fs.writeFile(configFilePath, JSON.stringify(config)) - - const before: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - expect(before).toEqual(config) - - new GlobalConfigStore({ defaults }) - - const after: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - expect(after).toEqual({ - someOldKey: 'someOldValue', - overrideMe: 'newValue', - }) - }) - - describe('#all', () => { - it('returns the entire configuration', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const config = { a: 'value' } - await fs.writeFile(configFilePath, JSON.stringify(config)) - - const store = new GlobalConfigStore() - - expect(store.all).toEqual(config) - }) - - it('works when no configuration file exists', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const store = new GlobalConfigStore() - - expect(store.all).toEqual({}) - }) - - it('works when no configuration directory exists', () => { - const store = new GlobalConfigStore() - - expect(store.all).toEqual({}) - }) - }) - - describe('#get', () => { - it('returns a single configuration value in the config', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const config = { a: 'value' } - await fs.writeFile(configFilePath, JSON.stringify(config)) - - const store = new GlobalConfigStore() - - expect(store.get('a')).toBe('value') - }) - - it('returns undefined when no configuration file exists', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const store = new GlobalConfigStore() - - expect(store.get('a')).toBe(undefined) - }) - - it('returns undefined when no configuration directory exists', () => { - const store = new GlobalConfigStore() - - expect(store.get('a')).toBe(undefined) - }) - }) - - describe('#set', () => { - it('updates an existing configuration file', async () => { - await fs.mkdir(configPath, { recursive: true }) - await fs.writeFile(configFilePath, JSON.stringify({ a: 'value' })) - - const store = new GlobalConfigStore() - store.set('b', 'another value') - const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - - expect(data).toEqual({ a: 'value', b: 'another value' }) - }) - - it('creates a configuration file when one does not exist', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const store = new GlobalConfigStore() - store.set('a', 'fresh start') - const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - - expect(data).toEqual({ a: 'fresh start' }) - }) - - it('sets nested values', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const store = new GlobalConfigStore() - store.set('a.new', 'hope') - const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - - expect(data).toEqual({ a: { new: 'hope' } }) - }) - - it('succeeds when no configuration directory exists', async () => { - await fs.mkdir(configPath, { recursive: true }) - - const store = new GlobalConfigStore() - store.set('a.new', 'hope') - const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) - - expect(data).toEqual({ a: { new: 'hope' } }) - }) - }) -}) diff --git a/tests/unit/utils/global-config/main.test.ts b/tests/unit/utils/global-config/main.test.ts new file mode 100644 index 00000000000..500d57726e3 --- /dev/null +++ b/tests/unit/utils/global-config/main.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from 'vitest' + +import { getGlobalConfigStore, resetConfigCache } from '../../../../src/utils/global-config/main.js' + +describe('getGlobalConfig', () => { + it.todo('retrieves the global config') + it.todo('caches a previously read global config') +}) + +describe('resetConfigCache', () => { + it.todo('resets the global config cache') + it.todo('succeeds when no config is cached') +}) diff --git a/tests/unit/utils/global-config/store.test.ts b/tests/unit/utils/global-config/store.test.ts new file mode 100644 index 00000000000..7e6e6e139c8 --- /dev/null +++ b/tests/unit/utils/global-config/store.test.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { describe, beforeEach, expect, it, vi } from 'vitest' +import { vol } from 'memfs' + +import { getPathInHome } from '../../../../src/lib/settings.js' +import { GlobalConfigStore } from '../../../../src/utils/global-config/store.js' + +// Mock filesystem calls +vi.mock('fs') +vi.mock('fs/promises') +vi.mock('write-file-atomic') + +const configFilePath = getPathInHome(['config.json']) +const configPath = path.dirname(configFilePath) + +describe('GlobalConfigStore', () => { + beforeEach(() => { + vol.reset() + }) + + it("creates a config store file in netlify's config dir if none exists and stores new values", async () => { + // Remove config dirs + await fs.rm(getPathInHome([]), { force: true, recursive: true }) + + const globalConfig = new GlobalConfigStore() + globalConfig.set('userId', 'newValue') + const configFile = JSON.parse(await fs.readFile(configFilePath, 'utf-8')) as unknown + + expect(globalConfig.all).toEqual(configFile) + }) + + describe('#all', () => { + it('returns the entire configuration', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const config = { + cliId: 'some-cli-id', + telemetryDisabled: true, + userId: 'some-user-id', + users: { + 'some-user-id': { + id: 'some-user-id', + auth: { + github: {}, + }, + }, + }, + } + await fs.writeFile(configFilePath, JSON.stringify(config)) + + const store = new GlobalConfigStore() + + expect(store.all).toEqual(config) + }) + + it('works when no configuration file exists', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const store = new GlobalConfigStore() + + expect(store.all).toEqual( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + cliId: expect.any(String), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + telemetryDisabled: expect.any(Boolean), + users: {}, + }), + ) + }) + + it('works when no configuration directory exists', () => { + const store = new GlobalConfigStore() + + expect(store.all).toEqual( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + cliId: expect.any(String), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + telemetryDisabled: expect.any(Boolean), + users: {}, + }), + ) + }) + }) + + describe('#get', () => { + it('returns a single configuration value in the config', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const config = { userId: 'value' } + await fs.writeFile(configFilePath, JSON.stringify(config)) + + const store = new GlobalConfigStore() + + expect(store.get('userId')).toBe('value') + }) + + it('returns undefined when no configuration file exists', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const store = new GlobalConfigStore() + + expect(store.get('userId')).toBe(undefined) + }) + + it('returns undefined when no configuration directory exists', () => { + const store = new GlobalConfigStore() + + expect(store.get('userId')).toBe(undefined) + }) + }) + + describe('#set', () => { + it('updates an existing configuration file', async () => { + await fs.mkdir(configPath, { recursive: true }) + await fs.writeFile(configFilePath, JSON.stringify({ userId: 'value' })) + + const store = new GlobalConfigStore() + store.set('cliId', 'another value') + const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) + + expect(data).toEqual(expect.objectContaining({ userId: 'value', cliId: 'another value' })) + }) + + it('creates a configuration file when one does not exist', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const store = new GlobalConfigStore() + store.set('userId', 'new file') + const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) + + expect(data).toEqual(expect.objectContaining({ userId: 'new file' })) + }) + + it('creates a configuration directory when one does not exist', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const store = new GlobalConfigStore() + store.set('userId', 'new directory') + const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) + + expect(data).toEqual(expect.objectContaining({ userId: 'new directory' })) + }) + + it('sets nested values', async () => { + await fs.mkdir(configPath, { recursive: true }) + + const store = new GlobalConfigStore() + store.set('users.some-user-id', { id: 'some-user-id' }) + const data: unknown = JSON.parse(await fs.readFile(configFilePath, 'utf8')) + + expect(data).toEqual(expect.objectContaining({ users: { 'some-user-id': { id: 'some-user-id' } } })) + }) + }) +}) diff --git a/tests/unit/utils/init/config-github.test.ts b/tests/unit/utils/init/config-github.test.ts index 5d0003dcab8..3c8298264f3 100644 --- a/tests/unit/utils/init/config-github.test.ts +++ b/tests/unit/utils/init/config-github.test.ts @@ -1,6 +1,7 @@ import { Octokit } from '@octokit/rest' import { beforeEach, describe, expect, test, vi } from 'vitest' -import type { GlobalConfigStore } from '../../../../src/utils/get-global-config-store.js' +import { GlobalConfigStore } from '../../../../src/utils/global-config/store.js' +import { MemoryStorageAdapter } from '../../../../src/utils/global-config/storage-adapter-memory.js' import { getGitHubToken } from '../../../../src/utils/init/config-github.js' @@ -37,15 +38,11 @@ describe('getGitHubToken', () => { let globalConfig: GlobalConfigStore beforeEach(() => { - const values = new Map() - // @ts-expect-error FIXME(ndhoule): mock is not full, make it more realistic - globalConfig = { - get: (key) => values.get(key), - set: (key, value) => { - values.set(key, value) - }, - } + globalConfig = new GlobalConfigStore({ + store: new MemoryStorageAdapter(), + }) globalConfig.set('userId', 'spongebob') + globalConfig.set('users.spongebob.id', 'spongebob') globalConfig.set(`users.spongebob.auth.github`, { provider: 'github', token: 'old_token',