diff --git a/examples/dream2nix-packages-php/composer-project/default.nix b/examples/dream2nix-packages-php/composer-project/default.nix new file mode 100644 index 00000000..cdba71ae --- /dev/null +++ b/examples/dream2nix-packages-php/composer-project/default.nix @@ -0,0 +1,31 @@ +{ + lib, + config, + dream2nix, + ... +}: { + imports = [ + dream2nix.modules.drv-parts.php-composer-lock + dream2nix.modules.drv-parts.php-granular + ]; + + mkDerivation = { + src = config.deps.fetchFromGitHub { + owner = "Gipetto"; + repo = "CowSay"; + rev = config.version; + sha256 = "sha256-jriyCzmvT2pPeNQskibBg0Bsh+h64cAEO+yOOfX2wbA="; + }; + }; + + deps = {nixpkgs, ...}: { + inherit + (nixpkgs) + fetchFromGitHub + stdenv + ; + }; + + name = "cowsay"; + version = "1.2.0"; +} diff --git a/lib/internal/php-semver.nix b/lib/internal/php-semver.nix new file mode 100644 index 00000000..da0378ac --- /dev/null +++ b/lib/internal/php-semver.nix @@ -0,0 +1,197 @@ +{lib, ...}: let + l = lib // builtins; + + # Replace a list entry at defined index with set value + ireplace = idx: value: list: + l.genList (i: + if i == idx + then value + else (l.elemAt list i)) (l.length list); + + orBlank = x: + if x != null + then x + else ""; + + operators = let + mkComparison = ret: version: v: + builtins.compareVersions version v == ret; + + mkCaretComparison = version: v: let + ver = builtins.splitVersion v; + major = l.toInt (l.head ver); + upper = builtins.toString (l.toInt (l.head ver) + 1); + in + if major == 0 + then mkTildeComparison version v + else operators.">=" version v && operators."<" version upper; + + mkTildeComparison = version: v: let + ver = builtins.splitVersion v; + len = l.length ver; + truncated = + if len > 1 + then l.init ver + else ver; + idx = (l.length truncated) - 1; + minor = l.toString (l.toInt (l.elemAt truncated idx) + 1); + upper = l.concatStringsSep "." (ireplace idx minor truncated); + in + operators.">=" version v && operators."<" version upper; + in { + # Prefix operators + "==" = mkComparison 0; + ">" = mkComparison 1; + "<" = mkComparison (-1); + "!=" = v: c: !operators."==" v c; + ">=" = v: c: operators."==" v c || operators.">" v c; + "<=" = v: c: operators."==" v c || operators."<" v c; + # Semver specific operators + "~" = mkTildeComparison; + "^" = mkCaretComparison; + }; + + re = { + operators = "([=>="; + u = "<="; + }; + v = { + vl = orBlank (l.elemAt mIn 0); + vu = orBlank (l.elemAt mIn reLengths.version); + }; + } + else if mUpperBound != null + then { + ops = { + t = "-"; + l = l.elemAt mUpperBound 0; + u = "<"; + }; + v = { + vl = orBlank (l.elemAt mUpperBound reLengths.operators); + vu = orBlank (l.elemAt mUpperBound (reLengths.operators + reLengths.version)); + }; + } + else if mNone != null + then { + ops.t = "=="; + v = orBlank (l.elemAt mNone 0); + } + else throw ''Constraint "${constraintStr}" could not be parsed'' + ); + + satisfiesSingleInternal = version: constraint: let + inherit (parseConstraint constraint) ops v; + in + if ops.t == "-" + then (operators."${ops.l}" version v.vl && operators."${ops.u}" version v.vu) + else operators."${ops.t}" version v; + + # remove v from version strings: ^v1.2.3 -> ^1.2.3 + # remove branch suffix: ^1.2.x-dev -> ^1.2 + satisfiesSingle = version: constraint: let + removeStability = c: let + m = l.match "^(.*)[@][[:alpha:]]+$" c; + in + if m != null && l.length m >= 0 + then l.head m + else c; + removeSuffix = c: let + m = l.match "^(.*)[-][[:alpha:]]+$" c; + in + if m != null && l.length m >= 0 + then l.head m + else c; + wildcard = c: let + m = l.match "^([[:d:]]+.*)[.][*x]$" c; + in + if m != null && l.length m >= 0 + then "~${l.head m}.0" + else c; + removeV = c: let + m = l.match "^(.)*v([[:d:]]+[.].*)$" c; + in + if m != null && l.length m > 0 + then l.concatStrings m + else c; + isVersionLike = c: let + m = l.match "^([0-9><=!-^~*]*)$" c; + in + m != null && l.length m > 0; + cleanConstraint = removeV (wildcard (removeSuffix (removeStability (l.removePrefix "dev-" constraint)))); + cleanVersion = l.removePrefix "v" (wildcard (removeSuffix version)); + in + (l.elem (removeStability constraint) ["" "*"]) + || (version == constraint) + || ((isVersionLike cleanConstraint) && (satisfiesSingleInternal cleanVersion cleanConstraint)); + + trim = s: l.head (l.match "^[[:space:]]*(.*[^[:space:]])[[:space:]]*$" s); + splitAlternatives = v: let + # handle version alternatives: ^1.2 || ^2.0 + clean = l.replaceStrings ["||"] ["|"] v; + in + map trim (l.splitString "|" clean); + splitConjunctives = v: let + clean = + l.replaceStrings + ["," " - " " -" "- " " as "] + [" " "-" "-" "-" "##"] + v; + cleanInlineAlias = v: let + m = l.match "^(.*)[#][#](.*)$" v; + in + if m != null && l.length m > 0 + then l.head m + else v; + in + map + (x: trim (cleanInlineAlias x)) + (l.filter (x: x != "") (l.splitString " " clean)); +in rec { + # matching a version with semver + # 1.0.2 (~1.0.1 || >=2.1 <2.4) + satisfies = version: constraint: + l.any + (c: + l.all + (satisfiesSingle version) + (splitConjunctives c)) + (splitAlternatives constraint); + + # matching multiversion like the one in `provide` with semver + # (1.0|2.0) (^2.0 || 3.2 - 3.6) + multiSatisfies = multiversion: constraint: + l.any + (version: satisfies version constraint) + (splitAlternatives multiversion); +} diff --git a/modules/drv-parts/php-composer-lock/default.nix b/modules/drv-parts/php-composer-lock/default.nix new file mode 100644 index 00000000..879c0cf4 --- /dev/null +++ b/modules/drv-parts/php-composer-lock/default.nix @@ -0,0 +1,51 @@ +{ + config, + lib, + ... +}: let + l = lib // builtins; + cfg = config.php-composer-lock; + + dreamLockUtils = import ../../../lib/internal/dreamLockUtils.nix {inherit lib;}; + nodejsUtils = import ../../../lib/internal/nodejsUtils.nix {inherit lib parseSpdxId;}; + parseSpdxId = import ../../../lib/internal/parseSpdxId.nix {inherit lib;}; + prepareSourceTree = import ../../../lib/internal/prepareSourceTree.nix {inherit lib;}; + simpleTranslate2 = import ../../../lib/internal/simpleTranslate2.nix {inherit lib;}; + + translate = import ./translate.nix { + inherit lib dreamLockUtils nodejsUtils parseSpdxId simpleTranslate2; + }; + + dreamLock = translate { + projectName = cfg.composerJson.name; + projectRelPath = ""; + source = cfg.source; + tree = prepareSourceTree {source = cfg.source;}; + noDev = ! cfg.withDevDependencies; + # php = "unknown"; + inherit (cfg) composerJson composerLock; + }; +in { + imports = [ + ./interface.nix + ]; + + # declare external dependencies + deps = {nixpkgs, ...}: { + inherit + (nixpkgs) + fetchgit + fetchurl + nix + runCommandLocal + ; + }; + php-composer-lock = { + inherit dreamLock; + composerJson = l.fromJSON (l.readFile cfg.composerJsonFile); + composerLock = + if cfg.composerLockFile != null + then l.fromJSON (l.readFile cfg.composerLockFile) + else lib.mkDefault {}; + }; +} diff --git a/modules/drv-parts/php-composer-lock/interface.nix b/modules/drv-parts/php-composer-lock/interface.nix new file mode 100644 index 00000000..3dd4a2d5 --- /dev/null +++ b/modules/drv-parts/php-composer-lock/interface.nix @@ -0,0 +1,53 @@ +{ + config, + options, + lib, + ... +}: let + l = lib // builtins; + t = l.types; + cfg = config.php-composer-lock; +in { + options.php-composer-lock = l.mapAttrs (_: l.mkOption) { + dreamLock = { + type = t.attrs; + internal = true; + description = "The content of the dream2nix generated lock file"; + }; + composerJsonFile = { + type = t.path; + description = '' + The composer.json file to use. + ''; + default = cfg.source + "/composer.json"; + }; + composerJson = { + type = t.attrs; + description = "The content of the composer.json"; + }; + composerLockFile = { + type = t.nullOr t.path; + description = '' + The composer.lock file to use. + ''; + default = cfg.source + "/composer.lock"; + }; + composerLock = { + type = t.attrs; + description = "The content of the composer.lock"; + }; + source = { + type = t.either t.path t.package; + description = "Source of the package"; + default = config.mkDerivation.src; + }; + withDevDependencies = { + type = t.bool; + default = true; + description = '' + Whether to include development dependencies. + Usually it's a bad idea to disable this, as development dependencies can contain important build time dependencies. + ''; + }; + }; +} diff --git a/modules/drv-parts/php-composer-lock/translate.nix b/modules/drv-parts/php-composer-lock/translate.nix new file mode 100644 index 00000000..6c122d0b --- /dev/null +++ b/modules/drv-parts/php-composer-lock/translate.nix @@ -0,0 +1,260 @@ +{ + lib, + nodejsUtils, + dreamLockUtils, + simpleTranslate2, + ... +}: let + l = lib // builtins; + # translate from a given source and a project specification to a dream-lock. + translate = { + projectName, + projectRelPath, + composerLock, + composerJson, + tree, + noDev, + ... + }: let + inherit + (import ../../../lib/internal/php-semver.nix {inherit lib;}) + satisfies + multiSatisfies + ; + + # get the root source and project source + rootSource = tree.fullPath; + projectTree = tree.getNodeFromPath projectRelPath; + + composerJson = (projectTree.getNodeFromPath "composer.json").jsonContent; + composerLock = (projectTree.getNodeFromPath "composer.lock").jsonContent; + + # toplevel php semver + phpSemver = composerJson.require."php" or "*"; + # all the php extensions + phpExtensions = let + allDepNames = l.flatten (map (x: l.attrNames (getRequire x)) packages); + extensions = l.unique (l.filter (l.hasPrefix "ext-") allDepNames); + in + map (l.removePrefix "ext-") extensions; + + composerPluginApiSemver = l.listToAttrs (l.flatten (map + ( + pkg: let + requires = getRequire pkg; + in + l.optional (requires ? "composer-plugin-api") + { + name = "${pkg.name}@${pkg.version}"; + value = requires."composer-plugin-api"; + } + ) + packages)); + + # get cleaned pkg attributes + getRequire = pkg: + l.mapAttrs + (_: version: resolvePkgVersion pkg version) + (pkg.require or {}); + getProvide = pkg: + l.mapAttrs + (_: version: resolvePkgVersion pkg version) + (pkg.provide or {}); + getReplace = pkg: + l.mapAttrs + (_: version: resolvePkgVersion pkg version) + (pkg.replace or {}); + + resolvePkgVersion = pkg: version: + if version == "self.version" + then pkg.version + else version; + + # project package + toplevelPackage = { + name = projectName; + version = composerJson.version or "unknown"; + source = { + type = "path"; + path = rootSource; + }; + require = + (l.optionalAttrs (!noDev) (composerJson.require-dev or {})) + // (composerJson.require or {}); + }; + # all the packages + packages = + # Add the top-level package, this is not written in composer.lock + [toplevelPackage] + ++ composerLock.packages + ++ (l.optionals (!noDev) (composerLock.packages-dev or [])); + # packages with replace/provide applied + resolvedPackages = let + apply = pkg: dep: candidates: let + original = getRequire pkg; + applied = + l.filterAttrs + ( + name: semver: + !((candidates ? "${name}") && (multiSatisfies candidates."${name}" semver)) + ) + original; + in + pkg + // { + require = + applied + // ( + l.optionalAttrs + (applied != original) + {"${dep.name}" = "${dep.version}";} + ); + }; + dropMissing = pkgs: let + doDropMissing = pkg: + pkg + // { + require = + l.filterAttrs + (name: semver: l.any (pkg: (pkg.name == name) && (satisfies pkg.version semver)) pkgs) + (getRequire pkg); + }; + in + map doDropMissing pkgs; + doReplace = pkg: + l.foldl + (pkg: dep: apply pkg dep (getProvide dep)) + pkg + packages; + doProvide = pkg: + l.foldl + (pkg: dep: apply pkg dep (getReplace dep)) + pkg + packages; + in + dropMissing (map (pkg: (doProvide (doReplace pkg))) packages); + + # resolve semvers into exact versions + pinPackages = pkgs: let + clean = requires: + l.filterAttrs + (name: _: + !(l.elem name ["php" "composer-plugin-api" "composer-runtime-api"]) + && !(l.strings.hasPrefix "ext-" name)) + requires; + doPin = name: semver: + (l.head + (l.filter (dep: satisfies dep.version semver) + (l.filter (dep: dep.name == name) + resolvedPackages))) + .version; + doPins = pkg: + pkg + // { + require = l.mapAttrs doPin (clean pkg.require); + }; + in + map doPins pkgs; + in + simpleTranslate2 + ({objectsByKey, ...}: rec { + translatorName = "composer-lock"; + + # relative path of the project within the source tree. + location = projectRelPath; + + # the name of the subsystem + subsystemName = "php"; + + # Extract subsystem specific attributes. + # The structure of this should be defined in: + # ./src/specifications/{subsystem} + subsystemAttrs = { + inherit noDev; + inherit phpSemver phpExtensions; + inherit composerPluginApiSemver; + }; + + # name of the default package + defaultPackage = toplevelPackage.name; + + /* + List the package candidates which should be exposed to the user. + Only top-level packages should be listed here. + Users will not be interested in all individual dependencies. + */ + exportedPackages = { + "${defaultPackage}" = toplevelPackage.version; + }; + + /* + a list of raw package objects + If the upstream format is a deep attrset, this list should contain + a flattened representation of all entries. + */ + serializedRawObjects = pinPackages resolvedPackages; + + /* + Define extractor functions which each extract one property from + a given raw object. + (Each rawObj comes from serializedRawObjects). + + Extractors can access the fields extracted by other extractors + by accessing finalObj. + */ + extractors = { + name = rawObj: finalObj: + rawObj.name; + + version = rawObj: finalObj: + rawObj.version; + + dependencies = rawObj: finalObj: + l.attrsets.mapAttrsToList + (name: version: {inherit name version;}) + (getRequire rawObj); + + sourceSpec = rawObj: finalObj: + if rawObj ? "source" && rawObj.source.type == "path" + then { + inherit (rawObj.source) type path; + rootName = finalObj.name; + rootVersion = finalObj.version; + } + else if rawObj ? "source" && rawObj.source.type == "git" + then { + inherit (rawObj.source) type url; + rev = rawObj.source.reference; + submodules = false; + } + else if rawObj ? "dist" && rawObj.dist.type == "path" + then { + inherit (rawObj.dist) type; + path = rawObj.dist.url; + rootName = null; + rootVersion = null; + } + else + l.abort '' + Cannot find source for ${finalObj.name}@${finalObj.version}, + rawObj: ${l.toJSON rawObj} + ''; + }; + + /* + Optionally define extra extractors which will be used to key all + final objects, so objects can be accessed via: + `objectsByKey.${keyName}.${value}` + */ + keys = { + }; + + /* + Optionally add extra objects (list of `finalObj`) to be added to + the dream-lock. + */ + extraObjects = [ + ]; + }); +in + translate diff --git a/modules/drv-parts/php-granular/default.nix b/modules/drv-parts/php-granular/default.nix new file mode 100644 index 00000000..53752135 --- /dev/null +++ b/modules/drv-parts/php-granular/default.nix @@ -0,0 +1,301 @@ +{ + config, + lib, + dream2nix, + ... +}: let + l = lib // builtins; + + cfg = config.php-granular; + + dreamLock = config.php-composer-lock.dreamLock; + + fetchDreamLockSources = + import ../../../lib/internal/fetchDreamLockSources.nix + {inherit lib;}; + getDreamLockSource = import ../../../lib/internal/getDreamLockSource.nix {inherit lib;}; + readDreamLock = import ../../../lib/internal/readDreamLock.nix {inherit lib;}; + hashPath = import ../../../lib/internal/hashPath.nix { + inherit lib; + inherit (config.deps) runCommandLocal nix; + }; + + # fetchers + fetchers = { + git = import ../../../lib/internal/fetchers/git { + inherit hashPath; + inherit (config.deps) fetchgit; + }; + path = import ../../../lib/internal/fetchers/path { + inherit hashPath; + }; + }; + + dreamLockLoaded = readDreamLock {inherit dreamLock;}; + dreamLockInterface = dreamLockLoaded.interface; + + inherit (dreamLockInterface) defaultPackageName defaultPackageVersion; + + fetchedSources = fetchDreamLockSources { + inherit defaultPackageName defaultPackageVersion; + inherit (dreamLockLoaded.lock) sources; + inherit fetchers; + }; + + getSource = getDreamLockSource fetchedSources; + + inherit + (dreamLockInterface) + getDependencies # name: version: -> [ {name=; version=; } ] + # Attributes + + subsystemAttrs # attrset + packageVersions + ; + + inherit (import ../../../lib/internal/php-semver.nix {inherit lib;}) satisfies; + + # php with required extensions + php = + if satisfies config.deps.php81.version subsystemAttrs.phpSemver + then + config.deps.php81.withExtensions + ( + { + all, + enabled, + }: + l.unique (enabled + ++ (l.attrValues (l.filterAttrs (e: _: l.elem e subsystemAttrs.phpExtensions) all))) + ) + else + l.abort '' + Error: incompatible php versions. + Package "${defaultPackageName}" defines required php version: + "php": "${subsystemAttrs.phpSemver}" + Using php version "${config.deps.php81.version}" from attribute "config.deps.php81". + ''; + composer = php.packages.composer; + + # packages to export + # packages = + # {default = packages.${defaultPackageName};} + # // ( + # lib.mapAttrs + # (name: version: { + # "${version}" = allPackages.${name}.${version}; + # }) + # dreamLockInterface.packages + # ); + # devShells = + # {default = devShells.${defaultPackageName};} + # // ( + # l.mapAttrs + # (name: version: packages.${name}.${version}.devShell) + # dreamLockInterface.packages + # ); + + # Generates a derivation for a specific package name + version + commonModule = name: version: let + isTopLevel = dreamLockInterface.packages.name or null == version; + + # name = l.strings.sanitizeDerivationName name; + + dependencies = getDependencies name version; + repositories = let + transform = dep: let + intoRepository = name: version: root: { + type = "path"; + url = "${root}/vendor/${name}"; + options = { + versions = { + "${l.strings.toLower name}" = "${version}"; + }; + symlink = false; + }; + }; + getAllSubdependencies = deps: let + getSubdependencies = dep: let + subdeps = getDependencies dep.name dep.version; + in + l.flatten ([dep] ++ (getAllSubdependencies subdeps)); + in + l.flatten (map getSubdependencies deps); + depRoot = cfg.deps."${dep.name}"."${dep.version}".public; + direct = intoRepository dep.name dep.version "${depRoot}/lib"; + transitive = + map + (subdep: intoRepository subdep.name subdep.version "${depRoot}/lib/vendor/${dep.name}") + (getAllSubdependencies (getDependencies dep.name dep.version)); + in + [direct] ++ transitive; + in + l.flatten (map transform dependencies); + repositoriesString = + l.toJSON + (repositories ++ [{packagist = false;}]); + + dependenciesString = l.toJSON (l.listToAttrs ( + map + (dep: { + name = l.strings.toLower dep.name; + value = dep.version; + }) + (dependencies + ++ l.optional (subsystemAttrs.composerPluginApiSemver ? "${name}@${version}") + { + name = "composer-plugin-api"; + version = subsystemAttrs.composerPluginApiSemver."${name}@${version}"; + }) + )); + + versionString = + if version == "unknown" + then "0.0.0" + else version; + + module = {config, ...}: { + imports = [ + dream2nix.modules.drv-parts.mkDerivation + ]; + deps = {nixpkgs, ...}: + l.mapAttrs (_: l.mkDefault) { + inherit + (nixpkgs) + jq + mkShell + moreutils + php81 + stdenv + ; + }; + public.devShell = import ./devShell.nix { + inherit + name + php + composer + ; + pkg = config.public; + inherit (config.deps) mkShell; + }; + php-granular = { + composerInstallFlags = + [ + "--no-scripts" + "--no-plugins" + ] + ++ l.optional (subsystemAttrs.noDev || !isTopLevel) "--no-dev"; + }; + env = { + inherit dependenciesString repositoriesString; + }; + mkDerivation = { + src = l.mkDefault (getSource name version); + + nativeBuildInputs = with config.deps; [ + jq + composer + moreutils + ]; + buildInputs = with config.deps; + [ + php + composer + ] + ++ map (dep: cfg.deps."${dep.name}"."${dep.version}".public) + dependencies; + + passAsFile = ["repositoriesString" "dependenciesString"]; + + unpackPhase = '' + runHook preUnpack + + mkdir -p $out/lib/vendor/${name} + cd $out/lib/vendor/${name} + + # copy source + cp -r ${config.mkDerivation.src}/* . + chmod -R +w . + + # create composer.json if does not exist + if [ ! -f composer.json ]; then + echo "{}" > composer.json + fi + + # save the original composer.json for reference + cp composer.json composer.json.orig + + # set name & version + jq \ + "(.name = \"${name}\") | \ + (.version = \"${versionString}\") | \ + (.extra.patches = {})" \ + composer.json | sponge composer.json + + runHook postUnpack + ''; + configurePhase = '' + runHook preConfigure + + # disable packagist, set path repositories + jq \ + --slurpfile repositories $repositoriesStringPath \ + --slurpfile dependencies $dependenciesStringPath \ + "(.repositories = \$repositories[0]) | \ + (.require = \$dependencies[0]) | \ + (.\"require-dev\" = {})" \ + composer.json | sponge composer.json + + runHook postConfigure + ''; + buildPhase = '' + runHook preBuild + + # remove composer.lock if exists + rm -f composer.lock + + # build + composer install ${l.strings.concatStringsSep " " config.php-granular.composerInstallFlags} + + runHook postBuild + + rm -rfv vendor/*/*/vendor + ''; + installPhase = '' + runHook preInstall + + BINS=$(jq -rcM "(.bin // [])[]" composer.json) + for bin in $BINS + do + mkdir -p $out/bin + pushd $out/bin + ln -s $out/lib/vendor/${name}/$bin + popd + done + + runHook postInstall + ''; + }; + }; + in + module; +in { + imports = [ + ./interface.nix + (commonModule defaultPackageName defaultPackageVersion) + ]; + php-granular.deps = + lib.mapAttrs + (name: versions: + lib.genAttrs + versions + ( + version: + # the submodule for this dependency + {...}: { + imports = [(commonModule name version)]; + inherit name version; + } + )) + packageVersions; +} diff --git a/modules/drv-parts/php-granular/devShell.nix b/modules/drv-parts/php-granular/devShell.nix new file mode 100644 index 00000000..4398e22c --- /dev/null +++ b/modules/drv-parts/php-granular/devShell.nix @@ -0,0 +1,27 @@ +{ + name, + pkg, + mkShell, + php, + composer, +}: +mkShell { + buildInputs = [ + php + composer + ]; + shellHook = let + vendorDir = + pkg.config.package-func.result.overrideAttrs + (_: { + dontInstall = true; + }) + + "/lib/vendor/${name}/vendor"; + in '' + rm -rf ./vendor + mkdir vendor + cp -r ${vendorDir}/* vendor/ + chmod -R +w ./vendor + export PATH="$PATH:$(realpath ./vendor)/bin" + ''; +} diff --git a/modules/drv-parts/php-granular/interface.nix b/modules/drv-parts/php-granular/interface.nix new file mode 100644 index 00000000..75a9b333 --- /dev/null +++ b/modules/drv-parts/php-granular/interface.nix @@ -0,0 +1,27 @@ +{ + config, + dream2nix, + packageSets, + lib, + ... +}: let + l = lib // builtins; + t = l.types; +in { + options.php-granular = l.mapAttrs (_: l.mkOption) { + deps = { + type = t.attrsOf (t.attrsOf (t.submodule { + imports = [ + dream2nix.modules.drv-parts.core + dream2nix.modules.drv-parts.mkDerivation + ./interface.nix + ]; + _module.args = {inherit dream2nix packageSets;}; + })); + }; + composerInstallFlags = { + type = t.listOf t.string; + default = []; + }; + }; +}