Compare commits

...

47 Commits

Author SHA1 Message Date
brianmcgee
9f9ab3de19 Merge pull request 'feat(ui): SidebarPane component' (#4248) from ui/sidebar-pane into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4248
2025-07-08 07:37:47 +00:00
hsjobeki
9739a5ae2b Merge pull request 'templates: rename 'new_clan' to default' (#4244) from templates into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4244
2025-07-08 07:31:22 +00:00
Mic92
54446d751f Merge pull request 'checks/backup: no longer depend on self' (#4258) from self into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4258
2025-07-07 19:57:30 +00:00
Jörg Thalheim
7bc8e091a5 checks/backup: no longer depend on self 2025-07-07 21:51:51 +02:00
Mic92
3462d458ac Merge pull request 'override-inputs: filter out self' (#4257) from improve-perf into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4257
2025-07-07 19:32:51 +00:00
Jörg Thalheim
bd42d67b0c override-inputs: filter out self 2025-07-07 21:25:33 +02:00
Mic92
d99ca36f9f Merge pull request 'checks/eval-module-clan-vars: optimize to use filtered source' (#4255) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4255
2025-07-07 19:02:25 +00:00
Jörg Thalheim
57f9cd9eee checks/eval-module-clan-vars: optimize to use filtered source
- Replace self.filter with lib.fileset for more precise filtering
- Remove unnecessary clan-core dependency from the test
- Test only needs lib and pkgs, not the full flake context
- Prevents unnecessary rebuilds when unrelated files change
2025-07-07 20:55:04 +02:00
Mic92
a9ec94b0df Merge pull request 'checks/inventory: optimize eval tests to use filtered sources' (#4254) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4254
2025-07-07 18:48:58 +00:00
Jörg Thalheim
c64dbceceb checks/inventory: optimize eval tests to use filtered sources
Replace full flake source (self) with minimal filtered filesets to prevent
unnecessary rebuilds when unrelated files change. All three inventory eval
tests now use the same unified fileset containing only necessary files.

This follows the same optimization pattern applied to other eval tests,
significantly reducing rebuild frequency during development.
2025-07-07 20:41:20 +02:00
Mic92
5d924e0c98 Merge pull request 'docs: no longer depend on self' (#4253) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4253
2025-07-07 18:31:35 +00:00
Jörg Thalheim
6a6688019b docs: no longer depend on self 2025-07-07 20:24:11 +02:00
Mic92
f33172fa73 Merge pull request 'don't rebuild eval tests on each ci run' (#4252) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4252
2025-07-07 18:13:57 +00:00
Jörg Thalheim
00914311a4 don't rebuild eval tests on each ci run 2025-07-07 20:05:45 +02:00
Mic92
ceeb40d9ac Merge pull request 'checks/borgbackup: don't rebuild on every pull request' (#4251) from borgbackup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4251
2025-07-07 17:44:16 +00:00
Jörg Thalheim
afab33056e checks/borgbackup: don't rebuild on every pull request 2025-07-07 19:35:48 +02:00
Mic92
a5183f4b4c Merge pull request 'avoid shebang in update-private-flake-inputs' (#4250) from fix-devflake-tryeval into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4250
2025-07-07 16:56:21 +00:00
Jörg Thalheim
a686d7523b avoid shebang in update-private-flake-inputs 2025-07-07 18:48:11 +02:00
Mic92
56b784992d Merge pull request 'devFlake: don't load if sources have been filtered out' (#4249) from fix-devflake-tryeval into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4249
2025-07-07 16:47:27 +00:00
Jörg Thalheim
5f723dc376 devFlake: don't load if sources have been filtered out 2025-07-07 18:38:01 +02:00
Brian McGee
1609989734 feat(ui): SidebarPane component
* implement Divider component using Kobalte's Separator
* refine read only state of form components to match the Sidebar Pane design
* introduce a SidebarPane component with sections that can toggle between editing and view states.
2025-07-07 17:31:58 +01:00
Mic92
0c07d5cfe0 Merge pull request 'add dev flake pattern' (#4245) from private-flake into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4245
2025-07-07 16:02:29 +00:00
Jörg Thalheim
9c37ef4cbe add dev flake pattern
This allows us to have dev dependencies which are not propagated to the user.
2025-07-07 15:59:09 +00:00
Jörg Thalheim
783b6a8b06 add gitea action to update private flake inputs 2025-07-07 15:59:09 +00:00
Jörg Thalheim
4f13049ee2 put flake input overrides into a helper function 2025-07-07 15:59:09 +00:00
Johannes Kirschbauer
2f4f303048 create/clan: do initial commit 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
d02868b950 templates: add .gitignore files to all templates 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
4f7d82671f Templates: remove 'minimal-flake-parts' 2025-07-07 15:50:00 +00:00
Johannes Kirschbauer
0dce3fc7ec templates: rename 'new_clan' to default 2025-07-07 15:50:00 +00:00
brianmcgee
a635f9c6fe Merge pull request 'ui: Modal component' (#4241) from feat/modal into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4241
2025-07-07 15:16:50 +00:00
Mic92
a8ed1c30e4 Merge pull request 'make treefmt work with git-worktrees' (#4246) from pytest into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4246
2025-07-07 15:07:53 +00:00
Jörg Thalheim
c0c41d52bd make treefmt work with git-worktrees 2025-07-07 16:55:36 +02:00
hsjobeki
bb236bb543 Merge pull request 'Docs: add missing documentation to api functions' (#4243) from api-cleanup into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4243
2025-07-07 14:02:08 +00:00
Johannes Kirschbauer
d7cf79faa7 openapi: error on missing api function docstring 2025-07-07 15:48:36 +02:00
Johannes Kirschbauer
dab11cb020 docs/api: add docstrings to {list_mdns_services, set_clan_details} 2025-07-07 15:47:14 +02:00
Johannes Kirschbauer
f2cb6fef41 api: remove unused get_directory 2025-07-07 15:45:51 +02:00
Johannes Kirschbauer
655b87ad04 docs/api: add docstrings to {run_machine_install,run_machine_deploy} 2025-07-07 15:41:02 +02:00
Johannes Kirschbauer
d462ae501e docs/api: add docstrings to {check_machine_ssh_login} 2025-07-07 15:38:09 +02:00
Johannes Kirschbauer
59a8c402ba docs/api: add docstrings to {delete_machine} 2025-07-07 15:36:16 +02:00
Johannes Kirschbauer
3b309ea74b docs/api: add docstrings to {get_flash_options, run_machine_flash} 2025-07-07 15:34:49 +02:00
Johannes Kirschbauer
508cd3c784 docs/api: add docstrings to {get_clan_details} 2025-07-07 15:31:06 +02:00
Johannes Kirschbauer
2bff7403df docs/api: add docstrings to {create_clan} 2025-07-07 15:29:19 +02:00
Johannes Kirschbauer
b5a6e809d0 docs/api: add docstrings to {get_generators, run_generators} 2025-07-07 15:22:44 +02:00
Johannes Kirschbauer
ec28c5c307 api/machines: document {get_machine,get_machine_details} 2025-07-07 15:13:23 +02:00
Johannes Kirschbauer
b8ba8b79ca api/check_machine_ssh_reachable: add function docs 2025-07-07 15:02:35 +02:00
Brian McGee
eb6460fb40 feat(ui): update playwright to match version in nixpkgs 2025-07-07 12:51:22 +01:00
Brian McGee
e1796e19e4 feat(ui): refine Fieldset API 2025-07-07 10:51:43 +01:00
92 changed files with 1274 additions and 468 deletions

75
.gitea/workflows/create-pr.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# Shared script for creating pull requests in Gitea workflows
set -euo pipefail
# Required environment variables:
# - CI_BOT_TOKEN: Gitea bot token for authentication
# - PR_BRANCH: Branch name for the pull request
# - PR_TITLE: Title of the pull request
# - PR_BODY: Body/description of the pull request
if [[ -z "${CI_BOT_TOKEN:-}" ]]; then
echo "Error: CI_BOT_TOKEN is not set" >&2
exit 1
fi
if [[ -z "${PR_BRANCH:-}" ]]; then
echo "Error: PR_BRANCH is not set" >&2
exit 1
fi
if [[ -z "${PR_TITLE:-}" ]]; then
echo "Error: PR_TITLE is not set" >&2
exit 1
fi
if [[ -z "${PR_BODY:-}" ]]; then
echo "Error: PR_BODY is not set" >&2
exit 1
fi
# Push the branch
git push origin "+HEAD:${PR_BRANCH}"
# Create pull request
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
-H "Authorization: token $CI_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"head\": \"${PR_BRANCH}\",
\"base\": \"main\",
\"title\": \"${PR_TITLE}\",
\"body\": \"${PR_BODY}\"
}" \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
pr_number=$(echo "$resp" | jq -r '.number')
if [[ "$pr_number" == "null" ]]; then
echo "Error creating pull request:" >&2
echo "$resp" | jq . >&2
exit 1
fi
echo "Created pull request #$pr_number"
# Merge when checks succeed
while true; do
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
-H "Authorization: token $CI_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"Do": "merge",
"merge_when_checks_succeed": true,
"delete_branch_after_merge": true
}' \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
msg=$(echo "$resp" | jq -r '.message')
if [[ "$msg" != "Please try again later" ]]; then
break
fi
echo "Retrying in 2 seconds..."
sleep 2
done
echo "Pull request #$pr_number merge initiated"

View File

@@ -19,35 +19,10 @@ jobs:
run: | run: |
export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol
git commit -am "Update pinned clan-core for checks" git commit -am "Update pinned clan-core for checks"
git push origin +HEAD:update-clan-core-for-checks
set -x
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \
-H "Authorization: token $CI_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"head": "update-clan-core-for-checks",
"base": "main",
"title": "Update Clan Core for Checks",
"body": "This PR updates the pinned clan-core flake input that is used for checks."
}' \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls")
pr_number=$(echo "$resp" | jq -r '.number')
# Merge when succeed # Use shared PR creation script
while true; do export PR_BRANCH="update-clan-core-for-checks"
resp=$(nix run --inputs-from . nixpkgs#curl -- -X POST \ export PR_TITLE="Update Clan Core for Checks"
-H "Authorization: token $CI_BOT_TOKEN" \ export PR_BODY="This PR updates the pinned clan-core flake input that is used for checks."
-H "Content-Type: application/json" \
-d '{ ./.gitea/workflows/create-pr.sh
"Do": "merge",
"merge_when_checks_succeed": true,
"delete_branch_after_merge": true
}' \
"https://git.clan.lol/api/v1/repos/clan/clan-core/pulls/$pr_number/merge")
msg=$(echo $resp | jq -r '.message')
if [[ "$msg" != "Please try again later" ]]; then
break
fi
echo "Retrying in 2 seconds..."
sleep 2
done

View File

@@ -0,0 +1,40 @@
name: "Update private flake inputs"
on:
repository_dispatch:
workflow_dispatch:
schedule:
- cron: "0 3 * * *" # Run daily at 3 AM
jobs:
update-private-flake:
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Update private flake inputs
run: |
# Update the private flake lock file
cd devFlake/private
nix flake update
cd ../..
# Update the narHash
bash ./devFlake/update-private-narhash
- name: Create pull request
env:
CI_BOT_TOKEN: ${{ secrets.CI_BOT_TOKEN }}
run: |
export GIT_AUTHOR_NAME=clan-bot GIT_AUTHOR_EMAIL=clan-bot@clan.lol GIT_COMMITTER_NAME=clan-bot GIT_COMMITTER_EMAIL=clan-bot@clan.lol
# Check if there are any changes
if ! git diff --quiet; then
git add devFlake/private/flake.lock devFlake/private.narHash
git commit -m "Update dev flake"
# Use shared PR creation script
export PR_BRANCH="update-dev-flake"
export PR_TITLE="Update dev flake"
export PR_BODY="This PR updates the dev flake inputs and corresponding narHash."
else
echo "No changes detected in dev flake inputs"
fi

View File

@@ -19,10 +19,11 @@
... ...
}: }:
let let
dependencies = [ dependencies =
self [
pkgs.stdenv.drvPath pkgs.stdenv.drvPath
] ++ builtins.map (i: i.outPath) (builtins.attrValues self.inputs); ]
++ builtins.map (i: i.outPath) (builtins.attrValues (builtins.removeAttrs self.inputs [ "self" ]));
closureInfo = pkgs.closureInfo { rootPaths = dependencies; }; closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in in
{ {

View File

@@ -47,14 +47,6 @@ nixosLib.runTest (
clientone = clientone =
{ config, pkgs, ... }: { config, pkgs, ... }:
let
dependencies = [
clan-core
pkgs.stdenv.drvPath
] ++ builtins.map (i: i.outPath) (builtins.attrValues clan-core.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{ {
services.openssh.enable = true; services.openssh.enable = true;
@@ -65,15 +57,6 @@ nixosLib.runTest (
environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ]; environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ];
environment.etc.install-closure.source = "${closureInfo}/store-paths";
nix.settings = {
substituters = pkgs.lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = pkgs.lib.mkForce 3;
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
};
system.extraDependencies = dependencies;
clan.core.state.test-backups.folders = [ "/var/test-backups" ]; clan.core.state.test-backups.folders = [ "/var/test-backups" ];
}; };

View File

@@ -41,14 +41,6 @@
clan-core, clan-core,
... ...
}: }:
let
dependencies = [
clan-core
pkgs.stdenv.drvPath
] ++ builtins.map (i: i.outPath) (builtins.attrValues clan-core.inputs);
closureInfo = pkgs.closureInfo { rootPaths = dependencies; };
in
{ {
services.openssh.enable = true; services.openssh.enable = true;
@@ -59,15 +51,6 @@
environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ]; environment.systemPackages = [ clan-core.packages.${pkgs.system}.clan-cli ];
environment.etc.install-closure.source = "${closureInfo}/store-paths";
nix.settings = {
substituters = pkgs.lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = pkgs.lib.mkForce 3;
flake-registry = pkgs.writeText "flake-registry" ''{"flakes":[],"version":2}'';
};
system.extraDependencies = dependencies;
clan.core.state.test-backups.folders = [ "/var/test-backups" ]; clan.core.state.test-backups.folders = [ "/var/test-backups" ];
}; };

View File

@@ -23,7 +23,13 @@ in
unit-test-module = ( unit-test-module = (
self.clanLib.test.flakeModules.makeEvalChecks { self.clanLib.test.flakeModules.makeEvalChecks {
inherit module; inherit module;
inherit self inputs; inherit inputs;
fileset = lib.fileset.unions [
# The hello-world service being tested
../../clanServices/hello-world
# Required modules
../../nixosModules/clanCore
];
testName = "hello-world"; testName = "hello-world";
tests = ./tests/eval-tests.nix; tests = ./tests/eval-tests.nix;
# Optional arguments passed to the test # Optional arguments passed to the test

View File

@@ -15,7 +15,15 @@ in
unit-test-module = ( unit-test-module = (
self.clanLib.test.flakeModules.makeEvalChecks { self.clanLib.test.flakeModules.makeEvalChecks {
inherit module; inherit module;
inherit self inputs; inherit inputs;
fileset = lib.fileset.unions [
# The zerotier service being tested
../../clanServices/zerotier
# Required modules
../../nixosModules/clanCore
# Dependencies like clan-cli
../../pkgs/clan-cli
];
testName = "zerotier"; testName = "zerotier";
tests = ./tests/eval-tests.nix; tests = ./tests/eval-tests.nix;
testArgs = { }; testArgs = { };

1
devFlake/private.narHash Normal file
View File

@@ -0,0 +1 @@
sha256-pFUj3KhQ4FkzZT19t+FHBru8u8Lspax0rS2cv7nXIgM=

165
devFlake/private/flake.lock generated Normal file
View File

@@ -0,0 +1,165 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": [
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"ixx": {
"inputs": {
"flake-utils": [
"nuschtos",
"flake-utils"
],
"nixpkgs": [
"nuschtos",
"nixpkgs"
]
},
"locked": {
"lastModified": 1748294338,
"narHash": "sha256-FVO01jdmUNArzBS7NmaktLdGA5qA3lUMJ4B7a05Iynw=",
"owner": "NuschtOS",
"repo": "ixx",
"rev": "cc5f390f7caf265461d4aab37e98d2292ebbdb85",
"type": "github"
},
"original": {
"owner": "NuschtOS",
"ref": "v0.0.8",
"repo": "ixx",
"type": "github"
}
},
"nixpkgs-dev": {
"locked": {
"lastModified": 1751867001,
"narHash": "sha256-3I49W0s3WVEDBO5S1RxYr74E2LLG7X8Wuvj9AmU0RDk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "73feb5e20ec7259e280ca6f424ba165059b3bb6b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable-small",
"repo": "nixpkgs",
"type": "github"
}
},
"nuschtos": {
"inputs": {
"flake-utils": "flake-utils_2",
"ixx": "ixx",
"nixpkgs": [
"nixpkgs-dev"
]
},
"locked": {
"lastModified": 1749730855,
"narHash": "sha256-L3x2nSlFkXkM6tQPLJP3oCBMIsRifhIDPMQQdHO5xWo=",
"owner": "NuschtOS",
"repo": "search",
"rev": "8dfe5879dd009ff4742b668d9c699bc4b9761742",
"type": "github"
},
"original": {
"owner": "NuschtOS",
"repo": "search",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs-dev": "nixpkgs-dev",
"nuschtos": "nuschtos",
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": []
},
"locked": {
"lastModified": 1750931469,
"narHash": "sha256-0IEdQB1nS+uViQw4k3VGUXntjkDp7aAlqcxdewb/hAc=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "ac8e6f32e11e9c7f153823abc3ab007f2a65d3e1",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -0,0 +1,19 @@
{
description = "private dev inputs";
# Dev dependencies
inputs.nixpkgs-dev.url = "github:NixOS/nixpkgs/nixos-unstable-small";
inputs.flake-utils.url = "github:numtide/flake-utils";
inputs.flake-utils.inputs.systems.follows = "systems";
inputs.nuschtos.url = "github:NuschtOS/search";
inputs.nuschtos.inputs.nixpkgs.follows = "nixpkgs-dev";
inputs.treefmt-nix.url = "github:numtide/treefmt-nix";
inputs.treefmt-nix.inputs.nixpkgs.follows = "";
inputs.systems.url = "github:nix-systems/default";
outputs = _: { };
}

12
devFlake/update-private-narhash Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Used to update the private dev flake hash reference.
set -euo pipefail
cd "$(dirname "$0")"
echo "Updating $PWD/private.narHash" >&2
nix --extra-experimental-features 'flakes nix-command' flake lock ./private
nix --extra-experimental-features 'flakes nix-command' hash path ./private >./private.narHash
echo OK

View File

@@ -1,5 +1,4 @@
{ {
clan-core,
pkgs, pkgs,
module-docs, module-docs,
clan-cli-docs, clan-cli-docs,
@@ -19,7 +18,17 @@ pkgs.stdenv.mkDerivation {
# Points to repository root. # Points to repository root.
# so that we can access directories outside of docs to include code snippets # so that we can access directories outside of docs to include code snippets
src = clan-core; src = pkgs.lib.fileset.toSource {
root = ../..;
fileset = pkgs.lib.fileset.unions [
# Docs directory
../../docs
# Icons needed for the build
../../pkgs/clan-app/ui/icons
# Any other directories that might be referenced for code snippets
# Add them here as needed based on what mkdocs actually uses
];
};
nativeBuildInputs = nativeBuildInputs =
[ [

View File

@@ -82,10 +82,9 @@
} }
'' ''
export CLAN_CORE_PATH=${ export CLAN_CORE_PATH=${
self.filter { inputs.nixpkgs.lib.fileset.toSource {
include = [ root = ../..;
"clanModules" fileset = ../../clanModules;
];
} }
} }
export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json export CLAN_CORE_DOCS=${jsonDocs.clanCore}/share/doc/nixos/options.json
@@ -126,7 +125,6 @@
}); });
packages = { packages = {
docs = pkgs.python3.pkgs.callPackage ./default.nix { docs = pkgs.python3.pkgs.callPackage ./default.nix {
clan-core = self;
inherit (self'.packages) inherit (self'.packages)
clan-cli-docs clan-cli-docs
docs-options docs-options

View File

@@ -1,9 +1,15 @@
{ self, config, ... }: {
self,
config,
inputs,
privateInputs ? { },
...
}:
{ {
perSystem = perSystem =
{ {
inputs',
lib, lib,
pkgs,
... ...
}: }:
let let
@@ -157,11 +163,16 @@
}; };
in in
{ {
packages.docs-options = inputs'.nuschtos.packages.mkMultiSearch { packages = lib.optionalAttrs ((privateInputs ? nuschtos) || (inputs ? nuschtos)) {
inherit baseHref; docs-options =
title = "Clan Options"; (privateInputs.nuschtos or inputs.nuschtos)
# scopes = mapAttrsToList mkScope serviceModules; .packages.${pkgs.stdenv.hostPlatform.system}.mkMultiSearch
scopes = [ (mkScope "Clan Inventory" serviceModules) ]; {
inherit baseHref;
title = "Clan Options";
# scopes = mapAttrsToList mkScope serviceModules;
scopes = [ (mkScope "Clan Inventory" serviceModules) ];
};
}; };
}; };
} }

View File

@@ -105,7 +105,7 @@ git+file:///home/lhebendanz/Projects/clan-core
│ ├───editor omitted (use '--all-systems' to show) │ ├───editor omitted (use '--all-systems' to show)
└───templates └───templates
├───default: template: Initialize a new clan flake ├───default: template: Initialize a new clan flake
└───new-clan: template: Initialize a new clan flake └───default: template: Initialize a new clan flake
``` ```
You can execute every test separately by following the tree path `nix run .#checks.x86_64-linux.clan-pytest -L` for example. You can execute every test separately by following the tree path `nix run .#checks.x86_64-linux.clan-pytest -L` for example.

72
flake.lock generated
View File

@@ -67,52 +67,6 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils": {
"inputs": {
"systems": [
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"ixx": {
"inputs": {
"flake-utils": [
"nuschtos",
"flake-utils"
],
"nixpkgs": [
"nuschtos",
"nixpkgs"
]
},
"locked": {
"lastModified": 1748294338,
"narHash": "sha256-FVO01jdmUNArzBS7NmaktLdGA5qA3lUMJ4B7a05Iynw=",
"owner": "NuschtOS",
"repo": "ixx",
"rev": "cc5f390f7caf265461d4aab37e98d2292ebbdb85",
"type": "github"
},
"original": {
"owner": "NuschtOS",
"ref": "v0.0.8",
"repo": "ixx",
"type": "github"
}
},
"nix-darwin": { "nix-darwin": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -174,41 +128,15 @@
"url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz" "url": "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz"
} }
}, },
"nuschtos": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"ixx": "ixx",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1749730855,
"narHash": "sha256-L3x2nSlFkXkM6tQPLJP3oCBMIsRifhIDPMQQdHO5xWo=",
"owner": "NuschtOS",
"repo": "search",
"rev": "8dfe5879dd009ff4742b668d9c699bc4b9761742",
"type": "github"
},
"original": {
"owner": "NuschtOS",
"repo": "search",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"data-mesher": "data-mesher", "data-mesher": "data-mesher",
"disko": "disko", "disko": "disko",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"flake-utils": "flake-utils",
"nix-darwin": "nix-darwin", "nix-darwin": "nix-darwin",
"nix-select": "nix-select", "nix-select": "nix-select",
"nixos-facter-modules": "nixos-facter-modules", "nixos-facter-modules": "nixos-facter-modules",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"nuschtos": "nuschtos",
"sops-nix": "sops-nix", "sops-nix": "sops-nix",
"systems": "systems", "systems": "systems",
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"

View File

@@ -35,19 +35,13 @@
}; };
}; };
# dependencies needed for nuschtos
flake-utils.url = "github:numtide/flake-utils";
flake-utils.inputs.systems.follows = "systems";
nuschtos.url = "github:NuschtOS/search";
nuschtos.inputs.nixpkgs.follows = "nixpkgs";
nuschtos.inputs.flake-utils.follows = "flake-utils";
}; };
outputs = outputs =
inputs@{ inputs@{
flake-parts,
nixpkgs, nixpkgs,
systems, systems,
flake-parts,
... ...
}: }:
let let
@@ -56,10 +50,25 @@
optional optional
pathExists pathExists
; ;
loadDevFlake =
path:
let
flakeHash = nixpkgs.lib.fileContents "${toString path}.narHash";
flakePath = "path:${toString path}?narHash=${flakeHash}";
in
builtins.getFlake (builtins.unsafeDiscardStringContext flakePath);
devFlake = builtins.tryEval (loadDevFlake ./devFlake/private);
privateInputs = if devFlake.success then devFlake.value.inputs else { };
in in
flake-parts.lib.mkFlake { inherit inputs; } ( flake-parts.lib.mkFlake { inherit inputs; } (
{ ... }: { ... }:
{ {
_module.args = {
inherit privateInputs;
};
clan = { clan = {
meta.name = "clan-core"; meta.name = "clan-core";
inventory = { inventory = {

View File

@@ -4,7 +4,7 @@
perSystem = perSystem =
{ self', pkgs, ... }: { self', pkgs, ... }:
{ {
treefmt.projectRootFile = ".git/config"; treefmt.projectRootFile = "LICENSE.md";
treefmt.programs.shellcheck.enable = true; treefmt.programs.shellcheck.enable = true;
treefmt.programs.mypy.enable = true; treefmt.programs.mypy.enable = true;

View File

@@ -37,6 +37,7 @@ lib.fix (
inventory = clanLib.callLib ./modules/inventory { }; inventory = clanLib.callLib ./modules/inventory { };
modules = clanLib.callLib ./modules/inventory/frontmatter { }; modules = clanLib.callLib ./modules/inventory/frontmatter { };
test = clanLib.callLib ./test { }; test = clanLib.callLib ./test { };
flake-inputs = clanLib.callLib ./flake-inputs.nix { };
# Custom types # Custom types
types = clanLib.callLib ./types { }; types = clanLib.callLib ./types { };

18
lib/flake-inputs.nix Normal file
View File

@@ -0,0 +1,18 @@
{ ... }:
{
/**
Generate nix-unit input overrides for tests
# Example
```nix
inputOverrides = clanLib.flake-inputs.getOverrides inputs;
```
*/
getOverrides =
inputs:
builtins.concatStringsSep " " (
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (
builtins.filter (name: name != "self") (builtins.attrNames inputs)
)
);
}

View File

@@ -1,8 +1,6 @@
{ self, inputs, ... }: { self, inputs, ... }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in in
{ {
perSystem = perSystem =

View File

@@ -4,9 +4,7 @@
... ...
}: }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in in
{ {
imports = [ imports = [

View File

@@ -1,8 +1,6 @@
{ self, inputs, ... }: { self, inputs, ... }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in in
{ {
perSystem = perSystem =
@@ -12,6 +10,23 @@ in
system, system,
... ...
}: }:
let
# Common filtered source for inventory tests
inventoryTestsSrc = lib.fileset.toSource {
root = ../../../..;
fileset = lib.fileset.unions [
../../../../flake.nix
../../../../flake.lock
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../../..)
../../../../flakeModules
../../../../lib
../../../../nixosModules/clanCore
../../../../clanModules/borgbackup
../../../../machines
../../../../inventory.json
];
};
in
{ {
# Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.<attrName> # Run: nix-unit --extra-experimental-features flakes --flake .#legacyPackages.x86_64-linux.<attrName>
legacyPackages.evalTests-distributedServices = import ./tests { legacyPackages.evalTests-distributedServices = import ./tests {
@@ -29,7 +44,7 @@ in
--extra-experimental-features flakes \ --extra-experimental-features flakes \
--show-trace \ --show-trace \
${inputOverrides} \ ${inputOverrides} \
--flake ${self}#legacyPackages.${system}.evalTests-distributedServices --flake ${inventoryTestsSrc}#legacyPackages.${system}.evalTests-distributedServices
touch $out touch $out
''; '';
@@ -39,7 +54,7 @@ in
--extra-experimental-features flakes \ --extra-experimental-features flakes \
--show-trace \ --show-trace \
${inputOverrides} \ ${inputOverrides} \
--flake ${self}#legacyPackages.${system}.eval-tests-resolve-module --flake ${inventoryTestsSrc}#legacyPackages.${system}.eval-tests-resolve-module
touch $out touch $out
''; '';

View File

@@ -5,9 +5,7 @@
... ...
}: }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in in
{ {
imports = [ imports = [
@@ -70,12 +68,18 @@ in
--show-trace \ --show-trace \
${inputOverrides} \ ${inputOverrides} \
--flake ${ --flake ${
self.filter { lib.fileset.toSource {
include = [ root = ../../..;
"flakeModules" fileset = lib.fileset.unions [
"lib" ../../../flake.nix
"clanModules/flake-module.nix" ../../../flake.lock
"clanModules/borgbackup" (lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
../../../flakeModules
../../../lib
../../../nixosModules/clanCore
../../../clanModules/borgbackup
../../../machines
../../../inventory.json
]; ];
} }
}#legacyPackages.${system}.evalTests-inventory }#legacyPackages.${system}.evalTests-inventory

View File

@@ -16,7 +16,7 @@
*/ */
makeEvalChecks = makeEvalChecks =
{ {
self, fileset,
inputs, inputs,
testName, testName,
tests, tests,
@@ -24,9 +24,7 @@
testArgs ? { }, testArgs ? { },
}: }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
attrName = "eval-tests-${testName}"; attrName = "eval-tests-${testName}";
in in
{ {
@@ -41,16 +39,44 @@
} }
// testArgs // testArgs
); );
checks.${attrName} = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } '' checks.${attrName} =
export HOME="$(realpath .)" let
# The root is two directories up from where this file is located
root = ../..;
nix-unit --eval-store "$HOME" \ # Combine the user-provided fileset with all flake-module.nix files
--extra-experimental-features flakes \ # and other essential files
--show-trace \ src = lib.fileset.toSource {
${inputOverrides} \ inherit root;
--flake ${self}#legacyPackages.${system}.${attrName} fileset = lib.fileset.unions [
touch $out # Core flake files
''; (root + "/flake.nix")
(root + "/flake.lock")
# All flake-module.nix files anywhere in the tree
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") root)
# The flakeModules/clan.nix if it exists
(lib.fileset.maybeMissing (root + "/flakeModules/clan.nix"))
# Core libraries
(root + "/lib")
# User-provided fileset
fileset
];
};
in
pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)"
nix-unit --eval-store "$HOME" \
--extra-experimental-features flakes \
--show-trace \
${inputOverrides} \
--flake ${src}#legacyPackages.${system}.${attrName}
touch $out
'';
}; };
} }

View File

@@ -1,4 +1,9 @@
{ self, inputs, ... }: {
self,
inputs,
lib,
...
}:
{ {
perSystem = perSystem =
{ ... }: { ... }:
@@ -10,7 +15,11 @@
test-types-module = ( test-types-module = (
self.clanLib.test.flakeModules.makeEvalChecks { self.clanLib.test.flakeModules.makeEvalChecks {
module = throw ""; module = throw "";
inherit self inputs; inherit inputs;
fileset = lib.fileset.unions [
# Only lib is needed for type tests
../../lib
];
testName = "types"; testName = "types";
tests = ./tests.nix; tests = ./tests.nix;
# Optional arguments passed to the test # Optional arguments passed to the test

View File

@@ -1,4 +1,4 @@
{ lib, pkgs, ... }: { lib, pkgs }:
let let
eval = eval =
module: module:

View File

@@ -5,18 +5,14 @@
... ...
}: }:
let let
inputOverrides = builtins.concatStringsSep " " ( inputOverrides = self.clanLib.flake-inputs.getOverrides inputs;
builtins.map (input: " --override-input ${input} ${inputs.${input}}") (builtins.attrNames inputs)
);
in in
{ {
perSystem = perSystem =
{ system, pkgs, ... }: { system, pkgs, ... }:
{ {
legacyPackages.evalTests-module-clan-vars = import ./eval-tests { legacyPackages.evalTests-module-clan-vars = import ./eval-tests {
inherit lib; inherit lib pkgs;
clan-core = self;
pkgs = inputs.nixpkgs.legacyPackages.${system};
}; };
checks.eval-module-clan-vars = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } '' checks.eval-module-clan-vars = pkgs.runCommand "tests" { nativeBuildInputs = [ pkgs.nix-unit ]; } ''
export HOME="$(realpath .)" export HOME="$(realpath .)"
@@ -26,11 +22,15 @@ in
--show-trace \ --show-trace \
${inputOverrides} \ ${inputOverrides} \
--flake ${ --flake ${
self.filter { lib.fileset.toSource {
include = [ root = ../../..;
"flakeModules" fileset = lib.fileset.unions [
"nixosModules" ../../../flake.nix
"lib" ../../../flake.lock
(lib.fileset.fileFilter (file: file.name == "flake-module.nix") ../../..)
../../../flakeModules/clan.nix
../../../lib
../../../nixosModules/clanCore/vars
]; ];
} }
}#legacyPackages.${system}.evalTests-module-clan-vars }#legacyPackages.${system}.evalTests-module-clan-vars

View File

@@ -48,7 +48,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10", "markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0", "playwright": "~1.53.2",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -6189,13 +6189,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.52.0", "version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.2.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", "integrity": "sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.52.0" "playwright-core": "1.53.2"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -6208,9 +6208,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.52.0", "version": "1.53.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.2.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", "integrity": "sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

@@ -48,7 +48,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2", "knip": "^5.61.2",
"markdown-to-jsx": "^7.7.10", "markdown-to-jsx": "^7.7.10",
"playwright": "~1.52.0", "playwright": "~1.53.2",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",

View File

@@ -1,15 +1,15 @@
div.divider { hr {
@apply bg-inv-2; @apply border-none outline-none bg-inv-2;
&.inverted { &.inverted {
@apply bg-def-3; @apply bg-def-3;
} }
&.horizontal { &[data-orientation="horizontal"] {
@apply w-full h-px; @apply w-full h-px;
} }
&.vertical { &[data-orientation="vertical"] {
@apply h-full w-px; @apply h-full w-px;
} }
} }

View File

@@ -1,14 +1,18 @@
import "./Divider.css"; import "./Divider.css";
import cx from "classnames"; import cx from "classnames";
import { Separator, SeparatorRootProps } from "@kobalte/core/separator";
export interface DividerProps { export interface DividerProps extends Pick<SeparatorRootProps, "orientation"> {
inverted?: boolean; inverted?: boolean;
orientation?: "horizontal" | "vertical";
} }
export const Divider = (props: DividerProps) => { export const Divider = (props: DividerProps) => {
const inverted = props.inverted || false; const inverted = props.inverted || false;
const orientation = () => props.orientation || "horizontal";
return <div class={cx("divider", orientation(), { inverted: inverted })} />; return (
<Separator
class={cx({ inverted: inverted })}
orientation={props.orientation}
/>
);
}; };

View File

@@ -16,6 +16,10 @@ div.form-field {
&[data-invalid] { &[data-invalid] {
@apply border-semantic-error-4; @apply border-semantic-error-4;
} }
&[data-readonly] {
@apply cursor-default bg-inherit border-none;
}
} }
} }
@@ -32,6 +36,10 @@ div.form-field {
&[data-disabled] { &[data-disabled] {
@apply bg-def-4 border-none; @apply bg-def-4 border-none;
} }
&[data-readonly] {
@apply bg-inherit;
}
} }
} }
} }

View File

@@ -94,7 +94,15 @@ export const Disabled: Story = {
}, },
}; };
export const ReadOnly: Story = { export const ReadOnlyUnchecked: Story = {
args: {
...Tooltip.args,
readOnly: true,
defaultChecked: false,
},
};
export const ReadOnlyChecked: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,

View File

@@ -11,37 +11,64 @@ import { PolymorphicProps } from "@kobalte/core/polymorphic";
import "./Checkbox.css"; import "./Checkbox.css";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
import { Orienter } from "./Orienter"; import { Orienter } from "./Orienter";
import { Show } from "solid-js";
export type CheckboxProps = FieldProps & export type CheckboxProps = FieldProps &
KCheckboxRootProps & { KCheckboxRootProps & {
input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>; input?: PolymorphicProps<"input", KCheckboxInputProps<"input">>;
}; };
export const Checkbox = (props: CheckboxProps) => ( export const Checkbox = (props: CheckboxProps) => {
<KCheckbox const alignment = () =>
class={cx("form-field", "checkbox", props.size, props.orientation, { (props.orientation || "vertical") == "vertical" ? "start" : "center";
inverted: props.inverted,
ghost: props.ghost, const iconChecked = (
})} <Icon
{...props} icon="Checkmark"
> inverted={props.inverted}
<Orienter orientation={props.orientation} align={"start"}> color="secondary"
<Label size="100%"
labelComponent={KCheckbox.Label} />
descriptionComponent={KCheckbox.Description} );
{...props}
/> const iconUnchecked = (
<KCheckbox.Input {...props.input} /> <Icon
<KCheckbox.Control class="checkbox-control"> icon="Close"
<KCheckbox.Indicator> inverted={props.inverted}
<Icon color="secondary"
icon="Checkmark" size="100%"
inverted={props.inverted} />
color="secondary" );
size="100%"
/> return (
</KCheckbox.Indicator> <KCheckbox
</KCheckbox.Control> class={cx("form-field", "checkbox", props.size, props.orientation, {
</Orienter> inverted: props.inverted,
</KCheckbox> ghost: props.ghost,
); })}
{...props}
>
<Orienter orientation={props.orientation} align={alignment()}>
<Label
labelComponent={KCheckbox.Label}
descriptionComponent={KCheckbox.Description}
{...props}
/>
<KCheckbox.Input {...props.input} />
<KCheckbox.Control class="checkbox-control">
{props.readOnly && (
<Show
when={props.checked || props.defaultChecked}
fallback={iconUnchecked}
>
{iconChecked}
</Show>
)}
{!props.readOnly && (
<KCheckbox.Indicator>{iconChecked}</KCheckbox.Indicator>
)}
</KCheckbox.Control>
</Orienter>
</KCheckbox>
);
};

View File

@@ -1,9 +1,9 @@
div.form-field.combobox { div.form-field.combobox {
div.control { div.control {
@apply flex flex-col w-full gap-2; @apply flex flex-col size-full gap-2;
div.selected-options { div.selected-options {
@apply flex flex-wrap gap-1 w-full min-h-5; @apply flex flex-wrap gap-1 size-full min-h-5;
} }
div.input-container { div.input-container {
@@ -44,7 +44,8 @@ div.form-field.combobox {
} }
&[data-readonly] { &[data-readonly] {
@apply outline-def-2 cursor-not-allowed; @apply outline-none border-none bg-inherit;
@apply p-0 resize-none;
} }
} }
@@ -76,6 +77,10 @@ div.form-field.combobox {
& > input { & > input {
@apply px-1.5 py-1; @apply px-1.5 py-1;
font-size: 0.75rem; font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
} }
& > button.trigger { & > button.trigger {
@@ -111,6 +116,10 @@ div.form-field.combobox {
&[data-invalid] { &[data-invalid] {
@apply outline-semantic-error-4; @apply outline-semantic-error-4;
} }
&[data-readonly] {
@apply outline-none border-none bg-inherit cursor-auto;
}
} }
} }
} }

View File

@@ -124,6 +124,7 @@ export const ReadOnly: Story = {
args: { args: {
...Tooltip.args, ...Tooltip.args,
readOnly: true, readOnly: true,
defaultValue: "foo",
}, },
}; };

View File

@@ -83,14 +83,18 @@ export const DefaultItemControl = <Option,>(
</For> </For>
</div> </div>
</Show> </Show>
<div class="input-container"> {!(props.readOnly && props.multiple) && (
<KCombobox.Input /> <div class="input-container">
<KCombobox.Trigger class="trigger"> <KCombobox.Input />
<KCombobox.Icon class="icon"> {!props.readOnly && (
<Icon icon="Expand" inverted={props.inverted} size="100%" /> <KCombobox.Trigger class="trigger">
</KCombobox.Icon> <KCombobox.Icon class="icon">
</KCombobox.Trigger> <Icon icon="Expand" inverted={props.inverted} size="100%" />
</div> </KCombobox.Icon>
</KCombobox.Trigger>
)}
</div>
)}
</> </>
); );
@@ -101,7 +105,13 @@ export const Combobox = <Option, OptGroup = never>(
const itemControl = () => props.itemControl || DefaultItemControl; const itemControl = () => props.itemControl || DefaultItemControl;
const itemComponent = () => props.itemComponent || DefaultItemComponent; const itemComponent = () => props.itemComponent || DefaultItemComponent;
const align = () => (props.orientation === "horizontal" ? "start" : "center"); const align = () => {
if (props.readOnly) {
return "center";
} else {
return props.orientation === "horizontal" ? "start" : "center";
}
};
return ( return (
<KCombobox <KCombobox

View File

@@ -40,7 +40,7 @@ export type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
args: { args: {
legend: "Signup", legend: "Signup",
fields: (props: FieldProps) => ( children: (props: FieldProps) => (
<> <>
<TextInput <TextInput
{...props} {...props}
@@ -90,7 +90,7 @@ export const Error: Story = {
args: { args: {
legend: "Signup", legend: "Signup",
error: "You must enter a First Name", error: "You must enter a First Name",
fields: (props: FieldProps) => ( children: (props: FieldProps) => (
<> <>
<TextInput <TextInput
{...props} {...props}

View File

@@ -1,41 +1,57 @@
import "./Fieldset.css"; import "./Fieldset.css";
import { JSX } from "solid-js"; import { JSX, splitProps } from "solid-js";
import cx from "classnames"; import cx from "classnames";
import { Typography } from "@/src/components/v2/Typography/Typography"; import { Typography } from "@/src/components/v2/Typography/Typography";
import { FieldProps } from "./Field"; import { FieldProps } from "./Field";
export interface FieldsetProps extends FieldProps { export type FieldsetFieldProps = Pick<
legend: string; FieldProps,
disabled: boolean; "orientation" | "inverted"
> & {
error?: string; error?: string;
fields: (props: FieldProps) => JSX.Element; disabled?: boolean;
};
export interface FieldsetProps
extends Pick<FieldProps, "orientation" | "inverted"> {
legend?: string;
disabled?: boolean;
error?: string;
children: (props: FieldsetFieldProps) => JSX.Element;
} }
export const Fieldset = (props: FieldsetProps) => { export const Fieldset = (props: FieldsetProps) => {
const orientation = () => props.orientation || "vertical"; const orientation = () => props.orientation || "vertical";
const [fieldProps] = splitProps(props, [
"orientation",
"inverted",
"disabled",
"error",
]);
return ( return (
<fieldset <fieldset
role="group" role="group"
class={cx(orientation(), { inverted: props.inverted })} class={cx({ inverted: props.inverted })}
disabled={props.disabled} disabled={props.disabled || false}
> >
<legend> {props.legend && (
<Typography <legend>
hierarchy="label" <Typography
family="mono" hierarchy="label"
size="default" family="mono"
weight="normal" size="default"
color="tertiary" weight="normal"
transform="uppercase" color="tertiary"
inverted={props.inverted} transform="uppercase"
> inverted={props.inverted}
{props.legend} >
</Typography> {props.legend}
</legend> </Typography>
<div class="fields"> </legend>
{props.fields({ ...props, orientation: orientation() })} )}
</div> <div class="fields">{props.children(fieldProps)}</div>
{props.error && ( {props.error && (
<div class="error" role="alert"> <div class="error" role="alert">
<Typography <Typography

View File

@@ -12,7 +12,7 @@ div.form-label {
@apply flex items-center gap-1; @apply flex items-center gap-1;
} }
& > label[data-required] { & > label[data-required] & not(label[data-readonly]) {
span.typography::after { span.typography::after {
@apply fg-def-4 ml-1; @apply fg-def-4 ml-1;

View File

@@ -28,6 +28,7 @@ export interface LabelProps {
tooltip?: string; tooltip?: string;
icon?: string; icon?: string;
inverted?: boolean; inverted?: boolean;
readOnly?: boolean;
validationState?: "valid" | "invalid"; validationState?: "valid" | "invalid";
} }
@@ -42,7 +43,7 @@ export const Label = (props: LabelProps) => {
hierarchy="label" hierarchy="label"
size={props.size || "default"} size={props.size || "default"}
color={props.validationState == "invalid" ? "error" : "primary"} color={props.validationState == "invalid" ? "error" : "primary"}
weight="bold" weight={props.readOnly ? "normal" : "bold"}
inverted={props.inverted} inverted={props.inverted}
> >
{props.label} {props.label}

View File

@@ -5,7 +5,7 @@ div.orienter {
} }
&.horizontal { &.horizontal {
@apply flex-row gap-2 justify-between; @apply flex-row justify-start;
& > div.form-label { & > div.form-label {
@apply w-1/2 shrink; @apply w-1/2 shrink;

View File

@@ -34,7 +34,13 @@ div.form-field {
} }
&[data-readonly] { &[data-readonly] {
@apply outline-def-2 cursor-not-allowed; @apply outline-none border-none bg-inherit p-0 cursor-auto resize-none;
}
}
&.textarea textarea {
&[data-readonly] {
@apply overflow-y-hidden;
} }
} }
@@ -72,6 +78,10 @@ div.form-field {
&.textarea textarea { &.textarea textarea {
@apply px-1.5 py-1; @apply px-1.5 py-1;
font-size: 0.75rem; font-size: 0.75rem;
&[data-readonly] {
@apply p-0;
}
} }
&.text div.input-container { &.text div.input-container {
@@ -114,6 +124,11 @@ div.form-field {
&[data-invalid] { &[data-invalid] {
@apply outline-semantic-error-4; @apply outline-semantic-error-4;
} }
&[data-readonly] {
@apply outline-def-2 cursor-auto;
@apply outline-none border-none bg-inherit;
}
} }
} }

View File

@@ -33,7 +33,7 @@ export const TextInput = (props: TextInputProps) => (
{...props} {...props}
/> />
<div class="input-container"> <div class="input-container">
{props.icon && ( {props.icon && !props.readOnly && (
<Icon <Icon
icon={props.icon} icon={props.icon}
inverted={props.inverted} inverted={props.inverted}
@@ -42,7 +42,7 @@ export const TextInput = (props: TextInputProps) => (
)} )}
<TextField.Input <TextField.Input
{...props.input} {...props.input}
classList={{ "has-icon": props.icon }} classList={{ "has-icon": props.icon && !props.readOnly }}
/> />
</div> </div>
</Orienter> </Orienter>

View File

@@ -0,0 +1,24 @@
div.modal-content {
@apply max-w-[512px];
@apply rounded-md;
/* todo replace with a theme() color */
box-shadow: 0.1875rem 0.1875rem 0 0 rgba(145, 172, 175, 0.32);
& > div.header {
@apply flex items-center justify-center;
@apply w-full px-2 py-1.5;
@apply bg-def-3;
@apply border border-def-2 rounded-tl-md rounded-tr-md;
@apply border-b-def-3;
& > .title {
@apply mx-auto;
}
}
& > div.body {
@apply p-6 bg-def-1;
@apply border border-def-2 rounded-bl-md rounded-br-md;
}
}

View File

@@ -0,0 +1,74 @@
import { TagProps } from "@/src/components/v2/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { fn } from "storybook/test";
import {
Modal,
ModalContext,
ModalProps,
} from "@/src/components/v2/Modal/Modal";
import { Fieldset } from "@/src/components/v2/Form/Fieldset";
import { TextInput } from "@/src/components/v2/Form/TextInput";
import { TextArea } from "@/src/components/v2/Form/TextArea";
import { Checkbox } from "@/src/components/v2/Form/Checkbox";
import { Button } from "../Button/Button";
const meta: Meta<ModalProps> = {
title: "Components/Modal",
component: Modal,
};
export default meta;
type Story = StoryObj<TagProps>;
export const Default: Story = {
args: {
title: "Example Modal",
onClose: fn(),
children: ({ close }: ModalContext) => (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props) => (
<>
<TextInput
{...props}
label="First Name"
size="s"
required={true}
input={{ placeholder: "Ron" }}
/>
<TextInput
{...props}
label="Last Name"
size="s"
required={true}
input={{ placeholder: "Burgundy" }}
/>
<TextArea
{...props}
label="Bio"
size="s"
input={{ placeholder: "Tell us a bit about yourself", rows: 8 }}
/>
<Checkbox
{...props}
size="s"
label="Accept Terms"
required={true}
/>
</>
)}
</Fieldset>
<div class="flex w-full items-center justify-end gap-4">
<Button size="s" hierarchy="secondary" onClick={close}>
Cancel
</Button>
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
Save
</Button>
</div>
</form>
),
},
};

View File

@@ -0,0 +1,40 @@
import { createSignal, JSX } from "solid-js";
import { Dialog as KDialog } from "@kobalte/core/dialog";
import "./Modal.css";
import { Typography } from "../Typography/Typography";
import Icon from "../Icon/Icon";
export interface ModalContext {
close(): void;
}
export interface ModalProps {
id?: string;
title: string;
onClose: () => void;
children: (ctx: ModalContext) => JSX.Element;
}
export const Modal = (props: ModalProps) => {
const [open, setOpen] = createSignal(true);
return (
<KDialog id={props.id} open={open()} modal={true}>
<KDialog.Portal>
<KDialog.Content class="modal-content">
<div class="header">
<Typography class="title" hierarchy="label" family="mono" size="xs">
{props.title}
</Typography>
<KDialog.CloseButton onClick={() => setOpen(false)}>
<Icon icon="Close" size="0.75rem" />
</KDialog.CloseButton>
</div>
<div class="body">
{props.children({ close: () => setOpen(false) })}
</div>
</KDialog.Content>
</KDialog.Portal>
</KDialog>
);
};

View File

@@ -4,8 +4,8 @@ div.sidebar-header {
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
theme(colors.bg.inv.3) 0%, theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.4) 0% theme(colors.bg.inv.3) 0%
); );
& > .dropdown-trigger { & > .dropdown-trigger {

View File

@@ -0,0 +1,33 @@
div.sidebar-pane {
@apply h-full w-auto max-w-60 border-none;
& > div.header {
@apply flex items-center justify-between px-3 py-2 rounded-t-[0.5rem];
@apply border-t-[1px] border-t-bg-inv-3
border-r-[1px] border-r-bg-inv-3
border-b-2 border-b-bg-inv-4
border-l-[1px] border-l-bg-inv-3;
background: linear-gradient(
90deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
}
& > div.body {
@apply flex flex-col gap-4 px-2 pt-4 pb-3 w-full h-full;
@apply backdrop-blur-md;
@apply rounded-b-[0.5rem]
border-r-[1px] border-r-bg-inv-3
border-b-2 border-b-bg-inv-4
border-l-[1px] border-l-bg-inv-3;
background:
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(
180deg,
theme(colors.bg.inv.2) 0%,
theme(colors.bg.inv.3) 100%
);
}
}

View File

@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
SidebarPane,
SidebarPaneProps,
} from "@/src/components/v2/Sidebar/SidebarPane";
import { SidebarSection } from "./SidebarSection";
import { Divider } from "@/src/components/v2/Divider/Divider";
import { TextInput } from "@/src/components/v2/Form/TextInput";
import { TextArea } from "@/src/components/v2/Form/TextArea";
import { Checkbox } from "@/src/components/v2/Form/Checkbox";
import { Combobox } from "../Form/Combobox";
const meta: Meta<SidebarPaneProps> = {
title: "Components/Sidebar/Pane",
component: SidebarPane,
};
export default meta;
type Story = StoryObj<SidebarPaneProps>;
export const Default: Story = {
args: {
title: "Neptune",
onClose: () => {
console.log("closing");
},
children: (
<>
<SidebarSection
title="General"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => (
<form class="flex flex-col gap-3">
<TextInput
label="First Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Ron" }}
/>
<Divider />
<TextInput
label="Last Name"
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
input={{ value: "Burgundy" }}
/>
<Divider />
<TextArea
label="Bio"
size="s"
inverted={true}
readOnly={!editing}
orientation="horizontal"
input={{
value:
"It's actually an optical illusion, it's the pattern on the pants.",
rows: 4,
}}
/>
<Divider />
<Checkbox
size="s"
label="Share Profile"
required={true}
inverted={true}
readOnly={!editing}
checked={true}
orientation="horizontal"
/>
</form>
)}
</SidebarSection>
<SidebarSection
title="Tags"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => (
<form class="flex flex-col gap-3">
<Combobox
size="s"
inverted={true}
required={true}
readOnly={!editing}
orientation="horizontal"
multiple={true}
options={["All", "Home Server", "Backup", "Random"]}
defaultValue={["All", "Home Server", "Backup", "Random"]}
/>
</form>
)}
</SidebarSection>
<SidebarSection
title="Advanced Settings"
onSave={async () => {
console.log("saving general");
}}
>
{(editing) => <></>}
</SidebarSection>
</>
),
},
};

View File

@@ -0,0 +1,27 @@
import { JSX } from "solid-js";
import "./SidebarPane.css";
import { Typography } from "@/src/components/v2/Typography/Typography";
import Icon from "../Icon/Icon";
import { Button as KButton } from "@kobalte/core/button";
export interface SidebarPaneProps {
title: string;
onClose: () => void;
children: JSX.Element;
}
export const SidebarPane = (props: SidebarPaneProps) => {
return (
<div class="sidebar-pane">
<div class="header">
<Typography hierarchy="body" size="s" weight="bold" inverted={true}>
{props.title}
</Typography>
<KButton onClick={props.onClose}>
<Icon icon="Close" color="primary" size="0.75rem" inverted={true} />
</KButton>
</div>
<div class="body">{props.children}</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
div.sidebar-section {
@apply flex flex-col gap-2 w-full h-full;
& > div.header {
@apply flex items-center justify-between px-1.5;
& > div.controls {
@apply flex justify-end gap-2;
}
}
& > div.content {
@apply w-full h-full px-1.5 py-3 rounded-md bg-inv-4;
}
}

View File

@@ -0,0 +1,61 @@
import { createSignal, JSX } from "solid-js";
import "./SidebarSection.css";
import { Typography } from "@/src/components/v2/Typography/Typography";
import { Button as KButton } from "@kobalte/core/button";
import Icon from "../Icon/Icon";
export interface SidebarSectionProps {
title: string;
onSave: () => Promise<void>;
children: (editing: boolean) => JSX.Element;
}
export const SidebarSection = (props: SidebarSectionProps) => {
const [editing, setEditing] = createSignal(false);
const save = async () => {
// todo how do we surface errors?
await props.onSave();
setEditing(false);
};
return (
<div class="sidebar-section">
<div class="header">
<Typography
hierarchy="label"
size="xs"
family="mono"
weight="light"
transform="uppercase"
color="tertiary"
inverted={true}
>
{props.title}
</Typography>
<div class="controls">
{editing() && (
<KButton>
<Icon
icon="Checkmark"
color="tertiary"
size="0.75rem"
inverted={true}
onClick={save}
/>
</KButton>
)}
<KButton onClick={() => setEditing(!editing())}>
<Icon
icon={editing() ? "Close" : "Edit"}
color="tertiary"
size="0.75rem"
inverted={true}
/>
</KButton>
</div>
</div>
<div class="content">{props.children(editing())}</div>
</div>
);
};

View File

@@ -1,5 +1,9 @@
/* Body */ /* Body */
.typography { .typography {
&.weight-light {
font-weight: 300;
}
&.weight-normal { &.weight-normal {
font-weight: 400; font-weight: 400;
} }

View File

@@ -6,7 +6,7 @@ import { Color, fgClass } from "@/src/components/v2/colors";
export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div"; export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser"; export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
export type Weight = "normal" | "medium" | "bold"; export type Weight = "normal" | "medium" | "bold" | "light";
export type Family = "regular" | "condensed" | "mono"; export type Family = "regular" | "condensed" | "mono";
export type Transform = "uppercase" | "lowercase" | "capitalize"; export type Transform = "uppercase" | "lowercase" | "capitalize";
@@ -80,9 +80,10 @@ const defaultFamilyMap: Record<Hierarchy, Family> = {
}; };
const weightMap: Record<Weight, string> = { const weightMap: Record<Weight, string> = {
normal: cx("weight-normal"), normal: "weight-normal",
medium: cx("weight-medium"), medium: "weight-medium",
bold: cx("weight-bold"), bold: "weight-bold",
light: "weight-light",
}; };
interface _TypographyProps<H extends Hierarchy> { interface _TypographyProps<H extends Hierarchy> {

View File

@@ -49,6 +49,21 @@ def run_machine_flash(
extra_args: list[str] | None = None, extra_args: list[str] | None = None,
graphical: bool = False, graphical: bool = False,
) -> None: ) -> None:
"""Flash a machine with the given configuration.
Args:
machine: The Machine instance to flash.
mode: The mode to use for flashing (e.g., "install", "reinstall
disks: List of Disk instances representing the disks to flash.
system_config: SystemConfig instance containing language, keymap, and SSH keys.
dry_run: If True, perform a dry run without making changes.
write_efi_boot_entries: If True, write EFI boot entries.
debug: If True, enable debug mode.
extra_args: Additional arguments to pass to the disko-install command.
graphical: If True, run the command in graphical mode.
Raises:
ClanError: If the language or keymap is invalid, or if there are issues with
reading SSH keys, or if disko-install fails.
"""
devices = [Path(disk.device) for disk in disks] devices = [Path(disk.device) for disk in disks]
with pause_automounting(devices, machine, request_graphical=graphical): with pause_automounting(devices, machine, request_graphical=graphical):
if extra_args is None: if extra_args is None:

View File

@@ -19,6 +19,12 @@ class FlashOptions(TypedDict):
@API.register @API.register
def get_flash_options() -> FlashOptions: def get_flash_options() -> FlashOptions:
"""Retrieve available languages and keymaps for flash configuration.
Returns:
FlashOptions: A dictionary containing lists of available languages and keymaps.
Raises:
ClanError: If the locale file or keymaps directory does not exist.
"""
return {"languages": list_languages(), "keymaps": list_keymaps()} return {"languages": list_languages(), "keymaps": list_keymaps()}

View File

@@ -45,7 +45,6 @@ def test_clan_core_templates(
"default", "default",
"flake-parts", "flake-parts",
"minimal", "minimal",
"minimal-flake-parts",
] ]
# clan.default # clan.default
@@ -55,18 +54,18 @@ def test_clan_core_templates(
template_path = default_template.get("path", None) template_path = default_template.get("path", None)
assert template_path is not None assert template_path is not None
new_clan = temporary_home / "new_clan" my_clan = temporary_home / "my_clan"
copy_from_nixstore( copy_from_nixstore(
Path(template_path), Path(template_path),
new_clan, my_clan,
) )
flake_nix = new_clan / "flake.nix" flake_nix = my_clan / "flake.nix"
assert (flake_nix).exists() assert (flake_nix).exists()
assert (flake_nix).is_file() assert (flake_nix).is_file()
assert (new_clan / "machines").is_dir() assert (my_clan / "machines").is_dir()
# Test if we can write to the flake.nix file # Test if we can write to the flake.nix file
with flake_nix.open("r+") as f: with flake_nix.open("r+") as f:

View File

@@ -429,6 +429,21 @@ def get_generators(
full_closure: bool = False, full_closure: bool = False,
include_previous_values: bool = False, include_previous_values: bool = False,
) -> list[Generator]: ) -> list[Generator]:
"""
Get the list of generators for a machine, optionally with previous values.
If `full_closure` is True, it returns the full closure of generators.
If `include_previous_values` is True, it includes the previous values for prompts.
Args:
machine_name (str): The name of the machine.
base_dir (Path): The base directory of the flake.
full_closure (bool): Whether to return the full closure of generators. If False,
it returns only the generators that are missing or need to be regenerated.
include_previous_values (bool): Whether to include previous values for prompts.
Returns:
list[Generator]: A list of generators for the machine.
"""
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
return get_closure( return get_closure(
@@ -468,6 +483,20 @@ def run_generators(
base_dir: Path, base_dir: Path,
no_sandbox: bool = False, no_sandbox: bool = False,
) -> bool: ) -> bool:
"""Run the specified generators for a machine.
Args:
machine_name (str): The name of the machine.
generators (list[str]): The list of generator names to run.
all_prompt_values (dict[str, dict[str, str]]): A dictionary mapping generator names
to their prompt values.
base_dir (Path): The base directory of the flake.
no_sandbox (bool): Whether to disable sandboxing when executing the generator.
Returns:
bool: True if any variables were generated, False otherwise.
Raises:
ClanError: If the machine or generator is not found, or if there are issues with
executing the generator.
"""
from clan_lib.machines.machines import Machine from clan_lib.machines.machines import Machine
machine = Machine(name=machine_name, flake=Flake(str(base_dir))) machine = Machine(name=machine_name, flake=Flake(str(base_dir)))

View File

@@ -1,12 +1,8 @@
import json import json
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from clan_lib.cmd import RunOpts, run from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.nix import nix_shell from clan_lib.nix import nix_shell
from . import API from . import API
@@ -42,55 +38,6 @@ def open_file(file_request: FileRequest) -> list[str] | None:
raise NotImplementedError(msg) raise NotImplementedError(msg)
@dataclass
class File:
path: str
file_type: Literal["file", "directory", "symlink"]
@dataclass
class Directory:
path: str
files: list[File] = field(default_factory=list)
@API.register
def get_directory(flake: Flake) -> Directory:
curr_dir = flake.path
directory = Directory(path=str(curr_dir))
if not curr_dir.is_dir():
msg = f"Path {curr_dir} is not a directory"
raise ClanError(msg)
with os.scandir(curr_dir.resolve()) as it:
for entry in it:
if entry.is_symlink():
directory.files.append(
File(
path=str(curr_dir / Path(entry.name)),
file_type="symlink",
)
)
elif entry.is_file():
directory.files.append(
File(
path=str(curr_dir / Path(entry.name)),
file_type="file",
)
)
elif entry.is_dir():
directory.files.append(
File(
path=str(curr_dir / Path(entry.name)),
file_type="directory",
)
)
return directory
@dataclass @dataclass
class BlkInfo: class BlkInfo:
name: str name: str

View File

@@ -89,6 +89,10 @@ def parse_avahi_output(output: str) -> DNSInfo:
@API.register @API.register
def list_mdns_services() -> DNSInfo: def list_mdns_services() -> DNSInfo:
"""List mDNS/DNS-SD services on the local network.
Returns:
DNSInfo: A dictionary containing discovered mDNS/DNS-SD services.
"""
cmd = nix_shell( cmd = nix_shell(
["avahi"], ["avahi"],
[ [

View File

@@ -31,6 +31,15 @@ def git_command(directory: Path, *args: str) -> list[str]:
@API.register @API.register
def create_clan(opts: CreateOptions) -> None: def create_clan(opts: CreateOptions) -> None:
"""Create a new clan repository with the specified template.
Args:
opts: CreateOptions containing the destination path, template name,
source flake, and other options.
Raises:
ClanError: If the source flake is not a valid flake or if the destination
directory already exists.
"""
dest = opts.dest.resolve() dest = opts.dest.resolve()
if opts.src_flake is not None: if opts.src_flake is not None:
@@ -69,6 +78,10 @@ def create_clan(opts: CreateOptions) -> None:
if opts.update_clan: if opts.update_clan:
run(nix_command(["flake", "update"]), RunOpts(cwd=dest)) run(nix_command(["flake", "update"]), RunOpts(cwd=dest))
if opts.setup_git:
run(git_command(dest, "add", "."))
run(git_command(dest, "commit", "-m", "Initial commit"))
if opts.initial: if opts.initial:
inventory_store = InventoryStore(flake=Flake(str(opts.dest))) inventory_store = InventoryStore(flake=Flake(str(opts.dest)))
inventory_store.write(opts.initial, message="Init inventory") inventory_store.write(opts.initial, message="Init inventory")

View File

@@ -7,6 +7,14 @@ from clan_lib.persist.inventory_store import InventoryStore
@API.register @API.register
def get_clan_details(flake: Flake) -> InventoryMeta: def get_clan_details(flake: Flake) -> InventoryMeta:
"""Retrieve the clan details from the inventory of a given flake.
Args:
flake: The Flake instance representing the clan.
Returns:
InventoryMeta: The meta information from the clan's inventory.
Raises:
ClanError: If the flake does not exist, or if the inventory is invalid (missing the meta attribute).
"""
if flake.is_local and not flake.path.exists(): if flake.is_local and not flake.path.exists():
msg = f"Path {flake} does not exist" msg = f"Path {flake} does not exist"
raise ClanError(msg, description="clan directory does not exist") raise ClanError(msg, description="clan directory does not exist")

View File

@@ -15,6 +15,14 @@ class UpdateOptions:
@API.register @API.register
def set_clan_details(options: UpdateOptions) -> InventorySnapshot: def set_clan_details(options: UpdateOptions) -> InventorySnapshot:
"""Update the clan metadata in the inventory of a given flake.
Args:
options: UpdateOptions containing the flake and the new metadata.
Returns:
InventorySnapshot: The updated inventory snapshot after modifying the metadata.
Raises:
ClanError: If the flake does not exist or if the inventory is invalid (missing the meta attribute).
"""
inventory_store = InventoryStore(options.flake) inventory_store = InventoryStore(options.flake)
inventory = inventory_store.read() inventory = inventory_store.read()
set_value_by_path(inventory, "meta", options.meta) set_value_by_path(inventory, "meta", options.meta)

View File

@@ -54,6 +54,19 @@ def list_machines(
@API.register @API.register
def get_machine(flake: Flake, name: str) -> InventoryMachine: def get_machine(flake: Flake, name: str) -> InventoryMachine:
"""
Retrieve a machine's inventory details by name from the given flake.
Args:
flake (Flake): The flake object representing the configuration source.
name (str): The name of the machine to retrieve from the inventory.
Returns:
InventoryMachine: An instance representing the machine's inventory details.
Raises:
ClanError: If the machine with the specified name is not found in the inventory.
"""
inventory_store = InventoryStore(flake=flake) inventory_store = InventoryStore(flake=flake)
inventory = inventory_store.read() inventory = inventory_store.read()

View File

@@ -19,6 +19,13 @@ log = logging.getLogger(__name__)
@API.register @API.register
def delete_machine(machine: Machine) -> None: def delete_machine(machine: Machine) -> None:
"""Delete a machine from the clan's inventory and remove its associated files.
Args:
machine: The Machine instance to be deleted.
Raises:
ClanError: If the machine does not exist in the inventory or if there are issues with
removing its files.
"""
inventory_store = InventoryStore(machine.flake) inventory_store = InventoryStore(machine.flake)
try: try:
inventory_store.delete( inventory_store.delete(

View File

@@ -40,6 +40,16 @@ class InstallOptions:
@API.register @API.register
def run_machine_install(opts: InstallOptions, target_host: Remote) -> None: def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
"""Install a machine using nixos-anywhere.
Args:
opts: InstallOptions containing the machine to install, kexec option, debug mode,
no-reboot option, phases, build-on option, hardware config update, password,
identity file, and use_tor flag.
target_host: Remote object representing the target host for installation.
Raises:
ClanError: If the machine is not found in the inventory or if there are issues with
generating facts or variables.
"""
machine = opts.machine machine = opts.machine
machine.debug(f"installing {machine.name}") machine.debug(f"installing {machine.name}")

View File

@@ -52,8 +52,22 @@ def extract_header(c: str) -> str:
return "\n".join(header_lines) return "\n".join(header_lines)
# TODO: Remove this function
# Split out the disko schema extraction into a separate function
# get machine returns the machine already
@API.register @API.register
def get_machine_details(machine: Machine) -> MachineDetails: def get_machine_details(machine: Machine) -> MachineDetails:
"""Retrieve detailed information about a machine, including its inventory,
hardware configuration, and disk schema if available.
Args:
machine (Machine): The machine instance for which details are to be retrieved.
Returns:
MachineDetails: An instance containing the machine's inventory, hardware configuration,
and disk schema.
Raises:
ClanError: If the machine's inventory cannot be found or if there are issues with the
hardware configuration or disk schema extraction.
"""
machine_inv = get_machine(machine.flake, machine.name) machine_inv = get_machine(machine.flake, machine.name)
hw_config = HardwareConfig.detect_type(machine) hw_config = HardwareConfig.detect_type(machine)

View File

@@ -106,6 +106,16 @@ def upload_sources(machine: Machine, ssh: Remote) -> str:
def run_machine_deploy( def run_machine_deploy(
machine: Machine, target_host: Remote, build_host: Remote | None machine: Machine, target_host: Remote, build_host: Remote | None
) -> None: ) -> None:
"""Update an existing machine using nixos-rebuild or darwin-rebuild.
Args:
machine: The Machine instance to deploy.
target_host: Remote object representing the target host for deployment.
build_host: Optional Remote object representing the build host.
Raises:
ClanError: If the machine is not found in the inventory or if there are issues with
generating facts or variables.
"""
with ExitStack() as stack: with ExitStack() as stack:
target_host = stack.enter_context(target_host.ssh_control_master()) target_host = stack.enter_context(target_host.ssh_control_master())

View File

@@ -444,6 +444,21 @@ class CheckResult:
def check_machine_ssh_login( def check_machine_ssh_login(
remote: Remote, opts: ConnectionOptions | None = None remote: Remote, opts: ConnectionOptions | None = None
) -> CheckResult: ) -> CheckResult:
"""Checks if a remote machine is reachable via SSH by attempting to run a simple command.
Args:
remote (Remote): The remote host to check for SSH login.
opts (ConnectionOptions, optional): Connection options such as timeout and number of retries.
If not provided, default values are used.
Returns:
CheckResult: An object indicating whether the SSH login is successful (`ok=True`) or not (`ok=False`),
and a reason if the check failed.
Usage:
result = check_machine_ssh_login(remote)
if result.ok:
print("SSH login successful")
else:
print(f"SSH login failed: {result.reason}")
"""
if opts is None: if opts is None:
opts = ConnectionOptions() opts = ConnectionOptions()
@@ -470,6 +485,22 @@ def check_machine_ssh_login(
def check_machine_ssh_reachable( def check_machine_ssh_reachable(
remote: Remote, opts: ConnectionOptions | None = None remote: Remote, opts: ConnectionOptions | None = None
) -> CheckResult: ) -> CheckResult:
"""
Checks if a remote machine is reachable via SSH by attempting to open a TCP connection
to the specified address and port.
Args:
remote (Remote): The remote host to check for SSH reachability.
opts (ConnectionOptions, optional): Connection options such as timeout and number of retries.
If not provided, default values are used.
Returns:
CheckResult: An object indicating whether the SSH port is reachable (`ok=True`) or not (`ok=False`),
and a reason if the check failed.
Usage:
result = check_machine_ssh_reachable(remote)
if result.ok:
print("SSH port is reachable")
print(f"SSH port is not reachable: {result.reason}")
"""
if opts is None: if opts is None:
opts = ConnectionOptions() opts = ConnectionOptions()

View File

@@ -131,7 +131,7 @@ def test_clan_create_api(
assert public_key.exists() assert public_key.exists()
assert public_key.is_file() assert public_key.is_file()
dest_clan_dir = Path("~/new-clan").expanduser() dest_clan_dir = Path("~/default").expanduser()
# ===== CREATE CLAN ====== # ===== CREATE CLAN ======
# TODO: We need to generate a lock file for the templates # TODO: We need to generate a lock file for the templates

View File

@@ -150,7 +150,7 @@ def main() -> None:
errors.extend(check_res) errors.extend(check_res)
if not func_schema.get("description"): if not func_schema.get("description"):
warnings.append( errors.append(
f"{func_name} doesn't have a description. Python docstring is required for an API function." f"{func_name} doesn't have a description. Python docstring is required for an API function."
) )

2
templates/clan/default/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
result
.direnv/

2
templates/clan/flake-parts/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
result
.direnv/

View File

@@ -1,2 +0,0 @@
result
.direnv/

View File

@@ -1,22 +0,0 @@
{ self, ... }:
{
perSystem =
{
self',
lib,
system,
...
}:
{
checks =
let
nixosMachines = lib.mapAttrs' (
name: config: lib.nameValuePair "nixos-${name}" config.config.system.build.toplevel
) ((lib.filterAttrs (_: config: config.pkgs.system == system)) self.nixosConfigurations);
packages = lib.mapAttrs' (n: lib.nameValuePair "package-${n}") self'.packages;
devShells = lib.mapAttrs' (n: lib.nameValuePair "devShell-${n}") self'.devShells;
in
nixosMachines // packages // devShells;
};
}

View File

@@ -1,13 +0,0 @@
{ self, inputs, ... }:
{
imports = [
inputs.clan.flakeModules.default
];
clan = {
meta.name = "__CHANGE_ME__";
inherit self;
specialArgs = {
inherit inputs;
};
};
}

View File

@@ -1,17 +0,0 @@
_: {
perSystem =
{
pkgs,
inputs',
...
}:
{
devShells = {
default = pkgs.mkShellNoCC {
packages = [
inputs'.clan.packages.default
];
};
};
};
}

View File

@@ -1,31 +0,0 @@
{
inputs = {
clan.url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
nixpkgs.follows = "clan/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "clan/nixpkgs";
};
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } (
{ ... }:
{
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
imports = [
./checks.nix
./clan.nix
./devshells.nix
./formatter.nix
];
}
);
}

View File

@@ -1,10 +0,0 @@
_: {
perSystem =
{
pkgs,
...
}:
{
formatter = pkgs.nixfmt;
};
}

2
templates/clan/minimal/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
result
.direnv/

View File

@@ -25,7 +25,7 @@
clan = { clan = {
default = { default = {
description = "Initialize a new clan flake"; description = "Initialize a new clan flake";
path = ./clan/new-clan; path = ./clan/default;
}; };
minimal = { minimal = {
description = "for clans managed via (G)UI"; description = "for clans managed via (G)UI";
@@ -35,10 +35,6 @@
description = "Flake-parts"; description = "Flake-parts";
path = ./clan/flake-parts; path = ./clan/flake-parts;
}; };
minimal-flake-parts = {
description = "Minimal flake-parts clan template";
path = ./clan/minimal-flake-parts;
};
}; };
}; };
in in