diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000000..341c6dfc57 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,37 @@ +# Apidash CLI + +A lightweight, portable, and **Flutter-free** Command Line Interface for Apidash. Access, test, and run your saved API requests directly from your terminal. + +## Key Features + +- **Zero Config**: Automatically detects your Apidash GUI workspace on Linux, macOS, and Windows. +- **Concurrency Safe**: Uses a "Shadow Copy" mechanism to read your data even while the Apidash desktop app is open and locking the database. +- **Interactive TUI**: A premium Terminal User Interface for selecting and editing requests. +- **UX-Focused Editor**: Pre-filled interactive line editor for URLs and Parameters—edit individual letters instead of re-typing. +- **Pure Dart**: Built as a standalone Dart package with no dependency on the Flutter SDK. + + +## Usage + +### List & Interact +Browse your saved requests using the interactive TUI. +```bash +dart run bin/apidash.dart list +``` +From the list, you can: +- **Run**: Execute the request and see beautifully formatted JSON responses. +- **Edit**: Temporarily modify the **URL**, **HTTP Method**, or **Query Parameters** before running. +- **Back**: Return to the list. + +### Direct Run +Execute a specific request immediately if you know its ID. +```bash +dart run bin/apidash.dart run +``` + +### Options +| Option | Abbr | Description | +| :--- | :--- | :--- | +| `--data-dir` | `-d` | Path to the directory containing `apidash-data.hive`. Overrides auto-detection. | +| `--help` | `-h` | Show usage information. | + diff --git a/packages/cli/apidash b/packages/cli/apidash new file mode 100755 index 0000000000..f70e52b1fa Binary files /dev/null and b/packages/cli/apidash differ diff --git a/packages/cli/bin/apidash.dart b/packages/cli/bin/apidash.dart new file mode 100644 index 0000000000..9693067544 --- /dev/null +++ b/packages/cli/bin/apidash.dart @@ -0,0 +1,37 @@ +import 'dart:io'; +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:apidash_cli/apidash_cli.dart'; + +void main(List args) async { + final logger = Logger(); + + final runner = CommandRunner("apidash", "Apidash CLI tool ") + ..argParser.addOption( + 'data-dir', + abbr: 'd', + help: 'Directory where Apidash Hive files are stored.', + defaultsTo: detectWorkspacePath() ?? getDefaultDataDir(), + ) + ..addCommand(ListCommand(logger)) + ..addCommand(RunCommand(logger)) + ..addCommand(EnvCommand(logger)) + ..addCommand(CodeCommand(logger)) + ..addCommand(ExportCommand(logger)); + + try { + await runner.run(args); + } catch (e) { + if (e is UsageException) { + logger.err(e.message); + logger.info(''); + logger.info(runner.usage); + } else { + logger.err(e.toString()); + } + } finally { + await Hive.close(); + exit(0); + } +} diff --git a/packages/cli/lib/apidash_cli.dart b/packages/cli/lib/apidash_cli.dart new file mode 100644 index 0000000000..28f5b51257 --- /dev/null +++ b/packages/cli/lib/apidash_cli.dart @@ -0,0 +1,6 @@ +export 'src/commands/list.dart'; +export 'src/commands/run.dart'; +export 'src/commands/stubs.dart'; +export 'src/storage/storage.dart'; +export 'src/ui/editor.dart'; +export 'src/utils/workspace.dart'; diff --git a/packages/cli/lib/src/commands/list.dart b/packages/cli/lib/src/commands/list.dart new file mode 100644 index 0000000000..1ab503f871 --- /dev/null +++ b/packages/cli/lib/src/commands/list.dart @@ -0,0 +1,173 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:hive_ce/hive.dart'; +import '../storage/storage.dart'; +import '../ui/editor.dart'; +import 'run.dart'; + +class ListCommand extends Command { + final Logger logger; + ListCommand(this.logger); + + @override final name = "list"; + @override final description = "Lists all saved requests. Features interactive TUI."; + + @override + void run() async { + final dataDir = globalResults?['data-dir'] as String; + final storage = StorageHelper(dataDir, logger); + + try { + await storage.init(); + var requests = await storage.getRequests(); + + if (requests.isEmpty) { + logger.warn("No data found in $dataDir."); + logger.info("Using mock requests for demonstration..."); + requests = _getMockRequests(); + } + + final choices = requests.map((r) { + final method = (r['method'] ?? r['httpRequestModel']?['method'] ?? 'GET').toString().toUpperCase().padRight(7); + return "$method | ${r['name']} [${r['id']}]"; + }).toList(); + + final choice = logger.chooseOne('Select a request:', choices: choices,); + + var selectedRequest = requests[choices.indexOf(choice)]; + + bool exitActions = false; + while (!exitActions) { + _displayRequest(selectedRequest); + + final action = logger.chooseOne( + 'Select Action:', + choices: ['Run', 'Edit', 'Curl', 'Back'], + ); + + switch (action) { + case 'Run': + await RunCommand(logger).executeRequest(selectedRequest); + exitActions = true; + break; + case 'Edit': + final editField = logger.chooseOne( + 'What do you want to edit?', + choices: ['URL', 'Method', 'Params', 'Cancel'], + ); + + if (editField == 'URL') { + final currentUrl = _getHttpField(selectedRequest, 'url') ?? ''; + final newUrl = interactiveEdit('Edit URL', currentUrl); + _updateRequestField(selectedRequest, 'url', newUrl); + logger.success('\nURL updated locally.'); + } else if (editField == 'Method') { + final currentMethod = (_getHttpField(selectedRequest, 'method') ?? 'GET').toString().toUpperCase(); + final newMethod = logger.chooseOne( + 'Select HTTP Method:', + choices: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], + defaultValue: currentMethod, + ); + _updateRequestField(selectedRequest, 'method', newMethod); + logger.success('Method updated locally.'); + } else if (editField == 'Params') { + await _handleEditParams(selectedRequest); + } + break; + case 'Curl': + logger.info('Coming soon: Curl generation'); + break; + case 'Back': + exitActions = true; + break; + } + } + } catch (e) { + logger.err(e.toString()); + } finally { + await Hive.close(); + await storage.cleanup(); + } + } + + Future _handleEditParams(Map request) async { + var params = _getHttpField(request, 'params') as List? ?? []; + var mutableParams = params.map((p) => Map.from(p)).toList(); + + final paramChoices = mutableParams.map((p) => "${p['name']}: ${p['value']}").toList(); + paramChoices.add('[Add New Parameter]'); + paramChoices.add('[Back]'); + + final choice = logger.chooseOne('Select a parameter to edit:', choices: paramChoices); + + if (choice == '[Back]') return; + + if (choice == '[Add New Parameter]') { + final name = logger.prompt('Parameter Name:'); + final value = logger.prompt('Parameter Value:'); + mutableParams.add({'name': name, 'value': value}); + } else { + final index = paramChoices.indexOf(choice); + final p = mutableParams[index]; + final newName = interactiveEdit('Edit Name', p['name'].toString()); + final newValue = interactiveEdit('Edit Value', p['value'].toString()); + mutableParams[index] = {'name': newName, 'value': newValue}; + } + + _updateRequestField(request, 'params', mutableParams); + logger.success('\nParameters updated locally.'); + } + + dynamic _getHttpField(Map request, String field) { + return request[field] ?? request['httpRequestModel']?[field]; + } + + void _updateRequestField(Map request, String field, dynamic value) { + if (request.containsKey('httpRequestModel')) { + var model = Map.from(request['httpRequestModel']); + model[field] = value; + request['httpRequestModel'] = model; + } else { + request[field] = value; + } + } + + List> _getMockRequests() { + return [ + { + 'id': 'get-1', + 'name': 'GitHub User Info', + 'method': 'GET', + 'url': 'https://api.github.com/users/badnikhil', + 'headers': [{'name': 'User-Agent', 'value': 'Apidash-CLI'}] + }, + { + 'id': 'get-2', + 'name': 'HTTPBin Get', + 'method': 'GET', + 'url': 'https://httpbin.org/get', + 'params': [{'name': 'foo', 'value': 'bar'}] + }, + ]; + } + + void _displayRequest(Map request) { + final httpRequest = request['httpRequestModel'] ?? request; + final method = httpRequest['method'] ?? 'GET'; + final url = httpRequest['url'] ?? 'N/A'; + final params = httpRequest['params'] as List? ?? []; + + logger.info('\n--- Request Details ---'); + logger.info('Name: ${request['name']}'); + logger.info('ID: ${request['id']}'); + logger.info('Method: ${lightCyan.wrap(method.toString().toUpperCase())}'); + logger.info('URL: $url'); + if (params.isNotEmpty) { + logger.info('Params:'); + for (var p in params) { + logger.info(' - ${p['name']}: ${p['value']}'); + } + } + logger.info('-----------------------\n'); + } +} diff --git a/packages/cli/lib/src/commands/run.dart b/packages/cli/lib/src/commands/run.dart new file mode 100644 index 0000000000..2af7b1bb12 --- /dev/null +++ b/packages/cli/lib/src/commands/run.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:http/http.dart' as http; +import 'package:hive_ce/hive.dart'; +import '../storage/storage.dart'; + +class RunCommand extends Command { + final Logger logger; + RunCommand(this.logger); + + @override final name = "run"; + @override final description = "Execute a saved request by ID."; + + @override + void run() async { + final dataDir = globalResults?['data-dir'] as String; + if (argResults!.rest.isEmpty) throw UsageException('Missing request ID.', usage); + final requestId = argResults!.rest.first; + + final storage = StorageHelper(dataDir, logger); + try { + await storage.init(); + final request = await storage.getRequest(requestId); + if (request == null) { + logger.err("Request not found: $requestId"); + return; + } + await executeRequest(request); + } catch (e) { + logger.err(e.toString()); + } finally { + await Hive.close(); + await storage.cleanup(); + } + } + + Future executeRequest(Map request) async { + final httpRequest = request['httpRequestModel'] ?? request; + final method = (httpRequest['method'] ?? 'GET').toString().toUpperCase(); + String url = (httpRequest['url'] as String?) ?? ''; + + if (url.isEmpty) { + logger.err("Cannot run request: URL is missing."); + return; + } + + final Map headers = {}; + final headersList = httpRequest['headers'] as List?; + if (headersList != null) { + for (var h in headersList) { + if (h is Map && h['name'] != null && h['value'] != null) { + headers[h['name'].toString()] = h['value'].toString(); + } + } + } + + final paramsList = httpRequest['params'] as List?; + if (paramsList != null && paramsList.isNotEmpty) { + final queryParams = []; + for (var p in paramsList) { + if (p is Map && p['name'] != null && p['value'] != null) { + queryParams.add('${Uri.encodeComponent(p['name'].toString())}=${Uri.encodeComponent(p['value'].toString())}'); + } + } + if (queryParams.isNotEmpty) { + url += (url.contains('?') ? '&' : '?') + queryParams.join('&'); + } + } + + final progress = logger.progress('Executing $method $url'); + final stopwatch = Stopwatch()..start(); + + try { + final client = http.Client(); + final uri = Uri.parse(url); + late http.Response response; + + switch (method) { + case 'GET': response = await client.get(uri, headers: headers); break; + case 'POST': response = await client.post(uri, headers: headers, body: httpRequest['body']); break; + case 'PUT': response = await client.put(uri, headers: headers, body: httpRequest['body']); break; + case 'DELETE': response = await client.delete(uri, headers: headers); break; + case 'PATCH': response = await client.patch(uri, headers: headers, body: httpRequest['body']); break; + case 'HEAD': response = await client.head(uri, headers: headers); break; + default: progress.fail("Unsupported method : $method"); return; + } + + stopwatch.stop(); + progress.complete('Completed in ${stopwatch.elapsedMilliseconds}ms'); + _displayResponse(response); + } catch (e) { + progress.fail('Failed to connect: $e'); + } + } + + void _displayResponse(http.Response response) { + final statusColor = response.statusCode >= 200 && response.statusCode < 300 + ? lightGreen + : (response.statusCode >= 400 ? lightRed : lightYellow); + + logger.info('\n${statusColor.wrap('Status: ${response.statusCode} ${response.reasonPhrase}')}'); + logger.info('Size: ${response.bodyBytes.length} bytes'); + + if (response.body.isNotEmpty) { + logger.info('\n--- Response Body ---'); + try { + final json = jsonDecode(response.body); + final prettyJson = JsonEncoder.withIndent(' ').convert(json); + logger.info(prettyJson); + } catch (_) { + logger.info(response.body); + } + logger.info('----------------------\n'); + } + } +} diff --git a/packages/cli/lib/src/commands/stubs.dart b/packages/cli/lib/src/commands/stubs.dart new file mode 100644 index 0000000000..b95a2c8064 --- /dev/null +++ b/packages/cli/lib/src/commands/stubs.dart @@ -0,0 +1,23 @@ +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; + +class EnvCommand extends Command { + final Logger logger; + EnvCommand(this.logger); + @override final name = "env"; + @override final description = "Environment management."; +} + +class CodeCommand extends Command { + final Logger logger; + CodeCommand(this.logger); + @override final name = "code"; + @override final description = "Generate code snippets."; +} + +class ExportCommand extends Command { + final Logger logger; + ExportCommand(this.logger); + @override final name = "export"; + @override final description = "Export collection."; +} diff --git a/packages/cli/lib/src/storage/storage.dart b/packages/cli/lib/src/storage/storage.dart new file mode 100644 index 0000000000..ee30d68c42 --- /dev/null +++ b/packages/cli/lib/src/storage/storage.dart @@ -0,0 +1,78 @@ +import 'dart:io'; +import 'package:hive_ce/hive.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +class StorageHelper { + final String dataDir; + final Logger logger; + late final String tempPath; + + StorageHelper(this.dataDir, this.logger) { + tempPath = p.join(Directory.systemTemp.path, 'apidash_cli_temp_${DateTime.now().millisecondsSinceEpoch}'); + } + + Future init() async { + if (!Directory(dataDir).existsSync()) return; + + final dir = Directory(tempPath); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + Hive.init(tempPath); + } + + Future _safeOpenBox(String boxName) async { + final sourceFile = File(p.join(dataDir, '$boxName.hive')); + if (!sourceFile.existsSync()) { + throw Exception("Box file not found: ${sourceFile.path}"); + } + + final targetFile = File(p.join(tempPath, '$boxName.hive')); + sourceFile.copySync(targetFile.path); + + return await Hive.openBox(boxName); + } + + Future>> getRequests() async { + if (!Directory(dataDir).existsSync()) return []; + try { + final box = await _safeOpenBox('apidash-data'); + final ids = box.get('ids') as List?; + if (ids == null) return []; + + final requests = >[]; + for (final id in ids) { + final data = box.get(id); + if (data != null) { + requests.add(Map.from(data)); + } + } + return requests; + } catch (e) { + logger.err("Failed to read requests: $e"); + return []; + } + } + + Future?> getRequest(String id) async { + if (!Directory(dataDir).existsSync()) return null; + try { + final box = await _safeOpenBox('apidash-data'); + final data = box.get(id); + return data != null ? Map.from(data) : null; + } catch (e) { + logger.err("Failed to read request $id: $e"); + return null; + } + } + + Future cleanup() async { + try { + await Future.delayed(Duration(milliseconds: 100)); + if (Directory(tempPath).existsSync()) { + Directory(tempPath).deleteSync(recursive: true); + } + } catch (_) {} + } +} diff --git a/packages/cli/lib/src/ui/editor.dart b/packages/cli/lib/src/ui/editor.dart new file mode 100644 index 0000000000..1b27e32854 --- /dev/null +++ b/packages/cli/lib/src/ui/editor.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +String interactiveEdit(String prompt, String initialValue) { + stdout.write('$prompt: '); + + // Save state + final originalEchoMode = stdin.echoMode; + final originalLineMode = stdin.lineMode; + + try { + stdin.echoMode = false; + stdin.lineMode = false; + + var buffer = initialValue; + stdout.write(buffer); + + while (true) { + final charCode = stdin.readByteSync(); + + if (charCode == 10 || charCode == 13) { // Enter + break; + } else if (charCode == 127 || charCode == 8) { // Backspace + if (buffer.isNotEmpty) { + buffer = buffer.substring(0, buffer.length - 1); + stdout.write('\b \b'); + } + } else if (charCode >= 32 && charCode <= 126) { // Printable chars + final char = String.fromCharCode(charCode); + buffer += char; + stdout.write(char); + } else if (charCode == 3) { // Ctrl+C + exit(130); + } + } + return buffer; + } finally { + stdin.echoMode = originalEchoMode; + stdin.lineMode = originalLineMode; + } +} diff --git a/packages/cli/lib/src/utils/workspace.dart b/packages/cli/lib/src/utils/workspace.dart new file mode 100644 index 0000000000..71db0a468a --- /dev/null +++ b/packages/cli/lib/src/utils/workspace.dart @@ -0,0 +1,43 @@ +import 'dart:io'; +import 'dart:convert'; +import 'package:path/path.dart' as p; + +String? detectWorkspacePath() { + try { + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null) return null; + + final configPath = Platform.isLinux + ? p.join(home, '.local', 'share', 'com.example.apidash', 'shared_preferences.json') + : (Platform.isMacOS + ? p.join(home, 'Library', 'Application Support', 'com.example.apidash', 'shared_preferences.json') + : null); + + if (configPath != null && File(configPath).existsSync()) { + final content = File(configPath).readAsStringSync(); + final json = jsonDecode(content) as Map; + final settingsStr = json['flutter.apidash-settings'] as String?; + + if (settingsStr != null) { + final settings = jsonDecode(settingsStr) as Map; + return settings['workspaceFolderPath'] as String?; + } + } + } catch (_) {} + return null; +} + +String getDefaultDataDir() { + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null) return './test-hive-storage'; + + if (Platform.isLinux) { + return p.join(home, '.local', 'share', 'apidash'); + } else if (Platform.isMacOS) { + return p.join(home, 'Library', 'Application Support', 'apidash'); + } else if (Platform.isWindows) { + return p.join(Platform.environment['APPDATA'] ?? home, 'apidash'); + } + + return './test-hive-storage'; +} diff --git a/packages/cli/pubspec.yaml b/packages/cli/pubspec.yaml new file mode 100644 index 0000000000..33347b122c --- /dev/null +++ b/packages/cli/pubspec.yaml @@ -0,0 +1,22 @@ +name: apidash_cli +description: " CLI for Apidash" +version: 0.0.1 +publish_to: none + +environment: + sdk: ^3.5.3 + +resolution: workspace + +dependencies: + args: ^2.5.0 + mason_logger: ^0.3.1 + hive_ce: ^2.1.2 + path: ^1.9.0 + http: ^1.2.1 + apidash_core: + path: ../apidash_core + +dev_dependencies: + test: ^1.24.0 + lints: ^6.1.0 diff --git a/pubspec.lock b/pubspec.lock index fd5c199097..9a6f31ce2a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1059,10 +1059,10 @@ packages: dependency: transitive description: name: mason_logger - sha256: "6d5a989ff41157915cb5162ed6e41196d5e31b070d2f86e1c2edf216996a158c" + sha256: "1d46102c6f299c0df7fe986dd3dd3271d57c2ec7c00ae590660b7c3018810048" url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.5" matcher: dependency: transitive description: