- Split the monolith into package/provider/fetch/merge/cache modules with the Resolver deciding install/adopt/update/merge/conflict outcomes - Three-way merges via git merge-file/diff3 against a content-addressed merge-base cache; conflicts go to a .picopackage-merge sibling - Tests for package, provider, resolver, merge, and cache - notes.md: update UX and signing design — diff-by-default, SSH signature identity pinning (TOFU), key changes as a hard stop, exit-code contract - Add CLAUDE.md; remove the pre-refactor exe/pppkg monolith and scratch files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EiyJC73Mz8xZyCTvCEY8qn
147 lines
4.8 KiB
Ruby
147 lines
4.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "optparse"
|
|
|
|
module Picopackage
|
|
class CLI
|
|
COMMANDS = %w[install update package verify inspect scan].freeze
|
|
|
|
def self.run(argv = ARGV)
|
|
command = argv.shift
|
|
|
|
case command
|
|
when "install"
|
|
options = {force: false}
|
|
OptionParser.new do |opts|
|
|
opts.banner = "Usage: ppkg install [options] URL [DIRECTORY]"
|
|
opts.on("-f", "--force", "Overwrite local changes") { options[:force] = true }
|
|
end.parse!(argv)
|
|
|
|
url = argv.shift
|
|
destination = argv.shift || "."
|
|
abort "Error: URL is required" if url.nil?
|
|
|
|
report Fetch.fetch(url, destination, force: options[:force])
|
|
|
|
when "update"
|
|
options = {force: false}
|
|
OptionParser.new do |opts|
|
|
opts.banner = "Usage: ppkg update [options] FILE"
|
|
opts.on("-f", "--force", "Discard local changes") { options[:force] = true }
|
|
end.parse!(argv)
|
|
|
|
path = argv.shift
|
|
abort "Error: FILE is required" if path.nil?
|
|
|
|
package = load_package(path)
|
|
abort "Error: #{path} has no url in its metadata, so there is nothing to update from" if package.url.nil? || package.url.empty?
|
|
|
|
# Updates write back to the file the user named, not to whatever
|
|
# filename upstream would prefer — renaming a package locally is
|
|
# allowed and shouldn't silently install a second copy.
|
|
report Fetch.fetch(package.url, File.dirname(path), force: options[:force], filename: File.basename(path))
|
|
|
|
when "package", "init"
|
|
options = {}
|
|
OptionParser.new do |opts|
|
|
opts.banner = "Usage: ppkg package [--url URL] FILE"
|
|
opts.on("--url URL", "Where updates should be fetched from") { |url| options[:url] = url }
|
|
end.parse!(argv)
|
|
|
|
path = argv.shift
|
|
abort "Error: FILE is required" if path.nil?
|
|
|
|
package = load_package(path)
|
|
was_bare = package.bare?
|
|
package.init_metadata(filename: File.basename(path), url: options[:url])
|
|
package.save(path)
|
|
Cache.new.store(package.payload)
|
|
|
|
puts was_bare ? "Packaged #{path}" : "Updated metadata in #{path}"
|
|
warn "Note: no url recorded — 'ppkg update' won't work until one is set with --url" if package.url.nil?
|
|
|
|
when "verify"
|
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg verify FILE" }.parse!(argv)
|
|
|
|
path = argv.shift
|
|
abort "Error: FILE is required" if path.nil?
|
|
|
|
package = load_package(path)
|
|
|
|
if package.payload_checksum.nil?
|
|
puts "⚠️ No checksum found in #{path}"
|
|
puts "Run 'ppkg package #{path}' to add one"
|
|
exit 1
|
|
end
|
|
|
|
unless package.verify_payload
|
|
puts "❌ #{path} does not match its recorded checksum"
|
|
puts "Recorded: #{package.payload_checksum}"
|
|
puts "Actual: #{package.payload_digest}"
|
|
exit 1
|
|
end
|
|
|
|
puts "✅ #{path} matches its recorded checksum"
|
|
|
|
when "inspect"
|
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg inspect FILE" }.parse!(argv)
|
|
|
|
path = argv.shift
|
|
abort "Error: FILE is required" if path.nil?
|
|
load_package(path).inspect_metadata
|
|
|
|
when "scan"
|
|
OptionParser.new { |opts| opts.banner = "Usage: ppkg scan [DIRECTORY]" }.parse!(argv)
|
|
|
|
found = Scanner.scan(argv.shift || ".")
|
|
found.each { |entry| puts [entry.path, entry.package.url].compact.join("\t") }
|
|
puts "No picopackages found" if found.empty?
|
|
|
|
when "--version", "-v", "version"
|
|
puts Picopackage::VERSION
|
|
|
|
when nil, "help", "--help", "-h"
|
|
usage
|
|
exit(command.nil? ? 1 : 0)
|
|
|
|
else
|
|
warn "Unknown command: #{command}"
|
|
usage
|
|
exit 1
|
|
end
|
|
rescue OptionParser::ParseError => e
|
|
abort e.message
|
|
rescue FetchError, Fetch::Error, ArgumentError => e
|
|
warn "Error: #{e.message}"
|
|
warn e.backtrace if ENV["DEBUG"]
|
|
exit 1
|
|
end
|
|
|
|
def self.usage
|
|
puts <<~TEXT
|
|
Usage: ppkg <command> [options]
|
|
|
|
Commands:
|
|
install URL [DIR] Fetch a picopackage into DIR (default: .)
|
|
update FILE Re-fetch FILE from the url in its metadata
|
|
package FILE Add a metadata block to a local file
|
|
verify FILE Check FILE against its recorded checksum
|
|
inspect FILE Print FILE's metadata as JSON
|
|
scan [DIR] List picopackages under DIR
|
|
|
|
Run 'ppkg <command> --help' for command options.
|
|
TEXT
|
|
end
|
|
|
|
def self.load_package(path)
|
|
abort "Error: no such file: #{path}" unless File.file?(path)
|
|
Package.from_file(path) or abort "Error: could not read #{path}"
|
|
end
|
|
|
|
def self.report(result)
|
|
puts result.message
|
|
exit 1 if result.conflict?
|
|
end
|
|
end
|
|
end
|