various
This commit is contained in:
parent
0f6a49366e
commit
024ea1168a
|
@ -13,5 +13,5 @@
|
|||
'';
|
||||
};
|
||||
|
||||
config.pubkeys = import (self + /pubkeys.nix);
|
||||
config.pubkeys = lib.mkForce (import (self + /pubkeys.nix));
|
||||
}
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
{pkgs, ...}: let
|
||||
puter = pkgs.writeShellApplication {
|
||||
name = "puter";
|
||||
runtimeInputs = [
|
||||
pkgs.nixos-rebuild
|
||||
];
|
||||
text = builtins.readFile ./puter.bash;
|
||||
};
|
||||
in {
|
||||
{
|
||||
pkgs,
|
||||
self,
|
||||
...
|
||||
}: {
|
||||
environment.systemPackages = [
|
||||
puter
|
||||
self.packages.${pkgs.system}.puter
|
||||
];
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
languages.python.enable = true;
|
||||
|
||||
pre-commit.hooks = {
|
||||
# Nix
|
||||
alejandra.enable = true;
|
||||
|
@ -10,5 +12,10 @@
|
|||
|
||||
# Shell
|
||||
shellcheck.enable = true;
|
||||
|
||||
# Python
|
||||
pyright.enable = true;
|
||||
ruff.enable = true;
|
||||
ruff-format.enable = true;
|
||||
};
|
||||
}
|
||||
|
|
11
flake.nix
11
flake.nix
|
@ -48,7 +48,7 @@
|
|||
devenv.root = let
|
||||
devenvRootFileContent = builtins.readFile inputs.devenv-root.outPath;
|
||||
in
|
||||
pkgs.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
|
||||
self.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
|
||||
|
||||
name = "puter";
|
||||
|
||||
|
@ -61,7 +61,14 @@
|
|||
];
|
||||
};
|
||||
|
||||
packages.disk = pkgs.callPackage ./disk {};
|
||||
packages =
|
||||
self.lib.genAttrs [
|
||||
"puter"
|
||||
"disk"
|
||||
"musicomp"
|
||||
] (
|
||||
name: pkgs.callPackage ./packages/${name} {}
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
{
|
||||
self,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
audiocomp = pkgs.writeShellApplication {
|
||||
name = "audiocomp";
|
||||
runtimeInputs = [
|
||||
pkgs.parallel
|
||||
pkgs.rsync
|
||||
pkgs.openssh
|
||||
];
|
||||
text = let
|
||||
remoteDir = self.nixosConfigurations.abacus.config.services.navidrome.settings.MusicFolder;
|
||||
enc = pkgs.writeShellApplication {
|
||||
name = "enc";
|
||||
runtimeInputs = [
|
||||
pkgs.opusTools
|
||||
];
|
||||
text = ''
|
||||
src=$1
|
||||
dst=$src
|
||||
dst=''${dst%.flac}.opus
|
||||
dst=/srv/compmusic/''${dst#/srv/music/}
|
||||
|
||||
if [[ -f "$dst" ]]; then
|
||||
exit
|
||||
fi
|
||||
|
||||
mkdir --parents -- "$(dirname -- "$dst")"
|
||||
|
||||
echo "encoding ''${src@Q} -> ''${dst@Q}" >&2
|
||||
exec opusenc --quiet --bitrate 96.000 -- "$src" "$dst"
|
||||
'';
|
||||
};
|
||||
clean = pkgs.writeShellApplication {
|
||||
name = "clean";
|
||||
text = ''
|
||||
del=$1
|
||||
chk=$del
|
||||
chk=''${chk%.opus}.flac
|
||||
chk=/srv/music/''${chk#/srv/compmusic/}
|
||||
|
||||
if [[ ! -f "$chk" ]]; then
|
||||
echo "deleting ''${del@Q}" >&2
|
||||
rm --force -- "$del"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
in ''
|
||||
shopt -s globstar nullglob
|
||||
|
||||
find /srv/music -name '*.flac' -print0 | parallel --null -- ${lib.getExe enc} {}
|
||||
|
||||
find /srv/compmusic -name '*.flac' -exec ${clean} {} \;
|
||||
|
||||
echo syncing >&2
|
||||
rsync --verbose --verbose --archive --update --delete --mkpath --exclude lost+found \
|
||||
--rsh 'ssh -i /etc/ssh/ssh_host_ed25519_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' \
|
||||
-- /srv/compmusic/ root@wrz.one:${remoteDir}
|
||||
'';
|
||||
};
|
||||
in {
|
||||
systemd.services.audiocomp = {
|
||||
description = "Compress and sync music";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
Group = "root";
|
||||
ExecStart = lib.getExe audiocomp;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.audiocomp = {
|
||||
description = "Compress and sync music daily";
|
||||
wantedBy = ["timers.target"];
|
||||
timerConfig = {
|
||||
OnCalendar = "*-*-* 03:00:00";
|
||||
Persistent = true;
|
||||
Unit = "audiocomp.service";
|
||||
};
|
||||
};
|
||||
}
|
51
hosts/server/vessel/musicomp.nix
Normal file
51
hosts/server/vessel/musicomp.nix
Normal file
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
self,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: {
|
||||
services.musicomp.jobs.main = {
|
||||
music = "/srv/music";
|
||||
comp = "/srv/compmusic";
|
||||
timerConfig = {
|
||||
OnCalendar = "daily";
|
||||
Persistent = true;
|
||||
};
|
||||
inhibitsSleep = true;
|
||||
post = let
|
||||
remoteDir = self.nixosConfigurations.abacus.config.services.navidrome.settings.MusicFolder;
|
||||
rsyncExe = lib.getExe pkgs.rsync;
|
||||
in ''
|
||||
${rsyncExe} \
|
||||
--archive \
|
||||
--recursive \
|
||||
--delete \
|
||||
--update \
|
||||
--mkpath \
|
||||
--verbose --verbose \
|
||||
--exclude lost+found \
|
||||
--rsh 'ssh -i /etc/ssh/ssh_host_ed25519_key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' \
|
||||
/srv/compmusic/ root@wrz.one:${remoteDir}
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.audiocomp = {
|
||||
description = "Compress and sync music";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
Group = "root";
|
||||
ExecStart = lib.getExe audiocomp;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.audiocomp = {
|
||||
description = "Compress and sync music daily";
|
||||
wantedBy = ["timers.target"];
|
||||
timerConfig = {
|
||||
OnCalendar = "*-*-* 03:00:00";
|
||||
Persistent = true;
|
||||
Unit = "audiocomp.service";
|
||||
};
|
||||
};
|
||||
}
|
10
lib.nix
10
lib.nix
|
@ -1,13 +1,13 @@
|
|||
lib: _: {
|
||||
findModules = dirs:
|
||||
builtins.concatMap (dir:
|
||||
lib.pipe dir [
|
||||
findModules = paths:
|
||||
builtins.concatMap (path:
|
||||
lib.pipe path [
|
||||
(lib.fileset.fileFilter (
|
||||
file: file.hasExt "nix"
|
||||
))
|
||||
lib.fileset.toList
|
||||
])
|
||||
dirs;
|
||||
paths;
|
||||
|
||||
formatHostPort = {
|
||||
host,
|
||||
|
@ -30,6 +30,7 @@ lib: _: {
|
|||
inputs,
|
||||
extraModules ? _: [],
|
||||
}: let
|
||||
modulesDir = ./modules;
|
||||
commonDir = ./common;
|
||||
classesDir = ./classes;
|
||||
hostsDir = ./hosts;
|
||||
|
@ -47,6 +48,7 @@ lib: _: {
|
|||
|
||||
modules =
|
||||
(lib.findModules [
|
||||
modulesDir
|
||||
commonDir
|
||||
./classes/${class}
|
||||
(classesDir + /${class})
|
||||
|
|
120
modules/musicomp.nix
Normal file
120
modules/musicomp.nix
Normal file
|
@ -0,0 +1,120 @@
|
|||
{
|
||||
self,
|
||||
lib,
|
||||
pkgs,
|
||||
utils,
|
||||
...
|
||||
}: let
|
||||
inherit (lib) types;
|
||||
inherit (utils.systemdUtils.unitOptions) unitOption;
|
||||
in {
|
||||
options.services.musicomp.jobs = lib.mkOption {
|
||||
description = ''
|
||||
Periodic jobs to run with musicomp.
|
||||
'';
|
||||
# type = types.attrsOf (types.submodule ({name, ...}: {
|
||||
type = types.attrsOf (types.submodule {
|
||||
options = {
|
||||
music = lib.mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Source directory.
|
||||
'';
|
||||
example = "/srv/music";
|
||||
};
|
||||
|
||||
comp = lib.mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Destination directory for compressed music.
|
||||
'';
|
||||
example = "/srv/comp";
|
||||
};
|
||||
|
||||
post = lib.mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
description = ''
|
||||
Shell commands that are run after compression has finished.
|
||||
'';
|
||||
};
|
||||
|
||||
workers = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 0;
|
||||
description = ''
|
||||
Number of workers.
|
||||
'';
|
||||
};
|
||||
|
||||
timerConfig = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.attrsOf unitOption);
|
||||
default = {
|
||||
OnCalendar = "daily";
|
||||
Persistent = true;
|
||||
};
|
||||
description = ''
|
||||
When to run the job.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkPackageOption self.packages.${pkgs.system} "musicomp" {};
|
||||
|
||||
inhibitsSleep = lib.mkOption {
|
||||
default = false;
|
||||
type = lib.types.bool;
|
||||
example = true;
|
||||
description = ''
|
||||
Prevents the system from sleeping while running the job.
|
||||
'';
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Periodic compression jobs to run with musicomp.
|
||||
'';
|
||||
default = {};
|
||||
});
|
||||
};
|
||||
|
||||
config = {
|
||||
systemd.services =
|
||||
lib.mapAttrs'
|
||||
(
|
||||
name: job:
|
||||
lib.nameValuePair "musicomp-jobs-${name}" {
|
||||
restartIfChanged = false;
|
||||
# TODO
|
||||
wants = ["network-online.target"];
|
||||
after = ["network-online.target"];
|
||||
|
||||
script = ''
|
||||
${lib.optionalString job.inhibitsSleep ''
|
||||
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
|
||||
--mode block \
|
||||
--who musicomp \
|
||||
--what sleep \
|
||||
--why ${lib.escapeShellArg "Scheduled musicomp ${name}"}
|
||||
''}
|
||||
|
||||
${lib.getExe job.package} \
|
||||
${lib.optionalString (job.workers > 0) "--workers ${job.workers}"} \
|
||||
-- ${job.music} ${job.comp}
|
||||
'';
|
||||
|
||||
postStop = job.post;
|
||||
|
||||
serviceConfig.Type = "oneshot";
|
||||
}
|
||||
)
|
||||
config.services.musicomp.jobs;
|
||||
|
||||
systemd.timers =
|
||||
lib.mapAttrs'
|
||||
(name: job:
|
||||
lib.nameValuePair "musicomp-jobs-${name}" {
|
||||
wantedBy = ["timers.target"];
|
||||
inherit (job) timerConfig;
|
||||
})
|
||||
(lib.filterAttrs (_: job: job.timerConfig != null) config.services.musicomp.jobs);
|
||||
};
|
||||
}
|
1
packages/musicomp/README.md
Normal file
1
packages/musicomp/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
|
19
packages/musicomp/default.nix
Normal file
19
packages/musicomp/default.nix
Normal file
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
lib,
|
||||
python3Packages,
|
||||
opusTools,
|
||||
}:
|
||||
python3Packages.buildPythonApplication {
|
||||
pname = "musicomp";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
pyproject = true;
|
||||
doCheck = false;
|
||||
build-system = [python3Packages.hatchling];
|
||||
makeWrapperArgs = [
|
||||
"--prefix"
|
||||
"PATH"
|
||||
":"
|
||||
(lib.makeBinPath [opusTools])
|
||||
];
|
||||
}
|
19
packages/musicomp/pyproject.toml
Normal file
19
packages/musicomp/pyproject.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[project]
|
||||
name = "musicomp"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Lukas Wurzinger", email = "lukas@wrz.one"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project.scripts]
|
||||
musicomp = "musicomp.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/musicomp"]
|
3
packages/musicomp/src/musicomp/__main__.py
Normal file
3
packages/musicomp/src/musicomp/__main__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .cli import main
|
||||
|
||||
main()
|
6
packages/musicomp/src/musicomp/clean.py
Normal file
6
packages/musicomp/src/musicomp/clean.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from os import PathLike
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def clean(dst: str | PathLike[str]) -> None:
|
||||
Path(dst).unlink(missing_ok=True)
|
118
packages/musicomp/src/musicomp/cli.py
Normal file
118
packages/musicomp/src/musicomp/cli.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
import sys
|
||||
from multiprocessing import Pool, cpu_count
|
||||
from argparse import ArgumentParser, ArgumentTypeError, Namespace
|
||||
from pathlib import Path
|
||||
from .todo import Todo
|
||||
|
||||
|
||||
args = Namespace()
|
||||
|
||||
|
||||
def task(todo: Todo) -> None:
|
||||
todo.run()
|
||||
if args.verbose:
|
||||
print("finished", todo, file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
def workers_type_func(value: object) -> int:
|
||||
try:
|
||||
value = int(value) # pyright: ignore[reportArgumentType]
|
||||
if value <= 0:
|
||||
raise ArgumentTypeError(f"{value} is not a positive integer")
|
||||
except ValueError:
|
||||
raise ArgumentTypeError(f"{value} is not an integer")
|
||||
|
||||
return value
|
||||
|
||||
parser = ArgumentParser()
|
||||
|
||||
_ = parser.add_argument(
|
||||
"-w",
|
||||
"--workers",
|
||||
default=cpu_count(),
|
||||
type=workers_type_func,
|
||||
help="amount of worker processes",
|
||||
)
|
||||
_ = parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="prompt before running",
|
||||
)
|
||||
_ = parser.add_argument(
|
||||
"-k",
|
||||
"--keep",
|
||||
action="store_true",
|
||||
help="whether source files should be kept if both directories are the same",
|
||||
)
|
||||
_ = parser.add_argument(
|
||||
"-r",
|
||||
"--redo",
|
||||
action="store_true",
|
||||
help="whether everything should be re-encoded regardless of whether they have already been transcoded",
|
||||
)
|
||||
_ = parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="count",
|
||||
default=0,
|
||||
help="verbose output",
|
||||
)
|
||||
|
||||
_ = parser.add_argument(
|
||||
"music",
|
||||
nargs=1,
|
||||
type=Path,
|
||||
help="the source directory",
|
||||
)
|
||||
_ = parser.add_argument(
|
||||
"comp",
|
||||
nargs=1,
|
||||
type=Path,
|
||||
help="the destination directory for compressed files",
|
||||
)
|
||||
|
||||
global args
|
||||
args = parser.parse_args(sys.argv[1:])
|
||||
|
||||
assert isinstance(args.workers, int) # pyright: ignore[reportAny]
|
||||
assert isinstance(args.interactive, bool) # pyright: ignore[reportAny]
|
||||
assert isinstance(args.keep, bool) # pyright: ignore[reportAny]
|
||||
assert isinstance(args.redo, bool) # pyright: ignore[reportAny]
|
||||
assert isinstance(args.verbose, int) # pyright: ignore[reportAny]
|
||||
assert isinstance(args.music, list) # pyright: ignore[reportAny]
|
||||
assert len(args.music) == 1 # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
assert isinstance(args.music[0], Path) # pyright: ignore[reportUnknownMemberType]
|
||||
assert isinstance(args.comp, list) # pyright: ignore[reportAny]
|
||||
assert len(args.comp) == 1 # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
assert isinstance(args.comp[0], Path) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
src_dir = args.music[0] # pyright: ignore[reportUnknownMemberType]
|
||||
dst_dir = args.comp[0] # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
plan = list(
|
||||
Todo.plan(
|
||||
src_dir,
|
||||
dst_dir,
|
||||
replace=src_dir.samefile(dst_dir) and not args.keep,
|
||||
redo=args.redo,
|
||||
)
|
||||
)
|
||||
|
||||
if len(plan) == 0:
|
||||
print("Nothing to do", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
if args.verbose >= 1 or args.interactive:
|
||||
print("Plan:", file=sys.stderr)
|
||||
for todo in plan:
|
||||
print(todo, file=sys.stderr)
|
||||
|
||||
if args.interactive:
|
||||
result = input("Do you want to continue? [Y/n] ")
|
||||
if result.lower() not in ("", "y", "yes"):
|
||||
sys.exit(1)
|
||||
|
||||
with Pool(args.workers) as pool:
|
||||
_ = pool.map(task, plan)
|
6
packages/musicomp/src/musicomp/replace.py
Normal file
6
packages/musicomp/src/musicomp/replace.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
from os import PathLike
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def replace(src: str | PathLike[str]) -> None:
|
||||
Path(src).unlink(missing_ok=True)
|
68
packages/musicomp/src/musicomp/todo.py
Normal file
68
packages/musicomp/src/musicomp/todo.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from pathlib import Path
|
||||
from os import PathLike
|
||||
from enum import StrEnum
|
||||
from dataclasses import dataclass
|
||||
from typing import Self, override
|
||||
from collections.abc import Generator
|
||||
from .transcode import transcode as todo_transcode
|
||||
from .clean import clean as todo_clean
|
||||
from .replace import replace as todo_replace
|
||||
|
||||
SRC_SUFFIX = ".flac"
|
||||
DST_SUFFIX = ".opus"
|
||||
|
||||
|
||||
class TodoAct(StrEnum):
|
||||
TRANSCODE = "transcode"
|
||||
CLEAN = "clean"
|
||||
REPLACE = "replace"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Todo:
|
||||
act: TodoAct
|
||||
src: str | PathLike[str]
|
||||
dst: str | PathLike[str]
|
||||
|
||||
@classmethod
|
||||
def plan(
|
||||
cls: type[Self],
|
||||
src_dir: str | PathLike[str],
|
||||
dst_dir: str | PathLike[str],
|
||||
replace: bool = False,
|
||||
redo: bool = False,
|
||||
) -> Generator[Self]:
|
||||
def list_files(dir: str | PathLike[str], suffix: str) -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for f in Path(dir).rglob("*"):
|
||||
if f.is_file() and f.suffix == suffix:
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
src_files = list_files(src_dir, SRC_SUFFIX)
|
||||
dst_files = list_files(dst_dir, DST_SUFFIX)
|
||||
|
||||
for f in src_files:
|
||||
e = dst_dir / (f.relative_to(src_dir).with_suffix(DST_SUFFIX))
|
||||
if redo or e not in dst_files:
|
||||
yield cls(TodoAct.TRANSCODE, f, e)
|
||||
if replace:
|
||||
yield cls(TodoAct.REPLACE, f, e)
|
||||
|
||||
for f in dst_files:
|
||||
e = src_dir / (f.relative_to(dst_dir).with_suffix(SRC_SUFFIX))
|
||||
if e not in src_files:
|
||||
yield cls(TodoAct.CLEAN, f, e)
|
||||
|
||||
def run(self) -> None:
|
||||
match self.act:
|
||||
case TodoAct.TRANSCODE:
|
||||
todo_transcode(self.src, self.dst)
|
||||
case TodoAct.CLEAN:
|
||||
todo_clean(self.dst)
|
||||
case TodoAct.REPLACE:
|
||||
todo_replace(self.src)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"{self.act} {self.src} -> {self.dst}"
|
34
packages/musicomp/src/musicomp/transcode.py
Normal file
34
packages/musicomp/src/musicomp/transcode.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from pathlib import Path
|
||||
from subprocess import run
|
||||
from os import PathLike
|
||||
|
||||
|
||||
class TranscodingError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def transcode(src: str | PathLike[str], dst: str | PathLike[str]) -> None:
|
||||
dst = Path(dst)
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dst.is_file():
|
||||
dst.unlink()
|
||||
|
||||
opusenc: tuple[str, ...] = (
|
||||
"opusenc",
|
||||
"--quiet",
|
||||
"--bitrate",
|
||||
"96.000",
|
||||
"--music",
|
||||
"--vbr",
|
||||
"--comp",
|
||||
"10",
|
||||
"--",
|
||||
str(src),
|
||||
str(dst),
|
||||
)
|
||||
|
||||
cp = run(opusenc)
|
||||
if cp.returncode != 0:
|
||||
raise TranscodingError(f"opusenc exited with code {cp.returncode}")
|
11
packages/puter/default.nix
Normal file
11
packages/puter/default.nix
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
writeShellApplication,
|
||||
nixos-rebuild,
|
||||
}:
|
||||
writeShellApplication {
|
||||
name = "puter";
|
||||
runtimeInputs = [
|
||||
nixos-rebuild
|
||||
];
|
||||
text = builtins.readFile ./puter.bash;
|
||||
}
|
Loading…
Reference in a new issue