6 Commits

18 changed files with 502 additions and 189 deletions

View File

@@ -1,5 +1,9 @@
## [Unreleased] ## [Unreleased]
## [0.2.0] - 2025-01-21
- Rename to from Picop to Picopackage
## [0.1.0] - 2025-01-19 ## [0.1.0] - 2025-01-19
- Initial release - Initial release

View File

@@ -1,7 +1,7 @@
PATH PATH
remote: . remote: .
specs: specs:
picop (0.1.0) picopackage (0.2.0)
digest digest
open-uri (~> 0.5) open-uri (~> 0.5)
yaml (~> 0.4) yaml (~> 0.4)
@@ -70,7 +70,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
debug debug
minitest (~> 5.16) minitest (~> 5.16)
picop! picopackage!
rake (~> 13.0) rake (~> 13.0)
rubocop (~> 1.21) rubocop (~> 1.21)

View File

@@ -1,8 +1,8 @@
# Picop # Picopackage
TODO: Delete this and the text below, and describe your gem TODO: Delete this and the text below, and describe your gem
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/picop`. To experiment with that code, run `bin/console` for an interactive prompt. Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/picopackge`. To experiment with that code, run `bin/console` for an interactive prompt.
## Installation ## Installation

View File

@@ -4,10 +4,10 @@
lib_path = File.expand_path('../lib', __dir__) lib_path = File.expand_path('../lib', __dir__)
$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path) $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
require 'picop' require 'picopackage'
begin begin
Picop::CLI.run(ARGV) Picopackage::CLI.run(ARGV)
rescue => e rescue => e
warn "Error: #{e.message}" warn "Error: #{e.message}"
warn e.backtrace if ENV['DEBUG'] warn e.backtrace if ENV['DEBUG']

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

View File

@@ -1,5 +0,0 @@
# frozen_string_literal: true
module Picop
VERSION = "0.1.0"
end

16
lib/picopackage.rb Normal file
View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
require_relative "picopackage/version"
require_relative "picopackage/http_fetcher"
require_relative "picopackage/provider"
require_relative "picopackage/source_file"
require_relative "picopackage/scanner"
require_relative "picopackage/fetch"
require_relative "picopackage/cli"
module Picopackage
class Error < StandardError; end
class FileTooLargeError < StandardError; end
class FetchError < StandardError; end
class LocalModificationError < StandardError; end
end

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

@@ -0,0 +1,120 @@
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 'digest'
OptionParser.new do |opts|
opts.banner = "Usage: ppkg digest FILE"
end.parse!(argv)
file = argv.first
Picopackage::SourceFile.from_file(file).digest!
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
Fetch.fetch(url, path, force: options[:force])
rescue LocalModificationError => e
puts "Error: #{e.message}"
rescue => e
puts "Error: #{e.message}"
exit 1
end
when 'update'
options = { force: false }
OptionParser.new do |opts|
opts.banner = "Usage: ppkg update [options] FILE"
opts.on('-f', '--force', 'Force update') { |f| options[:force] = f }
end.parse!(argv)
file = argv.first
source_file = SourceFile.from_file(file)
begin
Fetch.fetch(source_file.url, File.dirname(file), force: options[:force])
rescue LocalModificationError => e
puts "Error: #{e.message}"
rescue => e
puts "Error: #{e.message}"
exit 1
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

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

@@ -0,0 +1,105 @@
require 'net/http'
require 'fileutils'
require 'tempfile'
require 'json'
require 'debug'
module Picopackage
class Fetch
def self.fetch(url, destination, force: false)
raise ArgumentError, "Destination directory does not exist: #{destination}" unless Dir.exist?(destination)
provider = Provider.for(url)
source_file = provider.source_file
file_path = File.join(destination, source_file.filename)
if File.exist?(file_path) && force
source_file.save(destination)
elsif File.exist?(file_path)
local_source_file = SourceFile.from_file(file_path)
status = Status.compare(local_source_file, source_file)
if force
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 #{source_file.version}"
source_file.save(destination)
elsif status.up_to_date?
puts status.message
end
else
source_file.save(destination)
if source_file.imported?
source_file.digest!
puts "Picopackage created for #{source_file.filename}"
else
puts "Picopackage downloaded to #{file_path}"
end
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
"Picopackage is up to date"
when :outdated
if modified?
"Local Picopackage (v#{local_version}) has modifications but remote version (v#{remote_version}) is available"
else
"Local Picopackage (v#{local_version}) is outdated. Remote version: v#{remote_version}"
end
when :modified
"Local Picopackage has been modified from original version (v#{local_version})"
end
end
end
end

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

@@ -0,0 +1,112 @@
module Picopackage
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 body = @body ||= fetch
def json_body = @json_body ||= JSON.parse(body)
def transform_url(url) = url
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, Net::HTTPError, RuntimeError => e
false
end
# Implement in subclass - this come from the `body`.
# Spliting content into code and metadata is the job of the SourceFile class
def content = body
# Implement in subclass - this should return the filename extracted from the body - if it exists, but not from the metadata
def filename = File.basename @url
def source_file
@source_file ||= SourceFile.from_content(content, metadata: {'filename' => filename, 'url' => url, 'version' => '0.0.1'})
end
end
class GithubGistProvider < DefaultProvider
def self.handles_url?(url) = url.match?(%r{gist\.github\.com})
def content = json_body["files"].values.first["content"]
def filename = json_body["files"].values.first["filename"]
def transform_url(url)
gist_id = url[/gist\.github\.com\/[^\/]+\/([a-f0-9]+)/, 1]
"https://api.github.com/gists/#{gist_id}"
end
end
class OpenGistProvider < DefaultProvider
def handles_url?(url) = :maybe
def transform_url(url) = "#{url}.json"
def content = json_body.dig("files",0, "content")
def filename = json_body.dig("files",0, "filename")
def handles_body?
content && filename
rescue FileTooLargeError, Net::HTTPError, RuntimeError => e
false
end
# If we successfully fetch the body, and the body contains content and a filename, then we can handle the body
end
PROVIDERS = [
GithubGistProvider,
OpenGistProvider,
DefaultProvider
].freeze
end

View File

@@ -1,4 +1,4 @@
module Picop module Picopackage
module Scanner module Scanner
def self.scan(directory, pattern: "**/*") def self.scan(directory, pattern: "**/*")
Dir.glob(File.join(directory, pattern)).select do |file| Dir.glob(File.join(directory, pattern)).select do |file|

View File

@@ -0,0 +1,120 @@
require "yaml"
require "digest"
module Picopackage
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, metadata: {})
instance = new(content: content)
instance.imported! if instance.metadata.empty?
updated_metadata = metadata.merge(instance.metadata)
## For new Picopackages, we should add metadata and checksum
instance.update_metadata(updated_metadata)
instance
end
def initialize(content:, original_path: nil)
@original_path = original_path
@content = content
@metadata = extract_metadata
@code = extract_code
end
def imported! = @imported = true
def imported? = @imported ||= false
def content = @content
def url = @metadata['url']
def filename = @metadata['filename']
def version = @metadata['version'] || '0.0.1'
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 digest!
hash = checksum
return puts "File already has a checksum" 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

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Picopackage
VERSION = "0.2.0"
end

View File

@@ -1,24 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative "lib/picop/version" require_relative "lib/picopackage/version"
Gem::Specification.new do |spec| Gem::Specification.new do |spec|
spec.name = "picop" spec.name = "picopackage"
spec.version = Picop::VERSION spec.version = Picopackage::VERSION
spec.authors = ["Dan Milne"] spec.authors = ["Dan Milne"]
spec.email = ["d@nmilne.com"] spec.email = ["d@nmilne.com"]
spec.summary = "TODO: Write a short summary, because RubyGems requires one." spec.summary = "Picopackage Tool."
spec.description = "TODO: Write a longer description or delete this line." spec.description = "Picopackage Tool for managing Picopackages."
spec.homepage = "TODO: Put your gem's website or public repo URL here." spec.homepage = "https://picopackage.org"
spec.license = "MIT" spec.license = "MIT"
spec.required_ruby_version = ">= 3.1.0" spec.required_ruby_version = ">= 3.1.0"
spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" #spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
spec.metadata["homepage_uri"] = spec.homepage #spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." #spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." #spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
# Specify which files should be added to the gem when it is released. # Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git. # The `git ls-files -z` loads the files in the RubyGem that have been added into git.

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path("../lib", __dir__) $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
require "picop" require "picopackage"
require "minitest/autorun" require "minitest/autorun"

View File

@@ -2,9 +2,9 @@
require "test_helper" require "test_helper"
class TestPicop < Minitest::Test class TestPicopackage < Minitest::Test
def test_that_it_has_a_version_number def test_that_it_has_a_version_number
refute_nil ::Picop::VERSION refute_nil ::Picopackage::VERSION
end end
def test_it_does_something_useful def test_it_does_something_useful