#!/usr/bin/env perl
use v5.34;
use warnings;
use feature 'signatures';
no warnings ('experimental::signatures');
use Scalar::Util qw(looks_like_number);
use Getopt::Std ();
use JSON ();
use File::Basename ();
use Digest::MD5 ();
use Digest::SHA ();
sub usage($fh) {
print $fh <<~'EOF'
Usage:
paku [-f FILE] ACTION
paku [-f FILE] -C CONFIG_PATH
paku -h
EOF
}
sub help($fh) {
print $fh <<~'EOF'
Options:
-f FILE use data from FILE (default: paku.lock)
-C CONFIG get the final value of CONFIG_PATH
-h, --help show this message
ACTION what to emit, one of:
- guix
- debian
- alpine
- nix
- html
- guix-channel-key
Generate package definitions for different package managers.
Examples:
Emit Guix package definitions using all defaults:
$ paku guix > packages.scm
$ guix build -f packages.scm
Emit Debian build recipes from "debian.json", and execute
them as is:
$ paku -f debian.json debian | make -f-
Find out what is the final value of "datadir":
$ paku -C 'datadir'
EOF
}
for (@ARGV) {
last if $_ eq '--';
if ($_ eq '--help') {
usage *STDOUT;
help *STDOUT;
exit;
}
}
my %opts;
if (!Getopt::Std::getopts('f:C:h', \%opts)) {
usage *STDERR;
exit 2;
}
if ($opts{h}) {
usage *STDOUT;
help *STDOUT;
exit;
}
my $fname = $opts{f} || 'paku.lock';
sub load_json() {
my $json_str = do {
open my $fh, $fname or die "Failed opening \"$fname\"";
local $/;
<$fh>;
};
return JSON::decode_json($json_str);
}
sub get_in($j, @l) {
my ($head, @tail) = @l;
if (!defined $head) {
return ref $j ?
JSON::encode_json $j :
$j;
} elsif (looks_like_number($head)) {
return get_in($j->[$head], @tail);
} else {
return get_in($j->{$head}, @tail);
}
}
if ($opts{C}) {
say get_in(load_json(), split(/\./, $opts{C}));
exit;
}
my $licenses = {
guix => {
'AGPL-3.0-or-later' => 'agpl3+',
},
nix => {
'AGPL-3.0-or-later' => 'agpl3Plus',
},
};
sub license_for($target, $mappings, $id) {
return $mappings->{$id} || $licenses->{$target}->{$id} || $id;
}
sub inputs_for($mappings, @inputs) {
my @out = ();
for (@inputs) {
if (!$mappings->{$_}) {
push @out, $_;
} else {
push @out, @{$mappings->{$_}};
}
}
return @out;
}
sub emit_packages() {
for (@ARGV) {
my $fh;
my $size = (stat($_))[7];
open ($fh, '<', $_) or die "Can't open \"$_\": $!";
my $md5 = Digest::MD5->new->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $_) or die "Can't open \"$_\": $!";
my $sha1 = Digest::SHA->new(1)->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $_) or die "Can't open \"$_\": $!";
my $sha256 = Digest::SHA->new(256)->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $_) or die "Can't open \"$_\": $!";
my $sha512 = Digest::SHA->new(512)->addfile($fh)->hexdigest;
close $fh;
print `ar -p $_ control.tar.xz | tar -xJO ./control`;
say "Filename: ./$_";
say "Size: $size";
say "MD5sum: $md5";
say "SHA1: $sha1";
say "SHA256: $sha256";
print "\n";
}
}
sub emit_release() {
my $f = $ARGV[0] or die 'Missing "Packages" file';
my $name = File::Basename::basename $f;
my $size = (stat($f))[7];
my $now = `env LANG=POSIX.UTF-8 date -u '+%a, %d %b %Y %H:%M:%S +0000'`;
chomp $now;
my $fh;
open ($fh, '<', $f) or die "Can't open \"$f\": $!";
my $md5 = Digest::MD5->new->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $f) or die "Can't open \"$f\": $!";
my $sha1 = Digest::SHA->new(1)->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $f) or die "Can't open \"$f\": $!";
my $sha256 = Digest::SHA->new(256)->addfile($fh)->hexdigest;
close $fh;
open ($fh, '<', $f) or die "Can't open \"$f\": $!";
my $sha512 = Digest::SHA->new(512)->addfile($fh)->hexdigest;
close $fh;
print <<~EOF;
Date: $now
MD5Sum:
$md5 $size $name
SHA1:
$sha1 $size $name
SHA256:
$sha256 $size $name
SHA512:
$sha512 $size $name
EOF
exit;
}
sub emit_nix() {
my $json = load_json();
my $ns = $json->{namespace};
$ns =~ s/\./-/g;
print <<~EOF;
{ pkgs }:
self: super: {
$ns = rec {
EOF
my $license_mapping = $json->{mappings}{nix}{licenses};
for my $pkg (@{$json->{packages}}) {
my $long = $pkg->{'long-description'};
$long =~ s/^(.)/ $1/gm;
my $license = license_for 'nix', $license_mapping, $pkg->{license};
my $suffix = $pkg->{label} eq 'latest' ? '' : "-$pkg->{label}";
print <<~EOF;
$pkg->{name}$suffix = pkgs.stdenv.mkDerivation rec {
name = "$pkg->{name}";
version = "$pkg->{version}";
src = fetchTarball {
url =
"$pkg->{url}";
sha256 = "$pkg->{sha256nix}";
};
nativeBuildInputs = with pkgs; [
EOF
for my $input (@{$pkg->{'native-inputs'}}) {
print <<~EOF;
$input
EOF
}
print <<~EOF;
];
buildInputs = with pkgs; [
EOF
for my $input (@{$pkg->{inputs}}) {
print <<~EOF;
$input
EOF
}
print <<~EOF;
];
makeFlags = [ "PREFIX=\$(out)" ];
doCheck = true;
enableParallelBuilding = true;
meta = with pkgs.lib; {
description = "$pkg->{description}";
longDescription = ''
$long
'';
homepage = "$pkg->{homepage}";
changelog = "$pkg->{changelog}";
downloadPage = "$pkg->{'downloads-page'}";
license = licenses.$license;
platforms = platforms.unix;
};
};
EOF
}
print <<~EOF;
};
}
EOF
}
sub emit_guix() {
my $json = load_json();
my $ns = $json->{namespace};
$ns =~ s/\./ /g;
print <<~EOF;
(define-module ($ns packages)
EOF
for my $module (@{$json->{guix}{'module-use'}}) {
print <<~EOF;
#:use-module (gnu packages $module)
EOF
}
print <<~EOF;
#:use-module ((guix licenses) #:prefix licenses:)
#:use-module (guix gexp)
#:use-module (guix packages)
#:use-module (guix download)
#:use-module (guix build-system gnu))
EOF
my $pkg_mapping = $json->{mappings}{guix}{packages};
my $license_mapping = $json->{mappings}{guix}{licenses};
my @pkgs = ();
for my $pkg (@{$json->{packages}}) {
my $long = $pkg->{'long-description'};
$long =~ s/^(.)/ $1/gm;
my $ver = $pkg->{version} =~ s/^v//r;
my $license = license_for 'guix', $license_mapping, $pkg->{license};
my $name = $pkg->{name} . (
$pkg->{label} eq 'latest' ? '' : "-$pkg->{label}"
);
push @pkgs, $name;
print <<~EOF;
(define-public $name
(package
(name "$pkg->{name}")
(version "$ver")
(source
(origin
(method url-fetch)
(uri "$pkg->{url}")
(sha256
(base32 "$pkg->{sha256guix}"))))
(build-system gnu-build-system)
(native-inputs
EOF
print ' (list';
for (inputs_for $pkg_mapping, @{$pkg->{'native-inputs'}}) {
print "\n $_";
}
print "))\n";
print " (inputs\n";
print ' (list';
for (inputs_for $pkg_mapping, @{$pkg->{inputs}}) {
print "\n $_";
}
print "))\n";
print <<~EOF;
(arguments
(list
#:make-flags #~(list (string-append "PREFIX=" #\$output))
#:phases
#~(modify-phases %standard-phases
(delete 'configure))))
(synopsis "$pkg->{description}")
(description
"$pkg->{'long-description'}")
(home-page "$pkg->{homepage}")
(license licenses:$license)))
EOF
}
print '(list';
for (@pkgs) {
print "\n $_";
}
print ")\n";
}
sub emit_guix_channel_key() {
my $json = load_json();
my $name = $json->{maintainer}{name};
my $id = $json->{maintainer}{email};
print <<~EOF;
.POSIX:
$name.key:
gpg --armour --export $id > \$\@
EOF
}
sub emit_debian() {
my $json = load_json();
my @debs = ();
my @targets = ();
for my $pkg (@{$json->{packages}}) {
push @targets, <<~EOF;
\$(DIR)/tarballs/$pkg->{fname}:
mkdir -p \$(\@D)
wget -O \$\@ \\
'$pkg->{url}'
\$(DIR)/checkouts/$pkg->{name}-$pkg->{version}: \$(DIR)/tarballs/$pkg->{fname}
mkdir -p \$(\@D)
tar -C \$(\@D) -xf \$(DIR)/tarballs/$pkg->{fname}
touch \$\@
EOF
next if $pkg->{architectures} ne 'any';
my $deb_name = "$pkg->{name}_" . (
$pkg->{label} eq 'latest' ? 'latest' : $pkg->{version}
) . "_all.deb";
my $deb_path = "\$(DIR)/debian/$deb_name";
push @debs, "\t$deb_path \\\n";
my $ver = $pkg->{label} eq 'latest' ? '0.' . $pkg->{version} . '.latest' : $pkg->{version};
$ver =~ s/^v//;
push @targets, <<~EOF;
\$(DIR)/builddirs/$pkg->{name}-$pkg->{version}/DEBIAN: \$(DIR)/checkouts/$pkg->{name}-$pkg->{version}
\$(MAKE) \\
-C \$(DIR)/checkouts/$pkg->{name}-$pkg->{version} \\
install \\
PREFIX=/usr \\
DESTDIR="\$\$PWD"/\$(\@D)
mkdir -p \$\@
touch \$\@
\$(DIR)/builddirs/$pkg->{name}-$pkg->{version}/DEBIAN/control: \$(DIR)/builddirs/$pkg->{name}-$pkg->{version}/DEBIAN
printf '' > \$\@
printf 'Package: $pkg->{name}\\n' >> \$\@
printf 'Version: $ver\\n' >> \$\@
printf 'Section: custom\\n' >> \$\@
printf 'Depends:\\n' >> \$\@
printf 'Priority: optional\\n' >> \$\@
printf 'Architecture: all\\n' >> \$\@
printf 'Essential: no\\n' >> \$\@
printf 'Maintainer: ' >> \$\@
printf '$pkg->{"maintainer-b64"}' | base64 -d >> \$\@
printf '\\n' >> \$\@
printf 'Description: ' >> \$\@
printf '$pkg->{"description-b64"}' | base64 -d >> \$\@
printf '\\n' >> \$\@
printf '$pkg->{'long-description-b64'}' | \\
base64 -d | \\
sed 's|^\$\$|.|' | \\
sed 's|^| |' >> \$\@
printf '\\n' >> \$\@
\$(DIR)/builddirs/$pkg->{name}-$pkg->{version}.deb: \$(DIR)/builddirs/$pkg->{name}-$pkg->{version}/DEBIAN/control
dpkg-deb --build \$(DIR)/builddirs/$pkg->{name}-$pkg->{version}
$deb_path: \$(DIR)/builddirs/$pkg->{name}-$pkg->{version}.deb
mkdir -p \$(\@D)
cp \$(DIR)/builddirs/$pkg->{name}-$pkg->{version}.deb \$\@
EOF
}
print <<~EOF;
.POSIX:
DIR = $json->{datadir}
debs = \\
EOF
print @debs, "\n";
print <<~EOF;
GPGKEY = '$json->{maintainer}{name} <$json->{maintainer}{email}>'
all: \\
\$(DIR)/debian/InRelease \$(DIR)/debian/Release.gpg \\
\$(DIR)/debian/public-key.asc debian.out.txt
debian.out.txt:
\@printf '\$(DIR)/debian/*\\n' > \$\@
\$(DIR)/debian/Packages: \$(debs)
cd \$(\@D) && paku debian-packages *.deb > \$(\@F)
\$(DIR)/debian/Release: \$(DIR)/debian/Packages
paku debian-release \$(DIR)/debian/Packages > \$\@
\$(DIR)/debian/Release.gpg: \$(DIR)/debian/Release
gpg -abs -o \$\@ \$(DIR)/debian/Release
\$(DIR)/debian/InRelease: \$(DIR)/debian/Release
gpg --default-key \$(GPGKEY) -a --clear-sign -o \$\@ \$(DIR)/debian/Release
\$(DIR)/debian/public-key.asc: \$(debs)
gpg --armour --export \$(GPGKEY) > \$\@
EOF
print @targets;
}
sub emit_alpine() {
my $d = $ARGV[0] or die 'Missing "src/alpine/" directory';
my $json = load_json();
for my $pkg (@{$json->{packages}}) {
my $date = $pkg->{date} =~ s/-//gr;
my $ver = $pkg->{label} eq 'latest' ? "0.0.1_git$date" : $pkg->{version};
my $dir = "$d/$pkg->{name}" . (
$pkg->{label} eq 'latest' ? '' : "-$pkg->{label}"
);
mkdir "$dir";
open(my $fh, '>', "$dir/APKBUILD");
print $fh <<~EOF;
# Maintainer: $pkg->{maintainer}
pkgname='$pkg->{name}'
pkgver='$ver'
pkgrel='0'
pkgdesc='$pkg->{description}'
url='$pkg->{homepage}'
arch='noarch'
license='$pkg->{license}'
depends=''
makedepends=''
checkdepends=''
install=''
subpackages='$pkg->{name}-doc'
source='$pkg->{url}'
builddir="\$srcdir/$pkg->{name}-$pkg->{version}"
build() {
make PREFIX=/usr
}
check() {
make check
}
package() {
make install DESTDIR="\$pkgdir" PREFIX=/usr
}
sha512sums='
$pkg->{sha512} $pkg->{fname}
'
EOF
close $fh;
}
my @apks = ();
my @targets = ();
for my $pkg (@{$json->{packages}}) {
next if $pkg->{architectures} ne 'any';
my $date = $pkg->{date} =~ s/-//gr;
my $ver = $pkg->{label} eq 'latest' ? "0.0.1_git$date" : $pkg->{version};
my $recipe_dir = "src/alpine/$pkg->{name}" . (
$pkg->{label} eq 'latest' ? '' : "-$pkg->{label}"
) . '/';
my $apk_name = "$pkg->{name}-$ver-r0.apk";
my $apk_path = "\$(DIR)/alpine/REPODEST/alpine/x86_64/$apk_name";
push @apks, "\t$apk_path \\\n";
push @targets, <<~EOF;
$apk_path: $recipe_dir/APKBUILD
cd $recipe_dir && abuild \\
-s \$\$OLDPWD/\$(DIR)/alpine/SRCDEST \\
-P \$\$OLDPWD/\$(DIR)/alpine/REPODEST \\
sanitycheck clean fetch verify unpack build check rootpkg index clean
EOF
}
print <<~EOF;
.POSIX:
DIR = $json->{datadir}
RSAKEY = $json->{maintainer}{email}
apks = \\
EOF
print @apks, "\n";
print "\n\n";
print <<~EOF;
all: \$(apks) \$(DIR)/alpine/REPODEST/alpine/\$(RSAKEY).rsa.pub alpine.out.txt
alpine.out.txt:
\@printf '\$(DIR)/alpine/REPODEST/alpine/*\\n' > \$\@
\$(apks): src/secrets/\$(RSAKEY).rsa
\$(DIR)/alpine/REPODEST/alpine/\$(RSAKEY).rsa.pub: src/secrets/\$(RSAKEY).rsa
mkdir -p \$(\@D)
cp src/secrets/\$(RSAKEY).rsa.pub \$\@
src/secrets/\$(RSAKEY).rsa: src/secrets/\$(RSAKEY).rsa.gpg
gpg -d -o \$\@ \$\@.gpg
chmod 400 \$\@
EOF
print @targets;
}
sub pascal_case($s) {
return join('', map(ucfirst, split '-', $s))
}
sub emit_homebrew() {
my $d = $ARGV[0] or die 'Missing "Formula/" directory';
my $json = load_json();
for my $pkg (@{$json->{packages}}) {
my $name = $pkg->{name} . (
$pkg->{label} eq 'latest' ? '' : "-$pkg->{label}"
);
open(my $fh, '>', "$d/$name.rb");
my $class_name = pascal_case $name;
print $fh <<~EOF;
class $class_name < Formula
desc '$pkg->{description}'
homepage '$pkg->{homepage}'
url '$pkg->{url}'
sha256 '$pkg->{sha256}'
license 'AGPL-3.0-or-later'
def install
system 'make', "PREFIX=#{prefix}"
system 'make', 'check', "PREFIX=#{prefix}"
system 'make', 'install', "PREFIX=#{prefix}"
end
test do
system "#{bin}/$pkg->{name}", '-V'
end
end
EOF
close $fh;
}
}
sub emit_html() {
my $json = load_json();
print <<~EOF;
$json->{name} package index
$json->{name} package index
EOF
for my $pkg (@{$json->{packages}}) {
my $guix_suffix = $pkg->{label} eq 'latest' ? '' : '@' . $pkg->{label} =~ s/-/./gr;
my $suffix = $pkg->{label} eq 'latest' ? '' : "-$pkg->{label}";
my $apt_suffix = $suffix =~ s/-/=/r;
my $ver = $pkg->{label} eq 'latest' ? $pkg->{label} : $pkg->{version};
print <<~EOF;
$pkg->{name}
($ver) - $pkg->{description}
Homepage
Guix
After following the
Guix instructions
to include this channel, you can launch a shell
that includes this package:
\$ guix shell $pkg->{name}$guix_suffix
Alternatively, you can install it imperatively:
\$ guix install $pkg->{name}$guix_suffix
Debian
After following the
Debian instructions
to include this repository to
/etc/apt/sources.list
, you can
install it:
# apt install $pkg->{name}$apt_suffix
Alpine
After folowwing the
Alpine instructions
to include this repository to
/etc/apk/repositories
, you can
install it:
# apk add $pkg->{name}
Nix
After following the
Nix instructions
to include this repository as an overlay, you
can launch a shell that includes this package:
\$ nix-shell -p $pkg->{name}$suffix
Alternatively, you can install it imperatively:
\$ nix-env -i $pkg->{name}$suffix
Homebrew
After following the
Homebrew instructions
to include this repository as a tap
, you can
install it with:
\$ brew install $pkg->{name}$suffix
EOF
}
my $channel_name = $json->{namespace} =~ s/\./-/gr;
my $overlay_name = $json->{namespace} =~ s/\./-/gr;
my $tap_name = $json->{namespace} =~ s/\./\//gr;
print <<~EOF;
Guix instructions
Add this channel to your
~/.config/guix/channels.scm
:
(cons*
(channel
(name '$channel_name)
(url "$json->{vcs}->{git}")
(branch "main")
(introduction
(make-channel-introduction
"d749e053e6db365069cb9b2ef47a78b06f9e7361"
(openpgp-fingerprint
"5BDA E9B8 B2F6 C6BC BB0D 6CE5 81F9 0EC3 CD35 6060"))))
%default-channels)
Afterwards, do a guix pull
to make the
packages in this channel available to your profile.
See also the
Guix manual on channels
for more information.
Debian instructions
Include my public key for validating the repository
signatures:
\$ wget -qO- $json->{'base-url'}/debian/public-key.asc |
sudo tee /etc/apt/trusted.gpg.d/$json->{namespace}.asc
Afterwards, include this repository to the list of
repositories that apt(8)
uses for sources
by adding its URL to
/etc/apt/sources.list
:
\$ echo 'deb $json->{'base-url'}/debian ./' |
sudo tee -a /etc/apt/sources.list
\$ sudo apt update
After that the packages from this repository will
be available.
Alpine instructions
Get my public key used to sign the repository:
\$ wget -qO- $json->{'base-url'}/alpine/$json->{maintainer}{email}.rsa.pub |
doas tee /etc/apk/keys/$json->{maintainer}{email}.rsa.pub
Then include this repository in the
apk-repositories(5)
list that
apk(8)
uses to retrive package files for
installation:
\$ echo '$json->{'base-url'}/alpine' |
doas tee -a /etc/apk/repositories
After that the packages frmo this repository will be
available.
Nix instructions
Add this repository as an overlay to your
/etc/nixos/configuration.nix
:
nixpkgs = {
overlays = [
(import (fetchTarball {
url = "$json->{vcs}->{tarball}";
}) { inherit pkgs; })
];
};
All the packages live under the
$overlay_name
attribute set, like.
$overlay_name.$json->{packages}[0]->{name}
,
so it can be included in the list of packages:
environment.systemPackages = with pkgs; [
...
$overlay_name.$json->{packages}[0]->{name}
];
To make the overlay available outside the system
environment of
/etc/nixos/configuration.nix
, you need to
add this overlay to your
~/.config/nixpkgs/overlays/
directory,
like a
~/.config/nixpkgs/overlays/$json->{namespace}.nix
file containing:
import (fetchTarball {
url = "$json->{vcs}->{tarball}";
}) { pkgs = import <nixpkgs> {}; }
Then you'd be able to launch a shell with a package
from this overlay:
\$ nix-shell -p $overlay_name.$json->{packages}[0]->{name}
Homebrew instructions
Add this repository as a tap:
\$ brew tap --force-auto-update $tap_name $json->{vcs}->{http}
The explicit --force-auto-update
option
is required, because homebrew(1)
will only
fetch updates automatically for repositories hosted on
GitHub. With this option, repositories not on GitHub
are treated equally.
EOF
}
my %actions = (
'debian-packages' => \&emit_packages,
'debian-release' => \&emit_release,
nix => \&emit_nix,
guix => \&emit_guix,
'guix-channel-key' => \&emit_guix_channel_key,
debian => \&emit_debian,
alpine => \&emit_alpine,
homebrew => \&emit_homebrew,
html => \&emit_html,
);
my $action = $ARGV[0] or die "Missing ACTION";
shift;
my $fn = $actions{$action} or die "Unknown ACTION: \"$action\"";
&$fn;