diff --git a/AGENT.md b/AGENT.md index 5c83953..5d12796 100644 --- a/AGENT.md +++ b/AGENT.md @@ -15,7 +15,7 @@ - **Main Entry**: `bin/raygun_cli.dart` - CLI argument parsing and command routing - **Commands**: `lib/src/` - Five main command modules: sourcemap, symbols, deployments, proguard, dsym - **APIs**: Each command has corresponding API client (`*_api.dart`) for Raygun REST API calls -- **Config**: `config_props.dart` handles arg parsing with env var fallbacks (RAYGUN_APP_ID, RAYGUN_TOKEN, RAYGUN_API_KEY) +- **Config**: `config_props.dart` handles arg parsing with env var and `.env` file fallbacks (RAYGUN_APP_ID, RAYGUN_TOKEN, RAYGUN_API_KEY). Resolution order: CLI arg > env var > `.env` file. Empty and whitespace-only values at any tier are treated as missing and fall through. With `-v`/`--verbose`, each property's resolved source is logged. See `lib/src/config_file.dart` for `.env` discovery logic — discovery walks up from CWD, stopping at `$HOME`/`%USERPROFILE%`; if neither is set, discovery is restricted to CWD only. ### Directory Structure ``` @@ -326,8 +326,29 @@ export RAYGUN_TOKEN=your-token export RAYGUN_API_KEY=your-api-key ``` +### `.env` Config File +The same keys are also read from a `.env` file in the current working directory +(or any parent directory, walking up to `$HOME`/`%USERPROFILE%`). When neither +`HOME` nor `USERPROFILE` is set (sandboxed CI runners, minimal containers), +discovery is restricted to CWD only — no upward walking. An explicit path can +always be passed with `--config-file=`. + +```env +# .env +RAYGUN_APP_ID=your-app-id +RAYGUN_TOKEN=your-token +RAYGUN_API_KEY=your-api-key +``` + +Resolution precedence: CLI argument > environment variable > `.env` file. +Empty (`KEY=`) and whitespace-only (`KEY=" "`) values at any tier are treated +as missing and fall through to the next source — this prevents opaque HTTP +4xx errors when a user has copied the example `.env` and forgotten to fill +in a value. Use `-v`/`--verbose` to print which source supplied each value. + +A sample is committed at `example/.env.example`. + ## Known TODOs & Future Improvements -- Config file support (.raygun.yaml or similar) - see `lib/src/config_props.dart:9` - NodeJS sourcemap platform support (currently stubbed in sourcemap command) - System package manager installations (brew, apt, etc.) diff --git a/README.md b/README.md index f0a2bcf..1b80333 100644 --- a/README.md +++ b/README.md @@ -68,16 +68,50 @@ All `raygun-cli` commands share the same configuration parameters. - Token: An access token from https://app.raygun.com/user/tokens. - API key: The API key of your application in Raygun.com -You can pass these parameters via arguments, e.g. `--app-id=` -or you can set them as environment variables. - -Parameters passed as arguments have priority over environment variables. - -| Parameter | Argument | Environment Variable | -|-----------|----------|----------------------| -| App ID | `app-id` | `RAYGUN_APP_ID` | -| Token | `token` | `RAYGUN_TOKEN` | -| API key | `api-key`| `RAYGUN_API_KEY` | +You can provide these parameters via three sources, listed below in order of +priority (highest first): + +1. **CLI argument** — e.g. `--app-id=` +2. **Environment variable** — e.g. `RAYGUN_APP_ID=` +3. **`.env` config file** — see [Config file](#config-file) below + +| Parameter | Argument | Environment Variable / `.env` key | +|-----------|-----------|-----------------------------------| +| App ID | `app-id` | `RAYGUN_APP_ID` | +| Token | `token` | `RAYGUN_TOKEN` | +| API key | `api-key` | `RAYGUN_API_KEY` | + +##### Config file + +`raygun-cli` can read its configuration from a standard `.env` file. By +default, it looks for a `.env` in the current working directory and, if not +found, walks up parent directories until it finds one — stopping at your home +directory (`$HOME` / `%USERPROFILE%`). If neither is set (e.g. in some +sandboxed CI runners or minimal containers), discovery is restricted to the +current directory only; pass `--config-file=` for an explicit override +in those environments. + +> ⚠️ A `.env` typically contains secrets — make sure to add it to your +> `.gitignore` to avoid committing tokens to source control. + +Empty (`RAYGUN_TOKEN=`) and whitespace-only (`RAYGUN_TOKEN=" "`) values are +treated as **unset** at every tier (CLI argument, environment variable, and +`.env` file) and fall through to the next source. This way, copying +[`example/.env.example`](example/.env.example) and forgetting to fill in a +value — or fat-fingering an env var to whitespace — surfaces a friendly +`Error: Missing "..."` instead of an opaque HTTP error from the Raygun API. +Run with `-v` / `--verbose` to see exactly which source supplied each value. + +Example `.env`: + +```env +# Raygun CLI configuration +RAYGUN_APP_ID=your-app-id +RAYGUN_TOKEN=your-personal-access-token +RAYGUN_API_KEY=your-application-api-key +``` + +A sample is provided at [`example/.env.example`](example/.env.example). #### Sourcemap Uploader diff --git a/bin/raygun_cli.dart b/bin/raygun_cli.dart index 05ae1d7..2120e34 100644 --- a/bin/raygun_cli.dart +++ b/bin/raygun_cli.dart @@ -1,5 +1,6 @@ import 'package:args/args.dart'; import 'package:raygun_cli/raygun_cli.dart'; +import 'package:raygun_cli/src/config_file.dart'; const String version = '1.2.1'; @@ -18,6 +19,12 @@ ArgParser buildParser() { help: 'Show additional command output.', ) ..addFlag('version', negatable: false, help: 'Print the tool version.') + ..addOption( + 'config-file', + help: + 'Path to a .env config file. If omitted, the CLI will look for a ' + '.env file in the current directory and parent directories.', + ) ..addCommand(sourcemapCommand.name, sourcemapCommand.buildParser()) ..addCommand(symbolsCommand.name, symbolsCommand.buildParser()) ..addCommand(deploymentsCommand.name, deploymentsCommand.buildParser()) @@ -60,6 +67,17 @@ void main(List arguments) { return; } + // Initialize the config file singleton before any subcommand executes, + // so that ConfigProp.load() can read from it. + ConfigFile.setInstance( + ConfigFile.load( + explicitPath: results.wasParsed('config-file') + ? results['config-file'] as String + : null, + verbose: verbose, + ), + ); + if (results.command?.name == sourcemapCommand.name) { sourcemapCommand.execute(results.command!, verbose); return; diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..53281f0 --- /dev/null +++ b/example/.env.example @@ -0,0 +1,15 @@ +# Raygun CLI configuration +# +# Copy this file to `.env` in your project root (or any parent directory) +# and fill in your real credentials. raygun-cli will load it automatically. +# +# IMPORTANT: add `.env` to your .gitignore — it usually contains secrets. + +# The Application ID in Raygun.com (e.g. "abc123"). +RAYGUN_APP_ID= + +# A Personal Access Token from https://app.raygun.com/user/tokens +RAYGUN_TOKEN= + +# The API key of your application in Raygun.com +RAYGUN_API_KEY= diff --git a/lib/src/config_file.dart b/lib/src/config_file.dart new file mode 100644 index 0000000..920796b --- /dev/null +++ b/lib/src/config_file.dart @@ -0,0 +1,167 @@ +import 'dart:io'; + +import 'package:dotenv/dotenv.dart'; +import 'package:path/path.dart' as p; + +/// Wraps access to a `.env` config file. +/// +/// Lookup order during discovery: +/// 1. An explicit path provided via `--config-file=`. +/// 2. A `.env` in the current working directory. +/// 3. A `.env` in any parent directory, walking up to (but not past) the +/// user's home directory (`$HOME` / `%USERPROFILE%`). +/// +/// When neither `$HOME` nor `%USERPROFILE%` is set (e.g. in sandboxed CI +/// runners or minimal containers), discovery is restricted to the current +/// working directory only — we deliberately do not walk to the filesystem +/// root, which could otherwise pick up a stray `/.env` or `/etc/.env`. +/// Use `--config-file=` for explicit control in those environments. +/// +/// If no file is found, an empty instance is returned (silent no-op). +/// +/// Allows faking via [setInstance] for testing. +class ConfigFile { + static const String fileName = '.env'; + + static ConfigFile? _instance; + + final DotEnv _env; + + /// The absolute path to the loaded `.env`, or `null` if no file was loaded. + final String? path; + + ConfigFile._(this._env, this.path); + + /// Empty instance used when no `.env` was found. + factory ConfigFile.empty() => ConfigFile._(DotEnv(quiet: true), null); + + /// Singleton instance access. + /// + /// Will perform discovery if not already initialized. To override discovery + /// (e.g. via `--config-file`), call [setInstance] before accessing. + static ConfigFile get instance => _instance ??= load(); + + /// For testing or to inject a pre-loaded instance. + static void setInstance(ConfigFile instance) { + _instance = instance; + } + + /// For testing: clear the singleton so the next [instance] access re-discovers. + static void resetForTest() { + _instance = null; + } + + /// Read a value for [key]. Returns `null` if not present. + String? operator [](String key) => _env[key]; + + /// Discover and load a `.env` file. + /// + /// - [explicitPath]: if non-null, load that file directly. If the file does + /// not exist, prints an error and exits with code 2. + /// - [startDir]: directory to start searching from (defaults to CWD). + /// - [stopDir]: directory to stop searching at, inclusive (defaults to the + /// user's home directory). When `null` and `HOME`/`USERPROFILE` are also + /// unset, discovery is restricted to [startDir] only — no walking up. + /// - [environmentOverride]: optional map used in place of + /// `Platform.environment` for resolving `HOME`/`USERPROFILE`. Intended + /// for tests; production callers should leave this `null`. + /// - [verbose]: if true, prints the path of the loaded file. + static ConfigFile load({ + String? explicitPath, + String? startDir, + String? stopDir, + Map? environmentOverride, + bool verbose = false, + }) { + if (explicitPath != null) { + final file = File(explicitPath); + if (!file.existsSync()) { + print('Error: --config-file points to a missing file: $explicitPath'); + exit(2); + } + final env = DotEnv(quiet: true)..load([file.absolute.path]); + if (verbose) print('Loaded config from ${file.absolute.path}'); + return ConfigFile._(env, file.absolute.path); + } + + final effectiveStartDir = startDir ?? Directory.current.path; + final effectiveStopDir = stopDir ?? _defaultStopDir(environmentOverride); + + final String? discovered; + if (effectiveStopDir == null) { + // No home boundary available — only check the CWD. Walking up to the + // filesystem root would risk silently picking up a stray /.env or + // /etc/.env in sandboxed CI runners. + if (verbose) { + print( + '[VERBOSE] HOME/USERPROFILE not set; restricting .env discovery ' + 'to current directory', + ); + } + discovered = _checkSingleDir(effectiveStartDir); + } else { + discovered = _discover( + startDir: effectiveStartDir, + stopDir: effectiveStopDir, + ); + } + + if (discovered == null) return ConfigFile.empty(); + + final env = DotEnv(quiet: true)..load([discovered]); + if (verbose) print('Loaded config from $discovered'); + return ConfigFile._(env, discovered); + } + + /// Returns the absolute path to a `.env` directly in [dir], or null. + /// Used when no upward walk is performed. + static String? _checkSingleDir(String dir) { + final candidate = File(p.join(p.absolute(dir), fileName)); + return candidate.existsSync() ? candidate.absolute.path : null; + } + + /// Walk up from [startDir] looking for [fileName]. Stops once the parent + /// of the current directory is outside [stopDir]'s subtree, or once the + /// filesystem root is reached. + static String? _discover({ + required String startDir, + required String stopDir, + }) { + final stopAbsolute = p.absolute(stopDir); + var dir = Directory(p.absolute(startDir)); + + while (true) { + final candidate = File(p.join(dir.path, fileName)); + if (candidate.existsSync()) { + return candidate.absolute.path; + } + + // Stop if we've reached the configured boundary. + if (p.equals(dir.path, stopAbsolute)) return null; + + final parent = dir.parent; + // Reached filesystem root. + if (p.equals(parent.path, dir.path)) return null; + + // Don't escape the stopDir's subtree. + if (!p.isWithin(stopAbsolute, parent.path) && + !p.equals(parent.path, stopAbsolute)) { + return null; + } + + dir = parent; + } + } + + /// Default boundary for upward discovery: the user's home directory. + /// Returns `null` if neither `HOME` nor `USERPROFILE` is set, in which + /// case [load] will restrict discovery to the start directory only. + /// + /// [environmentOverride] is for tests; when `null`, reads from + /// `Platform.environment`. + static String? _defaultStopDir([Map? environmentOverride]) { + final src = environmentOverride ?? Platform.environment; + final home = src['HOME'] ?? src['USERPROFILE']; + return (home != null && home.isNotEmpty) ? home : null; + } +} diff --git a/lib/src/config_props.dart b/lib/src/config_props.dart index 29b1eb2..e9e49b5 100644 --- a/lib/src/config_props.dart +++ b/lib/src/config_props.dart @@ -1,12 +1,20 @@ import 'dart:io'; import 'package:args/args.dart'; +import 'package:raygun_cli/src/config_file.dart'; import 'package:raygun_cli/src/environment.dart'; -/// A Config property is a value -/// that can be set via argument -/// or environment variable. -/// TODO: #5 add support for config files (.raygun.yaml or similar) +/// A Config property is a value that can be set via: +/// 1. CLI argument (highest priority) +/// 2. Environment variable +/// 3. `.env` config file (lowest priority) +/// +/// Empty (`''`) or whitespace-only (`' '`, `'\t'`, …) values at any tier +/// are treated as **not set** and the lookup falls through to the next tier. +/// This avoids surfacing opaque downstream errors (e.g. an HTTP 401/404 from +/// Raygun) when a user has left a key blank in their `.env` file or fat- +/// fingered an env var. Raygun credentials never legitimately contain only +/// whitespace. class ConfigProp { static const appId = ConfigProp( name: 'app-id', @@ -31,21 +39,70 @@ class ConfigProp { const ConfigProp({required this.name, required this.envKey}); - /// Load the value of the property from arguments or environment variables - String load(ArgResults arguments) { - String? value; - if (arguments.wasParsed(name)) { - value = arguments[name]; - } else { - value = Environment.instance[envKey]; + /// Load the value of the property. + /// + /// Resolution order: CLI argument > environment variable > `.env` file. + /// Empty or whitespace-only values at any tier are treated as missing and + /// fall through. Exits with code 2 if the value cannot be found in any + /// source. + /// + /// When [verbose] is true, prints a `[VERBOSE] Resolved …` line indicating + /// which source supplied the value, and a `[VERBOSE] Ignoring blank …` + /// notice when a present-but-blank value is skipped. + String load(ArgResults arguments, {bool verbose = false}) { + final argValue = arguments.wasParsed(name) + ? arguments[name] as String? + : null; + if (_isPresent(argValue)) { + if (verbose) print('[VERBOSE] Resolved $name from CLI argument'); + return argValue!; } - if (value == null) { - print('Error: Missing "$name"'); + if (argValue != null && verbose) { print( - ' Please provide "$name" via argument or environment variable "$envKey"', + '[VERBOSE] Ignoring blank value for $name from CLI argument; ' + 'falling through', ); - exit(2); } - return value; + + final envValue = Environment.instance[envKey]; + if (_isPresent(envValue)) { + if (verbose) { + print('[VERBOSE] Resolved $name from environment variable ($envKey)'); + } + return envValue!; + } + if (envValue != null && verbose) { + print( + '[VERBOSE] Ignoring blank value for $name from environment variable ' + '($envKey); falling through', + ); + } + + final fileValue = ConfigFile.instance[envKey]; + if (_isPresent(fileValue)) { + if (verbose) { + final path = ConfigFile.instance.path; + final suffix = path != null ? ' ($path)' : ''; + print('[VERBOSE] Resolved $name from .env file$suffix'); + } + return fileValue!; + } + if (fileValue != null && verbose) { + print( + '[VERBOSE] Ignoring blank value for $name from .env file; ' + 'falling through', + ); + } + + print('Error: Missing "$name"'); + print( + ' Please provide "$name" via --$name argument, environment variable ' + '"$envKey", or as "$envKey" in a .env config file. ' + 'Empty or whitespace-only values are treated as missing.', + ); + exit(2); } + + static bool _isPresent(String? value) => + value != null && value.trim().isNotEmpty; } diff --git a/lib/src/deployments/deployments.dart b/lib/src/deployments/deployments.dart index a8b1f8e..f2e3d3d 100644 --- a/lib/src/deployments/deployments.dart +++ b/lib/src/deployments/deployments.dart @@ -28,8 +28,8 @@ class Deployments { final comment = command.option('comment'); final scmIdentifier = command.option('scm-identifier'); final scmType = command.option('scm-type'); - final apiKey = ConfigProp.apiKey.load(command); - final token = ConfigProp.token.load(command); + final apiKey = ConfigProp.apiKey.load(command, verbose: verbose); + final token = ConfigProp.token.load(command, verbose: verbose); if (verbose) { print('token: $token'); diff --git a/lib/src/dsym/dsym.dart b/lib/src/dsym/dsym.dart index 5ee53e8..a691142 100644 --- a/lib/src/dsym/dsym.dart +++ b/lib/src/dsym/dsym.dart @@ -25,7 +25,7 @@ class Dsym { final externalAccessToken = command.option('external-access-token') as String; final path = command.option('path') as String; - final appId = ConfigProp.appId.load(command); + final appId = ConfigProp.appId.load(command, verbose: verbose); if (verbose) { print('app-id: $appId'); diff --git a/lib/src/proguard/proguard.dart b/lib/src/proguard/proguard.dart index 1a9ce51..da3b0ad 100644 --- a/lib/src/proguard/proguard.dart +++ b/lib/src/proguard/proguard.dart @@ -33,7 +33,7 @@ class Proguard { final path = command.option('path') as String; final version = command.option('version') as String; final overwrite = command.wasParsed('overwrite'); - final appId = ConfigProp.appId.load(command); + final appId = ConfigProp.appId.load(command, verbose: verbose); if (verbose) { print('app-id: $appId'); diff --git a/lib/src/sourcemap/flutter/sourcemap_flutter.dart b/lib/src/sourcemap/flutter/sourcemap_flutter.dart index 69245ca..c2cf7c0 100644 --- a/lib/src/sourcemap/flutter/sourcemap_flutter.dart +++ b/lib/src/sourcemap/flutter/sourcemap_flutter.dart @@ -20,8 +20,8 @@ class SourcemapFlutter extends SourcemapBase { final uri = command.option('uri') ?? '${command.option('base-uri')}main.dart.js'; final path = command.option('input-map') ?? 'build/web/main.dart.js.map'; - final appId = ConfigProp.appId.load(command); - final token = ConfigProp.token.load(command); + final appId = ConfigProp.appId.load(command, verbose: verbose); + final token = ConfigProp.token.load(command, verbose: verbose); if (verbose) { print('app-id: $appId'); print('token: $token'); diff --git a/lib/src/sourcemap/sourcemap_single_file.dart b/lib/src/sourcemap/sourcemap_single_file.dart index ee87ea6..5b9cda3 100644 --- a/lib/src/sourcemap/sourcemap_single_file.dart +++ b/lib/src/sourcemap/sourcemap_single_file.dart @@ -25,8 +25,8 @@ class SourcemapSingleFile extends SourcemapBase { } final path = command.option('input-map')!; - final appId = ConfigProp.appId.load(command); - final token = ConfigProp.token.load(command); + final appId = ConfigProp.appId.load(command, verbose: verbose); + final token = ConfigProp.token.load(command, verbose: verbose); if (verbose) { print('app-id: $appId'); diff --git a/lib/src/symbols/symbols_command.dart b/lib/src/symbols/symbols_command.dart index da10080..7e7d9ce 100644 --- a/lib/src/symbols/symbols_command.dart +++ b/lib/src/symbols/symbols_command.dart @@ -24,8 +24,8 @@ class SymbolsCommand extends RaygunCommand { } run( command: command, - appId: ConfigProp.appId.load(command), - token: ConfigProp.token.load(command), + appId: ConfigProp.appId.load(command, verbose: verbose), + token: ConfigProp.token.load(command, verbose: verbose), ) .then((result) { if (result) { diff --git a/pubspec.lock b/pubspec.lock index ddfb676..0c4a81b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + dotenv: + dependency: "direct main" + description: + name: dotenv + sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1" + url: "https://pub.dev" + source: hosted + version: "4.2.0" file: dependency: transitive description: @@ -298,7 +306,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index be6658e..0ed71ed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,8 @@ environment: dependencies: args: ^2.6.0 http: ^1.6.0 + dotenv: ^4.2.0 + path: ^1.9.0 dev_dependencies: lints: ">=4.0.0 <7.0.0" diff --git a/test/config_file_test.dart b/test/config_file_test.dart new file mode 100644 index 0000000..7e3062d --- /dev/null +++ b/test/config_file_test.dart @@ -0,0 +1,371 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:raygun_cli/src/config_file.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConfigFile', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('raygun_cli_cfgfile_test_'); + ConfigFile.resetForTest(); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + ConfigFile.resetForTest(); + }); + + /// Writes a `.env` file in [dir] with the given [contents] and returns + /// the absolute path. + String writeEnv(Directory dir, String contents) { + final file = File(p.join(dir.path, ConfigFile.fileName)); + file.writeAsStringSync(contents); + return file.absolute.path; + } + + group('discovery', () { + test('returns empty when no .env exists anywhere', () { + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg.path, isNull); + expect(cfg['ANYTHING'], isNull); + }); + + test('loads .env from current directory', () { + final envPath = writeEnv(tempDir, 'RAYGUN_APP_ID=abc123\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg.path, envPath); + expect(cfg['RAYGUN_APP_ID'], 'abc123'); + }); + + test('walks up parent directories to find .env', () { + final envPath = writeEnv(tempDir, 'RAYGUN_TOKEN=tok\n'); + final nested = Directory(p.join(tempDir.path, 'a', 'b', 'c')) + ..createSync(recursive: true); + + final cfg = ConfigFile.load( + startDir: nested.path, + stopDir: tempDir.path, + ); + expect(cfg.path, envPath); + expect(cfg['RAYGUN_TOKEN'], 'tok'); + }); + + test('does not escape stopDir boundary when walking up', () { + // .env lives in a sibling above stopDir — must NOT be discovered. + writeEnv(tempDir, 'RAYGUN_API_KEY=secret\n'); + final stopDir = Directory(p.join(tempDir.path, 'project')) + ..createSync(); + final start = Directory(p.join(stopDir.path, 'sub'))..createSync(); + + final cfg = ConfigFile.load( + startDir: start.path, + stopDir: stopDir.path, + ); + expect(cfg.path, isNull); + expect(cfg['RAYGUN_API_KEY'], isNull); + }); + + test('discovers .env exactly at stopDir boundary', () { + final envPath = writeEnv(tempDir, 'RAYGUN_APP_ID=at-boundary\n'); + final start = Directory(p.join(tempDir.path, 'sub', 'sub2')) + ..createSync(recursive: true); + + final cfg = ConfigFile.load( + startDir: start.path, + stopDir: tempDir.path, + ); + expect(cfg.path, envPath); + expect(cfg['RAYGUN_APP_ID'], 'at-boundary'); + }); + + test('finds the closest .env when multiple exist in the chain', () { + writeEnv(tempDir, 'RAYGUN_APP_ID=outer\n'); + final inner = Directory(p.join(tempDir.path, 'inner'))..createSync(); + writeEnv(inner, 'RAYGUN_APP_ID=inner\n'); + + final cfg = ConfigFile.load( + startDir: inner.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], 'inner'); + }); + }); + + // ----------------------------------------------------------------- + // Discovery when HOME / USERPROFILE is unset (sandboxed containers, + // some CI runners). Without a home boundary, an unbounded walk would + // pick up a stray /.env or /etc/.env — we deliberately restrict + // discovery to the start directory only in that case. + // ----------------------------------------------------------------- + group('discovery without HOME', () { + test('finds .env in start directory when HOME/USERPROFILE unset', () { + final envPath = writeEnv(tempDir, 'RAYGUN_APP_ID=cwd-only\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + environmentOverride: const {}, // simulate no HOME / USERPROFILE + ); + expect(cfg.path, envPath); + expect(cfg['RAYGUN_APP_ID'], 'cwd-only'); + }); + + test( + 'does NOT walk to parent directories when HOME/USERPROFILE unset', + () { + // .env lives one level above start dir. With no home boundary, + // discovery must NOT find it (regression for the unbounded-walk + // bug flagged in PR #62 review). + writeEnv(tempDir, 'RAYGUN_APP_ID=parent-leaks\n'); + final start = Directory(p.join(tempDir.path, 'sub'))..createSync(); + + final cfg = ConfigFile.load( + startDir: start.path, + environmentOverride: const {}, + ); + expect(cfg.path, isNull); + expect(cfg['RAYGUN_APP_ID'], isNull); + }, + ); + + test('empty HOME string is treated the same as unset', () { + writeEnv(tempDir, 'RAYGUN_APP_ID=parent-leaks\n'); + final start = Directory(p.join(tempDir.path, 'sub'))..createSync(); + + final cfg = ConfigFile.load( + startDir: start.path, + environmentOverride: const {'HOME': ''}, + ); + expect(cfg.path, isNull); + }); + + test('USERPROFILE is honored as a fallback for HOME', () { + // Simulating Windows: only USERPROFILE is set. Discovery should + // still walk up to that boundary. + final envPath = writeEnv(tempDir, 'RAYGUN_APP_ID=found\n'); + final start = Directory(p.join(tempDir.path, 'a', 'b')) + ..createSync(recursive: true); + + final cfg = ConfigFile.load( + startDir: start.path, + environmentOverride: {'USERPROFILE': tempDir.path}, + ); + expect(cfg.path, envPath); + }); + + test( + 'verbose mode prints the "restricting to current directory" hint', + () { + final logs = []; + runZoned( + () { + ConfigFile.load( + startDir: tempDir.path, + environmentOverride: const {}, + verbose: true, + ); + }, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => logs.add(line), + ), + ); + expect( + logs.any( + (l) => l.contains( + 'HOME/USERPROFILE not set; restricting .env discovery ' + 'to current directory', + ), + ), + isTrue, + ); + }, + ); + }); + + group('explicit --config-file path', () { + test('honors explicit path even if a closer .env exists', () { + // closer .env in CWD has different value + writeEnv(tempDir, 'RAYGUN_APP_ID=from-cwd\n'); + // explicit override file elsewhere + final overrideDir = Directory.systemTemp.createTempSync('cfgoverride_'); + try { + final overridePath = p.join(overrideDir.path, 'custom.env'); + File(overridePath).writeAsStringSync('RAYGUN_APP_ID=from-override\n'); + + final cfg = ConfigFile.load( + explicitPath: overridePath, + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg.path, p.absolute(overridePath)); + expect(cfg['RAYGUN_APP_ID'], 'from-override'); + } finally { + overrideDir.deleteSync(recursive: true); + } + }); + + test( + 'exits with code 2 when explicit path is missing', + () async { + final result = await Process.run(Platform.resolvedExecutable, [ + 'run', + 'bin/raygun_cli.dart', + '--config-file=/definitely/does/not/exist.env', + 'deployments', + '--version=1.0.0', + ]); + expect(result.exitCode, 2); + expect(result.stdout, contains('Error: --config-file points to')); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + }); + + group('parsing', () { + test('returns null for unknown keys', () { + writeEnv(tempDir, 'RAYGUN_APP_ID=abc\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['NOT_PRESENT'], isNull); + }); + + test('reads double-quoted values with spaces', () { + writeEnv(tempDir, 'RAYGUN_TOKEN="tok with spaces"\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_TOKEN'], 'tok with spaces'); + }); + + test('reads single-quoted values with spaces', () { + writeEnv(tempDir, "RAYGUN_TOKEN='single quoted value'\n"); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_TOKEN'], 'single quoted value'); + }); + + test('ignores whole-line # comments', () { + writeEnv( + tempDir, + '# this is a comment\nRAYGUN_APP_ID=abc\n# another comment\n', + ); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], 'abc'); + }); + + test('ignores blank lines', () { + writeEnv(tempDir, '\n\nRAYGUN_APP_ID=abc\n\n\nRAYGUN_TOKEN=tok\n\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], 'abc'); + expect(cfg['RAYGUN_TOKEN'], 'tok'); + }); + + test('tolerates `export KEY=value` syntax', () { + writeEnv(tempDir, 'export RAYGUN_APP_ID=exported-app\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], 'exported-app'); + }); + + test('preserves UTF-8 / multibyte characters', () { + writeEnv(tempDir, 'RAYGUN_TOKEN=héllo-世界-🎉\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_TOKEN'], 'héllo-世界-🎉'); + }); + + test('parses multiple keys from a realistic .env', () { + writeEnv(tempDir, ''' +# Raygun config +RAYGUN_APP_ID=app-id-123 +RAYGUN_TOKEN="pat_xyz with spaces" +export RAYGUN_API_KEY=apikey-789 +'''); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], 'app-id-123'); + expect(cfg['RAYGUN_TOKEN'], 'pat_xyz with spaces'); + expect(cfg['RAYGUN_API_KEY'], 'apikey-789'); + }); + + test('lines without `=` are silently ignored', () { + writeEnv(tempDir, 'NOT_A_KV_LINE\nRAYGUN_APP_ID=valid\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['NOT_A_KV_LINE'], isNull); + expect(cfg['RAYGUN_APP_ID'], 'valid'); + }); + + test('empty value `KEY=` is passed through as empty string', () { + // dotenv stores empty strings; ConfigFile is a thin wrapper and + // returns them faithfully. The "empty == missing" rule lives in + // ConfigProp.load (see config_props_test.dart) so callers can still + // distinguish "key absent from file" (null) from "key present but + // blank" (''). + writeEnv(tempDir, 'RAYGUN_APP_ID=\n'); + final cfg = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + expect(cfg['RAYGUN_APP_ID'], ''); + }); + }); + + group('singleton + setInstance', () { + test('setInstance overrides discovery', () { + writeEnv(tempDir, 'RAYGUN_APP_ID=from-disk\n'); + + final injected = ConfigFile.load( + startDir: tempDir.path, + stopDir: tempDir.path, + ); + ConfigFile.setInstance(injected); + + expect(ConfigFile.instance['RAYGUN_APP_ID'], 'from-disk'); + }); + + test('resetForTest clears the cached singleton', () { + ConfigFile.setInstance(ConfigFile.empty()); + expect(ConfigFile.instance['ANY'], isNull); + ConfigFile.resetForTest(); + // Subsequent access triggers fresh discovery; with no .env, returns + // an empty instance again. We don't assert on path because it + // depends on the test runner's CWD. + expect(ConfigFile.instance, isA()); + }); + + test('empty() instance has no values and null path', () { + final cfg = ConfigFile.empty(); + expect(cfg.path, isNull); + expect(cfg['ANY'], isNull); + }); + }); + }); +} diff --git a/test/config_props_test.dart b/test/config_props_test.dart index 388ecd9..419ce88 100644 --- a/test/config_props_test.dart +++ b/test/config_props_test.dart @@ -1,27 +1,69 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:args/args.dart'; +import 'package:path/path.dart' as p; +import 'package:raygun_cli/src/config_file.dart'; import 'package:raygun_cli/src/config_props.dart'; import 'package:raygun_cli/src/environment.dart'; import 'package:test/test.dart'; +/// Captures `print` calls made inside [body] into [logs]. +void runZonedPrint(List logs, void Function() body) { + runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (_, _, _, line) => logs.add(line), + ), + ); +} + void main() { - group('ConfigProps', () { - test('should parse arguments', () { - ArgParser parser = ArgParser() - ..addFlag('verbose') - ..addOption('app-id') - ..addOption('token'); - final results = parser.parse([ + ArgParser buildParser() => ArgParser() + ..addFlag('verbose') + ..addOption('app-id') + ..addOption('token') + ..addOption('api-key'); + + late Directory tempDir; + + /// Writes a `.env` in a temp dir and installs it as the global ConfigFile. + void installConfigFile(Map values) { + final body = values.entries.map((e) => '${e.key}=${e.value}').join('\n'); + File(p.join(tempDir.path, ConfigFile.fileName)).writeAsStringSync(body); + ConfigFile.setInstance( + ConfigFile.load(startDir: tempDir.path, stopDir: tempDir.path), + ); + } + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('raygun_cli_props_test_'); + // Reset both singletons to a known-empty baseline before every test. + Environment.setInstance( + Environment(raygunAppId: null, raygunToken: null, raygunApiKey: null), + ); + ConfigFile.setInstance(ConfigFile.empty()); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + ConfigFile.resetForTest(); + }); + + // ----------------------------------------------------------------- + // Pre-existing behaviour (regression coverage) + // ----------------------------------------------------------------- + group('ConfigProp existing behaviour', () { + test('parses values from CLI arguments', () { + final results = buildParser().parse([ '--app-id=app-id-parsed', '--token=token-parsed', ]); - final token = ConfigProp.token.load(results); - final appId = ConfigProp.appId.load(results); - expect(appId, 'app-id-parsed'); - expect(token, 'token-parsed'); + expect(ConfigProp.appId.load(results), 'app-id-parsed'); + expect(ConfigProp.token.load(results), 'token-parsed'); }); - test('should parse from env vars', () { - // fake environment variables + test('parses values from environment variables', () { Environment.setInstance( Environment( raygunAppId: 'app-id-env', @@ -29,62 +71,207 @@ void main() { raygunApiKey: 'api-key-env', ), ); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-env'); + expect(ConfigProp.token.load(results), 'token-env'); + expect(ConfigProp.apiKey.load(results), 'api-key-env'); + }); - // define parser - ArgParser parser = ArgParser() - ..addFlag('verbose') - ..addOption('app-id') - ..addOption('api-key') - ..addOption('token'); + test('CLI argument wins over environment variable', () { + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: 'token-env', + raygunApiKey: 'api-key-env', + ), + ); + final results = buildParser().parse([ + '--app-id=app-id-arg', + '--token=token-arg', + '--api-key=api-key-arg', + ]); + expect(ConfigProp.appId.load(results), 'app-id-arg'); + expect(ConfigProp.token.load(results), 'token-arg'); + expect(ConfigProp.apiKey.load(results), 'api-key-arg'); + }); - // parse nothing - final results = parser.parse([]); + test('mixed: app-id from env, token from arg', () { + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + final results = buildParser().parse(['--token=token-arg']); + expect(ConfigProp.appId.load(results), 'app-id-env'); + expect(ConfigProp.token.load(results), 'token-arg'); + }); + }); + + // ----------------------------------------------------------------- + // New: .env config file integration + // ----------------------------------------------------------------- + group('ConfigProp .env file integration', () { + test('loads from file when arg and env are absent', () { + installConfigFile({Environment.raygunAppIdKey: 'app-id-from-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-from-file'); + }); - // load from env vars - final appId = ConfigProp.appId.load(results); - final token = ConfigProp.token.load(results); - final apiKey = ConfigProp.apiKey.load(results); - expect(appId, 'app-id-env'); - expect(token, 'token-env'); - expect(apiKey, 'api-key-env'); + test('arg wins over env and file', () { + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + installConfigFile({Environment.raygunAppIdKey: 'app-id-file'}); + final results = buildParser().parse(['--app-id=app-id-arg']); + expect(ConfigProp.appId.load(results), 'app-id-arg'); }); - test('should parse with priority', () { - // fake environment variables + test('env wins over file when arg absent', () { Environment.setInstance( Environment( raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + installConfigFile({Environment.raygunAppIdKey: 'app-id-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-env'); + }); + + test('file value used only when arg AND env are absent', () { + installConfigFile({Environment.raygunTokenKey: 'token-from-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.token.load(results), 'token-from-file'); + }); + + test('all three ConfigProps load from file', () { + installConfigFile({ + Environment.raygunAppIdKey: 'app-id-file', + Environment.raygunTokenKey: 'token-file', + Environment.raygunApiKeyKey: 'api-key-file', + }); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-file'); + expect(ConfigProp.token.load(results), 'token-file'); + expect(ConfigProp.apiKey.load(results), 'api-key-file'); + }); + + test('mixed sources across props: file→appId, env→token, arg→apiKey', () { + Environment.setInstance( + Environment( + raygunAppId: null, raygunToken: 'token-env', - raygunApiKey: 'api-key-env', + raygunApiKey: null, ), ); + installConfigFile({ + Environment.raygunAppIdKey: 'app-id-file', + Environment.raygunTokenKey: 'token-file', // shadowed by env + Environment.raygunApiKeyKey: 'api-key-file', // shadowed by arg + }); + final results = buildParser().parse(['--api-key=api-key-arg']); + expect(ConfigProp.appId.load(results), 'app-id-file'); + expect(ConfigProp.token.load(results), 'token-env'); + expect(ConfigProp.apiKey.load(results), 'api-key-arg'); + }); - // define parser - ArgParser parser = ArgParser() - ..addFlag('verbose') - ..addOption('app-id') - ..addOption('api-key') - ..addOption('token'); + test('falls through to env when file lacks the requested key', () { + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + installConfigFile({Environment.raygunTokenKey: 'token-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-env'); + expect(ConfigProp.token.load(results), 'token-file'); + }); - // parse arguments - final results = parser.parse([ - '--app-id=app-id-parsed', - '--token=token-parsed', - '--api-key=api-key-parsed', - ]); + test( + 'file with empty value (KEY=) is treated as missing and falls through', + () { + // dotenv stores '' for empty values. ConfigProp treats empty as + // missing and falls through to the next tier — a higher-priority + // env var must win. + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + installConfigFile({Environment.raygunAppIdKey: ''}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-env'); + }, + ); - // load from parsed even if env vars are set - final appId = ConfigProp.appId.load(results); - final token = ConfigProp.token.load(results); - final apiKey = ConfigProp.apiKey.load(results); - expect(appId, 'app-id-parsed'); - expect(token, 'token-parsed'); - expect(apiKey, 'api-key-parsed'); + test( + 'empty value at every tier exits with code 2', + () async { + // Empty .env value, empty env var, empty CLI arg — must surface the + // friendly "Missing" error, not propagate '' downstream. Spawned in + // a child process so the exit(2) doesn't kill the test runner. + File( + p.join(tempDir.path, ConfigFile.fileName), + ).writeAsStringSync('RAYGUN_TOKEN=\nRAYGUN_API_KEY=\n'); + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'run', + p.absolute('bin/raygun_cli.dart'), + 'deployments', + '--version=1.0.0', + '--token=', + '--api-key=', + ], + workingDirectory: tempDir.path, + environment: const {'RAYGUN_TOKEN': '', 'RAYGUN_API_KEY': ''}, + includeParentEnvironment: false, + ); + expect(result.exitCode, 2); + expect(result.stdout, contains('Missing')); + expect( + result.stdout, + contains('Empty or whitespace-only values are treated as missing'), + ); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + + test('empty CLI arg falls through to env var', () { + Environment.setInstance( + Environment( + raygunAppId: 'app-id-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + final results = buildParser().parse(['--app-id=']); + expect(ConfigProp.appId.load(results), 'app-id-env'); + }); + + test('empty env var falls through to .env file', () { + Environment.setInstance( + Environment(raygunAppId: '', raygunToken: null, raygunApiKey: null), + ); + installConfigFile({Environment.raygunAppIdKey: 'app-id-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-file'); }); - test('should parse from both', () { - // fake environment variables - // token is not provided + test('whitespace-only CLI arg falls through to env var', () { + // The user types `--app-id=" "` — currently accepted as a real value + // and propagated to Raygun (404). Must instead fall through. Environment.setInstance( Environment( raygunAppId: 'app-id-env', @@ -92,21 +279,152 @@ void main() { raygunApiKey: null, ), ); + final results = buildParser().parse(['--app-id= ']); + expect(ConfigProp.appId.load(results), 'app-id-env'); + }); + + test('whitespace-only env var falls through to .env file', () { + // `export RAYGUN_APP_ID=" "` should not satisfy the lookup. + Environment.setInstance( + Environment(raygunAppId: ' ', raygunToken: null, raygunApiKey: null), + ); + installConfigFile({Environment.raygunAppIdKey: 'app-id-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-file'); + }); + + test('tab-only env var is treated as missing', () { + Environment.setInstance( + Environment(raygunAppId: '\t\t', raygunToken: null, raygunApiKey: null), + ); + installConfigFile({Environment.raygunAppIdKey: 'app-id-file'}); + final results = buildParser().parse([]); + expect(ConfigProp.appId.load(results), 'app-id-file'); + }); + }); + + // ----------------------------------------------------------------- + // New: verbose source-attribution logging + // ----------------------------------------------------------------- + group('ConfigProp verbose attribution', () { + test('prints CLI source line when value comes from CLI', () { + final results = buildParser().parse(['--app-id=from-arg']); + final logs = []; + runZonedPrint(logs, () { + expect(ConfigProp.appId.load(results, verbose: true), 'from-arg'); + }); + expect(logs, contains('[VERBOSE] Resolved app-id from CLI argument')); + }); + + test('prints env source line (with key) when value comes from env', () { + Environment.setInstance( + Environment( + raygunAppId: 'from-env', + raygunToken: null, + raygunApiKey: null, + ), + ); + final results = buildParser().parse([]); + final logs = []; + runZonedPrint(logs, () { + expect(ConfigProp.appId.load(results, verbose: true), 'from-env'); + }); + expect( + logs, + contains( + '[VERBOSE] Resolved app-id from environment variable (RAYGUN_APP_ID)', + ), + ); + }); - // define parser - ArgParser parser = ArgParser() - ..addFlag('verbose') - ..addOption('app-id') - ..addOption('token'); + test('prints .env source line (with path) when value comes from file', () { + installConfigFile({Environment.raygunAppIdKey: 'from-file'}); + final results = buildParser().parse([]); + final logs = []; + runZonedPrint(logs, () { + expect(ConfigProp.appId.load(results, verbose: true), 'from-file'); + }); + final envPath = p.join(tempDir.path, ConfigFile.fileName); + expect( + logs, + contains('[VERBOSE] Resolved app-id from .env file ($envPath)'), + ); + }); - // parse arguments, only token is passed - final results = parser.parse(['--token=token-parsed']); + test('prints "ignoring blank" notice when falling through tiers', () { + // Empty CLI arg + empty env var + valid file value. + Environment.setInstance( + Environment(raygunAppId: '', raygunToken: null, raygunApiKey: null), + ); + installConfigFile({Environment.raygunAppIdKey: 'from-file'}); + final results = buildParser().parse(['--app-id=']); + final logs = []; + runZonedPrint(logs, () { + expect(ConfigProp.appId.load(results, verbose: true), 'from-file'); + }); + expect( + logs.any( + (l) => + l.contains('Ignoring blank value for app-id from CLI argument'), + ), + isTrue, + reason: 'should warn about blank CLI arg', + ); + expect( + logs.any( + (l) => l.contains( + 'Ignoring blank value for app-id from environment variable', + ), + ), + isTrue, + reason: 'should warn about blank env var', + ); + }); - // app-id from env, token from argument - final appId = ConfigProp.appId.load(results); - final token = ConfigProp.token.load(results); - expect(appId, 'app-id-env'); - expect(token, 'token-parsed'); + test('prints nothing when verbose is false', () { + final results = buildParser().parse(['--app-id=from-arg']); + final logs = []; + runZonedPrint(logs, () { + expect(ConfigProp.appId.load(results), 'from-arg'); + }); + expect(logs, isEmpty); }); }); + + // ----------------------------------------------------------------- + // New: failure mode — missing in all three sources + // ----------------------------------------------------------------- + group('ConfigProp missing-in-all-sources failure', () { + test( + 'exits with code 2 and prints helpful error', + () async { + // Use a child process to assert exit code, since exit(2) would kill + // the test runner. We invoke the real CLI with no arg, no env, no + // .env file present in the temp working directory. + final result = await Process.run( + Platform.resolvedExecutable, + [ + 'run', + p.absolute('bin/raygun_cli.dart'), + 'deployments', + '--version=1.0.0', + ], + workingDirectory: tempDir.path, + // includeParentEnvironment: false + empty env map ensures NONE of the + // RAYGUN_* env vars are visible to the child process — forcing + // ConfigProp.load() to fall through every tier and exit(2). + environment: const {}, + includeParentEnvironment: false, + ); + expect(result.exitCode, 2); + expect(result.stdout, contains('Missing')); + expect( + result.stdout, + contains('.env config file'), + reason: 'error message should mention .env as a third option', + ); + }, + timeout: const Timeout(Duration(seconds: 60)), + ); + }); } diff --git a/test/core/raygun_api_test.dart b/test/core/raygun_api_test.dart new file mode 100644 index 0000000..050108f --- /dev/null +++ b/test/core/raygun_api_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:raygun_cli/src/core/raygun_api.dart'; +import 'package:test/test.dart'; + +void main() { + group('RaygunMultipartRequestBuilder', () { + const url = 'https://app.raygun.com/upload'; + + test('parses the URL and HTTP method into the underlying request', () { + final req = RaygunMultipartRequestBuilder(url, 'POST').build(); + expect(req.method, 'POST'); + expect(req.url, Uri.parse(url)); + }); + + test('supports non-POST methods (e.g. PUT)', () { + final req = RaygunMultipartRequestBuilder(url, 'PUT').build(); + expect(req.method, 'PUT'); + }); + + test('addBearerToken adds an Authorization header', () { + final req = RaygunMultipartRequestBuilder( + url, + 'POST', + ).addBearerToken('abc123').build(); + expect(req.headers['Authorization'], 'Bearer abc123'); + }); + + test('addField stores key-value pairs in fields', () { + final req = RaygunMultipartRequestBuilder( + url, + 'POST', + ).addField('version', '1.2.3').addField('owner', 'alice').build(); + expect(req.fields['version'], '1.2.3'); + expect(req.fields['owner'], 'alice'); + }); + + test('addFile attaches a file when it exists', () { + final tempDir = Directory.systemTemp.createTempSync('raygun_api_test_'); + try { + final filePath = p.join(tempDir.path, 'mapping.txt'); + File(filePath).writeAsStringSync('hello world'); + + final req = RaygunMultipartRequestBuilder( + url, + 'POST', + ).addFile('mapping', filePath).build(); + + expect(req.files, hasLength(1)); + expect(req.files.single.field, 'mapping'); + expect(req.files.single.length, 'hello world'.length); + // Filename is the basename — last segment after splitting on `/`. + expect(req.files.single.filename, 'mapping.txt'); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + + test('addFile throws Exception when the file does not exist', () { + expect( + () => RaygunMultipartRequestBuilder( + url, + 'POST', + ).addFile('mapping', '/tmp/definitely_not_here_xyz123.txt'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('File not found'), + ), + ), + ); + }); + + test('builder methods return the same instance for chaining', () { + final builder = RaygunMultipartRequestBuilder(url, 'POST'); + expect(identical(builder.addBearerToken('t'), builder), isTrue); + expect(identical(builder.addField('k', 'v'), builder), isTrue); + }); + + test('build returns the same MultipartRequest on multiple calls', () { + final builder = RaygunMultipartRequestBuilder(url, 'POST'); + final r1 = builder.build(); + final r2 = builder.build(); + expect(identical(r1, r2), isTrue); + }); + + test('multiple addField calls accumulate fields', () { + final req = RaygunMultipartRequestBuilder( + url, + 'POST', + ).addField('a', '1').addField('b', '2').addField('c', '3').build(); + expect(req.fields, {'a': '1', 'b': '2', 'c': '3'}); + }); + + test('addField overwrites a previous value for the same key', () { + final req = RaygunMultipartRequestBuilder( + url, + 'POST', + ).addField('version', '1.0.0').addField('version', '2.0.0').build(); + expect(req.fields['version'], '2.0.0'); + }); + }); + + group('RaygunPostRequestBuilder', () { + const url = 'https://app.raygun.com/v3/deployments'; + + test('parses the URL and uses POST method', () { + final req = RaygunPostRequestBuilder(url).build(); + expect(req.method, 'POST'); + expect(req.url, Uri.parse(url)); + }); + + test('addBearerToken adds an Authorization header', () { + final req = RaygunPostRequestBuilder( + url, + ).addBearerToken('xyz789').build(); + expect(req.headers['Authorization'], 'Bearer xyz789'); + }); + + test('addJsonBody serializes the body to JSON and sets Content-Type', () { + final req = RaygunPostRequestBuilder( + url, + ).addJsonBody({'version': '1.2.3', 'owner': 'alice'}).build(); + expect(req.headers['Content-Type'], 'application/json'); + expect(jsonDecode(req.body), {'version': '1.2.3', 'owner': 'alice'}); + }); + + test('addJsonBody handles nested structures', () { + final body = { + 'version': '1.0.0', + 'meta': { + 'env': 'prod', + 'tags': ['a', 'b'], + }, + }; + final req = RaygunPostRequestBuilder(url).addJsonBody(body).build(); + expect(jsonDecode(req.body), body); + }); + + test('addJsonBody handles an empty map', () { + final req = RaygunPostRequestBuilder(url).addJsonBody({}).build(); + expect(req.body, '{}'); + expect(req.headers['Content-Type'], 'application/json'); + }); + + test('builder methods return the same instance for chaining', () { + final builder = RaygunPostRequestBuilder(url); + expect(identical(builder.addBearerToken('t'), builder), isTrue); + expect(identical(builder.addJsonBody({'k': 'v'}), builder), isTrue); + }); + + test('build returns the same Request on multiple calls', () { + final builder = RaygunPostRequestBuilder(url); + final r1 = builder.build(); + final r2 = builder.build(); + expect(identical(r1, r2), isTrue); + }); + + test('chained: bearer + JSON body produces both header and body', () { + final req = RaygunPostRequestBuilder( + url, + ).addBearerToken('tok').addJsonBody({'version': '1.0.0'}).build(); + expect(req.headers['Authorization'], 'Bearer tok'); + expect(req.headers['Content-Type'], 'application/json'); + expect(jsonDecode(req.body), {'version': '1.0.0'}); + }); + }); +} diff --git a/test/e2e_config_file_test.dart b/test/e2e_config_file_test.dart new file mode 100644 index 0000000..7b99479 --- /dev/null +++ b/test/e2e_config_file_test.dart @@ -0,0 +1,341 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +/// End-to-end tests that spawn the real CLI binary as a subprocess. +/// +/// These tests verify the full user-facing flow: argument parsing → +/// `--config-file` handling → `ConfigFile` discovery → `ConfigProp.load` +/// resolution → values reaching the command code. +/// +/// Strategy: we use the `deployments` command with `--verbose`, which prints +/// the resolved values (`token: ...`, `api-key: ...`) before attempting the +/// HTTP call. We assert on those verbose lines. The HTTP call itself will +/// fail (network/401), which is expected and irrelevant — we only care that +/// the credentials flowed through correctly. +void main() { + late Directory tempDir; + final cliEntry = p.absolute('bin/raygun_cli.dart'); + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('raygun_cli_e2e_'); + }); + + tearDown(() { + if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); + }); + + /// Writes a `.env` file in [dir]. + void writeEnv(Directory dir, Map values) { + final body = values.entries.map((e) => '${e.key}=${e.value}').join('\n'); + File(p.join(dir.path, '.env')).writeAsStringSync(body); + } + + /// Runs the CLI in [cwd] with the given [args] and [env] (no parent env + /// inherited so the host's RAYGUN_* vars can never bleed into the test). + Future runCli( + Directory cwd, { + required List args, + Map env = const {}, + }) { + return Process.run( + Platform.resolvedExecutable, + ['run', cliEntry, ...args], + workingDirectory: cwd.path, + environment: env, + includeParentEnvironment: false, + ); + } + + group('E2E: .env discovery', () { + test( + 'CLI loads credentials from .env in CWD', + () async { + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-cwd-e2e', + 'RAYGUN_API_KEY': 'key-cwd-e2e', + }); + final result = await runCli( + tempDir, + args: ['-v', 'deployments', '--version=1.0.0'], + ); + expect( + result.stdout, + contains('Loaded config from ${p.join(tempDir.path, '.env')}'), + ); + expect(result.stdout, contains('token: tok-cwd-e2e')); + expect(result.stdout, contains('api-key: key-cwd-e2e')); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'CLI walks up parent directories to find .env', + () async { + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-parent', + 'RAYGUN_API_KEY': 'key-parent', + }); + final nested = Directory(p.join(tempDir.path, 'a', 'b')) + ..createSync(recursive: true); + // Pin the upward walk's stop boundary to tempDir via HOME. + final result = await runCli( + nested, + args: ['-v', 'deployments', '--version=1.0.0'], + env: {'HOME': tempDir.path}, + ); + expect( + result.stdout, + contains('Loaded config from ${p.join(tempDir.path, '.env')}'), + ); + expect(result.stdout, contains('token: tok-parent')); + expect(result.stdout, contains('api-key: key-parent')); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + }); + + group('E2E: --config-file flag', () { + test( + 'explicit --config-file= is honored', + () async { + final altDir = Directory.systemTemp.createTempSync('raygun_cli_alt_'); + try { + final altPath = p.join(altDir.path, 'custom.env'); + File(altPath).writeAsStringSync( + 'RAYGUN_TOKEN=tok-explicit\nRAYGUN_API_KEY=key-explicit\n', + ); + + // CWD .env exists with different values — must be ignored. + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-cwd-should-not-win', + 'RAYGUN_API_KEY': 'key-cwd-should-not-win', + }); + + final result = await runCli( + tempDir, + args: [ + '-v', + '--config-file=$altPath', + 'deployments', + '--version=1.0.0', + ], + ); + expect(result.stdout, contains('Loaded config from $altPath')); + expect(result.stdout, contains('token: tok-explicit')); + expect(result.stdout, contains('api-key: key-explicit')); + expect(result.stdout, isNot(contains('tok-cwd-should-not-win'))); + } finally { + altDir.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'invalid --config-file path exits with code 2', + () async { + final result = await runCli( + tempDir, + args: [ + '--config-file=/definitely/does/not/exist.env', + 'deployments', + '--version=1.0.0', + ], + ); + expect(result.exitCode, 2); + expect( + result.stdout, + contains('Error: --config-file points to a missing file'), + ); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + }); + + group('E2E: precedence', () { + test( + 'CLI argument wins over both env var and .env file', + () async { + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-from-file', + 'RAYGUN_API_KEY': 'key-from-file', + }); + final result = await runCli( + tempDir, + args: [ + '-v', + 'deployments', + '--version=1.0.0', + '--token=tok-from-arg', + '--api-key=key-from-arg', + ], + env: { + 'RAYGUN_TOKEN': 'tok-from-env', + 'RAYGUN_API_KEY': 'key-from-env', + }, + ); + expect(result.stdout, contains('token: tok-from-arg')); + expect(result.stdout, contains('api-key: key-from-arg')); + expect(result.stdout, isNot(contains('tok-from-env'))); + expect(result.stdout, isNot(contains('tok-from-file'))); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'environment variable wins over .env file when arg is absent', + () async { + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-from-file', + 'RAYGUN_API_KEY': 'key-from-file', + }); + final result = await runCli( + tempDir, + args: ['-v', 'deployments', '--version=1.0.0'], + env: { + 'RAYGUN_TOKEN': 'tok-from-env', + 'RAYGUN_API_KEY': 'key-from-env', + }, + ); + expect(result.stdout, contains('token: tok-from-env')); + expect(result.stdout, contains('api-key: key-from-env')); + expect(result.stdout, isNot(contains('tok-from-file'))); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + '.env file is used as the lowest-priority fallback', + () async { + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-only-in-file', + 'RAYGUN_API_KEY': 'key-only-in-file', + }); + final result = await runCli( + tempDir, + args: ['-v', 'deployments', '--version=1.0.0'], + ); + expect(result.stdout, contains('token: tok-only-in-file')); + expect(result.stdout, contains('api-key: key-only-in-file')); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'mixed: env supplies token, .env supplies api-key', + () async { + writeEnv(tempDir, {'RAYGUN_API_KEY': 'key-from-file'}); + final result = await runCli( + tempDir, + args: ['-v', 'deployments', '--version=1.0.0'], + env: {'RAYGUN_TOKEN': 'tok-from-env'}, + ); + expect(result.stdout, contains('token: tok-from-env')); + expect(result.stdout, contains('api-key: key-from-file')); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + }); + + group('E2E: missing config', () { + test( + 'exits with code 2 and helpful error when no source provides creds', + () async { + // No .env in tempDir, no env vars, no args → must exit(2). + final result = await runCli( + tempDir, + args: ['deployments', '--version=1.0.0'], + ); + expect(result.exitCode, 2); + expect(result.stdout, contains('Missing')); + expect(result.stdout, contains('.env config file')); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'empty .env value falls through and surfaces "Missing" not an HTTP error', + () async { + // The user's .env exists but the value is blank — common after + // copying example/.env.example. We must surface the friendly + // "Missing" exit-code-2 instead of letting '' propagate to an + // opaque 401/404 from the Raygun API. + writeEnv(tempDir, {'RAYGUN_TOKEN': '', 'RAYGUN_API_KEY': ''}); + final result = await runCli( + tempDir, + args: ['deployments', '--version=1.0.0'], + ); + expect(result.exitCode, 2); + expect(result.stdout, contains('Missing')); + expect( + result.stdout, + contains('Empty or whitespace-only values are treated as missing'), + ); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'parent-directory .env is NOT discovered when HOME is unset', + () async { + // Regression for the unbounded-walk bug (PR #62 review): when HOME + // and USERPROFILE are both unset, we used to walk up to the + // filesystem root and could pick up a stray /.env. Now we only + // check the CWD, so a parent .env must not be discovered. + writeEnv(tempDir, { + 'RAYGUN_TOKEN': 'tok-from-parent', + 'RAYGUN_API_KEY': 'key-from-parent', + }); + final nested = Directory(p.join(tempDir.path, 'sub')) + ..createSync(recursive: true); + + final result = await runCli( + nested, + args: ['-v', 'deployments', '--version=1.0.0'], + // No HOME, no USERPROFILE, no RAYGUN_* vars. + ); + // Should fail with the friendly Missing message — neither the env + // nor the parent .env should have supplied credentials. + expect(result.exitCode, 2); + expect(result.stdout, contains('Missing')); + expect(result.stdout, isNot(contains('tok-from-parent'))); + expect(result.stdout, isNot(contains('key-from-parent'))); + expect( + result.stdout, + contains( + 'HOME/USERPROFILE not set; restricting .env discovery ' + 'to current directory', + ), + ); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + + test( + 'empty .env value falls through to env var', + () async { + // .env has the keys but blank; env vars supply real values. + // Verbose output should show the "Ignoring empty" notice and then + // resolve from the env var tier. + writeEnv(tempDir, {'RAYGUN_TOKEN': '', 'RAYGUN_API_KEY': ''}); + final result = await runCli( + tempDir, + args: ['-v', 'deployments', '--version=1.0.0'], + env: { + 'RAYGUN_TOKEN': 'tok-from-env', + 'RAYGUN_API_KEY': 'key-from-env', + }, + ); + expect(result.stdout, contains('token: tok-from-env')); + expect(result.stdout, contains('api-key: key-from-env')); + expect( + result.stdout, + contains('[VERBOSE] Resolved token from environment variable'), + ); + }, + timeout: const Timeout(Duration(seconds: 90)), + ); + }); +} diff --git a/test/environment_test.dart b/test/environment_test.dart new file mode 100644 index 0000000..97a3a3e --- /dev/null +++ b/test/environment_test.dart @@ -0,0 +1,117 @@ +import 'package:raygun_cli/src/environment.dart'; +import 'package:test/test.dart'; + +void main() { + group('Environment', () { + test('exposes the three RAYGUN_* env-var keys as constants', () { + expect(Environment.raygunAppIdKey, 'RAYGUN_APP_ID'); + expect(Environment.raygunTokenKey, 'RAYGUN_TOKEN'); + expect(Environment.raygunApiKeyKey, 'RAYGUN_API_KEY'); + }); + + test('constructor stores the three nullable fields', () { + final env = Environment( + raygunAppId: 'app', + raygunToken: 'tok', + raygunApiKey: 'key', + ); + expect(env.raygunAppId, 'app'); + expect(env.raygunToken, 'tok'); + expect(env.raygunApiKey, 'key'); + }); + + test('null fields are returned as null', () { + final env = Environment( + raygunAppId: null, + raygunToken: null, + raygunApiKey: null, + ); + expect(env.raygunAppId, isNull); + expect(env.raygunToken, isNull); + expect(env.raygunApiKey, isNull); + }); + + group('operator []', () { + late Environment env; + setUp(() { + env = Environment( + raygunAppId: 'app-val', + raygunToken: 'tok-val', + raygunApiKey: 'key-val', + ); + }); + + test('returns appId for RAYGUN_APP_ID', () { + expect(env[Environment.raygunAppIdKey], 'app-val'); + }); + + test('returns token for RAYGUN_TOKEN', () { + expect(env[Environment.raygunTokenKey], 'tok-val'); + }); + + test('returns apiKey for RAYGUN_API_KEY', () { + expect(env[Environment.raygunApiKeyKey], 'key-val'); + }); + + test('returns null when the matching field is null', () { + final empty = Environment( + raygunAppId: null, + raygunToken: null, + raygunApiKey: null, + ); + expect(empty[Environment.raygunAppIdKey], isNull); + expect(empty[Environment.raygunTokenKey], isNull); + expect(empty[Environment.raygunApiKeyKey], isNull); + }); + + test('throws ArgumentError for an unknown key', () { + expect(() => env['NOT_A_REAL_KEY'], throwsA(isA())); + }); + + test('is case-sensitive', () { + // The current impl uses an exact string switch; verify that fact + // so any future change to relax case-sensitivity is intentional. + expect(() => env['raygun_app_id'], throwsA(isA())); + }); + }); + + group('singleton', () { + tearDown(() { + // Reset the singleton to a known-empty state so we don't leak the + // process's real RAYGUN_* env vars into later tests. + Environment.setInstance( + Environment(raygunAppId: null, raygunToken: null, raygunApiKey: null), + ); + }); + + test('setInstance overrides the global instance', () { + final injected = Environment( + raygunAppId: 'injected-app', + raygunToken: 'injected-tok', + raygunApiKey: 'injected-key', + ); + Environment.setInstance(injected); + expect(identical(Environment.instance, injected), isTrue); + expect(Environment.instance.raygunAppId, 'injected-app'); + }); + + test('setInstance is idempotent (last call wins)', () { + Environment.setInstance( + Environment( + raygunAppId: 'first', + raygunToken: null, + raygunApiKey: null, + ), + ); + Environment.setInstance( + Environment( + raygunAppId: 'second', + raygunToken: null, + raygunApiKey: null, + ), + ); + expect(Environment.instance.raygunAppId, 'second'); + }); + }); + }); +}