Files
picopackage/lib/picopackage/cli.rb
T
Dan MilneandClaude Fable 5 6dd57e84f1 Version 0.3.0: resolver/merge/cache refactor, signing design notes
- 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
2026-08-13 21:18:19 +10:00

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