diff --git a/.code_preloader.yml b/.code_preloader.yml new file mode 100644 index 0000000..97920cc --- /dev/null +++ b/.code_preloader.yml @@ -0,0 +1,32 @@ +--- +# Example configuration for Code-Preloader + +# List of repository paths to preload +# source_list: +# - "path/to/repo1" +# - "path/to/repo2" + +# List of patterns to ignore during preloading +ignore_list: + - ^\.git/ + - ^lib.* + - ^doc/ + - ^bin/ + - ^_prompts/ + - ^\.reuse/ + - ^LICENSES/ + - ^\.vagrant/ + - ^scripts/ + +# Path to the output file (if null, output to STDOUT) +output_path: null + +prompt: + # Optional: Path to a file containing the prompt header + header_path: null + + # Optional: Path to a file containing the prompt footer + footer_path: null + + # Optional: Path to a file container a jinja template to structure the prompt + template_path: null diff --git a/.drone.yml b/.drone.yml index 8999d06..1599bbe 100644 --- a/.drone.yml +++ b/.drone.yml @@ -5,7 +5,7 @@ name: default steps: - name: build:binary - image: crystallang/crystal:1.10.1-alpine + image: crystallang/crystal:1.11.0-alpine environment: PACKAGE_BASENAME: mfm_linux_amd64 volumes: diff --git a/.gitignore b/.gitignore index ef729c3..ccd3fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: 2023 Glenn Y. Rolland # Copyright © 2023 Glenn Y. Rolland +/_* .vagrant bin lib diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..73cb02e --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +crystal 1.10.1 diff --git a/Makefile b/Makefile index 5e6983c..f0c5bb8 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,29 @@ # SPDX-FileCopyrightText: 2023 Glenn Y. Rolland # Copyright © 2023 Glenn Y. Rolland +PREFIX=/usr + all: build +prepare: + shards install + build: - shards build + shards build --error-trace -Dpreview_mt + @echo SUCCESS + +watch: + watchexec --restart --delay-run 3 -c -e cr make build + +spec: test +test: + crystal spec --error-trace + +install: + install \ + -m 755 \ + bin/code-preloader \ + $(PREFIX)/bin + +.PHONY: spec test build all prepare install + diff --git a/README.md b/README.md index bc58143..8e1a613 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ --> [![Build Status](https://cicd.apps.glenux.net/api/badges/glenux/mfm/status.svg)](https://cicd.apps.glenux.net/glenux/mfm) +![License LGPL3.0-or-later](https://img.shields.io/badge/license-LGPL3.0--or--later-blue.svg) +[![Donate on patreon](https://img.shields.io/badge/patreon-donate-orange.svg)](https://patreon.com/glenux) + +> :information_source: This project is available on our self-hosted server and +> on CodeBerg and GitHub as mirrors. For the latest updates and comprehensive +> version of our project, please visit our primary repository at: +> . # Minimalist Fuse Manager (MFM) @@ -59,17 +66,56 @@ version](https://code.apps.glenux.net/glenux/mfm/releases) of MFM. ### Command Line Options +Global + ``` Usage: mfm [options] -Global options: - -c, --config FILE Specify configuration file - -h, --help Display this help +Global options + -c, --config FILE Set configuration file + -v, --verbose Set more verbosity + -o, --open Automatically open directory after mount + --version Show version + -h, --help Show this help Commands (not implemented yet): - create Add a new filesystem - delete Remove an existing filesystem - edit Modify the configuration + config Manage configuration file + mapping Manage filesystems +``` + +Config management + +``` +Usage: mfm filesystem [options] + +Global options + -c, --config FILE Set configuration file + -v, --verbose Set more verbosity + -o, --open Automatically open directory after mount + --version Show version + -h, --help Show this help + +Commands (not implemented yet): + init Create init file +``` + +Filesystem management + +``` +Usage: mfm mapping [options] + +Global options + -c, --config FILE Set configuration file + -v, --verbose Set more verbosity + -o, --open Automatically open directory after mount + --version Show version + -h, --help Show this help + +Commands (not implemented yet): + list List fuse mappings + create Create new fuse mapping + edit Edit fuse mapping + delete Create new fuse mapping ``` ### Demo @@ -84,19 +130,20 @@ detail the filesystem names, types, and respective configurations. ### YAML File Format ```yaml +--- version: "1" global: - mountpoint: "/home/user/mnt/{{name}}" + mountpoint: "{{env.HOME}}/mnt" filesystems: - type: "gocryptfs" name: "Work - SSH Keys" - encrypted_path: "/home/user/.ssh/keyring.work" + encrypted_path: "/home/user/.ssh/keyring.work.vault" - type: "sshfs" name: "Personal - Media Server" - remote_user: "user" + remote_user: "{{env.USER}}" remote_host: "mediaserver.local" remote_path: "/mnt/largedisk/music" remote_port: 22 @@ -133,5 +180,5 @@ By contributing, you agree to our code of conduct and license terms. ## License -GNU GPL-3 +GNU GPL-3 diff --git a/doc/demo.mfm.yml b/doc/demo.mfm.yml index 169fde8..8c8ff2b 100644 --- a/doc/demo.mfm.yml +++ b/doc/demo.mfm.yml @@ -1,9 +1,8 @@ --- - version: 1 global: - mountpoint: "~/mnt" + mountpoint: "{{env.HOME}}/mnt" filesystems: - type: gocryptfs @@ -16,7 +15,7 @@ filesystems: - type: sshfs name: "Personal - Remote Media Server" - remote_user: user + remote_user: "{{env.USER}}" remote_host: mediaserver.local remote_port: 22 remote_path: "/remote/path/to/media" diff --git a/shard.lock b/shard.lock index e13838b..e19d81c 100644 --- a/shard.lock +++ b/shard.lock @@ -1,5 +1,9 @@ version: 2.0 shards: + ameba: + git: https://github.com/crystal-ameba/ameba.git + version: 1.6.1 + crinja: git: https://github.com/straight-shoota/crinja.git version: 0.8.1 @@ -8,6 +12,10 @@ shards: git: https://github.com/sztheory/shellwords-crystal.git version: 0.1.0 + tablo: + git: https://github.com/hutou/tablo.git + version: 0.10.1 + version_from_shard: git: https://github.com/hugopl/version_from_shard.git version: 1.2.5 diff --git a/shard.yml b/shard.yml index 91274e8..3b29118 100644 --- a/shard.yml +++ b/shard.yml @@ -5,17 +5,17 @@ # Copyright © 2023 Glenn Y. Rolland name: Minimalist FUSE Manager -version: 0.1.11 +version: 0.2.0 targets: mfm: main: src/main.cr -# authors: -# - name +authors: + - Glenn Y. Rolland -# description: | -# Short description of gx-vault +description: | + FIXME. write description dependencies: crinja: @@ -24,14 +24,11 @@ dependencies: github: szTheory/shellwords-crystal version_from_shard: github: hugopl/version_from_shard + tablo: + github: hutou/tablo -# dependencies: -# pg: -# github: will/crystal-pg -# version: "~> 0.5" - -# development_dependencies: -# webmock: -# github: manastech/webmock.cr +development_dependencies: + ameba: + github: crystal-ameba/ameba license: GPL-3 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..e2f4f80 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1 @@ +require "spec" diff --git a/spec/utils/breadcrumbs_spec.cr b/spec/utils/breadcrumbs_spec.cr new file mode 100644 index 0000000..deec943 --- /dev/null +++ b/spec/utils/breadcrumbs_spec.cr @@ -0,0 +1,46 @@ +require "../spec_helper" +require "../../src/utils/breadcrumbs" + +describe GX::Utils::BreadCrumbs do + context "Initialization" do + it "can initialize from array" do + # empty string + b1 = GX::Utils::BreadCrumbs.new([] of String) + b1.to_a.should be_empty + + # simple string + b2 = GX::Utils::BreadCrumbs.new(["test1"]) + b2.to_a.should eq(["test1"]) + + # array + b3 = GX::Utils::BreadCrumbs.new(["test1", "test2"]) + b3.to_a.should eq(["test1", "test2"]) + end + end + + context "Functioning" do + it "can add values" do + # empty string + b1 = GX::Utils::BreadCrumbs.new([] of String) + b1.to_a.should be_empty + + # simple string + b2 = b1 + "test1" + b2.to_a.should eq(["test1"]) + + b3 = b2 + "test2" + b3.to_a.should eq(["test1", "test2"]) + end + + it "can become a string" do + b1 = GX::Utils::BreadCrumbs.new([] of String) + b1.to_s.should eq("") + + b2 = b1 + "test1" + b2.to_a.should eq("test1") + + b3 = b2 + "test2" + b3.to_a.should eq("test1 test2") + end + end +end diff --git a/src/cli.cr b/src/cli.cr index f9a6578..04fa16f 100644 --- a/src/cli.cr +++ b/src/cli.cr @@ -5,128 +5,38 @@ require "option_parser" require "./config" -require "./fzf" require "./version" +require "./parsers/root_parser" +require "./utils/breadcrumbs" +require "./utils/fzf" +require "./file_system_manager" +require "./command_factory" module GX class Cli Log = ::Log.for("cli") - @config : Config + @config : GX::Config - def initialize() + def initialize # Main execution starts here + # # FIXME: add a method to verify that FZF is installed @config = Config.new - - ## FIXME: check that FZF is installed end def parse_command_line(args) - # update - add_args = { name: "", path: "" } - delete_args = { name: "" } pparser = OptionParser.new do |parser| - parser.banner = "Usage: #{PROGRAM_NAME} [options]\n\nGlobal options" - - parser.on("-c", "--config FILE", "Set configuration file") do |path| - Log.info { "Configuration set to #{path}" } - @config.path = path - end - - parser.on("-v", "--verbose", "Set more verbosity") do |flag| - Log.info { "Verbosity enabled" } - @config.verbose = true - end - - parser.on("--version", "Show version") do |flag| - @config.mode = Config::Mode::ShowVersion - end - - parser.on("-h", "--help", "Show this help") do |flag| - STDOUT.puts parser - exit(0) - end - - parser.separator("\nCommands") - parser.on("config", "Manage configuration") do - parser.banner = "Usage: #{PROGRAM_NAME} config [commands] [options]\n\nGlobal options" - parser.separator("\nCommands") - - parser.on("create", "Create vault") do - @config.mode = Config::Mode::ConfigAdd - - parser.banner = "Usage: #{PROGRAM_NAME} config create [commands] [options]\n\nGlobal options" - parser.separator("\nCommand options") - - parser.on("-n", "--name", "Set vault name") do |name| - add_args = add_args.merge({ name: name }) - end - parser.on("-p", "--path", "Set vault encrypted path") do |path| - add_args = add_args.merge({ path: path }) - end - end - - parser.on("delete", "Delete vault") do - @config.mode = Config::Mode::ConfigAdd - - parser.banner = "Usage: #{PROGRAM_NAME} delete [options]\n\nGlobal options" - parser.separator("\nCommand options") - - parser.on("-n", "--name", "Set vault name") do |name| - delete_args = delete_args.merge({ name: name }) - end - end - - parser.on("edit", "Edit configuration") do |flag| - @config.mode = Config::Mode::ConfigEdit - end - end - + breadcrumbs = Utils::BreadCrumbs.new([] of String) + Parsers::RootParser.new.build(parser, breadcrumbs, @config) end pparser.parse(args) end - def run() - case @config.mode - when Config::Mode::ShowVersion - STDOUT.puts "#{PROGRAM_NAME} #{VERSION}" - when Config::Mode::Mount - @config.load_from_file - mount - end - end - - def mount() - names_display = {} of String => NamedTuple(filesystem: Filesystem, ansi_name: String) - @config.filesystems.each do |filesystem| - fs_str = filesystem.type.ljust(12,' ') - - suffix = "" - suffix_ansi = "" - if filesystem.mounted? - suffix = "[open]" - suffix_ansi = "[#{ "open".colorize(:green) }]" - end - - result_name = "#{fs_str} #{filesystem.name} #{suffix}".strip - ansi_name = "#{fs_str.colorize(:dark_gray)} #{filesystem.name} #{suffix_ansi}".strip - - names_display[result_name] = { - filesystem: filesystem, - ansi_name: ansi_name - } - end - - result_filesystem_name = Fzf.run(names_display.values.map(&.[:ansi_name]).sort).strip - selected_filesystem = names_display[result_filesystem_name][:filesystem] - puts ">> #{selected_filesystem.name}".colorize(:yellow) - - if selected_filesystem - selected_filesystem.mounted? ? selected_filesystem.unmount : selected_filesystem.mount - else - STDERR.puts "Vault not found: #{selected_filesystem}.".colorize(:red) - end + def run + command = CommandFactory.create_command(@config, @config.mode) + abort("ERROR: unknown command for mode #{@config.mode}") if command.nil? + command.try &.execute end end end diff --git a/src/command_factory.cr b/src/command_factory.cr new file mode 100644 index 0000000..c2d7e22 --- /dev/null +++ b/src/command_factory.cr @@ -0,0 +1,11 @@ +require "./commands" + +module GX + class CommandFactory + def self.create_command(config : GX::Config, mode : GX::Types::Mode) : Commands::AbstractCommand? + classes = {{ Commands::AbstractCommand.all_subclasses }} + command_klass = classes.find { |klass| klass.handles_mode == mode } + command_klass.try &.new(config) + end + end +end diff --git a/src/commands.cr b/src/commands.cr new file mode 100644 index 0000000..034a066 --- /dev/null +++ b/src/commands.cr @@ -0,0 +1 @@ +require "./commands/*" diff --git a/src/commands/abstract_command.cr b/src/commands/abstract_command.cr new file mode 100644 index 0000000..7591f15 --- /dev/null +++ b/src/commands/abstract_command.cr @@ -0,0 +1,13 @@ +require "../config" + +module GX::Commands + abstract class AbstractCommand + abstract def initialize(config : GX::Config) + + abstract def execute + + def self.mode + Gx::Types::Mode::None + end + end +end diff --git a/src/commands/config_init.cr b/src/commands/config_init.cr new file mode 100644 index 0000000..b9407d3 --- /dev/null +++ b/src/commands/config_init.cr @@ -0,0 +1,15 @@ +require "./abstract_command" + +module GX::Commands + class ConfigInit < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + end + + def self.handles_mode + GX::Types::Mode::ConfigInit + end + end +end diff --git a/src/commands/global_completion.cr b/src/commands/global_completion.cr new file mode 100644 index 0000000..d1be696 --- /dev/null +++ b/src/commands/global_completion.cr @@ -0,0 +1,15 @@ +require "./abstract_command" + +module GX::Commands + class GlobalCompletion < AbstractCommand + def initialize(@config : GX::Config) + end + + def execute + end + + def self.handles_mode + GX::Types::Mode::GlobalConfig + end + end +end diff --git a/src/commands/global_config.cr b/src/commands/global_config.cr new file mode 100644 index 0000000..c369556 --- /dev/null +++ b/src/commands/global_config.cr @@ -0,0 +1,15 @@ +require "./abstract_command" + +module GX::Commands + class GlobalConfig < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + end + + def self.handles_mode + GX::Types::Mode::GlobalConfig + end + end +end diff --git a/src/commands/global_help.cr b/src/commands/global_help.cr new file mode 100644 index 0000000..78f1616 --- /dev/null +++ b/src/commands/global_help.cr @@ -0,0 +1,18 @@ +require "./abstract_command" + +module GX::Commands + class GlobalHelp < AbstractCommand + def initialize(@config : GX::Config) # FIXME + end + + def execute + STDOUT.puts "" + @config.help_options.try { |opts| puts opts.parser_snapshot } + exit(0) + end + + def self.handles_mode + GX::Types::Mode::GlobalHelp + end + end +end diff --git a/src/commands/global_mapping.cr b/src/commands/global_mapping.cr new file mode 100644 index 0000000..0c61b95 --- /dev/null +++ b/src/commands/global_mapping.cr @@ -0,0 +1,16 @@ +require "./abstract_command" + +module GX::Commands + class GlobalMapping < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + # FIXME: implement + end + + def self.handles_mode + GX::Types::Mode::GlobalMapping + end + end +end diff --git a/src/commands/global_tui.cr b/src/commands/global_tui.cr new file mode 100644 index 0000000..58f22ed --- /dev/null +++ b/src/commands/global_tui.cr @@ -0,0 +1,25 @@ +require "./abstract_command" +require "../file_system_manager" + +module GX::Commands + class GlobalTui < AbstractCommand + @file_system_manager : FileSystemManager + + def initialize(@config : GX::Config) + @config.load_from_env + @config.load_from_file + @file_system_manager = FileSystemManager.new(@config) + end + + def execute + filesystem = @file_system_manager.choose_filesystem + raise Models::InvalidFilesystemError.new("Invalid filesystem") if filesystem.nil? + @file_system_manager.mount_or_umount(filesystem) + @file_system_manager.auto_open(filesystem) if filesystem.mounted? && @config.auto_open + end + + def self.handles_mode + GX::Types::Mode::GlobalTui + end + end +end diff --git a/src/commands/global_version.cr b/src/commands/global_version.cr new file mode 100644 index 0000000..9cd6757 --- /dev/null +++ b/src/commands/global_version.cr @@ -0,0 +1,17 @@ +require "./abstract_command" +require "../config" + +module GX::Commands + class GlobalVersion < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + STDOUT.puts "#{File.basename PROGRAM_NAME} #{VERSION}" + end + + def self.handles_mode + GX::Types::Mode::GlobalVersion + end + end +end diff --git a/src/commands/mapping_create.cr b/src/commands/mapping_create.cr new file mode 100644 index 0000000..1e3ec6f --- /dev/null +++ b/src/commands/mapping_create.cr @@ -0,0 +1,16 @@ +require "./abstract_command" + +module GX::Commands + class MappingCreate < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + # FIXME: implement + end + + def self.handles_mode + GX::Types::Mode::MappingCreate + end + end +end diff --git a/src/commands/mapping_delete.cr b/src/commands/mapping_delete.cr new file mode 100644 index 0000000..bed0534 --- /dev/null +++ b/src/commands/mapping_delete.cr @@ -0,0 +1,16 @@ +require "./abstract_command" + +module GX::Commands + class MappingDelete < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + # FIXME: implement + end + + def self.handles_mode + GX::Types::Mode::MappingDelete + end + end +end diff --git a/src/commands/mapping_edit.cr b/src/commands/mapping_edit.cr new file mode 100644 index 0000000..b0ef961 --- /dev/null +++ b/src/commands/mapping_edit.cr @@ -0,0 +1,16 @@ +require "./abstract_command" + +module GX::Commands + class MappingEdit < AbstractCommand + def initialize(config : GX::Config) # FIXME + end + + def execute + # FIXME: implement + end + + def self.handles_mode + GX::Types::Mode::MappingEdit + end + end +end diff --git a/src/commands/mapping_list.cr b/src/commands/mapping_list.cr new file mode 100644 index 0000000..35cd299 --- /dev/null +++ b/src/commands/mapping_list.cr @@ -0,0 +1,45 @@ +require "./abstract_command" +require "../file_system_manager" +require "tablo" + +module GX::Commands + class MappingList < AbstractCommand + def initialize(@config : GX::Config) + @config.load_from_env + @config.load_from_file + @file_system_manager = FileSystemManager.new(@config) + end + + def execute + filesystems = @config.root.try &.filesystems + return if filesystems.nil? + # pp filesystems + + fsdata = [] of Array(String) + filesystems.each do |item| + fsdata << [ + item.type, + item.name, + item.mounted?.to_s, + ] + end + # pp fsdata + + report = Tablo::Table.new( + fsdata, + # connectors: Tablo::CONNECTORS_SINGLE_ROUNDED + column_padding: 0, + style: "" # Tablo::STYLE_NO_MID_COL +) do |table| + table.add_column("TYPE") { |row| row[0] } + table.add_column("NAME", width: 40) { |row| row[1] } + table.add_column("MOUNTED") { |row| row[2] } + end + puts report + end + + def self.handles_mode + GX::Types::Mode::MappingList + end + end +end diff --git a/src/commands/mapping_mount.cr b/src/commands/mapping_mount.cr new file mode 100644 index 0000000..db81351 --- /dev/null +++ b/src/commands/mapping_mount.cr @@ -0,0 +1,26 @@ +require "./abstract_command" +require "../file_system_manager" + +module GX::Commands + class MappingMount < AbstractCommand + @file_system_manager : FileSystemManager + + def initialize(@config : GX::Config) # FIXME + @config.load_from_env + @config.load_from_file + @file_system_manager = FileSystemManager.new(@config) + end + + def execute + filesystem = @file_system_manager.choose_filesystem + raise Models::InvalidFilesystemError.new("Invalid filesystem") if filesystem.nil? + # @file_system_manager.mount_or_umount(filesystem) + filesystem.mount + @file_system_manager.auto_open(filesystem) if filesystem.mounted? && @config.auto_open + end + + def self.handles_mode + GX::Types::Mode::MappingMount + end + end +end diff --git a/src/commands/mapping_umount.cr b/src/commands/mapping_umount.cr new file mode 100644 index 0000000..dc61f1d --- /dev/null +++ b/src/commands/mapping_umount.cr @@ -0,0 +1,24 @@ +require "./abstract_command" +require "../file_system_manager" + +module GX::Commands + class MappingUmount < AbstractCommand + @file_system_manager : FileSystemManager + + def initialize(@config : GX::Config) # FIXME + @config.load_from_env + @config.load_from_file + @file_system_manager = FileSystemManager.new(@config) + end + + def execute + filesystem = @file_system_manager.choose_filesystem + raise Models::InvalidFilesystemError.new("Invalid filesystem") if filesystem.nil? + filesystem.umount + end + + def self.handles_mode + GX::Types::Mode::MappingUmount + end + end +end diff --git a/src/config.cr b/src/config.cr index 913c066..d375cce 100644 --- a/src/config.cr +++ b/src/config.cr @@ -5,46 +5,54 @@ require "crinja" -require "./filesystems" +require "./models" +require "./types/modes" +require "./parsers/options/help_options" +require "./parsers/options/config_options" +require "./parsers/options/config_init_options" +require "./commands/abstract_command" module GX class Config Log = ::Log.for("config") - enum Mode - ConfigAdd - ConfigDelete - ConfigEdit - ShowVersion - Mount + class MissingFileError < Exception end record NoArgs record AddArgs, name : String, path : String record DelArgs, name : String - getter filesystems : Array(Filesystem) + # getter filesystems : Array(Models::AbstractFilesystemConfig) getter home_dir : String + getter root : Models::RootConfig? + property verbose : Bool - property mode : Mode + property mode : Types::Mode property path : String? property args : AddArgs.class | DelArgs.class | NoArgs.class + property auto_open : Bool - def initialize() - if !ENV["HOME"]? - raise "Home directory not found" - end + # FIXME: refactor and remove these parts from here + property help_options : Parsers::Options::HelpOptions? + property config_init_options : Parsers::Options::ConfigInitOptions? + property config_options : Parsers::Options::ConfigOptions? + + def initialize + raise Models::InvalidEnvironmentError.new("Home directory not found") if !ENV["HOME"]? @home_dir = ENV["HOME"] @verbose = false - @mode = Mode::Mount - @filesystems = [] of Filesystem + @auto_open = false + + @mode = Types::Mode::GlobalTui + @filesystems = [] of Models::AbstractFilesystemConfig @path = nil @args = NoArgs end - def detect_config_file() + private def detect_config_file possible_files = [ File.join(@home_dir, ".config", "mfm", "config.yaml"), File.join(@home_dir, ".config", "mfm", "config.yml"), @@ -64,39 +72,43 @@ module GX end Log.error { "No configuration file found in any of the standard locations" } - raise "Configuration file not found" + raise MissingFileError.new("Configuration file not found") + end + + def load_from_env + if !ENV["FZF_DEFAULT_OPTS"]? + # force defaults settings if none defined + ENV["FZF_DEFAULT_OPTS"] = "--height 40% --layout=reverse --border" + end end def load_from_file - path = @path - if path.nil? - path = detect_config_file() + config_path = @path + if config_path.nil? + config_path = detect_config_file() end - @path = path - @filesystems = [] of Filesystem + @path = config_path - if !File.exists? path + if !File.exists? config_path Log.error { "File #{path} does not exist!".colorize(:red) } exit(1) end - load_filesystems(path) - end - private def load_filesystems(config_path : String) file_data = File.read(config_path) - # FIXME: render template on a value basis (instead of global) - file_patched = Crinja.render(file_data, {"env" => ENV.to_h}) + file_patched = Crinja.render(file_data, {"env" => ENV.to_h}) - yaml_data = YAML.parse(file_patched) - vaults_data = yaml_data["filesystems"].as_a + root = Models::RootConfig.from_yaml(file_patched) - vaults_data.each do |filesystem_data| - type = filesystem_data["type"].as_s - name = filesystem_data["name"].as_s - # encrypted_path = filesystem_data["encrypted_path"].as_s - @filesystems << Filesystem.from_yaml(filesystem_data.to_yaml) - # @filesystems << Filesystem.new(name, encrypted_path, "#{name}.Open") + mount_point_base_safe = root.global.mount_point_base + raise Models::InvalidMountpointError.new("Invalid global mount point") if mount_point_base_safe.nil? + + root.filesystems.each do |selected_filesystem| + if !selected_filesystem.mount_point? + selected_filesystem.mount_point = + File.join(mount_point_base_safe, selected_filesystem.mounted_name) + end end + @root = root end end end diff --git a/src/file_system_manager.cr b/src/file_system_manager.cr new file mode 100644 index 0000000..91dc1a8 --- /dev/null +++ b/src/file_system_manager.cr @@ -0,0 +1,142 @@ +# require "./models/abstract_filesystem_config" +require "./utils/fzf" + +module GX + class FileSystemManager + Log = ::Log.for("file_system_manager") + + def initialize(@config : Config) + end + + # OBSOLETE: + # def mount_filesystem(filesystem : Models::AbstractFilesystemConfig) + # raise Models::InvalidFilesystemError.new("Invalid filesystem") if filesystem.nil? + # if filesystem.mounted? + # Log.info { "Filesystem already mounted." } + # return + # end + # filesystem.mount + # end + + # OBSOLETE: + # def umount_filesystem(filesystem : Models::AbstractFilesystemConfig) + # raise Models::InvalidFilesystemError.new("Invalid filesystem") if filesystem.nil? + # unless filesystem.mounted? + # Log.info { "Filesystem is not mounted." } + # return + # end + # filesystem.umount + # end + + def mount_or_umount(selected_filesystem) + if !selected_filesystem.mounted? + selected_filesystem.mount + else + selected_filesystem.umount + end + end + + def auto_open(filesystem) + # FIXME: detect xdg-open and use it if possible + # FIXME: detect mailcap and use it if no xdg-open found + # FIXME: support user-defined command in configuration + # FIXME: detect graphical environment + + mount_point_safe = filesystem.mount_point + raise Models::InvalidMountpointError.new("Invalid filesystem") if mount_point_safe.nil? + + if graphical_environment? + process = Process.new( + "xdg-open", # # FIXME: make configurable + [mount_point_safe], + input: STDIN, + output: STDOUT, + error: STDERR + ) + unless process.wait.success? + puts "Error opening filesystem".colorize(:red) + return + end + else + process = Process.new( + "vifm", # # FIXME: make configurable + [mount_point_safe], + input: STDIN, + output: STDOUT, + error: STDERR + ) + unless process.wait.success? + puts "Error opening filesystem".colorize(:red) + return + end + end + end + + def each(&) + config_root = @config.root + return if config_root.nil? + + config_root.filesystems.each do |filesystem| + yield filesystem + end + end + + def filesystems + config_root = @config.root + return if config_root.nil? + + config_root.filesystems + end + + def choose_filesystem + names_display = {} of String => NamedTuple(filesystem: Models::AbstractFilesystemConfig, ansi_name: String) + + config_root = @config.root + return if config_root.nil? + + config_root.filesystems.each do |filesystem| + fs_str = filesystem.type.ljust(12, ' ') + + suffix = "" + suffix_ansi = "" + if filesystem.mounted? + suffix = "[open]" + suffix_ansi = "[#{"open".colorize(:green)}]" + end + + result_name = "#{fs_str} #{filesystem.name} #{suffix}".strip + ansi_name = "#{fs_str.colorize(:dark_gray)} #{filesystem.name} #{suffix_ansi}".strip + + names_display[result_name] = { + filesystem: filesystem, + ansi_name: ansi_name, + } + end + + # # FIXME: feat: allow to sort by name or by filesystem + sorted_values = names_display.values.sort_by { |item| item[:filesystem].name } + result_filesystem_name = Utils::Fzf.run(sorted_values.map(&.[:ansi_name])).strip + selected_filesystem = names_display[result_filesystem_name][:filesystem] + puts ">> #{selected_filesystem.name}".colorize(:yellow) + + if !selected_filesystem + STDERR.puts "Vault not found: #{selected_filesystem}.".colorize(:red) + return + end + return selected_filesystem + end + + private def generate_display_name(filesystem : Models::AbstractFilesystemConfig) : String + fs_str = filesystem.type.ljust(12, ' ') + suffix = filesystem.mounted? ? "[open]" : "" + "#{fs_str} #{filesystem.name} #{suffix}".strip + end + + private def graphical_environment? + if ENV["DISPLAY"]? || ENV["WAYLAND_DISPLAY"]? + return true + end + return false + end + end +end diff --git a/src/filesystems.cr b/src/filesystems.cr deleted file mode 100644 index 2026122..0000000 --- a/src/filesystems.cr +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland -# Copyright © 2023 Glenn Y. Rolland - -require "./filesystems/gocryptfs" -require "./filesystems/sshfs" -require "./filesystems/httpdirfs" -require "./filesystems/filesystem" diff --git a/src/filesystems/filesystem.cr b/src/filesystems/filesystem.cr deleted file mode 100644 index b5cf44d..0000000 --- a/src/filesystems/filesystem.cr +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland -# Copyright © 2023 Glenn Y. Rolland - -require "yaml" - -module GX - abstract class Filesystem - include YAML::Serializable - - use_yaml_discriminator "type", { - gocryptfs: GoCryptFS, - sshfs: SshFS, - httpdirfs: HttpDirFS - } - - property type : String - end - - module GenericFilesystem - def unmount - system("fusermount -u #{mount_dir.shellescape}") - puts "Filesystem #{name} is now closed.".colorize(:green) - end - - def mount(&block) - Dir.mkdir_p(mount_dir) unless Dir.exists?(mount_dir) - if mounted? - puts "Already mounted. Skipping.".colorize(:yellow) - return - end - - yield - - puts "Filesystem #{name} is now available on #{mount_dir}".colorize(:green) - end - end -end - -require "./gocryptfs" -require "./sshfs" diff --git a/src/filesystems/gocryptfs.cr b/src/filesystems/gocryptfs.cr deleted file mode 100644 index ebe7f83..0000000 --- a/src/filesystems/gocryptfs.cr +++ /dev/null @@ -1,47 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland -# Copyright © 2023 Glenn Y. Rolland - -require "shellwords" -require "./filesystem" - -module GX - class GoCryptFS < Filesystem - getter name : String = "" - getter encrypted_path : String = "" - - @[YAML::Field(key: "mount_dir", ignore: true)] - getter mount_dir : String = "" - - include GenericFilesystem - - def after_initialize() - home_dir = ENV["HOME"] || raise "Home directory not found" - @mount_dir = File.join(home_dir, "mnt/#{@name}.Open") - end - - def mounted? : Bool - `mount`.includes?("#{encrypted_path} on #{mount_dir}") - end - - def mount - super do - input = STDIN - output = STDOUT - error = STDERR - process = Process.new( - "gocryptfs", - ["-idle", "15m", encrypted_path, mount_dir], - input: input, - output: output, - error: error - ) - unless process.wait.success? - puts "Error mounting the vault".colorize(:red) - return - end - end - end - end -end diff --git a/src/filesystems/httpdirfs.cr b/src/filesystems/httpdirfs.cr deleted file mode 100644 index e1e4279..0000000 --- a/src/filesystems/httpdirfs.cr +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland -# Copyright © 2023 Glenn Y. Rolland - -require "shellwords" -require "./filesystem" - -module GX - class HttpDirFS < Filesystem - getter name : String = "" - getter url : String = "" - - @[YAML::Field(key: "mount_dir", ignore: true)] - getter mount_dir : String = "" - - include GenericFilesystem - - def after_initialize() - home_dir = ENV["HOME"] || raise "Home directory not found" - @mount_dir = File.join(home_dir, "mnt/#{@name}") - end - - def mounted? : Bool - `mount`.includes?("httpdirfs on #{mount_dir}") - end - - def mount - super do - input = STDIN - output = STDOUT - error = STDERR - process = Process.new( - "httpdirfs", - ["#{url}", mount_dir], - input: input, - output: output, - error: error - ) - unless process.wait.success? - puts "Error mounting the filesystem".colorize(:red) - return - end - end - end - end -end - diff --git a/src/filesystems/sshfs.cr b/src/filesystems/sshfs.cr deleted file mode 100644 index 44ddab9..0000000 --- a/src/filesystems/sshfs.cr +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland -# Copyright © 2023 Glenn Y. Rolland - -require "shellwords" -require "./filesystem" - -module GX - class SshFS < Filesystem - getter name : String = "" - getter remote_path : String = "" - getter remote_user : String = "" - getter remote_host : String = "" - getter remote_port : String = "22" - - @[YAML::Field(key: "mount_dir", ignore: true)] - getter mount_dir : String = "" - - include GenericFilesystem - - def after_initialize() - home_dir = ENV["HOME"] || raise "Home directory not found" - @mount_dir = File.join(home_dir, "mnt/#{@name}") - end - - def mounted? : Bool - `mount`.includes?("#{remote_user}@#{remote_host}:#{remote_path} on #{mount_dir}") - end - - def mount - super do - input = STDIN - output = STDOUT - error = STDERR - process = Process.new( - "sshfs", - [ - "-p", remote_port, - "#{remote_user}@#{remote_host}:#{remote_path}", - mount_dir - ], - input: input, - output: output, - error: error - ) - unless process.wait.success? - puts "Error mounting the filesystem".colorize(:red) - return - end - end - end - end -end diff --git a/src/main.cr b/src/main.cr index f32183c..c02ff74 100644 --- a/src/main.cr +++ b/src/main.cr @@ -8,7 +8,6 @@ require "colorize" require "json" require "log" -require "./filesystems/gocryptfs" require "./config" require "./cli" @@ -32,9 +31,6 @@ Log.setup do |config| end end - -app = GX::Cli.new -app.parse_command_line(ARGV) -app.run - - +cli = GX::Cli.new +cli.parse_command_line(ARGV) +cli.run diff --git a/src/models.cr b/src/models.cr new file mode 100644 index 0000000..a5d97a4 --- /dev/null +++ b/src/models.cr @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "./models/root_config" +require "./models/global_config" +require "./models/gocryptfs_config" +require "./models/sshfs_config" +require "./models/httpdirfs_config" +require "./models/abstract_filesystem_config" diff --git a/src/models/abstract_filesystem_config.cr b/src/models/abstract_filesystem_config.cr new file mode 100644 index 0000000..928a0d7 --- /dev/null +++ b/src/models/abstract_filesystem_config.cr @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "yaml" + +module GX::Models + class InvalidFilesystemError < Exception + end + + class InvalidMountpointError < Exception + end + + abstract class AbstractFilesystemConfig + include YAML::Serializable + # include YAML::Serializable::Strict + + use_yaml_discriminator "type", { + gocryptfs: GoCryptFSConfig, + sshfs: SshFSConfig, + httpdirfs: HttpDirFSConfig, + } + + getter type : String + getter name : String + property mount_point : String? + + abstract def _mount_wrapper(&block) + abstract def _mount_action + abstract def _mounted_prefix + abstract def mounted_name + abstract def mounted? + abstract def mount + abstract def umount + abstract def mount_point? + end +end diff --git a/src/models/concerns/base.cr b/src/models/concerns/base.cr new file mode 100644 index 0000000..90254de --- /dev/null +++ b/src/models/concerns/base.cr @@ -0,0 +1,54 @@ +module GX::Models::Concerns + module Base + def mounted? : Bool + mount_point_safe = @mount_point + raise InvalidMountpointError.new("Invalid mountpoint value") if mount_point_safe.nil? + + `mount`.includes?(" on #{mount_point_safe} type ") + end + + def umount : Nil + mount_point_safe = @mount_point + raise InvalidMountpointError.new("Invalid mountpoint value") if mount_point_safe.nil? + + system("fusermount -u #{mount_point_safe.shellescape}") + fusermount_status = $? + + if fusermount_status.success? + puts "Models #{name} is now closed.".colorize(:green) + else + puts "Error: Unable to unmount filesystem #{name} (exit code: #{fusermount_status.exit_code}).".colorize(:red) + end + end + + def mount_point? + !mount_point.nil? + end + + def mount + _mount_wrapper() do + _mount_action + end + end + + def _mount_wrapper(&block) : Nil + mount_point_safe = mount_point + return if mount_point_safe.nil? + + Dir.mkdir_p(mount_point_safe) unless Dir.exists?(mount_point_safe) + if mounted? + puts "Already mounted. Skipping.".colorize(:yellow) + return + end + + result_status = yield + + if result_status.success? + puts "Models #{name} is now available on #{mount_point_safe}".colorize(:green) + else + puts "Error mounting the vault".colorize(:red) + return + end + end + end +end diff --git a/src/models/global_config.cr b/src/models/global_config.cr new file mode 100644 index 0000000..2cd57c1 --- /dev/null +++ b/src/models/global_config.cr @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "yaml" +require "./abstract_filesystem_config" + +module GX::Models + class InvalidEnvironmentError < Exception + end + + class GlobalConfig + include YAML::Serializable + include YAML::Serializable::Strict + + @[YAML::Field(key: "mount_point_base")] + getter mount_point_base : String? + + def after_initialize + raise InvalidEnvironmentError.new("Home directory not found") if !ENV["HOME"]? + home_dir = ENV["HOME"] + + # Set default mountpoint from global if none defined + if @mount_point_base.nil? || @mount_point_base.try &.empty? + @mount_point_base = File.join(home_dir, "mnt") + end + end + end +end diff --git a/src/models/gocryptfs_config.cr b/src/models/gocryptfs_config.cr new file mode 100644 index 0000000..9013bdf --- /dev/null +++ b/src/models/gocryptfs_config.cr @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "shellwords" +require "./abstract_filesystem_config" +require "./concerns/base" + +module GX::Models + class GoCryptFSConfig < AbstractFilesystemConfig + getter encrypted_path : String = "" + + include Concerns::Base + + def _mounted_prefix + "#{encrypted_path}" + end + + def mounted_name + "#{@name}.Open" + end + + def _mount_action + mount_point_safe = @mount_point + raise InvalidMountpointError.new("Invalid mount point") if mount_point_safe.nil? + + process = Process.new( + "gocryptfs", + ["-idle", "15m", @encrypted_path, mount_point_safe], + input: STDIN, + output: STDOUT, + error: STDERR + ) + return process.wait + end + end +end diff --git a/src/models/httpdirfs_config.cr b/src/models/httpdirfs_config.cr new file mode 100644 index 0000000..30db9eb --- /dev/null +++ b/src/models/httpdirfs_config.cr @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "shellwords" +require "./abstract_filesystem_config" +require "./concerns/base" + +module GX::Models + class HttpDirFSConfig < AbstractFilesystemConfig + getter url : String = "" + + include Concerns::Base + + def _mounted_prefix + "httpdirfs" + end + + def mounted_name + @name + end + + def _mount_action + mount_point_safe = @mount_point + raise InvalidMountpointError.new("Invalid mount point") if mount_point_safe.nil? + + process = Process.new( + "httpdirfs", + ["#{@url}", mount_point_safe], + input: STDIN, + output: STDOUT, + error: STDERR + ) + return process.wait + end + end +end diff --git a/src/models/root_config.cr b/src/models/root_config.cr new file mode 100644 index 0000000..d88dc61 --- /dev/null +++ b/src/models/root_config.cr @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "yaml" +require "./abstract_filesystem_config" +require "./global_config" + +module GX::Models + # class CrinjaConverter + # def self.from_yaml(ctx : YAML::ParseContext , node : YAML::Nodes::Node) + # l_node = node + # if l_node.is_a?(YAML::Nodes::Scalar) + # value_patched = Crinja.render(l_node.value, {"env" => ENV.to_h}) + # return value_patched + # end + + # return "" + # end + # + # def self.to_yaml(value, builder : YAML::Nodes::Builder) + # end + # end + + class RootConfig + include YAML::Serializable + include YAML::Serializable::Strict + + # @[YAML::Field(key: "version", converter: GX::Models::CrinjaConverter)] + @[YAML::Field(key: "version")] + getter version : String + + @[YAML::Field(key: "global")] + getter global : GlobalConfig + + @[YAML::Field(key: "filesystems")] + getter filesystems : Array(AbstractFilesystemConfig) + end +end diff --git a/src/models/sshfs_config.cr b/src/models/sshfs_config.cr new file mode 100644 index 0000000..898ccdb --- /dev/null +++ b/src/models/sshfs_config.cr @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# SPDX-FileCopyrightText: 2023 Glenn Y. Rolland +# Copyright © 2023 Glenn Y. Rolland + +require "shellwords" +require "./abstract_filesystem_config" +require "./concerns/base" + +module GX::Models + class SshFSConfig < AbstractFilesystemConfig + getter remote_path : String = "" + getter remote_user : String = "" + getter remote_host : String = "" + getter remote_port : String = "22" + getter options : Array(String) = [] of String + + include Concerns::Base + + def _mounted_prefix + "#{@remote_user}@#{@remote_host}:#{@remote_path}" + end + + def mounted_name + @name + end + + def _mount_action + mount_point_safe = @mount_point + raise InvalidMountpointError.new("Invalid mount point") if mount_point_safe.nil? + + options = [] of String + # merge sshfs options + @options.each do |option| + options.push("-o", option) + end + options.push("-p", remote_port) + options.push( + "#{@remote_user}@#{@remote_host}:#{@remote_path}", + mount_point_safe + ) + process = Process.new( + "sshfs", + options, + input: STDIN, + output: STDOUT, + error: STDERR + ) + return process.wait + end + end +end + diff --git a/src/parsers/base.cr b/src/parsers/base.cr new file mode 100644 index 0000000..8f3573b --- /dev/null +++ b/src/parsers/base.cr @@ -0,0 +1,5 @@ +module GX::Parsers + abstract class AbstractParser + abstract def build(parser : OptionParser, ancestors : BreadCrumbs, config : Config) + end +end diff --git a/src/parsers/completion_parser.cr b/src/parsers/completion_parser.cr new file mode 100644 index 0000000..1cdc4cc --- /dev/null +++ b/src/parsers/completion_parser.cr @@ -0,0 +1,26 @@ +require "./base.cr" + +module GX::Parsers + class CompletionParser < AbstractParser + def build(parser, ancestors, config) + breadcrumbs = ancestors + "completion" + + parser.banner = Utils.usage_line( + breadcrumbs, + "Manage #{PROGRAM_NAME} completion", + true + ) + parser.separator("\nCompletion commands:") + + parser.on("--bash", "Generate bash completion") do |flag| + Log.info { "Set bash completion" } + end + + parser.on("--zsh", "Generate zsh completion") do |flag| + Log.info { "Set zsh completion" } + end + + parser.separator Utils.help_line(breadcrumbs) + end + end +end diff --git a/src/parsers/config_parser.cr b/src/parsers/config_parser.cr new file mode 100644 index 0000000..b902cd5 --- /dev/null +++ b/src/parsers/config_parser.cr @@ -0,0 +1,38 @@ +require "./options/config_options" +require "./options/config_init_options" +require "./base" +require "../types/modes" +require "../utils/parser_lines" + +module GX::Parsers + class ConfigParser < AbstractParser + def build(parser, ancestors, config) + breadcrumbs = ancestors + "config" + config.config_options = Parsers::Options::ConfigOptions.new + parser.banner = Utils.usage_line( + breadcrumbs, + "Helpers for #{PROGRAM_NAME}'s configuration file", + true + ) + parser.separator("\nConfig commands") + + parser.on("init", "Create initial mfm configuration") do + config.mode = Types::Mode::ConfigInit + config.config_init_options = Parsers::Options::ConfigInitOptions.new + + parser.banner = Utils.usage_line(breadcrumbs + "init", "Create initial mfm configuration") + parser.separator("\nInit options") + + parser.on("-p", "--path", "Set vault encrypted path") do |path| + config.config_init_options.try do |opts| + opts.path = path + end + end + + parser.separator(Utils.help_line(breadcrumbs + "init")) + end + + parser.separator(Utils.help_line(breadcrumbs)) + end + end +end diff --git a/src/parsers/mapping_parser.cr b/src/parsers/mapping_parser.cr new file mode 100644 index 0000000..fc972a9 --- /dev/null +++ b/src/parsers/mapping_parser.cr @@ -0,0 +1,73 @@ +require "./base.cr" +require "../utils/parser_lines" + +module GX::Parsers + class MappingParser < AbstractParser + def build(parser, ancestors, config) + breadcrumbs = ancestors + "mapping" + add_args = {name: "", path: ""} + delete_args = {name: ""} + + parser.banner = Utils.usage_line( + breadcrumbs, + "Manage FUSE filesystem mappings", + true + ) + parser.separator("\nCommands") + + parser.on("list", "List mappings") do + config.mode = Types::Mode::MappingList + parser.separator(Utils.help_line(breadcrumbs + "list")) + # abort("FIXME: Not implemented") + end + + parser.on("create", "Create mapping") do + config.mode = Types::Mode::MappingCreate + + pp parser + parser.banner = Utils.usage_line(breadcrumbs + "create", "Create mapping", true) + parser.separator("\nCreate options") + + parser.on("-n", "--name", "Set vault name") do |name| + add_args = add_args.merge({name: name}) + end + parser.on("-p", "--path", "Set vault encrypted path") do |path| + add_args = add_args.merge({path: path}) + end + parser.separator(Utils.help_line(breadcrumbs + "create")) + end + + parser.on("edit", "Edit configuration") do |flag| + config.mode = Types::Mode::MappingEdit + parser.separator(Utils.help_line(breadcrumbs + "edit")) + # abort("FIXME: Not implemented") + end + + parser.on("mount", "Mount mapping") do |flag| + config.mode = Types::Mode::MappingMount + parser.separator(Utils.help_line(breadcrumbs + "mount")) + # abort("FIXME: Not implemented") + end + + parser.on("umount", "Umount mapping") do |flag| + config.mode = Types::Mode::MappingUmount + parser.separator(Utils.help_line(breadcrumbs + "umount")) + # abort("FIXME: Not implemented") + end + + parser.on("delete", "Delete mapping") do + config.mode = Types::Mode::MappingDelete + + parser.banner = Utils.usage_line(breadcrumbs + "delete", "Delete mapping", true) + parser.separator("\nDelete options") + + parser.on("-n", "--name", "Set vault name") do |name| + delete_args = delete_args.merge({name: name}) + end + parser.separator(Utils.help_line(breadcrumbs + "delete")) + end + + parser.separator Utils.help_line(breadcrumbs) + end + end +end diff --git a/src/parsers/options/config_init_options.cr b/src/parsers/options/config_init_options.cr new file mode 100644 index 0000000..97a31a3 --- /dev/null +++ b/src/parsers/options/config_init_options.cr @@ -0,0 +1,7 @@ +require "option_parser" + +module GX::Parsers::Options + class ConfigInitOptions + property path : String? + end +end diff --git a/src/parsers/options/config_options.cr b/src/parsers/options/config_options.cr new file mode 100644 index 0000000..6cacac0 --- /dev/null +++ b/src/parsers/options/config_options.cr @@ -0,0 +1,6 @@ +require "option_parser" + +module GX::Parsers::Options + class ConfigOptions + end +end diff --git a/src/parsers/options/help_options.cr b/src/parsers/options/help_options.cr new file mode 100644 index 0000000..e81fa2e --- /dev/null +++ b/src/parsers/options/help_options.cr @@ -0,0 +1,7 @@ +require "option_parser" + +module GX::Parsers::Options + class HelpOptions + property parser_snapshot : OptionParser? = nil + end +end diff --git a/src/parsers/root_parser.cr b/src/parsers/root_parser.cr new file mode 100644 index 0000000..70ac5a5 --- /dev/null +++ b/src/parsers/root_parser.cr @@ -0,0 +1,92 @@ +require "./base" +require "./config_parser" +require "./mapping_parser" +require "./completion_parser" +require "../utils/parser_lines" +require "../commands" + +module GX::Parsers + class RootParser < AbstractParser + def build(parser, ancestors, config) + breadcrumbs = ancestors + (File.basename PROGRAM_NAME) + + parser.banner = Utils.usage_line( + breadcrumbs, + "A management tool for your various FUSE filesystems", + true + ) + + parser.on("-c", "--config FILE", "Set configuration file") do |path| + Log.info { "Configuration set to #{path}" } + config.path = path + end + + parser.on("-v", "--verbose", "Set more verbosity") do |flag| + Log.info { "Verbosity enabled" } + config.verbose = true + end + + parser.on("-o", "--open", "Automatically open directory after mount") do |flag| + Log.info { "Auto-open enabled" } + config.auto_open = true + end + + parser.on("--version", "Show version") do |flag| + config.mode = Types::Mode::GlobalVersion + end + + parser.on("-h", "--help", "Show this help") do |flag| + config.mode = Types::Mode::GlobalHelp + config.help_options = Parsers::Options::HelpOptions.new + config.help_options.try { |opts| opts.parser_snapshot = parser.dup } + end + + parser.separator("\nGlobal commands:") + + parser.on("config", "Manage configuration file") do + config.mode = Types::Mode::GlobalHelp + config.help_options = Parsers::Options::HelpOptions.new + config.help_options.try { |opts| opts.parser_snapshot = parser.dup } + + # config.command = Commands::Config.new(config) + Parsers::ConfigParser.new.build(parser, breadcrumbs, config) + end + + parser.on("tui", "Interactive text user interface (default)") do + config.mode = Types::Mode::GlobalTui + end + + parser.on("mapping", "Manage mappings") do + config.mode = Types::Mode::GlobalHelp + config.help_options = Parsers::Options::HelpOptions.new + config.help_options.try { |opts| opts.parser_snapshot = parser.dup } + + Parsers::MappingParser.new.build(parser, breadcrumbs, config) + end + + # parser.on("interactive", "Interactive mapping mount/umount") do + # abort("FIXME: Not implemented") + # end + + parser.on("completion", "Manage completion") do + config.mode = Types::Mode::GlobalCompletion + Parsers::CompletionParser.new.build(parser, breadcrumbs, config) + end + + parser.separator(Utils.help_line(breadcrumbs)) + + # Manage errors + parser.unknown_args do |remaining_args, _| + next if remaining_args.size == 0 + + puts parser + abort("ERROR: Invalid arguments: #{remaining_args.join(" ")}") + end + + parser.invalid_option do |ex| + puts parser + abort("ERROR: Invalid option: '#{ex}'!") + end + end + end +end diff --git a/src/types/modes.cr b/src/types/modes.cr new file mode 100644 index 0000000..6498f89 --- /dev/null +++ b/src/types/modes.cr @@ -0,0 +1,21 @@ +module GX::Types + enum Mode + None + + GlobalVersion + GlobalHelp + GlobalCompletion + GlobalTui + GlobalConfig + GlobalMapping + + ConfigInit + + MappingCreate + MappingDelete + MappingEdit + MappingList + MappingMount + MappingUmount + end +end diff --git a/src/utils/breadcrumbs.cr b/src/utils/breadcrumbs.cr new file mode 100644 index 0000000..89c54e9 --- /dev/null +++ b/src/utils/breadcrumbs.cr @@ -0,0 +1,19 @@ +module GX::Utils + class BreadCrumbs + def initialize(base : Array(String)) + @ancestors = base + end + + def +(elem : String) + b = BreadCrumbs.new(@ancestors + [elem]) + end + + def to_s + @ancestors.join(" ") + end + + def to_a + @ancestors.clone + end + end +end diff --git a/src/fzf.cr b/src/utils/fzf.cr similarity index 92% rename from src/fzf.cr rename to src/utils/fzf.cr index bd1acc2..ff626f3 100644 --- a/src/fzf.cr +++ b/src/utils/fzf.cr @@ -3,9 +3,8 @@ # SPDX-FileCopyrightText: 2023 Glenn Y. Rolland # Copyright © 2023 Glenn Y. Rolland -module GX +module GX::Utils class Fzf - def self.run(list : Array(String)) : String input = IO::Memory.new input.puts list.join("\n") @@ -29,8 +28,7 @@ module GX exit(1) end - result = output.to_s.strip #.split.first? + result = output.to_s.strip # .split.first? end end end - diff --git a/src/utils/parser_lines.cr b/src/utils/parser_lines.cr new file mode 100644 index 0000000..dacd71f --- /dev/null +++ b/src/utils/parser_lines.cr @@ -0,0 +1,17 @@ +require "./breadcrumbs" + +module GX::Utils + def self.usage_line(breadcrumbs : BreadCrumbs, description : String, has_commands : Bool = false) + [ + "Usage: #{breadcrumbs.to_s}#{has_commands ? " [commands]" : ""} [options]", + "", + description, + "", + "Global options:", + ].join("\n") + end + + def self.help_line(breadcrumbs : BreadCrumbs) + "\nRun '#{breadcrumbs.to_s} COMMAND --help' for more information on a command." + end +end diff --git a/src/version.cr b/src/version.cr index 89bd308..0fd98ff 100644 --- a/src/version.cr +++ b/src/version.cr @@ -1,8 +1,5 @@ - require "version_from_shard" module GX VersionFromShard.declare end - - diff --git a/static/completion.bash b/static/completion.bash new file mode 100644 index 0000000..d990879 --- /dev/null +++ b/static/completion.bash @@ -0,0 +1,45 @@ +#!/bin/bash + +# mfm Bash completion script + +_mfm() { + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Options globales pour 'mfm' + local mfm_global_opts="-c --config -v --verbose -o --open --version -h --help" + + # Commandes pour 'mfm' + local mfm_cmds="config mapping completion" + + # Options pour 'config' et 'mapping' + local config_opts="init" + local mapping_opts="list create edit mount umount delete" + + # Ajouter les options globales à chaque cas + case "${prev}" in + mfm) + COMPREPLY=($(compgen -W "${mfm_cmds} ${mfm_global_opts}" -- ${cur})) + return 0 + ;; + config) + COMPREPLY=($(compgen -W "${config_opts} ${mfm_global_opts}" -- ${cur})) + return 0 + ;; + mapping) + COMPREPLY=($(compgen -W "${mapping_opts} ${mfm_global_opts}" -- ${cur})) + return 0 + ;; + *) + if [[ ${cur} == -* ]]; then + COMPREPLY=($(compgen -W "${mfm_global_opts}" -- ${cur})) + fi + return 0 + ;; + esac +} + +# Appliquer la complétion à la fonction 'mfm' +complete -F _mfm mfm