diff --git a/dovetail.nix b/dovetail.nix deleted file mode 100644 index 30d64ed..0000000 --- a/dovetail.nix +++ /dev/null @@ -1,8 +0,0 @@ -{ config, lib, ... }: { - options.dovetail.self-did = lib.mkOption { - type = lib.types.str; - }; - - config._module.args.dovetail.mkNamespaced = name: - "${config.dovetail.self-did}/${name}"; -} diff --git a/dovetail.toml b/dovetail.toml new file mode 100644 index 0000000..c59c209 --- /dev/null +++ b/dovetail.toml @@ -0,0 +1,14 @@ +did = "did:plc:zviscnpwyvj6y32agi5davn5" + +[hosts.zephyr] +system = "x86_64-linux" + +[hosts.zephyr.users.icorbrey] +stateVersion = "25.05" +tags = ["cli", "flatpak"] + +[recipes] +"./recipes/burin" = { tags = ["cli"] } +"./recipes/flatpak" = { tags = ["flatpak"] } +"./recipes/macos-scroll" = { tags = ["darwin"] } +"./recipes/wsl-bridge" = { tags = ["wsl"] } diff --git a/dovetail/default.nix b/dovetail/default.nix new file mode 100644 index 0000000..631d1c4 --- /dev/null +++ b/dovetail/default.nix @@ -0,0 +1,62 @@ +{ lib, ... }: let + v = import ./valibot.nix { inherit lib; }; + + schemas = import ./schemas.nix { inherit lib v; }; + + selectedBy = tags: _: entry: + lib.any (tag: lib.elem tag tags) entry.tags; +in { inputs, root }: let + inherit (inputs) home-manager nixpkgs; + + manifest = lib.importTOML (root + "/dovetail.toml") + |> v.parse schemas.root; + + inRepo = key: root + "/${lib.removePrefix "./" key}"; + + recipeManifests = manifest.recipes + |> lib.mapAttrs (key: _: + lib.importTOML (inRepo key + "/dovetail.toml") + |> v.parse schemas.recipe); + + topicModules = builtins.readDir (root + "/topics") + |> lib.attrNames + |> lib.filter (lib.hasSuffix ".nix") + |> lib.map (file: root + "/topics/${file}"); + + mkHome = hostname: host: username: user: let + pkgs = nixpkgs.legacyPackages.${host.system}; + tags = host.tags ++ user.tags; + + recipes = manifest.recipes + |> lib.filterAttrs (selectedBy tags) + |> lib.attrNames + |> lib.filter (key: recipeManifests.${key}.class == "home-manager") + |> lib.map inRepo; + + packages = manifest.packages + |> lib.filterAttrs (selectedBy tags) + |> lib.attrNames + |> lib.map (name: lib.getAttrFromPath (lib.splitString "." name) pkgs); + + identity = { + _module.args.dovetail.mkNamespaced = name: "${manifest.did}/${name}"; + + home.stateVersion = user.stateVersion; + home.packages = packages; + home.username = username; + + home.homeDirectory = + if lib.hasSuffix "-darwin" host.system + then "/Users/${username}" + else "/home/${username}"; + }; + in home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [identity] ++ topicModules ++ recipes; + }; +in { + homeConfigurations = manifest.hosts |> lib.concatMapAttrs (hostname: host: + host.users |> lib.mapAttrs' (username: user: + lib.nameValuePair "${username}@${hostname}" + (mkHome hostname host username user))); +} diff --git a/dovetail/schemas.nix b/dovetail/schemas.nix new file mode 100644 index 0000000..3a91a9f --- /dev/null +++ b/dovetail/schemas.nix @@ -0,0 +1,55 @@ +{ lib, v }: let + tags = v.array v.string |> v.default []; + + user = v.object { + inherit tags; + + stateVersion = v.string; + }; + + host = v.object { + inherit tags; + system = v.picklist lib.systems.flakeExposed; + users = v.record v.string user |> v.default {}; + }; + + package = v.object { inherit tags; }; + + recipe = v.object { inherit tags; }; + + did = v.string |> v.regex "did:(plc|web):.+" + "must be a valid Atproto DID"; + + recipePath = v.string |> v.regex "\\./.+" + "must be a ./path inside the repo"; +in { + root = v.object { + inherit did; + + packages = v.record v.string package |> v.default {}; + recipes = v.record recipePath recipe |> v.default {}; + hosts = v.record v.string host |> v.default {}; + }; + + recipe = v.object { + author = v.string; + name = v.string; + + keywords = v.array v.string |> v.default []; + description = v.string |> v.optional; + + class = v.picklist [ + "home-manager" + "nix-darwin" + "nixos" + ]; + + topics = v.object { + writes = v.array v.string |> v.default []; + reads = v.array v.string |> v.default []; + } |> v.default { + writes = []; + reads = []; + }; + }; +} diff --git a/dovetail/valibot.nix b/dovetail/valibot.nix new file mode 100644 index 0000000..0e1faca --- /dev/null +++ b/dovetail/valibot.nix @@ -0,0 +1,294 @@ +{ lib }: let + result = let + err = issues: { err = issues; }; + ok = value: { ok = value; }; + isOk = result: result ? ok; + in { + inherit ok err isOk; + + map = fn: result: + if isOk result + then fn result.ok |> ok + else result; + + mapErr = fn: result: + if isOk result + then result + else fn (result.err) |> err; + + andThen = fn: result: + if isOk result + then fn result.ok + else result; + + unwrapOrElse = fn: result: + if isOk result + then result.ok + else fn result.err; + + collect = results: let + issues = results |> lib.concatMap (res: res.err or []); + in + if issues == [] + then results |> map (res: res.ok) |> ok + else err issues; + }; + + issue = let + show = builtins.toJSON; + + describe = input: let + type = builtins.typeOf input; + descriptions = { + string = "a string"; + bool = "a boolean"; + int = "an integer"; + float = "a float"; + list = "an array"; + set = "a table"; + }; + in descriptions.${type} or type; + + fmt.key = key: + if builtins.match "[A-Za-z0-9_-]+" key != null + then key + else show key; + + fmt.segment = segment: + if lib.isInt segment + then "[${lib.toString segment}]" + else ".${fmt.key segment}"; + + fmt.path = path: + if path == [] + then "(root)" + else path + |> lib.concatMapStrings fmt.segment + |> lib.removePrefix "."; + in { + inherit show; + + at = path: message: { + inherit path message; + }; + + rebase = from: to: reported: reported // { + path = to ++ lib.drop(lib.length from) reported.path; + }; + + expected.type = what: input: + "expected ${what}, got ${describe input}"; + + expected.value = what: input: + "expected ${what}, got ${show input}"; + + suggest = names: input: let + nearest = names |> lib.filter (lib.strings.levenshteinAtMost 2 input); + in lib.optionalString (nearest != []) + "; did you mean ${lib.concatMapStringsSep " or " show nearest}?"; + + format = lib.concatMapStrings + (reported: "\n - ${fmt.path reported.path}: ${reported.message}"); + }; + + guard = let + predicate = kind: holds: mkMessage: { + inherit kind; + + run = path: input: + if holds input + then result.ok input + else result.err [ + (issue.at path (mkMessage input)) + ]; + }; + in { + inherit predicate; + + type = kind: holds: what: + predicate kind holds (issue.expected.type what); + }; + + schema = { + andThen = step: base: base // { + run = path: input: + base.run path input + |> result.andThen (step.run path); + }; + + map = fn: base: base // { + run = path: input: + base.run path input + |> result.map fn; + }; + + annotate = metadata: base: + base // metadata; + }; + + step = { + elements = item: { + kind = "elements"; + run = path: input: input + |> lib.imap0 (i: item.run (path ++ [i])) + |> result.collect; + }; + + entries = key: value: { + kind = "entries"; + run = path: input: input + |> lib.mapAttrsToList (name: element: + result.collect [ + (key.run (path ++ [name]) name) + (value.run (path ++ [name]) element) + ] + |> result.map (pair: lib.nameValuePair + (lib.elemAt pair 0) + (lib.elemAt pair 1) + ) + ) + |> result.collect + |> result.map lib.listToAttrs; + }; + + members = strict: fields: let + names = lib.attrNames fields; + + required = names + |> lib.filter (name: fields.${name}.kind != "optional"); + + defaults = names + |> lib.filter (name: fields.${name} ? default) + |> lib.map (name: lib.nameValuePair name fields.${name}.default) + |> lib.listToAttrs; + in { + kind = "members"; + run = path: input: let + present = names + |> lib.filter (name: input ? ${name}) + |> lib.map (name: + fields.${name}.run (path ++ [name]) input.${name} + |> result.map (lib.nameValuePair name) + ); + + missing = required + |> lib.filter (name: !(input ? ${name})) + |> lib.map (name: result.err [ + (issue.at (path ++ [name]) "missing required key") + ]); + + unknown = input + |> lib.attrNames + |> lib.filter (name: !(fields ? ${name})) + |> lib.map (name: result.err [ + (issue.at (path ++ [name]) "unknown key${issue.suggest names name}") + ]) + |> lib.optionals strict; + in present ++ missing ++ unknown + |> result.collect + |> result.map (pairs: defaults // lib.listToAttrs pairs); + }; + }; + + v = { + inherit result issue; + + unknown = { + kind = "unknown"; + run = _: result.ok; + }; + + boolean = guard.type "boolean" lib.isBool "a boolean"; + integer = guard.type "integer" lib.isInt "an integer"; + string = guard.type "string" lib.isString "a string"; + + number = guard.type "number" + (input: lib.isInt input || lib.isFloat input) + "a number"; + + literal = expected: + guard.predicate "literal" + (input: input == expected) + (issue.expected.value (issue.show expected)); + + picklist = options: + guard.predicate "picklist" + (input: lib.elem input options) + (issue.expected.value "one of ${lib.concatMapStringsSep ", " issue.show options}") + |> schema.annotate { inherit options; }; + + array = item: + guard.type "array" lib.isList "an array" + |> schema.andThen (step.elements item) + |> schema.annotate { inherit item; }; + + record = key: value: + guard.type "record" lib.isAttrs "a table" + |> schema.andThen (step.entries key value) + |> schema.annotate { inherit key value; }; + + looseObject = fields: + guard.type "object" lib.isAttrs "a table" + |> schema.andThen (step.members false fields) + |> schema.annotate { inherit fields; }; + + object = fields: + guard.type "object" lib.isAttrs "a table" + |> schema.andThen (step.members true fields) + |> schema.annotate { inherit fields; }; + + optional = base: base |> schema.annotate { + kind = "optional"; + schema = base; + }; + + default = default: base: base |> schema.annotate { + inherit default; + + kind = "optional"; + schema = base; + }; + + union = options: { + inherit options; + + kind = "union"; + run = path: input: let + outcomes = options |> lib.map (option: option.run path input); + messages = outcomes |> lib.concatMapStringsSep "; " (failed: (lib.head failed.err).message); + in outcomes |> lib.findFirst result.isOk (result.err [ + (issue.at path "matched none of: ${messages}") + ]); + }; + + check = holds: message: schema.andThen + (guard.predicate "check" holds (_: message)); + + regex = pattern: message: schema.andThen + (guard.predicate "regex" + (input: builtins.match pattern input != null) + (_: message)); + + transform = schema.map; + + rawCheck = fn: schema.andThen { + run = path: input: let + issues = fn input |> lib.map (issue.rebase [] path); + in if issues == [] + then result.ok input + else result.err issues; + }; + + forward = subpath: action: schema.andThen { + run = path: input: + (action v.unknown).run path input + |> result.mapErr (lib.map (issue.rebase path (path ++ subpath))); + }; + + safeParse = base: input: base.run [] input; + + parse = base: input: + base.run [] input + |> result.unwrapOrElse (issues: throw "invalid input:${issue.format issues}"); + }; +in v diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..63b07f2 --- /dev/null +++ b/flake.nix @@ -0,0 +1,11 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + inputs.home-manager.url = "github:nix-community/home-manager"; + inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs"; + + outputs = inputs: import ./dovetail { inherit (inputs.nixpkgs) lib; } { + inherit inputs; + root = ./.; + }; +}