From ce783a63ba6745648291d034cdecc8072d61a557 Mon Sep 17 00:00:00 2001 From: Nuri Yuri Date: Sat, 25 Apr 2026 21:40:37 +0200 Subject: [PATCH 1/5] Prepare plugin manager --- .idea/plugin/PluginManager.md | 65 ++++ .idea/plugin/PluginManager.rb | 536 +++++++++++++++++++++++++++++++ lib/psdk/helpers/900 Yuki__VD.rb | 147 +++++++++ spec/spec_helper.rb | 1 + spec/yuki_vd_spec.rb | 178 ++++++++++ 5 files changed, 927 insertions(+) create mode 100644 .idea/plugin/PluginManager.md create mode 100644 .idea/plugin/PluginManager.rb create mode 100644 lib/psdk/helpers/900 Yuki__VD.rb create mode 100644 spec/yuki_vd_spec.rb diff --git a/.idea/plugin/PluginManager.md b/.idea/plugin/PluginManager.md new file mode 100644 index 0000000..37de7c2 --- /dev/null +++ b/.idea/plugin/PluginManager.md @@ -0,0 +1,65 @@ +## Story + +Source: [Github Issue #10](https://github.com/PokemonWorkshop/psdk-cli/issues/10) + +### Description + +As a developer, I want to use `psdk-plugin` to manage my PSDK plugins (create, test, update) to simplify maintenance. + +> [!NOTE] +> We'll remove the plugin command from PSDK (aside the one for loading the plugin) and psdk cli will be responsible of plugin management (including build). +> It'll accept both workflow: +> - Plugin inside a PSDK project (meaning the resources are expected to be at the root of the project and scripts at the root of the plugin) +> - Plugin outside a PSDK project (meaning both resources and scripts are expected to be at the root of the plugin) +> The definition file remain the same for both workflow + +### Acceptance criteria + +- [ ] `psdk-plugin` exposes the same arguments as the original plugin manager. +- [ ] The command detects the project or switches to standalone mode. +- [ ] A clear message lists available actions (list, build). + +## Information about `PluginManager.rb` + +This file depends on Yuki::VD (defined in `lib/psdk/helpers/900 Yuki__VD.rb`) + +This file was taken out of Pokémon SDK and currently is only working in the context of a PSDK project. + +In the psdk-cli I want the following commands: + +- `PluginManager.start(:build, 'plugin_name')` +- `PluginManager.start(:list)` + +The `PluginManager.start(:load)` should never work in the psdk-cli as it's supposed to run in a game not in the CLI. + +Currently, the build process expects the following structure: + +- `scripts/{plugin_name}/config.yml` (plugin configuration file) +- `scripts/{plugin_name}/script/**/*.rb` (scripts, automatic) +- `graphics/**/*.*` (added_file) +- `Data/**/*.*` (added_files) +- `audio/**/*.*` (added_files) + +I want this process to be preserved if psdk-cli is running in a PSDK project (`Configuration.project_path != nil`). And if I'm not in a project (or a --no-psdk-project flag is set). I want the following structure so developing plugins is easier: + +- `config.yml` (plugin configuration file) +- `scripts/**/*.rb` (scripts, automatic) +- `graphics/**/*.*` (added_file) +- `Data/**/*.*` (added_files) +- `audio/**/*.*` (added_files) + +In this structure, it's assumed that we are inside `{plugin_name}` (if we use `.` as plugin directory we should skip the plugin_name argument of `PluginManager.start(:build)` since the name is also inside `config.yml`) + +## Task to perform + +1. Explode `PluginManager.rb` in several ruby scripts that are able to perform the following operations: + - `list` all the plugins of current project (should fail if not in a project) + - `build` a plugin (name is optional, if not provided and not in a project, it's `.`) +2. Rewrite the Build command so it's + 1. Verbose (explaining each stuff it does and where it is) + 2. Able to work outside of a psdk project (should also state it's not in a PSDK project) +3. Move the exploded PluginManager files to `lib/psdk/helpers/plugin-manager` +4. Write a script in `lib/psdk/cli` to expose the `psdk-cli plugin` cli commands +5. Write a `psdk-plugin` file in `exe` (so I can run `psdk-plugin` instead of `psdk-cli plugin`) +6. Write unit tests in `spec` for Exploded PluginManager files +7. Write unit tests in `spec` for the `psdk-cli plugin` commands diff --git a/.idea/plugin/PluginManager.rb b/.idea/plugin/PluginManager.rb new file mode 100644 index 0000000..bda8aac --- /dev/null +++ b/.idea/plugin/PluginManager.rb @@ -0,0 +1,536 @@ +# This script allow to manage plugins +# +# To get access to this script write: +# ScriptLoader.load_tool('PluginManager') +# +# To load the plugins execute: +# PluginManager.start(:load) +# +# To build a plugin execute: +# PluginManager.start(:build, 'plugin_name') +# +# To list all the plugins that are installed: +# PluginManager.start(:list) +class PluginManager + # Folder containing scripts + SCRIPTS_FOLDER = 'scripts' + # Folder containing the plugin scripts + PLUGIN_SCRIPTS_FOLDER = "#{SCRIPTS_FOLDER}/00000 Plugins" + # File containing plugin information + PLUGIN_INFO_FILE = "#{SCRIPTS_FOLDER}/plugins.dat" + # File extension of plugins + PLUGIN_FILE_EXT = 'psdkplug' + # Create a new PluginManager + # @param type [Symbol] type of management :load, :list or :build + # @param plugin_name [String] name of the plugin to build (if type == :build) + def initialize(type, plugin_name = nil) + @type = type + @plugin_name = plugin_name + end + + # Start the plugin manager + def start + return build_plugin(@plugin_name) if @type == :build + return list_plugins if @type == :list + + load_plugins + end + + private + + # Build a plugin + # @param name [String] name of the plugin + def build_plugin(name) + Builder.new(name).build + end + + # List all the plugins + def list_plugins + @plugins = load_existing_plugins + show_splash(' List of your plugins') + @plugins.each do |plugin| + puts "- \e[34m#{plugin.name}\e[36m v#{plugin.version}\e[37m" + puts " authors: #{plugin.authors.join(', ')}" + end + end + + # Load all the plugins + def load_plugins + @plugin_filenames = Dir[File.join(SCRIPTS_FOLDER, "*.#{PLUGIN_FILE_EXT}")] + # @type [Array] + @old_plugins = load_existing_plugins + return unless need_to_refresh_plugins? + + show_splash + cleanup_plugin_scripts + cleanup_removed_plugins + @plugins = load_all_plugin_data + @plugins.each(&:evaluate_pre_compatibility) + check_dependencies + @plugins.each_with_index { |plugin, index| plugin.extract(index) } + ScriptLoader.load_vscode_scripts(File.expand_path(PLUGIN_SCRIPTS_FOLDER)) + @plugins.each(&:evaluate_post_compatibility) + save_data(@plugins.map(&:config), PLUGIN_INFO_FILE) + end + + # Load the plugins that are already installed + # @return [Array] + def load_existing_plugins + return File.exist?(PLUGIN_INFO_FILE) ? load_data(PLUGIN_INFO_FILE) : [] + end + + # Show the plugin manager splash + # @param reason [String] reason to show the splash + def show_splash(reason = ' Something changed in your plugins! ') + pcc (sep = ''.center(80, '=')), 0x02 + pcc "##{' PSDK Plugin Manager v1.0 '.center(78, ' ')}#", 0x02 + pcc "##{reason.ljust(78, ' ')}#", 0x02 + pcc sep, 0x02 + end + + # Tell if the plugin manager needs to refresh the plugins + # @return [Boolean] + def need_to_refresh_plugins? + return true if @plugin_filenames.size != @old_plugins.size + return true if @old_plugins.any? { |plugin| plugin.psdk_version != PSDK_VERSION } + return true if @old_plugins.any? { |plugin| !@plugin_filenames.include?(PluginManager.filename(plugin)) } + return true if PARGV[:util].include?('plugin') + + return false + end + + # Function that cleans the plugin scripts up + def cleanup_plugin_scripts + glob = File.join(PLUGIN_SCRIPTS_FOLDER, '**', '*') + Dir[glob].select { |f| File.file?(f) }.sort_by { |f| -f.size }.each { |f| File.delete(f) } + Dir[glob].sort_by { |f| -f.size }.each { |f| Dir.delete(f) } + end + + # Function that cleanup the removed plugins + def cleanup_removed_plugins + removed_plugins = @old_plugins.reject { |plugin| @plugin_filenames.include?(PluginManager.filename(plugin)) } + removed_plugins.each { |plugin| cleanup_plugin(plugin) } + end + + # Function that clean a plugin up + # @param plugin [Config] + def cleanup_plugin(plugin) + return if plugin.added_files.empty? + + files_to_remove = plugin.added_files.flat_map { |dirglob| Dir[dirglob] }.sort_by { |f| -f.size } + return if files_to_remove.empty? + + print "The plugin \"#{plugin.name}\" has been removed, it added #{files_to_remove.size}.\nDo you want to remove the files? [Y/N]: " + ans = STDIN.gets.chomp + return unless ans.downcase == 'y' + + files_to_remove.each { |filename| File.delete(filename) } + end + + # Function that load all the plugin data + # @return [Array] + def load_all_plugin_data + return @plugin_filenames.map { |filename| LoadedPlugin.new(filename) } + end + + # Function that checks (and download) dependencies of all plugins + def check_dependencies + to_download = @plugins.flat_map { |plugin| plugin.dependencies_to_download(@plugins) } + to_download.uniq(&:name).each(&:download) + unless to_download.empty? + @plugin_filenames = Dir[File.join(SCRIPTS_FOLDER, "*.#{PLUGIN_FILE_EXT}")] + @plugins = load_all_plugin_data + end + incompatible_plugins = all_incompatible_plugin_message + if incompatible_plugins.any? + pcc 'There\'s plugin incompatibilities!', 0x01 + pcc incompatible_plugins, 0x01 + raise 'Incompatible plugin detected' + end + order_dependencies + end + + # Function that orders the plugins by dependencies + def order_dependencies + @plugins.each { |plugin| plugin.build_dependency_list(@plugins) } + assign_dependency_level_to_plugins + @plugins.sort! do |a, b| + next 1 if a.dependencies.include?(b) + next -1 if b.dependencies.include?(a) + + res = a.dependency_level <=> b.dependency_level + next res if res != 0 + + next a.config.name <=> b.config.name # Ensure that plugin on same level are always on same order + end + end + + # Function that assign the correct dependency level to the plugins + def assign_dependency_level_to_plugins + @plugins.each { |plugin| plugin.dependency_level = 0 if plugin.dependencies.empty? } + level = 0 + plugins_to_assign = @plugins.reject(&:dependency_level) + while plugins_to_assign.any? { |plugin| !plugin.dependency_level } + plugins_to_assign.each do |plugin| + next unless plugin.dependencies.all? { |dependency| dependency.dependency_level && dependency.dependency_level <= level } + + plugin.dependency_level ||= level + 1 + end + level += 1 + plugins_to_assign = plugins_to_assign.reject(&:dependency_level) + end + end + + # List all the message for incompatible plugin + # @return [Array] + def all_incompatible_plugin_message + return @plugins.flat_map { |plugin| plugin.all_incompatible_plugin_message(@plugins) } + end + + class << self + # Function that starts the plugin manager + # @param type [Symbol] type of management :load or :build + # @param plugin_name [String] name of the plugin to build (if type == :build) + def start(type, plugin_name = nil) + new(type, plugin_name).start + end + + # Get the plugin filename from a config + # @param plugin [Config] + def filename(plugin) + File.join(SCRIPTS_FOLDER, "#{plugin.name}.#{PLUGIN_FILE_EXT}") + end + end + + # Plugin configuration + class Config + # Get the plugin name + # @return [String] + attr_accessor :name + # Get the plugin authors + # @return [Array] + attr_accessor :authors + # Get the version of the plugin + # @return [String] + attr_accessor :version + # Get the dependecies or incompatibilities of the plugin + # @return [Array] + attr_accessor :deps + # Get the script that tests if PSDK is compatible with this plugin + # @return [String, nil] + attr_accessor :psdk_compatibility_script + # Tell if the psdk_compatibility_script should be executed after all plugins has been loaded + # @return [Boolean, nil] + attr_accessor :retry_psdk_compatibility_after_plugin_load + # Get the script that tests if the plugin is compatible with other plugins + # @return [String, nil] + attr_accessor :additional_compatibility_script + # Get all the files added by the plugin (in order to compile the plugin / remove files) + # @return [Array] + attr_accessor :added_files + # Get the SHA512 of the plugin (computed after it got compiled) + # @return [String] + attr_accessor :sha512 + # Get the PSDK version the plugin was installed + # @return [Integer] + attr_accessor :psdk_version + end + + # Class describing a loaded plugin (in order to process it) + class LoadedPlugin + # Message for incompatible plugins + INCOMPATIBLE_MESSAGE = '%s is incompatible with %s from v%s to v%s' + # Message shown when a dependency plugin has a bad version + INCOMPATIBLE_VERSION_MESSAGE = '%s depends on plugin %s between v%s & v%s, got v%s' + # Get the plugin configuration + # @return [Config] + attr_reader :config + # Get the dependency list if built + # @return [Array] + attr_reader :dependencies + # Get/Set the dependency level (for sorting) + # @return [Integer] + attr_accessor :dependency_level + + # Create a new loaded plugin + # @param filename [String] + def initialize(filename) + @yuki_vd = Yuki::VD.new(filename, :read) + @config_data = @yuki_vd.read_data("\x00") + # @type [Config] + @config = Marshal.load(@config_data) + @config.psdk_version = PSDK_VERSION + validate_file + end + + # List the dependencies that needs to be downloaded + # @param plugins [Array] + # @return [Array] + def dependencies_to_download(plugins) + hashes = @config.deps.reject { |hash| hash[:incompatible] || plugins.any? { |plugin| plugin.config.name == hash[:name] } } + return hashes.map { |hash| PluginToDownload.new(hash[:name], hash[:url]) } + end + + # List all the incompatible plugins message + # @param plugins [Array] + # @return [Array] + def all_incompatible_plugin_message(plugins) + incompatible = @config.deps.select do |hash| + next false unless hash[:incompatible] + next false unless (ic = plugins.find { |plugin| plugin.config.name == hash[:name] }) + next false unless ic.version_match?(hash[:version_min], hash[:version_max]) + + next true + end + return incompatible.map do |hash| + format(INCOMPATIBLE_MESSAGE, @config.name, hash[:name], hash[:version_min] || '0.0.0.0', hash[:version_max] || 'Infinity') + end + end + + # Tell if the version match + # @param min [String, nil] + # @param max [String, nil] + # @return [Boolean] + def version_match?(min, max) + return false if min && @config.version < min + return false if max && @config.version > max + + return true + end + + # Build the dependency list + # @param plugins [Array] + def build_dependency_list(plugins) + direct_dependency = dependency_from_deps(@config.deps, plugins) + cyclic_dep = direct_dependency.find { |plugin| dependency_of?(plugin) } + raise "Cyclic dependency detected between #{@config.name} & #{cyclic_dep.config.name}" if cyclic_dep + + version_error_message = check_direct_dependency_version(direct_dependency) + if version_error_message.any? + pcc 'There\'s plugin with bad version!', 0x01 + pcc version_error_message, 0x01 + raise 'Incompatible plugin detected' + end + + direct_dependency.concat(direct_dependency.flat_map { |plugin| recursive_dependency(plugin, plugins) }) + @dependencies = direct_dependency.uniq + end + + # Test if a plugin directly depends on this plugin + # @param plugin [LoadedPlugin] + # @return [Boolean] + def dependency_of?(plugin) + plugin.config.deps.any? { |hash| hash[:name] == @config.name } + end + + # Extract the plugin + # @param index [Integer] index of the plugin in the plugin list + def extract(index) + scripts, output_scripts, others, dirnames = list_filename_and_dirnames(index) + dirnames.each { |dirname| Dir.mkdir!(dirname) unless Dir.exist?(dirname) } + + puts "Extracting scripts for #{@config.name} plugin..." if output_scripts.any? + output_scripts.each_with_index do |script_filename, i| + File.write(script_filename, @yuki_vd.read_data(scripts[i]).force_encoding(Encoding::UTF_8)) + end + + puts "Extracting resources of #{@config.name} plugin..." if others.any? + others.each do |filename| + if File.exist?(filename) + puts "Skipping #{filename} (exist)" + next + end + + File.binwrite(filename, @yuki_vd.read_data(filename)) + end + end + + # Test if the plugin is compatible with PSDK + def evaluate_pre_compatibility + return unless (script_to_evaluate = @yuki_vd.read_data("\x01")) + + eval(script_to_evaluate, TOPLEVEL_BINDING) + rescue Exception + pcc "#{@config.name} could not validate compatibility with PSDK", 0x01 + pcc "Reason: #{$!.message}", 0x01 + raise 'Incompatible plugin detected' + end + + # Test if the plugin is compatible with other plugins + def evaluate_post_compatibility + if @config.retry_psdk_compatibility_after_plugin_load && (script_to_evaluate = @yuki_vd.read_data("\x01")) + eval(script_to_evaluate, TOPLEVEL_BINDING) + end + return unless (script_to_evaluate = @yuki_vd.read_data("\x02")) + + eval(script_to_evaluate, TOPLEVEL_BINDING) + rescue Exception + pcc "#{@config.name} could not validate compatibility with other plugins", 0x01 + pcc "Reason: #{$!.message}", 0x01 + raise 'Incompatible plugin detected' + end + + private + + # Create the filename & dirname lists + # @param index [Integer] index of the plugin in the plugin list + # @return [Array>] + def list_filename_and_dirnames(index) + filenames = @yuki_vd.get_filenames.select { |filename| filename.include?('/') } + scripts_folder = "#{SCRIPTS_FOLDER}/" + scripts = filenames.select { |filename| filename.start_with?(scripts_folder) } + others = filenames.reject { |filename| filename.start_with?(scripts_folder) } + plugin_script_folder = format('%s/%05d %s/', folder: PLUGIN_SCRIPTS_FOLDER, index: index, name: @config.name) + output_scripts = scripts.map { |filename| filename.sub(scripts_folder, plugin_script_folder) } + + dirnames = output_scripts.map { |filename| File.dirname(filename) }.uniq + dirnames.concat(others.map { |filename| File.dirname(filename) }.uniq) + return scripts, output_scripts, others, dirnames + end + + # Check if all direct dependencies match the version, if not add message in output about it + # @param direct_dependency [Array] + # @return [Array] + def check_direct_dependency_version(direct_dependency) + bad_deps = @config.deps.reject { |hash| hash[:incompatible] }.select do |hash| + next true unless (dependency = direct_dependency.find { |plugin| plugin.config.name == hash[:name] }) + next true unless dependency.version_match?(hash[:version_min], hash[:version_max]) + + next false + end + + return bad_deps.map do |hash| + dependency_version = direct_dependency.find { |plugin| plugin.config.name == hash[:name] }&.config&.version || 'NotFound' + next format(INCOMPATIBLE_VERSION_MESSAGE, @config.name, + hash[:name], hash[:version_min] || '0.0.0.0', hash[:version_max] || 'Infinity', dependency_version) + end + end + + # Get the recursive dependency of the plugin + # @param plugin [LoadedPlugin] + # @param plugins [Array] + # @return [Array] + def recursive_dependency(plugin, plugins) + next_dependencies = dependency_from_deps(plugin.config.deps, plugins) + return [] if next_dependencies.empty? + + cyclic_dep = next_dependencies.find { |sub_plugin| dependency_of?(sub_plugin) } + raise "Cyclic dependency detected between #{@config.name} & #{cyclic_dep.config.name}" if cyclic_dep + + next_dependencies.concat(next_dependencies.flat_map { |sub_plugin| recursive_dependency(sub_plugin, plugins) }) + return next_dependencies + end + + # Get dependency from deps + # @param deps [Array] + # @param plugins [Array] + # @return [Array] + def dependency_from_deps(deps, plugins) + deps = deps.reject { |hash| hash[:incompatible] } + return plugins.select { |plugin| deps.any? { |hash| hash[:name] == plugin.config.name } } + end + + # Function that validate the plugin file + def validate_file + # @type [IO] + file = @yuki_vd.instance_variable_get(:@file) + file.pos = 0 + content_to_read = file.read(Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - @config_data.bytesize - Yuki::VD::POINTER_SIZE * 2 + sha512 = Digest::SHA512.hexdigest(file.read(content_to_read)) + raise "Plugin #{@config.name} is invalid" if sha512 != @config.sha512 + end + + # Plugin that needs to be downloaded + class PluginToDownload + # Name of the plugin + # @return [String] + attr_reader :name + # Url of the plugin + # @return [String] + attr_reader :url + + # Create a new plugin to download + # @param name [String] name of the plugin + # @param url [String] url of the plugin + def initialize(name, url) + @name = name + @url = url + end + + # Download the plugin + def download + puts "Downloading #{name}..." + data = Net::HTTP.get(URI(@url)) + File.binwrite(PluginManager.filename(self), data) + puts 'Done!' + end + end + end + + # Class responsive of building plugins + class Builder + # Create a new plugin builder + # @param name [String] name of the plugin in scripts/ + def initialize(name) + @name = name + end + + # Start the building process + def build + puts "Building #{@name}..." + @config = load_plugin_configuration + @yuki_vd = Yuki::VD.new(plugin_filename = "#{PluginManager.filename(@config)}.tmp", :write) + add_scripts + add_files + add_testers + @yuki_vd.close + filesize = File.binread(plugin_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE + filedata = File.binread(plugin_filename, filesize, Yuki::VD::POINTER_SIZE) + @config.sha512 = Digest::SHA512.hexdigest(filedata) + @yuki_vd = Yuki::VD.new(plugin_filename, :update) + @yuki_vd.write_data("\x00", Marshal.dump(@config)) + @yuki_vd.close + File.rename(plugin_filename, plugin_filename.sub('.tmp', '')) + puts 'Done!' + end + + private + + # Function that adds all the scripts for the plugin + def add_scripts + basedir = "#{SCRIPTS_FOLDER}/#{@name}/" + scripts = Dir[File.join(basedir, SCRIPTS_FOLDER, '**', '*.rb')] + scripts.each do |filename| + script = File.read(filename) + @yuki_vd.write_data(filename.sub(basedir, ''), script) + end + end + + # Function that add all the files for the plugin + def add_files + filenames = @config.added_files.flat_map { |dirspec| Dir[dirspec] } + filenames.each do |filename| + data = File.binread(filename) + @yuki_vd.write_data(filename, data) + end + end + + # Function that adds the compatibility test script + def add_testers + if @config.psdk_compatibility_script + data = File.read(File.join(SCRIPTS_FOLDER, @name, @config.psdk_compatibility_script)) + @yuki_vd.write_data("\x01", data) + end + if @config.additional_compatibility_script + data = File.read(File.join(SCRIPTS_FOLDER, @name, @config.additional_compatibility_script)) + @yuki_vd.write_data("\x02", data) + end + end + + # Load the plugin configuration + # @return [Config] + def load_plugin_configuration + YAML.unsafe_load(File.read(File.join(SCRIPTS_FOLDER, @name, 'config.yml'))) + end + end +end diff --git a/lib/psdk/helpers/900 Yuki__VD.rb b/lib/psdk/helpers/900 Yuki__VD.rb new file mode 100644 index 0000000..b1a3b91 --- /dev/null +++ b/lib/psdk/helpers/900 Yuki__VD.rb @@ -0,0 +1,147 @@ +# rubocop:disable all +module Yuki + # Class that helps to read Virtual Directories + # + # In reading mode, the Virtual Directories can be loaded to RAM if MAX_SIZE >= VD.size + # + # All the filenames inside the Yuki::VD has to be downcased filename in utf-8 + # + # Note : Encryption is up to the developper and no longer supported on the basic script + class VD + # @return [String] the filename of the current Yuki::VD + attr_reader :filename + + # Is the debug info on ? + DEBUG_ON = ARGV.include?('debug-yuki-vd') + # The max size of the file that can be loaded in memory + MAX_SIZE = 10 * 1024 * 1024 # 10Mo + # List of allowed modes + ALLOWED_MODES = %i[read write update] + # Size of the pointer at the begin of the file + POINTER_SIZE = 4 + # Unpack method of the pointer at the begin of the file + UNPACK_METHOD = 'L' + # Create a new Yuki::VD file or load it + # @param filename [String] name of the Yuki::VD file + # @param mode [:read, :write, :update] if we read or write the virtual directory + def initialize(filename, mode) + @mode = mode = fix_mode(mode) + @filename = filename + send("initialize_#{mode}") + end + + # Read a file data from the VD + # @param filename [String] the file we want to read its data + # @return [String, nil] the data of the file + def read_data(filename) + return nil unless @file + + pos = @hash[filename] + return nil unless pos + + @file.pos = pos + size = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + return @file.read(size) + end + + # Test if a file exists in the VD + # @param filename [String] + # @return [Boolean] + def exists?(filename) + @hash[filename] != nil + end + + # Write a file with its data in the VD + # @param filename [String] the file name + # @param data [String] the data of the file + def write_data(filename, data) + return unless @file + + @hash[filename] = @file.pos + @file.write([data.bytesize].pack(UNPACK_METHOD)) + @file.write(data) + end + + # Add a file to the Yuki::VD + # @param filename [String] the file name + # @param ext_name [String, nil] the file extension + def add_file(filename, ext_name = nil) + sub_filename = ext_name ? "#{filename}.#{ext_name}" : filename + write_data(filename, File.binread(sub_filename)) + end + + # Get all the filename + # @return [Array] + def get_filenames + @hash.keys + end + + # Close the VD + def close + return unless @file + + if @mode != :read + pos = [@file.pos].pack(UNPACK_METHOD) + @file.write(Marshal.dump(@hash)) + @file.pos = 0 + @file.write(pos) + end + @file.close + @file = nil + end + + private + + # Initialize the Yuki::VD in read mode + def initialize_read + @file = File.new(filename, 'rb') + pos = @file.pos = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + @hash = Marshal.load(@file) + load_whole_file(pos) if pos < MAX_SIZE + rescue Errno::ENOENT + @file = nil + @hash = {} + log_error(format('%s not found', filename: filename)) if DEBUG_ON + end + + # Load the VD in the memory + # @param size [Integer] size of the VD memory + def load_whole_file(size) + @file.pos = 0 + data = @file.read(size) + @file.close + @file = StringIO.new(data, 'rb+') + @file.pos = 0 + end + + # Initialize the Yuki::VD in write mode + def initialize_write + @file = File.new(filename, 'wb') + @file.pos = POINTER_SIZE + @hash = {} + end + + # Initialize the Yuki::VD in update mode + def initialize_update + @file = File.new(filename, 'rb+') + pos = @file.pos = @file.read(POINTER_SIZE).unpack1(UNPACK_METHOD) + @hash = Marshal.load(@file) + @file.pos = pos + end + + # Fix the input mode in case it's a String + # @param mode [Symbol, String] + # @return [Symbol] one of the value of ALLOWED_MODES + def fix_mode(mode) + return mode if ALLOWED_MODES.include?(mode) + + r = (mode = mode.downcase).include?('r') + w = mode.include?('w') + plus = mode.include?('+') + return :update if plus || (r && w) + return :read if r + + return :write + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 54faba4..1ae9bd3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,6 +4,7 @@ require 'psdk/cli/configuration' require 'psdk/helpers/studio' require 'psdk/helpers/version' +require 'psdk/helpers/900 Yuki__VD' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/yuki_vd_spec.rb b/spec/yuki_vd_spec.rb new file mode 100644 index 0000000..d1e5525 --- /dev/null +++ b/spec/yuki_vd_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require_relative '../lib/psdk/helpers/900 Yuki__VD' + +RSpec.describe Yuki::VD do # rubocop:disable Metrics/BlockLength + let(:vd_filename) { 'test.vd' } + let(:file_double) { instance_double(File) } + let(:string_io_double) { instance_double(StringIO) } + let(:pointer_size) { 4 } + # MAX_SIZE is 10MB + let(:max_size) { 10 * 1024 * 1024 } + + before do + # Mock File class methods + allow(File).to receive(:new).and_return(file_double) + allow(File).to receive(:binread) + allow(File).to receive(:exist?).and_return(false) + allow(File).to receive(:delete) + allow(StringIO).to receive(:new).and_return(string_io_double) + + # Mock File instance methods + allow(file_double).to receive(:pos=) + allow(file_double).to receive(:pos).and_return(0) + allow(file_double).to receive(:read) + allow(file_double).to receive(:write) + allow(file_double).to receive(:close) + allow(string_io_double).to receive(:pos=) + allow(string_io_double).to receive(:pos).and_return(0) + allow(string_io_double).to receive(:read) + allow(string_io_double).to receive(:write) + allow(string_io_double).to receive(:close) + + # Mock Marshal since it interacts with the file + allow(Marshal).to receive(:dump).and_return('marshaled_data') + allow(Marshal).to receive(:load).and_return({}) + end + + context 'when writing a new VD' do # rubocop:disable Metrics/BlockLength + it 'creates a file with write mode' do + Yuki::VD.new(vd_filename, :write) + expect(File).to have_received(:new).with(vd_filename, 'wb') + end + + it 'writes data correctly' do + vd = Yuki::VD.new(vd_filename, :write) + filename = 'test.txt' + data = 'content' + + # Mock pos to return a specific offset for the file + allow(file_double).to receive(:pos).and_return(123) + + vd.write_data(filename, data) + + expect(file_double).to have_received(:write).with([data.bytesize].pack('L')) + expect(file_double).to have_received(:write).with(data) + end + + it 'writes header and hash on close' do + vd = Yuki::VD.new(vd_filename, :write) + filename = 'test.txt' + data = 'content' + + # Position of the file when attempting to write data + allow(file_double).to receive(:pos).and_return(250) + vd.write_data(filename, data) + + # Position of the file when closing + allow(file_double).to receive(:pos).and_return(500) + vd.close + + expect(Marshal).to have_received(:dump).with({ filename => 250 }) + expect(file_double).to have_received(:write).with('marshaled_data') + expect(file_double).to have_received(:pos=).with(0) + expect(file_double).to have_received(:write).with([500].pack('L')) + expect(file_double).to have_received(:close) + end + + it 'adds a file using File.binread' do + vd = Yuki::VD.new(vd_filename, :write) + allow(File).to receive(:binread).with('external.txt').and_return('external content') + + vd.add_file('external.txt') + + expect(File).to have_received(:binread).with('external.txt') + expect(file_double).to have_received(:write).with('external content') + end + end + + context 'when reading an existing VD' do # rubocop:disable Metrics/BlockLength + let(:fake_pointer) { 100 } + + before do + # Setup for read mode: read pointer, then Marshal.load + allow(file_double).to receive(:read).with(pointer_size).and_return([fake_pointer].pack('L')) + allow(Marshal).to receive(:load).and_return({ 'test.txt' => 50 }) + end + + it 'opens the file in read mode' do + # We need to ensure load_whole_file is NOT called for this test to keep using file_double + # If fake_pointer < MAX_SIZE, it loads whole file. + # Let's set fake_pointer to MAX_SIZE + 1 to avoid loading into memory for this test + allow(file_double).to receive(:read).with(pointer_size).and_return([max_size + 1].pack('L')) + + Yuki::VD.new(vd_filename, :read) + expect(File).to have_received(:new).with(vd_filename, 'rb') + end + + it 'reads data from the file' do + content = 'file content' + size = content.bytesize + + # Avoid load_whole_file + allow(file_double).to receive(:read).with(pointer_size).and_return( + [max_size + 1].pack('L'), # First call in initialize + [size].pack('L') # Second call in read_data (size) + ) + + # Ensure read(size) returns content + allow(file_double).to receive(:read).with(size).and_return(content) + + vd = Yuki::VD.new(vd_filename, :read) + result = vd.read_data('test.txt') + + expect(file_double).to have_received(:pos=).with(50) + expect(result).to eq(content) + end + + it 'loads into memory (StringIO) if file is small' do + # fake_pointer is 100, which is < MAX_SIZE + # It will call load_whole_file(100) + allow(file_double).to receive(:read).with(pointer_size).and_return([100].pack('L')) + allow(file_double).to receive(:read).with(100).and_return('whole file content') + + vd = Yuki::VD.new(vd_filename, :read) + + # Verify it read the whole file and closed the original file handle + expect(file_double).to have_received(:read).with(100) + expect(file_double).to have_received(:close) + + # Verify @file is now a StringIO + expect(vd.instance_variable_get(:@file)).to eq(string_io_double) + end + end + + context 'when updating a VD' do + let(:fake_pointer) { 200 } + + before do + allow(file_double).to receive(:read).with(pointer_size).and_return([fake_pointer].pack('L')) + allow(Marshal).to receive(:load).and_return({}) + end + + it 'opens the file in update mode' do + Yuki::VD.new(vd_filename, :update) + expect(File).to have_received(:new).with(vd_filename, 'rb+') + end + + it 'restores position after loading hash' do + Yuki::VD.new(vd_filename, :update) + # pos= is called twice: once to store return value of read, once to restore position + expect(file_double).to have_received(:pos=).with(fake_pointer).at_least(:once) + end + + it 'writes new data and updates hash on close' do + vd = Yuki::VD.new(vd_filename, :update) + + # Simulate adding a file + allow(file_double).to receive(:pos).and_return(300) + vd.write_data('new.txt', 'data') + + vd.close + + # Should write updated hash and new pointer + expect(Marshal).to have_received(:dump).with({ 'new.txt' => 300 }) + expect(file_double).to have_received(:write).with([300].pack('L')) + end + end +end From c68333c8583ecf3ed18f86138c4b29fe7837db8f Mon Sep 17 00:00:00 2001 From: Nuri Yuri Date: Sun, 26 Apr 2026 09:38:30 +0200 Subject: [PATCH 2/5] Commit work from Antigravity --- .idea/plugin/PluginManager.rb | 536 --------------------- Gemfile.lock | 2 +- lib/psdk/cli/plugin.rb | 33 +- lib/psdk/cli/version.rb | 2 +- lib/psdk/helpers/plugin_manager.rb | 27 ++ lib/psdk/helpers/plugin_manager/builder.rb | 165 +++++++ lib/psdk/helpers/plugin_manager/config.rb | 41 ++ lib/psdk/helpers/plugin_manager/list.rb | 53 ++ spec/psdk/cli/plugin_spec.rb | 86 ++++ spec/psdk/helpers/plugin_manager_spec.rb | 98 ++++ 10 files changed, 501 insertions(+), 542 deletions(-) delete mode 100644 .idea/plugin/PluginManager.rb create mode 100644 lib/psdk/helpers/plugin_manager.rb create mode 100644 lib/psdk/helpers/plugin_manager/builder.rb create mode 100644 lib/psdk/helpers/plugin_manager/config.rb create mode 100644 lib/psdk/helpers/plugin_manager/list.rb create mode 100644 spec/psdk/cli/plugin_spec.rb create mode 100644 spec/psdk/helpers/plugin_manager_spec.rb diff --git a/.idea/plugin/PluginManager.rb b/.idea/plugin/PluginManager.rb deleted file mode 100644 index bda8aac..0000000 --- a/.idea/plugin/PluginManager.rb +++ /dev/null @@ -1,536 +0,0 @@ -# This script allow to manage plugins -# -# To get access to this script write: -# ScriptLoader.load_tool('PluginManager') -# -# To load the plugins execute: -# PluginManager.start(:load) -# -# To build a plugin execute: -# PluginManager.start(:build, 'plugin_name') -# -# To list all the plugins that are installed: -# PluginManager.start(:list) -class PluginManager - # Folder containing scripts - SCRIPTS_FOLDER = 'scripts' - # Folder containing the plugin scripts - PLUGIN_SCRIPTS_FOLDER = "#{SCRIPTS_FOLDER}/00000 Plugins" - # File containing plugin information - PLUGIN_INFO_FILE = "#{SCRIPTS_FOLDER}/plugins.dat" - # File extension of plugins - PLUGIN_FILE_EXT = 'psdkplug' - # Create a new PluginManager - # @param type [Symbol] type of management :load, :list or :build - # @param plugin_name [String] name of the plugin to build (if type == :build) - def initialize(type, plugin_name = nil) - @type = type - @plugin_name = plugin_name - end - - # Start the plugin manager - def start - return build_plugin(@plugin_name) if @type == :build - return list_plugins if @type == :list - - load_plugins - end - - private - - # Build a plugin - # @param name [String] name of the plugin - def build_plugin(name) - Builder.new(name).build - end - - # List all the plugins - def list_plugins - @plugins = load_existing_plugins - show_splash(' List of your plugins') - @plugins.each do |plugin| - puts "- \e[34m#{plugin.name}\e[36m v#{plugin.version}\e[37m" - puts " authors: #{plugin.authors.join(', ')}" - end - end - - # Load all the plugins - def load_plugins - @plugin_filenames = Dir[File.join(SCRIPTS_FOLDER, "*.#{PLUGIN_FILE_EXT}")] - # @type [Array] - @old_plugins = load_existing_plugins - return unless need_to_refresh_plugins? - - show_splash - cleanup_plugin_scripts - cleanup_removed_plugins - @plugins = load_all_plugin_data - @plugins.each(&:evaluate_pre_compatibility) - check_dependencies - @plugins.each_with_index { |plugin, index| plugin.extract(index) } - ScriptLoader.load_vscode_scripts(File.expand_path(PLUGIN_SCRIPTS_FOLDER)) - @plugins.each(&:evaluate_post_compatibility) - save_data(@plugins.map(&:config), PLUGIN_INFO_FILE) - end - - # Load the plugins that are already installed - # @return [Array] - def load_existing_plugins - return File.exist?(PLUGIN_INFO_FILE) ? load_data(PLUGIN_INFO_FILE) : [] - end - - # Show the plugin manager splash - # @param reason [String] reason to show the splash - def show_splash(reason = ' Something changed in your plugins! ') - pcc (sep = ''.center(80, '=')), 0x02 - pcc "##{' PSDK Plugin Manager v1.0 '.center(78, ' ')}#", 0x02 - pcc "##{reason.ljust(78, ' ')}#", 0x02 - pcc sep, 0x02 - end - - # Tell if the plugin manager needs to refresh the plugins - # @return [Boolean] - def need_to_refresh_plugins? - return true if @plugin_filenames.size != @old_plugins.size - return true if @old_plugins.any? { |plugin| plugin.psdk_version != PSDK_VERSION } - return true if @old_plugins.any? { |plugin| !@plugin_filenames.include?(PluginManager.filename(plugin)) } - return true if PARGV[:util].include?('plugin') - - return false - end - - # Function that cleans the plugin scripts up - def cleanup_plugin_scripts - glob = File.join(PLUGIN_SCRIPTS_FOLDER, '**', '*') - Dir[glob].select { |f| File.file?(f) }.sort_by { |f| -f.size }.each { |f| File.delete(f) } - Dir[glob].sort_by { |f| -f.size }.each { |f| Dir.delete(f) } - end - - # Function that cleanup the removed plugins - def cleanup_removed_plugins - removed_plugins = @old_plugins.reject { |plugin| @plugin_filenames.include?(PluginManager.filename(plugin)) } - removed_plugins.each { |plugin| cleanup_plugin(plugin) } - end - - # Function that clean a plugin up - # @param plugin [Config] - def cleanup_plugin(plugin) - return if plugin.added_files.empty? - - files_to_remove = plugin.added_files.flat_map { |dirglob| Dir[dirglob] }.sort_by { |f| -f.size } - return if files_to_remove.empty? - - print "The plugin \"#{plugin.name}\" has been removed, it added #{files_to_remove.size}.\nDo you want to remove the files? [Y/N]: " - ans = STDIN.gets.chomp - return unless ans.downcase == 'y' - - files_to_remove.each { |filename| File.delete(filename) } - end - - # Function that load all the plugin data - # @return [Array] - def load_all_plugin_data - return @plugin_filenames.map { |filename| LoadedPlugin.new(filename) } - end - - # Function that checks (and download) dependencies of all plugins - def check_dependencies - to_download = @plugins.flat_map { |plugin| plugin.dependencies_to_download(@plugins) } - to_download.uniq(&:name).each(&:download) - unless to_download.empty? - @plugin_filenames = Dir[File.join(SCRIPTS_FOLDER, "*.#{PLUGIN_FILE_EXT}")] - @plugins = load_all_plugin_data - end - incompatible_plugins = all_incompatible_plugin_message - if incompatible_plugins.any? - pcc 'There\'s plugin incompatibilities!', 0x01 - pcc incompatible_plugins, 0x01 - raise 'Incompatible plugin detected' - end - order_dependencies - end - - # Function that orders the plugins by dependencies - def order_dependencies - @plugins.each { |plugin| plugin.build_dependency_list(@plugins) } - assign_dependency_level_to_plugins - @plugins.sort! do |a, b| - next 1 if a.dependencies.include?(b) - next -1 if b.dependencies.include?(a) - - res = a.dependency_level <=> b.dependency_level - next res if res != 0 - - next a.config.name <=> b.config.name # Ensure that plugin on same level are always on same order - end - end - - # Function that assign the correct dependency level to the plugins - def assign_dependency_level_to_plugins - @plugins.each { |plugin| plugin.dependency_level = 0 if plugin.dependencies.empty? } - level = 0 - plugins_to_assign = @plugins.reject(&:dependency_level) - while plugins_to_assign.any? { |plugin| !plugin.dependency_level } - plugins_to_assign.each do |plugin| - next unless plugin.dependencies.all? { |dependency| dependency.dependency_level && dependency.dependency_level <= level } - - plugin.dependency_level ||= level + 1 - end - level += 1 - plugins_to_assign = plugins_to_assign.reject(&:dependency_level) - end - end - - # List all the message for incompatible plugin - # @return [Array] - def all_incompatible_plugin_message - return @plugins.flat_map { |plugin| plugin.all_incompatible_plugin_message(@plugins) } - end - - class << self - # Function that starts the plugin manager - # @param type [Symbol] type of management :load or :build - # @param plugin_name [String] name of the plugin to build (if type == :build) - def start(type, plugin_name = nil) - new(type, plugin_name).start - end - - # Get the plugin filename from a config - # @param plugin [Config] - def filename(plugin) - File.join(SCRIPTS_FOLDER, "#{plugin.name}.#{PLUGIN_FILE_EXT}") - end - end - - # Plugin configuration - class Config - # Get the plugin name - # @return [String] - attr_accessor :name - # Get the plugin authors - # @return [Array] - attr_accessor :authors - # Get the version of the plugin - # @return [String] - attr_accessor :version - # Get the dependecies or incompatibilities of the plugin - # @return [Array] - attr_accessor :deps - # Get the script that tests if PSDK is compatible with this plugin - # @return [String, nil] - attr_accessor :psdk_compatibility_script - # Tell if the psdk_compatibility_script should be executed after all plugins has been loaded - # @return [Boolean, nil] - attr_accessor :retry_psdk_compatibility_after_plugin_load - # Get the script that tests if the plugin is compatible with other plugins - # @return [String, nil] - attr_accessor :additional_compatibility_script - # Get all the files added by the plugin (in order to compile the plugin / remove files) - # @return [Array] - attr_accessor :added_files - # Get the SHA512 of the plugin (computed after it got compiled) - # @return [String] - attr_accessor :sha512 - # Get the PSDK version the plugin was installed - # @return [Integer] - attr_accessor :psdk_version - end - - # Class describing a loaded plugin (in order to process it) - class LoadedPlugin - # Message for incompatible plugins - INCOMPATIBLE_MESSAGE = '%s is incompatible with %s from v%s to v%s' - # Message shown when a dependency plugin has a bad version - INCOMPATIBLE_VERSION_MESSAGE = '%s depends on plugin %s between v%s & v%s, got v%s' - # Get the plugin configuration - # @return [Config] - attr_reader :config - # Get the dependency list if built - # @return [Array] - attr_reader :dependencies - # Get/Set the dependency level (for sorting) - # @return [Integer] - attr_accessor :dependency_level - - # Create a new loaded plugin - # @param filename [String] - def initialize(filename) - @yuki_vd = Yuki::VD.new(filename, :read) - @config_data = @yuki_vd.read_data("\x00") - # @type [Config] - @config = Marshal.load(@config_data) - @config.psdk_version = PSDK_VERSION - validate_file - end - - # List the dependencies that needs to be downloaded - # @param plugins [Array] - # @return [Array] - def dependencies_to_download(plugins) - hashes = @config.deps.reject { |hash| hash[:incompatible] || plugins.any? { |plugin| plugin.config.name == hash[:name] } } - return hashes.map { |hash| PluginToDownload.new(hash[:name], hash[:url]) } - end - - # List all the incompatible plugins message - # @param plugins [Array] - # @return [Array] - def all_incompatible_plugin_message(plugins) - incompatible = @config.deps.select do |hash| - next false unless hash[:incompatible] - next false unless (ic = plugins.find { |plugin| plugin.config.name == hash[:name] }) - next false unless ic.version_match?(hash[:version_min], hash[:version_max]) - - next true - end - return incompatible.map do |hash| - format(INCOMPATIBLE_MESSAGE, @config.name, hash[:name], hash[:version_min] || '0.0.0.0', hash[:version_max] || 'Infinity') - end - end - - # Tell if the version match - # @param min [String, nil] - # @param max [String, nil] - # @return [Boolean] - def version_match?(min, max) - return false if min && @config.version < min - return false if max && @config.version > max - - return true - end - - # Build the dependency list - # @param plugins [Array] - def build_dependency_list(plugins) - direct_dependency = dependency_from_deps(@config.deps, plugins) - cyclic_dep = direct_dependency.find { |plugin| dependency_of?(plugin) } - raise "Cyclic dependency detected between #{@config.name} & #{cyclic_dep.config.name}" if cyclic_dep - - version_error_message = check_direct_dependency_version(direct_dependency) - if version_error_message.any? - pcc 'There\'s plugin with bad version!', 0x01 - pcc version_error_message, 0x01 - raise 'Incompatible plugin detected' - end - - direct_dependency.concat(direct_dependency.flat_map { |plugin| recursive_dependency(plugin, plugins) }) - @dependencies = direct_dependency.uniq - end - - # Test if a plugin directly depends on this plugin - # @param plugin [LoadedPlugin] - # @return [Boolean] - def dependency_of?(plugin) - plugin.config.deps.any? { |hash| hash[:name] == @config.name } - end - - # Extract the plugin - # @param index [Integer] index of the plugin in the plugin list - def extract(index) - scripts, output_scripts, others, dirnames = list_filename_and_dirnames(index) - dirnames.each { |dirname| Dir.mkdir!(dirname) unless Dir.exist?(dirname) } - - puts "Extracting scripts for #{@config.name} plugin..." if output_scripts.any? - output_scripts.each_with_index do |script_filename, i| - File.write(script_filename, @yuki_vd.read_data(scripts[i]).force_encoding(Encoding::UTF_8)) - end - - puts "Extracting resources of #{@config.name} plugin..." if others.any? - others.each do |filename| - if File.exist?(filename) - puts "Skipping #{filename} (exist)" - next - end - - File.binwrite(filename, @yuki_vd.read_data(filename)) - end - end - - # Test if the plugin is compatible with PSDK - def evaluate_pre_compatibility - return unless (script_to_evaluate = @yuki_vd.read_data("\x01")) - - eval(script_to_evaluate, TOPLEVEL_BINDING) - rescue Exception - pcc "#{@config.name} could not validate compatibility with PSDK", 0x01 - pcc "Reason: #{$!.message}", 0x01 - raise 'Incompatible plugin detected' - end - - # Test if the plugin is compatible with other plugins - def evaluate_post_compatibility - if @config.retry_psdk_compatibility_after_plugin_load && (script_to_evaluate = @yuki_vd.read_data("\x01")) - eval(script_to_evaluate, TOPLEVEL_BINDING) - end - return unless (script_to_evaluate = @yuki_vd.read_data("\x02")) - - eval(script_to_evaluate, TOPLEVEL_BINDING) - rescue Exception - pcc "#{@config.name} could not validate compatibility with other plugins", 0x01 - pcc "Reason: #{$!.message}", 0x01 - raise 'Incompatible plugin detected' - end - - private - - # Create the filename & dirname lists - # @param index [Integer] index of the plugin in the plugin list - # @return [Array>] - def list_filename_and_dirnames(index) - filenames = @yuki_vd.get_filenames.select { |filename| filename.include?('/') } - scripts_folder = "#{SCRIPTS_FOLDER}/" - scripts = filenames.select { |filename| filename.start_with?(scripts_folder) } - others = filenames.reject { |filename| filename.start_with?(scripts_folder) } - plugin_script_folder = format('%s/%05d %s/', folder: PLUGIN_SCRIPTS_FOLDER, index: index, name: @config.name) - output_scripts = scripts.map { |filename| filename.sub(scripts_folder, plugin_script_folder) } - - dirnames = output_scripts.map { |filename| File.dirname(filename) }.uniq - dirnames.concat(others.map { |filename| File.dirname(filename) }.uniq) - return scripts, output_scripts, others, dirnames - end - - # Check if all direct dependencies match the version, if not add message in output about it - # @param direct_dependency [Array] - # @return [Array] - def check_direct_dependency_version(direct_dependency) - bad_deps = @config.deps.reject { |hash| hash[:incompatible] }.select do |hash| - next true unless (dependency = direct_dependency.find { |plugin| plugin.config.name == hash[:name] }) - next true unless dependency.version_match?(hash[:version_min], hash[:version_max]) - - next false - end - - return bad_deps.map do |hash| - dependency_version = direct_dependency.find { |plugin| plugin.config.name == hash[:name] }&.config&.version || 'NotFound' - next format(INCOMPATIBLE_VERSION_MESSAGE, @config.name, - hash[:name], hash[:version_min] || '0.0.0.0', hash[:version_max] || 'Infinity', dependency_version) - end - end - - # Get the recursive dependency of the plugin - # @param plugin [LoadedPlugin] - # @param plugins [Array] - # @return [Array] - def recursive_dependency(plugin, plugins) - next_dependencies = dependency_from_deps(plugin.config.deps, plugins) - return [] if next_dependencies.empty? - - cyclic_dep = next_dependencies.find { |sub_plugin| dependency_of?(sub_plugin) } - raise "Cyclic dependency detected between #{@config.name} & #{cyclic_dep.config.name}" if cyclic_dep - - next_dependencies.concat(next_dependencies.flat_map { |sub_plugin| recursive_dependency(sub_plugin, plugins) }) - return next_dependencies - end - - # Get dependency from deps - # @param deps [Array] - # @param plugins [Array] - # @return [Array] - def dependency_from_deps(deps, plugins) - deps = deps.reject { |hash| hash[:incompatible] } - return plugins.select { |plugin| deps.any? { |hash| hash[:name] == plugin.config.name } } - end - - # Function that validate the plugin file - def validate_file - # @type [IO] - file = @yuki_vd.instance_variable_get(:@file) - file.pos = 0 - content_to_read = file.read(Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - @config_data.bytesize - Yuki::VD::POINTER_SIZE * 2 - sha512 = Digest::SHA512.hexdigest(file.read(content_to_read)) - raise "Plugin #{@config.name} is invalid" if sha512 != @config.sha512 - end - - # Plugin that needs to be downloaded - class PluginToDownload - # Name of the plugin - # @return [String] - attr_reader :name - # Url of the plugin - # @return [String] - attr_reader :url - - # Create a new plugin to download - # @param name [String] name of the plugin - # @param url [String] url of the plugin - def initialize(name, url) - @name = name - @url = url - end - - # Download the plugin - def download - puts "Downloading #{name}..." - data = Net::HTTP.get(URI(@url)) - File.binwrite(PluginManager.filename(self), data) - puts 'Done!' - end - end - end - - # Class responsive of building plugins - class Builder - # Create a new plugin builder - # @param name [String] name of the plugin in scripts/ - def initialize(name) - @name = name - end - - # Start the building process - def build - puts "Building #{@name}..." - @config = load_plugin_configuration - @yuki_vd = Yuki::VD.new(plugin_filename = "#{PluginManager.filename(@config)}.tmp", :write) - add_scripts - add_files - add_testers - @yuki_vd.close - filesize = File.binread(plugin_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE - filedata = File.binread(plugin_filename, filesize, Yuki::VD::POINTER_SIZE) - @config.sha512 = Digest::SHA512.hexdigest(filedata) - @yuki_vd = Yuki::VD.new(plugin_filename, :update) - @yuki_vd.write_data("\x00", Marshal.dump(@config)) - @yuki_vd.close - File.rename(plugin_filename, plugin_filename.sub('.tmp', '')) - puts 'Done!' - end - - private - - # Function that adds all the scripts for the plugin - def add_scripts - basedir = "#{SCRIPTS_FOLDER}/#{@name}/" - scripts = Dir[File.join(basedir, SCRIPTS_FOLDER, '**', '*.rb')] - scripts.each do |filename| - script = File.read(filename) - @yuki_vd.write_data(filename.sub(basedir, ''), script) - end - end - - # Function that add all the files for the plugin - def add_files - filenames = @config.added_files.flat_map { |dirspec| Dir[dirspec] } - filenames.each do |filename| - data = File.binread(filename) - @yuki_vd.write_data(filename, data) - end - end - - # Function that adds the compatibility test script - def add_testers - if @config.psdk_compatibility_script - data = File.read(File.join(SCRIPTS_FOLDER, @name, @config.psdk_compatibility_script)) - @yuki_vd.write_data("\x01", data) - end - if @config.additional_compatibility_script - data = File.read(File.join(SCRIPTS_FOLDER, @name, @config.additional_compatibility_script)) - @yuki_vd.write_data("\x02", data) - end - end - - # Load the plugin configuration - # @return [Config] - def load_plugin_configuration - YAML.unsafe_load(File.read(File.join(SCRIPTS_FOLDER, @name, 'config.yml'))) - end - end -end diff --git a/Gemfile.lock b/Gemfile.lock index 39616f2..b37d2fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - psdk-cli (0.1.0) + psdk-cli (0.1.1) GEM remote: https://rubygems.org/ diff --git a/lib/psdk/cli/plugin.rb b/lib/psdk/cli/plugin.rb index cade952..447bca9 100644 --- a/lib/psdk/cli/plugin.rb +++ b/lib/psdk/cli/plugin.rb @@ -8,10 +8,35 @@ module Cli class Plugin < Thor package_name 'psdk-plugin' - # TODO: remove this and actually implement the plugin cli - desc 'fake ARG1', 'run a fake command with ARG1 as first argument' - def fake(arg1) - puts "Fake called with ARG1=#{arg1}" + desc 'list', 'List all installed plugins in the current PSDK project' + def list + require_relative '../helpers/plugin_manager' + + unless Psdk::Cli::Configuration.get(:local)&.project_path + puts 'Error: You must be inside a PSDK project to use `psdk-plugin list`.' + exit(1) + end + + Psdk::Helpers::PluginManager.list + end + + desc 'build [PLUGIN_NAME]', 'Build a plugin (requires to be at the root of the project or the plugin)' + option :no_psdk_project, type: :boolean, desc: 'Force standalone mode (build outside a PSDK project)' + option :out_dir, type: :string, default: '.', desc: 'Output directory for the generated .psdkplug file' + def build(plugin_name = nil) + require_relative '../helpers/plugin_manager' + + in_project = Psdk::Cli::Configuration.get(:local)&.project_path + in_project = false if options[:no_psdk_project] + + plugin_name = '.' if !in_project && plugin_name.nil? + + unless plugin_name + puts 'Error: You must provide a plugin_name when building inside a PSDK project.' + exit(1) + end + + Psdk::Helpers::PluginManager.build(plugin_name, in_project: in_project, out_dir: options[:out_dir]) end end end diff --git a/lib/psdk/cli/version.rb b/lib/psdk/cli/version.rb index 16ac99e..8dc989b 100644 --- a/lib/psdk/cli/version.rb +++ b/lib/psdk/cli/version.rb @@ -2,6 +2,6 @@ module Psdk module Cli - VERSION = '0.1.0' + VERSION = '0.1.1' end end diff --git a/lib/psdk/helpers/plugin_manager.rb b/lib/psdk/helpers/plugin_manager.rb new file mode 100644 index 0000000..950f327 --- /dev/null +++ b/lib/psdk/helpers/plugin_manager.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'plugin_manager/config' +require_relative 'plugin_manager/list' +require_relative 'plugin_manager/builder' + +module Psdk + module Helpers + # Module handling the plugin commands + module PluginManager + class << self + # List all the plugins installed in the current PSDK project + def list + List.list_plugins + end + + # Build a plugin + # @param plugin_name [String] name of the plugin to build + # @param in_project [Boolean] whether to build using PSDK project structure + # @param out_dir [String] directory to output the compiled plugin + def build(plugin_name, in_project: true, out_dir: '.') + Builder.new(plugin_name, in_project: in_project, out_dir: out_dir).build + end + end + end + end +end diff --git a/lib/psdk/helpers/plugin_manager/builder.rb b/lib/psdk/helpers/plugin_manager/builder.rb new file mode 100644 index 0000000..2a1834b --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/builder.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'yaml' +require 'digest' +require_relative '../900 Yuki__VD' + +module Psdk + module Helpers + module PluginManager + # Class responsible of building plugins + class Builder # rubocop:disable Metrics/ClassLength + PLUGIN_FILE_EXT = 'psdkplug' + SCRIPTS_FOLDER = 'scripts' + + # Create a new plugin builder + # @param plugin_name [String] name of the plugin directory + # @param in_project [Boolean] whether we are building inside a PSDK project + # @param out_dir [String] directory to output the compiled plugin + def initialize(plugin_name, in_project: true, out_dir: '.') + @name = plugin_name + @in_project = in_project + @out_dir = out_dir + end + + # Start the building process + def build # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + puts "--- Starting build for plugin '#{@name}' ---" + if @in_project + puts '[INFO] Operating inside a PSDK project.' + else + puts '[INFO] Operating in standalone mode (outside a PSDK project).' + end + + @config = load_plugin_configuration + plugin_filename = File.join(@out_dir, "#{@config.name}.#{PLUGIN_FILE_EXT}") + tmp_filename = "#{plugin_filename}.tmp" + + puts "Creating temporary plugin file: #{tmp_filename}" + @yuki_vd = Yuki::VD.new(tmp_filename, :write) + + add_scripts + add_files + add_testers + + @yuki_vd.close + + puts 'Computing SHA512 of the generated package...' + filesize = File.binread(tmp_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE + filedata = File.binread(tmp_filename, filesize, Yuki::VD::POINTER_SIZE) + @config.sha512 = Digest::SHA512.hexdigest(filedata) + + puts 'Writing final configuration with SHA512 to package...' + @yuki_vd = Yuki::VD.new(tmp_filename, :update) + @yuki_vd.write_data("\x00", Marshal.dump(@config)) + @yuki_vd.close + + File.rename(tmp_filename, plugin_filename) + puts "\e[32m[SUCCESS]\e[0m Built #{@config.name} at #{plugin_filename}" + end + + private + + # Return the base directory of the plugin source code + def base_dir + if @in_project + File.join(SCRIPTS_FOLDER, @name) + else + @name == '.' ? '.' : @name + end + end + + # Return the directory name used for scripts relative to base_dir + def script_src_dir + # Inside a project, scripts are often in `scripts/{plugin_name}/scripts/**/*.rb` + # Outside, it's `scripts/**/*.rb` + # In both cases, the folder is `scripts` relative to base_dir (or `script` according to some docs, we check both or use 'scripts') # rubocop:disable Layout/LineLength + return 'scripts' + end + + # Function that adds all the scripts for the plugin + def add_scripts # rubocop:disable Metrics/MethodLength + b_dir = base_dir + b_dir_prefix = b_dir == '.' ? '' : "#{b_dir}/" + + search_path = File.join(b_dir, script_src_dir, '**', '*.rb') + search_path = search_path.sub(%r{^/}, '') if search_path.start_with?('/') + + scripts = Dir[search_path] + + puts "Found #{scripts.size} ruby scripts to pack." + scripts.each do |filename| + script = File.read(filename) + internal_path = filename.sub(b_dir_prefix, '') + puts " - Packing script: #{filename} -> #{internal_path}" + @yuki_vd.write_data(internal_path, script) + end + end + + # Function that add all the files for the plugin + def add_files + project_root = @in_project.is_a?(String) ? @in_project : Dir.pwd + + Dir.chdir(project_root) do + filenames = (@config.added_files || []).flat_map { |dirspec| Dir[dirspec] }.select { |f| File.file?(f) } + + puts "Found #{filenames.size} resource files to pack." + filenames.each do |filename| + data = File.binread(filename) + puts " - Packing file: #{filename}" + @yuki_vd.write_data(filename, data) + end + end + end + + # Function that adds the compatibility test script + def add_testers # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + b_dir = base_dir + if @config.psdk_compatibility_script + tester_path = File.join(b_dir, @config.psdk_compatibility_script) + if File.exist?(tester_path) + puts "Adding PSDK compatibility script: #{tester_path}" + data = File.read(tester_path) + @yuki_vd.write_data("\x01", data) + else + puts "[WARNING] PSDK compatibility script not found: #{tester_path}" + end + end + return unless @config.additional_compatibility_script + + tester_path = File.join(b_dir, @config.additional_compatibility_script) + if File.exist?(tester_path) + puts "Adding additional compatibility script: #{tester_path}" + data = File.read(tester_path) + @yuki_vd.write_data("\x02", data) + else + puts "[WARNING] Additional compatibility script not found: #{tester_path}" + end + end + + # Load the plugin configuration + # @return [Psdk::Helpers::PluginManager::Config] + def load_plugin_configuration # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + config_path = File.join(base_dir, 'config.yml') + raise "Configuration file not found at #{config_path}" unless File.exist?(config_path) + + puts "Loading configuration from #{config_path}" + yaml_content = YAML.unsafe_load(File.read(config_path)) + + # Populate Config object + config = Psdk::Helpers::PluginManager::Config.new + config.name = yaml_content['name'] || @name + config.authors = yaml_content['authors'] || [] + config.version = yaml_content['version'] || '1.0.0' + config.deps = yaml_content['deps'] || [] + config.psdk_compatibility_script = yaml_content['psdk_compatibility_script'] + config.retry_psdk_compatibility_after_plugin_load = yaml_content['retry_psdk_compatibility_after_plugin_load'] + config.additional_compatibility_script = yaml_content['additional_compatibility_script'] + config.added_files = yaml_content['added_files'] || [] + + return config + end + end + end + end +end diff --git a/lib/psdk/helpers/plugin_manager/config.rb b/lib/psdk/helpers/plugin_manager/config.rb new file mode 100644 index 0000000..a6a271b --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/config.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Psdk + module Helpers + module PluginManager + # Plugin configuration + class Config + # Get the plugin name + # @return [String] + attr_accessor :name + # Get the plugin authors + # @return [Array] + attr_accessor :authors + # Get the version of the plugin + # @return [String] + attr_accessor :version + # Get the dependecies or incompatibilities of the plugin + # @return [Array] + attr_accessor :deps + # Get the script that tests if PSDK is compatible with this plugin + # @return [String, nil] + attr_accessor :psdk_compatibility_script + # Tell if the psdk_compatibility_script should be executed after all plugins has been loaded + # @return [Boolean, nil] + attr_accessor :retry_psdk_compatibility_after_plugin_load + # Get the script that tests if the plugin is compatible with other plugins + # @return [String, nil] + attr_accessor :additional_compatibility_script + # Get all the files added by the plugin (in order to compile the plugin / remove files) + # @return [Array] + attr_accessor :added_files + # Get the SHA512 of the plugin (computed after it got compiled) + # @return [String] + attr_accessor :sha512 + # Get the PSDK version the plugin was installed + # @return [Integer] + attr_accessor :psdk_version + end + end + end +end diff --git a/lib/psdk/helpers/plugin_manager/list.rb b/lib/psdk/helpers/plugin_manager/list.rb new file mode 100644 index 0000000..dff285d --- /dev/null +++ b/lib/psdk/helpers/plugin_manager/list.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Psdk + module Helpers + module PluginManager + # Module handling the listing of plugins + module List + # Folder containing scripts + SCRIPTS_FOLDER = 'scripts' + # File containing plugin information + PLUGIN_INFO_FILE = "#{SCRIPTS_FOLDER}/plugins.dat".freeze + + class << self + # List all the plugins + def list_plugins + plugins = load_existing_plugins + show_splash(' List of your plugins') + if plugins.empty? + puts 'No plugins installed.' + return + end + + plugins.each do |plugin| + puts "- \e[34m#{plugin.name}\e[36m v#{plugin.version}\e[0m" + puts " authors: #{plugin.authors.join(', ')}" + end + end + + private + + # Load the plugins that are already installed + # @return [Array] + def load_existing_plugins + return File.exist?(PLUGIN_INFO_FILE) ? Marshal.load(File.binread(PLUGIN_INFO_FILE)) : [] # rubocop:disable Security/MarshalLoad + rescue StandardError => e + puts "Failed to load plugins.dat: #{e.message}" + [] + end + + # Show the plugin manager splash + # @param reason [String] reason to show the splash + def show_splash(reason = ' Something changed in your plugins! ') + sep = ''.center(80, '=') + puts "\e[32m#{sep}\e[0m" + puts "\e[32m##{' PSDK Plugin Manager v1.0 '.center(78, ' ')}#\e[0m" + puts "\e[32m##{reason.ljust(78, ' ')}#\e[0m" + puts "\e[32m#{sep}\e[0m" + end + end + end + end + end +end diff --git a/spec/psdk/cli/plugin_spec.rb b/spec/psdk/cli/plugin_spec.rb new file mode 100644 index 0000000..b54943e --- /dev/null +++ b/spec/psdk/cli/plugin_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'psdk/cli/plugin' +require 'psdk/cli/configuration' + +RSpec.describe Psdk::Cli::Plugin do # rubocop:disable Metrics/BlockLength + subject { described_class.new } + + describe '#list' do + context 'when in a PSDK project' do + let(:config_mock) { double('Configuration', project_path: '/path/to/project') } + + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + allow(Psdk::Helpers::PluginManager).to receive(:list) + end + + it 'calls Psdk::Helpers::PluginManager.list' do + expect(Psdk::Helpers::PluginManager).to receive(:list) + subject.list + end + end + + context 'when not in a PSDK project' do + let(:config_mock) { double('Configuration', project_path: nil) } + + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + end + + it 'exits with 1 and prints an error' do + expect($stdout).to receive(:puts).with(/You must be inside a PSDK project/) + expect { subject.list }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) } + end + end + end + + describe '#build' do # rubocop:disable Metrics/BlockLength + let(:config_mock) { double('Configuration', project_path: '/path/to/project') } + + before do + allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + allow(Psdk::Helpers::PluginManager).to receive(:build) + subject.options = { out_dir: '.' } + end + + context 'when in a PSDK project' do + it 'calls Psdk::Helpers::PluginManager.build with plugin name' do + expect(Psdk::Helpers::PluginManager).to( + receive(:build).with('my_plugin', in_project: '/path/to/project', out_dir: '.') + ) + subject.build('my_plugin') + end + + it 'exits with 1 if no plugin_name is provided' do + expect($stdout).to receive(:puts).with(/You must provide a plugin_name/) + expect { subject.build(nil) }.to raise_error(SystemExit) { |e| expect(e.status).to eq(1) } + end + end + + context 'when in standalone mode' do + let(:config_mock) { double('Configuration', project_path: nil) } + + it 'calls Psdk::Helpers::PluginManager.build with default name' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('.', in_project: nil, out_dir: '.') + subject.build(nil) + end + + it 'calls Psdk::Helpers::PluginManager.build with provided name' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('my_plugin', in_project: nil, out_dir: '.') + subject.build('my_plugin') + end + end + + context 'when forcing standalone mode with flag' do + before do + subject.options = { no_psdk_project: true, out_dir: '.' } + end + + it 'builds with in_project: false despite being in a project' do + expect(Psdk::Helpers::PluginManager).to receive(:build).with('my_plugin', in_project: false, out_dir: '.') + subject.build('my_plugin') + end + end + end +end diff --git a/spec/psdk/helpers/plugin_manager_spec.rb b/spec/psdk/helpers/plugin_manager_spec.rb new file mode 100644 index 0000000..679127a --- /dev/null +++ b/spec/psdk/helpers/plugin_manager_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'psdk/helpers/plugin_manager' + +RSpec.describe Psdk::Helpers::PluginManager do + describe '.list' do + it 'calls List.list_plugins' do + expect(Psdk::Helpers::PluginManager::List).to receive(:list_plugins) + described_class.list + end + end + + describe '.build' do + it 'instantiates Builder and calls build' do + builder_mock = instance_double(Psdk::Helpers::PluginManager::Builder) + expect(Psdk::Helpers::PluginManager::Builder).to( + receive(:new).with('my_plugin', in_project: '/path/to/project', out_dir: '.').and_return(builder_mock) + ) + expect(builder_mock).to receive(:build) + + described_class.build('my_plugin', in_project: '/path/to/project', out_dir: '.') + end + end +end + +RSpec.describe Psdk::Helpers::PluginManager::List do + describe '.list_plugins' do + before do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:binread).and_return('mocked_data') + + plugin_mock = Psdk::Helpers::PluginManager::Config.new + plugin_mock.name = 'TestPlugin' + plugin_mock.version = '1.0' + plugin_mock.authors = ['Test Author'] + + allow(Marshal).to receive(:load).and_return([plugin_mock]) + allow($stdout).to receive(:puts) + end + + it 'prints the plugins' do + expect($stdout).to receive(:puts).with(/- \e\[34mTestPlugin\e\[36m v1.0\e\[0m/) + described_class.list_plugins + end + end +end + +RSpec.describe Psdk::Helpers::PluginManager::Builder do # rubocop:disable Metrics/BlockLength + let(:plugin_name) { 'my_plugin' } + subject { described_class.new(plugin_name, in_project: in_project, out_dir: '.') } + + describe '#build' do # rubocop:disable Metrics/BlockLength + let(:yuki_vd_mock) { instance_double(Yuki::VD) } + let(:config_hash) { { 'name' => 'test_plugin', 'version' => '1.0' } } + + before do + allow($stdout).to receive(:puts) + allow(Yuki::VD).to receive(:new).and_return(yuki_vd_mock) + allow(yuki_vd_mock).to receive(:write_data) + allow(yuki_vd_mock).to receive(:close) + + allow(File).to( + receive(:read).with(/config\.yml/).and_return("name: test_plugin\nversion: 1.0\nadded_files:\n - '*'\n") + ) + config_hash_with_files = config_hash.merge('added_files' => ['*.png']) + allow(YAML).to receive(:unsafe_load).and_return(config_hash_with_files) + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:binread).and_return("\x00" * 32) + allow(File).to receive(:rename) + allow(Dir).to receive(:chdir).and_yield + end + + context 'when in a PSDK project' do + let(:in_project) { '/path/to/project' } + + it 'uses project script paths' do + expect(subject).to receive(:add_scripts).and_call_original + expect(Dir).to receive(:[]).with('scripts/my_plugin/scripts/**/*.rb').and_return([]) + expect(Dir).to receive(:[]).with('*.png').and_return([]) # added_files is empty + + subject.build + end + end + + context 'when in standalone mode' do + let(:in_project) { false } + let(:plugin_name) { '.' } + + it 'uses standalone script paths' do + expect(subject).to receive(:add_scripts).and_call_original + expect(Dir).to receive(:[]).with('./scripts/**/*.rb').and_return([]) + expect(Dir).to receive(:[]).with('*.png').and_return([]) # added_files is empty + + subject.build + end + end + end +end From ee739c4f3de2e5219783319f447594f979f824d3 Mon Sep 17 00:00:00 2001 From: Nuri Yuri Date: Sun, 26 Apr 2026 11:34:20 +0200 Subject: [PATCH 3/5] Fix plugin build --- lib/psdk/cli/plugin.rb | 10 +- lib/psdk/helpers/plugin_manager/builder.rb | 102 +++++++++++---------- lib/psdk/helpers/plugin_manager/config.rb | 2 + spec/psdk/helpers/plugin_manager_spec.rb | 11 ++- 4 files changed, 71 insertions(+), 54 deletions(-) diff --git a/lib/psdk/cli/plugin.rb b/lib/psdk/cli/plugin.rb index 447bca9..41cf540 100644 --- a/lib/psdk/cli/plugin.rb +++ b/lib/psdk/cli/plugin.rb @@ -10,9 +10,11 @@ class Plugin < Thor desc 'list', 'List all installed plugins in the current PSDK project' def list + require_relative 'configuration' require_relative '../helpers/plugin_manager' + Psdk::Cli::Configuration.get(:local) - unless Psdk::Cli::Configuration.get(:local)&.project_path + unless Psdk::Cli::Configuration.project_path puts 'Error: You must be inside a PSDK project to use `psdk-plugin list`.' exit(1) end @@ -23,10 +25,12 @@ def list desc 'build [PLUGIN_NAME]', 'Build a plugin (requires to be at the root of the project or the plugin)' option :no_psdk_project, type: :boolean, desc: 'Force standalone mode (build outside a PSDK project)' option :out_dir, type: :string, default: '.', desc: 'Output directory for the generated .psdkplug file' - def build(plugin_name = nil) + def build(plugin_name = nil) # rubocop:disable Metrics/MethodLength + require_relative 'configuration' require_relative '../helpers/plugin_manager' + Psdk::Cli::Configuration.get(:local) - in_project = Psdk::Cli::Configuration.get(:local)&.project_path + in_project = Psdk::Cli::Configuration.project_path in_project = false if options[:no_psdk_project] plugin_name = '.' if !in_project && plugin_name.nil? diff --git a/lib/psdk/helpers/plugin_manager/builder.rb b/lib/psdk/helpers/plugin_manager/builder.rb index 2a1834b..cacb3bb 100644 --- a/lib/psdk/helpers/plugin_manager/builder.rb +++ b/lib/psdk/helpers/plugin_manager/builder.rb @@ -23,7 +23,7 @@ def initialize(plugin_name, in_project: true, out_dir: '.') end # Start the building process - def build # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + def build # rubocop:disable Metrics/MethodLength puts "--- Starting build for plugin '#{@name}' ---" if @in_project puts '[INFO] Operating inside a PSDK project.' @@ -31,31 +31,20 @@ def build # rubocop:disable Metrics/AbcSize,Metrics/MethodLength puts '[INFO] Operating in standalone mode (outside a PSDK project).' end - @config = load_plugin_configuration - plugin_filename = File.join(@out_dir, "#{@config.name}.#{PLUGIN_FILE_EXT}") - tmp_filename = "#{plugin_filename}.tmp" - - puts "Creating temporary plugin file: #{tmp_filename}" - @yuki_vd = Yuki::VD.new(tmp_filename, :write) - - add_scripts - add_files - add_testers - - @yuki_vd.close + project_root = @in_project.is_a?(String) ? @in_project : Dir.pwd + out_dir = File.absolute_path(@out_dir) - puts 'Computing SHA512 of the generated package...' - filesize = File.binread(tmp_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE - filedata = File.binread(tmp_filename, filesize, Yuki::VD::POINTER_SIZE) - @config.sha512 = Digest::SHA512.hexdigest(filedata) + Dir.chdir(project_root) do + @config = load_plugin_configuration + plugin_filename = File.join(out_dir, "#{@config.name}.#{PLUGIN_FILE_EXT}") + tmp_filename = "#{plugin_filename}.tmp" - puts 'Writing final configuration with SHA512 to package...' - @yuki_vd = Yuki::VD.new(tmp_filename, :update) - @yuki_vd.write_data("\x00", Marshal.dump(@config)) - @yuki_vd.close + build_internal(tmp_filename) + compute_sha512(tmp_filename) + write_config_an_rename_file(tmp_filename, plugin_filename) - File.rename(tmp_filename, plugin_filename) - puts "\e[32m[SUCCESS]\e[0m Built #{@config.name} at #{plugin_filename}" + puts "\e[32m[SUCCESS]\e[0m Built #{@config.name} at #{plugin_filename}" + end end private @@ -77,6 +66,19 @@ def script_src_dir return 'scripts' end + # Internal plugin build process + # @param tmp_filename [String] temporary filename of the .psdkplug file (before SHA512 computation) + def build_internal(tmp_filename) + puts "Creating temporary plugin file: #{tmp_filename}" + @yuki_vd = Yuki::VD.new(tmp_filename, :write) + + add_scripts + add_files + add_testers + + @yuki_vd.close + end + # Function that adds all the scripts for the plugin def add_scripts # rubocop:disable Metrics/MethodLength b_dir = base_dir @@ -98,17 +100,13 @@ def add_scripts # rubocop:disable Metrics/MethodLength # Function that add all the files for the plugin def add_files - project_root = @in_project.is_a?(String) ? @in_project : Dir.pwd - - Dir.chdir(project_root) do - filenames = (@config.added_files || []).flat_map { |dirspec| Dir[dirspec] }.select { |f| File.file?(f) } + filenames = (@config.added_files || []).flat_map { |dirspec| Dir[dirspec] }.select { |f| File.file?(f) } - puts "Found #{filenames.size} resource files to pack." - filenames.each do |filename| - data = File.binread(filename) - puts " - Packing file: #{filename}" - @yuki_vd.write_data(filename, data) - end + puts "Found #{filenames.size} resource files to pack." + filenames.each do |filename| + data = File.binread(filename) + puts " - Packing file: #{filename}" + @yuki_vd.write_data(filename, data) end end @@ -139,25 +137,33 @@ def add_testers # rubocop:disable Metrics/AbcSize,Metrics/MethodLength # Load the plugin configuration # @return [Psdk::Helpers::PluginManager::Config] - def load_plugin_configuration # rubocop:disable Metrics/AbcSize,Metrics/MethodLength + def load_plugin_configuration config_path = File.join(base_dir, 'config.yml') raise "Configuration file not found at #{config_path}" unless File.exist?(config_path) puts "Loading configuration from #{config_path}" - yaml_content = YAML.unsafe_load(File.read(config_path)) - - # Populate Config object - config = Psdk::Helpers::PluginManager::Config.new - config.name = yaml_content['name'] || @name - config.authors = yaml_content['authors'] || [] - config.version = yaml_content['version'] || '1.0.0' - config.deps = yaml_content['deps'] || [] - config.psdk_compatibility_script = yaml_content['psdk_compatibility_script'] - config.retry_psdk_compatibility_after_plugin_load = yaml_content['retry_psdk_compatibility_after_plugin_load'] - config.additional_compatibility_script = yaml_content['additional_compatibility_script'] - config.added_files = yaml_content['added_files'] || [] - - return config + return YAML.unsafe_load(File.read(config_path)) + end + + # Compute the SHA512 hash of the temporary psdkplug + # @param tmp_filename [String] filename of the temporary psdkplug + def compute_sha512(tmp_filename) + puts 'Computing SHA512 of the generated package...' + filesize = File.binread(tmp_filename, Yuki::VD::POINTER_SIZE).unpack1(Yuki::VD::UNPACK_METHOD) - Yuki::VD::POINTER_SIZE + filedata = File.binread(tmp_filename, filesize, Yuki::VD::POINTER_SIZE) + @config.sha512 = Digest::SHA512.hexdigest(filedata) + end + + # Write the config of the plugin and rename the temporary file to the final plugin filename + # @param tmp_filename [String] filename of the temporary psdkplug + # @param plugin_filename [String] filename of the final psdkplug + def write_config_an_rename_file(tmp_filename, plugin_filename) + puts 'Writing final configuration with SHA512 to package...' + @yuki_vd = Yuki::VD.new(tmp_filename, :update) + @yuki_vd.write_data("\x00", Marshal.dump(@config)) + @yuki_vd.close + + File.rename(tmp_filename, plugin_filename) end end end diff --git a/lib/psdk/helpers/plugin_manager/config.rb b/lib/psdk/helpers/plugin_manager/config.rb index a6a271b..08b89d8 100644 --- a/lib/psdk/helpers/plugin_manager/config.rb +++ b/lib/psdk/helpers/plugin_manager/config.rb @@ -39,3 +39,5 @@ class Config end end end + +PluginManager = Psdk::Helpers::PluginManager diff --git a/spec/psdk/helpers/plugin_manager_spec.rb b/spec/psdk/helpers/plugin_manager_spec.rb index 679127a..e559517 100644 --- a/spec/psdk/helpers/plugin_manager_spec.rb +++ b/spec/psdk/helpers/plugin_manager_spec.rb @@ -51,7 +51,13 @@ describe '#build' do # rubocop:disable Metrics/BlockLength let(:yuki_vd_mock) { instance_double(Yuki::VD) } - let(:config_hash) { { 'name' => 'test_plugin', 'version' => '1.0' } } + let(:config_obj) do + config = Psdk::Helpers::PluginManager::Config.new + config.name = 'test_plugin' + config.version = '1.0' + config.added_files = ['*.png'] + config + end before do allow($stdout).to receive(:puts) @@ -62,8 +68,7 @@ allow(File).to( receive(:read).with(/config\.yml/).and_return("name: test_plugin\nversion: 1.0\nadded_files:\n - '*'\n") ) - config_hash_with_files = config_hash.merge('added_files' => ['*.png']) - allow(YAML).to receive(:unsafe_load).and_return(config_hash_with_files) + allow(YAML).to receive(:unsafe_load).and_return(config_obj) allow(File).to receive(:exist?).and_return(true) allow(File).to receive(:binread).and_return("\x00" * 32) allow(File).to receive(:rename) From b32df3dff91f956877c146fd7332071008f7a345 Mon Sep 17 00:00:00 2001 From: Nuri Yuri Date: Sun, 26 Apr 2026 11:40:49 +0200 Subject: [PATCH 4/5] Fix spec --- spec/psdk/cli/plugin_spec.rb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/spec/psdk/cli/plugin_spec.rb b/spec/psdk/cli/plugin_spec.rb index b54943e..7aae63d 100644 --- a/spec/psdk/cli/plugin_spec.rb +++ b/spec/psdk/cli/plugin_spec.rb @@ -22,10 +22,9 @@ end context 'when not in a PSDK project' do - let(:config_mock) { double('Configuration', project_path: nil) } - before do - allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + allow(Psdk::Cli::Configuration).to receive(:get).with(:local) + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return(nil) end it 'exits with 1 and prints an error' do @@ -36,10 +35,9 @@ end describe '#build' do # rubocop:disable Metrics/BlockLength - let(:config_mock) { double('Configuration', project_path: '/path/to/project') } - before do - allow(Psdk::Cli::Configuration).to receive(:get).with(:local).and_return(config_mock) + allow(Psdk::Cli::Configuration).to receive(:get).with(:local) + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return('/path/to/project') allow(Psdk::Helpers::PluginManager).to receive(:build) subject.options = { out_dir: '.' } end From dbe4842b374ff71bc97c0966b0200baa701be8f9 Mon Sep 17 00:00:00 2001 From: Nuri Yuri Date: Sun, 26 Apr 2026 11:43:49 +0200 Subject: [PATCH 5/5] Fix spec issues --- spec/psdk/cli/plugin_spec.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/spec/psdk/cli/plugin_spec.rb b/spec/psdk/cli/plugin_spec.rb index 7aae63d..9e388db 100644 --- a/spec/psdk/cli/plugin_spec.rb +++ b/spec/psdk/cli/plugin_spec.rb @@ -37,12 +37,15 @@ describe '#build' do # rubocop:disable Metrics/BlockLength before do allow(Psdk::Cli::Configuration).to receive(:get).with(:local) - allow(Psdk::Cli::Configuration).to receive(:project_path).and_return('/path/to/project') allow(Psdk::Helpers::PluginManager).to receive(:build) subject.options = { out_dir: '.' } end context 'when in a PSDK project' do + before do + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return('/path/to/project') + end + it 'calls Psdk::Helpers::PluginManager.build with plugin name' do expect(Psdk::Helpers::PluginManager).to( receive(:build).with('my_plugin', in_project: '/path/to/project', out_dir: '.') @@ -57,7 +60,9 @@ end context 'when in standalone mode' do - let(:config_mock) { double('Configuration', project_path: nil) } + before do + allow(Psdk::Cli::Configuration).to receive(:project_path).and_return(nil) + end it 'calls Psdk::Helpers::PluginManager.build with default name' do expect(Psdk::Helpers::PluginManager).to receive(:build).with('.', in_project: nil, out_dir: '.')