diff --git a/exe/picop b/exe/ppkg similarity index 83% rename from exe/picop rename to exe/ppkg index 158eb7d..a7d4f33 100755 --- a/exe/picop +++ b/exe/ppkg @@ -4,10 +4,10 @@ lib_path = File.expand_path('../lib', __dir__) $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) -require 'picop' +require 'picopackage' begin - Picop::CLI.run(ARGV) + Picopackage::CLI.run(ARGV) rescue => e warn "Error: #{e.message}" warn e.backtrace if ENV['DEBUG'] diff --git a/lib/picop.rb b/lib/picop.rb deleted file mode 100644 index 784b1a3..0000000 --- a/lib/picop.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require_relative "picop/version" -require_relative "picop/source_file" -require_relative "picop/scanner" -require_relative "picop/cli" - -module Picop - class Error < StandardError; end - # Your code goes here... -end diff --git a/lib/picop/cli.rb b/lib/picop/cli.rb deleted file mode 100644 index b990bbe..0000000 --- a/lib/picop/cli.rb +++ /dev/null @@ -1,88 +0,0 @@ -require "optparse" - -module Picop - class CLI - def self.run(argv = ARGV) - command = argv.shift - case command - when 'scan' - options = {} - OptionParser.new do |opts| - opts.banner = "Usage: picop scan [options] DIRECTORY" - opts.on('-v', '--verbose', 'Run verbosely') { |v| options[:verbose] = v } - end.parse!(argv) - - dir = argv.first || '.' - Picop::Scanner.scan(dir, options) - - when 'sign' - OptionParser.new do |opts| - opts.banner = "Usage: picop sign FILE" - end.parse!(argv) - - file = argv.first - Picop::SourceFile.new(file).sign - - when 'checksum' - OptionParser.new do |opts| - opts.banner = "Usage: picop checksum FILE" - end.parse!(argv) - file = argv.first - puts Picop::SourceFile.new(file).checksum - - when 'verify' - OptionParser.new do |opts| - opts.banner = "Usage: picop sign FILE" - end.parse!(argv) - - path = argv.first - source = SourceFile.new(path) - - if source.metadata['content_checksum'].nil? - puts "⚠️ No checksum found in #{path}" - puts "Run 'picop sign #{path}' to add one" - exit 1 - end - - unless source.verify - puts "❌ Checksum verification failed for #{path}" - puts "Expected: #{source.metadata['content_checksum']}" - puts "Got: #{source.checksum}" - exit 1 - end - - puts "✅ #{path} verified successfully" - - when 'show' - OptionParser.new do |opts| - opts.banner = "Usage: picop show FILE|DIRECTORY" - end.parse!(argv) - - path = argv.first - Picop.show(path) - - when 'update' - options = {} - OptionParser.new do |opts| - opts.banner = "Usage: picop update [options] FILE" - opts.on('-f', '--force', 'Force update') { |f| options[:force] = f } - end.parse!(argv) - - file = argv.first - Picop.update(file, options) - - else - puts "Unknown command: #{command}" - puts "Available commands: scan, sign, show, update" - exit 1 - end - rescue OptionParser::InvalidOption => e - puts e.message - exit 1 - rescue => e - puts "Error: #{e.message}" - puts e.backtrace if ENV['DEBUG'] - exit 1 - end - end -end \ No newline at end of file diff --git a/lib/picop/source_file.rb b/lib/picop/source_file.rb deleted file mode 100644 index 3fec8f9..0000000 --- a/lib/picop/source_file.rb +++ /dev/null @@ -1,65 +0,0 @@ -require "yaml" -require "digest" - -module Picop - class SourceFile - attr_reader :file_path, :content - - METADATA_PATTERN = /^\s*#\s*@META_START\n(.*?)^\s*#\s*@META_END/m - - def initialize(file_path) - @file_path = file_path - @content = File.read(file_path) - end - - def metadata - @metadata ||= begin - return {} unless content =~ METADATA_PATTERN - - yaml_content = $1.lines.map do |line| - line.sub(/^\s*#\s?/, '').rstrip - end.join("\n") - - YAML.safe_load(yaml_content) - end - end - - def file_content = content.sub(/^\s*#\s*@META_START\n.*?^\s*#\s*@META_END\n*/m, '') - - def checksum = "sha256:#{Digest::SHA256.hexdigest(file_content)}" - - def show = puts(metadata.merge({checksum: checksum})) - - def save = File.write(file_path, content) - - def add_metadata(metadata_hash) - yaml_content = metadata_hash.to_yaml.strip - metadata_block = [ - "# @META_START", - yaml_content.lines.map { |line| "# #{line}" }.join, - "# @META_END" - ].join("\n") - - if content =~ METADATA_PATTERN - @content = content.sub(METADATA_PATTERN, metadata_block) - else - @content = [metadata_block, content].join("\n\n") - end - end - - def sign - hash = checksum - meta = metadata || {} - return puts "File already signed" if meta['content_checksum'] == hash - - meta['content_checksum'] = "#{hash}" - add_metadata(meta) - save - end - - def verify - return false unless metadata.key? 'content_checksum' - checksum == metadata['content_checksum'] - end - end -end \ No newline at end of file diff --git a/lib/picopackage/cli.rb b/lib/picopackage/cli.rb new file mode 100644 index 0000000..b3641e1 --- /dev/null +++ b/lib/picopackage/cli.rb @@ -0,0 +1,104 @@ +require "optparse" + +module Picopackage + class CLI + def self.run(argv = ARGV) + command = argv.shift + case command + when 'scan' + options = {} + OptionParser.new do |opts| + opts.banner = "Usage: ppkg scan [options] DIRECTORY" + # opts.on('-v', '--verbose', 'Run verbosely') { |v| options[:verbose] = v } + end.parse!(argv) + + dir = argv.first || '.' + Picopackage::Scanner.scan(dir).each {|f| puts f.file_path } + + when 'sign' + OptionParser.new do |opts| + opts.banner = "Usage: ppkg sign FILE" + end.parse!(argv) + + file = argv.first + Picopackage::SourceFile.from_file(file).sign + + when 'checksum' + OptionParser.new do |opts| + opts.banner = "Usage: ppkg checksum FILE" + end.parse!(argv) + file = argv.first + puts Picopackage::SourceFile.from_file(file).checksum + + when 'verify' + OptionParser.new do |opts| + opts.banner = "Usage: ppkg sign FILE" + end.parse!(argv) + + path = argv.first + source = SourceFile.from_file(path) + + if source.metadata['content_checksum'].nil? + puts "⚠️ No checksum found in #{path}" + puts "Run 'ppkg sign #{path}' to add one" + exit 1 + end + + unless source.verify + puts "❌ Checksum verification failed for #{path}" + puts "Expected: #{source.metadata['content_checksum']}" + puts "Got: #{source.checksum}" + exit 1 + end + + puts "✅ #{path} verified successfully" + + when 'inspect' + OptionParser.new do |opts| + opts.banner = "Usage: ppkg inspect FILE|DIRECTORY" + end.parse!(argv) + + path = argv.first + Picopackage::SourceFile.from_file(path).inspect + + when 'fetch' + options = { force: false } + OptionParser.new do |opts| + opts.banner = "Usage: ppkg fetch [options] URI [PATH]" + opts.on('-f', '--force', 'Force fetch') { |f| options[:force] = f } + end.parse!(argv) + + url = argv.shift + path = argv.shift || '.' # use '.' if no path provided + + if url.nil? + puts "Error: URI is required" + exit 1 + end + + begin + source_file = Fetch.fetch(url, path, force: options[:force]) + rescue LocalModificationError => e + puts "Error: #{e.message}" + rescue => e + puts "Error: #{e.message}" + exit 1 + # Optionally retry with force + # source_file = Fetch.fetch(url, destination, force: true) + end + + else + puts "Unknown command: #{command}" + puts "Available commands: scan, sign, inspect, update" + exit 1 + end + rescue OptionParser::InvalidOption => e + puts e.message + exit 1 + rescue => e + puts "Error: #{e.message}" + puts e.backtrace if ENV['DEBUG'] + exit 1 + end + end +end \ No newline at end of file diff --git a/lib/picopackage/fetch.rb b/lib/picopackage/fetch.rb new file mode 100644 index 0000000..9299cbd --- /dev/null +++ b/lib/picopackage/fetch.rb @@ -0,0 +1,97 @@ +require 'net/http' +require 'fileutils' +require 'tempfile' +require 'json' +require 'debug' + +module Picop + class Fetch + def self.fetch(url, destination, force: false) + raise ArgumentError, "Destination directory does not exist: #{destination}" unless Dir.exist?(destination) +debugger + provider = Provider.for(url) + file_path = File.join(destination, provider.source_file.filename) + debugger + if File.exist?(file_path) && force + provider.source_file.save(destination) + elsif File.exist?(file_path) + local_source_file = SourceFile.from_file(file_path) + status = Status.compare(local_source_file, provider.source_file) + + if force + provider.source_file.save(destination) + elsif status.modified? + raise LocalModificationError, "#{status.message}. Use -f or --force to overwrite local version" + elsif status.outdated? + puts "Updated from #{local_source_file.version} to #{provider.source_file.version}" + provider.source_file.save(destination) + elsif status.up_to_date? + puts status.message + end + + else + provider.source_file.save(destination) + end + provider.source_file + end + end + + class Status + attr_reader :state, :local_version, :remote_version + + def self.compare(local_source_file, remote_source_file) + return new(:outdated) if local_source_file.metadata.nil? || remote_source_file.metadata.nil? + + local_version = local_source_file.metadata["version"] + remote_version = remote_source_file.metadata["version"] + + if local_version == remote_version + if local_source_file.modified? + new(:modified, local_version:) + else + new(:up_to_date, local_version:) + end + else + new(:outdated, + local_version:, + remote_version:, + modified: local_source_file.modified? + ) + end + end + + def initialize(state, local_version: nil, remote_version: nil, modified: false) + @state = state + @local_version = local_version + @remote_version = remote_version + @modified = modified + end + + def modified? + @modified || @state == :modified + end + + def up_to_date? + @state == :up_to_date + end + + def outdated? + @state == :outdated + end + + def message + case state + when :up_to_date + "File is up to date" + when :outdated + if modified? + "Local file (v#{local_version}) has modifications but remote version (v#{remote_version}) is available" + else + "Local file (v#{local_version}) is outdated. Remote version: v#{remote_version}" + end + when :modified + "Local file has been modified from original version (v#{local_version})" + end + end + end +end diff --git a/lib/picopackage/provider.rb b/lib/picopackage/provider.rb new file mode 100644 index 0000000..47a08fc --- /dev/null +++ b/lib/picopackage/provider.rb @@ -0,0 +1,132 @@ +module Picop + class Provider + def self.for(url) + PROVIDERS.each do |provider| + case provider.handles_url?(url) + when false + next + when true + return provider.new(url) + when :maybe + instance = provider.new(url) + return instance if instance.handles_body? + end + end + nil # Return nil if no provider found + end + end + + # Base class for fetching content from a URL + # The variable `body` will contain the content retrieved from the URL + # The variable `content` will contain both and code + metadata - this would be writen to a file. + # The variable `code` will contain the code extracted from `content` + # The variable `metadata` will contain the metadata extracted from `content` + + # Job of the Provider class is to fetch the body from the URL, and then extract the content and the filename from the body + # The SourceFile class will then take the body and split it into code and metadata + + class DefaultProvider + MAX_SIZE = 1024 * 1024 + TIMEOUT = 10 + + attr_reader :url, :source_file + + def self.handles_url?(url) = :maybe + + def initialize(url) + @url = transform_url(url) + @uri = URI(@url) + @body = nil + @content = nil + end + + def transform_url(url) = url + + def body = @body ||= fetch + + def fetch + begin + Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == 'https', read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |http| + http.request_get(@uri.path) do |response| + raise "Unexpected response: #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + @body = String.new(capacity: MAX_SIZE) + response.read_body do |chunk| + if @body.bytesize + chunk.bytesize > MAX_SIZE + raise FileTooLargeError, "Response would exceed #{MAX_SIZE} bytes" + end + @body << chunk + end + @body + end + end + end + @body + end + + def handles_body? + true + rescue FileTooLargeError + false + end + + def content + # Implement in subclass - this come from the `body`. Spliting content into code and metadata is the job of the SourceFile class + raise NotImplementedError + end + + def filename + # Implement in subclass - this should return the filename extracted from the body - if it exists, but not from the metadata + raise NotImplementedError + end + + def source_file + @source_file ||= SourceFile.from_content(content) + end + end + + class GithubGistProvider < DefaultProvider + def self.handles_url?(url) = url.match?(%r{gist\.github\.com}) + + def transform_url(url) + gist_id = url[/gist\.github\.com\/[^\/]+\/([a-f0-9]+)/, 1] + "https://api.github.com/gists/#{gist_id}" + end + + def content + data = JSON.parse(body) + file = data["files"].values.first["content"] + end + + def filename + data = JSON.parse(body) + data["files"].values.first["filename"] + end + end + + class OpenGistProvider < DefaultProvider + def handles_url?(url) + :maybe + end + + def transform_url(url) + "#{url}.json" + end + + def content + data = JSON.parse(body) + @content = data.dig("files",0, "content") + end + + def filename + data = JSON.parse(body) + data.dig("files",0, "filename") + end + end + + PROVIDERS = [ + GithubGistProvider, + OpenGistProvider, + DefaultProvider + ].freeze +end \ No newline at end of file diff --git a/lib/picop/scanner.rb b/lib/picopackage/scanner.rb similarity index 100% rename from lib/picop/scanner.rb rename to lib/picopackage/scanner.rb diff --git a/lib/picopackage/source_file.rb b/lib/picopackage/source_file.rb new file mode 100644 index 0000000..4d0acb6 --- /dev/null +++ b/lib/picopackage/source_file.rb @@ -0,0 +1,109 @@ +require "yaml" +require "digest" + +module Picop + class SourceFile + attr_reader :content, :metadata, :code, :original_path + + METADATA_PATTERN = /^\n*#\s*@PICOPACKAGE_START\n(.*?)^\s*#\s*@PICOPACKAGE_END\s*$/m + + def self.from_file(file_path) = new(content: File.read(file_path), original_path: file_path) + + def self.from_content(content, filename: nil) + instance = new(content: content) + if filename && !instance.metadata['filename'] + metadata = instance.metadata.merge('filename' => filename) + instance.update_metadata(metadata) #TODO: FIX THIS + end + instance + end + + def initialize(content:, original_path: nil) + @original_path = original_path + + @content = content + @metadata = extract_metadata + @code = extract_code + end + + def filename = @metadata['filename'] + + def version = @metadata['version'] || '0.0.0' + + def checksum = "sha256:#{Digest::SHA256.hexdigest(code)}" + + def inspect_metadata = puts JSON.pretty_generate(@metadata) + + def save(destination = nil) + path = determine_save_path(destination) + File.write(path, content) + path + end + + def extract_code = content.sub(METADATA_PATTERN, '') + + def extract_metadata + return {} unless content =~ METADATA_PATTERN + + yaml_content = $1.lines.map do |line| + line.sub(/^\s*#\s?/, '').rstrip + end.join("\n") + + YAML.safe_load(yaml_content) + end + + def update_metadata(metadata_hash) + @metadata = metadata_hash + @content = generate_content + end + + def sign + hash = checksum + return puts "File already signed" if metadata['content_checksum'] == hash + + new_metadata = metadata.merge('content_checksum' => hash) + update_metadata(new_metadata) + save + end + + def verify + return false unless metadata.key? 'content_checksum' + checksum == metadata['content_checksum'] + end + + def modified? = !verify + + private + + def generate_content + metadata_block = generate_metadata + if content =~ METADATA_PATTERN + content.sub(METADATA_PATTERN, "\n#{metadata_block}") + else + [content.rstrip, "\n#{metadata_block}"].join("\n") + end + end + + # This will need a comment style one day, to work with other languages + def generate_metadata + yaml_content = @metadata.to_yaml.strip + [ + "# @PICOPACKAGE_START", + yaml_content.lines.map { |line| "# #{line}" }.join, + "# @PICOPACKAGE_END", + "" + ].join("\n") + end + + def determine_save_path(destination) + if destination.nil? + @original_path || filename || raise("No filename available") + elsif File.directory?(destination) + File.join(destination, filename || File.basename(@original_path)) + else + destination + end + end + + end +end \ No newline at end of file diff --git a/lib/picop/version.rb b/lib/picopackage/version.rb similarity index 100% rename from lib/picop/version.rb rename to lib/picopackage/version.rb diff --git a/picop.gemspec b/picopackage.gemspec similarity index 93% rename from picop.gemspec rename to picopackage.gemspec index 5ec63fa..3ce61a1 100644 --- a/picop.gemspec +++ b/picopackage.gemspec @@ -1,10 +1,10 @@ # frozen_string_literal: true -require_relative "lib/picop/version" +require_relative "lib/picopackage/version" Gem::Specification.new do |spec| - spec.name = "picop" - spec.version = Picop::VERSION + spec.name = "picopackage" + spec.version = Picopackage::VERSION spec.authors = ["Dan Milne"] spec.email = ["d@nmilne.com"] diff --git a/test/test_helper.rb b/test/test_helper.rb index b1650f8..168e2c4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true $LOAD_PATH.unshift File.expand_path("../lib", __dir__) -require "picop" +require "picopackage" require "minitest/autorun" diff --git a/test/test_picop.rb b/test/test_picopackage.rb similarity index 68% rename from test/test_picop.rb rename to test/test_picopackage.rb index b6adc95..c309b22 100644 --- a/test/test_picop.rb +++ b/test/test_picopackage.rb @@ -2,9 +2,9 @@ require "test_helper" -class TestPicop < Minitest::Test +class TestPicopackage < Minitest::Test def test_that_it_has_a_version_number - refute_nil ::Picop::VERSION + refute_nil ::Picopackage::VERSION end def test_it_does_something_useful