diff --git a/packages/console/lib/common.dart b/packages/console/lib/common.dart index dbfe4c1..757bd68 100644 --- a/packages/console/lib/common.dart +++ b/packages/console/lib/common.dart @@ -2,6 +2,7 @@ * This file is part of the Protevus Platform. * * (C) Protevus + * (C) S. Brett Sutton * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. diff --git a/packages/console/lib/core.dart b/packages/console/lib/core.dart new file mode 100644 index 0000000..bc40d64 --- /dev/null +++ b/packages/console/lib/core.dart @@ -0,0 +1,55 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +library; + +export 'src/functions/backup.dart'; +export 'src/functions/cat.dart'; +export 'src/functions/copy.dart'; // show copy, CopyException; +export 'src/functions/copy_tree.dart' show CopyTreeException, copyTree; +export 'src/functions/create_dir.dart' + show CreateDirException, createDir, createTempDir, withTempDirAsync; +export 'src/functions/create_dir.dart'; +export 'src/functions/dcli_function.dart'; +export 'src/functions/delete.dart' show DeleteException, delete; +export 'src/functions/delete_dir.dart' show DeleteDirException, deleteDir; +export 'src/functions/env.dart' + show + Env, + HOME, + PATH, + env, + envs, + isOnPATH, + withEnvironment, + withEnvironmentAsync; +export 'src/functions/find.dart'; +export 'src/functions/find_async.dart'; +export 'src/functions/head.dart'; +export 'src/functions/is.dart'; +export 'src/functions/move.dart' show MoveException, move; +export 'src/functions/move_dir.dart' show MoveDirException, moveDir; +export 'src/functions/move_tree.dart'; +export 'src/functions/pwd.dart' show pwd; +export 'src/functions/tail.dart'; +export 'src/functions/touch.dart'; +export 'src/functions/which.dart' show Which, WhichSearch, which; +export 'src/settings.dart'; +export 'src/utils/dcli_exception.dart'; +export 'src/utils/dcli_platform.dart'; +export 'src/utils/dev_null.dart'; +export 'src/utils/file.dart'; +export 'src/utils/limited_stream_controller.dart'; +export 'src/utils/line_action.dart'; +export 'src/utils/line_file.dart'; +export 'src/utils/platform.dart'; +export 'src/utils/run_exception.dart'; +export 'src/utils/stack_list.dart'; +export 'src/utils/truepath.dart' show privatePath, rootPath, truepath; diff --git a/packages/console/lib/input.dart b/packages/console/lib/input.dart new file mode 100644 index 0000000..e18e997 --- /dev/null +++ b/packages/console/lib/input.dart @@ -0,0 +1,16 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +library; + +export 'src/input/ask.dart'; +export 'src/input/confirm.dart'; +export 'src/input/echo.dart'; +export 'src/input/menu.dart'; diff --git a/packages/console/lib/src/functions/backup.dart b/packages/console/lib/src/functions/backup.dart new file mode 100644 index 0000000..2a5fb48 --- /dev/null +++ b/packages/console/lib/src/functions/backup.dart @@ -0,0 +1,321 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:path/path.dart'; +import 'package:protevus_console/common.dart'; +import 'package:protevus_console/core.dart'; + +/// Provide a very simple mechanism to backup a single file. +/// +/// The backup is placed in '.bak' subdirectory under the passed +/// [pathToFile]'s directory. +/// +/// Be cautious that you don't nest backups of the same file +/// in your code as we always use the same backup target. +/// Instead use [withFileProtectionAsync]. +/// +/// We also renamed the backup to '.bak' to ensure +/// the backupfile doesn't interfere with dev tools +/// (e.g. we don't want an extra pubspec.yaml hanging about) +/// +/// If a file at [pathToFile] doesn't exist then a [BackupFileException] +/// is thrown unless you pass the [ignoreMissing] flag. +/// +/// See: [restoreFile] +/// [withFileProtectionAsync] +/// +void backupFile(String pathToFile, {bool ignoreMissing = false}) { + if (!exists(pathToFile)) { + throw BackupFileException( + 'The backup file ${truepath(pathToFile)} is missing', + ); + } + final pathToBackupFile = _backupFilePath(pathToFile); + if (exists(pathToBackupFile)) { + delete(pathToBackupFile); + } + if (!exists(dirname(pathToBackupFile))) { + createDir(dirname(pathToBackupFile)); + } + + verbose(() => 'Backing up ${truepath(pathToFile)}'); + copy(pathToFile, pathToBackupFile); +} + +/// Designed to work with [backupFile] to restore +/// a file from backup. +/// The existing file is deleted and restored +/// from the .bak/.bak file created when +/// you called [backupFile]. +/// +/// Consider using [withFileProtectionAsync] for a more robust solution. +/// +/// When the last .bak file is restored, the .bak directory +/// will be deleted. If you don't restore all files (your app crashes) +/// then a .bak directory and files may be left hanging around and you may +/// need to manually restore these files. +/// If the backup file doesn't exists this function throws +/// a [RestoreFileException] unless you pass the [ignoreMissing] +/// flag. +void restoreFile(String pathToFile, {bool ignoreMissing = false}) { + final pathToBackupFile = _backupFilePath(pathToFile); + + if (exists(pathToBackupFile)) { + if (exists(pathToFile)) { + delete(pathToFile); + } + + move(pathToBackupFile, pathToFile); + + if (isEmpty(dirname(pathToBackupFile))) { + deleteDir(dirname(pathToBackupFile)); + } + verbose(() => 'Restoring ${truepath(pathToFile)}'); + } else { + if (ignoreMissing) { + verbose( + () => 'Missing restoreFile ${truepath(pathToBackupFile)} ignored.', + ); + } else { + throw RestoreFileException( + 'The backup file ${truepath(pathToBackupFile)} is missing', + ); + } + } +} + +/// EXPERIMENTAL - use with caution and the api may change. +/// +/// Allows you to nominate a list of files to be backed up +/// before an operation commences +/// and then restored once the operation completes. +/// +/// Currently the files a protected by making a copy of each file/directory +/// into a unique system temp directory and then moved back once the +/// [action] has completed. +/// + +/// +/// [withFileProtectionAsync] is safe to use in a nested fashion as each call +/// to [withFileProtectionAsync] creates its own separate backup area. +/// +/// If the VM aborts during execution of the [action] you will find +/// the backed up files in the system temp directory under a directory named +/// .withFileProtection'. You may need to use the time stamp to determine which +/// directory is the right one if you have had mulitple failures. +/// Under normal circumstances the temp directory is delete once the action +/// completes. +/// +/// The [protected] list can contain files, directories. +/// +/// If the entry is a directory then all children (files and directories) +/// are protected. +/// +/// Entries in the [protected] list may be relative or absolute. +/// +/// If [protected] contains a file or directory that doesn't exist +/// and the [action] subsequently creates those entities, then those files +/// and/or directories will be deleted after [action] completes. +/// +/// This function can be useful for doing dry-run operations +/// where you need to ensure the filesystem is restore to its +/// prior state after the dry-run completes. +/// +// ignore: flutter_style_todos +/// TODO(bsutton): make this work for other than current drive under Windows +/// +Future withFileProtectionAsync( + List protected, + Future Function() action, { + String? workingDirectory, +}) async { + // removed glob support for the moment. + // This is because if one of the protected entries is missing + // then we are assuming its a glob. + // We should probably change to accepting a Pattern + // and the have the user pass an actual Glob. + // Problem with this is that find uses a subset of Glob. + // so for the moment, no glob support + // a glob pattern as supported by the [find] command. + // We only support searching for files by the glob pattern (not directories). + // If the entry is a glob pattern then it is applied recusively. + + final workingDirectory0 = workingDirectory ?? pwd; + final result = await withTempDirAsync( + (backupDir) async { + verbose(() => 'withFileProtection: backing up to $backupDir'); + + /// backup the protected files + /// to a backupDir + for (final path in protected) { + final paths = determinePaths( + path: path, + workingDirectory: workingDirectory0, + backupDir: backupDir, + ); + + if (!exists(paths.sourcePath)) { + /// the file/directory doesn't exist. + /// During the restore process this path will be deleted + /// so that once again they don't exist. + continue; + } + + if (isFile(paths.sourcePath)) { + if (!exists(dirname(paths.backupPath))) { + createDir(dirname(paths.backupPath), recursive: true); + } + + /// the entity is a simple file. + copy(paths.sourcePath, paths.backupPath); + } else if (isDirectory(paths.sourcePath)) { + /// the entity is a directory so copy the whole tree + /// recursively. + if (!exists(paths.backupPath)) { + createDir(paths.backupPath, recursive: true); + } + copyTree(paths.sourcePath, paths.backupPath, includeHidden: true); + } else { + throw BackupFileException( + 'Unsupported entity type for ${paths.sourcePath}. ' + 'Only files and directories are supported', + ); + } + // else { + // /// Must be a glob. + // for (final file in find(paths.source, includeHidden: true) + // .toList()) { + // // we need to determine the paths for each [file] + // // as the can have a different relative path as we + // // do a recursive search. + // final paths = _determinePaths( + // path: file, sourceDir: sourceDir, backupDir: backupDir); + + // if (!exists(dirname(paths.target))) { + // createDir(dirname(paths.target), recursive: true); + // } + // copy(paths.source, paths.target); + // } + // } + } + final result = await action(); + + /// restore the protected entities + for (final path in protected) { + final paths = determinePaths( + path: path, + workingDirectory: workingDirectory0, + backupDir: backupDir, + ); + { + if (!exists(paths.backupPath)) { + /// If the protected entity didn't exist before we started + /// the make certain it doesn't exist now. + _deleteEntity(paths.sourcePath); + } + + if (isFile(paths.backupPath)) { + await _restoreFile(paths); + } + + if (isDirectory(paths.backupPath)) { + _restoreDirectory(paths); + } + } + } + + return result; + }, + keep: true, + ); + + return result; +} + +Future _restoreFile(Paths paths) async { + await withTempFileAsync( + (dotBak) async { + try { + if (exists(paths.sourcePath)) { + move(paths.sourcePath, dotBak); + } + + // ignore: flutter_style_todos + /// TODO(bsutton): consider only restoring the file if its last modified + /// time has changed. + move(paths.backupPath, paths.sourcePath); + if (exists(dotBak)) { + delete(dotBak); + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + /// The restore failed so if the dotBak file + /// exists lets at least restore that. + if (exists(dotBak)) { + /// this should never happen as if we have the dotBak + /// file then the originalFile should not exists. + /// but just in case. + if (exists(paths.sourcePath)) { + delete(paths.sourcePath); + } + move(dotBak, paths.sourcePath); + } + } + }, + create: false, + ); +} + +String _backupFilePath(String pathToFile) { + final sourcePath = dirname(pathToFile); + final destPath = join(sourcePath, '.bak'); + final filename = basename(pathToFile); + + return '${join(destPath, filename)}.bak'; +} + +/// Thrown by the [restoreFile] function when +/// the backup file is missing. +class RestoreFileException extends DCliException { + /// Creates a [RestoreFileException] with the given + /// message. + RestoreFileException(super.message); +} + +/// Thrown by the [backupFile] function when +/// the file to be backed up is missing. +class BackupFileException extends DCliException { + /// Creates a [BackupFileException] with the given + /// message. + BackupFileException(super.message); +} + +void _restoreDirectory(Paths paths) { + /// For directories we just recreate them if necessary. + /// This allows us to restore empty directories. + if (exists(paths.sourcePath)) { + deleteDir(paths.sourcePath); + } + createDir(paths.sourcePath, recursive: true); + + /// The find command will return all of the nested files so + /// we don't need to restore them when we see the directory. + moveTree(paths.backupPath, paths.sourcePath, includeHidden: true); +} + +void _deleteEntity(String path) { + if (isFile(path)) { + delete(path); + } else if (isDirectory(path)) { + deleteDir(path); + } else { + verbose(() => 'Path is of unsuported type'); + } +} diff --git a/packages/console/lib/src/functions/cat.dart b/packages/console/lib/src/functions/cat.dart new file mode 100644 index 0000000..b992954 --- /dev/null +++ b/packages/console/lib/src/functions/cat.dart @@ -0,0 +1,45 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:protevus_console/core.dart'; + +/// Prints the contents of the file located at [path] to stdout. +/// +/// ```dart +/// cat("/var/log/syslog"); +/// ``` +/// +/// If the file does not exists then a CatException is thrown. +/// +void cat(String path, {LineAction stdout = print}) => + Cat().cat(path, stdout: stdout); + +/// Class for the [cat] function. +class Cat extends DCliFunction { + /// implementation for the [cat] function. + void cat(String path, {LineAction stdout = print}) { + verbose(() => 'cat: ${truepath(path)}'); + + if (!exists(path)) { + throw CatException('The file at ${truepath(path)} does not exists'); + } + + LineFile(path).readAll((line) { + stdout(line); + return true; + }); + } +} + +/// Thrown if the [cat] function encouters an error. +class CatException extends DCliFunctionException { + /// Thrown if the [cat] function encouters an error. + CatException(super.reason, [super.stacktrace]); +} diff --git a/packages/console/lib/src/functions/copy.dart b/packages/console/lib/src/functions/copy.dart new file mode 100644 index 0000000..42f6d9a --- /dev/null +++ b/packages/console/lib/src/functions/copy.dart @@ -0,0 +1,96 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +/// +/// Copies the file [from] to the path [to]. +/// +/// ```dart +/// copy("/tmp/fred.text", "/tmp/fred2.text", overwrite=true); +/// ``` +/// +/// [to] may be a directory in which case the [from] filename is +/// used to construct the [to] files full path. +/// +/// If [to] is a file then the file must not exist unless [overwrite] +/// is set to true. +/// +/// If [to] is a directory then the directory must exist. +/// +/// If [from] is a symlink we copy the file it links to rather than +/// the symlink. This mimics the behaviour of gnu 'cp' command. +/// +/// If you need to copy the actualy symlink see [symlink]. +/// +/// The default for [overwrite] is false. +/// +/// If an error occurs a [CopyException] is thrown. + +void copy(String from, String to, {bool overwrite = false}) { + var finalto = to; + if (isDirectory(finalto)) { + finalto = join(finalto, basename(from)); + } + verbose(() => + 'copy ${truepath(from)} -> ${truepath(finalto)} overwrite: $overwrite'); + + if (overwrite == false && exists(finalto, followLinks: false)) { + throw CopyException( + 'The target file ${truepath(finalto)} already exists.', + ); + } + + try { + /// if we are copying a symlink then we copy the file rather than + /// the symlink as this mimicks gnu 'cp'. + if (isLink(from)) { + final resolvedFrom = resolveSymLink(from); + File(resolvedFrom).copySync(finalto); + } else { + File(from).copySync(finalto); + } + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + /// lets try and improve the message. + /// We do these checks only on failure + /// so in the most common case (everything is correct) + /// we don't waste cycles on unnecessary work. + if (isDirectory(from)) { + throw CopyException( + "The 'from' argument ${truepath(from)} is a directory. " + 'Use copyTree instead.'); + } + if (!exists(from)) { + throw CopyException("The 'from' file ${truepath(from)} does not exists."); + } + if (!exists(dirname(to))) { + throw CopyException( + "The 'to' directory ${truepath(dirname(to))} does not exists.", + ); + } + + throw CopyException( + 'An error occured copying ${truepath(from)} to ${truepath(finalto)}. ' + 'Error: $e', + ); + } +} + +/// Throw when the [copy] function encounters an error. +class CopyException extends DCliFunctionException { + /// Throw when the [copy] function encounters an error. + CopyException(super.reason); +} diff --git a/packages/console/lib/src/functions/copy_tree.dart b/packages/console/lib/src/functions/copy_tree.dart new file mode 100644 index 0000000..673e4a9 --- /dev/null +++ b/packages/console/lib/src/functions/copy_tree.dart @@ -0,0 +1,161 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:path/path.dart'; +import 'package:protevus_console/core.dart'; + +/// +/// Copies the contents of the [from] directory to the +/// [to] path with an optional filter. +/// +/// The [to] path must exist. +/// +/// If any copied file already exists in the [to] path then +/// an exeption is throw and a parital copyTree may occur. +/// +/// You can force the copyTree to overwrite files in the [to] +/// directory by setting [overwrite] to true (defaults to false). +/// +/// The [recursive] argument controls whether subdirectories are +/// copied. If [recursive] is true (the default) it will copy +/// subdirectories. +/// +/// +/// ```dart +/// copyTree("/tmp/", "/tmp/new_dir", overwrite:true); +/// ``` +/// By default hidden files are ignored. To allow hidden files to +/// be processed set [includeHidden] to true. +/// +/// You can select which files are to be copied by passing a [filter]. +/// If a [filter] isn't passed then all files are copied as per +/// the [includeHidden] state. +/// +/// ```dart +/// copyTree("/tmp/", "/tmp/new_dir", overwrite:true, includeHidden:true +/// , filter: (file) => extension(file) == 'dart'); +/// ``` +/// +/// The [filter] method can also be used to report progress as it +/// is called just before we copy a file. +/// +/// ```dart +/// copyTree("/tmp/", "/tmp/new_dir", overwrite:true +/// , filter: (file) { +/// var include = extension(file) == 'dart'; +/// if (include) { +/// print('copying: $file'); +/// } +/// return include; +/// }); +/// ``` +/// +/// +/// The default for [overwrite] is false. +/// +/// If an error occurs a [CopyTreeException] is thrown. +void copyTree( + String from, + String to, { + bool overwrite = false, + bool includeHidden = false, + bool recursive = true, + bool Function(String file) filter = _allowAll, +}) => + _CopyTree().copyTree( + from, + to, + overwrite: overwrite, + includeHidden: includeHidden, + filter: filter, + recursive: recursive, + ); + +bool _allowAll(String file) => true; + +class _CopyTree extends DCliFunction { + void copyTree( + String from, + String to, { + bool overwrite = false, + bool Function(String file) filter = _allowAll, + bool includeHidden = false, + bool recursive = true, + }) { + verbose(() => 'copyTree: from: $from, to: $to, overwrite: $overwrite ' + 'includeHidden: $includeHidden recursive: $recursive '); + if (!isDirectory(from)) { + throw CopyTreeException( + 'The [from] path ${truepath(from)} must be a directory.', + ); + } + if (!exists(to)) { + throw CopyTreeException( + 'The [to] path ${truepath(to)} must already exist.', + ); + } + + if (!isDirectory(to)) { + throw CopyTreeException( + 'The [to] path ${truepath(to)} must be a directory.', + ); + } + + try { + find('*', + workingDirectory: from, + includeHidden: includeHidden, + recursive: recursive, progress: (item) { + _process(item.pathTo, filter, from, to, + overwrite: overwrite, recursive: recursive); + return true; + }); + verbose( + () => 'copyTree copied: ${truepath(from)} -> ${truepath(to)}, ' + 'includeHidden: $includeHidden, recursive: $recursive, ' + 'overwrite: $overwrite', + ); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw CopyTreeException( + 'An error occured copying directory' + ' ${truepath(from)} to ${truepath(to)}. ' + 'Error: $e', + ); + } + } + + void _process( + String file, bool Function(String file) filter, String from, String to, + {required bool overwrite, required bool recursive}) { + if (filter(file)) { + final target = join(to, relative(file, from: from)); + + if (recursive && !exists(dirname(target))) { + createDir(dirname(target), recursive: true); + } + + if (!overwrite && exists(target)) { + throw CopyTreeException( + 'The target file ${truepath(target)} already exists.', + ); + } + + copy(file, target, overwrite: overwrite); + } + } +} + +/// Throw when the [copy] function encounters an error. +class CopyTreeException extends DCliFunctionException { + /// Throw when the [copy] function encounters an error. + CopyTreeException(super.reason); +} diff --git a/packages/console/lib/src/functions/create_dir.dart b/packages/console/lib/src/functions/create_dir.dart new file mode 100644 index 0000000..11bd26e --- /dev/null +++ b/packages/console/lib/src/functions/create_dir.dart @@ -0,0 +1,108 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; +import 'package:protevus_console/core.dart'; + +/// Creates a directory as described by [path]. +/// Path may be a single path segment (e.g. bin) +/// or a full or partial tree (e.g. /usr/bin) +/// +/// ```dart +/// createDir("/tmp/fred/tools", recursive: true); +/// ``` +/// +/// If [recursive] is true then any parent +/// paths that don't exist will be created. +/// +/// If [recursive] is false then any parent paths +/// don't exist then a [CreateDirException] will be thrown. +/// +/// If the [path] already exists an exception is thrown. +/// +/// As a convenience [createDir] returns the same path +/// that it was passed. +/// +/// ```dart +/// var path = createDir('/tmp/new_home')); +/// ``` +/// + +String createDir(String path, {bool recursive = false}) => + _CreateDir().createDir(path, recursive: recursive); + +/// Creates a temp directory and then calls [action]. +/// Once action completes the temporary directory will be deleted. +/// +/// The actions return value [R] is returned from the [withTempDirAsync] +/// function. +/// +/// If you pass [keep] = true then the temp directory won't be deleted. +/// This can be useful when testing and you need to examine the temp directory. +/// +/// You can optionally pass in your own tempDir via [pathToTempDir]. +/// This can be useful when sometimes you need to control the tempDir +/// and sometimes you want it created. +/// If you pass in [pathToTempDir] it will NOT be deleted regardless +/// of the value of [keep]. +Future withTempDirAsync(Future Function(String tempDir) action, + {bool keep = false, String? pathToTempDir}) async { + final dir = pathToTempDir ?? createTempDir(); + + R result; + try { + result = await action(dir); + } finally { + if (!keep && pathToTempDir == null) { + deleteDir(dir); + } + } + return result; +} + +/// Creates a temporary directory under the system temp folder. +/// +/// The temporary directory name is formed from a uuid. +/// It is your responsiblity to delete the directory once you have +/// finsihed with it. +String createTempDir() => _CreateDir().createDir( + join(Directory.systemTemp.path, '.dclitmp', const Uuid().v4()), + recursive: true, + ); + +class _CreateDir extends DCliFunction { + String createDir(String path, {required bool recursive}) { + verbose(() => 'createDir: ${truepath(path)} recursive: $recursive'); + + try { + if (exists(path)) { + throw CreateDirException('The path ${truepath(path)} already exists'); + } + + Directory(path).createSync(recursive: recursive); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw CreateDirException( + 'Unable to create the directory ${truepath(path)}. Error: $e', + ); + } + return path; + } +} + +/// Thrown when the function [createDir] encounters an error +class CreateDirException extends DCliFunctionException { + /// Thrown when the function [createDir] encounters an error + CreateDirException(super.reason); +} diff --git a/packages/console/lib/src/functions/dcli_function.dart b/packages/console/lib/src/functions/dcli_function.dart new file mode 100644 index 0000000..4375d0a --- /dev/null +++ b/packages/console/lib/src/functions/dcli_function.dart @@ -0,0 +1,21 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:protevus_console/core.dart'; + +/// Base class for the classes that implement +/// the public DCli functions. +class DCliFunction {} + +/// Base class for all dcli function exceptions. +class DCliFunctionException extends DCliException { + /// Base class for all dcli function exceptions. + DCliFunctionException(super.message, [super.stackTrace]); +} diff --git a/packages/console/lib/src/functions/delete.dart b/packages/console/lib/src/functions/delete.dart new file mode 100644 index 0000000..04d095c --- /dev/null +++ b/packages/console/lib/src/functions/delete.dart @@ -0,0 +1,55 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:protevus_console/core.dart'; + +/// +/// Deletes the file at [path]. +/// +/// If the file does not exists a DeleteException is thrown. +/// +/// ```dart +/// delete("/tmp/test.fred", ask: true); +/// ``` +/// +/// If the [path] is a directory a DeleteException is thrown. +void delete(String path) => _Delete().delete(path); + +class _Delete extends DCliFunction { + void delete(String path) { + verbose(() => 'delete: ${truepath(path)}'); + + if (!exists(path)) { + throw DeleteException('The path ${truepath(path)} does not exists.'); + } + + if (isDirectory(path)) { + throw DeleteException('The path ${truepath(path)} is a directory.'); + } + + try { + File(path).deleteSync(); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw DeleteException( + 'An error occured deleting ${truepath(path)}. Error: $e', + ); + } + } +} + +/// Thrown when the [delete] function encounters an error +class DeleteException extends DCliFunctionException { + /// Thrown when the [delete] function encounters an error + DeleteException(super.reason); +} diff --git a/packages/console/lib/src/functions/delete_dir.dart b/packages/console/lib/src/functions/delete_dir.dart new file mode 100644 index 0000000..6ba44d4 --- /dev/null +++ b/packages/console/lib/src/functions/delete_dir.dart @@ -0,0 +1,70 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; +import 'package:protevus_console/core.dart'; + +/// +/// Deletes the directory located at [path]. +/// +/// If [recursive] is true (default true) then the directory and all child files +/// and directories will be deleted. +/// +/// ```dart +/// deleteDir("/tmp/testing", recursive=true); +/// ``` +/// +/// If [path] is not a directory then a [DeleteDirException] is thrown. +/// +/// If the directory does not exists a [DeleteDirException] is thrown. +/// +/// If the directory cannot be delete (e.g. permissions) a +/// [DeleteDirException] is thrown. +/// +/// If recursive is false the directory must be empty otherwise a +/// [DeleteDirException] is thrown. +/// +/// See [isDirectory] +/// [exists] +/// +void deleteDir(String path, {bool recursive = true}) => + _DeleteDir().deleteDir(path, recursive: recursive); + +class _DeleteDir extends DCliFunction { + void deleteDir(String path, {required bool recursive}) { + verbose(() => 'deleteDir: ${truepath(path)} recursive: $recursive'); + + if (!exists(path)) { + throw DeleteDirException('The path ${truepath(path)} does not exist.'); + } + + if (!isDirectory(path)) { + throw DeleteDirException( + 'The path ${truepath(path)} is not a directory.', + ); + } + + try { + Directory(path).deleteSync(recursive: recursive); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw DeleteDirException( + 'Unable to delete the directory ${truepath(path)}. Error: $e', + ); + } + } +} + +/// Throw when [deleteDir] function encounters an error +class DeleteDirException extends DCliFunctionException { + /// Throw when [deleteDir] function encounters an error + DeleteDirException(super.reason); +} diff --git a/packages/console/lib/src/functions/delete_tree.dart b/packages/console/lib/src/functions/delete_tree.dart new file mode 100644 index 0000000..b9082cd --- /dev/null +++ b/packages/console/lib/src/functions/delete_tree.dart @@ -0,0 +1,135 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; +import 'package:protevus_console/core.dart'; + +/// Recursively deletes the contents of the directory located at [path] +/// with an optional filter. The directory at [path] is not deleted. +/// +/// +/// [path] must be a directory and must exist. +/// +/// ```dart +/// deleteTree(join(rootPath, 'tmp'))); +/// ``` +/// +/// Pass a filter to control what is deleted. Only files/directories +/// that match the filter will be deleted. +/// +/// ```dart +/// deleteTree(join(rootPath, 'tmp') +/// , filter: (type, path) => extension(file) == 'dart'); +/// ``` +/// +/// The [filter] method can also be used to report progress as it +/// is called just before we move a file or directory. +/// +/// ```dart +/// deleteTree(join(rootPath, 'tmp') +/// , filter: (entity) { +/// var delete = extension(entity) == 'dart'; +/// if (delete) { +/// print('deleting: $file'); +/// } +/// return delete; +/// }); +/// ``` +/// +/// +/// If an error occurs a [DeleteTreeException] is thrown. +/// +/// EXPERIMENTAL +void deleteTree( + String path, { + bool Function(FileSystemEntityType type, String file) filter = _deleteAll, +}) => + _DeleteTree().deleteTree( + path, + filter: filter, + ); + +bool _deleteAll(FileSystemEntityType type, String file) => true; + +class _DeleteTree extends DCliFunction { + void deleteTree( + String path, { + bool Function(FileSystemEntityType type, String file) filter = _deleteAll, + }) { + if (!exists(path)) { + throw DeleteTreeException( + 'The [path] ${truepath(path)} does not exist.', + ); + } + if (!isDirectory(path)) { + throw DeleteTreeException( + 'The [path] ${truepath(path)} is not a directory.', + ); + } + + verbose(() => 'deleteTree called ${truepath(path)}'); + try { + find('*', + workingDirectory: path, + includeHidden: true, + types: [Find.file, Find.directory, Find.link], progress: (item) { + _process(item.pathTo, filter, item.type); + return true; + }); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw DeleteTreeException( + 'An error occured deleting directory ${truepath(path)}. ' + 'Error: $e', + ); + } + } + + void _process( + String pathToFile, + bool Function(FileSystemEntityType type, String file) filter, + FileSystemEntityType type, + ) { + if (filter(type, pathToFile)) { + // we create directories as we go. + // only directories that contain a file that is to be + // moved will be created. + // ignore: exhaustive_cases + switch (type) { + case FileSystemEntityType.directory: + { + deleteDir(pathToFile); + break; + } + case FileSystemEntityType.file: + { + delete(pathToFile); + break; + } + case FileSystemEntityType.link: + { + deleteSymlink(pathToFile); + break; + } + } + + verbose( + () => 'deleteTree delting: ${truepath(pathToFile)}', + ); + } + } +} + +/// Thrown when the [deleteTree] function encouters an error. +class DeleteTreeException extends DCliFunctionException { + /// Thrown when the [deleteTree] function encouters an error. + DeleteTreeException(super.reason); +} diff --git a/packages/console/lib/src/functions/env.dart b/packages/console/lib/src/functions/env.dart new file mode 100644 index 0000000..8adef80 --- /dev/null +++ b/packages/console/lib/src/functions/env.dart @@ -0,0 +1,428 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart'; +import 'package:scope/scope.dart'; + +// ignore: unused_import +import '../settings.dart'; +import '../utils/dcli_exception.dart'; +import '../utils/truepath.dart'; +import 'dcli_function.dart'; + +/// Provides access to shell environment variables. +Env get env => Env(); + +/// Tests if the given [path] is contained +/// in the OS's PATH environment variable. +/// An canonicalized match of [path] is made against +/// each path on the OS's path. +bool isOnPATH(String path) => Env().isOnPATH(path); + +/// Returns the list of directory paths that are contained +/// in the OS's PATH environment variable. +/// They are returned in the same order that they appear within +/// the PATH environment variable (as order is important.) +//ignore: non_constant_identifier_names +List get PATH => Env()._path; + +/// returns the path to the OS specific HOME directory +//ignore: non_constant_identifier_names +String get HOME => Env().HOME; + +/// Returns a map of all the environment variables +/// inherited from the parent as well as any changes +/// made by calls to [env[]=]. +/// +/// See [env[]] +/// [env[]=] +Map get envs => Env()._envVars; + +/// +/// Sets gets an environment variable for the current process. +/// +/// Passing a null value will remove the key from the +/// set of environment variables. +/// +/// Any child process spawned will inherit these changes. +/// e.g. +/// ``` +/// /// Set the environment variable 'XXX' to 'A Value' +/// env['XXX'] = 'A Value'; +/// +/// // the echo command will display the value off XXX. +/// '''echo $XXX'''.run; +/// +/// /// Get the current value of an environment variable +/// var xxx = env['XXX']; +/// +/// ``` +/// NOTE: this does NOT affect the parent +/// processes environment. +/// + +/// Implementation class for the functions [env[]] and [env[]=]. +class Env extends DCliFunction { + /// Implementation class for the functions [env[]] and [env[]=]. + /// Returns a singleton unless we are running in a [Scope] + /// and a [scopeKey] for [Env] has been placed into the scope. + factory Env() { + if (Scope.hasScopeKey(scopeKey)) { + return Scope.use(scopeKey); + } else { + return _self ??= Env._internal(); + } + } + + /// Use this ctor for injecting an altered Environment + /// into a Scope. + /// The main use for this ctor is for unit testing. + factory Env.forScope(Map map) { + final env = Env._internal(); + + map.forEach((key, value) { + env[key] = value; + }); + return env; + } + + Env._internal() : _caseSensitive = !Settings().isWindows { + final platformVars = Platform.environment; + + _envVars = + CanonicalizedMap((key) => _caseSensitive ? key : key.toUpperCase()); + + // build a local map with all of the OS environment vars. + for (final entry in platformVars.entries) { + _envVars.putIfAbsent(entry.key, () => entry.value); + } + } + + static ScopeKey scopeKey = ScopeKey(); + static Env? _self = Env._internal(); + + late Map _envVars; + + final bool _caseSensitive; + + /// Returns true if environment variable keys are case sensitive. + /// All OSs are case sensitive except for Windows + bool get caseSensitive => _caseSensitive; + + String? _env(String name) { + verbose(() => 'env: $name:${_envVars[name]}'); + + return _envVars[_caseSensitive ? name : name.toUpperCase()]; + } + + /// Returns the complete set of Environment variable entries. + Iterable> get entries => _envVars.entries; + + /// Adds all of the entries in the [other] map as environment variables. + /// Case translation will occur if the platform is case sensitive. + void addAll(Map other) { + for (final entry in other.entries) { + _setEnv(entry.key, entry.value); + } + } + + /// Returns true if an environment variable with the name + /// [key] exists. + bool exists(String key) => _envVars.keys.contains(key); + + /// returns the PATH environment var as an ordered + /// array of the paths contained in the PATH. + /// The list is ordered Left to right of the paths in + /// the PATH environment var. + List get _path { + final pathEnv = this['PATH'] ?? ''; + + return pathEnv + .split(delimiterForPATH) + // on linux an empty path equates to the current directory. + // .where((value) => value.trim().isNotEmpty) + .toList(); + } + + /// Returns the value of an environment variable. + /// + /// name of the environment variable. + /// + /// On posix systems name of the environment variable is case sensitive. + /// + /// + ///```dart + ///String path = env["PATH"]; + ///``` + /// + String? operator [](String name) => _env(name); + + /// Sets the value of an environment variable + /// ```dart + /// env["PASS"] = 'mypassword'; + /// ``` + /// + void operator []=(String name, String? value) => _setEnv(name, value); + + /// Appends [newPath] to the list of paths in the + /// PATH environment variable. + /// + /// If [newPath] is already in PATH no action is taken. + /// + /// Changing the PATH has no affect on the parent + /// process (shell) that launched this script. + /// + /// Changing the path affects the current script + /// and any children that it spawns. + /// + /// See: [prependToPATH] + /// [removeFromPATH] + void appendToPATH(String newPath) { + if (!isOnPATH(newPath)) { + final path = PATH..add(newPath); + _setEnv('PATH', path.join(delimiterForPATH)); + } + } + + /// Prepends [newPath] to the list of paths in the + /// PATH environment variable provided the + /// path isn't already on the PATH. + /// + /// If [newPath] is already in PATH no action is taken. + /// + /// Changing the PATH has no affect on the parent + /// process (shell) that launched this script. + /// + /// Changing the path affects the current script + /// and any children that it spawns. + /// + /// See: [appendToPATH] + /// [removeFromPATH] + void prependToPATH(String newPath) { + if (!isOnPATH(newPath)) { + final path = PATH..insert(0, newPath); + _setEnv('PATH', path.join(delimiterForPATH)); + } + } + + /// Removes the given [oldPath] from the PATH environment variable. + /// + /// Changing the PATH has no affect on the parent + /// process (shell) that launched this script. + /// + /// Changing the path affects the current script + /// and any children that it spawns. + /// + /// See: [appendToPATH] + /// [prependToPATH] + void removeFromPATH(String oldPath) { + final path = PATH..remove(oldPath); + _setEnv('PATH', path.join(delimiterForPATH)); + } + + /// Adds [newPath] to the PATH environment variable + /// if it is not already present. + /// + /// The [newPath] will be added to the end of the PATH list. + /// + /// Changing the PATH has no affect on the parent + /// process (shell) that launched this script. + /// + /// Changing the PATH affects the current script + /// and any children that it spawns. + @Deprecated('Use appendToPATH') + void addToPATHIfAbsent(String newPath) => appendToPATH(newPath); + + /// + /// Gets the path to the user's home directory + /// using the enviornment var appropriate for the user's OS. + //ignore: non_constant_identifier_names + String get HOME { + String? home; + + if (Settings().isWindows) { + home = _env('APPDATA'); + } else { + home = _env('HOME'); + } + + if (home == null) { + if (Settings().isWindows) { + throw DCliException( + "Unable to find the 'APPDATA' enviroment variable. " + 'Ensure it is set and try again.', + ); + } else { + throw DCliException( + "Unable to find the 'HOME' enviroment variable. " + 'Ensure it is set and try again.', + ); + } + } + return home; + } + + /// returns true if the given [checkPath] is in the list + /// of paths defined in the environment variable [PATH]. + bool isOnPATH(String checkPath) { + final canon = canonicalize(truepath(checkPath)); + var found = false; + for (final path in _path) { + if (canonicalize(truepath(path)) == canon) { + found = true; + break; + } + } + return found; + } + + /// Passing a null [value] will remove the key from the + /// set of environment variables. + void _setEnv(String name, String? value) { + verbose(() => 'env[$name] = $value'); + if (value == null) { + _envVars.remove(name); + if (Settings().isWindows) { + if (name == 'HOME' || name == 'APPDATA') { + _envVars + ..remove('HOME') + ..remove('APPDATA'); + } + } + } else { + _envVars[name] = value; + + if (Settings().isWindows) { + if (name == 'HOME' || name == 'APPDATA') { + _envVars['HOME'] = value; + _envVars['APPDATA'] = value; + } + } + } + } + + /// returns the delimiter used by the PATH enviorment variable. + /// + /// On linix it is ':' ond windows it is ';' + /// + /// NOTE do NOT confuses this with the file system path root!!! + /// + String get delimiterForPATH { + var separator = ':'; + + if (Settings().isWindows) { + separator = ';'; + } + return separator; + } + + /// Encodes all environment variables to a json string. + /// This method is intended to be used in conjuction with + /// [fromJson]. + /// + /// You will find this method useful when spawning an isolate + /// that depends on environment variables created by calls + /// to DCli [env] property. + /// + /// When creating an isolate it takes its environment variables + /// from [Platform.environment]. This means that any environment + /// variables created via DCli will not be visible to the isolate. + /// + /// The way to over come this problem is to call [Env().toJson()] + /// pass the resulting string to the isolate and then have the + /// isolate call [Env().fromJson()] which resets the isolates + /// environment variables. + /// + /// ```dart + /// Future startIsolate() async { + /// var iso = await IsolateRunner.spawn(); + /// + /// try { + /// iso.run(scheduler, Env().toJson()); + /// } finally { + /// await iso.close(); + /// } + ///} + /// + /// // This method runs in the new isolate. + /// void scheduler(String jsonEnvironment) { + /// Env().fromJson(jsonEnvironment); + /// Certbot().scheduleRenews(); + /// } + /// ``` + String toJson() { + final envMap = {}..addEntries(env.entries.toSet()); + return JsonEncoder(_toEncodable).convert(envMap); + } + + /// Takes a json string created by [toJson] + /// clears the current set of environment variables + /// and replaces them with the environment variables + /// encoded in the json string. + /// + /// If you choose not to use [toJson] to create the json + /// then [json ] must be in form of an json encoded Map. + void fromJson(String json) { + _envVars.clear(); + env.addAll( + Map.from( + const JsonDecoder().convert(json) as Map, + ), + ); + } + + String _toEncodable(Object? object) => object.toString(); +} + +/// Injects environment variables into the scope +/// of the [callback] method. +/// +/// The passed [environment] map is merged with the current [env] and +/// injected into the [callback]'s scope. +/// +/// Note: code that access [Platform.environment] directly +/// will not see the environment variables injected via +/// this method. You must use the dcli [env] variable. +/// +/// Any changes to [env] within the scope of the callback +/// are only visible inside that scope and revert once [callback] +/// returns. +/// This is particularly useful for unit tests and running +/// a process that requires specific environment variables. +Future withEnvironmentAsync(Future Function() callback, + {required Map environment}) async { + final existing = Env()._envVars; + return (Scope() + ..value(Env.scopeKey, Env.forScope(existing)..addAll(environment))) + .run(() async => callback()); +} + +/// Injects environment variables into the scope +/// of the [callback] method. +/// You must [withEnvironmentAsync] if the callback is async. +/// +/// See [withEnvironmentAsync] for general details +R withEnvironment(R Function() callback, + {required Map environment}) { + final existing = Env()._envVars; + return (Scope() + ..value(Env.scopeKey, Env.forScope(existing)..addAll(environment))) + .runSync(() => callback()); +} + +/// Base class for all Environment variable related exceptions. +class EnvironmentException extends DCliException { + /// Create an environment variable exception. + EnvironmentException(super.message); +} diff --git a/packages/console/lib/src/functions/find.dart b/packages/console/lib/src/functions/find.dart new file mode 100644 index 0000000..a5015a9 --- /dev/null +++ b/packages/console/lib/src/functions/find.dart @@ -0,0 +1,564 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +// TODO(bsutton): investigate if we need to restore the +// [LimitedStreamController] +// typedef FindController = LimitedStreamController; +typedef ProgressCallback = bool Function(FindItem item); + +/// +/// Returns the list of files in the current and child +/// directories that match the passed glob pattern. +/// +/// Each file is returned as an absolute path. +/// +/// You can obtain a relative path by calling: +/// ```dart +/// var relativePath = relative(filePath, from: searchRoot); +/// ``` +/// +/// Note: this is a limited implementation of glob. +/// See the below notes for details. +/// +/// ```dart +/// find('*.jpg', recursive:true).forEach((file) => print(file)); +/// +/// List results = find('[a-z]*.jpg', caseSensitive:true).toList(); +/// +/// find('*.jpg' +/// , types:[Find.directory, Find.file]) +/// .forEach((file) => print(file)); +/// ``` +/// +/// Valid patterns are: +/// ``` +/// +/// [*] - matches any number of any characters including none. +/// +/// [?] - matches any single character +/// +/// [[abc]] - matches any one character given in the bracket +/// +/// [[a-z]] - matches one character from the range given in the bracket +/// +/// [[!abc]] - matches one character that is not given in the bracket +/// +/// [[!a-z]] - matches one character that is not from the range given +/// in the bracket +/// ``` +/// +/// If [caseSensitive] is true then a case sensitive match is performed. +/// [caseSensitive] defaults to false. +/// +/// If [recursive] is true then a recursive search of all subdirectories +/// (all the way down) is performed. +/// [recursive] is true by default. +/// +/// [includeHidden] controls whether hidden files (.xx) are returned and +/// whether hidden directorys (.xx) are recursed into when the [recursive] +/// option is true. By default hidden files and directories are ignored. +/// If the wildcard begins with a '.' then includeHidden will be enabled +/// automatically. +/// +/// [types] allows you to specify the file types you want the find to return. +/// By default [types] limits the results to files. +/// +/// [workingDirectory] allows you to specify an alternate d +/// irectory to seach within +/// rather than the current work directory. +/// +/// [types] the list of types to search file. Defaults to [Find.file]. +/// See [Find.file], [Find.directory], [Find.link]. +/// +/// Passing a [progress] will allow you to process the results as the are +/// produced rather than having to wait for the call to find to complete. +/// The passed progress is also returned. +/// If the [progress] doesn't output [stdout] then you will get no results +/// back. +/// +// TODO(bsutton): consider having find return a Stream and eliminate passing +/// a controller in. +void find( + String pattern, { + required ProgressCallback progress, + bool caseSensitive = false, + bool recursive = true, + bool includeHidden = false, + String workingDirectory = '.', + List types = const [Find.file], +}) => + Find()._find( + pattern, + caseSensitive: caseSensitive, + recursive: recursive, + includeHidden: includeHidden, + workingDirectory: workingDirectory, + progress: progress, + types: types, + ); + +/// Implementation for the [_find] function. +class Find extends DCliFunction { + final bool _closed = false; + + /// Find matching files an call [progress] for each one. + void _find( + String pattern, { + required ProgressCallback progress, + bool caseSensitive = false, + bool recursive = true, + String workingDirectory = '.', + List types = const [Find.file], + bool includeHidden = false, + }) { + final config = FindConfig.build( + pattern: pattern, + workingDirectory: workingDirectory, + includeHidden: includeHidden, + caseSensitive: caseSensitive); + + _innerFind( + config: config, + recursive: recursive, + progress: progress, + types: types, + ); + } + + void _innerFind({ + required FindConfig config, + required ProgressCallback progress, + bool recursive = true, + List types = const [Find.file], + }) { + verbose( + () => 'find: pwd: $pwd ' + 'workingDirectory: ${truepath(config.workingDirectory)} ' + 'pattern: ${config.pattern} caseSensitive: ${config.caseSensitive} ' + 'recursive: $recursive types: $types ', + ); + final nextLevel = List.filled(100, null, growable: true); + final singleDirectory = + List.filled(100, null, growable: true); + final childDirectories = + List.filled(100, null, growable: true); + if (!_processDirectory( + config.workingDirectory, + config.workingDirectory, + recursive, + types, + config.matcher, + config.includeHidden, + progress, + childDirectories, + )) { + return; + } + while (childDirectories[0] != null) { + _zeroElements(nextLevel); + for (final directory in childDirectories) { + if (directory == null) { + break; + } + // print('calling _processDirectory ${count++}'); + if (!_processDirectory( + config.workingDirectory, + directory.path, + recursive, + types, + config.matcher, + config.includeHidden, + progress, + singleDirectory, + )) { + break; + } + _appendTo(nextLevel, singleDirectory); + _zeroElements(singleDirectory); + } + _copyInto(childDirectories, nextLevel); + } + } + + bool _processDirectory( + String workingDirectory, + String currentDirectory, + bool recursive, + List types, + PatternMatcher matcher, + bool includeHidden, + ProgressCallback progress, + List nextLevel, + ) { + // print('process Directory ${dircount++}'); + final list = Directory(currentDirectory).listSync(followLinks: false); + + var nextLevelIndex = 0; + + for (final entity in list) { + try { + late final FileSystemEntityType type; + type = FileSystemEntity.typeSync(entity.path, followLinks: false); + + if (types.contains(type) && + matcher.match(entity.path) && + _allowed( + workingDirectory, + entity, + includeHidden: includeHidden, + )) { + if (_closed) { + return false; + } + + /// If the controller has been paused or hasn't yet been + /// listened to then we don't want to add files to + /// it otherwise we may run out of memory. + if (!progress(FindItem(entity.path, type))) { + return false; + } + } + + /// If we are recursing then we need to add any directories + /// to the list of childDirectories that need to be recursed. + if (recursive && type == Find.directory) { + if (nextLevel.length > nextLevelIndex) { + nextLevel[nextLevelIndex] = entity; + } else { + nextLevel.add(entity); + } + nextLevelIndex++; + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + if (_isGeneralIOError(e)) { + /// can mean a corrupt disk, problems with virtualisation + /// I've seen this when gdrive. + } else if (e is FileSystemException && + e.osError?.errorCode == _accessDenied) { + /// check for and ignore permission denied. + verbose(() => 'Permission denied: ${e.path}'); + } else if (e is FileSystemException && e.osError?.errorCode == 40) { + /// ignore recursive symbolic link problems. + verbose(() => 'Too many levels of symbolic links: ${e.path}'); + } else if (e is FileSystemException && e.osError?.errorCode == 22) { + /// Invalid argument - not really certain what this means but we get + /// it when processing a .steam folder that includes a windows + /// emulator. + verbose(() => 'Invalid argument: ${e.path}'); + } else if (e is FileSystemException && + e.osError?.errorCode == _directoryNotFound) { + /// The directory may have been deleted between us finding it and + /// processing it. + verbose( + () => 'File or Directory deleted whilst we were processing it:' + ' ${e.path}', + ); + } else { + // ignore: only_throw_errors + rethrow; + } + } + } + return true; + } + + int get _accessDenied => Settings().isWindows ? 5 : 13; + int get _directoryNotFound => Settings().isWindows ? 3 : 2; + + /// Checks if a hidden file is allowed. + /// Non-hidden files are always allowed. + bool _allowed( + String workingDirectory, + FileSystemEntity entity, { + required bool includeHidden, + }) => + includeHidden || !_isHidden(workingDirectory, entity); + + // check if the entity is a hidden file (.xxx) or + // if lives in a hidden directory. + bool _isHidden(String workingDirectory, FileSystemEntity entity) { + final relativePath = relative(entity.path, from: workingDirectory); + + final parts = relativePath.split(separator); + + var isHidden = false; + for (final part in parts) { + if (part.startsWith('.')) { + isHidden = true; + break; + } + } + return isHidden; + } + + /// set all elements in the array to null so we can re-use the list + /// to reduce GC. + void _zeroElements(List nextLevel) { + for (var i = 0; i < nextLevel.length && nextLevel[i] != null; i++) { + nextLevel[i] = null; + } + } + + void _copyInto( + List childDirectories, + List nextLevel, + ) { + _zeroElements(childDirectories); + for (var i = 0; i < nextLevel.length; i++) { + if (childDirectories.length > i) { + childDirectories[i] = nextLevel[i]; + } else { + childDirectories.add(nextLevel[i]); + } + } + } + + void _appendTo( + List nextLevel, + List singleDirectory, + ) { + var index = _firstAvailable(nextLevel); + + for (var i = 0; i < singleDirectory.length; i++) { + if (singleDirectory[i] == null) { + break; + } + if (index >= nextLevel.length) { + nextLevel.add(singleDirectory[i]); + index++; + } else { + nextLevel[index++] = singleDirectory[i]; + } + } + } + + int _firstAvailable(List nextLevel) { + var firstAvailable = 0; + while (firstAvailable < nextLevel.length && + nextLevel[firstAvailable] != null) { + firstAvailable++; + } + return firstAvailable; + } + + /// pass as a value to the find types argument + /// to select files to be found + static const file = FileSystemEntityType.file; + + /// pass as a value to the final types argument + /// to select directories to be found + static const directory = FileSystemEntityType.directory; + + /// pass as a value to the final types argument + /// to select links to be found + static const link = FileSystemEntityType.link; + + bool _isGeneralIOError(Object e) { + var error = false; + error = e is FileSystemException && + !Platform.isWindows && + e.osError?.errorCode == 5; + + if (error) { + verbose(() => 'General IO Error(5) accessing: ${e.path}'); + } + + return error; + } +} + +class PatternMatcher { + PatternMatcher( + this.pattern, { + required this.workingDirectory, + required this.caseSensitive, + }) { + regEx = buildRegEx(); + + final patternParts = split(dirname(pattern)); + var count = patternParts.length; + if (patternParts.length == 1 && patternParts[0] == '.') { + count = 0; + } + directoryParts = count; + } + + String pattern; + String workingDirectory; + late RegExp regEx; + bool caseSensitive; + + /// the no. of directories in the pattern + late final int directoryParts; + + bool match(String path) { + final matchPart = _extractMatchPart(path); + // print('path: $path, matchPart: $matchPart pattern: $pattern'); + return regEx.stringMatch(matchPart) == matchPart; + } + + RegExp buildRegEx() { + var regEx = ''; + + for (var i = 0; i < pattern.length; i++) { + final char = pattern[i]; + + switch (char) { + case '[': + regEx += '['; + break; + case ']': + regEx += ']'; + break; + case '*': + regEx += '.*'; + break; + case '?': + regEx += '.'; + break; + case '-': + regEx += '-'; + break; + case '!': + regEx += '^'; + break; + case '.': + regEx += r'\.'; + break; + case r'\': + regEx += r'\\'; + break; + default: + regEx += char; + break; + } + } + return RegExp(regEx, caseSensitive: caseSensitive); + } + + /// A pattern may contain a relative path in which case + /// we need to match [path] with the same no. of directories + /// as is contained in the pattern. + /// + /// This method extracts the components of a absolute [path] + /// that must be used when doing the pattern match. + String _extractMatchPart(String path) { + if (directoryParts == 0) { + return basename(path); + } + + final pathParts = split(dirname(relative(path, from: workingDirectory))); + + var partsCount = pathParts.length; + if (pathParts.length == 1 && pathParts[0] == '.') { + partsCount = 0; + } + + /// If the path doesn't have enough parts then just + /// return the path relative to the workingDirectory. + if (partsCount < directoryParts) { + return relative(path, from: workingDirectory); + } + + /// return just the required parts. + return joinAll( + [...pathParts.sublist(partsCount - directoryParts), basename(path)], + ); + } +} + +//typedef FindProgress = Future Function(String path); +//typedef FindProgress = Sink(); + +/// Holds details of a file system entity returned by the +/// [find] function. +class FindItem { + /// [pathTo] is the path to the file system entity + /// [type] is the type of file system entity. + FindItem(this.pathTo, this.type); + + /// the path to the file system entity + String pathTo; + + /// type of file system entity + FileSystemEntityType type; + + @override + String toString() => pathTo; +} + +/// Thrown when the [find] function encouters an error. +class FindException extends DCliFunctionException { + /// Thrown when the [move] function encouters an error. + FindException(super.reason); +} + +class FindConfig { + factory FindConfig.build( + {required String pattern, + required String workingDirectory, + required bool includeHidden, + required bool caseSensitive}) { + /// strip any path components out of the pattern + /// and add them to the working directory. + /// If there is no dirname component we get '.' + final directoryPart = dirname(pattern); + if (directoryPart != '.') { + workingDirectory = join(workingDirectory, directoryPart); + } + pattern = basename(pattern); + + if (!exists(workingDirectory)) { + throw FindException( + 'The path ${truepath(workingDirectory)} does not exists', + ); + } + + final matcher = PatternMatcher( + pattern, + caseSensitive: caseSensitive, + workingDirectory: workingDirectory, + ); + if (workingDirectory == '.') { + workingDirectory = pwd; + } else { + workingDirectory = truepath(workingDirectory); + } + + if (basename(pattern).startsWith('.')) { + includeHidden = true; + } + + return FindConfig._( + workingDirectory: workingDirectory, + pattern: pattern, + includeHidden: includeHidden, + caseSensitive: caseSensitive, + matcher: matcher); + } + FindConfig._( + {required this.workingDirectory, + required this.pattern, + required this.includeHidden, + required this.caseSensitive, + required this.matcher}); + String workingDirectory; + String pattern; + bool includeHidden; + bool caseSensitive; + PatternMatcher matcher; +} diff --git a/packages/console/lib/src/functions/find_async.dart b/packages/console/lib/src/functions/find_async.dart new file mode 100644 index 0000000..49077c6 --- /dev/null +++ b/packages/console/lib/src/functions/find_async.dart @@ -0,0 +1,369 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +/// +/// Returns the list of files in the current and child +/// directories that match the passed glob pattern as a Stream +/// of absolute paths. +/// +/// You can obtain a relative path by calling: +/// ```dart +/// var relativePath = relative(filePath, from: searchRoot); +/// ``` +/// +/// Note: this is a limited implementation of glob. +/// See the below notes for details. +/// +/// ```dart +/// await for (final file in find('*.jpg', recursive:true)) +/// print(file); +/// +/// List results = findAsync('[a-z]*.jpg', caseSensitive:true).toList(); +/// +/// await for (final file in find('*.jpg', types:[Find.directory, Find.file]) +/// print(file); +/// ``` +/// +/// Valid patterns are: +/// ``` +/// +/// [*] - matches any number of any characters including none. +/// +/// [?] - matches any single character +/// +/// [[abc]] - matches any one character given in the bracket +/// +/// [[a-z]] - matches one character from the range given in the bracket +/// +/// [[!abc]] - matches one character that is not given in the bracket +/// +/// [[!a-z]] - matches one character that is not from the range given +/// in the bracket +/// ``` +/// +/// If [caseSensitive] is true then a case sensitive match is performed. +/// [caseSensitive] defaults to false. +/// +/// If [recursive] is true then a recursive search of all subdirectories +/// (all the way down) is performed. +/// [recursive] is true by default. +/// +/// [includeHidden] controls whether hidden files (.xx) are returned and +/// whether hidden directorys (.xx) are recursed into when the [recursive] +/// option is true. By default hidden files and directories are ignored. +/// If the wildcard begins with a '.' then includeHidden will be enabled +/// automatically. +/// +/// [types] allows you to specify the file types you want the find to return. +/// By default [types] limits the results to files. +/// +/// [workingDirectory] allows you to specify an alternate d +/// irectory to seach within +/// rather than the current work directory. +/// +/// [types] the list of types to search file. Defaults to [Find.file]. +/// See [Find.file], [Find.directory], [Find.link]. +/// +Stream findAsync( + String pattern, { + bool caseSensitive = false, + bool recursive = true, + bool includeHidden = false, + String workingDirectory = '.', + List types = const [Find.file], +}) async* { + // We us a [LimitedStreamController] as a slow reader + // can cause an out of memory exception if we keep pumping + // more files into the stream. + // ignore: close_sinks + final controller = LimitedStreamController(100); + await FindAsync()._findAsync( + pattern, + caseSensitive: caseSensitive, + recursive: recursive, + includeHidden: includeHidden, + workingDirectory: workingDirectory, + controller: controller, + types: types, + ); + + yield* controller.stream; +} + +/// Implementation for the [_findAsync] function. +class FindAsync extends DCliFunction { + final bool _closed = false; + + /// Find matching files and return them as a stream + Future _findAsync( + String pattern, { + required LimitedStreamController controller, + bool caseSensitive = false, + bool recursive = true, + String workingDirectory = '.', + List types = const [Find.file], + bool includeHidden = false, + }) async { + final config = FindConfig.build( + pattern: pattern, + workingDirectory: workingDirectory, + includeHidden: includeHidden, + caseSensitive: caseSensitive); + + await _innerFindAsync( + config: config, + recursive: recursive, + controller: controller, + types: types, + ); + } + + Future _innerFindAsync({ + required FindConfig config, + required LimitedStreamController controller, + bool recursive = true, + List types = const [Find.file], + }) async { + verbose( + () => 'find: pwd: $pwd ' + 'workingDirectory: ${truepath(config.workingDirectory)} ' + 'pattern: ${config.pattern} caseSensitive: ${config.caseSensitive} ' + 'recursive: $recursive types: $types ', + ); + final nextLevel = List.filled(100, null, growable: true); + final singleDirectory = + List.filled(100, null, growable: true); + final childDirectories = + List.filled(100, null, growable: true); + + if (!await _processDirectory( + config, + config.workingDirectory, + recursive, + types, + controller, + childDirectories, + )) { + return; + } + while (childDirectories[0] != null) { + _zeroElements(nextLevel); + for (final directory in childDirectories) { + if (directory == null) { + break; + } + // print('calling _processDirectory ${count++}'); + if (!await _processDirectory( + config, + directory.path, + recursive, + types, + controller, + singleDirectory, + )) { + break; + } + _appendTo(nextLevel, singleDirectory); + _zeroElements(singleDirectory); + } + _copyInto(childDirectories, nextLevel); + } + unawaited(controller.close()); + } + + Future _processDirectory( + FindConfig config, + String currentDirectory, + bool recursive, + List types, + LimitedStreamController controller, + List nextLevel, + ) async { + // print('process Directory ${dircount++}'); + + var nextLevelIndex = 0; + + await for (final entity + in Directory(currentDirectory).list(followLinks: false)) { + try { + late final FileSystemEntityType type; + type = FileSystemEntity.typeSync(entity.path, followLinks: false); + + if (types.contains(type) && + config.matcher.match(entity.path) && + _allowed( + config.workingDirectory, + entity, + includeHidden: config.includeHidden, + )) { + if (_closed) { + return false; + } + + // TODO(bsutton): do we need to wait if the controller is + /// paused? + await controller.asyncAdd(FindItem(entity.path, type)); + } + + /// If we are recursing then we need to add any directories + /// to the list of childDirectories that need to be recursed. + if (recursive && type == Find.directory) { + if (nextLevel.length > nextLevelIndex) { + nextLevel[nextLevelIndex] = entity; + } else { + nextLevel.add(entity); + } + nextLevelIndex++; + } + // ignore: avoid_catches_without_on_clauses + } catch (e) { + if (_isGeneralIOError(e)) { + /// can mean a corrupt disk, problems with virtualisation + /// I've seen this when gdrive. + } else if (e is FileSystemException && + e.osError?.errorCode == _accessDenied) { + /// check for and ignore permission denied. + verbose(() => 'Permission denied: ${e.path}'); + } else if (e is FileSystemException && e.osError?.errorCode == 40) { + /// ignore recursive symbolic link problems. + verbose(() => 'Too many levels of symbolic links: ${e.path}'); + } else if (e is FileSystemException && e.osError?.errorCode == 22) { + /// Invalid argument - not really certain what this means but we get + /// it when processing a .steam folder that includes a windows + /// emulator. + verbose(() => 'Invalid argument: ${e.path}'); + } else if (e is FileSystemException && + e.osError?.errorCode == _directoryNotFound) { + /// The directory may have been deleted between us finding it and + /// processing it. + verbose( + () => 'File or Directory deleted whilst we were processing it:' + ' ${e.path}', + ); + } else { + // ignore: only_throw_errors + rethrow; + } + } + } + return true; + } + + int get _accessDenied => Settings().isWindows ? 5 : 13; + int get _directoryNotFound => Settings().isWindows ? 3 : 2; + + /// Checks if a hidden file is allowed. + /// Non-hidden files are always allowed. + bool _allowed( + String workingDirectory, + FileSystemEntity entity, { + required bool includeHidden, + }) => + includeHidden || !_isHidden(workingDirectory, entity); + + // check if the entity is a hidden file (.xxx) or + // if lives in a hidden directory. + bool _isHidden(String workingDirectory, FileSystemEntity entity) { + final relativePath = relative(entity.path, from: workingDirectory); + + final parts = relativePath.split(separator); + + var isHidden = false; + for (final part in parts) { + if (part.startsWith('.')) { + isHidden = true; + break; + } + } + return isHidden; + } + + /// set all elements in the array to null so we can re-use the list + /// to reduce GC. + void _zeroElements(List nextLevel) { + for (var i = 0; i < nextLevel.length && nextLevel[i] != null; i++) { + nextLevel[i] = null; + } + } + + void _copyInto( + List childDirectories, + List nextLevel, + ) { + _zeroElements(childDirectories); + for (var i = 0; i < nextLevel.length; i++) { + if (childDirectories.length > i) { + childDirectories[i] = nextLevel[i]; + } else { + childDirectories.add(nextLevel[i]); + } + } + } + + void _appendTo( + List nextLevel, + List singleDirectory, + ) { + var index = _firstAvailable(nextLevel); + + for (var i = 0; i < singleDirectory.length; i++) { + if (singleDirectory[i] == null) { + break; + } + if (index >= nextLevel.length) { + nextLevel.add(singleDirectory[i]); + index++; + } else { + nextLevel[index++] = singleDirectory[i]; + } + } + } + + int _firstAvailable(List nextLevel) { + var firstAvailable = 0; + while (firstAvailable < nextLevel.length && + nextLevel[firstAvailable] != null) { + firstAvailable++; + } + return firstAvailable; + } + + /// pass as a value to the find types argument + /// to select files to be found + static const file = FileSystemEntityType.file; + + /// pass as a value to the final types argument + /// to select directories to be found + static const directory = FileSystemEntityType.directory; + + /// pass as a value to the final types argument + /// to select links to be found + static const link = FileSystemEntityType.link; + + bool _isGeneralIOError(Object e) { + var error = false; + error = e is FileSystemException && + !Platform.isWindows && + e.osError?.errorCode == 5; + + if (error) { + verbose(() => 'General IO Error(5) accessing: ${e.path}'); + } + + return error; + } +} diff --git a/packages/console/lib/src/functions/head.dart b/packages/console/lib/src/functions/head.dart new file mode 100644 index 0000000..69bc072 --- /dev/null +++ b/packages/console/lib/src/functions/head.dart @@ -0,0 +1,62 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:protevus_console/core.dart'; + +/// +/// Returns count [lines] from the file at [path]. +/// +/// ```dart +/// head('/var/log/syslog', 10).forEach((line) => print(line)); +/// ``` +/// +/// Throws a [HeadException] exception if [path] is not a file. +/// +List head(String path, int lines) => _Head().head(path, lines); + +class _Head extends DCliFunction { + List head( + String path, + int lines, + ) { + verbose(() => 'head ${truepath(path)} lines: $lines'); + + if (!exists(path)) { + throw HeadException('The path ${truepath(path)} does not exist.'); + } + + if (!isFile(path)) { + throw HeadException('The path ${truepath(path)} is not a file.'); + } + + try { + return withOpenLineFile(path, (file) { + final result = []; + file.readAll((line) { + result.add(line); + return result.length < lines; + }); + return result; + }); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw HeadException( + 'An error occured reading ${truepath(path)}. Error: $e', + ); + } finally {} + } +} + +/// Thrown if the [head] function encounters an error. +class HeadException extends DCliFunctionException { + /// Thrown if the [head] function encounters an error. + HeadException(super.reason); +} diff --git a/packages/console/lib/src/functions/is.dart b/packages/console/lib/src/functions/is.dart new file mode 100644 index 0000000..fe21206 --- /dev/null +++ b/packages/console/lib/src/functions/is.dart @@ -0,0 +1,169 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:stack_trace/stack_trace.dart'; + +import 'package:protevus_console/core.dart'; + +// import 'package:posix/posix.dart' as posix; + +/// +/// Returns true if the given [path] points to a file. +/// +/// ```dart +/// isFile("~/fred.jpg"); +/// ``` +bool isFile(String path) => _Is().isFile(path); + +/// Returns true if the given [path] is a directory. +/// ```dart +/// isDirectory("/tmp"); +/// +/// ``` +bool isDirectory(String path) => _Is().isDirectory(path); + +/// Returns true if the given [path] is a symlink +/// +/// // ```dart +/// isLink("~/fred.jpg"); +/// ``` +bool isLink(String path) => _Is().isLink(path); + +/// Returns true if the given path exists. +/// It may be a file, directory or link. +/// +/// If [followLinks] is true (the default) then [exists] +/// will return true if the resolved path exists. +/// +/// If [followLinks] is false then [exists] will return +/// true if path exist, whether its a link or not. +/// +/// ```dart +/// if (exists("/fred.txt")) +/// ``` +/// +/// Throws [ArgumentError] if [path] is null or an empty string. +/// +/// See [isLink] +/// [isDirectory] +/// [isFile] +bool exists(String path, {bool followLinks = true}) => + _Is().exists(path, followLinks: followLinks); + +/// Returns the datetime the path was last modified +/// +/// [path[ can be either a file or a directory. +/// +/// Throws a [DCliException] with a nested +/// [FileSystemException] if the file does not +/// exist or the operation fails. +DateTime lastModified(String path) { + try { + return File(path).lastModifiedSync(); + } on FileSystemException catch (e) { + throw DCliException.from(e, Trace.current()); + } +} + +/// Sets the last modified datetime on the given the path. +/// +/// [path] can be either a file or a directory. +/// +/// Throws a [DCliException] with a nested +/// [FileSystemException] if the file does not +/// exist or the operation fails. + +void setLastModifed(String path, DateTime lastModified) { + try { + File(path).setLastModifiedSync(lastModified); + } on FileSystemException catch (e) { + throw DCliException.from(e, Trace.current()); + } +} + +/// Returns true if the passed [pathToDirectory] is an +/// empty directory. +/// For large directories this operation can be expensive. +bool isEmpty(String pathToDirectory) => _Is().isEmpty(pathToDirectory); + +class _Is extends DCliFunction { + bool isFile(String path) { + final fromType = FileSystemEntity.typeSync(path); + return fromType == FileSystemEntityType.file; + } + + /// true if the given path is a directory. + bool isDirectory(String path) { + final fromType = FileSystemEntity.typeSync(path); + return fromType == FileSystemEntityType.directory; + } + + bool isLink(String path) { + final fromType = FileSystemEntity.typeSync(path, followLinks: false); + return fromType == FileSystemEntityType.link; + } + + /// checks if the given [path] exists. + /// + /// Throws [ArgumentError] if [path] is an empty string. + bool exists(String path, {bool followLinks = true}) { + if (path.isEmpty) { + throw ArgumentError('path must not be empty.'); + } + + final exists = FileSystemEntity.typeSync(path, followLinks: followLinks) != + FileSystemEntityType.notFound; + + verbose( + () => + 'exists(${truepath(path)}) found: $exists followLinks: $followLinks', + ); + + return exists; + } + + /// checks if the given [path] exists. + /// + /// Throws [ArgumentError] if [path] is an empty string. + bool existsSync(String path, {required bool followLinks}) { + if (path.isEmpty) { + throw ArgumentError('path must not be empty.'); + } + + final exists = FileSystemEntity.typeSync(path, followLinks: followLinks) != + FileSystemEntityType.notFound; + + verbose( + () => + 'exists(${truepath(path)}) found: $exists followLinks: $followLinks', + ); + + return exists; + } + + DateTime lastModified(String path) => File(path).lastModifiedSync(); + + void setLastModifed(String path, DateTime lastModified) { + File(path).setLastModifiedSync(lastModified); + } + + /// Returns true if the passed [pathToDirectory] is an + /// empty directory. + /// For large directories this operation can be expensive. + bool isEmpty(String pathToDirectory) { + final empty = + Directory(pathToDirectory).listSync(followLinks: false).isEmpty; + verbose(() => 'isEmpty(${truepath(pathToDirectory)}) : $empty'); + + return empty; + } +} diff --git a/packages/console/lib/src/functions/move.dart b/packages/console/lib/src/functions/move.dart new file mode 100644 index 0000000..ed52f8f --- /dev/null +++ b/packages/console/lib/src/functions/move.dart @@ -0,0 +1,90 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +/// +/// Moves the file [from] to the location [to]. +/// +/// ```dart +/// createDir('/tmp/folder'); +/// move('/tmp/fred.txt', '/tmp/folder/tom.txt'); +/// ``` +/// [from] must be a file. +/// +/// [to] may be a file or a path. +/// +/// If [to] is a file then a rename occurs. +/// +/// if [to] is a path then [from] is moved to the given path. +/// +/// If the move fails for any reason a [MoveException] is thrown. +/// + +void move(String from, String to, {bool overwrite = false}) { + verbose( + () => 'move ${truepath(from)} -> ${truepath(to)} overwrite: $overwrite'); + + var dest = to; + + if (isDirectory(to)) { + dest = p.join(to, p.basename(from)); + } + + if (!overwrite && exists(dest)) { + throw MoveException( + 'The [to] path ${truepath(dest)} already exists.' + ' Use overwrite:true ', + ); + } + try { + File(from).renameSync(dest); + } on FileSystemException catch (_) { + /// Invalid cross-device link + /// We can't move files across a partition so + /// do a copy/delete. + copy(from, to, overwrite: overwrite); + delete(from); + } + + /// ignore: avoid_catches_without_on_clauses + catch (e) { + _improveError(e, from, to); + } +} + +/// We try to improve the OS error message, because its crap. +void _improveError(Object e, String from, String to) { + if (!exists(from)) { + throw MoveException( + 'The Move of ${truepath(from)} failed as it does not exist.', + ); + } else if (!exists(dirname(truepath(to)))) { + throw MoveException( + 'The Move of ${truepath(from)} failed as the target directory ' + '${truepath(dirname(to))} does not exist.', + ); + } else { + throw MoveException( + 'The Move of ${truepath(from)} to ${truepath(to)} failed. Error $e', + ); + } +} + +/// Thrown when the [move] function encouters an error. +class MoveException extends DCliFunctionException { + /// Thrown when the [move] function encouters an error. + MoveException(super.reason); +} diff --git a/packages/console/lib/src/functions/move_dir.dart b/packages/console/lib/src/functions/move_dir.dart new file mode 100644 index 0000000..86c823a --- /dev/null +++ b/packages/console/lib/src/functions/move_dir.dart @@ -0,0 +1,85 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:protevus_console/core.dart'; + +/// Moves or renames the [from] directory to the +/// to the [to] path. +/// +/// The [to] path must NOT exist. +/// +/// The [from] path must be a directory. +/// +/// [moveDir] first tries to rename the directory, if that +/// fails due to the [to] path being on a different device +/// we fall back to a copy/delete operation. +/// +/// ```dart +/// moveDir("/tmp/", "/tmp/new_dir"); +/// ``` +/// +/// Throws a [MoveDirException] if: +/// the [from] path doesn't exist +/// the [from] path isn't a directory +/// the [to] path already exists. +/// +void moveDir(String from, String to) => _MoveDir().moveDir( + from, + to, + ); + +class _MoveDir extends DCliFunction { + void moveDir(String from, String to) { + if (!exists(from)) { + throw MoveDirException( + 'The [from] path ${truepath(from)} does not exists.', + ); + } + if (!isDirectory(from)) { + throw MoveDirException( + 'The [from] path ${truepath(from)} must be a directory.', + ); + } + if (exists(to)) { + throw MoveDirException('The [to] path ${truepath(to)} must NOT exist.'); + } + + verbose(() => 'moveDir called ${truepath(from)} -> ${truepath(to)}'); + + try { + Directory(from).renameSync(to); + } on FileSystemException catch (_) { + /// Most likley an Invalid cross-device move. + /// We can't move files across a partition so + /// do a copy/delete. + verbose( + () => + 'rename failed so falling back to copy/delete: ${truepath(from)} -> ${truepath(to)}', + ); + + copyTree(from, to, includeHidden: true); + delete(from); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw MoveDirException( + 'The Move of ${truepath(from)} to ${truepath(to)} failed. Error $e', + ); + } + } +} + +/// Thrown when the [moveDir] function encouters an error. +class MoveDirException extends DCliFunctionException { + /// Thrown when the [moveDir] function encouters an error. + MoveDirException(super.reason); +} diff --git a/packages/console/lib/src/functions/move_tree.dart b/packages/console/lib/src/functions/move_tree.dart new file mode 100644 index 0000000..cf6ebb2 --- /dev/null +++ b/packages/console/lib/src/functions/move_tree.dart @@ -0,0 +1,164 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +/// Recursively moves the contents of the [from] directory to the +/// to the [to] path with an optional filter. +/// +/// When filtering any files that don't match the filter will be +/// left in the [from] directory tree. +/// +/// Any [from] directories that are emptied as a result of the move will +/// be removed. This includes the [from] directory itself. +/// +/// [from] must be a directory +/// +/// [to] must be a directory and its parent directory must exist. +/// +/// If any moved files already exists in the [to] path then +/// an exeption is throw and a parital move may occured. +/// +/// You can force moveTree to overwrite files in the [to] +/// directory by setting [overwrite] to true (defaults to false). +/// +/// +/// ```dart +/// moveTree("/tmp/", "/tmp/new_dir", overwrite: true); +/// ``` +/// +/// By default hidden files are ignored. To allow hidden files to +/// be passed set [includeHidden] to true. +/// +/// You can select which files/directories are to be moved by passing a [filter]. +/// If a [filter] isn't passed then all files/directories are copied as per +/// the [includeHidden] state. +/// +/// ```dart +/// moveTree("/tmp/", "/tmp/new_dir", overwrite: true +/// , filter: (file) => extension(file) == 'dart'); +/// ``` +/// +/// The [filter] method can also be used to report progress as it +/// is called just before we move a file or directory. +/// +/// ```dart +/// moveTree("/tmp/", "/tmp/new_dir", overwrite: true +/// , filter: (entity) { +/// var include = extension(entity) == 'dart'; +/// if (include) { +/// print('moving: $file'); +/// } +/// return include; +/// }); +/// ``` +/// +/// +/// The default for [overwrite] is false. +/// +/// If an error occurs a [MoveTreeException] is thrown. +/// +/// EXPERIMENTAL +void moveTree( + String from, + String to, { + bool overwrite = false, + bool includeHidden = false, + bool Function(String file) filter = _allowAll, +}) => + _MoveTree().moveTree( + from, + to, + overwrite: overwrite, + includeHidden: includeHidden, + filter: filter, + ); + +bool _allowAll(String file) => true; + +class _MoveTree extends DCliFunction { + void moveTree( + String from, + String to, { + bool overwrite = false, + bool Function(String file) filter = _allowAll, + bool includeHidden = false, + }) { + if (!isDirectory(from)) { + throw MoveTreeException( + 'The [from] path ${truepath(from)} must be a directory.', + ); + } + if (!exists(to)) { + throw MoveTreeException( + 'The [to] path ${truepath(to)} must already exist.', + ); + } + + if (!isDirectory(to)) { + throw MoveTreeException( + 'The [to] path ${truepath(to)} must be a directory.', + ); + } + + verbose(() => 'moveTree called ${truepath(from)} -> ${truepath(to)}'); + + try { + find('*', workingDirectory: from, includeHidden: includeHidden, + progress: (item) { + _process(item.pathTo, filter, to, from, overwrite: overwrite); + return true; + }); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw MoveTreeException( + 'An error occured moving directory ${truepath(from)} ' + 'to ${truepath(to)}. Error: $e', + ); + } + } + + void _process(String pathToFile, bool Function(String file) filter, String to, + String from, + {required bool overwrite}) { + if (filter(pathToFile)) { + final target = join(to, relative(pathToFile, from: from)); + + // we create directories as we go. + // only directories that contain a file that is to be + // moved will be created. + if (isDirectory(dirname(pathToFile))) { + if (!exists(dirname(target))) { + createDir(dirname(target), recursive: true); + } + } + + if (!overwrite && exists(target)) { + throw MoveTreeException( + 'The target file ${truepath(to)} already exists', + ); + } + + move(pathToFile, target, overwrite: overwrite); + verbose( + () => 'moveTree moving: ${truepath(from)} -> ${truepath(target)}', + ); + } + } +} + +/// Thrown when the [moveTree] function encouters an error. +class MoveTreeException extends DCliFunctionException { + /// Thrown when the [moveTree] function encouters an error. + MoveTreeException(super.reason); +} diff --git a/packages/console/lib/src/functions/pwd.dart b/packages/console/lib/src/functions/pwd.dart new file mode 100644 index 0000000..834e126 --- /dev/null +++ b/packages/console/lib/src/functions/pwd.dart @@ -0,0 +1,28 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'dcli_function.dart'; + +/// +/// Returns the current working directory. +/// +/// ```dart +/// print(pwd); +/// ``` +/// +/// See join +/// +String get pwd => _PWD().pwd; + +class _PWD extends DCliFunction { + String get pwd => Directory.current.path; +} diff --git a/packages/console/lib/src/functions/replace.dart b/packages/console/lib/src/functions/replace.dart new file mode 100644 index 0000000..5b322d7 --- /dev/null +++ b/packages/console/lib/src/functions/replace.dart @@ -0,0 +1,92 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:protevus_console/core.dart'; + +/// +/// Does an insitu replacement on the file located at [path]. +/// +/// [replace] searches the file at [path] for any occurances +/// of [existing] and replaces them with [replacement]. +/// +/// By default we only replace the first occurance of [existing] on each line. +/// To replace every (non-overlapping) occurance of [existing] on a +/// line then set [all] to true; +/// +/// The [replace] method returns the no. of lines modified. +/// +/// The [existing] argument can be a simple String which or a regex. +/// +/// ```dart +/// replace(pathToFile, 'change me', 'changed'); +/// replace(pathToFile, RegExp(r'change \w+'), 'changed'); +/// ``` +/// +/// During the process a temporary file called [path].tmp is created +/// in the directory of [path]. +/// The modified file is written to [path].tmp. +/// Once the replacement completes successfully the file at [path] +/// is renamed to [path].bak, [path].tmp is renamed to [path] and then +/// [path].bak is deleted. +/// +/// The above process essentially makes replace atomic so it should +/// be impossible to loose your file. If replace does crash you may +/// have to delete [path].tmp or [path].bak but this is highly unlikely. +/// +int replace( + String path, + Pattern existing, + String replacement, { + bool all = false, +}) => + _Replace().replace(path, existing, replacement, all: all); + +class _Replace extends DCliFunction { + int replace( + String path, + Pattern existing, + String replacement, { + bool all = false, + }) { + var changes = 0; + final tmp = '$path.tmp'; + if (exists(tmp)) { + delete(tmp); + } + touch(tmp, create: true); + withOpenLineFile(tmp, (tmpFile) { + withOpenLineFile(path, (file) { + file.readAll((line) { + String newline; + if (all) { + newline = line.replaceAll(existing, replacement); + } else { + newline = line.replaceFirst(existing, replacement); + } + if (newline != line) { + changes++; + } + + tmpFile.append(newline); + return true; + }); + }); + }); + + if (changes != 0) { + move(path, '$path.bak'); + move(tmp, path); + delete('$path.bak'); + } else { + delete(tmp); + } + return changes; + } +} diff --git a/packages/console/lib/src/functions/tail.dart b/packages/console/lib/src/functions/tail.dart new file mode 100644 index 0000000..5897c8e --- /dev/null +++ b/packages/console/lib/src/functions/tail.dart @@ -0,0 +1,83 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:circular_buffer/circular_buffer.dart'; + +import '../settings.dart'; +import '../utils/line_file.dart'; +import '../utils/truepath.dart'; +import 'dcli_function.dart'; +import 'is.dart'; + +/// +/// Returns count [lines] from the end of the file at [path]. +/// +/// ```dart +/// tail('/var/log/syslog', 10).forEach((line) => print(line)); +/// ``` +/// +/// Throws a [TailException] exception if [path] is not a file. +/// +List tail(String path, int lines) => _Tail().tail(path, lines); + +class _Tail extends DCliFunction { + List tail( + String path, + int lines, + ) { + verbose(() => 'tail ${truepath(path)} lines: $lines'); + + if (lines < 1) { + throw TailException('lines must be >= 1'); + } + + if (!exists(path)) { + throw TailException('The path ${truepath(path)} does not exist.'); + } + + if (!isFile(path)) { + throw TailException('The path ${truepath(path)} is not a file.'); + } + + /// circbuffer requires a min size of 2 so we + /// add one to make certain it is always greater than one + /// and then adjust later. + final buffer = CircularBuffer(lines + 1); + try { + withOpenLineFile(path, (file) { + file.readAll((line) { + buffer.add(line); + return true; + }); + }); + } + // ignore: avoid_catches_without_on_clauses + catch (e) { + throw TailException( + 'An error occured reading ${truepath(path)}. Error: $e', + ); + } + + final lastLines = buffer.toList(); + + /// adjust the buffer by stripping extra line. + if (buffer.isFilled) { + lastLines.removeAt(0); + } + + return lastLines; + } +} + +/// thrown when the [tail] function encounters an exception +class TailException extends DCliFunctionException { + /// thrown when the [tail] function encounters an exception + TailException(super.reason); +} diff --git a/packages/console/lib/src/functions/touch.dart b/packages/console/lib/src/functions/touch.dart new file mode 100644 index 0000000..ae336ab --- /dev/null +++ b/packages/console/lib/src/functions/touch.dart @@ -0,0 +1,77 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'package:protevus_console/core.dart'; + +/// Updates the last modified time stamp of a file. +/// +/// ```dart +/// touch('fred.txt'); +/// touch('fred.txt, create: true'); +/// ``` +/// +/// +/// If [create] is true and the file doesn't exist +/// it will be created. +/// +/// If [create] is false and the file doesn't exist +/// a [TouchException] will be thrown. +/// +/// [create] is false by default. +/// +/// As a convenience the touch function returns the [path] variable +/// that was passed in. + +String touch(String path, {bool create = false}) { + final absolutePath = truepath(path); + + verbose(() => 'touch: $absolutePath create: $create'); + + if (!exists(p.dirname(absolutePath))) { + throw TouchException( + 'The directory tree above $absolutePath does not exist. ' + 'Create the tree and try again.', + ); + } + if (create == false && !exists(absolutePath)) { + throw TouchException( + 'The file $absolutePath does not exist. ' + 'Did you mean to use touch(path, create: true) ?', + ); + } + + try { + final file = File(absolutePath); + + if (file.existsSync()) { + final now = DateTime.now(); + file + ..setLastAccessedSync(now) + ..setLastModifiedSync(now); + } else { + if (create) { + file.createSync(); + } + } + } on FileSystemException catch (e) { + throw TouchException('Unable to touch file $absolutePath: ${e.message}'); + } + return path; +} + +/// thrown when the [touch] function encounters an exception +class TouchException extends DCliFunctionException { + /// thrown when the [touch] function encounters an exception + TouchException(super.reason); +} diff --git a/packages/console/lib/src/functions/which.dart b/packages/console/lib/src/functions/which.dart new file mode 100644 index 0000000..de01a51 --- /dev/null +++ b/packages/console/lib/src/functions/which.dart @@ -0,0 +1,196 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:path/path.dart'; + +import 'package:protevus_console/core.dart'; + +/// +/// Searches the PATH for the location of the application +/// give by [appname]. +/// +/// The search is conducted by searching each of the +/// paths in the environment variable 'PATH' from +/// left to right (start to end) as this is the +/// same order the OS searches the path. +/// +/// If the [verbose] flag is true then a line is output to +/// the [progress] for each path searched. +/// +/// It is possible that more than one copy of the +/// appliation is found. +/// +/// [which] returns a list of paths that contain +/// [appname] in the order they were found. +/// +/// The first path in the list is the one the OS +/// will be using. +/// +/// if the [first] flag is true then which will +/// stop searching as soon as it finds a match. +/// [first] is true by default. +/// +/// ```dart +/// which('ls', first: false, verbose: true); +/// ``` +/// +/// To print the path to the command: +/// +/// ```dart +/// print(which('ls').path); +/// ``` +/// +/// To check if an app is on the path use: +/// +/// ```dart +/// if (which('apt').found) +/// { +/// print('found apt'); +/// } +/// ``` +/// +/// if [extensionSearch] is true and the passed [appname] doesn't have a file +/// extension then when running on Windows the which command will search +/// for [appname] plus [appname] with each of the extensions listed +/// in the Windows environment variable PATHEX. +/// This feature is intended to make it easier to implement cross platform +/// command search. For example the dart will be 'dart' +/// on Linux and 'dart.bat' on Windows. Using `which('dart')` will find `dart` +/// on linux and `dart.bat` on Windows. +Which which( + String appname, { + bool first = true, + bool verbose = false, + bool extensionSearch = true, + void Function(WhichSearch)? progress, +}) => + _Which().which( + appname, + first: first, + verbose: verbose, + extensionSearch: extensionSearch, + progress: progress, + ); + +/// Returned from the [which] funtion to provide the details we discovered +/// about appname. +class Which { + String? _path; + final _paths = []; + bool _found = false; + + /// The progress used to accumualte the results + /// If verbose was passed this will contain all + /// of the verbose output. If you passed a [progress] + /// into the which call then this will be the same progress + /// otherwse a Progress.devNull will be allocated and returned. + Stream? progress; + + /// The first path found containing appname + /// + /// See [paths] for a list of all paths that contained appname + String? get path => _path; + + /// Contains the list of paths that contain appname. + /// + /// If no paths are found then this list will be empty. + /// + /// If first is true this will contain at most 1 path. + List get paths => _paths; + + /// Returns true if at least one path was found that contained appname + bool get found => _found; + + /// Returns true if appname was not found in any path. + bool get notfound => !_found; +} + +/// Search resutls from the [which] method. +class WhichSearch { + /// the app was found on the path. + WhichSearch.found(this.path, this.exePath) : found = true; + + /// the app was not found. + WhichSearch.notfound(this.path) : found = false; + + /// passed in path to search for. + String path; + + /// true if the app was found + bool found; + + /// If the app was found this is the fully qualified path to the app. + String? exePath; +} + +class _Which extends DCliFunction { + /// + /// Searches the path for the given appname. + Which which( + String appname, { + required bool extensionSearch, + bool first = true, + bool verbose = false, + void Function(WhichSearch)? progress, + }) { + final results = Which(); + for (final path in PATH) { + final fullpath = + _appExists(path, appname, extensionSearch: extensionSearch); + if (fullpath == null) { + progress?.call(WhichSearch.notfound(path)); + } else { + progress?.call(WhichSearch.found(path, fullpath)); + + if (!results._found) { + results._path = fullpath; + } + results.paths.add(fullpath); + results._found = true; + if (first) { + break; + } + } + } + + return results; + } + + /// Checks if [appname] exists in [pathTo]. + /// + /// On Windows if [extensionSearch] is true and [appname] doesn't + /// have an extension then we check each appname.extension variant + /// to see if it exists. We first check if just an file of [appname] with + /// no extension exits. + String? _appExists( + String pathTo, + String appname, { + required bool extensionSearch, + }) { + final pathToAppname = join(pathTo, appname); + if (exists(pathToAppname)) { + return pathToAppname; + } + if (Settings().isWindows && extensionSearch && extension(appname).isEmpty) { + final pathExt = env['PATHEXT']; + + if (pathExt != null) { + final extensions = pathExt.split(';'); + for (final extension in extensions) { + final fullname = '$pathToAppname$extension'; + if (exists(fullname)) { + return fullname; + } + } + } + } + return null; + } +} diff --git a/packages/console/lib/src/input/ask.dart b/packages/console/lib/src/input/ask.dart new file mode 100644 index 0000000..6e03f93 --- /dev/null +++ b/packages/console/lib/src/input/ask.dart @@ -0,0 +1,848 @@ +/* Copyright (C) S. Brett Sutton - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Brett Sutton , Jan 2022 + */ + +import 'dart:convert'; +import 'dart:io'; +import 'package:protevus_console/core.dart' as core; +import 'package:protevus_console/core.dart'; +import 'package:protevus_console/terminal.dart'; +import 'package:meta/meta.dart'; +import 'package:validators2/validators2.dart'; + +import 'echo.dart'; + +typedef CustomAskPrompt = String Function( + String prompt, + String? defaultValue, + // ignore: avoid_positional_boolean_parameters + bool hidden); + +/// +/// Reads a line of text from stdin with an optional prompt. +/// +/// +/// ```dart +/// String response = ask("Do you like me?"); +/// ``` +/// +/// If the script is not attached to terminal [Terminal().hasTerminal] +/// then ask returns immediatly with the [defaultValue]. If no [defaultValue] +/// is passed then an empty string is returned. No validate will be applied. +/// +/// If [prompt] is set then the prompt will be printed +/// to the console and the cursor placed immediately after the prompt. +/// +/// Pass an empty string to suppress the prompt. +/// +/// ```dart +/// var secret = ask('', required: false); +/// ``` +/// +/// By default the ask [required] argument is true requiring the user to enter +/// a non-empty string. +/// All whitespace is trimmed from the string before the user input +/// is validated so +/// a single space is not an accepted input. +/// +/// If you set the [required] argument to false then the user can just hit +/// enter to skip past the ask prompt. If you use other validators when +/// [required] = false +/// then those validators will not be called if the entered value is empty +/// (after it is trimmed). +/// +/// if [toLower] is true then the returned result is converted to lower case. +/// This can be useful if you need to compare the entered value. +/// +/// If [hidden] is true then the entered values will not be echoed to the +/// console, instead '*' will be displayed. This is uesful for capturing +/// passwords. +/// +/// NOTE: if there is no terminal detected then this will fallback to +/// a standard ask input in which case the hidden characters WILL BE DISPLAYED +/// as they are typed. +/// +/// If a [defaultValue] is passed then it is displayed and the user +/// fails to enter a value (just hits the enter key) then the +/// [defaultValue] is returned. +/// +/// Passing a [defaultValue] also modifies the prompt to display the value: +/// +/// ```dart +/// var result = ask('How many', defaultValue: '5'); +/// > 'How many [5]' +/// ``` +/// [ask] will throw an [AskValidatorException] if the defaultValue +/// doesn't match the given [validator]. +/// +/// The [validator] is called each time the user hits enter. +/// +/// The [validator] allows you to normalise and validate the user's +/// input. The [validator] must return the normalised value which +/// will be the value returned by [ask]. +/// +/// If the [validator] detects an invalid input then you MUST +/// throw [AskValidatorException(error)]. The error will +/// be displayed on the console and the user reprompted. +/// You can color code the error using any of the dcli +/// color functions. By default all input is considered valid. +/// +/// The [customErrorMessage] allows you to provide a custom error message +/// to be displayed instead of the default one when the input is invalid. +/// This can be useful if you want to provide specific guidance or instructions +/// to the user regarding the expected input format or constraints. +/// +///```dart +/// var subject = ask( 'Subject'); +/// subject = ask( 'Subject', required: true); +/// subject = ask( 'Subject', validator: Ask.minLength(10)); +/// var name = ask( 'What is your name?', validator: Ask.alpha); +/// var age = ask( 'How old are you?', validator: Ask.integer); +/// var username = ask( 'Username?', validator: Ask.email); +/// var password = ask( 'Password?', hidden: true, +/// validator: Ask.all([Ask.alphaNumeric, AskValidatorLength(10,16)])); +/// var color = ask( 'Favourite colour?' +/// , Ask.inList(['red', 'green', 'blue'])); +/// +///``` +Future ask( + String prompt, { + bool toLower = false, + bool hidden = false, + bool required = true, + String? defaultValue, + CustomAskPrompt customPrompt = Ask.defaultPrompt, + AskValidator validator = Ask.dontCare, + String? customErrorMessage, +}) async => + Ask()._ask( + prompt, + toLower: toLower, + hidden: hidden, + required: required, + defaultValue: defaultValue, + customPrompt: customPrompt, + validator: validator, + customErrorMessage: customErrorMessage, + ); + +// ignore: avoid_clas +/// Class for [ask] and related code. +class Ask extends core.DCliFunction { + static const int _backspace = 127; + static const int _space = 32; + static const int _ = 8; + + /// + /// Reads user input from stdin and returns it as a string. + /// [prompt] + Future _ask( + String prompt, { + required bool hidden, + required bool required, + required AskValidator validator, + required CustomAskPrompt customPrompt, + bool toLower = false, + String? defaultValue, + String? customErrorMessage, + }) async { + ArgumentError.checkNotNull(prompt); + core.verbose( + () => 'ask: $prompt toLower: $toLower hidden: $hidden ' + 'required: $required ' + 'defaultValue: ${hidden ? '******' : defaultValue}', + ); + + if (!Terminal().hasTerminal) { + return defaultValue ?? ''; + } + final finalPrompt = customPrompt(prompt, defaultValue, hidden); + + var line = ''; + var valid = false; + do { + await echo('$finalPrompt '); + + if (hidden == true && stdin.hasTerminal) { + line = await _readHidden(); + } else { + line = stdin.readLineSync(encoding: Encoding.getByName('utf-8')!) ?? ''; + } + + if (line.isEmpty && defaultValue != null) { + line = defaultValue; + } + + if (toLower == true) { + line = line.toLowerCase(); + } + + try { + if (required) { + await const _AskRequired() + .validate(line, customErrorMessage: customErrorMessage); + } + verbose(() => 'ask: pre validation "$line"'); + line = await validator.validate(line, + customErrorMessage: customErrorMessage); + verbose(() => 'ask: post validation "$line"'); + valid = true; + } on AskValidatorException catch (e) { + print(e.message); + } + + verbose(() => 'ask: result $line'); + } while (!valid); + + return line; + } + + Future _readHidden() async { + final value = []; + + try { + stdin.echoMode = false; + stdin.lineMode = false; + int char; + do { + char = stdin.readByteSync(); + if (char != 10 && char != 13) { + if (char == _backspace) { + if (value.isNotEmpty) { + // move back a character, + // print a space an move back again. + // required to clear the current character + // move back one space. + stdout + ..writeCharCode(_) + ..writeCharCode(_space) + ..writeCharCode(_); + value.removeLast(); + } + } else { + // apparently flush isn't need - despite the doc. + stdout.write('*'); + value.add(char); + } + } + } while (char != 10 && char != 13); + } finally { + stdin.lineMode = true; + stdin.echoMode = true; + } + + // output a newline as we have suppressed it. + print(''); + + // return the entered value as a String. + return Encoding.getByName('utf-8')!.decode(value); + } + + static String defaultPrompt( + String prompt, + String? defaultValue, + // ignore: avoid_positional_boolean_parameters + bool hidden) { + var result = prompt; + + /// completely suppress the default value and the prompt if + /// the prompt is empty. + if (defaultValue != null && prompt.isNotEmpty) { + /// don't display the default value if hidden is true. + result = '$prompt [${hidden ? '******' : defaultValue}]'; + } + return result; + } + + /// The default validator that considers any input as valid + static const AskValidator dontCare = _AskDontCare(); + + /// Takes an array of validators. The input is considered valid if any one + /// of the validators returns true. + /// The validators are processed in order from left to right. + /// If none of the validators pass then the error from the first validator + /// that failed is returned. The implications is that the user will only + /// ever see the error from the first validator. + static AskValidator any(List validators) => + _AskValidatorAny(validators); + + /// Takes an array of validators. The input is considered valid only if + /// everyone of the validators pass. + /// + /// The validators are processed in order from left to right. + /// + /// The error from the first validator that failes is returned. + /// + /// It should be noted that the user input is passed to each validator in turn + /// and each validator has the opportunity to modify the input. As a result + /// a validators will be operating on a version of the input + /// that has been processed by all validators that appear + /// earlier in the list. + static AskValidator all(List validators) => + _AskValidatorAll(validators); + + /// Validates that input is a IP address + /// By default both v4 and v6 addresses are valid + /// Pass a [version] to limit the input to one or the + /// other. If passed [version] must be [AskValidatorIPAddress.ipv4] + /// or [AskValidatorIPAddress.ipv6]. + static AskValidator ipAddress({int version = AskValidatorIPAddress.either}) => + AskValidatorIPAddress(version: version); + + /// Validates the input against a regular expression + /// ```dart + /// ask('Variable Name:', validator: Ask.regExp(r'^[a-zA-Z_]+$')); + /// ``` + static AskValidator regExp(String regExp, {String? error}) => + _AskRegExp(regExp, error: error); + + /// Validates that the entered line is no longer + /// than [maxLength]. + static AskValidator lengthMax(int maxLength) => + _AskValidatorMaxLength(maxLength); + + /// Validates that the entered line is not less + /// than [minLength]. + static AskValidator lengthMin(int minLength) => + _AskValidatorMinLength(minLength); + + /// Validates that the length of the entered text + /// as at least [minLength] but no more than [maxLength]. + static AskValidator lengthRange(int minLength, int maxLength) => + _AskValidatorLength(minLength, maxLength); + + /// Validates that a number is between a minimum value (inclusive) + /// and a maximum value (inclusive). + static AskValidator valueRange(num minValue, num maxValue) => + _AskValidatorValueRange(minValue, maxValue); + + /// Checks that the input matches one of the + /// provided [validItems]. + /// If the validator fails it prints out the + /// list of available inputs. + /// By default [caseSensitive] matches are off. + static AskValidator inList( + List validItems, { + bool caseSensitive = false, + }) => + _AskValidatorList(validItems, caseSensitive: caseSensitive); + + /// The user must enter a non-empty string. + /// Whitespace will be trimmed before the string is tested. + static const AskValidator required = _AskRequired(); + + /// validates that the input is an email address + static const AskValidator email = _AskEmail(); + + /// validates that the input is a fully qualified domian name. + static const AskValidator fqdn = _AskFQDN(); + + /// Validates that the input is a valid url. + /// You may pass in a list of acceptable protocols. + /// By default only 'https' is allowed. + static AskValidator url({List protocols = const ['https']}) => + _AskURL(protocols: protocols); + + /// validates that the input is a date. + static const AskValidator date = _AskDate(); + + /// validates that the input is an integer + static const AskValidator integer = _AskInteger(); + + /// validates that the input is a decimal + static const AskValidator decimal = _AskDecimal(); + + /// validates that the input is only alpha characters + static const AskValidator alpha = _AskAlpha(); + + /// validates that the input is only alphanumeric characters. + static const AskValidator alphaNumeric = _AskAlphaNumeric(); +} + +/// Thrown when an [AskValidator] detects an invalid input. +class AskValidatorException extends DCliException { + /// validator with a [message] indicating the error. + AskValidatorException(super.message); +} + +/// Base class for all [AskValidator]s. +/// You can add your own by extending this class. +// ignore: one_member_abstracts +abstract class AskValidator { + /// allows us to make validators consts. + const AskValidator(); + + /// This method is called by [ask] to valiate the + /// string entered by the user. + /// It should throw an AskValidatorException if the input + /// is invalid. + /// The validate method is called when the user hits the enter key. + /// If the validation succeeds the validated line is returned. + @visibleForTesting + Future validate(String line, {String? customErrorMessage}); +} + +/// The default validator that considers any input as valid +class _AskDontCare extends AskValidator { + const _AskDontCare(); + @override + Future validate(String line, {String? customErrorMessage}) async => + line; +} + +/// The user must enter a non-empty string. +/// Whitespace will be trimmed before the string is tested. +/// +class _AskRequired extends AskValidator { + const _AskRequired(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim(); + if (finalLine.isEmpty) { + throw AskValidatorException( + red(customErrorMessage ?? 'You must enter a value.')); + } + return finalLine; + } +} + +class _AskEmail extends AskValidator { + const _AskEmail(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim(); + + if (!isEmail(finalLine)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Invalid email address.')); + } + return finalLine; + } +} + +class _AskFQDN extends AskValidator { + const _AskFQDN(); + + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim().toLowerCase(); + + if (!isFQDN(finalLine)) { + throw AskValidatorException(red(customErrorMessage ?? 'Invalid FQDN.')); + } + return finalLine; + } +} + +class _AskURL extends AskValidator { + const _AskURL({this.protocols = const ['https']}); + + final List protocols; + + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim().toLowerCase(); + + if (!isURL(finalLine, protocols: protocols)) { + throw AskValidatorException(red(customErrorMessage ?? 'Invalid URL.')); + } + return finalLine; + } +} + +/// Validates the input against a regular expression +/// ```dart +/// ask('Variable Name:', validator: Ask.regExp(r'^[a-zA-Z_]+$')); +/// ``` +/// +class _AskRegExp extends AskValidator { + /// Creates a regular expression based validator. + /// You can customise the error message by providing a value for [error]. + _AskRegExp(this.regexp, {String? error}) { + _regexp = RegExp(regexp); + _error = error ?? 'Input does not match: $regexp'; + } + + final String regexp; + late final RegExp _regexp; + late final String _error; + + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim(); + + if (!_regexp.hasMatch(finalLine)) { + throw AskValidatorException(red(customErrorMessage ?? _error)); + } + return finalLine; + } +} + +class _AskDate extends AskValidator { + const _AskDate(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim(); + + if (!isDate(finalLine)) { + throw AskValidatorException(red(customErrorMessage ?? 'Invalid date.')); + } + return finalLine; + } +} + +class _AskInteger extends AskValidator { + const _AskInteger(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalLine = line.trim(); + verbose(() => 'AskInteger: $finalLine'); + + if (!isInt(finalLine)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Invalid integer.')); + } + return finalLine; + } +} + +class _AskDecimal extends AskValidator { + const _AskDecimal(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + if (!isFloat(finalline)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Invalid decimal number.')); + } + return finalline; + } +} + +class _AskAlpha extends AskValidator { + const _AskAlpha(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + if (!isAlpha(finalline)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Alphabetical characters only.')); + } + return finalline; + } +} + +class _AskAlphaNumeric extends AskValidator { + const _AskAlphaNumeric(); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + if (!isAlphanumeric(finalline)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Alphanumerical characters only.')); + } + return finalline; + } +} + +/// Validates that input is a IP address +/// By default both v4 and v6 addresses are valid +/// Pass a [version] to limit the input to one or the +/// other. If passed [version] must be [ipv4] or [ipv6]. +class AskValidatorIPAddress extends AskValidator { + /// Validates that input is a IP address + /// By default both v4 and v6 addresses are valid + /// Pass a [version] to limit the input to one or the + /// other. If passed [version] must be 4 or 6. + const AskValidatorIPAddress({this.version = either}); + + /// The ip address may be either [ipv4] or [ipv6]. + static const int either = 0; + + /// The ip address must be an ipv4 address. + static const int ipv4 = 4; + + /// The ip address must be an ipv6 address. + static const int ipv6 = 6; + + /// IP version (on 4 and 6 are valid versions.) + final int version; + + @override + Future validate(String line, {String? customErrorMessage}) async { + assert( + version == either || version == ipv4 || version == ipv6, + 'The version must be AskValidatorIPAddress.either or ' + 'AskValidatorIPAddress.ipv4 or AskValidatorIPAddress.ipv6', + ); + + final finalline = line.trim(); + + var validatorsVersion = IPVersion.any; + switch (version) { + case ipv4: + validatorsVersion = IPVersion.ipV4; + break; + case ipv6: + validatorsVersion = IPVersion.ipV6; + break; + } + + if (!isIP(finalline, version: validatorsVersion)) { + throw AskValidatorException( + red(customErrorMessage ?? 'Invalid IP Address.')); + } + return finalline; + } +} + +/// Validates that the entered line is no longer +/// than [maxLength]. +class _AskValidatorMaxLength extends AskValidator { + /// Validates that the entered line is no longer + /// than [maxLength]. + const _AskValidatorMaxLength(this.maxLength); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + if (finalline.length > maxLength) { + throw AskValidatorException( + red( + customErrorMessage ?? + 'You have exceeded the maximum length of $maxLength characters.', + ), + ); + } + return finalline; + } + + /// the maximum allows length for the entered string. + final int maxLength; +} + +/// Validates that the entered line is not less +/// than [minLength]. +class _AskValidatorMinLength extends AskValidator { + /// Validates that the entered line is not less + /// than [minLength]. + const _AskValidatorMinLength(this.minLength); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + if (finalline.length < minLength) { + throw AskValidatorException( + red(customErrorMessage ?? + 'You must enter at least $minLength characters.'), + ); + } + return finalline; + } + + /// the minimum allows length of the string. + final int minLength; +} + +/// Validates that the length of the entered text +// ignore: comment_references +/// as at least [minLength] but no more than [maxLength]. +class _AskValidatorLength extends AskValidator { + /// Validates that the length of the entered text + /// as at least [minLength] but no more than [maxLength]. + _AskValidatorLength(int minLength, int maxLength) { + _validator = _AskValidatorAll([ + _AskValidatorMinLength(minLength), + _AskValidatorMaxLength(maxLength), + ]); + } + + late _AskValidatorAll _validator; + + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + return _validator.validate(finalline, + customErrorMessage: customErrorMessage); + } +} + +class _AskValidatorValueRange extends AskValidator { + const _AskValidatorValueRange(this.minValue, this.maxValue); + + final num minValue; + final num maxValue; + + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + + final value = num.tryParse(finalline); + if (value == null) { + throw AskValidatorException( + red(customErrorMessage ?? 'Must be a number.')); + } + + if (value < minValue) { + throw AskValidatorException( + red(customErrorMessage ?? + 'The number must be greater than or equal to $minValue.'), + ); + } + + if (value > maxValue) { + throw AskValidatorException( + red(customErrorMessage ?? + 'The number must be less than or equal to $maxValue.'), + ); + } + + return finalline; + } +} + +/// Takes an array of validators. The input is considered valid only if +/// everyone of the validators pass. +/// +/// The validators are processed in order from left to right. +/// +/// The error from the first validator that failes is returned. +/// +/// It should be noted that the user input is passed to each validator in turn +/// and the validator has the opportunity to modify the input. As a result +/// a validators will be operating on a version of the input +/// that has been processed by all validators that appear earlier in the list. +class _AskValidatorAll extends AskValidator { + /// Takes an array of validators. The input is considered valid only if + /// everyone of the validators pass. + /// + /// The validators are processed in order from left to right. + /// + /// The error from the first validator that failes is returned. + /// + /// It should be noted that the user input is passed to each validator in turn + /// and the validator has the opportunity to modify the input. As a result + /// a validators will be operating on a version of the input + /// that has been processed by all validators that appear earlier + /// in the list. + _AskValidatorAll(this._validators); + + final List _validators; + + @override + Future validate(String line, {String? customErrorMessage}) async { + var finalline = line.trim(); + + for (final validator in _validators) { + finalline = await validator.validate(finalline, + customErrorMessage: customErrorMessage); + } + return finalline; + } +} + +/// Takes an array of validators. The input is considered valid if any one +/// of the validators returns true. +/// The validators are processed in order from left to right. +/// If none of the validators pass then the error from the first validator +/// that failed is returned. The implications is that the user will only +/// ever see the error from the first validator. +/// +/// It should be noted that the user input is passed to each validator in turn +/// and each validator has the opportunity to modify the input. As a result +/// a validators will be operating on a version of the input +/// that has been processed by all validators that appear earlier in the list. +class _AskValidatorAny extends AskValidator { + /// Takes an array of validators. The input is considered valid if any one + /// of the validators returns true. + /// The validators are processed in order from left to right. + /// If none of the validators pass then the error from the first validator + /// that failed is returned. The implications is that the user will only + /// ever see the error from the first validator. + /// + /// It should be noted that the user input is passed to each validator in turn + /// and each validator has the opportunity to modify the input. As a result + /// a validators will be operating on a version of the input + /// that has been processed by all successful validators + /// that appear earlier in the list. + /// + /// Validators that fail don't get an opportunity to modify the input. + _AskValidatorAny(this._validators); + + final List _validators; + + @override + Future validate(String line, {String? customErrorMessage}) async { + var finalline = line.trim(); + + AskValidatorException? firstFailure; + + var onePassed = false; + + for (final validator in _validators) { + try { + finalline = await validator.validate(finalline, + customErrorMessage: customErrorMessage); + onePassed = true; + } on AskValidatorException catch (e) { + firstFailure ??= e; + } + } + if (!onePassed) { + throw firstFailure!; + } + return finalline; + } +} + +/// Checks that the input matches one of the +/// provided [validItems]. +/// If the validator fails it prints out the +/// list of available inputs. +class _AskValidatorList extends AskValidator { + /// Checks that the input matches one of the + /// provided [validItems]. + /// If the validator fails it prints out the + /// list of available inputs. + /// By default [caseSensitive] matches are off. + _AskValidatorList(this.validItems, {this.caseSensitive = false}); + + /// The list of allowed values. + final List validItems; + final bool caseSensitive; + + @override + Future validate(String line, {String? customErrorMessage}) async { + var finalline = line.trim(); + + if (caseSensitive) { + finalline = finalline.toLowerCase(); + } + var found = false; + for (final item in validItems) { + var itemValue = item.toString(); + if (caseSensitive) { + itemValue = itemValue.toLowerCase(); + } + + if (finalline == itemValue) { + found = true; + break; + } + } + if (!found) { + throw AskValidatorException( + red(customErrorMessage ?? + 'The valid responses are ${validItems.join(' | ')}.'), + ); + } + + return finalline; + } +} diff --git a/packages/console/lib/src/input/confirm.dart b/packages/console/lib/src/input/confirm.dart new file mode 100644 index 0000000..ff56424 --- /dev/null +++ b/packages/console/lib/src/input/confirm.dart @@ -0,0 +1,90 @@ +/* Copyright (C) S. Brett Sutton - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Brett Sutton , Jan 2022 + */ + +import 'package:protevus_console/terminal.dart'; + +import 'ask.dart'; + +typedef CustomConfirmPrompt = String Function( + String prompt, + // ignore: avoid_positional_boolean_parameters + bool? defaultValue); + +/// [confirm] is a specialized version of ask that returns true or +/// false based on the value entered. +/// +/// The user must enter a valid value or, if a [defaultValue] +/// is passed, the enter key. +/// +/// Accepted values are y|t|true|yes and n|f|false|no (case insenstiive). +/// +/// If the user enters an unknown value an error is printed +/// and they are reprompted. +/// +/// The [prompt] is displayed to the user with ' (y/n)' appended. +/// +/// If a [defaultValue] is passed then either the y or n will be capitalised +/// and if the user hits the enter key then the [defaultValue] will be returned. +/// +/// If the script is not attached to a terminal [Terminal().hasTerminal] +/// then confirm returns immediately with the [defaultValue]. +/// If there is no [defaultValue] then true is returned. +Future confirm(String prompt, + {bool? defaultValue, + CustomConfirmPrompt customPrompt = Confirm.defaultPrompt}) async { + var result = false; + var matched = false; + + if (!Terminal().hasTerminal) { + return defaultValue ?? true; + } + + while (!matched) { + final entered = await ask( + prompt, + toLower: true, + required: false, + customPrompt: (_, __, ___) => customPrompt(prompt, defaultValue), + ); + var lower = entered.trim().toLowerCase(); + + if (lower.isEmpty && defaultValue != null) { + lower = defaultValue ? 'true' : 'false'; + } + + if (['y', 't', 'true', 'yes'].contains(lower)) { + result = true; + matched = true; + break; + } + if (['n', 'f', 'false', 'no'].contains(lower)) { + result = false; + matched = true; + break; + } + print('Invalid value: $entered'); + } + return result; +} + +// ignore: avoid_classes_with_only_static_members +class Confirm { + // ignore: avoid_positional_boolean_parameters + static String defaultPrompt(String prompt, bool? defaultValue) { + var finalPrompt = prompt; + + if (defaultValue == null) { + finalPrompt += ' (y/n):'; + } else { + if (defaultValue == true) { + finalPrompt += ' (Y/n):'; + } else { + finalPrompt += ' (y/N):'; + } + } + return finalPrompt; + } +} diff --git a/packages/console/lib/src/input/echo.dart b/packages/console/lib/src/input/echo.dart new file mode 100644 index 0000000..0aab925 --- /dev/null +++ b/packages/console/lib/src/input/echo.dart @@ -0,0 +1,32 @@ +/* Copyright (C) S. Brett Sutton - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Brett Sutton , Jan 2022 + */ + +import 'dart:io'; +import 'package:protevus_console/core.dart'; + +/// Writes [text] to stdout including a newline. +/// +/// ```dart +/// echo("Hello world", newline=false); +/// ``` +/// +/// If [newline] is false then a newline will not be output. +/// +/// [newline] defaults to false. +Future echo(String text, {bool newline = false}) async => + _Echo().echo(text, newline: newline); + +class _Echo extends DCliFunction { + Future echo(String text, {required bool newline}) async { + if (newline) { + stdout.writeln(text); + } else { + stdout.write(text); + } + // ignore: discarded_futures + await stdout.flush(); + } +} diff --git a/packages/console/lib/src/input/menu.dart b/packages/console/lib/src/input/menu.dart new file mode 100644 index 0000000..5786c89 --- /dev/null +++ b/packages/console/lib/src/input/menu.dart @@ -0,0 +1,185 @@ +/* Copyright (C) S. Brett Sutton - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Brett Sutton , Jan 2022 + */ + +import 'dart:math'; + +import 'package:protevus_console/terminal.dart'; + +import 'ask.dart'; + +String _noFormat(T option) => option.toString(); + +typedef CustomMenuPrompt = String Function( + String prompt, String? defaultOption); + +/// Displays a menu with each of the provided [options], prompts +/// the user to select an option and returns the selected option. +/// +/// e.g. +/// ```dart +/// var colors = [Color('Red'), Color('Green')]; +/// var color = menu( 'Please select a color', options: colors); +/// ``` +/// Results in: +///``` +/// 1) Red +/// 2) Green +/// Please select a color: +/// ``` +/// +/// [menu] will display an error if the user enters a non-valid +/// response and then redisplay the prompt. +/// +/// Once a user selects a valid option, that option is returned. +/// +/// You may provide a [limit] which will cause the +/// menu to only display the first [limit] options passed. +/// +/// If you pass a [format] lambda then the [format] function +/// will be called for for each option and the resulting format +/// used to display the option in the menu. +/// +/// e.g. +/// ```dart +/// +/// var colors = [Color('Red'), Color('Green')]; +/// var color = menu(prompt: 'Please select a color' +/// , options: colors, format: (color) => color.name); +/// ``` +/// +/// If [format] is not passed [option.toString()] will be used +/// as the format for the menu option. +/// +/// When a [limit] is applied the menu will display the first [limit] +/// options. If you specify [fromStart: false] then the menu will display the +/// last [limit] options. +/// +/// If you pass a [defaultOption] the matching option is highlighted +/// in green in the menu +/// and if the user hits enter without entering a value the [defaultOption] +/// is returned. +/// +/// If the [defaultOption] does not match any the supplied [options] +/// then an ArgumentError is thrown. +/// +/// If the app is not attached to a terminal then the menu will not be +/// displayed and the [defaultOption] will be returned. +/// If there is no [defaultOption] then the first [options] will be returned. +/// +Future menu( + String prompt, { + required List options, + T? defaultOption, + CustomMenuPrompt customPrompt = Menu.defaultPrompt, + int? limit, + String Function(T)? format, + bool fromStart = true, +}) async { + if (options.isEmpty) { + throw ArgumentError( + 'The list of [options] passed to menu(options: ) was empty.', + ); + } + limit ??= options.length; + // ignore: parameter_assignments + limit = min(options.length, limit); + format ??= _noFormat; + + if (!Terminal().hasTerminal) { + if (defaultOption == null) { + return options.first; + } + return defaultOption; + } + + var displayList = options; + if (fromStart == false) { + // get the last [limit] options + displayList = options.sublist(min(options.length, options.length - limit)); + } + + // on the way in we check that the default value actually exists in the list. + String? defaultAsString; + // display each option. + for (var i = 1; i <= limit; i++) { + final option = displayList[i - 1]; + + if (option == defaultOption) { + defaultAsString = i.toString(); + } + final desc = format(option); + final no = '$i'.padLeft(3); + if (defaultOption != null && defaultOption == option) { + /// highlight the default value. + print(green('$no) $desc')); + } else { + print('$no) $desc'); + } + } + + if (defaultOption != null && defaultAsString == null) { + throw ArgumentError( + "The [defaultOption] $defaultOption doesn't match any " + 'of the passed [options].' + ' Check the == operator for ${options[0].runtimeType}.', + ); + } + + var valid = false; + + var index = -1; + + // loop until the user enters a valid selection. + while (!valid) { + final selected = await ask(prompt, + defaultValue: defaultAsString, + validator: _MenuRange(limit), + customPrompt: (_, __, ___) => customPrompt(prompt, defaultAsString)); + if (selected.isEmpty) { + continue; + } + valid = true; + index = int.parse(selected); + } + + return options[index - 1]; +} + +// ignore: avoid_classes_with_only_static_members +class Menu { + static String defaultPrompt(String prompt, T? defaultValue) { + var result = prompt; + + /// completely suppress the default value and the prompt if + /// the prompt is empty. + if (defaultValue != null && prompt.isNotEmpty) { + result = '$prompt [$defaultValue]'; + } + return result; + } +} + +class _MenuRange extends AskValidator { + const _MenuRange(this.limit); + @override + Future validate(String line, {String? customErrorMessage}) async { + final finalline = line.trim(); + final value = num.tryParse(finalline); + if (value == null) { + throw AskValidatorException( + red(customErrorMessage ?? 'Value must be an integer from 1 to $limit'), + ); + } + + if (value < 1 || value > limit) { + throw AskValidatorException('Invalid selection.'); + } + + return finalline; + } + + final int limit; +} diff --git a/packages/console/lib/src/settings.dart b/packages/console/lib/src/settings.dart new file mode 100644 index 0000000..9deedf1 --- /dev/null +++ b/packages/console/lib/src/settings.dart @@ -0,0 +1,112 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:stack_trace/stack_trace.dart'; + +import 'package:protevus_console/core.dart'; + +class Settings { + /// Returns a singleton providing + /// access to DCli settings. + factory Settings() => _self ??= Settings._init(); + + Settings._init(); + + final logger = Logger('dcli'); + static Settings? _self; + + bool _verboseEnabled = false; + + /// returns true if the -v (verbose) flag was set on the + /// dcli command line. + /// e.g. + /// dcli -v clean + bool get isVerbose => _verboseEnabled; + + // ignore: cancel_subscriptions + static StreamSubscription? listener; + + /// Turns on verbose logging. + Future setVerbose({required bool enabled}) async { + _verboseEnabled = enabled; + + // ignore: flutter_style_todos + /// TODO(bsutton): this affects everyones logging so + /// I'm uncertain if this is a problem. + hierarchicalLoggingEnabled = true; + + if (enabled) { + logger.level = Level.INFO; + listener ??= logger.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + } else { + logger.level = Level.OFF; + if (listener != null) { + await listener!.cancel(); + listener = null; + } + } + } + + /// Logs a message to the console if the verbose + /// settings are on. + void verbose(String? message, {Frame? frame}) { + final Frame calledBy; + if (frame == null) { + final st = Trace.current(); + calledBy = st.frames[1]; + } else { + calledBy = frame; + } + + /// We log at info level (as that is logger's default) + /// so that verbose messages will print when verbose + /// is enabled. + Logger('dcli').info('${calledBy.library}:${calledBy.line} $message'); + } + + Stream captureLogOutput() => logger.onRecord; + + void clearLogCapture() { + logger.clearListeners(); + } + + /// True if you are running on a Mac. + bool get isMacOS => DCliPlatform().isMacOS; + + /// True if you are running on a Linux system. + bool get isLinux => DCliPlatform().isLinux; + + /// True if you are running on a Window system. + bool get isWindows => DCliPlatform().isWindows; +} + +/// +/// If Settings.isVerbose is true then +/// this method will call [callback] to +/// get a String which will be logged to the +/// console or the log file set via the verbose command line +/// option. +/// +/// This method is more efficient than calling Settings.verbose +/// as it will only build the string if verbose is enabled. +/// +/// ```dart +/// verbose(() => 'Log the users name $user'); +/// +void verbose(String Function() callback) { + if (Settings().isVerbose) { + Settings().verbose(callback(), frame: Trace.current().frames[1]); + } +} diff --git a/packages/console/lib/src/terminal/ansi.dart b/packages/console/lib/src/terminal/ansi.dart new file mode 100644 index 0000000..09df6cc --- /dev/null +++ b/packages/console/lib/src/terminal/ansi.dart @@ -0,0 +1,75 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'ansi_color.dart'; +import 'terminal.dart'; + +/// Helper class to assist in printing text to the console with a color. +/// +/// Use one of the color functions instead of this class. +/// +/// See: +/// * [AnsiColor] +/// * [Terminal] +/// ... +class Ansi { + /// Factory ctor + factory Ansi() => _self; + + const Ansi._internal(); + + static const _self = Ansi._internal(); + static bool? _emitAnsi; + + /// returns true if stdout supports ansi escape characters. + static bool get isSupported { + if (_emitAnsi == null) { + // We don't trust [stdout.supportsAnsiEscapes] except on Windows. + // [stdout] relies on the TERM environment variable + // which generates false negatives. + if (!Platform.isWindows) { + _emitAnsi = true; + } else { + _emitAnsi = stdout.supportsAnsiEscapes; + } + } + return _emitAnsi!; + } + + /// You can set [isSupported] to + /// override the detected ansi settings. + /// Dart doesn't do a great job of correctly detecting + /// ansi support so this give a way to override it. + /// If [isSupported] is true then escape charaters are emmitted + /// If [isSupported] is false escape characters are not emmited + /// By default the detected setting is used. + /// After setting emitAnsi you can reset back to the + /// default detected by calling [resetEmitAnsi]. + static set isSupported(bool emit) => _emitAnsi = emit; + + /// If you have called [isSupported] then calling + /// [resetEmitAnsi] will reset the emit + /// setting to the default detected. + static void get resetEmitAnsi => _emitAnsi = null; + + /// ANSI Control Sequence Introducer, signals the terminal for new settings. + static const esc = '\x1b['; + // static const esc = '\u001b['; + + /// Strip all ansi escape sequences from [line]. + /// + /// This method is useful when logging messages + /// or if you need to calculate the number of printable + /// characters in a message. + static String strip(String line) => + line.replaceAll(RegExp('\x1b\\[[0-9;]+m'), ''); +} diff --git a/packages/console/lib/src/terminal/ansi_color.dart b/packages/console/lib/src/terminal/ansi_color.dart new file mode 100644 index 0000000..2c06962 --- /dev/null +++ b/packages/console/lib/src/terminal/ansi_color.dart @@ -0,0 +1,496 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'ansi.dart'; + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(red('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(red('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +/// +String red( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeRed, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(black('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(black('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +/// +String black( + String text, { + AnsiColor background = AnsiColor.white, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeBlack, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(green('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(green('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +/// +String green( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeGreen, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(blue('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(blue('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +/// +String blue( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeBlue, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(yellow('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(yellow('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +/// +String yellow( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeYellow, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(magenta('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(magenta('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +///xt. Defaults to none. +/// +String magenta( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeMagenta, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(cyan('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(cyan('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +///xt. Defaults to none. +/// +String cyan( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeCyan, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(white('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(white('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +///xt. Defaults to none. +/// +String white( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeWhite, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(orange('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(orange('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +///xt. Defaults to none. +/// +String orange( + String text, { + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor(AnsiColor.codeOrange, bold: bold), + text, + background: background, + ); + +/// Wraps the passed text with the ANSI escape sequence for +/// the color red. +/// Use this to control the color of text when printing to the +/// console. +/// +/// ``` +/// print(grey('a dark message')); +/// ``` +/// The [text] to wrap. +/// By default the color is [bold] however you can turn off bold +/// by setting the [bold] argment to false: +/// +/// ``` +/// print(grey('a dark message', bold: false)); +/// ``` +/// +/// [background] is the background color to use when printing the +/// text. Defaults to White. +///xt. Defaults to none. +/// +String grey( + String text, { + double level = 0.5, + AnsiColor background = AnsiColor.none, + bool bold = true, +}) => + AnsiColor._apply( + AnsiColor._grey(level: level, bold: bold), + text, + background: background, + ); + +/// Helper class to assist in printing text to the console with a color. +/// +/// Use one of the color functions instead of this class. +/// +/// See: +/// * [black] +/// * [white] +/// * [green] +/// * [orange] +/// ... +class AnsiColor { + /// + const AnsiColor( + int code, { + bool bold = true, + }) : _code = code, + _bold = bold; + + AnsiColor._grey({ + double level = 0.5, + bool bold = true, + }) : _code = codeGrey + (level.clamp(0.0, 1.0) * 23).round(), + _bold = bold; + + /// resets the color scheme. + static String reset() => _emit(_resetCode); + + /// resets the foreground color + static String fgReset() => _emit(_fgResetCode); + + /// resets the background color. + static String bgReset() => _emit(_bgResetCode); + + final int _code; + + final bool _bold; + + // + static String _emit(String ansicode) => '${Ansi.esc}${ansicode}m'; + + /// ansi code for this color. + int get code => _code; + + /// do we bold the color + bool get bold => _bold; + + /// writes the text to the terminal. + String apply(String text, {AnsiColor background = none}) => + _apply(this, text, background: background); + + static String _apply( + AnsiColor color, + String text, { + AnsiColor background = none, + }) { + String? output; + + if (Ansi.isSupported) { + output = '${_fg(color.code, bold: color.bold)}' + '${_bg(background.code)}$text$_reset'; + } else { + output = text; + } + return output; + } + + static String get _reset => '${Ansi.esc}${_resetCode}m'; + + static String _fg( + int code, { + bool bold = true, + }) { + String output; + + if (code == none.code) { + output = ''; + } else if (code > 39) { + output = '${Ansi.esc}$_fgColorCode$code${bold ? ';1' : ''}m'; + } else { + output = '${Ansi.esc}$code${bold ? ';1' : ''}m'; + } + return output; + } + + // background colors are fg color + 10 + static String _bg(int code) { + String output; + + if (code == none.code) { + output = ''; + } else if (code > 49) { + output = '${Ansi.esc}$_backgroundCode${code + 10}m'; + } else { + output = '${Ansi.esc}${code + 10}m'; + } + return output; + } + + /// Resets + + /// Reset fg and bg colors + static const String _resetCode = '0'; + + /// Defaults the terminal's fg color without altering the bg. + static const String _fgResetCode = '39'; + + /// Defaults the terminal's bg color without altering the fg. + static const String _bgResetCode = '49'; + + // emit this code followed by a color code to set the fg color + static const String _fgColorCode = '38;5;'; + +// emit this code followed by a color code to set the fg color + static const String _backgroundCode = '48;5;'; + + /// code for black + static const int codeBlack = 30; + + /// code for red + static const int codeRed = 31; + + /// code for green + static const int codeGreen = 32; + + /// code for yellow + static const int codeYellow = 33; + + /// code for blue + static const int codeBlue = 34; + + /// code for magenta + static const int codeMagenta = 35; + + /// code for cyan + static const int codeCyan = 36; + + /// code for white + static const int codeWhite = 37; + + /// code for orange + static const int codeOrange = 208; + + /// code for grey + static const int codeGrey = 232; + + /// Colors + /// black + static const AnsiColor black = AnsiColor(codeBlack); + + /// red + static const AnsiColor red = AnsiColor(codeRed); + + /// green + static const AnsiColor green = AnsiColor(codeGreen); + + /// yellow + static const AnsiColor yellow = AnsiColor(codeYellow); + + /// blue + static const AnsiColor blue = AnsiColor(codeBlue); + + /// magenta + static const AnsiColor magenta = AnsiColor(codeMagenta); + + /// cyan + static const AnsiColor cyan = AnsiColor(codeCyan); + + /// white + static const AnsiColor white = AnsiColor(codeWhite); + + /// orange + static const AnsiColor orange = AnsiColor(codeOrange); + + /// passing this as the background color will cause + /// the background code to be suppressed resulting + /// in the default background color. + static const AnsiColor none = AnsiColor(-1, bold: false); +} diff --git a/packages/console/lib/src/terminal/format.dart b/packages/console/lib/src/terminal/format.dart new file mode 100644 index 0000000..63245bb --- /dev/null +++ b/packages/console/lib/src/terminal/format.dart @@ -0,0 +1,190 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:math'; + +/// provides a random collection of formatters +/// EXPERIMENTAL +/// +class Format { + /// Factory constructor. + factory Format() => _self; + Format._internal(); + + static final _self = Format._internal(); + + /// [cols] is a list of strings (the columns) that + /// are to be formatted as a set of fixed with + /// columns. + /// + /// As this is a fixed width row colums that exceed the given + /// width will be clipped. + /// You can make any column variable width by passing -1 as the width. + /// + /// By default there is a single space between each column you + /// can pass a [delimiter] to modify this behaviour. You can + /// suppress the [delimiter] by passing an empty string ''. + /// + /// [widths] defines the width of each column in the row. + /// + /// If their are more [cols] than [widths] then the last width + /// is used repeatedly. + /// If [widths] is null then a default width of 20 is used. + /// + /// returns a string with each of the columns padded according to the + /// [widths]. + /// + /// + String row( + List cols, { + List? widths, + List? alignments, + String? delimiter, + }) { + var row = ''; + var i = 0; + widths ??= [20]; + var width = widths[0]; + + alignments ??= [TableAlignment.left]; + var alignment = alignments[0]; + + delimiter ??= ' '; + + for (var col in cols) { + if (row.isNotEmpty) { + row += delimiter; + } + + /// make row robust if a null col is passed. + col ??= ''; + var colwidth = col.length; + if (colwidth > width) { + colwidth = width; + if (width != -1) { + col = col.substring(0, width); + } + } + switch (alignment) { + case TableAlignment.left: + row += col.padRight(width); + break; + case TableAlignment.right: + row += col.padLeft(width); + break; + case TableAlignment.middle: + final padding = width = colwidth; + row += col.padLeft(padding); + break; + } + + i++; + + if (i < widths.length) { + width = widths[i]; + } + if (i < alignments.length) { + alignment = alignments[i]; + } + } + return row; + } + + /// Limits the [display] string's length to [width] by removing the centre + /// components of the string and replacing them with '...' + /// + /// Example: + /// var long = 'http://www.onepub.dev/some/long/url'; + /// print(limitString(long, width: 20)) + /// > http://...ong/url + String limitString(String display, {int width = 40}) { + if (display.length <= width) { + return display; + } + final elipses = width <= 2 ? 1 : 3; + final partLength = (width - elipses) ~/ 2; + // ignore: lines_longer_than_80_chars + return '${display.substring(0, partLength)}${'.' * elipses}${display.substring(display.length - partLength)}'; + } + + /// returns a double as a percentage to the given [precision] + /// e.g. 0.11 becomes 11% if [precision] is 0. + String percentage(double progress, int precision) => + '${(progress * 100).toStringAsFixed(precision)}%'; + + /// returns the the number of [bytes] in a human readable + /// form. e.g. 1e+5 3.000T, 10.00G, 100.0M, 20.00K, 10B + /// + /// Except for absurdly large no. (> 10^20) + /// the return is guarenteed to be 6 characters long. + /// For no. < 1000K we right pad the no. with spaces. + String bytesAsReadable(int bytes, {bool pad = true}) { + String human; + + if (bytes < 1000) { + human = _fiveDigits(bytes, 0, 'B', pad: pad); + } else if (bytes < 1000000) { + human = _fiveDigits(bytes, 3, 'K', pad: pad); + } else if (bytes < 1000000000) { + human = _fiveDigits(bytes, 6, 'M', pad: pad); + } else if (bytes < 1000000000000) { + human = _fiveDigits(bytes, 9, 'G', pad: pad); + } else if (bytes < 1000000000000000) { + human = _fiveDigits(bytes, 12, 'T', pad: pad); + } else { + human = bytes.toStringAsExponential(0); + } + return human; + } + + String _fiveDigits(int bytes, int exponent, String letter, + {bool pad = true}) { + final num result; + String human; + if (bytes < 1000) { + // less than 1K we display integers only + result = bytes ~/ pow(10, exponent); + human = '$result'.padLeft(pad ? 5 : 0); + } else { + // greater than 1K we display decimals + result = bytes / pow(10, exponent); + human = '$result'; + + if (human.length > 5) { + human = human.substring(0, 5); + } else { + /// add trailing zeros to maintain a fixed width of 5 chars. + if (pad) { + human = human.padRight(5, '0'); + } + } + } + + return '$human$letter'; + } + + // /// + // void colprint(String label, String value, {int pad = 25}) { + // print('${label.padRight(pad)}: ${value}'); + // } +} + +/// Used by [Format.row] to control the alignment of each +/// column in the table. +enum TableAlignment { + /// + left, + + /// + right, + + /// + middle +} diff --git a/packages/console/lib/src/terminal/terminal.dart b/packages/console/lib/src/terminal/terminal.dart new file mode 100644 index 0000000..f49a1b0 --- /dev/null +++ b/packages/console/lib/src/terminal/terminal.dart @@ -0,0 +1,245 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:dart_console/dart_console.dart'; + +import 'ansi.dart'; + +/// +/// Modes available when clearing a screen or line. +/// +/// When used with clearScreen: +/// [all] - clears the entire screen +/// [fromCursor] - clears from the cursor until the end of the screen +/// [toCursor] - clears from the start of the screen to the cursor. +/// +/// When used with clearLine: +/// [all] - clears the entire line +/// [fromCursor] - clears from the cursor until the end of the line. +/// [toCursor] - clears from the start of the line to the cursor. +/// +enum TerminalClearMode { + // scrollback, + /// clear whole screen + all, + + /// clear screen from the cursor to the bottom of the screen. + fromCursor, + + /// clear screen from the top of the screen to the cursor + toCursor +} + +/// Provides access to the Ansi Terminal. +class Terminal { + /// Factory ctor to get a Terminal + factory Terminal() => _self; + + // ignore: flutter_style_todos + /// TODO(bsutton): if we don't have a terminal or ansi isn't supported + /// we need to suppress any ansi codes being output. + + Terminal._internal(); + + static final _self = Terminal._internal(); + + final _console = Console(); + + /// Returns true if ansi escape characters are supported. + bool get isAnsi => Ansi.isSupported; + + /// Clears the screen. + /// If ansi escape sequences are not supported this is a no op. + /// This call does not update the cursor position so in most + /// cases you will want to call [home] after calling [clearScreen]. + /// ```dart + /// Terminal() + /// ..clearScreen() + /// ..home(); + /// ``` + void clearScreen({TerminalClearMode mode = TerminalClearMode.all}) { + switch (mode) { + // case AnsiClearMode.scrollback: + // write('${esc}3J', newline: false); + // break; + + case TerminalClearMode.all: + _console.clearScreen(); + break; + case TerminalClearMode.fromCursor: + write('${Ansi.esc}0Jm'); + break; + case TerminalClearMode.toCursor: + write('${Ansi.esc}1Jm'); + break; + } + } + + /// Clears the current line, moves the cursor to column 0 + /// and then prints [text] effectively overwriting the current + /// console line. + /// If the current console doesn't support ansi escape + /// sequences ([isAnsi] == false) then this call + /// will simply revert to calling [print]. + void overwriteLine(String text) { + clearLine(); + column = 0; + _console.write(text); + } + + /// Writes [text] to the terminal at the current + /// cursor location without appending a newline character. + void write(String text) { + _console.write(text); + } + + /// Writes [text] to the console followed by a newline. + /// You can control the alignment of [text] by passing the optional + /// [alignment] argument which defaults to left alignment. + /// The alignment is based on the current terminals width with + /// spaces inserted to the left of the string to facilitate the alignment. + /// Make certain the current line is clear and the cursor is at column 0 + /// before calling this method otherwise the alignment will not work + /// as expected. + void writeLine(String text, {TextAlignment alignment = TextAlignment.left}) => + _console.writeLine(text, alignment); + + /// Clears the current console line without moving the cursor. + /// If you want to write over the current line then + /// call [clearLine] followed by [startOfLine] and then + /// use [write] rather than print as it will leave + /// the cursor on the current line. + /// Alternatively use [overwriteLine]; + void clearLine({TerminalClearMode mode = TerminalClearMode.all}) { + switch (mode) { + // case AnsiClearMode.scrollback: + case TerminalClearMode.all: + _console.eraseLine(); + break; + case TerminalClearMode.fromCursor: + _console.eraseCursorToEnd(); + break; + case TerminalClearMode.toCursor: + write('${Ansi.esc}1K'); + break; + } + } + + /// show/hide the cursor + void showCursor({required bool show}) { + if (show) { + _console.showCursor(); + } else { + _console.hideCursor(); + } + } + + /// Moves the cursor to the start of previous line. + @Deprecated('Use [cursorUp]') + static void previousLine() { + Terminal().cursorUp(); + } + + /// Moves the cursor up one row + void cursorUp() => _console.cursorUp(); + + /// Moves the cursor down one row + void cursorDown() => _console.cursorDown(); + + /// Moves the cursor to the left one column + void cursorLeft() => _console.cursorUp(); + + /// Moves the cursor to the right one column + void cursorRight() => _console.cursorRight(); + + /// Returns the column location of the cursor + int get column => _cursor?.col ?? 0; + + /// moves the cursor to the given column + /// 0 is the first column + // ignore: avoid_setters_without_getters + set column(int column) { + _console.cursorPosition = Coordinate(row, column); + } + + /// Moves the cursor to the start of line. + void startOfLine() { + column = 0; + } + + /// The width of the terminal in columns. + /// Where a column is one character wide. + /// If no terminal is attached, a value of 80 is returned. + /// This value can change if the user resizes the console window. + int get columns { + if (hasTerminal) { + return _console.windowWidth; + } else { + return 80; + } + } + + /// Returns the row location of the cursor. + /// The first row is row 0. + int get row => _cursor?.row ?? 24; + + /// moves the cursor to the given row + /// 0 is the first row + set row(int row) { + _console.cursorPosition = Coordinate(row, column); + } + + /// Returns the current co-ordinates of the cursor. + /// If no terminal is attached we return null + /// as attempting to read from from stdin to + /// obtain the cursor will hang the app. + Coordinate? get _cursor { + if (hasTerminal && Ansi.isSupported) { + try { + return _console.cursorPosition; + // ignore: avoid_catching_errors + } on RangeError catch (_) { + // if stdin is closed (seems to be within docker) + // then the call to cursorPosition will fail. + // RangeError: Invalid value: Not in inclusive range 0..1114111: -1 + // new String.fromCharCode (dart:core-patch/string_patch.dart:45) + // Console.cursorPosition (package:dart_console/src/console.dart:304) + return null; + } + } else { + return null; + } + } + + /// Whether a terminal is attached to stdin. + bool get hasTerminal => stdin.hasTerminal; + + /// The height of the terminal in rows. + /// Where a row is one character high. + /// If no terminal is attached to stdout, a [StdoutException] is thrown. + /// This value can change if the users resizes the console window. + int get rows { + if (hasTerminal) { + return _console.windowHeight; + } else { + return 24; + } + } + + /// Sets the cursor to the top left corner + /// of the screen (0,0) + void home() => _console.resetCursorPosition(); + + /// Returns the current console height in rows. + @Deprecated('Use rows') + int get lines => rows; +} diff --git a/packages/console/lib/src/utils/async_circular_buffer.dart b/packages/console/lib/src/utils/async_circular_buffer.dart new file mode 100644 index 0000000..e041a36 --- /dev/null +++ b/packages/console/lib/src/utils/async_circular_buffer.dart @@ -0,0 +1,327 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +import 'dart:async'; + +import 'dart:math'; + +/// A [AsyncCircularBuffer] with a fixed capacity supporting +/// all [List] operations +/// +/// ```dart +/// final buffer = CircularBuffer(3)..add(1)..add(2); +/// print(buffer.length); // 2 +/// print(buffer.first); // 1 +/// print(buffer.isFilled); // false +/// print(buffer.isUnfilled); // true +/// +/// buffer.add(3); +/// print(buffer.length); // 3 +/// print(buffer.isFilled); // true +/// print(buffer.isUnfilled); // false +/// +/// buffer.add(4); +/// print(buffer.first); // 4 +/// ``` +class AsyncCircularBuffer +// with IterableMixin> +// implements Iterable> +// with ListMixin +{ + /// Creates a [AsyncCircularBuffer] with a `capacity` + AsyncCircularBuffer(int capacity) + : assert(capacity > 1, 'capacity must be at least 1'), + _capacity = capacity, + _buf = [], + _end = -1, + _count = 0, + _threshold = max(1, (0.2 * capacity).toInt()); + + /// Creates a [AsyncCircularBuffer] based on another `list` + AsyncCircularBuffer.of(List list, [int? capacity]) + : assert(capacity == null || capacity >= list.length, + 'capacity must be null or greater than list'), + _capacity = capacity ?? list.length, + _buf = [...list], + _end = list.length - 1, + _count = list.length, + _threshold = max(1, 0.2 * (capacity ?? list.length) as int); + + final List _buf; + final int _capacity; + + final int _threshold; + + /// Space is available in the buffer. + /// Each time the buffer fills we won't mark + /// space available until the buffer has + /// bee read down to the [_threshold]. + var _spaceAvailable = Completer(); + + /// Elements are available in the buffer. + var _elementsAvailable = Completer(); + + /// indicates that the buffer has been closed by the provider. + /// We complete the future once the buffer closes. + final _closed = Completer(); + + /// indicates that the buffer has been closed and + /// all elements read. + final _done = Completer(); + + int _start = 0; + int _end; + int _count; + + /// Completes when the buffer has been closed + /// and all elements read. + Future get isDone => _done.future; + + /// The [AsyncCircularBuffer] is `reset` + void reset() { + if (_closed.isCompleted) { + throw BadStateException('Buffer has been closed'); + } + _start = 0; + _end = -1; + _count = 0; + + _spaceAvailable.complete(true); + _elementsAvailable = Completer(); + } + + /// Close the circular buffer indicating no more values will be added. + /// Calls to get will throw with an underflow exception if called + /// when there are no more elements in the buffer and [close] has bee + /// called. + void close() { + if (!_closed.isCompleted) { + _closed.complete(true); + } + + if (_isEmpty && !_done.isCompleted) { + _done.complete(true); + } + } + + /// Adds [element] to the buffer. + /// This method will wait if the buffer is full. + /// An [BadStateException] will be thrown if the buffer + /// has been closed. + Future add(T element) async { + if (_closed.isCompleted) { + throw BadStateException('Buffer has been closed'); + } + if (isFilled) { + /// wait until we have more space available. + /// If the buffer was closed after we start waiting + /// we still allow this last add to continue. + await _spaceAvailable.future; + } + + // Adding the next value + _end++; + if (_end == _capacity) { + _end = 0; + } + + if (_buf.length == _capacity) { + /// [_buf] is full grown so add at [_end] + _buf[_end] = element; + } else { + /// [_buf] isn't full grown yet so grow it. + _buf.add(element); + } + _count++; + + if (isFilled) { + /// we are full so block add + _spaceAvailable = Completer(); + } + // _incStart(); + if (!_elementsAvailable.isCompleted) { + /// we now have elements available. + _elementsAvailable.complete(true); + } + } + + void _incStart() { + _start++; + if (_start == _capacity) { + _start = 0; + } + } + + /// Returns the next element in the buffer. + /// If the buffer is closed and empty then returns null. + /// If the buffer is empty [get] waits until a new + /// element arrives before returning. + Future get() async { + if (_isEmpty) { + if (_closed.isCompleted) { + return throw UnderflowException(); + } else { + await Future.any([_elementsAvailable.future, _closed.future]); + if (_closed.isCompleted && _isEmpty) { + return throw UnderflowException(); + } + } + } + final element = this[0]; + _incStart(); + _count--; + + /// we have less items than threshold so allow more + /// items to be added. + if (length < _threshold && !_spaceAvailable.isCompleted) { + _spaceAvailable.complete(true); + } + + if (_isEmpty) { + /// no elements available so block futher gets. + _elementsAvailable = Completer(); + + /// we are closed and empty. + if (_closed.isCompleted) { + _done.complete(true); + } + } + + return element; + } + + /// iterator over the list of elements. + Iterator> get iterator => _CircularBufferIterator(this); + + /// Number of elements of [AsyncCircularBuffer] + int get length => _count; + + /// Maximum number of elements of [AsyncCircularBuffer] + int get capacity => _capacity; + + /// The [AsyncCircularBuffer] `isFilled` if the `length` + /// is equal to the `capacity` + bool get isFilled => _count == _capacity; + + /// The [AsyncCircularBuffer] `isUnfilled` if the `length` is + /// is less than the `capacity` + bool get isUnfilled => _count < _capacity; + + /// True if the buffer is closed and will not + /// accept any more calls to [add] + bool get isClosed => _closed.isCompleted; + + bool get _isEmpty => _count == 0; + + bool get _isNotEmpty => _count > 0; + + /// Access element at [index] + T operator [](int index) { + if (index >= 0 && index < _count) { + return _buf[(_start + index) % _buf.length]!; + } + throw RangeError.index(index, this); + } + + /// Assign an element at [index] + void operator []=(int index, T value) { + if (index >= 0 && index < _count) { + _buf[(_start + index) % _buf.length] = value; + } else { + throw RangeError.index(index, this); + } + } + + /// Returns a stream of the contained elements. + Stream stream() async* { + try { + while (!_closed.isCompleted) { + final element = await get(); + yield element; + } + } on UnderflowException catch (_) { + // if we are closed whilst waiting for get we get an [UnderFlowException] + // Nothing to do here as the stream will just end naturally. + } + } + + /// The `length` mutation is forbidden + set length(int newLength) { + throw UnsupportedError('Cannot resize immutable CircularBuffer.'); + } + + @override + String toString() { + final sb = StringBuffer()..write('['); + for (var i = 0; i < length; i++) { + if (sb.length != 1) { + sb.write(','); + } + sb.write('${this[i]}'); + } + sb.write(']'); + return sb.toString(); + } + + /// empties the buffer, discarding all elements. + Future drain() async { + while (_isNotEmpty) { + await get(); + } + } +} + +class _CircularBufferIterator implements Iterator> { + /// + _CircularBufferIterator(this._buffer); + // Iterate over odd numbers + final AsyncCircularBuffer _buffer; + + /// + @override + bool moveNext() => !(_buffer._closed.isCompleted && _buffer._isEmpty); + + /// will throw an [UnderflowException] if the buffer is + /// empty and closed. + @override + Future get current async { + final element = await _buffer.get(); + + if (element == null) { + throw UnderflowException(); + } + + return element; + } +} + +/// An attempt was made to access the buffer when it was closed. +class BadStateException implements Exception { + /// An attempt was made to access the buffer when it was closed. + BadStateException(this.message); + + /// The message. + String message; + + @override + String toString() => message; +} + +/// An attempt was made to access the buffer when +/// it was closed and empty. +class UnderflowException implements Exception { + /// An attempt was made to access the buffer when + /// it was closed and empty. + UnderflowException(); + + /// the error message. + String get message => 'The buffer is closed and empty'; + @override + String toString() => message; +} diff --git a/packages/console/lib/src/utils/dcli_exception.dart b/packages/console/lib/src/utils/dcli_exception.dart new file mode 100644 index 0000000..0bed212 --- /dev/null +++ b/packages/console/lib/src/utils/dcli_exception.dart @@ -0,0 +1,74 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:convert'; + +import 'package:stack_trace/stack_trace.dart'; + +/// Base class for all DCli exceptions. +class DCliException implements Exception { + /// + DCliException(this.message, [Trace? stackTrace]) + : cause = null, + stackTrace = stackTrace ?? Trace.current(2); + + // Factory method to create DCliException from a JSON string + factory DCliException.fromJson(String jsonStr) { + final jsonMap = jsonDecode(jsonStr) as Map; + + return DCliException._( + jsonMap['message'] as String, + jsonMap['cause'] as String, + Trace.parse(jsonMap['stackTrace'] as String), + ); + } + + DCliException._(this.message, this.cause, [Trace? stackTrace]) + : stackTrace = stackTrace ?? Trace.current(2); + + /// + DCliException.from(this.cause, this.stackTrace) : message = cause.toString(); + + /// + DCliException.fromException(this.cause) + : message = cause.toString(), + stackTrace = Trace.current(2); + + /// + final String message; + + /// If DCliException is wrapping another exception then this is the + /// exeception that is wrapped. + final Object? cause; + + /// + Trace stackTrace; + + // { + // return DCliException(this.message, stackTrace); + // } + + @override + String toString() => message; + + /// + void printStackTrace() { + print(stackTrace.terse); + } + + Map toJson() => { + 'message': message, + 'cause': cause?.toString(), + 'stackTrace': stackTrace.toString(), + }; + + // Method to convert DCliException to a JSON string + String toJsonString() => jsonEncode(toJson()); +} diff --git a/packages/console/lib/src/utils/dcli_platform.dart b/packages/console/lib/src/utils/dcli_platform.dart new file mode 100644 index 0000000..a54a8a3 --- /dev/null +++ b/packages/console/lib/src/utils/dcli_platform.dart @@ -0,0 +1,58 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:scope/scope.dart'; + +/// [DCliPlatform] exists so we can scope +/// Platform in unit tests to return non-standard results +/// e.g. isWindows == true on a linux platform +class DCliPlatform { + /// Returns a singleton providing + /// access to DCli settings. + factory DCliPlatform() { + if (Scope.hasScopeKey(scopeKey)) { + return Scope.use(scopeKey); + } else { + return _self ??= DCliPlatform._internal(); + } + } + + /// To use this method create a [Scope] and inject this + /// as a value into the scope. + factory DCliPlatform.forScope({DCliPlatformOS? overriddenPlatform}) => + DCliPlatform._internal(overriddenPlatform: overriddenPlatform); + + DCliPlatform._internal({this.overriddenPlatform}); + + static ScopeKey scopeKey = ScopeKey(); + + static DCliPlatform? _self; + + DCliPlatformOS? overriddenPlatform; + + /// True if you are running on a Mac. + bool get isMacOS => overriddenPlatform == null + ? Platform.isMacOS + : overriddenPlatform == DCliPlatformOS.macos; + + /// True if you are running on a Linux system. + bool get isLinux => overriddenPlatform == null + ? Platform.isLinux + : overriddenPlatform == DCliPlatformOS.linux; + + /// True if you are running on a Window system. + bool get isWindows => overriddenPlatform == null + ? Platform.isWindows + : overriddenPlatform == DCliPlatformOS.windows; +} + +enum DCliPlatformOS { windows, linux, macos } diff --git a/packages/console/lib/src/utils/dev_null.dart b/packages/console/lib/src/utils/dev_null.dart new file mode 100644 index 0000000..09a6d59 --- /dev/null +++ b/packages/console/lib/src/utils/dev_null.dart @@ -0,0 +1,23 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// +/// devNull is a convenience function which you can use +/// if you want to ignore the output of a LineAction. +/// Its typical useage is a forEach where you don't want +/// to see any stdout but you still want to see errors +/// printed to stderr. +/// +/// ```dart +/// 'git pull'.forEach(devNull, stderr: (line) => printerr(line)); +/// ``` +/// +/// use this to consume the output. +void devNull(String? line) {} diff --git a/packages/console/lib/src/utils/file.dart b/packages/console/lib/src/utils/file.dart new file mode 100644 index 0000000..a85de5f --- /dev/null +++ b/packages/console/lib/src/utils/file.dart @@ -0,0 +1,232 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; +import 'package:protevus_console/core.dart'; + +/// Opens a File and calls [action] passing in the open file. +/// When action completes the file is closed. +/// Use this method in preference to directly callling [FileSync()] +Future withOpenFile( + String pathToFile, + R Function(RandomAccessFile) action, { + FileMode fileMode = FileMode.writeOnlyAppend, +}) async { + final raf = File(pathToFile).openSync(mode: fileMode); + + R result; + try { + result = action(raf); + } finally { + await raf.close(); + } + return result; +} + +/// +/// Creates a link at [linkPath] which points to an +/// existing file or directory at [existingPath] +/// +/// On Windows you need to be in developer mode or running as an Administrator +/// to create a symlink. +/// +/// To enable developer mode see: +/// https://dcli.onepub.dev/getting-started/installing-on-windows +/// +/// To check if your script is running as an administrator use: +/// +/// [Shell.current.isPrivileged] +/// +void symlink( + String existingPath, + String linkPath, +) { + verbose(() => 'symlink existingPath: $existingPath linkPath $linkPath'); + Link(linkPath).createSync(existingPath); +} + +/// +/// Deletes the symlink at [linkPath] +/// +/// On Windows you need to be in developer mode or running as an Administrator +/// to delete a symlink. +/// +/// To enable developer mode see: +/// https://dcli.onepub.dev/getting-started/installing-on-windows +/// +/// To check if your script is running as an administrator use: +/// +/// [Shell.current.isPrivileged] +/// +void deleteSymlink(String linkPath) { + verbose(() => 'deleteSymlink linkPath: $linkPath'); + Link(linkPath).deleteSync(); +} + +/// +/// Resolves the a symbolic link [pathToLink] +/// to the ultimate target path. +/// +/// The return path will be canonicalized. +/// +/// e.g. +/// ```dart +/// resolveSymLink('/usr/bin/dart) == '/usr/lib/bin/dart' +/// ``` +/// +/// throws a FileSystemException if the target path does not exist. +String resolveSymLink(String pathToLink) { + final normalised = canonicalize(pathToLink); + + String resolved; + if (isDirectory(normalised)) { + resolved = Directory(normalised).resolveSymbolicLinksSync(); + } else { + resolved = canonicalize(File(normalised).resolveSymbolicLinksSync()); + } + + verbose(() => 'resolveSymLink $pathToLink resolved: $resolved'); + return resolved; +} + +/// +/// +/// Returns a FileStat instance describing the +/// file or directory located by [path]. +/// +FileStat stat(String path) => File(path).statSync(); + +/// Generates a temporary filename in [pathToTempDir] +/// or if inTempDir os not passed then in +/// the system temp directory. +/// The generated filename is is guaranteed to be globally unique. +/// +/// This method does NOT create the file. +/// +/// The temp file name will be .tmp +/// unless you provide a [suffix] in which +/// case the file name will be . +String createTempFilename({String? suffix, String? pathToTempDir}) { + var finalsuffix = suffix ?? 'tmp'; + + if (!finalsuffix.startsWith('.')) { + finalsuffix = '.$finalsuffix'; + } + pathToTempDir ??= Directory.systemTemp.path; + const uuid = Uuid(); + return '${join(pathToTempDir, uuid.v4())}$finalsuffix'; +} + +/// Generates a temporary filename in the system temp directory +/// that is guaranteed to be unique. +/// +/// This method does not create the file. +/// +/// The temp file name will be .tmp +/// unless you provide a [suffix] in which +/// case the file name will be . +String createTempFile({String? suffix}) { + final filename = createTempFilename(suffix: suffix); + touch(filename, create: true); + return filename; +} + +/// Returns the length of the file at [pathToFile] in bytes. +int fileLength(String pathToFile) => File(pathToFile).lengthSync(); + +/// Creates a temp file and then calls [action]. +/// +/// Once [action] completes the temporary file will be deleted. +/// +/// The [action]s return value [R] is returned from the [withTempFileAsync] +/// function. +/// +/// If [create] is true (default true) then the temp file will be +/// created. If [create] is false then just the name will be +/// generated. +/// +/// if [pathToTempDir] is passed then the file will be created in that +/// directory otherwise the file will be created in the system +/// temp directory. +/// +/// The temp file name will be .tmp +/// unless you provide a [suffix] in which +/// case the file name will be . +Future withTempFileAsync( + Future Function(String tempFile) action, { + String? suffix, + String? pathToTempDir, + bool create = true, + bool keep = false, +}) async { + final tmp = createTempFilename(suffix: suffix, pathToTempDir: pathToTempDir); + if (create) { + touch(tmp, create: true); + } + + R result; + try { + result = await action(tmp); + } finally { + if (exists(tmp) && !keep) { + delete(tmp); + } + } + return result; +} + +Digest calculateHash(String path) { + if (!exists(path)) { + throw FileNotFoundException(path); + } + final file = File(path); + var digest = Digest([0]); + + if (file.lengthSync() == 0) { + return digest; + } + + const blockSize = 8192; // Set the desired block size (e.g., 8 KB) + const hasher = sha256; + + final randomAccessFile = file.openSync(); + final chunk = List.filled(blockSize, 0); + int bytesRead; + + while ((bytesRead = randomAccessFile.readIntoSync(chunk)) > 0) { + digest = md5.convert([...digest.bytes, ...chunk.sublist(0, bytesRead)]); + } + + randomAccessFile.closeSync(); + + final digestAsString = hasher.toString(); + verbose(() => 'calculateHash($path) = $digestAsString'); + + return digest; +} + +/// Thrown when a file doesn't exist +class FileNotFoundException extends DCliException { + /// Thrown when a file doesn't exist + FileNotFoundException(String path) + : super('The file ${truepath(path)} does not exist.'); +} + +/// Thrown when a path is not a file. +class NotAFileException extends DCliException { + /// Thrown when a path is not a file. + NotAFileException(String path) + : super('The path ${truepath(path)} is not a file.'); +} diff --git a/packages/console/lib/src/utils/limited_stream_controller.dart b/packages/console/lib/src/utils/limited_stream_controller.dart new file mode 100644 index 0000000..32e3378 --- /dev/null +++ b/packages/console/lib/src/utils/limited_stream_controller.dart @@ -0,0 +1,130 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:async'; + +/// A specialized StreamController that limits the no. +/// of elements that can be in the stream. +class LimitedStreamController implements StreamController { + /// Creates a new [LimitedStreamController] that limits the no. + /// of elements that can be in the queue. + LimitedStreamController(this._limit, + {void Function()? onListen, void Function()? onCancel, bool sync = false}) + : _streamController = StreamController( + onListen: onListen, onCancel: onCancel, sync: sync); + + final StreamController _streamController; + + final int _limit; + + /// Tracks the no. of elements in the stream. + var _count = 0; + + /// Used to indicate when the stream is full + var _spaceAvailable = Completer(); + + /// Returns the no. of elements waiting in the stream. + int get length => _count; // _buffer.length; + + @override + bool get isClosed => _streamController.isClosed; + + @override + bool get hasListener => _streamController.hasListener; + + @override + bool get isPaused => _streamController.isPaused; + + @Deprecated('Use asyncAdd') + @override + void add(T event) { + throw UnsupportedError('Use asyncAdd'); + } + + /// Add an event to the stream. If the + /// stream is full then this method will + /// wait until there is room. + Future asyncAdd(T event) async { + if (_count >= _limit) { + await _spaceAvailable.future; + } + _count++; + _streamController.add(event); + + if (_count >= _limit) { + _spaceAvailable = Completer(); + } + } + + @override + Stream get stream async* { + /// return _buffer.stream(); + await for (final element in _streamController.stream) { + _count--; + + if (_count < _limit && !_spaceAvailable.isCompleted) { + /// notify that we have space available + _spaceAvailable.complete(true); + } + yield element; + } + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + _streamController.addError(error, stackTrace); + } + + @override + Future addStream(Stream source, {bool? cancelOnError = true}) { + throw UnsupportedError('Use asyncAdd'); + } + + @override + Future close() => _streamController.close(); + + @override + Future get done => _streamController.done; + + @override + StreamSink get sink => throw UnsupportedError('Use asyncAdd'); + + @override + set onListen(void Function()? onListenHandler) { + _streamController.onListen = onListenHandler; + } + + @override + ControllerCallback? get onListen => _streamController.onListen; + + @override + set onPause(void Function()? onPauseHandler) { + _streamController.onPause = onPauseHandler; + } + + @override + ControllerCallback? get onPause => _streamController.onPause; + + @override + set onResume(void Function()? onResumeHandler) { + _streamController.onResume = onResumeHandler; + } + + @override + ControllerCallback? get onResume => _streamController.onResume; + + @override + set onCancel(void Function()? onCancelHandler) { + _streamController.onCancel = onCancelHandler; + } + + @override + ControllerCancelCallback? get onCancel => _streamController.onCancel; +} diff --git a/packages/console/lib/src/utils/line_action.dart b/packages/console/lib/src/utils/line_action.dart new file mode 100644 index 0000000..73d9946 --- /dev/null +++ b/packages/console/lib/src/utils/line_action.dart @@ -0,0 +1,15 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/// Typedef for LineActions +typedef LineAction = void Function(String line); + +/// Typedef for cancellable LineActions. +typedef CancelableLineAction = bool Function(String line); diff --git a/packages/console/lib/src/utils/line_file.dart b/packages/console/lib/src/utils/line_file.dart new file mode 100644 index 0000000..5d24e46 --- /dev/null +++ b/packages/console/lib/src/utils/line_file.dart @@ -0,0 +1,188 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:convert'; +import 'dart:io'; + +import 'platform.dart'; + +/// Provide s collection of methods to make it easy +/// to read/write a file line by line. +class LineFile { + /// If you instantiate FileSync you MUST call [close]. + /// + /// We rececommend that you use withOpenFile in prefernce to directly + /// calling this method. + LineFile(String path, {FileMode fileMode = FileMode.writeOnlyAppend}) + : _fileMode = fileMode { + _file = File(path); + } + + final FileMode _fileMode; + late final File _file; + late final RandomAccessFile _raf = _open(_fileMode); + + RandomAccessFile _open(FileMode fileMode) => _file.openSync(mode: fileMode); + + /// + /// Flushes the contents of the file to disk. + void flush() => _raf.flushSync(); + + /// Returns the length of the file in bytes + /// The file does NOT have to be open + /// to determine its length. + int get length => _file.lengthSync(); + + /// Close and flushes the file to disk. + void close() => _raf.closeSync(); + + /// Read file line by line. + void readAll(bool Function(String) handleLine) { + final stream = _file.openSync(); + try { + final splitter = const LineSplitter() + .startChunkedConversion(_CallbackStringSync((str) { + if (!handleLine(str)) { + throw const _StopIteration(); + } + })); + + final decoder = const Utf8Decoder().startChunkedConversion(splitter); + + while (true) { + final bytes = stream.readSync(16 * 1024); + if (bytes.isEmpty) { + break; + } + decoder.add(bytes); + } + decoder.close(); + } on _StopIteration catch (_) { + // Ignore. + } finally { + stream.closeSync(); + } + } + + /// Truncates the file to zero bytes and + /// then writes the given text to the file. + /// If [newline] is null or isn't passed then the platform + /// end of line characters are appended as defined by + /// [Platform().eol]. + /// Pass null or an '' to [newline] to not add a line terminator. + void write(String line, {String? newline}) { + final finalline = line + (newline ?? eol); + _raf + ..truncateSync(0) + ..setPositionSync(0) + ..writeStringSync(finalline) + ..flushSync(); + } + + /// Appends the [line] to the file + /// Appends [newline] after the line. + /// If [newline] is null or isn't passed then the platform + /// end of line characters are appended as defined by + /// [Platform().eol]. + /// Pass null or an '' to [newline] to not add a line terminator. + void append(String line, {String? newline}) { + final finalline = line + (newline ?? eol); + + _raf + ..setPositionSync(_raf.lengthSync()) + ..writeStringSync(finalline); + } + + /// Reads a single line from the file. + /// [lineDelimiter] the end of line delimiter. + /// May be one or two characters long. + /// Defaults to the platform specific delimiter as + /// defined by [Platform().eol]. + /// + String? read({String? lineDelimiter}) { + lineDelimiter ??= eol; + final line = StringBuffer(); + int byte; + var priorChar = ''; + + var foundDelimiter = false; + + while ((byte = _raf.readByteSync()) != -1) { + final char = utf8.decode([byte]); + + if (_isLineDelimiter(priorChar, char, lineDelimiter)) { + foundDelimiter = true; + break; + } + + line.write(char); + priorChar = char; + } + final endOfFile = line.isEmpty && foundDelimiter == false; + return endOfFile ? null : line.toString(); + } + + /// Truncates the file to zero bytes in length. + void truncate() => _raf.truncateSync(0); + + bool _isLineDelimiter(String priorChar, String char, String lineDelimiter) { + if (lineDelimiter.length == 1) { + return char == lineDelimiter; + } else { + return priorChar + char == lineDelimiter; + } + } + + /// Opens the file for random access. + void open() { + /// accessing raf causes the file to open. + // ignore: unnecessary_statements + _raf; + } +} + +/// Opens a File and calls [action] passing in the open [LineFile]. +/// When action completes the file is closed. +/// Use this method in preference to directly callling [FileSync()] +R withOpenLineFile( + String pathToFile, + R Function(LineFile) action, { + FileMode fileMode = FileMode.writeOnlyAppend, +}) { + final file = LineFile(pathToFile, fileMode: fileMode)..open(); + + late R result; + try { + result = action(file); + } finally { + file + ..flush() + ..close(); + } + return result; +} + +class _StopIteration implements Exception { + const _StopIteration(); +} + +class _CallbackStringSync implements Sink { + _CallbackStringSync(this.callback); + + final void Function(String) callback; + + @override + void add(String data) { + callback(data); + } + + @override + void close() {} +} diff --git a/packages/console/lib/src/utils/platform.dart b/packages/console/lib/src/utils/platform.dart new file mode 100644 index 0000000..0e2f8c0 --- /dev/null +++ b/packages/console/lib/src/utils/platform.dart @@ -0,0 +1,30 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:io'; + +import 'package:protevus_console/core.dart'; + +/// Extensions for the Platform class +extension PlatformEx on Platform { + /// Returns the OS specific End Of Line (eol) character. + /// On Windows this is '\r\n' on all other platforms + /// it is '\n'. + /// Usage: Platform().eol + /// + /// Note: you must import both: + /// ```dart + /// import 'dart:io'; + /// import 'package:dcli/dcli.dart'; + /// ``` + String get eol => DCliPlatform().isWindows ? '\r\n' : '\n'; +} + +String get eol => DCliPlatform().isWindows ? '\r\n' : '\n'; diff --git a/packages/console/lib/src/utils/run_exception.dart b/packages/console/lib/src/utils/run_exception.dart new file mode 100644 index 0000000..950ba64 --- /dev/null +++ b/packages/console/lib/src/utils/run_exception.dart @@ -0,0 +1,101 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:convert'; + +import 'package:stack_trace/stack_trace.dart'; + +import 'dcli_exception.dart'; + +/// Thrown when any of the process related method +/// such as .run and .start fail. +class RunException extends DCliException { + /// + RunException( + this.cmdLine, + this.exitCode, + this.reason, { + Trace? stackTrace, + }) : super(reason, stackTrace); + + RunException.fromJson(Map json) + : cmdLine = json['cmdLine'] as String, + exitCode = json['exitCode'] as int, + reason = json['reason'] as String, + super(json['reason'] as String, json['stackTrace'] as Trace); + + factory RunException.fromJsonString(String jsonString) { + final jsonMap = jsonDecode(jsonString) as Map; + return RunException( + jsonMap['cmdLine'] as String, + jsonMap['exitCode'] as int, + jsonMap['reason'] as String, + stackTrace: jsonMap['stackTrace'] != null + ? Trace.parse(jsonMap['stackTrace'] as String) + : null, + ); + } + + /// + RunException.withArgs( + String? cmd, + List args, + this.exitCode, + this.reason, { + Trace? stackTrace, + }) : cmdLine = '$cmd ${args.join(' ')}', + super(reason, stackTrace); + + /// + RunException.fromException( + Object exception, + String? cmd, + List args, { + Trace? stackTrace, + }) : cmdLine = '$cmd ${args.join(' ')}', + reason = exception.toString(), + exitCode = -1, + super(exception.toString(), stackTrace); + + /// The command line that was being run. + String cmdLine; + + /// the exit code of the command. + int? exitCode; + + /// the error. + String reason; + + @override + String get message => ''' +$cmdLine +exit: $exitCode +reason: $reason'''; + + Map toJsonMap() => { + 'cmdLine': cmdLine, + 'exitCode': exitCode, + 'reason': reason, + 'stackTrace': stackTrace, + }; + + @override + String toJsonString() { + final jsonMap = { + 'cmdLine': cmdLine, + 'exitCode': exitCode, + 'reason': reason, + 'stackTrace': stackTrace.toString(), + }; + final json = jsonEncode(jsonMap); + print(json); + return json; + } +} diff --git a/packages/console/lib/src/utils/stack_list.dart b/packages/console/lib/src/utils/stack_list.dart new file mode 100644 index 0000000..bfc14a2 --- /dev/null +++ b/packages/console/lib/src/utils/stack_list.dart @@ -0,0 +1,47 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'dart:collection'; + +/// +/// A classic Stack of items with a push and pop method. +/// +class StackList { + /// + StackList(); + + /// + /// Creates a stack from [initialStack] + /// by pushing each element of the list + /// onto the stack from first to last. + StackList.fromList(List initialStack) { + initialStack.forEach(push); + } + + final _stack = Queue(); + + /// + bool get isEmpty => _stack.isEmpty; + + /// push an [item] onto th stack. + void push(T item) { + _stack.addFirst(item); + } + + /// return and remove an item from the stack. + T pop() => _stack.removeFirst(); + + /// returns the item onf the top of the stack + /// but does not remove the item. + T peek() => _stack.first; + + /// The of items in the stack + List asList() => _stack.toList(); +} diff --git a/packages/console/lib/src/utils/truepath.dart b/packages/console/lib/src/utils/truepath.dart new file mode 100644 index 0000000..469a1f6 --- /dev/null +++ b/packages/console/lib/src/utils/truepath.dart @@ -0,0 +1,63 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import 'package:path/path.dart'; + +import '../functions/env.dart'; +import '../functions/pwd.dart'; + +/// [truepath] creates an absolute and normalized path. +/// +/// True path provides a safe and consistent manner for +/// manipulating, accessing and displaying paths. +/// +/// Works like [join] in that it concatenates a set of directories +/// into a path. +/// [truepath] then goes on to create an absolute path which +/// is then normalize to remove any segments (.. or .). +/// +String truepath( + String part1, [ + String? part2, + String? part3, + String? part4, + String? part5, + String? part6, + String? part7, +]) => + normalize(absolute(join(part1, part2, part3, part4, part5, part6, part7))); + +/// Removes the users home directory from a path replacing it with ~ +String privatePath( + String part1, [ + String? part2, + String? part3, + String? part4, + String? part5, + String? part6, + String? part7, +]) { + final prefix = rootPrefix(HOME); + var tp = truepath(part1, part2, part3, part4, part5, part6, part7); + if (HOME != '.') { + tp = tp.replaceAll(HOME, '$prefix'); + } + return tp; +} + +/// Returns the root path of your file system. +/// +/// On Linux and MacOS this will be `/` +/// +/// On Windows this will be `'C:\` +/// +/// The drive letter will depend on the +/// drive of your present working directory (pwd). +String get rootPath => rootPrefix(pwd); diff --git a/packages/console/lib/terminal.dart b/packages/console/lib/terminal.dart new file mode 100644 index 0000000..4f0d5c2 --- /dev/null +++ b/packages/console/lib/terminal.dart @@ -0,0 +1,16 @@ +/* + * This file is part of the Protevus Platform. + * + * (C) Protevus + * (C) S. Brett Sutton + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +library; + +export 'src/terminal/ansi.dart'; +export 'src/terminal/ansi_color.dart'; +export 'src/terminal/format.dart'; +export 'src/terminal/terminal.dart'; diff --git a/packages/console/pubspec.yaml b/packages/console/pubspec.yaml index 19a4db6..8a84446 100644 --- a/packages/console/pubspec.yaml +++ b/packages/console/pubspec.yaml @@ -10,8 +10,19 @@ environment: # Add regular dependencies here. dependencies: - path: ^1.9.0 - scope: ^4.1.0 + async: ^2.5.0 + circular_buffer: ^0.11.0 + collection: ^1.15.0 + crypto: ^3.0.1 + dart_console: ^4.0.0 + ffi: ^2.1.0 + logging: ^1.0.2 + meta: ">=1.11.0 <2.0.0" + path: ^1.8.0 + scope: ^4.0.0 + stack_trace: ^1.10.0 + uuid: ^4.1.0 + validators2: ^5.0.0 dev_dependencies: lints: ^3.0.0