diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 9d2d266..c02bdf8 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -18,6 +18,7 @@ title: Changelog * `[Added]` Add `lets self doc` command to open the online documentation in a browser. * `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out. * `[Changed]` Centralize the `lets:` log prefix in the formatter and render debug messages in blue. +* `[Added]` Add user settings in `~/.config/lets/config.yaml` for lets behavior such as `no_color` and `upgrade_notify`, with env variables still taking precedence. * `[Fixed]` Resolve `go to definition` from YAML merge aliases such as `<<: *test` to the referenced command in `lets self lsp`. * `[Added]` Load local mixin files into LSP storage and command index so mixin commands are available for navigation. diff --git a/docs/docs/settings.md b/docs/docs/settings.md new file mode 100644 index 0000000..78f6b6b --- /dev/null +++ b/docs/docs/settings.md @@ -0,0 +1,78 @@ +--- +id: settings +title: Settings +--- + +`lets` settings control the behavior of `lets` itself. + +Use settings for things like colored output or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`. + +## Settings file location + +`lets` reads settings from: + +```text +~/.config/lets/config.yaml +``` + +This file is per-user and applies to all projects on the machine. + +## Precedence + +Settings are resolved in this order: + +1. environment variables +2. settings file +3. built-in defaults + +This means env vars always win over `config.yaml`. + +## Supported settings + +### `no_color` + +Disable colored output from `lets`. + +Example: + +```yaml +no_color: true +``` + +Environment override: + +- `NO_COLOR` disables colors even if `no_color` is not set + +Note: + +- this affects `lets` output itself +- it does not inject `NO_COLOR` into commands from `lets.yaml` + +### `upgrade_notify` + +Enable or disable background update notifications for interactive sessions. + +Example: + +```yaml +upgrade_notify: false +``` + +Environment override: + +- `LETS_CHECK_UPDATE` disables update checks and notifications regardless of the settings file + +Default: + +- `upgrade_notify: true` + +## Example + +```yaml +no_color: true +upgrade_notify: false +``` + +## Invalid settings + +Unknown keys and invalid YAML cause `lets` startup to fail with an error. Keep this file limited to supported settings only. diff --git a/docs/package-lock.json b/docs/package-lock.json index 8758a18..2e414fd 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -105,6 +105,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.14.2.tgz", "integrity": "sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw==", + "peer": true, "dependencies": { "@algolia/client-common": "4.14.2", "@algolia/requester-common": "4.14.2", @@ -195,6 +196,7 @@ "version": "7.19.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", @@ -2687,6 +2689,7 @@ "version": "7.12.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -3008,6 +3011,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.5.0.tgz", "integrity": "sha512-jIbu36GMjfK8HCCQitkfVVeQ2vSXGfq0ef0GO9HUxZGjal6Kvpkk4PwpkFP+OyCzF+skQFT9aWrUqekT3pKF8w==", + "peer": true, "dependencies": { "@babel/core": "^7.18.5", "@svgr/babel-preset": "^6.5.0", @@ -3298,6 +3302,7 @@ "version": "18.0.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.21.tgz", "integrity": "sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3578,6 +3583,7 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3625,6 +3631,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3684,6 +3691,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.14.2.tgz", "integrity": "sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg==", + "peer": true, "dependencies": { "@algolia/cache-browser-local-storage": "4.14.2", "@algolia/cache-common": "4.14.2", @@ -4122,6 +4130,7 @@ "url": "https://tidelift.com/funding/github/npm/browserslist" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001400", "electron-to-chromium": "^1.4.251", @@ -4719,6 +4728,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4968,6 +4978,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6206,6 +6217,20 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -7817,6 +7842,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -8481,6 +8507,7 @@ "url": "https://tidelift.com/funding/github/npm/postcss" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -9272,6 +9299,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9394,6 +9422,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9459,6 +9488,7 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "peer": true, "dependencies": { "@types/react": "*", "prop-types": "^15.6.2" @@ -9486,6 +9516,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -9738,6 +9769,7 @@ "version": "7.12.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -11810,6 +11842,7 @@ "version": "5.76.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -11908,6 +11941,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12038,6 +12072,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12475,6 +12510,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.14.2.tgz", "integrity": "sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw==", + "peer": true, "requires": { "@algolia/client-common": "4.14.2", "@algolia/requester-common": "4.14.2", @@ -12556,6 +12592,7 @@ "version": "7.19.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "peer": true, "requires": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", @@ -14304,6 +14341,7 @@ "version": "7.12.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "peer": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -14500,6 +14538,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.5.0.tgz", "integrity": "sha512-jIbu36GMjfK8HCCQitkfVVeQ2vSXGfq0ef0GO9HUxZGjal6Kvpkk4PwpkFP+OyCzF+skQFT9aWrUqekT3pKF8w==", + "peer": true, "requires": { "@babel/core": "^7.18.5", "@svgr/babel-preset": "^6.5.0", @@ -14743,6 +14782,7 @@ "version": "18.0.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.21.tgz", "integrity": "sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA==", + "peer": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -15015,7 +15055,8 @@ "acorn": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==" + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "peer": true }, "acorn-import-assertions": { "version": "1.8.0", @@ -15046,6 +15087,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15089,6 +15131,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.14.2.tgz", "integrity": "sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg==", + "peer": true, "requires": { "@algolia/cache-browser-local-storage": "4.14.2", "@algolia/cache-common": "4.14.2", @@ -15422,6 +15465,7 @@ "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", + "peer": true, "requires": { "caniuse-lite": "^1.0.30001400", "electron-to-chromium": "^1.4.251", @@ -15847,6 +15891,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16001,6 +16046,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16905,6 +16951,12 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", @@ -18068,6 +18120,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -18537,6 +18590,7 @@ "version": "8.4.18", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.18.tgz", "integrity": "sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA==", + "peer": true, "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -19049,6 +19103,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -19140,6 +19195,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -19193,6 +19249,7 @@ "version": "npm:@docusaurus/react-loadable@5.5.2", "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "peer": true, "requires": { "@types/react": "*", "prop-types": "^15.6.2" @@ -19210,6 +19267,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, "requires": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -19407,6 +19465,7 @@ "version": "7.12.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.9.tgz", "integrity": "sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ==", + "peer": true, "requires": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -20879,6 +20938,7 @@ "version": "5.76.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", + "peer": true, "requires": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^0.0.51", @@ -20970,6 +21030,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -21061,6 +21122,7 @@ "version": "8.11.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", diff --git a/docs/sidebars.js b/docs/sidebars.js index 8447a3a..b756bc3 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -1,90 +1,91 @@ module.exports = { mySidebar: [ { - type: 'category', - label: 'Introduction', + type: "category", + label: "Introduction", collapsed: false, items: [ { - type: 'doc', - id: 'what_is_lets', + type: "doc", + id: "what_is_lets", }, { - type: 'doc', - id: 'installation', + type: "doc", + id: "installation", }, { - type: 'doc', - id: 'quick_start', + type: "doc", + id: "quick_start", }, { - type: 'doc', - id: 'completion', + type: "doc", + id: "completion", }, ], }, { - type: 'category', - label: 'Usage', + type: "category", + label: "Usage", items: [ { - type: 'doc', - id: 'basic_usage', + type: "doc", + id: "basic_usage", }, { - type: 'doc', - id: 'advanced_usage', + type: "doc", + id: "advanced_usage", }, ], }, - 'config', + "config", + "settings", { - type: 'category', - label: 'API Reference', + type: "category", + label: "API Reference", items: [ { - type: 'doc', - id: 'cli', + type: "doc", + id: "cli", }, { - type: 'doc', - id: 'env', + type: "doc", + id: "env", }, ], }, { - type: 'category', - label: 'Examples', + type: "category", + label: "Examples", items: [ { - type: 'doc', - id: 'examples', + type: "doc", + id: "examples", }, { - type: 'doc', - id: 'example_js', + type: "doc", + id: "example_js", }, ], }, - 'best_practices', - 'changelog', - 'ide_support', + "best_practices", + "changelog", + "ide_support", { - type: 'category', - label: 'Development', + type: "category", + label: "Development", items: [ { - type: 'doc', - id: 'architecture', + type: "doc", + id: "architecture", }, { - type: 'doc', - id: 'development', + type: "doc", + id: "development", }, { - type: 'doc', - id: 'contribute', + type: "doc", + id: "contribute", }, ], }, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 93d9ff0..d65d7c7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/lets-cli/lets/internal/executor" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" + "github.com/lets-cli/lets/internal/settings" "github.com/lets-cli/lets/internal/upgrade" "github.com/lets-cli/lets/internal/upgrade/registry" "github.com/lets-cli/lets/internal/workdir" @@ -36,6 +37,14 @@ func Main(version string, buildDate string) int { configDir := os.Getenv("LETS_CONFIG_DIR") + appSettings, err := settings.Load() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "lets: settings error: %s\n", err) + return 1 + } + + appSettings.Apply() + logging.InitLogging(os.Stdout, os.Stderr) rootCmd := cmd.CreateRootCommand(version, buildDate) @@ -128,7 +137,7 @@ func Main(version string, buildDate string) int { return 0 } - updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command) + updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command, appSettings) defer cancelUpdateCheck() if err := rootCmd.ExecuteContext(ctx); err != nil { @@ -209,8 +218,9 @@ func maybeStartUpdateCheck( ctx context.Context, version string, command *cobra.Command, + appSettings settings.Settings, ) (<-chan updateCheckResult, context.CancelFunc) { - if !shouldCheckForUpdate(command.Name(), isInteractiveStderr()) { + if !shouldCheckForUpdate(command.Name(), isInteractiveStderr(), appSettings) { return nil, func() {} } @@ -263,8 +273,8 @@ func printUpdateNotice(updateCh <-chan updateCheckResult) { } } -func shouldCheckForUpdate(commandName string, interactive bool) bool { - if !interactive || os.Getenv("CI") != "" || os.Getenv("LETS_CHECK_UPDATE") != "" { +func shouldCheckForUpdate(commandName string, interactive bool, appSettings settings.Settings) bool { + if !interactive || !appSettings.UpgradeNotify || os.Getenv("CI") != "" { return false } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 321809b..5147d5f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,6 +4,7 @@ import ( "testing" cmdpkg "github.com/lets-cli/lets/internal/cmd" + "github.com/lets-cli/lets/internal/settings" "github.com/spf13/cobra" ) @@ -59,38 +60,41 @@ func TestAllowsMissingConfig(t *testing.T) { } func TestShouldCheckForUpdate(t *testing.T) { + defaultSettings := settings.Default() + t.Run("should allow normal interactive commands", func(t *testing.T) { t.Setenv("CI", "") - t.Setenv("LETS_CHECK_UPDATE", "") - if !shouldCheckForUpdate("lets", true) { + if !shouldCheckForUpdate("lets", true, defaultSettings) { t.Fatal("expected update check to be enabled") } }) t.Run("should skip non interactive sessions", func(t *testing.T) { - if shouldCheckForUpdate("lets", false) { + if shouldCheckForUpdate("lets", false, defaultSettings) { t.Fatal("expected non-interactive session to skip update check") } }) t.Run("should skip when CI is set", func(t *testing.T) { t.Setenv("CI", "1") - if shouldCheckForUpdate("lets", true) { + if shouldCheckForUpdate("lets", true, defaultSettings) { t.Fatal("expected CI to skip update check") } }) - t.Run("should skip when notifier disabled", func(t *testing.T) { - t.Setenv("LETS_CHECK_UPDATE", "1") - if shouldCheckForUpdate("lets", true) { - t.Fatal("expected opt-out env to skip update check") + t.Run("should skip when notifier disabled in settings", func(t *testing.T) { + disabled := settings.Default() + disabled.UpgradeNotify = false + + if shouldCheckForUpdate("lets", true, disabled) { + t.Fatal("expected disabled settings to skip update check") } }) t.Run("should skip internal commands", func(t *testing.T) { for _, name := range []string{"completion", "help", "lsp", "self"} { - if shouldCheckForUpdate(name, true) { + if shouldCheckForUpdate(name, true, defaultSettings) { t.Fatalf("expected %q to skip update check", name) } } diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 0000000..d2be135 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,87 @@ +package settings + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/lets-cli/lets/internal/util" + "gopkg.in/yaml.v3" +) + +type FileSettings struct { + NoColor *bool `yaml:"no_color"` + UpgradeNotify *bool `yaml:"upgrade_notify"` +} + +type Settings struct { + NoColor bool + UpgradeNotify bool +} + +func Default() Settings { + return Settings{ + NoColor: false, + UpgradeNotify: true, + } +} + +func Load() (Settings, error) { + path, err := util.LetsUserFile("config.yaml") + if err != nil { + return Settings{}, err + } + + return LoadFile(path) +} + +func LoadFile(path string) (Settings, error) { + cfg := Default() + + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + applyEnvOverrides(&cfg) + return cfg, nil + } + + return Settings{}, fmt.Errorf("failed to open settings file: %w", err) + } + + defer file.Close() + + var fileSettings FileSettings + decoder := yaml.NewDecoder(file) + decoder.KnownFields(true) + if err := decoder.Decode(&fileSettings); err != nil { + return Settings{}, fmt.Errorf("failed to decode settings file: %w", err) + } + + if fileSettings.NoColor != nil { + cfg.NoColor = *fileSettings.NoColor + } + + if fileSettings.UpgradeNotify != nil { + cfg.UpgradeNotify = *fileSettings.UpgradeNotify + } + + applyEnvOverrides(&cfg) + + return cfg, nil +} + +func (s Settings) Apply() { + if s.NoColor { + color.NoColor = true + } +} + +func applyEnvOverrides(cfg *Settings) { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + cfg.NoColor = true + } + + if _, ok := os.LookupEnv("LETS_CHECK_UPDATE"); ok { + cfg.UpgradeNotify = false + } +} diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..45b9d4b --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,150 @@ +package settings + +import ( + "os" + "path/filepath" + "testing" + + "github.com/fatih/color" +) + +func unsetEnv(t *testing.T, key string) { + t.Helper() + + oldValue, hadValue := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("failed to unset %s: %v", key, err) + } + + t.Cleanup(func() { + if hadValue { + _ = os.Setenv(key, oldValue) + return + } + + _ = os.Unsetenv(key) + }) +} + +func TestLoadFile(t *testing.T) { + t.Run("uses defaults when file is missing", func(t *testing.T) { + unsetEnv(t, "NO_COLOR") + unsetEnv(t, "LETS_CHECK_UPDATE") + + cfg, err := LoadFile(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + + if cfg.NoColor { + t.Fatal("expected no_color default to be false") + } + if !cfg.UpgradeNotify { + t.Fatal("expected upgrade_notify default to be true") + } + }) + + t.Run("loads file values", func(t *testing.T) { + unsetEnv(t, "NO_COLOR") + unsetEnv(t, "LETS_CHECK_UPDATE") + + path := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(path, []byte("no_color: true\nupgrade_notify: false\n"), 0o644) + if err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + + if !cfg.NoColor { + t.Fatal("expected no_color to be true") + } + if cfg.UpgradeNotify { + t.Fatal("expected upgrade_notify to be false") + } + }) + + t.Run("env overrides file values", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(path, []byte("no_color: false\nupgrade_notify: true\n"), 0o644) + if err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + t.Setenv("NO_COLOR", "") + t.Setenv("LETS_CHECK_UPDATE", "1") + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile() error = %v", err) + } + + if !cfg.NoColor { + t.Fatal("expected NO_COLOR to override settings file") + } + if cfg.UpgradeNotify { + t.Fatal("expected LETS_CHECK_UPDATE to disable notifications") + } + }) + + t.Run("rejects unknown fields", func(t *testing.T) { + unsetEnv(t, "NO_COLOR") + unsetEnv(t, "LETS_CHECK_UPDATE") + + path := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(path, []byte("wat: true\n"), 0o644) + if err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + _, err = LoadFile(path) + if err == nil { + t.Fatal("expected error") + } + }) +} + +func TestLoad(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + unsetEnv(t, "NO_COLOR") + unsetEnv(t, "LETS_CHECK_UPDATE") + + configPath := filepath.Join(tmpDir, ".config", "lets", "config.yaml") + err := os.MkdirAll(filepath.Dir(configPath), 0o755) + if err != nil { + t.Fatalf("failed to create config dir: %v", err) + } + + err = os.WriteFile(configPath, []byte("no_color: true\n"), 0o644) + if err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if !cfg.NoColor { + t.Fatal("expected loaded no_color to be true") + } +} + +func TestApply(t *testing.T) { + previous := color.NoColor + t.Cleanup(func() { + color.NoColor = previous + }) + + color.NoColor = false + + Settings{NoColor: true}.Apply() + + if !color.NoColor { + t.Fatal("expected Apply to disable colors") + } +} diff --git a/internal/upgrade/notifier.go b/internal/upgrade/notifier.go index 842c59b..f62bce6 100644 --- a/internal/upgrade/notifier.go +++ b/internal/upgrade/notifier.go @@ -221,12 +221,7 @@ func (n *UpdateNotifier) writeState(state notifierState) error { } func letsStatePath() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get user config dir: %w", err) - } - - return filepath.Join(homeDir, ".config", "lets", "state.yaml"), nil + return util.LetsUserFile("state.yaml") } func parseStableVersion(version string) (*semver.Version, bool) { diff --git a/internal/util/lets_user_dir.go b/internal/util/lets_user_dir.go new file mode 100644 index 0000000..4ebfa83 --- /dev/null +++ b/internal/util/lets_user_dir.go @@ -0,0 +1,25 @@ +package util + +import ( + "fmt" + "os" + "path/filepath" +) + +func LetsUserDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home dir: %w", err) + } + + return filepath.Join(homeDir, ".config", "lets"), nil +} + +func LetsUserFile(name string) (string, error) { + dir, err := LetsUserDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, name), nil +} diff --git a/internal/util/lets_user_dir_test.go b/internal/util/lets_user_dir_test.go new file mode 100644 index 0000000..c24d394 --- /dev/null +++ b/internal/util/lets_user_dir_test.go @@ -0,0 +1,36 @@ +package util + +import ( + "path/filepath" + "testing" +) + +func TestLetsUserDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + dir, err := LetsUserDir() + if err != nil { + t.Fatalf("LetsUserDir() error = %v", err) + } + + expected := filepath.Join(tmpDir, ".config", "lets") + if dir != expected { + t.Fatalf("expected %q, got %q", expected, dir) + } +} + +func TestLetsUserFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("HOME", tmpDir) + + path, err := LetsUserFile("state.yaml") + if err != nil { + t.Fatalf("LetsUserFile() error = %v", err) + } + + expected := filepath.Join(tmpDir, ".config", "lets", "state.yaml") + if path != expected { + t.Fatalf("expected %q, got %q", expected, path) + } +}