Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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=<path>`.

```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.)

Expand Down
54 changes: 44 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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=<id>`
2. **Environment variable** β€” e.g. `RAYGUN_APP_ID=<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
Comment thread
TheRealAgentK marked this conversation as resolved.
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=<path>` 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
Expand Down
18 changes: 18 additions & 0 deletions bin/raygun_cli.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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())
Expand Down Expand Up @@ -60,6 +67,17 @@ void main(List<String> 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;
Expand Down
15 changes: 15 additions & 0 deletions example/.env.example
Original file line number Diff line number Diff line change
@@ -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=
167 changes: 167 additions & 0 deletions lib/src/config_file.dart
Original file line number Diff line number Diff line change
@@ -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=<path>`.
/// 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=<path>` 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];
Comment thread
TheRealAgentK marked this conversation as resolved.

/// 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<String, String>? 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<String, String>? environmentOverride]) {
final src = environmentOverride ?? Platform.environment;
final home = src['HOME'] ?? src['USERPROFILE'];
return (home != null && home.isNotEmpty) ? home : null;
}
}
Loading
Loading