nixos/goupile: init; goupile: init at 3.12.1 (#470416)
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
designed to run on affordable, low-power devices. Available as [services.meshtasticd]
|
||||
(#opt-services.meshtasticd.enable).
|
||||
|
||||
- [Goupile](https://goupile.org/en), an open-source design tool for secure forms including Clinical Report Forms (eCRF). Available as [services.goupile](#opt-services.goupile.enable).
|
||||
|
||||
- [knot-resolver](https://www.knot-resolver.cz/) in version 6. Available as `services.knot-resolver`. A module for knot-resolver 5 was already available as `services.kresd`.
|
||||
|
||||
- [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`.
|
||||
|
||||
@@ -1651,6 +1651,7 @@
|
||||
./services/web-apps/goatcounter.nix
|
||||
./services/web-apps/gotify-server.nix
|
||||
./services/web-apps/gotosocial.nix
|
||||
./services/web-apps/goupile.nix
|
||||
./services/web-apps/grav.nix
|
||||
./services/web-apps/grocy.nix
|
||||
./services/web-apps/guacamole-client.nix
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.goupile;
|
||||
settingsFormat = pkgs.formats.ini { };
|
||||
in
|
||||
{
|
||||
options.services.goupile = {
|
||||
enable = lib.mkEnableOption "Goupile server";
|
||||
package = lib.mkPackageOption pkgs "goupile" { };
|
||||
|
||||
enableSandbox = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable the sandbox option.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
freeformType = settingsFormat.type;
|
||||
options = {
|
||||
HTTP.Port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8889;
|
||||
description = "The port goupile runs on";
|
||||
};
|
||||
Data.RootDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/lib/goupile";
|
||||
description = "Goupile's data directory.";
|
||||
};
|
||||
};
|
||||
};
|
||||
default = { }; # default will be lost for submodules if overriden
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
HTTP.Port = 8888;
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
The options for `systemd.services.goupile` in ini format.
|
||||
|
||||
The configuration options available can be found here
|
||||
https://github.com/Koromix/rygel/blob/goupile/3.11.1/src/goupile/server/admin.cc#L41
|
||||
'';
|
||||
};
|
||||
|
||||
configFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = ''
|
||||
The configuration file to be passed to goupile server.
|
||||
|
||||
By default the configuration file is created from `services.goupile.settings`.
|
||||
'';
|
||||
};
|
||||
|
||||
hostName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "goupile";
|
||||
description = "Nginx service name for goupile service.";
|
||||
};
|
||||
};
|
||||
config = lib.mkIf cfg.enable (
|
||||
lib.mkMerge [
|
||||
{
|
||||
services.nginx = {
|
||||
enable = lib.mkDefault true;
|
||||
virtualHosts.${cfg.hostName} = {
|
||||
locations."/".proxyPass = "http://${cfg.hostName}:${builtins.toString cfg.settings.HTTP.Port}";
|
||||
};
|
||||
};
|
||||
}
|
||||
{
|
||||
services.goupile.configFile = settingsFormat.generate "goupile.ini" cfg.settings;
|
||||
}
|
||||
{
|
||||
systemd.services.goupile = {
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
documentation = [ "https://goupile.org/en" ];
|
||||
description = "Goupile eCRF";
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = ''
|
||||
${lib.getExe cfg.package} \
|
||||
${lib.optionalString cfg.enableSandbox "--sandbox"} \
|
||||
-C ${cfg.configFile}
|
||||
'';
|
||||
|
||||
DynamicUser = true;
|
||||
|
||||
RuntimeDirectory = "goupile";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
StateDirectory = "goupile";
|
||||
UMask = 0077;
|
||||
WorkingDirectory = "%S/goupile";
|
||||
|
||||
SystemCallArchitectures = "native";
|
||||
SystemCallFilter = [
|
||||
"~@privileged"
|
||||
"~@resources"
|
||||
"~@obsolete"
|
||||
"~@mount"
|
||||
"@system-service"
|
||||
"@file-system"
|
||||
"@basic-io"
|
||||
"@clock"
|
||||
];
|
||||
|
||||
ProtectHome = true;
|
||||
PrivateUsers = true;
|
||||
PrivateDevices = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectKernelModules = true;
|
||||
|
||||
CapabilityBoundingSet = [
|
||||
"CAP_SYS_PTRACE"
|
||||
"CAP_CHOWN"
|
||||
"CAP_DAC_OVERRIDE"
|
||||
"CAP_FOWNER"
|
||||
"CAP_KILL" # Required for child process management
|
||||
"CAP_NET_BIND_SERVICE"
|
||||
"CAP_SETGID"
|
||||
"CAP_SETUID"
|
||||
"CAP_SYS_CHROOT"
|
||||
"CAP_SYS_RESOURCE"
|
||||
];
|
||||
|
||||
Restart = "always";
|
||||
RestartSec = 20;
|
||||
TimeoutStopSec = 30;
|
||||
LimitNOFILE = 4096;
|
||||
};
|
||||
};
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
meta.maintainers = lib.teams.ngi.members;
|
||||
}
|
||||
@@ -689,6 +689,7 @@ in
|
||||
gotenberg = runTest ./gotenberg.nix;
|
||||
gotify-server = runTest ./gotify-server.nix;
|
||||
gotosocial = runTest ./web-apps/gotosocial.nix;
|
||||
goupile = runTest ./web-apps/goupile;
|
||||
grafana = handleTest ./grafana { };
|
||||
graphite = runTest ./graphite.nix;
|
||||
grav = runTest ./web-apps/grav.nix;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import os
|
||||
import openpyxl
|
||||
import tempfile
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE_URL = "http://localhost:8889"
|
||||
|
||||
# NOTE: these are the passwords
|
||||
ADMIN_PASSWD = "car-shop-in-the-mall"
|
||||
ALICE_PASSWD = "user-goes-to-the-car-shop"
|
||||
|
||||
|
||||
def run_test():
|
||||
is_headful = os.getenv("HEADFUL") == "1"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=not is_headful)
|
||||
context = browser.new_context(
|
||||
accept_downloads=True, record_video_dir="/tmp/videos/"
|
||||
)
|
||||
# more default timeout for slow nixos test vms
|
||||
context.set_default_timeout(90 * 1000)
|
||||
page = context.new_page()
|
||||
|
||||
page.goto(f"{BASE_URL}/admin")
|
||||
|
||||
# admin and doman setup
|
||||
page.get_by_role("textbox", name="Domain name *").fill("domain")
|
||||
page.get_by_role("textbox", name="Domain title *").fill("domain")
|
||||
page.get_by_role("textbox", name="Password *").fill(ADMIN_PASSWD)
|
||||
page.get_by_role("textbox", name="Confirmation").fill(ADMIN_PASSWD)
|
||||
page.get_by_role("textbox", name="Decryption key *").click()
|
||||
page.get_by_role("button", name="Installer").click()
|
||||
|
||||
# login to admin dashboard as admin
|
||||
page.get_by_role("textbox", name="Username *").fill("admin")
|
||||
page.get_by_role("textbox", name="Password *").fill(ADMIN_PASSWD)
|
||||
page.get_by_role("button", name="Login").click()
|
||||
|
||||
# create a sample project, it will switch the view to project's configure page
|
||||
page.get_by_text("Create new project").click()
|
||||
page.get_by_role("textbox", name="Name *").fill("proj1")
|
||||
page.get_by_role("button", name="Create").click()
|
||||
|
||||
# create a test non-root user, alice
|
||||
page.get_by_text("Create new user").click()
|
||||
page.get_by_role("textbox", name="Username *").fill("alice")
|
||||
page.get_by_role("button", name="No", exact=True).click()
|
||||
page.get_by_role("textbox", name="Password *").fill(ALICE_PASSWD)
|
||||
page.get_by_role("textbox", name="Confirmation").fill(ALICE_PASSWD)
|
||||
page.get_by_role("button", name="Create").click()
|
||||
|
||||
# give alice, permissions to access the project
|
||||
page.get_by_role("button", name="Assign").nth(1).click()
|
||||
page.get_by_text("Read", exact=True).click()
|
||||
page.get_by_text("Save", exact=True).click()
|
||||
page.get_by_text("Export", exact=True).click()
|
||||
page.get_by_text("Download", exact=True).click()
|
||||
|
||||
# Open the project in new page
|
||||
page.locator("form").get_by_role("button", name="Edit").click()
|
||||
with page.expect_popup() as page1_info:
|
||||
page.get_by_role("link", name="access").click()
|
||||
page1 = page1_info.value
|
||||
page1.set_default_timeout(120 * 1000)
|
||||
|
||||
# fill entries as admin (enter 1 for everything)
|
||||
page1.get_by_role("button", name="Create new record").click()
|
||||
|
||||
page1.locator("#ins_tiles").get_by_text("Introduction").click()
|
||||
page1.get_by_role("textbox", name="Inclusion date *").fill("2000-01-01")
|
||||
page1.get_by_role("spinbutton", name="Age *").click()
|
||||
page1.get_by_role("spinbutton", name="Age *").fill("1")
|
||||
page1.get_by_role("button", name="Save").click()
|
||||
page1.wait_for_timeout(1000)
|
||||
|
||||
page1.get_by_role("button", name="Advanced").click()
|
||||
page1.get_by_role("spinbutton", name="Age *").click()
|
||||
page1.get_by_role("spinbutton", name="Age *").fill("1")
|
||||
page1.get_by_role("button", name="Save").click()
|
||||
page1.wait_for_timeout(1000)
|
||||
|
||||
page1.get_by_role("button", name="Page layout").click()
|
||||
page1.get_by_role("spinbutton", name="Variable A1").fill("1")
|
||||
page1.get_by_role("button", name="Save").click()
|
||||
page1.wait_for_timeout(1000)
|
||||
|
||||
# create export #1
|
||||
page1.get_by_role("button", name="Data").click()
|
||||
page1.wait_for_timeout(1000)
|
||||
|
||||
page1.get_by_role("button", name="Data exports").click()
|
||||
with page1.expect_download() as download_info:
|
||||
page1.get_by_role("button", name="Create export").click()
|
||||
|
||||
# logout as admin
|
||||
page.get_by_role("button", name="admin", exact=True).click()
|
||||
with page.expect_popup() as page2_info:
|
||||
page.get_by_role("link", name="access").click()
|
||||
page2 = page2_info.value
|
||||
page2.set_default_timeout(120 * 1000)
|
||||
|
||||
page2.get_by_role("button", name="admin").click()
|
||||
page2.get_by_role("button", name="Logout").click()
|
||||
|
||||
# login as alice
|
||||
page2.get_by_role("textbox", name="Username *").fill("alice")
|
||||
page2.get_by_role("textbox", name="Password *").fill(ALICE_PASSWD)
|
||||
page2.get_by_role("button", name="Login").click()
|
||||
|
||||
# create entry as alice (fill `2` for everything)
|
||||
page2.get_by_role("button", name="Create new record").click()
|
||||
|
||||
page2.get_by_text("1 Introduction").click()
|
||||
page2.get_by_role("textbox", name="Inclusion date *").fill("2000-01-01")
|
||||
page2.get_by_role("spinbutton", name="Age *").click()
|
||||
page2.get_by_role("spinbutton", name="Age *").fill("2")
|
||||
page2.get_by_role("button", name="Save").click()
|
||||
page2.wait_for_timeout(1000)
|
||||
|
||||
page2.get_by_role("button", name="Advanced").click()
|
||||
page2.get_by_role("spinbutton", name="Age *").click()
|
||||
page2.get_by_role("spinbutton", name="Age *").fill("2")
|
||||
page2.get_by_role("button", name="Save").click()
|
||||
page2.wait_for_timeout(1000)
|
||||
|
||||
page2.get_by_role("button", name="Page layout").click()
|
||||
page2.get_by_role("spinbutton", name="Variable A1").click()
|
||||
page2.get_by_role("spinbutton", name="Variable A1").fill("2")
|
||||
page2.get_by_role("button", name="Save").click()
|
||||
page2.wait_for_timeout(1000)
|
||||
|
||||
# create export #2
|
||||
page2.get_by_role("button", name="Data").click()
|
||||
page2.wait_for_timeout(1000)
|
||||
|
||||
page2.get_by_role("button", name="Data exports").click()
|
||||
page2.get_by_role("button", name="Previous exports").click()
|
||||
|
||||
with page2.expect_download() as download1_info:
|
||||
page2.locator("a").filter(has_text="Download").click()
|
||||
|
||||
download1 = download1_info.value
|
||||
save_path1 = os.path.join(tempfile.gettempdir(), download1.suggested_filename)
|
||||
download1.save_as(save_path1)
|
||||
|
||||
print(f"exported all records to {save_path1}")
|
||||
|
||||
page2.get_by_role("button", name="Data exports").click()
|
||||
with page2.expect_download() as download2_info:
|
||||
page2.get_by_role("button", name="Create export").click()
|
||||
|
||||
download2 = download2_info.value
|
||||
save_path2 = os.path.join(tempfile.gettempdir(), download2.suggested_filename)
|
||||
download2.save_as(save_path2)
|
||||
|
||||
print(f"exported all records to {save_path2}")
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
# check that exported files have correct entries
|
||||
|
||||
wb1 = openpyxl.load_workbook(save_path1)
|
||||
for sheet, cell in zip(["intro", "advanced", "layout"], ["D2", "D2", "C2"]):
|
||||
val = wb1[sheet][cell].value
|
||||
assert val == 1, f"Sheet {sheet}, Cell {cell}: Expected 1 (admin), got {val}"
|
||||
|
||||
wb2 = openpyxl.load_workbook(save_path2)
|
||||
for sheet, cell in zip(["intro", "advanced", "layout"], ["D3", "D3", "C3"]):
|
||||
val = wb2[sheet][cell].value
|
||||
assert val == 2, f"Sheet {sheet}, Cell {cell}: Expected 2 (alice), got {val}"
|
||||
|
||||
print("Test passed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_test()
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
python = pkgs.python3.withPackages (
|
||||
ps: with ps; [
|
||||
requests
|
||||
playwright
|
||||
openpyxl
|
||||
]
|
||||
);
|
||||
|
||||
runScript = "${lib.getExe python} ${./basic_interaction_test.py}";
|
||||
|
||||
run-goupile-test = pkgs.writeShellScriptBin "run-goupile-test" ''
|
||||
set -euo pipefail
|
||||
|
||||
export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
|
||||
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
# check if attached to a terminal
|
||||
if [ -t 1 ]; then
|
||||
# interactive testing
|
||||
export HEADFUL=''${HEADFUL:-1}
|
||||
export PWDEBUG=''${PWDEBUG:-0}
|
||||
export DISPLAY=''${DISPLAY:-:0}
|
||||
if [ "$(id -u)" = "0" ] && [ -d "/home/alice" ]; then
|
||||
runuser -u alice \
|
||||
-w DISPLAY,HEADFUL,PWDEBUG,PLAYWRIGHT_BROWSERS_PATH,PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD \
|
||||
-- ${runScript}
|
||||
else
|
||||
${runScript}
|
||||
fi
|
||||
else
|
||||
# non-interactive nixos test
|
||||
|
||||
# Print instructions to the nix logs
|
||||
cat <<'EOF' | tee >(systemd-cat -t goupile-e2e)
|
||||
================================================================================
|
||||
NOTE: The goupile e2e test can be run interactively either inside the vm or on the host
|
||||
- First, run `nix-build -A nixosTests.goupile.driverInteractive` and `./result/bin/nixos-test-driver`
|
||||
- Run `start_all()` inside the repl
|
||||
- Then `$(nix-build -A nixosTests.goupile.interactive-script)/bin/run-goupile-test` to run the full test interactively
|
||||
- Or `env PWDEBUG=1 $(nix-build -A nixosTests.goupile.interactive-script)/bin/run-goupile-test` to show the playwright inspector to debug
|
||||
================================================================================
|
||||
EOF
|
||||
|
||||
echo "Starting smoke test..." | systemd-cat -t goupile-e2e
|
||||
${runScript} 2>&1 | tee >(systemd-cat -t goupile-e2e)
|
||||
fi
|
||||
'';
|
||||
in
|
||||
{
|
||||
name = "goupile";
|
||||
|
||||
passthru.interactive-script = run-goupile-test;
|
||||
|
||||
nodes.machine =
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
services.goupile = {
|
||||
enable = true;
|
||||
enableSandbox = true;
|
||||
settings.HTTP.Port = 8889;
|
||||
};
|
||||
#systemd.services.goupile.environment.DEFAULT_SECCOMP_ACTION = "Log"; # Block|Log|Kill
|
||||
networking = {
|
||||
firewall.allowedTCPPorts = [ config.services.nginx.defaultHTTPListenPort ];
|
||||
hostName = "goupile";
|
||||
domain = "local";
|
||||
};
|
||||
|
||||
# goupile tries to resolve it at runtime, resolve it instead of patching it out
|
||||
# as the dns resolution step serves a purpose, to force glibc to load NSS libraries
|
||||
# see server/goupile.cc and search for getaddrinfo or www.example.com
|
||||
networking.extraHosts = ''
|
||||
127.0.0.1 www.example.com
|
||||
'';
|
||||
|
||||
environment.systemPackages = [
|
||||
python
|
||||
run-goupile-test
|
||||
];
|
||||
|
||||
# more cores and memory to improve chromium performance
|
||||
virtualisation.memorySize = lib.mkForce 8192;
|
||||
virtualisation.cores = 4;
|
||||
};
|
||||
|
||||
testScript =
|
||||
{ nodes, ... }:
|
||||
let
|
||||
port = builtins.toString nodes.machine.services.goupile.settings.HTTP.Port;
|
||||
in
|
||||
# py
|
||||
''
|
||||
import os
|
||||
start_all()
|
||||
|
||||
machine.wait_for_unit("goupile.service")
|
||||
machine.wait_for_open_port(${port})
|
||||
|
||||
machine.succeed("curl -q http://localhost:${port}")
|
||||
machine.succeed("curl -q http://goupile.local")
|
||||
machine.succeed("curl -q http://localhost")
|
||||
|
||||
machine.succeed("run-goupile-test")
|
||||
out_dir = os.environ.get("out", os.getcwd())
|
||||
machine.copy_from_vm("/tmp/videos", out_dir)
|
||||
'';
|
||||
|
||||
# Debug interactively with:
|
||||
# - nix-build -A nixosTests.goupile.driverInteractive
|
||||
# - ./result/bin/nixos-test-driver
|
||||
# - run_tests()
|
||||
# ssh -o User=root vsock%3 (can also do vsock/3, but % works with scp etc.)
|
||||
interactive.sshBackdoor.enable = true;
|
||||
|
||||
interactive.nodes.machine =
|
||||
{ config, ... }:
|
||||
let
|
||||
port = config.services.goupile.settings.HTTP.Port;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
# enable graphical session + users (alice, bob)
|
||||
../../common/x11.nix
|
||||
../../common/user-account.nix
|
||||
];
|
||||
services.xserver.enable = true;
|
||||
test-support.displayManager.auto.user = "alice";
|
||||
|
||||
virtualisation.forwardPorts = [
|
||||
{
|
||||
from = "host";
|
||||
host.port = port;
|
||||
guest.port = port;
|
||||
}
|
||||
];
|
||||
|
||||
# forwarded ports need to be accessible
|
||||
networking.firewall.allowedTCPPorts = [ port ];
|
||||
};
|
||||
|
||||
meta.maintainers = lib.teams.ngi.members;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
lib,
|
||||
fetchFromGitHub,
|
||||
versionCheckHook,
|
||||
installShellFiles,
|
||||
|
||||
stdenv,
|
||||
clangStdenv,
|
||||
llvmPackages,
|
||||
nixosTests,
|
||||
|
||||
# https://goupile.org/en/build recommends a Paranoid build
|
||||
# which is not bit by bit reproducible, whereas others are
|
||||
profile ? "Paranoid",
|
||||
}:
|
||||
|
||||
assert lib.assertOneOf "profile" profile [
|
||||
"Fast"
|
||||
"Debug"
|
||||
"Paranoid"
|
||||
];
|
||||
|
||||
let
|
||||
stdenv' = if (profile == "Paranoid") then clangStdenv else stdenv;
|
||||
in
|
||||
stdenv'.mkDerivation (finalAttrs: {
|
||||
pname = "goupile";
|
||||
version = "3.12.1";
|
||||
|
||||
# https://github.com/Koromix/rygel/tags
|
||||
src = fetchFromGitHub {
|
||||
owner = "Koromix";
|
||||
repo = "rygel";
|
||||
tag = "goupile/${finalAttrs.version}";
|
||||
hash = "sha256-Pn/0tjezVKJedAtqj69avxeIK2l3l9FGioYSyEao12E=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
installShellFiles
|
||||
]
|
||||
++ lib.optionals (profile == "Paranoid") [
|
||||
llvmPackages.bintools
|
||||
];
|
||||
|
||||
# pipe2() is only exposed with _GNU_SOURCE
|
||||
NIX_CFLAGS_COMPILE = [ "-D_GNU_SOURCE" ];
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
./bootstrap.sh
|
||||
echo "goupile = ${finalAttrs.version}" >FelixVersions.ini
|
||||
./felix -s -p${profile} goupile
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
installBin bin/${profile}/goupile
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
doInstallCheck = true;
|
||||
versionCheckProgramArg = "--version";
|
||||
nativeInstallCheckInputs = [ versionCheckHook ];
|
||||
|
||||
passthru.tests = { inherit (nixosTests) goupile; };
|
||||
|
||||
meta = {
|
||||
changelog = "https://github.com/Koromix/rygel/blob/${finalAttrs.src.rev}/src/goupile/CHANGELOG.md";
|
||||
description = "Free design tool for secure forms including Clinical Report Forms (eCRF)";
|
||||
homepage = "https://goupile.org/en";
|
||||
license = lib.licenses.gpl3Plus; # sdpx headers
|
||||
platforms = lib.platforms.linux; # https://goupile.org/en/build
|
||||
mainProgram = "goupile";
|
||||
teams = with lib.teams; [ ngi ];
|
||||
};
|
||||
})
|
||||
Reference in New Issue
Block a user