From cb611a9f469f53f9df4ff52a7f9a37069b811619 Mon Sep 17 00:00:00 2001 From: Nathan Houle Date: Mon, 21 Apr 2025 10:05:12 -0700 Subject: [PATCH] refactor: type global configuration file Follow-up to a comment thread in https://github.com/netlify/cli/pull/7199. This changeset adds types for the global configuration file (`$CONFIG_HOME/netlify/config.json`). This is mainly a step forward in documenting the configuration format. The type safety on it isn't ideal; e.g. you can try to read or write properties that don't exist in the config schema. That said, it's an improvement on the read side in the sense that any property you read is typed--even unknown ones default to `undefined`. This comes with two _potential_ breaking changes; I didn't find that they actually broke anything in practice, but they're worth looking out for in review: 1. We now set defaults for some values we did not previously set defaults for, e.g. `users[id: string].auth` defaults to an object. This could only break if a code path assumes e.g. the presence of `auth.github` means the user is authenticated; however, the way the types are structured marks all properties on that object as undefined, so this is a difficult error to make now. 2. We now validate that `users[id: string].id` is set. Failing to set it would make looking up user configuration impossible (we index into this object using the top-level `userId` field), so this prevents a class of mysterious logic errors. Stronger typing caused some issues in tests that mock the global config store; broke the `GlobalConfigStore`'s storage logic out into an adapter and implemented an in-memory adapter for use in tests. There are better ways to solve this problem--tests should be able to mock the global config without resorting to module mocking--but this will do for now. I think the global config store interface could use some work: at this point I think the dot-prop style of setting and getting properties only adds complexity vs something like a Proxy-wrapped object. That said, this is a step forward without committing to a bigger change. --- package-lock.json | 179 ++++++++--------- package.json | 4 +- src/commands/logs/build.ts | 5 + src/commands/status/status.ts | 4 +- src/commands/switch/switch.ts | 31 ++- src/lib/settings.ts | 7 - src/utils/get-global-config-store.ts | 110 +---------- src/utils/global-config/main.ts | 17 ++ src/utils/global-config/schema.ts | 80 ++++++++ .../storage-adapter-atomic-disk.ts | 41 ++++ .../global-config/storage-adapter-memory.ts | 17 ++ src/utils/global-config/storage-adapter.ts | 13 ++ src/utils/global-config/store.ts | 54 +++++ src/utils/init/config-github.ts | 10 +- .../utils/get-global-config-store.test.ts | 187 ------------------ tests/unit/utils/global-config/main.test.ts | 13 ++ tests/unit/utils/global-config/store.test.ts | 158 +++++++++++++++ tests/unit/utils/init/config-github.test.ts | 15 +- 18 files changed, 509 insertions(+), 436 deletions(-) create mode 100644 src/utils/global-config/main.ts create mode 100644 src/utils/global-config/schema.ts create mode 100644 src/utils/global-config/storage-adapter-atomic-disk.ts create mode 100644 src/utils/global-config/storage-adapter-memory.ts create mode 100644 src/utils/global-config/storage-adapter.ts create mode 100644 src/utils/global-config/store.ts delete mode 100644 tests/unit/utils/get-global-config-store.test.ts create mode 100644 tests/unit/utils/global-config/main.test.ts create mode 100644 tests/unit/utils/global-config/store.test.ts 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',