docs: use MarkDown for option docs

This commit is contained in:
Naïm Favier 2022-11-30 22:30:45 +01:00
parent bc667fb6af
commit 4fcab839d7
No known key found for this signature in database
GPG Key ID: 95AFCE8211908325
13 changed files with 1403 additions and 1487 deletions

View File

@ -5,9 +5,9 @@
version: 2 version: 2
build: build:
os: ubuntu-20.04 os: ubuntu-22.04
tools: tools:
python: "3.9" python: "3"
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py

View File

@ -79,7 +79,7 @@ in
``` ```
Warning: this is stored in plaintext in the Nix store! Warning: this is stored in plaintext in the Nix store!
Use `hashedPasswordFile` instead. Use {option}`mailserver.loginAccounts.<name>.hashedPasswordFile` instead.
''; '';
}; };
@ -156,7 +156,7 @@ in
description = '' description = ''
Specifies if the account should be a send-only account. Specifies if the account should be a send-only account.
Emails sent to send-only accounts will be rejected from Emails sent to send-only accounts will be rejected from
unauthorized senders with the sendOnlyRejectMessage unauthorized senders with the `sendOnlyRejectMessage`
stating the reason. stating the reason.
''; '';
}; };
@ -200,7 +200,7 @@ in
description = '' description = ''
Folder to store search indices. If null, indices are stored Folder to store search indices. If null, indices are stored
along with email, which could not necessarily be desirable, along with email, which could not necessarily be desirable,
especially when the fullTextSearch option is enable since especially when {option}`mailserver.fullTextSearch.enable` is `true` since
indices it creates are voluminous and do not need to be backed indices it creates are voluminous and do not need to be backed
up. up.
@ -242,8 +242,8 @@ in
default = "no"; default = "no";
description = '' description = ''
Fail searches when no index is available. If set to Fail searches when no index is available. If set to
<literal>body</literal>, then only body searches (as opposed to `body`, then only body searches (as opposed to
header) are affected. If set to <literal>no</literal>, searches may header) are affected. If set to `no`, searches may
fall back to a very slow brute force search. fall back to a very slow brute force search.
''; '';
}; };
@ -281,7 +281,7 @@ in
randomizedDelaySec = mkOption { randomizedDelaySec = mkOption {
type = types.int; type = types.int;
default = 1000; default = 1000;
description = "Run the maintenance job not exactly at the time specified with <literal>onCalendar</literal>, but plus or minus this many seconds."; description = "Run the maintenance job not exactly at the time specified with `onCalendar`, but plus or minus this many seconds.";
}; };
}; };
}; };
@ -333,7 +333,7 @@ in
the value {`"user@example.com" = "user@elsewhere.com";}` the value {`"user@example.com" = "user@elsewhere.com";}`
means that mails to `user@example.com` are forwarded to means that mails to `user@example.com` are forwarded to
`user@elsewhere.com`. The difference with the `user@elsewhere.com`. The difference with the
`extraVirtualAliases` option is that `user@elsewhere.com` {option}`mailserver.extraVirtualAliases` option is that `user@elsewhere.com`
can't send mail as `user@example.com`. Also, this option can't send mail as `user@example.com`. Also, this option
allows to forward mails to external addresses. allows to forward mails to external addresses.
''; '';
@ -367,7 +367,7 @@ in
description = '' description = ''
The unix UID of the virtual mail user. Be mindful that if this is The unix UID of the virtual mail user. Be mindful that if this is
changed, you will need to manually adjust the permissions of changed, you will need to manually adjust the permissions of
mailDirectory. `mailDirectory`.
''; '';
}; };
@ -582,7 +582,7 @@ in
type = types.str; type = types.str;
default = "mail"; default = "mail";
description = '' description = ''
The DKIM selector.
''; '';
}; };
@ -590,7 +590,7 @@ in
type = types.path; type = types.path;
default = "/var/dkim"; default = "/var/dkim";
description = '' description = ''
The DKIM directory.
''; '';
}; };
@ -601,7 +601,7 @@ in
How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys.
If you have already deployed a key with a different number of bits than specified If you have already deployed a key with a different number of bits than specified
here, then you should use a different selector (dkimSelector). In order to get here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
this package to generate a key with the new number of bits, you will either have to this package to generate a key with the new number of bits, you will either have to
change the selector or delete the old key file. change the selector or delete the old key file.
''; '';
@ -673,7 +673,7 @@ in
type = types.str; type = types.str;
example = "ACME Corp."; example = "ACME Corp.";
description = '' description = ''
The name of your organization used in the <literal>org_name</literal> attribute in The name of your organization used in the `org_name` attribute in
DMARC reports. DMARC reports.
''; '';
}; };
@ -681,7 +681,7 @@ in
fromName = mkOption { fromName = mkOption {
type = types.str; type = types.str;
default = cfg.dmarcReporting.organizationName; default = cfg.dmarcReporting.organizationName;
defaultText = literalExpression "organizationName"; defaultText = literalMD "{option}`mailserver.dmarcReporting.organizationName`";
description = '' description = ''
The sender name for DMARC reports. Defaults to the organization name. The sender name for DMARC reports. Defaults to the organization name.
''; '';
@ -738,7 +738,7 @@ in
if (ip == "0.0.0.0" || ip == "::") if (ip == "0.0.0.0" || ip == "::")
then "127.0.0.1" then "127.0.0.1"
else if isIpv6 ip then "[${ip}]" else ip; else if isIpv6 ip then "[${ip}]" else ip;
defaultText = lib.literalDocBook "computed from <option>config.services.redis.servers.rspamd.bind</option>"; defaultText = lib.literalMD "computed from `config.services.redis.servers.rspamd.bind`";
description = '' description = ''
Address that rspamd should use to contact redis. Address that rspamd should use to contact redis.
''; '';
@ -776,7 +776,7 @@ in
sendingFqdn = mkOption { sendingFqdn = mkOption {
type = types.str; type = types.str;
default = cfg.fqdn; default = cfg.fqdn;
defaultText = "config.mailserver.fqdn"; defaultText = lib.literalMD "{option}`mailserver.fqdn`";
example = "myserver.example.com"; example = "myserver.example.com";
description = '' description = ''
The fully qualified domain name of the mail server used to The fully qualified domain name of the mail server used to
@ -792,7 +792,7 @@ in
This setting allows the server to identify as This setting allows the server to identify as
myserver.example.com when forwarding mail, independently of myserver.example.com when forwarding mail, independently of
`fqdn` (which, for SSL reasons, should generally be the name {option}`mailserver.fqdn` (which, for SSL reasons, should generally be the name
to which the user connects). to which the user connects).
Set this to the name to which the sending IP's reverse DNS Set this to the name to which the sending IP's reverse DNS
@ -864,7 +864,7 @@ in
start program = "${pkgs.systemd}/bin/systemctl start rspamd" start program = "${pkgs.systemd}/bin/systemctl start rspamd"
stop program = "${pkgs.systemd}/bin/systemctl stop rspamd" stop program = "${pkgs.systemd}/bin/systemctl stop rspamd"
''; '';
defaultText = lib.literalDocBook "see source"; defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)";
description = '' description = ''
The configuration used for monitoring via monit. The configuration used for monitoring via monit.
Use a mail address that you actively check and set it via 'set alert ...'. Use a mail address that you actively check and set it via 'set alert ...'.
@ -881,7 +881,8 @@ in
description = '' description = ''
The location where borg saves the backups. The location where borg saves the backups.
This can be a local path or a remote location such as user@host:/path/to/repo. This can be a local path or a remote location such as user@host:/path/to/repo.
It is exported and thus available as an environment variable to cmdPreexec and cmdPostexec. It is exported and thus available as an environment variable to
{option}`mailserver.borgbackup.cmdPreexec` and {option}`mailserver.borgbackup.cmdPostexec`.
''; '';
}; };
@ -941,7 +942,7 @@ in
default = "none"; default = "none";
description = '' description = ''
The backup can be encrypted by choosing any other value than 'none'. The backup can be encrypted by choosing any other value than 'none'.
When using encryption the password / passphrase must be provided in passphraseFile. When using encryption the password/passphrase must be provided in `passphraseFile`.
''; '';
}; };
@ -964,6 +965,7 @@ in
locations = mkOption { locations = mkOption {
type = types.listOf types.path; type = types.listOf types.path;
default = [cfg.mailDirectory]; default = [cfg.mailDirectory];
defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]";
description = "The locations that are to be backed up by borg."; description = "The locations that are to be backed up by borg.";
}; };
@ -984,9 +986,10 @@ in
default = null; default = null;
description = '' description = ''
The command to be executed before each backup operation. The command to be executed before each backup operation.
This is called prior to borg init in the same script that runs borg init and create and cmdPostexec. This is called prior to borg init in the same script that runs borg init and create and `cmdPostexec`.
Example: '';
export BORG_RSH="ssh -i /path/to/private/key" example = ''
export BORG_RSH="ssh -i /path/to/private/key"
''; '';
}; };
@ -996,7 +999,7 @@ in
description = '' description = ''
The command to be executed after each backup operation. The command to be executed after each backup operation.
This is called after borg create completed successfully and in the same script that runs This is called after borg create completed successfully and in the same script that runs
cmdPreexec, borg init and create. `cmdPreexec`, borg init and create.
''; '';
}; };
@ -1009,7 +1012,7 @@ in
example = true; example = true;
description = '' description = ''
Whether to enable automatic reboot after kernel upgrades. Whether to enable automatic reboot after kernel upgrades.
This is to be used in conjunction with system.autoUpgrade.enable = true" This is to be used in conjunction with `system.autoUpgrade.enable = true;`
''; '';
}; };
method = mkOption { method = mkOption {

View File

@ -1,5 +1,5 @@
Add Roundcube, a webmail Add Roundcube, a webmail
======================= ========================
The NixOS module for roundcube nearly works out of the box with SNM. By The NixOS module for roundcube nearly works out of the box with SNM. By
default, it sets up a nginx virtual host to serve the webmail, other web default, it sets up a nginx virtual host to serve the webmail, other web

View File

@ -18,7 +18,7 @@
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'NixOS Mailserver' project = 'NixOS Mailserver'
copyright = '2020, NixOS Mailserver Contributors' copyright = '2022, NixOS Mailserver Contributors'
author = 'NixOS Mailserver Contributors' author = 'NixOS Mailserver Contributors'
@ -28,8 +28,16 @@ author = 'NixOS Mailserver Contributors'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'myst_parser'
] ]
myst_enable_extensions = [
'colon_fence',
'linkify',
]
smartquotes = False
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ['_templates']
@ -50,4 +58,4 @@ html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] html_static_path = []

View File

@ -30,8 +30,8 @@ run tests manually. For instance:
Contributing to the documentation Contributing to the documentation
--------------------------------- ---------------------------------
The documentation is written in RST, build with Sphinx and published The documentation is written in RST (except option documentation which is in MarkDown),
by `Read the Docs <https://readthedocs.org/>`_. built with Sphinx and published by `Read the Docs <https://readthedocs.org/>`_.
For the syntax, see `RST/Sphinx Cheatsheet For the syntax, see `RST/Sphinx Cheatsheet
<https://sphinx-tutorial.readthedocs.io/cheatsheet/>`_. <https://sphinx-tutorial.readthedocs.io/cheatsheet/>`_.
@ -47,11 +47,11 @@ documentation:
$ firefox ./_build/html/index.html $ firefox ./_build/html/index.html
Note if you modify some NixOS mailserver options, you would also need Note if you modify some NixOS mailserver options, you would also need
to regenerate the ``options.rst`` file: to regenerate the ``options.md`` file:
:: ::
$ nix-shell --run generate-rst-options $ nix-shell --run generate-options
Nixops Nixops
------ ------

1202
docs/options.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,2 +1,4 @@
sphinx==4.0.2 sphinx ~= 5.3
sphinx_rtd_theme==0.5.2 sphinx_rtd_theme ~= 1.1
myst-parser ~= 0.18
linkify-it-py ~= 2.0

View File

@ -16,6 +16,22 @@
"type": "gitlab" "type": "gitlab"
} }
}, },
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1668681692,
"narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "009399224d5e398d03b22badca40a37ac85412a1",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1669542132, "lastModified": 1669542132,
@ -49,6 +65,7 @@
"root": { "root": {
"inputs": { "inputs": {
"blobs": "blobs", "blobs": "blobs",
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nixpkgs-22_11": "nixpkgs-22_11", "nixpkgs-22_11": "nixpkgs-22_11",
"utils": "utils" "utils": "utils"

View File

@ -2,6 +2,10 @@
description = "A complete and Simple Nixos Mailserver"; description = "A complete and Simple Nixos Mailserver";
inputs = { inputs = {
flake-compat = {
url = "github:edolstra/flake-compat";
flake = false;
};
utils.url = "github:numtide/flake-utils"; utils.url = "github:numtide/flake-utils";
nixpkgs.url = "flake:nixpkgs/nixos-unstable"; nixpkgs.url = "flake:nixpkgs/nixos-unstable";
nixpkgs-22_11.url = "flake:nixpkgs/nixos-22.11"; nixpkgs-22_11.url = "flake:nixpkgs/nixos-22.11";
@ -11,7 +15,8 @@
}; };
}; };
outputs = { self, utils, blobs, nixpkgs, nixpkgs-22_11 }: let outputs = { self, utils, blobs, nixpkgs, nixpkgs-22_11, ... }: let
lib = nixpkgs.lib;
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
releases = [ releases = [
@ -43,22 +48,18 @@
# external-21_05 = <derivation>; # external-21_05 = <derivation>;
# ... # ...
# } # }
allTests = pkgs.lib.listToAttrs ( allTests = lib.listToAttrs (
pkgs.lib.flatten (map (t: map (r: genTest t r) releases) testNames)); lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.; mailserverModule = import ./.;
# Generate a rst file describing options of the NixOS mailserver module # Generate a MarkDown file describing the options of the NixOS mailserver module
generateRstOptions = let optionsDoc = let
eval = import (pkgs.path + "/nixos/lib/eval-config.nix") { eval = lib.evalModules {
inherit system;
modules = [ modules = [
mailserverModule mailserverModule
{ {
# Because the blockbook package is currently broken (we _module.check = false;
# don't care about this package but it is part of the
# NixOS module evaluation)
nixpkgs.config.allowBroken = true;
mailserver = { mailserver = {
fqdn = "mx.example.com"; fqdn = "mx.example.com";
domains = [ domains = [
@ -71,27 +72,26 @@
}; };
} }
]; ];
}; };
options = pkgs.nixosOptionsDoc { options = builtins.toFile "options.json" (builtins.toJSON
options = eval.options; (lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
}; (lib.optionAttrSetToDocList eval.options)));
in pkgs.runCommand "options.rst" { buildInputs = [pkgs.python3]; } '' in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
echo Generating options.rst from ${options.optionsJSON}/share/doc/nixos/options.json echo "Generating options.md from ${options}"
python ${./scripts/generate-rst-options.py} ${options.optionsJSON}/share/doc/nixos/options.json > $out python ${./scripts/generate-options.py} ${options} > $out
''; '';
# This is a script helping users to generate this file in the docs directory # This is a script helping users to generate this file in the docs directory
generateRstOptionsScript = pkgs.writeScriptBin "generate-rst-options" '' generateOptions = pkgs.writeShellScriptBin "generate-options" ''
cp -v ${generateRstOptions} ./docs/options.rst install -vm644 ${optionsDoc} ./docs/options.md
''; '';
# This is to ensure we don't forget to update the options.rst file # This is to ensure we don't forget to update the options.md file
testRstOptions = pkgs.runCommand "test-rst-options" {} '' testOptions = pkgs.runCommand "test-options" {} ''
if ! diff -q ${./docs/options.rst} ${generateRstOptions} if ! diff -q ${./docs/options.md} ${optionsDoc}
then then
echo "The file ./docs/options.rst is not up-to-date and needs to be regenerated!" echo "The file ./docs/options.md is not up-to-date and needs to be regenerated!"
echo " hint: run 'nix-shell --run generate-rst-options' to generate this file" echo " hint: run 'nix-shell --run generate-options' to generate this file"
exit 1 exit 1
fi fi
echo "test: ok" > $out echo "test: ok" > $out
@ -99,43 +99,43 @@
documentation = pkgs.stdenv.mkDerivation { documentation = pkgs.stdenv.mkDerivation {
name = "documentation"; name = "documentation";
src = pkgs.lib.sourceByRegex ./docs ["logo.png" "conf.py" "Makefile" ".*rst$"]; src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
buildInputs = [( buildInputs = [(
pkgs.python3.withPackages(p: [ pkgs.python3.withPackages (p: with p; [
p.sphinx sphinx
p.sphinx_rtd_theme sphinx_rtd_theme
myst-parser
]) ])
)]; )];
buildPhase = '' buildPhase = ''
cp ${generateRstOptions} options.rst cp ${optionsDoc} options.md
mkdir -p _static
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
export SOURCE_DATE_EPOCH=$(${pkgs.coreutils}/bin/date +%s) unset SOURCE_DATE_EPOCH
make html make html
''; '';
installPhase = '' installPhase = ''
cp -r _build/html $out cp -Tr _build/html $out
''; '';
}; };
in rec { in {
nixosModules.mailserver = mailserverModule ; nixosModules = rec {
nixosModule = self.nixosModules.mailserver; mailserver = mailserverModule;
default = mailserver;
};
nixosModule = self.nixosModules.default; # compatibility
hydraJobs.${system} = allTests // { hydraJobs.${system} = allTests // {
test-rst-options = testRstOptions; test-options = testOptions;
inherit documentation; inherit documentation;
}; };
checks.${system} = allTests; checks.${system} = allTests;
devShell.${system} = pkgs.mkShell { devShells.${system}.default = pkgs.mkShell {
buildInputs = with pkgs; [ inputsFrom = [ documentation ];
generateRstOptionsScript packages = with pkgs; [
(python3.withPackages (p: with p; [ generateOptions
sphinx
sphinx_rtd_theme
]))
jq
clamav clamav
]; ];
}; };
devShell.${system} = self.devShells.${system}.default; # compatibility
}; };
} }

View File

@ -0,0 +1,81 @@
import json
import sys
header = """
# Mailserver options
## `mailserver`
"""
template = """
`````{{option}} {key}
{description}
{type}
{default}
{example}
`````
"""
f = open(sys.argv[1])
options = json.load(f)
groups = ["mailserver.loginAccounts",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borgbackup"]
def render_option_value(opt, attr):
if attr in opt:
if isinstance(opt[attr], dict) and '_type' in opt[attr]:
if opt[attr]['_type'] == 'literalExpression':
if '\n' in opt[attr]['text']:
res = '\n```nix\n' + opt[attr]['text'].rstrip('\n') + '\n```'
else:
res = '```{}```'.format(opt[attr]['text'])
elif opt[attr]['_type'] == 'literalMD':
res = opt[attr]['text']
else:
s = str(opt[attr])
if s == "":
res = '`""`'
elif '\n' in s:
res = '\n```\n' + s.rstrip('\n') + '\n```'
else:
res = '```{}```'.format(s)
res = '- ' + attr + ': ' + res
else:
res = ""
return res
def print_option(opt):
if isinstance(opt['description'], dict) and '_type' in opt['description']: # mdDoc
description = opt['description']['text']
else:
description = opt['description']
print(template.format(
key=opt['name'],
description=description or "",
type="- type: ```{}```".format(opt['type']),
default=render_option_value(opt, 'default'),
example=render_option_value(opt, 'example')))
print(header)
for opt in options:
if any([opt['name'].startswith(c) for c in groups]):
continue
print_option(opt)
for c in groups:
print('## `{}`'.format(c))
print()
for opt in options:
if opt['name'].startswith(c):
print_option(opt)

View File

@ -1,87 +0,0 @@
import json
import sys
import re
import textwrap
header = """
Mailserver Options
==================
mailserver
~~~~~~~~~~
"""
template = """
{key}
{line}
{description}
{type}
{default}
{example}
"""
f = open(sys.argv[1])
options = json.load(f)
options = {k: v for k, v in options.items()
if k.startswith("mailserver.")}
groups = ["mailserver.loginAccount",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borg"]
def render_option_value(opt, attr):
if attr in opt:
if isinstance(opt[attr], dict) and '_type' in opt[attr]:
if opt[attr]['_type'] == 'literalExpression':
if '\n' in opt[attr]['text']:
res = '\n.. code:: nix\n\n' + textwrap.indent(opt[attr]['text'], ' ') + '\n'
else:
res = '``{}``'.format(opt[attr]['text'])
elif opt[attr]['_type'] == 'literalDocBook':
res = opt[attr]['text']
else:
s = str(opt[attr])
if s == "":
res = '``""``'
elif '\n' in s:
res = '\n.. code::\n\n' + textwrap.indent(s, ' ') + '\n'
else:
res = '``{}``'.format(s)
res = '- ' + attr + ': ' + res
else:
res = ""
return res
def print_option(name, value):
print(template.format(
key=name,
line="-"*len(name),
description=value['description'] or "",
type="- type: ``{}``".format(value['type']),
default=render_option_value(value, 'default'),
example=render_option_value(value, 'example')))
print(header)
for k, v in options.items():
if any([k.startswith(c) for c in groups]):
continue
print_option(k, v)
for c in groups:
print(c)
print("~"*len(c))
print()
for k, v in options.items():
if k.startswith(c):
print_option(k, v)

View File

@ -1 +1,10 @@
(import (builtins.fetchGit "https://github.com/edolstra/flake-compat") { src = ./.; }).shellNix (import
(
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
)
{ src = ./.; }
).shellNix