#!/usr/bin/env perl
use v5.34;
use warnings;
use feature 'signatures';
no warnings ('experimental::signatures');
use Getopt::Std ();
use JSON ();
use File::Basename ();
use Digest::MD5 ();
use Digest::SHA ();
sub usage($fh) {
print $fh <<~'EOF'
Usage:
paku [-d DIR] [-f FILE] ACTION
paku -h
EOF
}
sub help($fh) {
print $fh <<~'EOF'
Options:
-d DIR use DIR for intermediary data (default: .paku/)
-f FILE use data from FILE (default: paku.lock)
-h, --help show this message
ACTION what to emit, one of:
- guix
- debian
- nix
- html
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", using
".debtmp/" as the temporary directory, and execute them
as is:
$ paku -d .debtemp/ -f debian.json debian | make -f-
EOF
}
for (@ARGV) {
last if $_ eq '--';
if ($_ eq '--help') {
usage *STDOUT;
help *STDOUT;
exit;
}
}
my %opts;
if (!Getopt::Std::getopts('d:f:h', \%opts)) {
usage *STDERR;
exit 2;
}
if ($opts{h}) {
usage *STDOUT;
help *STDOUT;
exit;
}
my $dir = $opts{d} || '.paku';
my $fname = $opts{f} || 'paku.lock';
my $action = $ARGV[0] or die "Missing ACTION";
shift;
sub load_json() {
my $json_str = do {
open my $fh, $fname or die "Failed opening \"$fname\"";
local $/;
<$fh>;
};
return JSON::decode_json($json_str);
}
my $licenses = {
guix => {
'AGPL-3.0-or-later' => 'agpl3+',
},
nix => {
'AGPL-3.0-or-later' => 'agpl3Plus',
},
};
sub license_for($target, $id) {
return $licenses->{$target}->{$id};
}
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
for my $pkg (@{$json->{packages}}) {
my $long = $pkg->{'long-description'};
$long =~ s/^(.)/ $1/gm;
my $license = license_for 'nix', $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 @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', $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 my $input (@{$pkg->{'native-inputs'}}) {
my $name = $json->{mappings}{$input}{guix} || $input;
print "\n $name";
}
print "))\n";
print " (inputs\n";
print ' (list';
for my $input (@{$pkg->{inputs}}) {
my $name = $json->{mappings}{$input}{guix} || $input;
print "\n $name";
}
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_debian() {
my $json = load_json();
my %vars = (
tarballs => ["tarballs = \\\n"],
checkouts => ["checkouts = \\\n"],
destdirs => ["debian-destdirs = \\\n"],
ctrlfiles => ["control-files = \\\n"],
destdebs => ["destdir-debs = \\\n"],
debs => ["debs = \\\n"],
);
my @targets = ();
for my $pkg (@{$json->{packages}}) {
push @{$vars{tarballs}}, "\t\$(DIR)/tarballs/$pkg->{fname} \\\n";
push @{$vars{checkouts}}, "\t\$(DIR)/checkouts/$pkg->{name}-$pkg->{version} \\\n";
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 \$(DIR)/checkouts/ -xf \$(DIR)/tarballs/$pkg->{fname}
touch \$\@
EOF
next if $pkg->{architectures} ne 'any';
push @{$vars{destdirs}}, "\t\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}/DEBIAN \\\n";
push @{$vars{ctrlfiles}}, "\t\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}/DEBIAN/control \\\n";
push @{$vars{destdebs}}, "\t\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}.deb \\\n";
push @{$vars{debs}}, "\t\$(DIR)/debian/$pkg->{name}_$pkg->{version}_all.deb \\\n";
my $ver = $pkg->{label} eq 'latest' ? '0.' . $pkg->{version} . '.latest' : $pkg->{version};
$ver =~ s/^v//;
push @targets, <<~EOF;
\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}/DEBIAN: \$(DIR)/checkouts/$pkg->{name}-$pkg->{version}
\$(MAKE) \\
-C \$(DIR)/checkouts/$pkg->{name}-$pkg->{version} \\
install \\
PREFIX=/usr \\
DESTDIR="\$\$PWD"/\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}
mkdir -p \$\@
touch \$\@
\$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}/DEBIAN/control: \$(DIR)/debian-destdir/$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)/debian-destdir/$pkg->{name}-$pkg->{version}.deb: \$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}/DEBIAN/control
dpkg-deb --build \$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}
\$(DIR)/debian/$pkg->{name}_$pkg->{version}_all.deb: \$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}.deb
mkdir -p \$(\@D)
cp \$(DIR)/debian-destdir/$pkg->{name}-$pkg->{version}.deb \$\@
EOF
}
print "DIR = $dir\n\n";
print
@{$vars{tarballs}}, "\n",
@{$vars{checkouts}}, "\n",
@{$vars{destdirs}}, "\n",
@{$vars{ctrlfiles}}, "\n",
@{$vars{destdebs}}, "\n",
@{$vars{debs}}, "\n";
print <<~EOF;
GPGKEY = '$json->{maintainer}'
all: \$(DIR)/debian/InRelease \$(DIR)/debian/Release.gpg \$(DIR)/debian/public-key.asc
public-dir:
\@printf '\$(DIR)/debian'
\$(DIR)/debian/Packages: \$(debs)
cd \$(\@D) && perl \$\$OLDPWD/src/bin/paku debian-packages *.deb > \$(\@F)
\$(DIR)/debian/Release: \$(DIR)/debian/Packages
perl src/bin/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: \$(DIR)/debian/Release
gpg --armour --export \$(GPGKEY) > \$\@
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'
system 'make', 'check'
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;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+html" href="favicon.svg" />
<title>$json->{name} package index</title>
<style>
body {
max-width: 800px;
margin: 0 auto;
}
ul {
list-style: none;
}
li {
margin-top: 2em;
}
</style>
</head>
<body>
<main>
<h1>
$json->{name} package index
</h1>
<ul>
EOF
for my $pkg (@{$json->{packages}}) {
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;
<li id="$pkg->{name}-$ver">
<details>
<summary>
<a href="#$pkg->{name}-$ver">$pkg->{name}</a>
($ver) - $pkg->{description}
</summary>
<p>
<a href="$pkg->{homepage}/">Homepage</a>
</p>
<section>
<h2>Guix</h2>
<p>
After following the
<a href="#guix-instructions">Guix instructions</a>
to include this channel, you can launch a shell
that includes this package:
</p>
<pre><code>\$ guix shell $pkg->{name}$suffix</code></pre>
<p>
Alternatively, you can install it imperatively:
</p>
<pre><code>\$ guix install $pkg->{name}$suffix</code></pre>
</section>
<section>
<h2>Debian</h2>
<p>
After following the
<a href="#debian-instructions">Debian instructions</a>
to include this repository to
<code>/etc/apt/sources.list</code>, you can
install it:
</p>
<pre><code># apt install $pkg->{name}$apt_suffix</code></pre>
</section>
<section>
<h2>Nix</h2>
<p>
After following the
<a href="#nix-instructions">Nix instructions</a>
to include this repository as an overlay, you
can launch a shell that includes this package:
</p>
<pre><code>\$ nix-shell -p $pkg->{name}$suffix</code></pre>
<p>
Alternatively, you can install it imperatively:
</p>
<pre><code>\$ nix-env -i $pkg->{name}$suffix</code></pre>
</section>
<section>
<h2>Homebrew</h2>
<p>
After following the
<a href="#homebrew-instructions">Homebrew instructions</a>
to include this repository as a <code>tap</code>, you can
install it with:
</p>
<pre><code>\$ brew install $pkg->{name}$suffix</code></pre>
</section>
</details>
</li>
EOF
}
my $channel_name = $json->{namespace} =~ s/\./-/gr;
my $overlay_name = $json->{namespace} =~ s/\./-/gr;
my $tap_name = $json->{namespace} =~ s/\./\//gr;
print <<~EOF;
</ul>
<article id="guix-instructions">
<h2>Guix instructions</h2>
<p>
Add this channel to your
<code>~/.config/guix/channels.scm</code>:
</p>
<pre><code>(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)</code></pre>
<p>
Afterwards, do a <code>guix pull</code> to make the
packages in this channel available to your profile.
</p>
<p>
See also the
<a href="https://guix.gnu.org/manual/en/guix.html#Channels">Guix manual on channels</a>
for more information.
</p>
</article>
<article id="debian-instructions">
<h2>Debian instructions</h2>
<p>
Include my public key for validating the repository
signatures:
</p>
<pre><code>\$ wget -qO- $json->{'base-url'}/debian/public-key.asc | sudo tee /etc/apt/trusted.gpg.d/$json->{namespace}.asc</code></pre>
<p>
Afterwards, include this repository to the list of
repositories that <code>apt</code> uses for sources
by adding its URL to
<code>/etc/apt/sources.list</code>:
</p>
<pre><code>\$ sudo apt-add-repository 'deb $json->{'base-url'}/debian ./'</code></pre>
<p>
<code>apt-add-repository</code> will already perform
an <code>apt update</code>, so the packages from the
new repository will already be available.
</p>
</article>
<article id="nix-instructions">
<h2>Nix instructions</h2>
<p>
Add this repository as an overlay to your
<code>/etc/nixos/configuration.nix</code>:
</p>
<pre><code> nixpkgs = {
overlays = [
(import (fetchTarball {
url = "$json->{vcs}->{tarball}";
}) { inherit pkgs; })
];
};</code></pre>
<p>
All the packages live under the
<code>$overlay_name</code> attribute set.
</p>
</article>
<article id="homebrew-instructions">
<h2>Homebrew instructions</h2>
<p>
Add this repository as a tap:
</p>
<pre><code>\$ brew tap --force-auto-update $tap_name $json->{vcs}->{http}</code></pre>
<p>
The explicit <code>--force-auto-update</code> option
is required, because <code>homebrew(1)</code> will only
fetch updates automatically for repositories hosted on
GitHub. With this option, repositories not on GitHub
are treated equally.
</p>
</article>
</main>
</body>
</html>
EOF
}
my %actions = (
'debian-packages' => \&emit_packages,
'debian-release' => \&emit_release,
nix => \&emit_nix,
guix => \&emit_guix,
debian => \&emit_debian,
homebrew => \&emit_homebrew,
html => \&emit_html,
);
my $fn = $actions{$action} or die "Unknown ACTION: \"$action\"";
&$fn;