Rename from Picop to Picopackage

This commit is contained in:
Dan Milne
2025-01-21 08:29:23 +11:00
parent a9d6b85e81
commit 5e153bfb86
13 changed files with 450 additions and 172 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

104
lib/picopackage/cli.rb Normal file
View File

@@ -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

97
lib/picopackage/fetch.rb Normal file
View File

@@ -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

132
lib/picopackage/provider.rb Normal file
View File

@@ -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

View File

@@ -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