Compare commits

...

80 Commits

Author SHA1 Message Date
lassulus
e18afbc5ea pkgs: add clan-autorefresh
This adds a clan-cli shim that always uses nix run to run the pinned
clan-cli version. For this we need to expose the clan-cli via
clanInternals.
2025-07-16 12:03:31 +02:00
Jörg Thalheim
a04443adb8 waypipe: disable gpu for now 2025-07-16 12:03:31 +02:00
Jörg Thalheim
20ad968d04 waypipe: disable gpu for now 2025-07-16 11:55:15 +02:00
Mic92
65608ad401 Merge pull request 'Update data-mesher' (#4370) from update-data-mesher into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4370
2025-07-16 09:42:26 +00:00
gitea-actions[bot]
f46bf04b30 Update data-mesher 2025-07-16 09:30:40 +00:00
Mic92
d036f98cd4 Merge pull request 'clan-cli: Move flash.py to clan_lib/flash' (#4374) from Qubasa/clan-core:move_flash into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4374
2025-07-16 09:30:04 +00:00
DavHau
81df09a284 Merge pull request 'cleanup_install' (#4373) from Qubasa/clan-core:cleanup_install into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4373
2025-07-16 09:18:09 +00:00
Qubasa
a90cb56886 clan-cli: Move flash.py to clan_lib/flash 2025-07-16 15:29:18 +07:00
brianmcgee
af2ad09517 Merge pull request 'feat: ui/toolbar' (#4357) from ui/toolbar into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4357
2025-07-16 08:00:11 +00:00
Brian McGee
08ee06447b feat(ui): toolbar component 2025-07-16 09:55:11 +02:00
brianmcgee
b741340607 Merge pull request 'onboarding workflow' (#4366) from ui/onboarding-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4366
Reviewed-by: Mic92 <joerg@thalheim.io>
2025-07-16 07:17:26 +00:00
Qubasa
cfba97eee5 clan-cli: Reference HostKeyCheck literal instead of duplicating the list everywhere 2025-07-16 13:12:48 +07:00
Qubasa
fb4ccd1f63 clan-lib: Remove duplicate fields from installOptions and instead use them from Remote 2025-07-16 13:05:05 +07:00
Qubasa
2c4e688b0a clan-lib: Change BuildOn enum to Literal type. Literals can be translated better to typescript 2025-07-16 12:48:04 +07:00
Qubasa
f8a0943fbd clan-cli: Fix incorrect ipv6 check in check_machine_ssh_reachable 2025-07-16 12:34:30 +07:00
hsjobeki
9d61e550d5 Merge pull request 'cli: fix dot files not copied to $out in buildPythonApplication' (#4371) from pkgs-for into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4371
2025-07-15 21:44:19 +00:00
Johannes Kirschbauer
5742b88777 cli: fix dot files not copied $out in buildPythonApplication
File such as .envrc, .gitignore where not copied into the package and thus missing in all templates
2025-07-15 23:33:34 +02:00
hsjobeki
2ef3e4cac4 Merge pull request 'clanInternals: refactor configsPerSystem, minimize diff' (#4369) from pkgs-for into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4369
2025-07-15 20:04:10 +00:00
Johannes Kirschbauer
5fc98a9611 clanInternals: refactor configsPerSystem, minimize diff 2025-07-15 21:40:22 +02:00
Kenji Berthold
79922e57b2 Merge pull request 'pkgs/cli: Validate clan directory for update-hardware-config' (#4367) from kenji/ke-hardware-update-validation into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4367
2025-07-15 18:07:12 +00:00
hsjobeki
164cc4a455 Merge pull request 'revert bd3861c58056a847556c459ce420968044ce1459' (#4368) from hsjobeki-patch-1 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4368
2025-07-15 18:02:53 +00:00
hsjobeki
341f444fa0 revert bd3861c580
revert Merge pull request 'Remove clanModules/*' (#4202) from remove-modules into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4202

See: https://git.clan.lol/clan/clan-core/issues/4365

Not all modules are migrated.
If they are not migrated, we need to write migration docs and please display the link to the migration docs
2025-07-15 17:51:36 +00:00
a-kenji
a76bea3537 pkgs/cli: Validate clan directory for update-hardware-config 2025-07-15 19:11:07 +02:00
Mic92
9bb366cdd7 Merge pull request 'gitignore-images' (#4364) from gitignore-images into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4364
2025-07-15 15:03:31 +00:00
Brian McGee
9f582cd338 wip: onboarding workflow 2025-07-15 15:42:37 +01:00
Jörg Thalheim
028700b058 update-flake-inputs: email/user doesn't need to be configured 2025-07-15 16:19:45 +02:00
Jörg Thalheim
e8b111e229 run flake updates every 5 hours 2025-07-15 16:11:54 +02:00
Luis Hebendanz
1d8445a347 Merge pull request 'pkgs/cli: Fix ssh logging' (#4362) from kenji/ke-ssh-remove-debug into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4362
2025-07-15 13:00:00 +00:00
kenji
d84822ae39 Merge pull request 'pkgs/clan(templates): Add shell completions' (#4327) from kenji/ke-disko-shell into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4327
2025-07-15 12:58:59 +00:00
kenji
c2229b4da3 Merge pull request 'pkgs/cli: Add facts deprecation warning to clan facts help output' (#4329) from kenji/ke-facts-cli-warning into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4329
2025-07-15 12:43:19 +00:00
a-kenji
936290b01d pkgs/cli: Add facts deprecation warning to clan facts help output 2025-07-15 14:28:36 +02:00
clan-bot
d8dbdb4419 Merge pull request 'Update sops-nix' (#4361) from update-sops-nix into main 2025-07-15 12:28:25 +00:00
pinpox
bd3861c580 Merge pull request 'Remove clanModules/*' (#4202) from remove-modules into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4202
2025-07-15 12:25:15 +00:00
pinpox
13d69bcd66 Merge pull request 'clanServices: users -> remove isNormalUser option, set automatically' (#4351) from Qubasa/clan-core:good_default_for_users into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4351
Reviewed-by: lassulus <clanlol@lassul.us>
Reviewed-by: pinpox <clan@pablo.tools>
2025-07-15 12:24:55 +00:00
a-kenji
e342996306 pkgs/cli: Fix ssh logging
Fix the ssh logging level.
Currently the ssh commands is printed every time on an ssh connection.
While seeing the command is useful, we should print this when running
clan with the `--debug` flag.
2025-07-15 14:20:40 +02:00
kenji
bfec09e652 Merge pull request 'pkgs/clan: Add clan validation to vars' (#4360) from kenji/ke-add-clan-validation into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4360
2025-07-15 12:14:18 +00:00
gitea-actions[bot]
42ac9f3579 Update sops-nix 2025-07-15 12:14:08 +00:00
a-kenji
8178c41c7b pkgs/clan: Add clan validation to vars
Add clan validation to vars and facts subcommmands
2025-07-15 14:01:41 +02:00
pinpox
2a50dadf84 fmt 2025-07-15 13:48:37 +02:00
kenji
143fbb929f Merge pull request 'pkgs/clan: Further unify clan flake validation' (#4358) from kenji/ke-non-clan-commands into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4358
2025-07-15 11:46:04 +00:00
pinpox
aeb555a320 Fix tests 2025-07-15 13:40:54 +02:00
pinpox
8caaaa5b8b wip 2025-07-15 13:17:34 +02:00
a-kenji
6347bb7f3a pkgs/clan: Further unify clan flake validation
Further unify clan flake validation and improve test coverage.
2025-07-15 13:03:49 +02:00
Mic92
9a7288df3d Merge pull request 'add images to gitignore' (#4355) from gitignore-images into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4355
2025-07-15 10:37:25 +00:00
kenji
ce0ff60ad3 Merge pull request 'pkgs/clan: Add flake validation to clan show' (#4352) from kenji/ke-non-clan-show into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4352
2025-07-15 10:30:19 +00:00
Jörg Thalheim
d76dc55325 add images to gitignore
images now needed to be added with `git add --force`.
This makes accidential commits of large files harder.
2025-07-15 12:30:04 +02:00
Qubasa
70c1648caf clanServices: users -> remove isNormalUser option, set automatically
nix fmt
2025-07-15 17:21:52 +07:00
a-kenji
2ddba36b17 pkgs/clan: Add flake validation to clan show 2025-07-15 12:04:23 +02:00
a-kenji
d4cb206e3e pkgs/cli: Add require_flake clan validation logic
Add a `require_flake` function that checks, if no argument is passed, if
we are in a clan directory.
If not will throw a helpful error.

Before `clan show`:

```
Traceback (most recent call last):
  File "/nix/store/8kb3l3yvz6svygnxdlrw5lmd3h3chc8a-clan-cli/bin/.clan-wrapped", line 9, in <module>
    sys.exit(main())
             ~~~~^^
  File "/nix/store/8kb3l3yvz6svygnxdlrw5lmd3h3chc8a-clan-cli/lib/python3.13/site-packages/clan_cli/cli.py", line 493, in main
    args.func(args)
    ~~~~~~~~~^^^^^^
  File "/nix/store/8kb3l3yvz6svygnxdlrw5lmd3h3chc8a-clan-cli/lib/python3.13/site-packages/clan_cli/clan/show.py", line 12, in show_command
    meta = get_clan_details(flake)
  File "/nix/store/8kb3l3yvz6svygnxdlrw5lmd3h3chc8a-clan-cli/lib/python3.13/site-packages/clan_lib/clan/get.py", line 22, in get_clan_details
    if flake.is_local and not flake.path.exists():
       ^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'is_local'
```

with `require_flake`:

```
No clan flake found in the current directory or its parents - Use the --flake flag to specify a clan flake path or URL
```
2025-07-15 12:01:20 +02:00
pinpox
0e53499f40 Remove clanModules 2025-07-15 11:53:32 +02:00
Mic92
1befc86308 Merge pull request 'Update disko' (#4347) from update-disko into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4347
2025-07-15 09:50:39 +00:00
gitea-actions[bot]
999ade9dc4 Update disko 2025-07-15 09:03:02 +00:00
Mic92
01ab9e5dbf Merge pull request 'update-flake-inputs: enable auto-merge' (#4346) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4346
2025-07-15 09:02:06 +00:00
Jörg Thalheim
2ef248a10e update-flake-inputs: enable auto-merge 2025-07-15 10:55:28 +02:00
Mic92
24493e8768 Merge pull request 'Update nixpkgs' (#4340) from update-nixpkgs into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4340
2025-07-15 08:43:26 +00:00
DavHau
b91158f454 vars/interface: make type of dependencies configurable
One vars get lifted to the global scope, dependencies need to be structured differently, eg. categorized by instances
2025-07-15 13:41:05 +07:00
DavHau
66a6758db4 vars/interface: cleanup + don't use specialArgs for pkgs 2025-07-15 13:07:18 +07:00
DavHau
61df393c2d vars: reduce dependency on pkgs
pass pkgs only to generators submodule which is the only place where it is needed because of finalScript
2025-07-15 12:15:12 +07:00
gitea-actions[bot]
13458e4f58 Update nixpkgs 2025-07-14 18:45:03 +00:00
Mic92
dc3a41f403 Merge pull request 'update-flake-inputs: set gitea-token/github-token correctly' (#4339) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4339
2025-07-14 18:32:00 +00:00
Jörg Thalheim
a424f318e4 update-flake-inputs: set gitea-token/github-token correctly 2025-07-14 20:28:30 +02:00
Mic92
36adc38ec4 Merge pull request 'update-flake-inputs: drop gitea vars' (#4338) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4338
2025-07-14 15:45:21 +00:00
Jörg Thalheim
29581cd1f4 update-flake-inputs: drop gitea vars 2025-07-14 17:41:48 +02:00
Mic92
4523596eba Merge pull request 'drop renovate' (#4337) from merge-when-green-joerg into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4337
2025-07-14 15:41:00 +00:00
Jörg Thalheim
e97b06c410 drop renovate
we now use gitea actions for it.
2025-07-14 17:37:32 +02:00
Mic92
f79b3c2761 Merge pull request 'add new workflow to do flake updates' (#4336) from flakes into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4336
2025-07-14 15:14:42 +00:00
Jörg Thalheim
a509e16627 add new workflow to do flake updates 2025-07-14 17:11:22 +02:00
Luis Hebendanz
e05f4380d4 Merge pull request 'clan-cli: Make 'clan ssh' read out the targetHost to connect to' (#4335) from Qubasa/clan-core:fix_clan_ssh into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4335
2025-07-14 13:57:45 +00:00
Qubasa
e8b5e2c2c5 clan-cli: Fixup clan install which depends on ssh_parseargs.
clan-cli: Remove --ssh-option for now, as it can't work in current state

clan-cli: Remove nix_config from test as its impure
2025-07-14 20:47:49 +07:00
Qubasa
9630b6dbe4 clan-cli: Make 'clan ssh' read out the targetHost to connect to 2025-07-14 19:35:48 +07:00
DavHau
1c2b72c6f0 vars: cleanup nix interface 2025-07-14 18:20:04 +07:00
pinpox
c49a7c8277 Merge pull request 'Remove clanModules dependencies from admin service' (#4237) from admin-no-modules into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4237
2025-07-14 08:32:27 +00:00
pinpox
939f724878 Remove clanModules dependencies from admin service 2025-07-14 10:26:35 +02:00
Luis Hebendanz
8a56776032 Merge pull request 'Simplify flake.select logs, make logs readable again' (#4333) from Qubasa/clan-core:improve_log_output into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4333
2025-07-14 05:14:12 +00:00
Qubasa
60f7f8598b docs: Document new debug env vars in debugging.md 2025-07-14 12:11:16 +07:00
Qubasa
36282b92bc clan-cli: improve log messages further
nix fmt
2025-07-14 12:02:03 +07:00
Qubasa
0cf35480a2 clan-cli: Filter out flake select traces to improve debug log visibility 2025-07-14 11:51:35 +07:00
renovate[bot]
e875df5665 chore(deps): update data-mesher digest to 309e06f 2025-07-14 00:10:13 +00:00
hsjobeki
92d5ea82a7 Merge pull request 'deploy: add warning about disko.nix' (#4330) from docs-3 into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4330
2025-07-13 19:29:52 +00:00
Johannes Kirschbauer
8f5bf1ff2a deploy: add warning about disko.nix 2025-07-13 21:26:07 +02:00
102 changed files with 2763 additions and 1225 deletions

View File

@@ -0,0 +1,26 @@
name: Update Flake Inputs
on:
schedule:
# Run every 5 hours
- cron: "0 */5 * * *"
workflow_dispatch:
repository_dispatch:
jobs:
update-flake-inputs:
runs-on: nix
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Update flake inputs
uses: Mic92/update-flake-inputs-gitea@main
with:
# Exclude private flakes and update-clan-core checks flake
exclude-patterns: "devFlake/private/flake.nix,checks/impure/flake.nix"
auto-merge: true
gitea-token: ${{ secrets.CI_BOT_TOKEN }}
github-token: ${{ secrets.CI_BOT_GITHUB_TOKEN }}

10
.gitignore vendored
View File

@@ -43,3 +43,13 @@ pkgs/clan-app/ui/api/Inventory.ts
pkgs/clan-app/ui/api/modules_schemas.json
pkgs/clan-app/ui/api/schema.json
pkgs/clan-app/ui/.fonts
# To avoid accidentally committing large files
# Can be added with `git add -f` after reviewing the filesize
# Large files should be avoided or stored externally i.e. a gitea release
*.jpg
*.png
*.jpeg
*.gif
*.mp4
*.mkv

View File

@@ -9,7 +9,9 @@
interface =
{ lib, ... }:
{
options.allowedKeys = lib.mkOption {
options = {
allowedKeys = lib.mkOption {
default = { };
type = lib.types.attrsOf lib.types.str;
description = "The allowed public keys for ssh access to the admin user";
@@ -18,6 +20,26 @@
};
};
rsaHostKey.enable = lib.mkEnableOption "Generate RSA host key";
# TODO: allow per-server domains that we than collect in the inventory
#certicficateDomains = lib.mkOption {
# type = lib.types.listOf lib.types.str;
# default = [ ];
# example = [ "git.mydomain.com" ];
# description = "List of domains to include in the certificate. This option will not prepend the machine name in front of each domain.";
#};
certificateSearchDomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "mydomain.com" ];
description = ''
List of domains to include in the certificate.
This option will prepend the machine name in front of each domain before adding it to the certificate.
'';
};
};
};
perInstance =
@@ -27,10 +49,15 @@
{ ... }:
{
imports = [
../../clanModules/sshd
../../clanModules/root-password
# We don't have a good way to specify dependencies between
# clanServices for now. When it get's implemtende, we should just
# use the ssh and users modules here.
./ssh.nix
./root-password.nix
];
_module.args = { inherit settings; };
users.users.root.openssh.authorizedKeys.keys = builtins.attrValues settings.allowedKeys;
};
};

View File

@@ -0,0 +1,39 @@
# We don't have a way of specifying dependencies between clanServices for now.
# When it get's added this file should be removed and the users module used instead.
{
config,
pkgs,
...
}:
{
users.mutableUsers = false;
users.users.root.hashedPasswordFile =
config.clan.core.vars.generators.root-password.files.password-hash.path;
clan.core.vars.generators.root-password = {
files.password-hash.neededFor = "users";
files.password.deploy = false;
runtimeInputs = [
pkgs.coreutils
pkgs.mkpasswd
pkgs.xkcdpass
];
prompts.password.type = "hidden";
prompts.password.persist = true;
prompts.password.description = "You can autogenerate a password, if you leave this prompt blank.";
script = ''
prompt_value="$(cat "$prompts"/password)"
if [[ -n "''${prompt_value-}" ]]; then
echo "$prompt_value" | tr -d "\n" > "$out"/password
else
xkcdpass --numwords 5 --delimiter - --count 1 | tr -d "\n" > "$out"/password
fi
mkpasswd -s -m sha-512 < "$out"/password | tr -d "\n" > "$out"/password-hash
'';
};
}

115
clanServices/admin/ssh.nix Normal file
View File

@@ -0,0 +1,115 @@
{
config,
pkgs,
lib,
settings,
...
}:
let
stringSet = list: builtins.attrNames (builtins.groupBy lib.id list);
domains = stringSet settings.certificateSearchDomains;
in
{
services.openssh = {
enable = true;
settings.PasswordAuthentication = false;
settings.HostCertificate = lib.mkIf (
settings.certificateSearchDomains != [ ]
) config.clan.core.vars.generators.openssh-cert.files."ssh.id_ed25519-cert.pub".path;
hostKeys =
[
{
path = config.clan.core.vars.generators.openssh.files."ssh.id_ed25519".path;
type = "ed25519";
}
]
++ lib.optional settings.rsaHostKey.enable {
path = config.clan.core.vars.generators.openssh-rsa.files."ssh.id_rsa".path;
type = "rsa";
};
};
clan.core.vars.generators.openssh = {
files."ssh.id_ed25519" = { };
files."ssh.id_ed25519.pub".secret = false;
migrateFact = "openssh";
runtimeInputs = [
pkgs.coreutils
pkgs.openssh
];
script = ''
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/ssh.id_ed25519
'';
};
programs.ssh.knownHosts.clan-sshd-self-ed25519 = {
hostNames = [
"localhost"
config.networking.hostName
] ++ (lib.optional (config.networking.domain != null) config.networking.fqdn);
publicKey = config.clan.core.vars.generators.openssh.files."ssh.id_ed25519.pub".value;
};
clan.core.vars.generators.openssh-rsa = lib.mkIf settings.rsaHostKey.enable {
files."ssh.id_rsa" = { };
files."ssh.id_rsa.pub".secret = false;
runtimeInputs = [
pkgs.coreutils
pkgs.openssh
];
script = ''
ssh-keygen -t rsa -b 4096 -N "" -C "" -f "$out"/ssh.id_rsa
'';
};
clan.core.vars.generators.openssh-cert = lib.mkIf (settings.certificateSearchDomains != [ ]) {
files."ssh.id_ed25519-cert.pub".secret = false;
dependencies = [
"openssh"
"openssh-ca"
];
validation = {
name = config.clan.core.settings.machine.name;
domains = lib.genAttrs settings.certificateSearchDomains lib.id;
};
runtimeInputs = [
pkgs.openssh
pkgs.jq
];
script = ''
ssh-keygen \
-s $in/openssh-ca/id_ed25519 \
-I ${config.clan.core.settings.machine.name} \
-h \
-n ${lib.concatMapStringsSep "," (d: "${config.clan.core.settings.machine.name}.${d}") domains} \
$in/openssh/ssh.id_ed25519.pub
mv $in/openssh/ssh.id_ed25519-cert.pub "$out"/ssh.id_ed25519-cert.pub
'';
};
clan.core.vars.generators.openssh-ca = lib.mkIf (settings.certificateSearchDomains != [ ]) {
share = true;
files.id_ed25519.deploy = false;
files."id_ed25519.pub" = {
deploy = false;
secret = false;
};
runtimeInputs = [
pkgs.openssh
];
script = ''
ssh-keygen -t ed25519 -N "" -C "" -f "$out"/id_ed25519
'';
};
programs.ssh.knownHosts.ssh-ca = lib.mkIf (settings.certificateSearchDomains != [ ]) {
certAuthority = true;
extraHostNames = builtins.map (domain: "*.${domain}") settings.certificateSearchDomains;
publicKey = config.clan.core.vars.generators.openssh-ca.files."id_ed25519.pub".value;
};
}

View File

@@ -11,7 +11,7 @@
roles.default = {
interface =
{ config, lib, ... }:
{ lib, ... }:
{
options = {
user = lib.mkOption {
@@ -37,23 +37,6 @@
- `clan vars get <machine-name> <name-of-password-variable>`
'';
};
regularUser = lib.mkOption {
type = lib.types.bool;
default = config.user != "root";
defaultText = lib.literalExpression "config.user != \"root\"";
example = false;
description = ''
Whether the user should be a regular user or a system user.
Regular users are normal users that can log in and have a home directory.
System users are used for system services and do not have a home directory.
!!! Warning
`root` cannot be a regular user.
You must set this to `false` for `root`
'';
};
groups = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@@ -91,7 +74,7 @@
}:
{
users.users.${settings.user} = {
isNormalUser = settings.regularUser;
isNormalUser = if settings.user == "root" then false else true;
extraGroups = settings.groups;
hashedPasswordFile =

View File

@@ -55,9 +55,37 @@ If you're using VSCode, it has a handy feature that makes paths to source code f
## Finding Print Messages
To identify where a specific print message comes from, you can enable a helpful feature. Simply set the environment variable `export TRACE_PRINT=1`. When you run commands with `--debug` mode, each print message will include information about its source location.
To trace the origin of print messages in `clan-cli`, you can enable special debugging features using environment variables:
- Set `TRACE_PRINT=1` to include the source location with each print message:
```bash
export TRACE_PRINT=1
```
When running commands with `--debug`, every print will show where it was triggered in the code.
- To see a deeper stack trace for each print, set `TRACE_DEPTH` to the desired number of stack frames (e.g., 3):
```bash
export TRACE_DEPTH=3
```
### Additional Debug Logging
You can enable more detailed logging for specific components by setting these environment variables:
- `CLAN_DEBUG_NIX_SELECTORS=1` — verbose logs for flake.select operations
- `CLAN_DEBUG_NIX_PREFETCH=1` — verbose logs for flake.prefetch operations
- `CLAN_DEBUG_COMMANDS=1` — print the diffed environment of executed commands
Example:
```bash
export CLAN_DEBUG_NIX_SELECTORS=1
export CLAN_DEBUG_NIX_PREFETCH=1
export CLAN_DEBUG_COMMANDS=1
```
These options help you pinpoint the source and context of print messages and debug logs during development.
If you need more details, you can expand the stack trace information that appears with each print by setting the environment variable `export TRACE_DEPTH=3`.
## Analyzing Performance

View File

@@ -181,6 +181,13 @@ You can have a look and customize it if needed.
!!! tip
For advanced partitioning, see [Disko templates](https://github.com/nix-community/disko-templates) or [Disko examples](https://github.com/nix-community/disko/tree/master/example).
!!! Danger
Don't change the `disko.nix` after the machine is installed for the first time.
Changing disko configuration requires wiping and reinstalling the machine.
Unless you really know what you are doing.
## Deploy the machine
**Finally deployment time!** Use one of the following commands to build and deploy the image via SSH onto your machine.
@@ -267,4 +274,3 @@ clan {
```
This is useful for machines that are not always online or are not part of the regular update cycle.

29
flake.lock generated
View File

@@ -8,19 +8,16 @@
"nixpkgs": [
"nixpkgs"
],
"systems": [
"systems"
],
"treefmt-nix": [
"treefmt-nix"
]
},
"locked": {
"lastModified": 1751846468,
"narHash": "sha256-h0mpWZIOIAKj4fmLNyI2HDG+c0YOkbYmyJXSj/bQ9s0=",
"rev": "a2166c13b0cb3febdaf36391cd2019aa2ccf4366",
"lastModified": 1752589312,
"narHash": "sha256-BafZOenlzMYdumG12AzgVLhEVu+GcEa8nYNDSIYe1U0=",
"rev": "496bbf05a2aa7b061ef464254db5804d1c6f45b4",
"type": "tarball",
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/a2166c13b0cb3febdaf36391cd2019aa2ccf4366.tar.gz"
"url": "https://git.clan.lol/api/v1/repos/clan/data-mesher/archive/496bbf05a2aa7b061ef464254db5804d1c6f45b4.tar.gz"
},
"original": {
"type": "tarball",
@@ -34,11 +31,11 @@
]
},
"locked": {
"lastModified": 1752113600,
"narHash": "sha256-7LYDxKxZgBQ8LZUuolAQ8UkIB+jb4A2UmiR+kzY9CLI=",
"lastModified": 1752541678,
"narHash": "sha256-dyhGzkld6jPqnT/UfGV2oqe7tYn7hppAqFvF3GZTyXY=",
"owner": "nix-community",
"repo": "disko",
"rev": "79264292b7e3482e5702932949de9cbb69fedf6d",
"rev": "2bf3421f7fed5c84d9392b62dcb9d76ef09796a7",
"type": "github"
},
"original": {
@@ -118,10 +115,10 @@
"nixpkgs": {
"locked": {
"lastModified": 315532800,
"narHash": "sha256-mUlYenGbsUFP0A3EhfKJXmUl5+MQGJLhoEop2t3g5p4=",
"rev": "ceb24d94c6feaa4e8737a8e2bd3cf71c3a7eaaa0",
"narHash": "sha256-lUi+sPH7Kuh9uP3PyfgbENcJGReUM8Ffk9GxGBFbSN8=",
"rev": "be9e214982e20b8310878ac2baa063a961c1bdf6",
"type": "tarball",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre826033.ceb24d94c6fe/nixexprs.tar.xz"
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.11pre827262.be9e214982e2/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
@@ -149,11 +146,11 @@
]
},
"locked": {
"lastModified": 1751606940,
"narHash": "sha256-KrDPXobG7DFKTOteqdSVeL1bMVitDcy7otpVZWDE6MA=",
"lastModified": 1752544651,
"narHash": "sha256-GllP7cmQu7zLZTs9z0J2gIL42IZHa9CBEXwBY9szT0U=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "3633fc4acf03f43b260244d94c71e9e14a2f6e0d",
"rev": "2c8def626f54708a9c38a5861866660395bb3461",
"type": "github"
},
"original": {

View File

@@ -275,6 +275,8 @@ in
templates = lib.mkOption { type = lib.types.raw; };
machines = lib.mkOption { type = lib.types.raw; };
clan-cli = lib.mkOption { type = lib.types.raw; };
};
};
};

View File

@@ -101,19 +101,15 @@ let
system:
lib.nameValuePair system (
lib.mapAttrs (
name: _:
moduleSystemConstructor.${machineClasses.${name}} {
_: machine:
machine.extendModules {
modules = [
(config.outputs.moduleForMachine.${name} or { })
(lib.modules.importApply ../machineModules/overridePkgs.nix {
pkgs = pkgsFor.${system};
})
];
specialArgs = {
inherit clan-core;
} // specialArgs;
}
) allMachines
) configurations
)
) supportedSystems
);
@@ -277,6 +273,9 @@ in
# machine specifics
machines = configsPerSystem;
# export clan-cli in clanInternals to tie the CLI to the flake
clan-cli = builtins.mapAttrs (_sys: pkgs: pkgs.clan-cli) clan-core.packages;
};
};
}

View File

@@ -26,6 +26,7 @@ in
++ lib.optionals (_class == "nixos") [
./secret/password-store.nix
];
options.clan.core.vars = lib.mkOption {
description = ''
Generated Variables
@@ -36,7 +37,14 @@ in
- generate secrets like private keys automatically when they are needed
- output multiple values like private and public keys simultaneously
'';
type = submodule { imports = [ ./interface.nix ]; };
type = submodule {
imports = [
./interface.nix
{
settings.dependenciesType = lib.types.listOf lib.types.str;
}
];
};
};
config = {

View File

@@ -10,6 +10,9 @@ let
hashString
toJSON
;
inherit (lib)
mkOption
;
inherit (lib.types)
attrsOf
bool
@@ -22,20 +25,29 @@ let
package
path
str
strMatching
submoduleWith
;
# the original types.submodule has strange behavior
submodule =
module:
submoduleWith {
specialArgs.pkgs = pkgs;
modules = [ module ];
};
submoduleWithPkgs =
module:
submoduleWith {
modules = [
module
{ config._module.args.pkgs = pkgs; }
];
};
in
{
options = {
settings = import ./settings-opts.nix { inherit lib; };
generators = lib.mkOption {
generators = mkOption {
description = ''
A set of generators that can be used to generate files.
Generators are scripts that produce files based on the values of other generators and user input.
@@ -43,11 +55,11 @@ in
'';
default = { };
type = attrsOf (
submodule (generator: {
submoduleWithPkgs (generator: {
imports = [ ./generator.nix ];
options = {
name = lib.mkOption {
type = lib.types.str;
name = mkOption {
type = str;
description = ''
The name of the generator.
This name will be used to refer to the generator in other generators.
@@ -56,8 +68,7 @@ in
default = generator.config._module.args.name;
defaultText = "Name of the generator";
};
dependencies = lib.mkOption {
dependencies = mkOption {
description = ''
A list of other generators that this generator depends on.
The output values of these generators will be available to the generator script as files.
@@ -66,10 +77,10 @@ in
**A file `file1` of a generator named `dep1` will be available via `$in/dep1/file1`**
'';
type = listOf str;
type = config.settings.dependenciesType;
default = [ ];
};
migrateFact = lib.mkOption {
migrateFact = mkOption {
description = ''
The fact service name to import the files from.
@@ -79,7 +90,7 @@ in
example = "my_service";
default = null;
};
validation = lib.mkOption {
validation = mkOption {
description = ''
A set of values that invalidate the generated values.
If any of these values change, the generated values will be re-generated.
@@ -106,7 +117,7 @@ in
]);
};
# the validationHash is the validation interface to the outside world
validationHash = lib.mkOption {
validationHash = mkOption {
internal = true;
description = ''
A hash of the invalidation data.
@@ -122,7 +133,7 @@ in
hashString "sha256" (toJSON generator.config.validation);
defaultText = "Hash of the invalidation data";
};
files = lib.mkOption {
files = mkOption {
description = ''
A set of files to generate.
The generator 'script' is expected to produce exactly these files under $out.
@@ -152,8 +163,8 @@ in
];
options =
{
name = lib.mkOption {
type = lib.types.str;
name = mkOption {
type = str;
description = ''
name of the public fact
'';
@@ -161,8 +172,8 @@ in
default = file.config._module.args.name;
defaultText = "Name of the file";
};
generatorName = lib.mkOption {
type = lib.types.str;
generatorName = mkOption {
type = str;
description = ''
name of the generator
'';
@@ -170,8 +181,8 @@ in
default = generator.config._module.args.name;
defaultText = "Name of the generator that generates this file";
};
share = lib.mkOption {
type = lib.types.bool;
share = mkOption {
type = bool;
description = ''
Whether the generated vars should be shared between machines.
Shared vars are only generated once, when the first machine using it is deployed.
@@ -182,7 +193,7 @@ in
default = generator.config.share;
defaultText = "Mirror of the share flag of the generator";
};
deploy = lib.mkOption {
deploy = mkOption {
description = ''
Whether the file should be deployed to the target machine.
@@ -191,14 +202,14 @@ in
type = bool;
default = true;
};
secret = lib.mkOption {
secret = mkOption {
description = ''
Whether the file should be treated as a secret.
'';
type = bool;
default = true;
};
flakePath = lib.mkOption {
flakePath = mkOption {
description = ''
The path to the file containing the content of the generated value.
This will be set automatically
@@ -206,7 +217,7 @@ in
type = nullOr path;
default = null;
};
path = lib.mkOption {
path = mkOption {
description = ''
The path to the file containing the content of the generated value.
This will be set automatically
@@ -223,7 +234,7 @@ in
path = file.config.flakePath;
};
};
neededFor = lib.mkOption {
neededFor = mkOption {
description = ''
This option determines when the secret will be decrypted and deployed to the target machine.
@@ -233,7 +244,7 @@ in
By setting this to `user`, the secret will be deployed prior to users and groups are created, allowing
users' passwords to be managed by vars. The secret will be stored in `/run/secrets-for-users` and `owner` and `group` must be `root`.
'';
type = lib.types.enum [
type = enum [
"partitioning"
"activation"
"users"
@@ -241,22 +252,22 @@ in
];
default = "services";
};
owner = lib.mkOption {
owner = mkOption {
description = "The user name or id that will own the file.";
default = "root";
};
group = lib.mkOption {
group = mkOption {
description = "The group name or id that will own the file.";
default = if _class == "darwin" then "wheel" else "root";
defaultText = lib.literalExpression ''if _class == "darwin" then "wheel" else "root"'';
};
mode = lib.mkOption {
type = lib.types.strMatching "^[0-7]{4}$";
mode = mkOption {
type = strMatching "^[0-7]{4}$";
description = "The unix file mode of the file. Must be a 4-digit octal number.";
default = "0400";
};
value =
lib.mkOption {
mkOption {
description = ''
The content of the generated value.
Only available if the file is not secret.
@@ -269,7 +280,7 @@ in
};
}
// (lib.optionalAttrs (_class == "nixos") {
restartUnits = lib.mkOption {
restartUnits = mkOption {
description = ''
A list of systemd units that should be restarted after the file is deployed.
This is useful for services that need to reload their configuration after the file is updated.
@@ -283,7 +294,7 @@ in
})
);
};
prompts = lib.mkOption {
prompts = mkOption {
description = ''
A set of prompts to ask the user for values.
Prompts are available to the generator script as files.
@@ -293,7 +304,7 @@ in
type = attrsOf (
submodule (prompt: {
options = {
name = lib.mkOption {
name = mkOption {
description = ''
The name of the prompt.
This name will be used to refer to the prompt in the generator script.
@@ -302,7 +313,7 @@ in
default = prompt.config._module.args.name;
defaultText = "Name of the prompt";
};
persist = lib.mkOption {
persist = mkOption {
description = ''
Whether the prompted value should be stored in a file with the same name as the prompt.
@@ -317,7 +328,7 @@ in
type = bool;
default = false;
};
description = lib.mkOption {
description = mkOption {
description = ''
The description of the prompted value
'';
@@ -326,7 +337,7 @@ in
default = prompt.config._module.args.name;
defaultText = "Name of the prompt";
};
type = lib.mkOption {
type = mkOption {
description = ''
The input type of the prompt.
The following types are available:
@@ -347,7 +358,7 @@ in
})
);
};
runtimeInputs = lib.mkOption {
runtimeInputs = mkOption {
description = ''
A list of packages that the generator script requires.
These packages will be available in the PATH when the script is run.
@@ -355,7 +366,7 @@ in
type = listOf package;
default = [ ];
};
script = lib.mkOption {
script = mkOption {
description = ''
The script to run to generate the files.
The script will be run with the following environment variables:
@@ -369,17 +380,17 @@ in
type = either str path;
default = "";
};
finalScript = lib.mkOption {
finalScript = mkOption {
description = ''
The final generator script, wrapped, so:
- all required programs are in PATH
- sandbox is set up correctly
'';
type = lib.types.path;
type = path;
readOnly = true;
internal = true;
};
share = lib.mkOption {
share = mkOption {
description = ''
Whether the generated vars should be shared between machines.
Shared vars are only generated once, when the first machine using it is deployed.

View File

@@ -65,4 +65,14 @@
Set it to pkgs.pass for GPG or pkgs.passage for age encryption.
'';
};
dependenciesType = lib.mkOption {
type = lib.types.raw;
description = ''
The type of the `dependencies` option.
'';
internal = true;
readOnly = true;
visible = false;
};
}

View File

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="223" height="89" fill="currentColor">
<g clip-path="url(#a)">
<path d="M55.503 18.696h10.104a1.946 1.946 0 0 0 1.943-1.948v-7.79c0-1.075-.87-1.947-1.943-1.947h-3.186a1.863 1.863 0 0 1-1.866-1.87V1.947C60.555.872 59.685 0 58.612 0h-27.98a1.946 1.946 0 0 0-1.944 1.947v3.194c0 1.036-.832 1.87-1.865 1.87h-3.187a1.946 1.946 0 0 0-1.943 1.947v3.194c0 1.036-.832 1.87-1.866 1.87h-3.186a1.946 1.946 0 0 0-1.943 1.947s-.467 1.153-.467 23.253c0 19.763.467 21.913.467 21.913 0 1.075.87 1.948 1.943 1.948h3.186c1.034 0 1.866.833 1.866 1.87v3.271c0 1.036.831 1.87 1.865 1.87h3.265c1.033 0 1.865.833 1.865 1.87v3.193c0 1.075.87 1.948 1.943 1.948h27.981a1.946 1.946 0 0 0 1.943-1.948v-3.194c0-1.036.832-1.87 1.866-1.87h5.145a1.946 1.946 0 0 0 1.943-1.947v-9.285c0-1.075-.87-1.948-1.943-1.948H55.503a1.946 1.946 0 0 0-1.943 1.948v4.69c0 1.035-.832 1.869-1.866 1.869H37.55a1.863 1.863 0 0 1-1.866-1.87v-4.752c0-1.075-.87-1.947-1.943-1.947H29c-1.034 0-1.609.148-1.865-1.87-.078-.646-.125-1.44-.18-2.508-.147-2.68-.287-5.5-.287-13.539 0-11.24.288-16.81.466-18.369.18-1.558.832-1.87 1.866-1.87h4.741a1.946 1.946 0 0 0 1.943-1.947v-3.193c0-1.037.832-1.87 1.866-1.87h14.145c1.034 0 1.866.833 1.866 1.87v3.193c0 1.075.87 1.948 1.943 1.948M20.247 74.822h-2.293a.814.814 0 0 1-.808-.81v-2.298c0-.896-.723-1.62-1.617-1.62H9.327c-.894 0-1.617.724-1.617 1.62v2.298c0 .444-.365.81-.808.81H4.609c-.894 0-1.617.725-1.617 1.62v6.217c0 .896.723 1.62 1.617 1.62h2.293c.443 0 .808.366.808.81v2.299c0 .895.723 1.62 1.617 1.62h6.202c.894 0 1.617-.725 1.617-1.62v-2.299c0-.444.365-.81.808-.81h2.293c.894 0 1.617-.724 1.617-1.62v-6.216c0-.896-.723-1.62-1.617-1.62M221.135 35.04h-1.71a1.863 1.863 0 0 1-1.866-1.87v-3.272c0-1.036-.831-1.87-1.865-1.87h-3.265a1.863 1.863 0 0 1-1.865-1.87v-3.271c0-1.036-.832-1.87-1.865-1.87h-20.971a1.863 1.863 0 0 0-1.865 1.87v3.965c0 .514-.42.935-.933.935h-3.559c-.513 0-.84-.32-.933-.935l-.622-3.918c-.148-1.099-.676-1.777-1.788-1.777l-3.653-.14h-2.052a3.736 3.736 0 0 0-3.73 3.74V61.68a3.736 3.736 0 0 1-3.731 3.739h-8.394a1.863 1.863 0 0 1-1.866-1.87V36.714c0-11.825-7.461-18.813-22.556-18.813-13.718 0-20.325 5.04-21.203 14.443-.109 1.153.552 1.815 1.702 1.815l7.757.569c1.143.1 1.594-.554 1.811-1.652.77-3.74 4.174-5.827 9.933-5.827 7.081 0 10.042 3.358 10.042 9.076v3.014c0 1.036-.831 1.87-1.865 1.87l-.342-.024h-9.715c-15.421 0-22.984 5.983-22.984 17.956 0 3.802.778 7.058 2.254 9.738h-.59c-1.765-1.27-2.457-2.236-3.055-2.93-.256-.295-.653-.537-1.345-.537h-1.717l-5.993.008h-3.264a3.736 3.736 0 0 1-3.731-3.74V1.769C89.74.654 89.072 0 87.969 0H79.55c-1.034 0-1.865.732-1.865 1.768l-.024 54.304v13.554c0 4.13 3.343 7.479 7.462 7.479h50.84c8.448-.429 8.604-3.42 9.436-4.542.645 3.56 1.865 4.347 4.71 4.518 8.137.117 18.343.032 18.49.024h4.975c4.119 0 6.684-3.35 6.684-7.479l.777-27.264c0-1.036.832-1.87 1.866-1.87h2.021a1.56 1.56 0 0 0 1.554-1.558v-3.583c0-1.036.832-1.87 1.866-1.87h11.868a3.37 3.37 0 0 1 3.366 3.373v3.249c0 1.075.87 1.947 1.943 1.947h4.119c.513 0 .933.42.933.935v32.25c0 1.036.831 1.87 1.865 1.87h6.84a3.736 3.736 0 0 0 3.731-3.74V36.91c0-1.036-.832-1.87-1.866-1.87zM142.64 54.225c0 8.927-6.132 14.715-15.335 14.715-6.606 0-9.793-2.953-9.793-8.748 0-6.442 3.832-9.636 11.62-9.636h13.508v3.669"/>
</g>
<defs>
<clipPath id="a">
<path d="M0 0h223v89H0z"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 26" fill="none">
<path d="M15.2735 5.89115H18.2129C18.5249 5.89115 18.7781 5.63627 18.7781 5.32222V3.04651C18.7781 2.73246 18.5249 2.47758 18.2129 2.47758H17.2858C16.9851 2.47758 16.7432 2.23408 16.7432 1.93141V0.998371C16.7432 0.684323 16.4899 0.429443 16.1779 0.429443H8.03798C7.72595 0.429443 7.47271 0.684323 7.47271 0.998371V1.93141C7.47271 2.23408 7.23077 2.47758 6.93005 2.47758H6.003C5.69097 2.47758 5.43773 2.73246 5.43773 3.04651V3.97955C5.43773 4.28222 5.1958 4.52572 4.89507 4.52572H3.96803C3.656 4.52572 3.40276 4.7806 3.40276 5.09465C3.40276 5.09465 3.26709 5.43146 3.26709 11.4582C3.26709 17.2317 3.40276 17.8598 3.40276 17.8598C3.40276 18.1739 3.656 18.4287 3.96803 18.4287H4.89507C5.1958 18.4287 5.43773 18.6722 5.43773 18.9749V19.9307C5.43773 20.2334 5.67967 20.4769 5.98039 20.4769H6.93005C7.23077 20.4769 7.47271 20.7204 7.47271 21.023V21.9561C7.47271 22.2701 7.72595 22.525 8.03798 22.525H16.1779C16.4899 22.525 16.7432 22.2701 16.7432 21.9561V21.023C16.7432 20.7204 16.9851 20.4769 17.2858 20.4769H18.7827C19.0947 20.4769 19.3479 20.222 19.3479 19.9079V17.1953C19.3479 16.8812 19.0947 16.6264 18.7827 16.6264H15.2735C14.9614 16.6264 14.7082 16.8812 14.7082 17.1953V18.5653C14.7082 18.8679 14.4662 19.1114 14.1655 19.1114H10.0503C9.74962 19.1114 9.50768 18.8679 9.50768 18.5653V17.1771C9.50768 16.863 9.25444 16.6082 8.94241 16.6082H7.56315C7.26243 16.6082 7.09511 16.6514 7.02049 16.062C6.99788 15.8731 6.98431 15.641 6.96849 15.3292C6.92552 14.5464 6.88483 13.7226 6.88483 11.374C6.88483 8.5196 6.96849 6.89246 7.02049 6.43732C7.0725 5.98218 7.26243 5.89115 7.56315 5.89115H8.94241C9.25444 5.89115 9.50768 5.63627 9.50768 5.32222V4.38918C9.50768 4.08651 9.74962 3.84301 10.0503 3.84301H14.1655C14.4662 3.84301 14.7082 4.08651 14.7082 4.38918V5.32222C14.7082 5.63627 14.9614 5.89115 15.2735 5.89115Z" fill="currentColor"/>
<path d="M5.01717 21.8582H4.35015C4.22126 21.8582 4.11499 21.7513 4.11499 21.6216V20.9502C4.11499 20.6885 3.90471 20.4769 3.64469 20.4769H1.84034C1.58032 20.4769 1.37004 20.6885 1.37004 20.9502V21.6216C1.37004 21.7513 1.26377 21.8582 1.13488 21.8582H0.467864C0.207839 21.8582 -0.00244141 22.0699 -0.00244141 22.3316V24.1476C-0.00244141 24.4093 0.207839 24.6209 0.467864 24.6209H1.13488C1.26377 24.6209 1.37004 24.7279 1.37004 24.8576V25.5289C1.37004 25.7907 1.58032 26.0023 1.84034 26.0023H3.64469C3.90471 26.0023 4.11499 25.7907 4.11499 25.5289V24.8576C4.11499 24.7279 4.22126 24.6209 4.35015 24.6209H5.01717C5.27719 24.6209 5.48747 24.4093 5.48747 24.1476V22.3316C5.48747 22.0699 5.27719 21.8582 5.01717 21.8582Z" fill="currentColor"/>
<path d="M63.4576 10.2361H62.9601C62.6594 10.2361 62.4175 9.99265 62.4175 9.68998V8.73418C62.4175 8.43151 62.1755 8.18801 61.8748 8.18801H60.9252C60.6244 8.18801 60.3825 7.94451 60.3825 7.64184V6.68604C60.3825 6.38337 60.1406 6.13987 59.8398 6.13987H53.7394C53.4387 6.13987 53.1968 6.38337 53.1968 6.68604V7.84438C53.1968 7.99457 53.0747 8.11746 52.9254 8.11746H51.8899C51.7406 8.11746 51.6457 8.02416 51.6185 7.84438L51.4376 6.69969C51.3947 6.37882 51.2409 6.18083 50.9176 6.18083L49.8549 6.13987H49.258C48.6588 6.13987 48.1726 6.62915 48.1726 7.23221V18.0191C48.1726 18.6221 47.6865 19.1114 47.0873 19.1114H44.6453C44.3446 19.1114 44.1027 18.8679 44.1027 18.5653V10.7254C44.1027 7.2709 41.932 5.22958 37.541 5.22958C33.5502 5.22958 31.6283 6.70197 31.3728 9.44875C31.3411 9.78556 31.5333 9.97899 31.868 9.97899L34.1245 10.1451C34.4569 10.1747 34.588 9.98354 34.6514 9.66267C34.8752 8.57033 35.8656 7.96044 37.541 7.96044C39.6009 7.96044 40.4623 8.94127 40.4623 10.6116V11.4923C40.4623 11.795 40.2204 12.0385 39.9197 12.0385L39.8202 12.0317H36.9938C32.5078 12.0317 30.3078 13.7794 30.3078 17.2772C30.3078 18.3877 30.5339 19.339 30.9635 20.1218H30.7917C30.2784 19.7509 30.0772 19.4687 29.9031 19.2662C29.8285 19.1797 29.7131 19.1091 29.5119 19.1091H29.0122L27.2689 19.1114H26.3193C25.7201 19.1114 25.2339 18.6221 25.2339 18.0191V0.516586C25.2339 0.19116 25.0395 0 24.7184 0H22.2697C21.9689 0 21.727 0.213917 21.727 0.516586L21.7202 16.3806V20.3403C21.7202 21.5464 22.6925 22.525 23.8909 22.525H38.6806C41.1384 22.3998 41.1836 21.5259 41.4256 21.1982C41.6132 22.2382 41.9682 22.4681 42.7958 22.5182C45.1631 22.5523 48.1319 22.5273 48.1749 22.525H49.622C50.8204 22.525 51.5665 21.5464 51.5665 20.3403L51.7926 12.3753C51.7926 12.0726 52.0346 11.8291 52.3353 11.8291H52.9232C53.1719 11.8291 53.3754 11.6243 53.3754 11.374V10.3272C53.3754 10.0245 53.6173 9.781 53.9181 9.781H57.3707C57.9111 9.781 58.3498 10.2225 58.3498 10.7664V11.7154C58.3498 12.0294 58.603 12.2843 58.915 12.2843H60.1134C60.2626 12.2843 60.3848 12.4072 60.3848 12.5574V21.9788C60.3848 22.2815 60.6267 22.525 60.9274 22.525H62.9172C63.5164 22.525 64.0025 22.0357 64.0025 21.4326V10.7823C64.0025 10.4796 63.7606 10.2361 63.4598 10.2361H63.4576ZM40.6229 15.8412C40.6229 18.4492 38.8389 20.14 36.1618 20.14C34.2398 20.14 33.3128 19.2775 33.3128 17.5844C33.3128 15.7024 34.4275 14.7694 36.6931 14.7694H40.6229V15.8389V15.8412Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,43 @@
<svg viewBox="0 0 200 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M191.879 19.297C191.736 19.297 191.602 19.2434 191.477 19.1362C191.37 19.029 191.316 18.9039 191.316 18.761V6.16432C191.316 6.02138 191.37 5.8963 191.477 5.7891C191.602 5.68189 191.736 5.62829 191.879 5.62829H193.246C193.568 5.62829 193.764 5.80697 193.836 6.16432C193.907 6.48594 193.979 6.72715 194.05 6.88796C194.139 7.0309 194.273 7.10237 194.452 7.10237C194.559 7.10237 194.676 7.06663 194.801 6.99516C194.926 6.90582 195.051 6.80755 195.176 6.70035C195.569 6.36086 195.971 6.10178 196.382 5.92311C196.793 5.72656 197.365 5.62829 198.097 5.62829H199.464C199.607 5.62829 199.732 5.68189 199.839 5.7891C199.946 5.8963 200 6.02138 200 6.16432V8.28163C200 8.42457 199.946 8.54964 199.839 8.65685C199.732 8.76405 199.607 8.81766 199.464 8.81766H197.936C196.9 8.81766 196.042 9.12141 195.363 9.7289C194.702 10.3364 194.372 11.2745 194.372 12.543V18.761C194.372 18.9039 194.318 19.029 194.211 19.1362C194.104 19.2434 193.979 19.297 193.836 19.297H191.879Z"
fill="currentColor"/>
<path d="M182.709 19.7526C180.583 19.7526 178.93 19.0915 177.751 17.7693C176.589 16.4471 176.009 14.6782 176.009 12.4626C176.009 11.0154 176.268 9.74677 176.786 8.65685C177.304 7.54906 178.064 6.69141 179.064 6.08391C180.065 5.47642 181.253 5.17267 182.629 5.17267C184.648 5.17267 186.238 5.7623 187.399 6.94156C188.579 8.12082 189.168 9.74677 189.168 11.8194V12.7843C189.168 13.0165 189.106 13.1952 188.981 13.3203C188.856 13.4454 188.686 13.5079 188.471 13.5079H179.573C179.395 13.5079 179.305 13.6062 179.305 13.8027C179.341 14.839 179.681 15.6609 180.324 16.2684C180.967 16.8581 181.762 17.1529 182.709 17.1529C184.031 17.1529 184.978 16.6526 185.55 15.652C185.747 15.3304 185.97 15.1696 186.22 15.1696H188.284C188.445 15.1696 188.579 15.2321 188.686 15.3572C188.793 15.4644 188.82 15.6163 188.766 15.8128C188.427 16.9921 187.73 17.948 186.676 18.6806C185.622 19.3953 184.299 19.7526 182.709 19.7526ZM179.279 10.801C179.279 10.9796 179.377 11.069 179.573 11.069H185.791C185.952 11.069 186.032 10.9796 186.032 10.801C186.032 9.92545 185.72 9.19288 185.094 8.60325C184.487 7.99575 183.665 7.692 182.629 7.692C181.664 7.692 180.869 7.99575 180.243 8.60325C179.636 9.19288 179.314 9.92545 179.279 10.801Z"
fill="currentColor"/>
<path d="M166.551 19.7526C165.621 19.7526 164.773 19.6008 164.004 19.297C163.236 18.9754 162.584 18.5108 162.048 17.9033C161.44 17.2601 160.976 16.4739 160.654 15.5448C160.333 14.6157 160.172 13.5883 160.172 12.4627C160.172 11.337 160.333 10.3096 160.654 9.38049C160.976 8.45137 161.44 7.6652 162.048 7.02197C162.584 6.43234 163.236 5.97671 164.004 5.6551C164.773 5.33348 165.621 5.17267 166.551 5.17267C167.247 5.17267 167.801 5.24414 168.212 5.38708C168.641 5.51215 169.079 5.69083 169.526 5.92311C169.561 5.94098 169.66 5.99458 169.82 6.08392C169.981 6.17326 170.133 6.21792 170.276 6.21792C170.598 6.21792 170.758 6.03032 170.758 5.6551V0.536027C170.758 0.393087 170.812 0.268014 170.919 0.160808C171.026 0.0536026 171.16 0 171.321 0H173.305C173.447 0 173.573 0.0536026 173.68 0.160808C173.787 0.268014 173.841 0.393087 173.841 0.536027V18.761C173.841 18.9039 173.787 19.029 173.68 19.1362C173.573 19.2434 173.447 19.297 173.305 19.297H171.911C171.661 19.297 171.446 19.1541 171.268 18.8682C171.16 18.7252 171.053 18.618 170.946 18.5466C170.857 18.4751 170.732 18.4394 170.571 18.4394C170.356 18.4394 170.062 18.5644 169.686 18.8146C169.24 19.1005 168.793 19.3238 168.346 19.4846C167.9 19.6633 167.301 19.7526 166.551 19.7526ZM163.308 12.4627C163.308 13.6955 163.62 14.6961 164.246 15.4644C164.567 15.8575 164.969 16.1702 165.452 16.4025C165.934 16.6169 166.488 16.7241 167.113 16.7241C168.328 16.7241 169.284 16.3042 169.981 15.4644C170.589 14.6961 170.892 13.6955 170.892 12.4627C170.892 11.2298 170.589 10.2381 169.981 9.4877C169.302 8.63005 168.346 8.20123 167.113 8.20123C166.488 8.20123 165.934 8.31737 165.452 8.54965C164.969 8.76406 164.567 9.07674 164.246 9.4877C163.62 10.2203 163.308 11.2119 163.308 12.4627Z"
fill="currentColor"/>
<path d="M155.415 19.297C155.272 19.297 155.147 19.2434 155.04 19.1362C154.933 19.029 154.879 18.9039 154.879 18.761V0.536027C154.879 0.393087 154.933 0.268014 155.04 0.160808C155.147 0.0536026 155.272 0 155.415 0H157.399C157.542 0 157.667 0.0536026 157.774 0.160808C157.881 0.268014 157.935 0.393087 157.935 0.536027V18.761C157.935 18.9039 157.881 19.029 157.774 19.1362C157.667 19.2434 157.542 19.297 157.399 19.297H155.415Z"
fill="currentColor"/>
<path d="M149.107 19.297C148.946 19.297 148.812 19.2434 148.705 19.1362C148.615 19.029 148.571 18.9039 148.571 18.761V6.16432C148.571 6.02138 148.615 5.89631 148.705 5.7891C148.812 5.68189 148.946 5.62829 149.107 5.62829H151.09C151.251 5.62829 151.376 5.68189 151.465 5.7891C151.573 5.87844 151.626 6.00351 151.626 6.16432V18.761C151.626 18.9218 151.573 19.0558 151.465 19.163C151.376 19.2523 151.251 19.297 151.09 19.297H149.107ZM148.33 0.696836C148.33 0.58963 148.365 0.491358 148.437 0.40202C148.526 0.312682 148.633 0.268013 148.758 0.268013H151.492C151.617 0.268013 151.715 0.312682 151.787 0.40202C151.876 0.491358 151.921 0.58963 151.921 0.696836V3.18937C151.921 3.31444 151.876 3.42164 151.787 3.51098C151.715 3.58245 151.617 3.61819 151.492 3.61819H148.758C148.633 3.61819 148.526 3.58245 148.437 3.51098C148.365 3.42164 148.33 3.31444 148.33 3.18937V0.696836Z"
fill="currentColor"/>
<path d="M105.963 19.7526C105.213 19.7526 104.614 19.6633 104.168 19.4846C103.721 19.3238 103.274 19.1005 102.828 18.8146C102.452 18.5644 102.158 18.4394 101.943 18.4394C101.782 18.4394 101.648 18.4751 101.541 18.5466C101.452 18.618 101.353 18.7252 101.246 18.8682C101.068 19.1541 100.853 19.297 100.603 19.297H99.2094C99.0664 19.297 98.9414 19.2434 98.8342 19.1362C98.7269 19.029 98.6733 18.9039 98.6733 18.761V0.536027C98.6733 0.393087 98.7269 0.268014 98.8342 0.160808C98.9414 0.0536026 99.0664 0 99.2094 0H101.193C101.336 0 101.461 0.0536026 101.568 0.160808C101.675 0.268014 101.729 0.393087 101.729 0.536027V5.6551C101.729 6.03032 101.898 6.21792 102.238 6.21792C102.381 6.21792 102.533 6.17326 102.694 6.08392C102.854 5.99458 102.953 5.94098 102.988 5.92311C103.435 5.69083 103.864 5.51215 104.275 5.38708C104.704 5.24414 105.266 5.17267 105.963 5.17267C106.892 5.17267 107.741 5.33348 108.509 5.6551C109.278 5.97671 109.93 6.43234 110.466 7.02197C111.073 7.6652 111.538 8.45137 111.86 9.38049C112.181 10.3096 112.342 11.337 112.342 12.4627C112.342 13.5883 112.181 14.6157 111.86 15.5448C111.538 16.4739 111.073 17.2601 110.466 17.9033C109.93 18.5108 109.278 18.9754 108.509 19.297C107.741 19.6008 106.892 19.7526 105.963 19.7526ZM101.621 12.4627C101.621 13.6955 101.925 14.6961 102.533 15.4644C103.23 16.3042 104.185 16.7241 105.4 16.7241C106.026 16.7241 106.58 16.6169 107.062 16.4025C107.545 16.1702 107.947 15.8575 108.268 15.4644C108.894 14.6961 109.206 13.6955 109.206 12.4627C109.206 11.2119 108.894 10.2203 108.268 9.4877C107.947 9.07674 107.545 8.76406 107.062 8.54965C106.58 8.31737 106.026 8.20123 105.4 8.20123C104.168 8.20123 103.212 8.63005 102.533 9.4877C101.925 10.2381 101.621 11.2298 101.621 12.4627Z"
fill="currentColor"/>
<path d="M93.5555 19.297C92.1619 19.297 91.0898 18.9039 90.3394 18.1177C89.6068 17.3316 89.2405 16.2863 89.2405 14.982V8.81765C89.2405 8.63897 89.1512 8.54964 88.9725 8.54964H86.2924C86.1494 8.54964 86.0244 8.49603 85.9172 8.38883C85.81 8.28162 85.7563 8.15655 85.7563 8.01361V6.16431C85.7563 6.02137 85.81 5.8963 85.9172 5.78909C86.0244 5.68189 86.1494 5.62828 86.2924 5.62828H88.9725C89.1512 5.62828 89.2405 5.53895 89.2405 5.36027V1.47407C89.2405 1.33113 89.2941 1.20605 89.4013 1.09885C89.5085 0.991643 89.6336 0.93804 89.7766 0.93804H91.7598C91.9028 0.93804 92.0279 0.991643 92.1351 1.09885C92.2423 1.20605 92.2959 1.33113 92.2959 1.47407V5.36027C92.2959 5.53895 92.3852 5.62828 92.5639 5.62828H96.2625C96.4054 5.62828 96.5305 5.68189 96.6377 5.78909C96.7449 5.8963 96.7985 6.02137 96.7985 6.16431V8.01361C96.7985 8.15655 96.7449 8.28162 96.6377 8.38883C96.5305 8.49603 96.4054 8.54964 96.2625 8.54964H92.5639C92.3852 8.54964 92.2959 8.63897 92.2959 8.81765V14.5531C92.2959 15.107 92.4477 15.5269 92.7515 15.8128C93.0552 16.0987 93.4751 16.2416 94.0112 16.2416H96.2625C96.4054 16.2416 96.5305 16.2952 96.6377 16.4024C96.7449 16.5096 96.7985 16.6347 96.7985 16.7777V18.761C96.7985 18.9039 96.7449 19.029 96.6377 19.1362C96.5305 19.2434 96.4054 19.297 96.2625 19.297H93.5555Z"
fill="currentColor"/>
<path d="M78.7594 19.7526C76.6332 19.7526 74.9804 19.0915 73.8012 17.7693C72.6398 16.4471 72.0591 14.6782 72.0591 12.4626C72.0591 11.0154 72.3182 9.74677 72.8363 8.65685C73.3545 7.54906 74.1139 6.69141 75.1144 6.08391C76.115 5.47642 77.3032 5.17267 78.679 5.17267C80.698 5.17267 82.2883 5.7623 83.4496 6.94156C84.6289 8.12082 85.2185 9.74677 85.2185 11.8194V12.7843C85.2185 13.0165 85.156 13.1952 85.0309 13.3203C84.9059 13.4454 84.7361 13.5079 84.5217 13.5079H75.6237C75.445 13.5079 75.3556 13.6062 75.3556 13.8027C75.3914 14.839 75.7309 15.6609 76.3741 16.2684C77.0173 16.8581 77.8124 17.1529 78.7594 17.1529C80.0816 17.1529 81.0286 16.6526 81.6004 15.652C81.7969 15.3304 82.0202 15.1696 82.2704 15.1696H84.3341C84.4949 15.1696 84.6289 15.2321 84.7361 15.3572C84.8433 15.4644 84.8701 15.6163 84.8165 15.8128C84.477 16.9921 83.7802 17.948 82.726 18.6806C81.6718 19.3953 80.3496 19.7526 78.7594 19.7526ZM75.3288 10.801C75.3288 10.9796 75.4271 11.069 75.6237 11.069H81.8416C82.0024 11.069 82.0828 10.9796 82.0828 10.801C82.0828 9.92545 81.7701 9.19288 81.1447 8.60325C80.5372 7.99575 79.7153 7.692 78.679 7.692C77.7142 7.692 76.9191 7.99575 76.2937 8.60325C75.6862 9.19288 75.3646 9.92545 75.3288 10.801Z"
fill="currentColor"/>
<path d="M58.246 19.297C58.103 19.297 57.978 19.2434 57.8708 19.1362C57.7636 19.029 57.71 18.9039 57.71 18.761V6.16432C57.71 6.02138 57.7636 5.8963 57.8708 5.7891C57.978 5.68189 58.103 5.62829 58.246 5.62829H59.6665C59.8273 5.62829 59.9523 5.66402 60.0417 5.7355C60.131 5.7891 60.2382 5.88737 60.3633 6.03031C60.4526 6.15538 60.533 6.25366 60.6045 6.32513C60.6938 6.37873 60.81 6.40553 60.9529 6.40553C61.078 6.40553 61.2031 6.3698 61.3281 6.29833C61.4532 6.22685 61.5783 6.14645 61.7034 6.05711C62.0964 5.7891 62.4985 5.57469 62.9094 5.41388C63.3382 5.25307 63.9279 5.17267 64.6783 5.17267C65.7146 5.17267 66.6348 5.39601 67.4388 5.8427C68.2429 6.28939 68.8682 6.94156 69.3149 7.7992C69.7795 8.65685 70.0118 9.68424 70.0118 10.8814V18.761C70.0118 18.9039 69.9582 19.029 69.851 19.1362C69.7438 19.2434 69.6187 19.297 69.4757 19.297H67.4924C67.3495 19.297 67.2244 19.2434 67.1172 19.1362C67.01 19.029 66.9564 18.9039 66.9564 18.761V11.3638C66.9564 10.3632 66.6705 9.58596 66.0988 9.03207C65.5449 8.47817 64.8034 8.20122 63.8743 8.20122C62.9452 8.20122 62.1947 8.47817 61.623 9.03207C61.0512 9.58596 60.7653 10.3632 60.7653 11.3638V18.761C60.7653 18.9039 60.7117 19.029 60.6045 19.1362C60.4973 19.2434 60.3722 19.297 60.2293 19.297H58.246Z"
fill="currentColor"/>
<path d="M53.1744 19.297C52.8528 19.297 52.5669 19.1541 52.3168 18.8682L48.0822 13.8295C48.0107 13.7581 47.9303 13.7223 47.8409 13.7223C47.7695 13.7223 47.7069 13.7491 47.6533 13.8027C47.5997 13.8563 47.5729 13.9189 47.5729 13.9903V18.761C47.5729 18.9039 47.5193 19.029 47.4121 19.1362C47.3049 19.2434 47.1798 19.297 47.0369 19.297H45.0536C44.9107 19.297 44.7856 19.2434 44.6784 19.1362C44.5712 19.029 44.5176 18.9039 44.5176 18.761V0.536027C44.5176 0.393087 44.5712 0.268014 44.6784 0.160808C44.7856 0.0536026 44.9107 0 45.0536 0H47.0369C47.1798 0 47.3049 0.0536026 47.4121 0.160808C47.5193 0.268014 47.5729 0.393087 47.5729 0.536027V10.5061C47.5729 10.5955 47.5997 10.667 47.6533 10.7206C47.7069 10.7742 47.7695 10.801 47.8409 10.801C47.9124 10.801 47.9839 10.7652 48.0554 10.6938L52.424 6.00351C52.6741 5.75337 52.9421 5.62829 53.228 5.62829H55.3989C55.5597 5.62829 55.6848 5.6819 55.7741 5.7891C55.8813 5.89631 55.9349 6.02138 55.9349 6.16432C55.9349 6.32513 55.8813 6.45914 55.7741 6.56634L50.8695 11.8462C50.7623 11.9534 50.7087 12.0606 50.7087 12.1678C50.7087 12.275 50.7623 12.3822 50.8695 12.4895L55.8813 18.359C55.9885 18.484 56.0421 18.618 56.0421 18.761C56.0421 18.9218 55.9885 19.0558 55.8813 19.163C55.792 19.2523 55.6669 19.297 55.5061 19.297H53.1744Z"
fill="currentColor"/>
<path d="M34.9505 19.297C34.8076 19.297 34.6736 19.2434 34.5485 19.1362C34.4413 19.029 34.3877 18.9039 34.3877 18.761V6.16432C34.3877 6.02138 34.4413 5.8963 34.5485 5.7891C34.6736 5.68189 34.8076 5.62829 34.9505 5.62829H36.3174C36.639 5.62829 36.8356 5.80697 36.907 6.16432C36.9785 6.48594 37.05 6.72715 37.1214 6.88796C37.2108 7.0309 37.3448 7.10237 37.5235 7.10237C37.6307 7.10237 37.7468 7.06663 37.8719 6.99516C37.9969 6.90582 38.122 6.80755 38.2471 6.70035C38.6402 6.36086 39.0422 6.10178 39.4531 5.92311C39.8641 5.72656 40.4359 5.62829 41.1684 5.62829H42.5353C42.6782 5.62829 42.8033 5.68189 42.9105 5.7891C43.0177 5.8963 43.0713 6.02138 43.0713 6.16432V8.28163C43.0713 8.42457 43.0177 8.54964 42.9105 8.65685C42.8033 8.76405 42.6782 8.81766 42.5353 8.81766H41.0076C39.9713 8.81766 39.1137 9.12141 38.4347 9.7289C37.7736 10.3364 37.443 11.2745 37.443 12.543V18.761C37.443 18.9039 37.3894 19.029 37.2822 19.1362C37.175 19.2434 37.05 19.297 36.907 19.297H34.9505Z"
fill="currentColor"/>
<path d="M23.8885 19.7526C22.9594 19.7526 22.1107 19.6007 21.3424 19.297C20.5741 18.9754 19.9219 18.5108 19.3859 17.9033C18.7784 17.2601 18.3138 16.4739 17.9922 15.5448C17.6706 14.6157 17.5098 13.5883 17.5098 12.4626C17.5098 11.337 17.6706 10.3096 17.9922 9.38048C18.3138 8.45137 18.7784 7.6652 19.3859 7.02196C19.9219 6.43233 20.5741 5.97671 21.3424 5.65509C22.1107 5.33348 22.9594 5.17267 23.8885 5.17267C24.6389 5.17267 25.2375 5.262 25.6842 5.44068C26.1309 5.60149 26.5775 5.82483 27.0242 6.11072C27.3995 6.36086 27.6943 6.48594 27.9087 6.48594C28.0695 6.48594 28.1946 6.4502 28.2839 6.37873C28.3911 6.30726 28.4983 6.20005 28.6055 6.05711C28.7842 5.77123 28.9986 5.62829 29.2487 5.62829H30.6424C30.7854 5.62829 30.9104 5.68189 31.0176 5.7891C31.1248 5.8963 31.1784 6.02138 31.1784 6.16432V18.761C31.1784 18.9039 31.1248 19.029 31.0176 19.1362C30.9104 19.2434 30.7854 19.297 30.6424 19.297H29.2487C28.9986 19.297 28.7842 19.1541 28.6055 18.8682C28.4983 18.7252 28.3911 18.618 28.2839 18.5466C28.1946 18.4751 28.0695 18.4394 27.9087 18.4394C27.6943 18.4394 27.3995 18.5644 27.0242 18.8146C26.5775 19.1005 26.1309 19.3238 25.6842 19.4846C25.2375 19.6633 24.6389 19.7526 23.8885 19.7526ZM20.6455 12.4626C20.6455 13.6955 20.9582 14.6961 21.5836 15.4644C21.9052 15.8575 22.3072 16.1702 22.7896 16.4024C23.2721 16.6169 23.8259 16.7241 24.4513 16.7241C25.0767 16.7241 25.6306 16.6169 26.113 16.4024C26.5954 16.1702 26.9974 15.8575 27.3191 15.4644C27.9265 14.6961 28.2303 13.6955 28.2303 12.4626C28.2303 11.2298 27.9265 10.2381 27.3191 9.48769C26.9974 9.07674 26.5954 8.76405 26.113 8.54964C25.6306 8.31736 25.0767 8.20122 24.4513 8.20122C23.8259 8.20122 23.2721 8.31736 22.7896 8.54964C22.3072 8.76405 21.9052 9.07674 21.5836 9.48769C20.9582 10.2203 20.6455 11.2119 20.6455 12.4626Z"
fill="currentColor"/>
<path d="M0.536027 19.297C0.393086 19.297 0.268013 19.2434 0.160808 19.1362C0.0536025 19.029 0 18.9039 0 18.761V1.07206C0 0.929116 0.0536025 0.804043 0.160808 0.696837C0.268013 0.589631 0.393086 0.536028 0.536027 0.536028H6.83434C9.47874 0.536028 11.6407 1.349 13.3203 2.97495C14.16 3.79686 14.8033 4.80638 15.25 6.00351C15.6966 7.18277 15.92 8.48711 15.92 9.91652C15.92 11.3459 15.6966 12.6592 15.25 13.8563C14.8033 15.0356 14.16 16.0362 13.3203 16.8581C11.6407 18.484 9.47874 19.297 6.83434 19.297H0.536027ZM3.24296 15.786C3.24296 15.9647 3.3323 16.054 3.51098 16.054H6.83434C8.53176 16.054 9.88076 15.5537 10.8813 14.5532C11.4352 14.0171 11.8551 13.356 12.141 12.5699C12.4269 11.7837 12.5698 10.8992 12.5698 9.91652C12.5698 8.9338 12.4269 8.04935 12.141 7.26318C11.8551 6.477 11.4352 5.8159 10.8813 5.27988C9.84502 4.27929 8.49602 3.779 6.83434 3.779H3.51098C3.3323 3.779 3.24296 3.86833 3.24296 4.04701V15.786Z"
fill="currentColor"/>
<g clip-path="url(#clip0_4498_21233)">
<path d="M140.006 0.211082H142.457C143.062 0.211082 143.553 0.705733 143.553 1.31591V4.01815C143.553 4.62833 143.062 5.12298 142.457 5.12298H140.006C139.401 5.12298 138.911 4.62833 138.911 4.01815V1.31591C138.911 0.705733 139.401 0.211082 140.006 0.211082Z"
fill="currentColor"/>
<path d="M116.73 0.211082H119.181C119.786 0.211082 120.277 0.705733 120.277 1.31591V4.01815C120.277 4.62833 119.786 5.12298 119.181 5.12298H116.73C116.125 5.12298 115.635 4.62833 115.635 4.01815V1.31591C115.635 0.705733 116.125 0.211082 116.73 0.211082Z"
fill="currentColor"/>
<path d="M116.673 11.4481H119.184C119.499 11.4481 119.702 11.6806 119.754 12.0236L119.983 13.6601C120.017 13.8373 120.106 13.9363 120.256 13.9363H121.576C121.89 13.9363 122.089 14.1527 122.146 14.5118L122.374 15.8974C122.411 16.192 122.612 16.3716 122.922 16.4498C122.922 16.4498 124.223 16.8572 129.643 16.8572V20.3121C124.225 20.3121 121.119 19.9024 121.119 19.9024C120.861 19.8633 120.549 19.6446 120.549 19.327L120.32 18.1071C120.32 17.9551 120.197 17.8309 120.046 17.8309H118.49C118.175 17.8309 118.002 17.5754 117.919 17.2554L117.691 16.3278C117.657 16.1345 117.568 16.0516 117.417 16.0516H116.902C116.587 16.0516 116.331 15.7938 116.331 15.4762L116.103 12.0236C116.103 11.7059 116.358 11.4481 116.673 11.4481Z"
fill="currentColor"/>
<path d="M136.912 15.8974L137.14 14.5118C137.197 14.1527 137.395 13.9363 137.71 13.9363H139.03C139.18 13.9363 139.269 13.8373 139.303 13.6601L139.532 12.0236C139.584 11.6806 139.787 11.4481 140.102 11.4481H142.613C142.927 11.4481 143.183 11.7059 143.183 12.0236L142.955 15.4762C142.955 15.7938 142.699 16.0516 142.384 16.0516H141.869C141.718 16.0516 141.629 16.1345 141.595 16.3278L141.366 17.2554C141.284 17.5754 141.111 17.8309 140.796 17.8309H139.239C139.089 17.8309 138.966 17.9551 138.966 18.1071L138.737 19.327C138.737 19.6446 138.425 19.8633 138.167 19.9024C138.167 19.9024 135.061 20.3121 129.643 20.3121V16.8572C135.063 16.8572 136.364 16.4498 136.364 16.4498C136.674 16.3716 136.875 16.192 136.912 15.8974Z"
fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_4498_21233">
<rect width="27.9181" height="20.101" fill="currentColor" transform="matrix(-1 0 0 1 143.553 0.211082)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,8 @@
"@tanstack/solid-query": "^5.76.0",
"solid-js": "^1.9.7",
"solid-toast": "^0.5.0",
"three": "^0.176.0"
"three": "^0.176.0",
"valibot": "^1.1.0"
},
"optionalDependencies": {
"@esbuild/darwin-arm64": "^0.25.4",

View File

@@ -29,7 +29,7 @@
box-shadow: 0.125rem 0.125rem 0 0 theme(colors.bg.inv.acc.3) inset;
&.ghost {
@apply bg-transparent border-none shadow-none;
@apply bg-transparent border-transparent shadow-none;
}
&:hover {
@@ -71,7 +71,7 @@
0.125rem 0.125rem 0 0 theme(colors.off.white) inset;
&.ghost {
@apply bg-transparent border-none shadow-none;
@apply bg-transparent border-transparent shadow-none;
}
&:hover {

View File

@@ -1,5 +1,5 @@
hr {
@apply border-none outline-none bg-inv-2;
@apply border-none outline-none bg-inv-2 self-stretch;
&.inverted {
@apply bg-def-3;

View File

@@ -1,5 +1,9 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Fieldset, FieldsetProps } from "@/src/components/Form/Fieldset";
import {
Fieldset,
FieldsetFieldProps,
FieldsetProps,
} from "@/src/components/Form/Fieldset";
import cx from "classnames";
import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
@@ -40,7 +44,7 @@ export type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
legend: "Signup",
children: (props: FieldProps) => (
children: (props: FieldsetFieldProps) => (
<>
<TextInput
{...props}

View File

@@ -12,24 +12,28 @@ export type FieldsetFieldProps = Pick<
disabled?: boolean;
};
export interface FieldsetProps
extends Pick<FieldProps, "orientation" | "inverted"> {
export type FieldsetProps = Pick<FieldProps, "orientation" | "inverted"> & {
legend?: string;
disabled?: boolean;
error?: string;
children: (props: FieldsetFieldProps) => JSX.Element;
}
disabled?: boolean;
name?: string;
children: JSX.Element | ((props: FieldsetFieldProps) => JSX.Element);
};
export const Fieldset = (props: FieldsetProps) => {
const orientation = () => props.orientation || "vertical";
const [fieldProps] = splitProps(props, [
"orientation",
"inverted",
"disabled",
"error",
"children",
]);
const children = () =>
typeof props.children === "function"
? props.children(fieldProps)
: props.children;
return (
<fieldset
role="group"
@@ -51,7 +55,7 @@ export const Fieldset = (props: FieldsetProps) => {
</Typography>
</legend>
)}
<div class="fields">{props.children(fieldProps)}</div>
<div class="fields">{children()}</div>
{props.error && (
<div class="error" role="alert">
<Typography

View File

@@ -0,0 +1,5 @@
div.form-field.host-file {
button {
@apply w-1/2;
}
}

View File

@@ -0,0 +1,131 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import cx from "classnames";
import {
HostFileInput,
HostFileInputProps,
} from "@/src/components/Form/HostFileInput";
const Examples = (props: HostFileInputProps) => (
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-8 p-8">
<HostFileInput {...props} />
<HostFileInput {...props} size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<HostFileInput {...props} inverted={true} />
<HostFileInput {...props} inverted={true} size="s" />
</div>
<div class="flex flex-col gap-8 p-8">
<HostFileInput {...props} orientation="horizontal" />
<HostFileInput {...props} orientation="horizontal" size="s" />
</div>
<div class="flex flex-col gap-8 p-8 bg-inv-acc-3">
<HostFileInput {...props} inverted={true} orientation="horizontal" />
<HostFileInput
{...props}
inverted={true}
orientation="horizontal"
size="s"
/>
</div>
</div>
);
const meta = {
title: "Components/Form/HostFileInput",
component: Examples,
decorators: [
(Story: StoryObj, context: StoryContext<HostFileInputProps>) => {
return (
<div
class={cx({
"w-[600px]": (context.args.orientation || "vertical") == "vertical",
"w-[1024px]": context.args.orientation == "horizontal",
"bg-inv-acc-3": context.args.inverted,
})}
>
<Story />
</div>
);
},
],
} satisfies Meta<HostFileInputProps>;
export default meta;
export type Story = StoryObj<typeof meta>;
export const Bare: Story = {
args: {
onSelectFile: async () => {
return "/home/bob/clans/my-clan";
},
input: {
placeholder: "e.g. 11/06/89",
},
},
};
export const Label: Story = {
args: {
...Bare.args,
label: "DOB",
},
};
export const Description: Story = {
args: {
...Label.args,
description: "The date you were born",
},
};
export const Required: Story = {
args: {
...Description.args,
required: true,
},
};
export const Tooltip: Story = {
args: {
...Required.args,
tooltip: "The day you came out of your momma",
},
};
export const Icon: Story = {
args: {
...Tooltip.args,
icon: "Checkmark",
},
};
export const Ghost: Story = {
args: {
...Icon.args,
ghost: true,
},
};
export const Invalid: Story = {
args: {
...Tooltip.args,
validationState: "invalid",
},
};
export const Disabled: Story = {
args: {
...Icon.args,
disabled: true,
},
};
export const ReadOnly: Story = {
args: {
...Icon.args,
readOnly: true,
defaultValue: "14/05/02",
},
};

View File

@@ -0,0 +1,59 @@
import {
TextField,
TextFieldInputProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import cx from "classnames";
import { Label } from "./Label";
import { Button } from "../Button/Button";
import "./HostFileInput.css";
import { PolymorphicProps } from "@kobalte/core/polymorphic";
import { FieldProps } from "./Field";
import { Orienter } from "./Orienter";
import { createSignal } from "solid-js";
export type HostFileInputProps = FieldProps &
TextFieldRootProps & {
onSelectFile: () => Promise<string>;
input?: PolymorphicProps<"input", TextFieldInputProps<"input">>;
};
export const HostFileInput = (props: HostFileInputProps) => {
const [value, setValue] = createSignal<string | undefined>(undefined);
const selectFile = async () => {
setValue(await props.onSelectFile());
};
return (
<TextField
class={cx("form-field", "host-file", props.size, props.orientation, {
inverted: props.inverted,
ghost: props.ghost,
})}
{...props}
value={value()}
onChange={setValue}
>
<Orienter orientation={props.orientation} align={"start"}>
<Label
labelComponent={TextField.Label}
descriptionComponent={TextField.Description}
{...props}
/>
<TextField.Input {...props.input} hidden={true} />
<Button
hierarchy="secondary"
size={props.size}
startIcon="Folder"
onClick={selectFile}
>
{value() ? value() : "No Selection"}
</Button>
</Orienter>
</TextField>
);
};

View File

@@ -102,6 +102,7 @@ export type IconVariant = keyof typeof icons;
const viewBoxes: Partial<Record<IconVariant, string>> = {
ClanIcon: "0 0 72 89",
Offline: "0 0 38 27",
Cursor: "0 0 35 42",
};
export interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {

View File

@@ -0,0 +1,88 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { Component, For } from "solid-js";
import { Logo, LogoProps, LogoVariant } from "./Logo";
import cx from "classnames";
const variants: LogoVariant[] = ["Clan", "Darknet"];
const LogoExamples: Component<LogoProps> = (props) => (
<div class="grid grid-cols-6 items-center gap-4">
<For each={variants}>{(item) => <Logo {...props} variant={item} />}</For>
</div>
);
const meta: Meta<LogoProps> = {
title: "Components/Logo",
component: LogoExamples,
decorators: [
(Story: StoryObj, context: StoryContext<LogoProps>) => (
<div class={cx(context.args.inverted || false ? "bg-inv-acc-3" : "")}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<LogoProps>;
export const Default: Story = {};
export const Primary: Story = {
args: {
color: "primary",
},
};
export const Secondary: Story = {
args: {
color: "secondary",
},
};
export const Tertiary: Story = {
args: {
color: "tertiary",
},
};
export const Quaternary: Story = {
args: {
color: "quaternary",
},
};
export const PrimaryInverted: Story = {
args: {
...Primary.args,
inverted: true,
},
};
export const SecondaryInverted: Story = {
args: {
...Secondary.args,
inverted: true,
},
};
export const TertiaryInverted: Story = {
args: {
...Tertiary.args,
inverted: true,
},
};
export const QuaternaryInverted: Story = {
args: {
...Quaternary.args,
inverted: true,
},
};
export const Inverted: Story = {
args: {
inverted: true,
},
};

View File

@@ -0,0 +1,40 @@
import Clan from "@/logos/clan.svg";
import Darknet from "@/logos/darknet.svg";
import { Dynamic } from "solid-js/web";
import { Color, fgClass } from "@/src/components/colors";
import { JSX, splitProps } from "solid-js";
import cx from "classnames";
const logos = {
Clan,
Darknet,
};
export type LogoVariant = keyof typeof logos;
export interface LogoProps extends JSX.SvgSVGAttributes<SVGElement> {
class?: string;
variant: LogoVariant;
color?: Color;
inverted?: boolean;
}
export const Logo = (props: LogoProps) => {
const [local, iconProps] = splitProps(props, [
"variant",
"color",
"class",
"inverted",
]);
const Logo = logos[local.variant];
return (
<Dynamic
component={Logo}
class={cx("icon", local.class, fgClass(local.color, local.inverted), {
inverted: local.inverted,
})}
data-logo-name={local.variant}
/>
);
};

View File

@@ -2,7 +2,7 @@ import { TagProps } from "@/src/components/Tag/Tag";
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { fn } from "storybook/test";
import { Modal, ModalContext, ModalProps } from "@/src/components/Modal/Modal";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { Fieldset, FieldsetFieldProps } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
import { Checkbox } from "@/src/components/Form/Checkbox";
@@ -24,7 +24,7 @@ export const Default: Story = {
children: ({ close }: ModalContext) => (
<form class="flex flex-col gap-5">
<Fieldset legend="General">
{(props) => (
{(props: FieldsetFieldProps) => (
<>
<TextInput
{...props}

View File

@@ -0,0 +1,30 @@
div.toolbar {
@apply flex flex-row items-center justify-center gap-1.5 p-1 size-fit rounded-md self-stretch;
border: 0.0625rem solid theme(colors.off.toolbar_border);
background: linear-gradient(
180deg,
theme(colors.bg.inv.3) 0%,
theme(colors.bg.inv.4) 100%
);
& > hr {
@apply h-full min-h-8;
}
& > button.toolbar-button {
@apply w-6 h-6 p-1 rounded-[0.25rem];
&:hover {
@apply bg-inv-1;
}
&:active {
@apply bg-inv-4;
}
&.selected {
@apply bg-bg-semantic-success-4;
}
}
}

View File

@@ -0,0 +1,27 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Toolbar, ToolbarProps } from "@/src/components/Toolbar/Toolbar";
import { Divider } from "@/src/components/Divider/Divider";
import { ToolbarButton } from "./ToolbarButton";
const meta: Meta<ToolbarProps> = {
title: "Components/Toolbar",
component: Toolbar,
};
export default meta;
type Story = StoryObj<ToolbarProps>;
export const Default: Story = {
args: {
children: (
<>
<ToolbarButton name="select" icon="Cursor" />
<ToolbarButton name="new-machine" icon="NewMachine" />
<Divider orientation="vertical" />
<ToolbarButton name="modules" icon="Modules" selected={true} />
<ToolbarButton name="ai" icon="AI" />
</>
),
},
};

View File

@@ -0,0 +1,14 @@
import "./Toolbar.css";
import { JSX } from "solid-js";
export interface ToolbarProps {
children: JSX.Element;
}
export const Toolbar = (props: ToolbarProps) => {
return (
<div class="toolbar" role="toolbar" aria-orientation="horizontal">
{props.children}
</div>
);
};

View File

@@ -0,0 +1,22 @@
import "./Toolbar.css";
import cx from "classnames";
import { Button } from "@kobalte/core/button";
import Icon, { IconVariant } from "@/src/components/Icon/Icon";
import type { JSX } from "solid-js";
export interface ToolbarButtonProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
icon: IconVariant;
selected?: boolean;
}
export const ToolbarButton = (props: ToolbarButtonProps) => {
return (
<Button
class={cx("toolbar-button", { selected: props.selected })}
{...props}
>
<Icon icon={props.icon} inverted={!props.selected} />
</Button>
);
};

View File

@@ -148,6 +148,18 @@
line-height: 116%;
letter-spacing: 0.06rem;
}
&.size-xl {
font-size: 2.5rem;
line-height: 104%;
letter-spacing: -0.06rem;
}
&.size-xxl {
font-size: 3rem;
line-height: 104%;
letter-spacing: -0.06rem;
}
}
&.teaser {
@@ -161,4 +173,16 @@
letter-spacing: -0.06rem;
}
}
&.align-left {
text-align: left;
}
&.align-center {
text-align: center;
}
&.align-right {
text-align: right;
}
}

View File

@@ -28,6 +28,8 @@ interface SizeForHierarchy {
default: string;
m: string;
l: string;
xl: string;
xxl: string;
};
title: {
default: string;
@@ -52,6 +54,8 @@ const sizeHierarchyMap: SizeForHierarchy = {
default: cx("size-default"),
m: cx("size-m"),
l: cx("size-l"),
xl: cx("size-xl"),
xxl: cx("size-xxl"),
},
title: {
default: cx("size-default"),
@@ -97,6 +101,7 @@ interface _TypographyProps<H extends Hierarchy> {
tag?: Tag;
class?: string;
transform?: Transform;
align?: "left" | "center" | "right";
}
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
@@ -106,6 +111,7 @@ export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
const size = () => sizeHierarchyMap[props.hierarchy][props.size] as string;
const weight = () => weightMap[props.weight || "normal"];
const color = () => fgClass(props.color, props.inverted);
const align = () => `align-${props.align || "left"}`;
return (
<Dynamic
@@ -116,6 +122,7 @@ export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
weight(),
size(),
color(),
align(),
props.transform,
props.class,
)}

View File

@@ -0,0 +1,92 @@
import { API } from "@/api/API";
import { Schema as Inventory } from "@/api/Inventory";
type OperationNames = keyof API;
type Services = NonNullable<Inventory["services"]>;
type ServiceNames = keyof Services;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
interface SendHeaderType {
logging?: { group_path: string[] };
}
interface BackendSendType<K extends OperationNames> {
body: OperationArgs<K>;
header?: SendHeaderType;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ReceiveHeaderType {}
interface BackendReturnType<K extends OperationNames> {
body: OperationResponse<K>;
header: ReceiveHeaderType;
}
/**
* Interface representing an API call with a unique identifier, result promise, and cancellation capability.
*
* @template K - A generic type parameter extending the set of operation names.
*
* @property {string} uuid - A unique identifier for the API call.
* @property {Promise<BackendReturnType<K>>} result - A promise that resolves to the return type of the backend operation.
* @property {() => Promise<void>} cancel - A function to cancel the API call, returning a promise that resolves when cancellation is completed.
*/
interface ApiCall<K extends OperationNames> {
uuid: string;
result: Promise<OperationResponse<K>>;
cancel: () => Promise<void>;
}
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
backendOpts?: SendHeaderType,
): ApiCall<K> => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
return {
uuid: "",
result: Promise.reject(`Method ${method} not found on window object`),
cancel: () => Promise.resolve(),
};
}
const req: BackendSendType<OperationNames> = {
body: args,
header: backendOpts,
};
const result = (
window as unknown as Record<
OperationNames,
(
args: BackendSendType<OperationNames>,
) => Promise<BackendReturnType<OperationNames>>
>
)[method](req) as Promise<BackendReturnType<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (result as any)._webviewMessageId as string;
return {
uuid: op_key,
result: result.then(({ body }) => body),
cancel: async () => {
console.log("Cancelling api call: ", op_key);
await callApi("delete_task", { task_id: op_key }).result;
},
};
};

View File

@@ -0,0 +1,29 @@
import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan";
import { Params, Navigator } from "@solidjs/router";
export const selectClanFolder = async () => {
const req = callApi("get_clan_folder", {});
const res = await req.result;
if (res.status === "error") {
throw new Error(res.errors[0].message);
}
if (res.status === "success" && res.data) {
const { identifier: uri } = res.data;
addClanURI(uri);
setActiveClanURI(uri);
return uri;
}
throw new Error("Illegal state exception");
};
export const navigateToClan = (navigate: Navigator, uri: string) => {
navigate("/clan/" + window.btoa(uri));
};
export const clanURIParam = (params: Params) => {
return window.atob(params.clanURI);
};

View File

@@ -3,7 +3,9 @@ import { render } from "solid-js/web";
import "./index.css";
import { QueryClient } from "@tanstack/solid-query";
import { CubeScene } from "./scene/cubes";
import { Routes } from "@/src/routes";
import { Router } from "@solidjs/router";
import { Layout } from "@/src/routes/Layout";
export const client = new QueryClient();
@@ -20,4 +22,4 @@ if (import.meta.env.DEV) {
await import("solid-devtools");
}
render(() => <CubeScene />, root!);
render(() => <Router root={Layout}>{Routes}</Router>, root!);

View File

@@ -0,0 +1,10 @@
import { RouteSectionProps, useParams } from "@solidjs/router";
import { Component } from "solid-js";
import { clanURIParam } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes";
export const Clan: Component<RouteSectionProps> = (props) => {
const params = useParams();
const clanURI = clanURIParam(params);
return <CubeScene />;
};

View File

@@ -0,0 +1,6 @@
import { Component } from "solid-js";
import { RouteSectionProps } from "@solidjs/router";
export const Layout: Component<RouteSectionProps> = (props) => (
<div class="size-full h-screen">{props.children}</div>
);

View File

@@ -0,0 +1,85 @@
main#welcome {
@apply absolute top-0 left-0;
@apply flex items-center justify-center;
@apply min-h-screen w-full;
div.background {
.layer-1 {
@apply -z-30;
background:
url("./background.png") 0 -69.032px / 100% 119.049% no-repeat,
url("./background.png") 50% / cover no-repeat;
}
.layer-2 {
@apply -z-20;
background: #103131;
mix-blend-mode: screen;
}
.layer-3 {
@apply -z-10;
background: #749095;
mix-blend-mode: soft-light;
}
.layer-1,
.layer-2,
.layer-3 {
@apply absolute top-0 left-0 w-full h-full;
}
svg[data-logo-name="Darknet"] {
@apply w-52;
@apply absolute top-28 left-1/2 transform -translate-x-1/2;
}
svg[data-logo-name="Clan"] {
@apply w-16;
@apply absolute bottom-28 left-1/2 transform -translate-x-1/2;
}
div.darknet-info {
@apply absolute bottom-[6.5rem] left-12;
@apply flex flex-col gap-y-2;
span.darknet-label {
color: theme(colors.off.darknet_label);
}
span.darknet-name {
color: theme(colors.off.darknet_name);
}
}
}
& > div.container {
@apply flex flex-col items-center justify-evenly gap-y-20;
@apply size-fit;
& > div.welcome {
@apply flex flex-col min-w-80 gap-y-6;
& > div.separator {
@apply grid grid-cols-3 grid-rows-1 gap-x-4 items-center;
}
}
& > div.setup {
@apply flex flex-col min-w-[520px] gap-y-5;
@apply pt-10 px-8 pb-8 bg-def-1 rounded-lg;
& > div.header {
@apply flex items-center justify-start gap-x-2;
}
form {
@apply flex flex-col gap-y-5;
& > div.form-controls {
@apply flex justify-end pt-6;
}
}
}
}
}

View File

@@ -0,0 +1,235 @@
import { Component, createSignal, Match, Setter, Show, Switch } from "solid-js";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import "./Onboarding.css";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Divider } from "@/src/components/Divider/Divider";
import { Logo } from "@/src/components/Logo/Logo";
import { navigateToClan, selectClanFolder } from "@/src/hooks/clan";
import { activeClanURI } from "@/src/stores/clan";
import {
createForm,
FormStore,
getError,
getErrors,
getValue,
valiForm,
} from "@modular-forms/solid";
import { TextInput } from "@/src/components/Form/TextInput";
import { TextArea } from "@/src/components/Form/TextArea";
import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
type State = "welcome" | "setup";
const SetupSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty("Please enter a name.")),
description: v.pipe(v.string(), v.nonEmpty("Please describe your clan.")),
directory: v.pipe(v.string(), v.nonEmpty("Please select a directory.")),
});
type SetupForm = v.InferInput<typeof SetupSchema>;
interface backgroundProps {
state: State;
form: FormStore<SetupForm>;
}
const background = (props: backgroundProps) => (
<div class="background">
<div class="layer-1" />
<div class="layer-2" />
<div class="layer-3" />
<Logo variant="Darknet" inverted={true} />
<Logo variant="Clan" inverted={true} />
<Show when={props.state === "setup"}>
<div class="darknet-info">
<Typography
class="darknet-label"
hierarchy="label"
family="mono"
size="default"
color="inherit"
weight="medium"
inverted={true}
>
Your Darknet:
</Typography>
<Typography
class="darknet-name"
hierarchy="teaser"
size="default"
color="inherit"
weight="medium"
inverted={true}
>
{getValue(props.form, "name")}
</Typography>
</div>
</Show>
</div>
);
const welcome = (setState: Setter<State>) => {
const navigate = useNavigate();
const selectFolder = async () => {
const uri = await selectClanFolder();
navigateToClan(navigate, uri);
};
return (
<div class="welcome">
<Typography
hierarchy="headline"
size="xxl"
weight="bold"
align="center"
inverted={true}
>
Build your <br />
own darknet
</Typography>
<Button hierarchy="secondary" onClick={() => setState("setup")}>
Start building
</Button>
<div class="separator">
<Divider orientation="horizontal" inverted={true} />
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted={true}
align="center"
>
or
</Typography>
<Divider orientation="horizontal" inverted={true} />
</div>
<Button hierarchy="primary" ghost={true} onAction={selectFolder}>
Select folder
</Button>
</div>
);
};
export const Onboarding: Component<RouteSectionProps> = (props) => {
const navigate = useNavigate();
const activeURI = activeClanURI();
if (activeURI) {
// the user has already selected a clan, so we should navigate to it
console.log("active clan detected, navigating to it", activeURI);
navigateToClan(navigate, activeURI);
}
const [state, setState] = createSignal<State>("welcome");
const [setupForm, { Form, Field }] = createForm<SetupForm>({
validate: valiForm(SetupSchema),
});
const metaError = () => {
const errors = getErrors(setupForm, ["name", "description"]);
return errors ? errors.name || errors.description : undefined;
};
return (
<main id="welcome">
{background({ form: setupForm, state: state() })}
<div class="container">
<Switch>
<Match when={state() === "welcome"}>{welcome(setState)}</Match>
<Match when={state() === "setup"}>
<div class="setup">
<div class="header">
<Button
hierarchy="secondary"
ghost={true}
icon="ArrowLeft"
onClick={() => setState("welcome")}
/>
<Typography hierarchy="headline" size="default" weight="bold">
Setup
</Typography>
</div>
<Form>
<Fieldset name="meta" error={metaError()}>
<Field name="name">
{(field, input) => (
<TextInput
{...field}
label="Name"
value={field.value}
required
orientation="horizontal"
validationState={
getError(setupForm, "name") ? "invalid" : "valid"
}
input={{
...input,
placeholder: "Name your Clan",
}}
/>
)}
</Field>
<Divider inverted={true} />
<Field name="description">
{(field, input) => (
<TextArea
{...field}
value={field.value}
label="Description"
required
orientation="horizontal"
validationState={
getError(setupForm, "description")
? "invalid"
: "valid"
}
input={input}
/>
)}
</Field>
</Fieldset>
<Fieldset
name="location"
error={getError(setupForm, "directory")}
>
<Field name="directory">
{(field, input) => (
<HostFileInput
onSelectFile={async () => "test"}
{...field}
label="Select directory"
orientation="horizontal"
required={true}
validationState={
getError(setupForm, "directory") ? "invalid" : "valid"
}
input={input}
/>
)}
</Field>
</Fieldset>
<div class="form-controls">
<Button
type="submit"
hierarchy="primary"
endIcon="ArrowRight"
>
Next
</Button>
</div>
</Form>
</div>
</Match>
</Switch>
</div>
</main>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

View File

@@ -0,0 +1,14 @@
import type { RouteDefinition } from "@solidjs/router/dist/types";
import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
import { Clan } from "@/src/routes/Clan/Clan";
export const Routes: RouteDefinition[] = [
{
path: "/",
component: Onboarding,
},
{
path: "/clan/:clanURI",
component: Clan,
},
];

View File

@@ -0,0 +1,88 @@
import { createStore, produce } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage";
interface ClanStoreType {
clanURIs: string[];
activeClanURI?: string;
}
const [store, setStore] = makePersisted(
createStore<ClanStoreType>({
clanURIs: [],
}),
{
name: "clanStore",
storage: localStorage,
},
);
/**
* Retrieves the active clan URI from the store.
*
* @function
* @returns {string} The URI of the active clan.
*/
const activeClanURI = (): string | undefined => store.activeClanURI;
/**
* Updates the active Clan URI in the store.
*
* @param {string} uri - The URI to be set as the active Clan URI.
*/
const setActiveClanURI = (uri: string) => setStore("activeClanURI", uri);
/**
* Retrieves the current list of clan URIs from the store.
*
* @function clanURIs
* @returns {*} The clan URIs from the store.
*/
const clanURIs = (): string[] => store.clanURIs;
/**
* Adds a new clan URI to the list of clan URIs in the store.
*
* @param {string} uri - The URI of the clan to be added.
*
*/
const addClanURI = (uri: string) =>
setStore("clanURIs", store.clanURIs.length, uri);
/**
* Removes a specified URI from the clan URI list and updates the active clan URI.
*
* This function modifies the store in the following ways:
* - Removes the specified URI from the `clanURIs` array.
* - Clears the `activeClanURI` if the removed URI matches the currently active URI.
* - Sets a new active clan URI to the last URI in the `clanURIs` array if the active clan URI is undefined
* and there are remaining clan URIs in the list.
*
* @param {string} uri - The URI to be removed from the clan list.
*/
const removeClanURI = (uri: string) => {
setStore(
produce((state) => {
// remove from the clan list
state.clanURIs = state.clanURIs.filter((el) => el !== uri);
// clear active clan uri if it's the one being removed
if (state.activeClanURI === uri) {
state.activeClanURI = undefined;
}
// select a new active URI if at least one remains
if (!state.activeClanURI && state.clanURIs.length > 0) {
state.activeClanURI = state.clanURIs[state.clanURIs.length - 1];
}
}),
);
};
export {
store,
activeClanURI,
setActiveClanURI,
clanURIs,
addClanURI,
removeClanURI,
};

View File

@@ -11,6 +11,9 @@ const primaries = {
off: {
white: toRGB("#ffffff"),
black: toRGB("#000000"),
darknet_name: toRGB("#00ff57"),
darknet_label: toRGB("#2cff74"),
toolbar_border: toRGB("#2e4a4b"),
},
primary: {
50: toRGB("#f4f9f9"),

View File

@@ -2,7 +2,7 @@ import argparse
import logging
from clan_lib.backups.create import create_backup
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_cli.completions import (
@@ -15,10 +15,8 @@ log = logging.getLogger(__name__)
def create_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
create_backup(machine=machine, provider=args.provider)

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_create_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["backups", "create", "machine1"])

View File

@@ -1,7 +1,7 @@
import argparse
from clan_lib.backups.list import list_backups
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_cli.completions import (
@@ -12,11 +12,8 @@ from clan_cli.completions import (
def list_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
backups = list_backups(machine=machine, provider=args.provider)
for backup in backups:
print(backup.name)

View File

@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_list_command_no_flake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["backups", "list", "machine1"])

View File

@@ -1,7 +1,7 @@
import argparse
from clan_lib.backups.restore import restore_backup
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_cli.completions import (
@@ -12,10 +12,8 @@ from clan_cli.completions import (
def restore_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
restore_backup(
machine=machine,
provider=args.provider,

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_restore_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["backups", "restore", "machine1", "provider1", "backup1"])

View File

@@ -2,13 +2,13 @@ import argparse
import logging
from clan_lib.clan.get import get_clan_details
from clan_lib.flake import Flake
from clan_lib.flake import require_flake
log = logging.getLogger(__name__)
def show_command(args: argparse.Namespace) -> None:
flake: Flake = args.flake
flake = require_flake(args.flake)
meta = get_clan_details(flake)
print(f"Name: {meta.get('name')}")

View File

@@ -1,4 +1,7 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.fixtures_flakes import FlakeForTest
from clan_cli.tests.helpers import cli
@@ -14,3 +17,19 @@ def test_clan_show(
assert "Name:" in output.out
assert "Name: test_flake_with_core" in output.out
assert "Description:" in output.out
def test_clan_show_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capture_output: CaptureOutput
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError) as exc_info:
cli.run(["show"])
assert "No clan flake found in the current directory or its parents" in str(
exc_info.value
)
assert "Use the --flake flag to specify a clan flake path or URL" in str(
exc_info.value
)

View File

@@ -250,12 +250,12 @@ This subcommand allows seamless ssh access to the nixos-image builders or a mach
Examples:
$ clan ssh [ssh_args ...] berlin`
$ clan ssh berlin
Will ssh in to the machine called `berlin`, using the
`clan.core.networking.targetHost` specified in its configuration
$ clan ssh [ssh_args ...] --json [JSON]
$ clan ssh --json [JSON] --host-key-check none
Will ssh in to the machine based on the deployment information contained in
the json string. [JSON] can either be a json formatted string itself, or point
towards a file containing the deployment information
@@ -297,6 +297,8 @@ For more detailed information, visit: {help_hyperlink("secrets", "https://docs.c
description="Manage facts",
epilog=(
f"""
Note: Facts are being deprecated, please use Vars instead.
For a migration guide visit: {help_hyperlink("vars", "https://docs.clan.lol/guides/migrations/migration-facts-vars")}
This subcommand provides an interface to facts of clan machines.
Facts are artifacts that a service can generate.

View File

@@ -1,6 +1,7 @@
import argparse
import logging
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_cli.completions import add_dynamic_completer, complete_machines
@@ -37,9 +38,10 @@ def check_secrets(machine: Machine, service: None | str = None) -> bool:
def check_command(args: argparse.Namespace) -> None:
flake = require_flake(args.flake)
machine = Machine(
name=args.machine,
flake=args.flake,
flake=flake,
)
check_secrets(machine, service=args.service)

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_check_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["facts", "check", "machine1"])

View File

@@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.git import commit_files
from clan_lib.machines.list import list_full_machines
from clan_lib.machines.machines import Machine
@@ -223,11 +224,8 @@ def generate_facts(
def generate_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machines: list[Machine] = list(list_full_machines(args.flake).values())
flake = require_flake(args.flake)
machines: list[Machine] = list(list_full_machines(flake).values())
if len(args.machines) > 0:
machines = list(
filter(

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_generate_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["facts", "generate"])

View File

@@ -2,6 +2,7 @@ import argparse
import json
import logging
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_cli.completions import add_dynamic_completer, complete_machines
@@ -10,7 +11,8 @@ log = logging.getLogger(__name__)
def get_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
# the raw_facts are bytestrings making them not json serializable
raw_facts = machine.public_facts_store.get_all()

View File

@@ -0,0 +1,13 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_list_command_no_flake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["facts", "list", "machine1"])

View File

@@ -3,6 +3,7 @@ import logging
from pathlib import Path
from tempfile import TemporaryDirectory
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_lib.ssh.remote import Remote
@@ -25,7 +26,8 @@ def upload_secrets(machine: Machine, host: Remote) -> None:
def upload_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
with machine.target_host().ssh_control_master() as host:
upload_secrets(machine, host)

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_upload_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["facts", "upload", "machine1"])

View File

@@ -7,12 +7,11 @@ from pathlib import Path
from typing import Any
from clan_lib.flake import Flake
from clan_lib.flash.flash import Disk, SystemConfig, run_machine_flash
from clan_lib.machines.machines import Machine
from clan_cli.completions import add_dynamic_completer, complete_machines
from .flash import Disk, SystemConfig, run_machine_flash
log = logging.getLogger(__name__)

View File

@@ -1,79 +1,11 @@
import argparse
import logging
import os
from pathlib import Path
from typing import TypedDict
from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.nix import nix_build
from clan_lib.flash.list import list_keymaps, list_languages
log = logging.getLogger(__name__)
class FlashOptions(TypedDict):
languages: list[str]
keymaps: list[str]
@API.register
def get_machine_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()}
def list_languages() -> list[str]:
cmd = nix_build(["nixpkgs#glibcLocales"])
result = run(cmd, RunOpts(log=Log.STDERR, error_msg="Failed to find glibc locales"))
locale_file = Path(result.stdout.strip()) / "share" / "i18n" / "SUPPORTED"
if not locale_file.exists():
msg = f"Locale file '{locale_file}' does not exist."
raise ClanError(msg)
with locale_file.open() as f:
lines = f.readlines()
languages = []
for line in lines:
if line.startswith("#"):
continue
if "SUPPORTED-LOCALES" in line:
continue
# Split by '/' and take the first part
language = line.split("/")[0].strip()
languages.append(language)
return languages
def list_keymaps() -> list[str]:
cmd = nix_build(["nixpkgs#kbd"])
result = run(cmd, RunOpts(log=Log.STDERR, error_msg="Failed to find kbdinfo"))
keymaps_dir = Path(result.stdout.strip()) / "share" / "keymaps"
if not keymaps_dir.exists():
msg = f"Keymaps directory '{keymaps_dir}' does not exist."
raise ClanError(msg)
keymap_files = []
for _root, _, files in os.walk(keymaps_dir):
for file in files:
if file.endswith(".map.gz"):
# Remove '.map.gz' ending
name_without_ext = file[:-7]
keymap_files.append(name_without_ext)
return keymap_files
def list_command(args: argparse.Namespace) -> None:
if args.cmd == "languages":
languages = list_languages()

View File

@@ -1,7 +1,9 @@
import argparse
import logging
from pathlib import Path
from typing import get_args
from clan_lib.flake import require_flake
from clan_lib.machines.hardware import (
HardwareConfig,
HardwareGenerateOptions,
@@ -9,6 +11,7 @@ from clan_lib.machines.hardware import (
)
from clan_lib.machines.machines import Machine
from clan_lib.machines.suggestions import validate_machine_names
from clan_lib.ssh.host_key import HostKeyCheck
from clan_lib.ssh.remote import Remote
from clan_cli.completions import add_dynamic_completer, complete_machines
@@ -19,8 +22,9 @@ log = logging.getLogger(__name__)
def update_hardware_config_command(args: argparse.Namespace) -> None:
validate_machine_names([args.machine], args.flake)
machine = Machine(flake=args.flake, name=args.machine)
flake = require_flake(args.flake)
validate_machine_names([args.machine], flake)
machine = Machine(flake=flake, name=args.machine)
opts = HardwareGenerateOptions(
machine=machine,
password=args.password,
@@ -57,7 +61,7 @@ def register_update_hardware_config(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
"--host-key-check",
choices=["strict", "ask", "tofu", "none"],
choices=list(get_args(HostKeyCheck)),
default="ask",
help="Host key (.ssh/known_hosts) check mode.",
)

View File

@@ -0,0 +1,15 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_cli.tests.helpers import cli
def test_create_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["machines", "update-hardware-config", "machine"])

View File

@@ -2,10 +2,13 @@ import argparse
import logging
import sys
from pathlib import Path
from typing import get_args
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.install import BuildOn, InstallOptions, run_machine_install
from clan_lib.machines.machines import Machine
from clan_lib.ssh.host_key import HostKeyCheck
from clan_lib.ssh.remote import Remote
from clan_cli.completions import (
@@ -21,15 +24,18 @@ log = logging.getLogger(__name__)
def install_command(args: argparse.Namespace) -> None:
try:
flake = require_flake(args.flake)
# Only if the caller did not specify a target_host via args.target_host
# Find a suitable target_host that is reachable
target_host_str = args.target_host
deploy_info: DeployInfo | None = ssh_command_parse(args)
deploy_info: DeployInfo | None = (
ssh_command_parse(args) if target_host_str is None else None
)
use_tor = False
if deploy_info and not args.target_host:
if deploy_info:
host = find_reachable_host(deploy_info)
if host is None:
if host is None or host.tor_socks:
use_tor = True
target_host_str = deploy_info.tor.target
else:
@@ -42,7 +48,7 @@ def install_command(args: argparse.Namespace) -> None:
else:
password = None
machine = Machine(name=args.machine, flake=args.flake)
machine = Machine(name=args.machine, flake=flake)
host_key_check = args.host_key_check
if target_host_str is not None:
@@ -56,15 +62,20 @@ def install_command(args: argparse.Namespace) -> None:
msg = "Installing macOS machines is not yet supported"
raise ClanError(msg)
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
if not args.yes:
ask = input(f"Install {args.machine} to {target_host.target}? [y/N] ")
if ask != "y":
return None
if args.identity_file:
target_host = target_host.override(private_key=args.identity_file)
if password:
target_host = target_host.override(password=password)
if use_tor:
target_host = target_host.override(tor_socks=True)
return run_machine_install(
InstallOptions(
machine=machine,
@@ -72,11 +83,8 @@ def install_command(args: argparse.Namespace) -> None:
phases=args.phases,
debug=args.debug,
no_reboot=args.no_reboot,
build_on=BuildOn(args.build_on) if args.build_on is not None else None,
build_on=args.build_on if args.build_on is not None else None,
update_hardware_config=HardwareConfig(args.update_hardware_config),
password=password,
identity_file=args.identity_file,
use_tor=use_tor,
),
target_host=target_host,
)
@@ -99,13 +107,14 @@ def register_install_parser(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
"--host-key-check",
choices=["strict", "ask", "tofu", "none"],
choices=list(get_args(HostKeyCheck)),
default="ask",
help="Host key (.ssh/known_hosts) check mode.",
)
parser.add_argument(
"--build-on",
choices=[x.value for x in BuildOn],
choices=list(get_args(BuildOn)),
default=None,
help="where to build the NixOS configuration",
)

View File

@@ -1,9 +1,11 @@
import argparse
import logging
import sys
from typing import get_args
from clan_lib.async_run import AsyncContext, AsyncOpts, AsyncRuntime
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.flake.flake import Flake
from clan_lib.machines.actions import list_machines
from clan_lib.machines.list import instantiate_inventory_to_machines
@@ -11,6 +13,7 @@ from clan_lib.machines.machines import Machine
from clan_lib.machines.suggestions import validate_machine_names
from clan_lib.machines.update import run_machine_update
from clan_lib.nix import nix_config
from clan_lib.ssh.host_key import HostKeyCheck
from clan_lib.ssh.remote import Remote
from clan_cli.completions import (
@@ -95,13 +98,8 @@ def get_machines_for_update(
def update_command(args: argparse.Namespace) -> None:
try:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machines_to_update = get_machines_for_update(
args.flake, args.machines, args.tags
)
flake = require_flake(args.flake)
machines_to_update = get_machines_for_update(flake, args.machines, args.tags)
if args.target_host is not None and len(machines_to_update) > 1:
msg = "Target Host can only be set for one machines"
@@ -111,7 +109,7 @@ def update_command(args: argparse.Namespace) -> None:
config = nix_config()
system = config["system"]
machine_names = [machine.name for machine in machines_to_update]
args.flake.precache(
flake.precache(
[
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.deployment.requireExplicitUpdate",
@@ -178,7 +176,7 @@ def register_update_parser(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--host-key-check",
choices=["strict", "ask", "tofu", "none"],
choices=list(get_args(HostKeyCheck)),
default="ask",
help="Host key (.ssh/known_hosts) check mode.",
)

View File

@@ -1,10 +1,12 @@
from pathlib import Path
import pytest
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_cli.machines.update import get_machines_for_update
# Functions to test
from clan_cli.tests.fixtures_flakes import FlakeForTest
from clan_cli.tests.helpers import cli
@pytest.mark.parametrize(
@@ -159,4 +161,13 @@ def test_get_machines_for_update_implicit_all(
assert names == expected_names
def test_update_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["machines", "update", "machine1"])
# TODO: Add more tests for requireExplicitUpdate

View File

@@ -1,7 +1,7 @@
import argparse
from pathlib import Path
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.git import commit_files
from clan_cli.completions import (
@@ -108,56 +108,44 @@ def remove_secret(
def list_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
lst = list_sops_machines(args.flake.path)
flake = require_flake(args.flake)
lst = list_sops_machines(flake.path)
if len(lst) > 0:
print("\n".join(lst))
def add_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
add_machine(args.flake.path, args.machine, args.key, args.force)
flake = require_flake(args.flake)
add_machine(flake.path, args.machine, args.key, args.force)
def get_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
print(get_machine_pubkey(args.flake.path, args.machine))
flake = require_flake(args.flake)
print(get_machine_pubkey(flake.path, args.machine))
def remove_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
remove_machine(args.flake.path, args.machine)
flake = require_flake(args.flake)
remove_machine(flake.path, args.machine)
def add_secret_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
add_secret(
args.flake.path,
flake.path,
args.machine,
sops_secrets_folder(args.flake.path) / args.secret,
age_plugins=load_age_plugins(args.flake),
sops_secrets_folder(flake.path) / args.secret,
age_plugins=load_age_plugins(flake),
)
def remove_secret_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
remove_secret(
args.flake.path,
flake.path,
args.machine,
args.secret,
age_plugins=load_age_plugins(args.flake),
age_plugins=load_age_plugins(flake),
)

View File

@@ -6,6 +6,7 @@ from collections.abc import Iterable
from pathlib import Path
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.git import commit_files
from clan_cli.completions import add_dynamic_completer, complete_secrets, complete_users
@@ -122,10 +123,8 @@ def remove_secret(
def list_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
lst = list_users(args.flake.path)
flake = require_flake(args.flake)
lst = list_users(flake.path)
if len(lst) > 0:
print("\n".join(lst))
@@ -193,66 +192,52 @@ def _key_args(args: argparse.Namespace) -> Iterable[sops.SopsKey]:
def add_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
add_user(args.flake.path, args.user, _key_args(args), args.force)
add_user(flake.path, args.user, _key_args(args), args.force)
def get_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
keys = get_user(args.flake.path, args.user)
flake = require_flake(args.flake)
keys = get_user(flake.path, args.user)
json.dump([key.as_dict() for key in keys], sys.stdout, indent=2, sort_keys=True)
def remove_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
remove_user(args.flake.path, args.user)
flake = require_flake(args.flake)
remove_user(flake.path, args.user)
def add_secret_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
add_secret(
args.flake.path,
flake.path,
args.user,
args.secret,
age_plugins=load_age_plugins(args.flake),
age_plugins=load_age_plugins(flake),
)
def remove_secret_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
remove_secret(
args.flake.path,
flake.path,
args.user,
args.secret,
age_plugins=load_age_plugins(args.flake),
age_plugins=load_age_plugins(flake),
)
def add_key_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
add_user_key(args.flake.path, args.user, _key_args(args))
add_user_key(flake.path, args.user, _key_args(args))
def remove_key_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
flake = require_flake(args.flake)
remove_user_key(args.flake.path, args.user, _key_args(args))
remove_user_key(flake.path, args.user, _key_args(args))
def register_users_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -1,12 +1,14 @@
import argparse
import json
import logging
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, get_args
from clan_lib.cmd import run
from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import HostKeyCheck, Remote
@@ -37,20 +39,23 @@ class DeployInfo:
raise ClanError(msg)
return addrs[0]
@staticmethod
def from_hostnames(
hostname: list[str], host_key_check: HostKeyCheck
def overwrite_remotes(
self,
host_key_check: HostKeyCheck | None = None,
private_key: Path | None = None,
ssh_options: dict[str, str] | None = None,
) -> "DeployInfo":
remotes = []
for host in hostname:
if not host:
msg = "Hostname cannot be empty."
raise ClanError(msg)
remote = Remote.from_ssh_uri(
machine_name="clan-installer", address=host
).override(host_key_check=host_key_check)
remotes.append(remote)
return DeployInfo(addrs=remotes)
"""Return a new DeployInfo with all Remotes overridden with the given host_key_check."""
return DeployInfo(
addrs=[
addr.override(
host_key_check=host_key_check,
private_key=private_key,
ssh_options=ssh_options,
)
for addr in self.addrs
]
)
@staticmethod
def from_json(data: dict[str, Any], host_key_check: HostKeyCheck) -> "DeployInfo":
@@ -103,9 +108,22 @@ def find_reachable_host(deploy_info: DeployInfo) -> Remote | None:
return None
def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None:
def ssh_shell_from_deploy(
deploy_info: DeployInfo, command: list[str] | None = None
) -> None:
if command and len(command) == 1 and command[0].count(" ") > 0:
msg = (
textwrap.dedent("""
It looks like you quoted the remote command.
The first argument should be the command to run, not a quoted string.
""")
.lstrip("\n")
.rstrip("\n")
)
raise ClanError(msg)
if host := find_reachable_host(deploy_info):
host.interactive_ssh()
host.interactive_ssh(command)
return
log.info("Could not reach host via clearnet 'addrs'")
@@ -127,7 +145,7 @@ def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None:
log.info(
"Host reachable via tor address, starting interactive ssh session."
)
tor_addr.interactive_ssh()
tor_addr.interactive_ssh(command)
return
log.error("Could not reach host via tor address.")
@@ -135,56 +153,99 @@ def ssh_shell_from_deploy(deploy_info: DeployInfo) -> None:
def ssh_command_parse(args: argparse.Namespace) -> DeployInfo | None:
host_key_check = args.host_key_check
deploy = None
if args.json:
json_file = Path(args.json)
if json_file.is_file():
data = json.loads(json_file.read_text())
return DeployInfo.from_json(data, host_key_check)
data = json.loads(args.json)
return DeployInfo.from_json(data, host_key_check)
if args.png:
return DeployInfo.from_qr_code(Path(args.png), host_key_check)
if hasattr(args, "machines"):
return DeployInfo.from_hostnames(args.machines, host_key_check)
deploy = DeployInfo.from_json(data, host_key_check)
elif args.png:
deploy = DeployInfo.from_qr_code(Path(args.png), host_key_check)
elif hasattr(args, "machine") and args.machine:
machine = Machine(args.machine, args.flake)
target = machine.target_host().override(
command_prefix=machine.name, host_key_check=host_key_check
)
deploy = DeployInfo(addrs=[target])
else:
return None
ssh_options = None
if hasattr(args, "ssh_option") and args.ssh_option:
for name, value in args.ssh_option:
ssh_options = {}
ssh_options[name] = value
deploy = deploy.overwrite_remotes(ssh_options=ssh_options)
return deploy
def ssh_command(args: argparse.Namespace) -> None:
deploy_info = ssh_command_parse(args)
if not deploy_info:
msg = "No MACHINE, --json or --png data provided"
raise ClanError(msg)
ssh_shell_from_deploy(deploy_info)
ssh_shell_from_deploy(deploy_info, args.remote_command)
def register_parser(parser: argparse.ArgumentParser) -> None:
group = parser.add_mutually_exclusive_group(required=True)
machines_parser = group.add_argument(
"machines",
group = parser.add_mutually_exclusive_group()
group.add_argument(
"machine",
type=str,
nargs="*",
default=[],
nargs="?",
metavar="MACHINE",
help="Machine to ssh into.",
help="Machine to ssh into (uses clan.core.networking.targetHost from configuration).",
)
add_dynamic_completer(machines_parser, complete_machines)
group.add_argument(
"-j",
"--json",
help="specify the json file for ssh data (generated by starting the clan installer)",
type=str,
help=(
"Deployment information as a JSON string or path to a JSON file "
"(generated by starting the clan installer)."
),
)
group.add_argument(
"-P",
"--png",
help="specify the json file for ssh data as the qrcode image (generated by starting the clan installer)",
type=str,
help="Deployment information as a QR code image file (generated by starting the clan installer).",
)
parser.add_argument(
"--host-key-check",
choices=["strict", "ask", "tofu", "none"],
choices=list(get_args(HostKeyCheck)),
default="tofu",
help="Host key (.ssh/known_hosts) check mode.",
)
parser.add_argument(
"--ssh-option",
help="SSH option to set (can be specified multiple times)",
nargs=2,
metavar=("name", "value"),
action="append",
default=[],
)
parser.add_argument(
"-c",
"--remote-command",
type=str,
metavar="COMMAND",
nargs=argparse.REMAINDER,
help="Command to execute on the remote host, needs to be the LAST argument as it takes all remaining arguments.",
)
add_dynamic_completer(
parser._actions[1], # noqa: SLF001
complete_machines,
) # assumes 'machine' is the first positional
parser.set_defaults(func=ssh_command)

View File

@@ -7,6 +7,8 @@ from clan_lib.nix import nix_shell
from clan_lib.ssh.remote import Remote
from clan_cli.ssh.deploy_info import DeployInfo, find_reachable_host
from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli
def test_qrcode_scan(temp_dir: Path) -> None:
@@ -69,7 +71,10 @@ def test_from_json() -> None:
@pytest.mark.with_core
def test_find_reachable_host(hosts: list[Remote]) -> None:
host = hosts[0]
deploy_info = DeployInfo.from_hostnames(["172.19.1.2", host.ssh_url()], "none")
uris = ["172.19.1.2", host.ssh_url()]
remotes = [Remote.from_ssh_uri(machine_name="some", address=uri) for uri in uris]
deploy_info = DeployInfo(addrs=remotes)
assert deploy_info.addrs[0].address == "172.19.1.2"
@@ -77,3 +82,40 @@ def test_find_reachable_host(hosts: list[Remote]) -> None:
assert remote is not None
assert remote.ssh_url() == host.ssh_url()
@pytest.mark.with_core
def test_ssh_shell_from_deploy(
hosts: list[Remote],
flake: ClanFlake,
) -> None:
host = hosts[0]
machine1_config = flake.machines["m1_machine"]
machine1_config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
machine1_config["clan"]["networking"]["targetHost"] = host.ssh_url()
flake.refresh()
assert host.private_key
success_txt = flake.path / "success.txt"
assert not success_txt.exists()
cli.run(
[
"ssh",
"--flake",
str(flake.path),
"m1_machine",
"--host-key-check=none",
"--ssh-option",
"IdentityFile",
str(host.private_key),
"--remote-command",
"touch",
str(success_txt),
"&&",
"exit 0",
]
)
assert success_txt.exists()

View File

@@ -3,6 +3,7 @@ import logging
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_lib.errors import ClanError
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
log = logging.getLogger(__name__)
@@ -29,9 +30,10 @@ def fix_vars(machine: Machine, generator_name: None | str = None) -> None:
def fix_command(args: argparse.Namespace) -> None:
flake = require_flake(args.flake)
machine = Machine(
name=args.machine,
flake=args.flake,
flake=flake,
)
fix_vars(machine, generator_name=args.generator)

View File

@@ -0,0 +1,12 @@
from pathlib import Path
import pytest
from clan_cli.tests.helpers import cli
from clan_lib.errors import ClanError
def test_fix_command_no_flake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["vars", "fix", "machine1"])

View File

@@ -20,7 +20,7 @@ from clan_cli.vars.migration import check_can_migrate, migrate_files
from clan_lib.api import API
from clan_lib.cmd import RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.flake import Flake, require_flake
from clan_lib.git import commit_files
from clan_lib.machines.list import list_full_machines
from clan_lib.nix import nix_config, nix_shell, nix_test_store
@@ -603,11 +603,8 @@ def generate_vars(
def generate_command(args: argparse.Namespace) -> None:
if args.flake is None:
msg = "Could not find clan flake toplevel directory"
raise ClanError(msg)
machines: list[Machine] = list(list_full_machines(args.flake).values())
flake = require_flake(args.flake)
machines: list[Machine] = list(list_full_machines(flake).values())
if len(args.machines) > 0:
machines = list(
@@ -622,7 +619,7 @@ def generate_command(args: argparse.Namespace) -> None:
system = config["system"]
machine_names = [machine.name for machine in machines]
# test
args.flake.precache(
flake.precache(
[
f"clanInternals.machines.{system}.{{{','.join(machine_names)}}}.config.clan.core.vars.generators.*.validationHash",
]
@@ -635,7 +632,7 @@ def generate_command(args: argparse.Namespace) -> None:
fake_prompts=args.fake_prompts,
)
if has_changed:
args.flake.invalidate_cache()
flake.invalidate_cache()
def register_generate_parser(parser: argparse.ArgumentParser) -> None:

View File

@@ -0,0 +1,14 @@
from pathlib import Path
import pytest
from clan_cli.tests.helpers import cli
from clan_lib.errors import ClanError
def test_generate_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["vars", "generate"])

View File

@@ -4,7 +4,7 @@ import sys
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.flake import Flake, require_flake
from .generate import Var
from .list import get_machine_vars
@@ -52,10 +52,11 @@ def get_command(machine_name: str, var_id: str, flake: Flake) -> None:
def _get_command(
args: argparse.Namespace,
) -> None:
flake = require_flake(args.flake)
get_command(
machine_name=args.machine,
var_id=args.var_id,
flake=args.flake,
flake=flake,
)

View File

@@ -0,0 +1,12 @@
from pathlib import Path
import pytest
from clan_cli.tests.helpers import cli
from clan_lib.errors import ClanError
def test_get_command_no_flake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["vars", "get", "machine1", "var1"])

View File

@@ -2,7 +2,7 @@ import argparse
import logging
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_lib.flake import Flake
from clan_lib.flake import Flake, require_flake
from clan_lib.machines.machines import Machine
from .generate import Var
@@ -37,7 +37,8 @@ def stringify_all_vars(machine: Machine) -> str:
def list_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
print(stringify_all_vars(machine))

View File

@@ -0,0 +1,12 @@
from pathlib import Path
import pytest
from clan_cli.tests.helpers import cli
from clan_lib.errors import ClanError
def test_list_command_no_flake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["vars", "list", "machine1"])

View File

@@ -3,6 +3,7 @@ import logging
from pathlib import Path
from clan_cli.completions import add_dynamic_completer, complete_machines
from clan_lib.flake import require_flake
from clan_lib.machines.machines import Machine
from clan_lib.ssh.remote import Remote
@@ -22,7 +23,8 @@ def populate_secret_vars(machine: Machine, directory: Path) -> None:
def upload_command(args: argparse.Namespace) -> None:
machine = Machine(name=args.machine, flake=args.flake)
flake = require_flake(args.flake)
machine = Machine(name=args.machine, flake=flake)
directory = None
if args.directory:
directory = Path(args.directory)

View File

@@ -0,0 +1,14 @@
from pathlib import Path
import pytest
from clan_cli.tests.helpers import cli
from clan_lib.errors import ClanError
def test_upload_command_no_flake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ClanError):
cli.run(["vars", "upload", "machine1"])

View File

@@ -1 +1 @@
from .flake import Flake # noqa
from .flake import Flake, require_flake # noqa

View File

@@ -1,6 +1,7 @@
import json
import logging
import os
import textwrap
from dataclasses import asdict, dataclass, field
from enum import Enum
from hashlib import sha1
@@ -588,7 +589,7 @@ class FlakeCache:
def load_from_file(self, path: Path) -> None:
with path.open("r") as f:
log.debug(f"Loading cache from {path}")
log.debug("Loading flake cache from file")
data = json.load(f)
self.cache = FlakeCacheEntry.from_json(data["cache"])
@@ -662,7 +663,7 @@ class Flake:
"""
Loads the flake into the store and populates self.store_path and self.hash such that the flake can evaluate locally and offline
"""
from clan_lib.cmd import run
from clan_lib.cmd import RunOpts, run
from clan_lib.nix import (
nix_command,
)
@@ -681,7 +682,10 @@ class Flake:
self.identifier,
]
flake_prefetch = run(nix_command(cmd))
trace_prefetch = os.environ.get("CLAN_DEBUG_NIX_PREFETCH", "0") == "1"
if not trace_prefetch:
log.debug(f"Prefetching flake {self.identifier}")
flake_prefetch = run(nix_command(cmd), RunOpts(trace=trace_prefetch))
flake_metadata = json.loads(flake_prefetch.stdout)
self.store_path = flake_metadata["storePath"]
self.hash = flake_metadata["hash"]
@@ -698,8 +702,6 @@ class Flake:
nix_metadata,
)
log.debug(f"Invalidating cache for {self.identifier}")
self.prefetch()
self._cache = FlakeCache()
@@ -813,12 +815,15 @@ class Flake:
];
}}
"""
if len(selectors) > 1:
log.debug(f"""
selecting: {selectors}
to debug run:
nix repl --expr 'rec {{
flake = builtins.getFlake "self.identifier";
if len(selectors) > 1 :
msg = textwrap.dedent(f"""
clan select "{selectors}"
""").lstrip("\n").rstrip("\n")
if os.environ.get("CLAN_DEBUG_NIX_SELECTORS"):
msg += textwrap.dedent(f"""
to debug run:
nix repl --expr 'rec {{
flake = builtins.getFlake "{self.identifier}";
selectLib = (builtins.getFlake "path:{select_source()}?narHash={select_hash}").lib;
query = [
{" ".join(
@@ -828,21 +833,24 @@ nix repl --expr 'rec {{
]
)}
];
}}'
""")
}}'
""").lstrip("\n")
log.debug(msg)
# fmt: on
elif len(selectors) == 1:
log.debug(
f"""
selecting: {selectors[0]}
to debug run:
nix repl --expr 'rec {{
msg = textwrap.dedent(f"""
$ clan select "{selectors[0]}"
""").lstrip("\n").rstrip("\n")
if os.environ.get("CLAN_DEBUG_NIX_SELECTORS"):
msg += textwrap.dedent(f"""
to debug run:
nix repl --expr 'rec {{
flake = builtins.getFlake "{self.identifier}";
selectLib = (builtins.getFlake "path:{select_source()}?narHash={select_hash}").lib;
query = selectLib.select '"''{selectors[0]}''"' flake;
}}'
"""
)
}}'
""").lstrip("\n")
log.debug(msg)
build_output = Path(
run(
@@ -923,3 +931,28 @@ nix repl --expr 'rec {{
full_selector = f'clanInternals.machines."{system}"."{machine_name}".{selector}'
return self.select(full_selector)
def require_flake(flake: Flake | None) -> Flake:
"""
Require that a flake argument is provided, if not in a clan flake.
This should be called by commands that require a flake but don't have
a sensible default when no clan flake is found locally.
Args:
flake: The flake object to check, may be None
Returns:
The validated flake object
Raises:
ClanError: If the flake is None
"""
if flake is None:
msg = "No clan flake found in the current directory or its parents"
raise ClanError(
msg,
description="Use the --flake flag to specify a clan flake path or URL",
)
return flake

View File

View File

@@ -6,16 +6,16 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from clan_cli.facts.generate import generate_facts
from clan_cli.vars.generate import generate_vars
from clan_cli.vars.upload import populate_secret_vars
from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, cmd_with_root, run
from clan_lib.errors import ClanError
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell
from clan_cli.facts.generate import generate_facts
from clan_cli.vars.generate import generate_vars
from clan_cli.vars.upload import populate_secret_vars
from .automount import pause_automounting
from .list import list_keymaps, list_languages

View File

@@ -0,0 +1,73 @@
import logging
import os
from pathlib import Path
from typing import TypedDict
from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, run
from clan_lib.errors import ClanError
from clan_lib.nix import nix_build
log = logging.getLogger(__name__)
class FlashOptions(TypedDict):
languages: list[str]
keymaps: list[str]
@API.register
def get_machine_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()}
def list_languages() -> list[str]:
cmd = nix_build(["nixpkgs#glibcLocales"])
result = run(cmd, RunOpts(log=Log.STDERR, error_msg="Failed to find glibc locales"))
locale_file = Path(result.stdout.strip()) / "share" / "i18n" / "SUPPORTED"
if not locale_file.exists():
msg = f"Locale file '{locale_file}' does not exist."
raise ClanError(msg)
with locale_file.open() as f:
lines = f.readlines()
languages = []
for line in lines:
if line.startswith("#"):
continue
if "SUPPORTED-LOCALES" in line:
continue
# Split by '/' and take the first part
language = line.split("/")[0].strip()
languages.append(language)
return languages
def list_keymaps() -> list[str]:
cmd = nix_build(["nixpkgs#kbd"])
result = run(cmd, RunOpts(log=Log.STDERR, error_msg="Failed to find kbdinfo"))
keymaps_dir = Path(result.stdout.strip()) / "share" / "keymaps"
if not keymaps_dir.exists():
msg = f"Keymaps directory '{keymaps_dir}' does not exist."
raise ClanError(msg)
keymap_files = []
for _root, _, files in os.walk(keymaps_dir):
for file in files:
if file.endswith(".map.gz"):
# Remove '.map.gz' ending
name_without_ext = file[:-7]
keymap_files.append(name_without_ext)
return keymap_files

View File

@@ -1,9 +1,9 @@
import logging
import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Literal
from clan_cli.facts.generate import generate_facts
from clan_cli.machines.hardware import HardwareConfig
@@ -18,10 +18,7 @@ from clan_lib.ssh.remote import Remote
log = logging.getLogger(__name__)
class BuildOn(Enum):
AUTO = "auto"
LOCAL = "local"
REMOTE = "remote"
BuildOn = Literal["auto", "local", "remote"]
@dataclass
@@ -33,9 +30,6 @@ class InstallOptions:
phases: str | None = None
build_on: BuildOn | None = None
update_hardware_config: HardwareConfig = HardwareConfig.NONE
password: str | None = None
identity_file: Path | None = None
use_tor: bool = False
@API.register
@@ -75,8 +69,8 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
machine.name, partitioning_secrets, phases=["partitioning"]
)
if opts.password:
os.environ["SSHPASS"] = opts.password
if target_host.password:
os.environ["SSHPASS"] = target_host.password
cmd = [
"nixos-anywhere",
@@ -114,18 +108,18 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
]
)
if opts.password:
if target_host.password:
cmd += [
"--env-password",
"--ssh-option",
"IdentitiesOnly=yes",
]
if opts.identity_file:
cmd += ["-i", str(opts.identity_file)]
if target_host.private_key:
cmd += ["-i", str(target_host.private_key)]
if opts.build_on:
cmd += ["--build-on", opts.build_on.value]
cmd += ["--build-on", opts.build_on]
if target_host.port:
cmd += ["--ssh-port", str(target_host.port)]
@@ -139,7 +133,7 @@ def run_machine_install(opts: InstallOptions, target_host: Remote) -> None:
cmd.extend(opts.machine.flake.nix_options or [])
cmd.append(target_host.target)
if opts.use_tor:
if target_host.tor_socks:
# nix copy does not support tor socks proxy
# cmd.append("--ssh-option")
# cmd.append("ProxyCommand=nc -x 127.0.0.1:9050 -X 5 %h %p")

View File

@@ -7,7 +7,7 @@ from clan_lib.errors import ClanError
HostKeyCheck = Literal[
"strict", # Strictly check ssh host keys, prompt for unknown ones
"ask", # Ask for confirmation on first use
"tofu", # Trust on ssh keys on first use
"accept-new", # Trust on ssh keys on first use
"none", # Do not check ssh host keys
]
@@ -21,7 +21,7 @@ def hostkey_to_ssh_opts(host_key_check: HostKeyCheck) -> list[str]:
return ["-o", "StrictHostKeyChecking=yes"]
case "ask":
return []
case "tofu":
case "accept-new" | "tofu":
return ["-o", "StrictHostKeyChecking=accept-new"]
case "none":
return [

View File

@@ -16,7 +16,7 @@ from tempfile import TemporaryDirectory
from clan_lib.api import API
from clan_lib.cmd import ClanCmdError, ClanCmdTimeoutError, CmdOut, RunOpts, run
from clan_lib.colors import AnsiColor
from clan_lib.errors import ClanError # Assuming these are available
from clan_lib.errors import ClanError, indent_command # Assuming these are available
from clan_lib.nix import nix_shell
from clan_lib.ssh.host_key import HostKeyCheck, hostkey_to_ssh_opts
from clan_lib.ssh.parse import parse_ssh_uri
@@ -61,6 +61,9 @@ class Remote:
private_key: Path | None = None,
password: str | None = None,
tor_socks: bool | None = None,
command_prefix: str | None = None,
port: int | None = None,
ssh_options: dict[str, str] | None = None,
) -> "Remote":
"""
Returns a new Remote instance with the same data but with a different host_key_check.
@@ -68,8 +71,8 @@ class Remote:
return Remote(
address=self.address,
user=self.user,
command_prefix=self.command_prefix,
port=self.port,
command_prefix=command_prefix or self.command_prefix,
port=port or self.port,
private_key=private_key if private_key is not None else self.private_key,
password=password if password is not None else self.password,
forward_agent=self.forward_agent,
@@ -77,7 +80,7 @@ class Remote:
host_key_check if host_key_check is not None else self.host_key_check
),
verbose_ssh=self.verbose_ssh,
ssh_options=self.ssh_options,
ssh_options=ssh_options or self.ssh_options,
tor_socks=tor_socks if tor_socks is not None else self.tor_socks,
_control_path_dir=self._control_path_dir,
_askpass_path=self._askpass_path,
@@ -418,10 +421,30 @@ class Remote:
msg = f"SSH command failed with return code {res.returncode}"
raise ClanError(msg)
def interactive_ssh(self) -> None:
cmd_list = self.ssh_cmd(tty=True, control_master=False)
res = subprocess.run(cmd_list, check=False)
def interactive_ssh(self, command: list[str] | None = None) -> None:
ssh_cmd = self.ssh_cmd(tty=True, control_master=False)
if command:
ssh_cmd = [
*self.ssh_cmd(tty=True, control_master=False),
"--",
"bash",
"-c",
quote('exec "$@"'),
"--",
" ".join(map(quote, command)),
]
cmdlog.debug(
f"{indent_command(ssh_cmd)}",
extra={
"command_prefix": self.command_prefix,
"color": AnsiColor.GREEN.value,
},
)
res = subprocess.run(ssh_cmd, check=False)
# We only check the error code if a password is set, as sshpass is used.
# AS sshpass swallows all output.
if self.password:
self.check_sshpass_errorcode(res)
def check_machine_ssh_reachable(self) -> bool:
@@ -431,7 +454,7 @@ class Remote:
@dataclass(frozen=True)
class ConnectionOptions:
timeout: int = 2
retries: int = 10
retries: int = 5
@dataclass
@@ -504,7 +527,11 @@ def check_machine_ssh_reachable(
if opts is None:
opts = ConnectionOptions()
address_family = socket.AF_INET6 if ":" in remote.address else socket.AF_INET
cmdlog.debug(
f"Checking SSH reachability for {remote.target} on port {remote.port or 22}",
)
address_family = socket.AF_INET6 if remote.is_ipv6() else socket.AF_INET
for _ in range(opts.retries):
with socket.socket(address_family, socket.SOCK_STREAM) as sock:
sock.settimeout(opts.timeout)

View File

@@ -258,6 +258,8 @@ pythonRuntime.pkgs.buildPythonApplication {
# leading to a different NAR hash and copying it here would also lead to `patchShebangs`
# changing the contents
postInstall = ''
cp -arf clan_lib/clan_core_templates/* $out/${pythonRuntime.sitePackages}/clan_lib/clan_core_templates
cp -r ${nixpkgs'} $out/${pythonRuntime.sitePackages}/clan_lib/nixpkgs
ln -sf ${nix-select} $out/${pythonRuntime.sitePackages}/clan_lib/select
installShellCompletion --bash --name clan \

View File

@@ -29,6 +29,7 @@ clan_lib = [
"clan_core_templates/**/*",
"**/allowed-packages.json",
"ssh/*.sh",
"flash/*.sh",
]
[tool.pytest.ini_options]

View File

@@ -13,7 +13,12 @@
];
perSystem =
{ config, pkgs, ... }:
{
config,
pkgs,
self',
...
}:
{
packages = {
agit = pkgs.callPackage ./agit { };
@@ -28,6 +33,25 @@
classgen = pkgs.callPackage ./classgen { };
zerotierone = pkgs.callPackage ./zerotierone { };
update-clan-core-for-checks = pkgs.callPackage ./update-clan-core-for-checks { };
clan-autorefresh = pkgs.symlinkJoin {
name = "clan";
paths = [
(pkgs.writeScriptBin "clan" ''
#!/bin/sh
set -efu
system=$(nix config show system)
nix \
--extra-experimental-features 'flakes nix-command' \
run ".#clanInternals.clan-cli.$system" -- "$@"
'')
self'.packages.clan-cli
];
postBuild = ''
rm -r $out/lib
'';
};
};
};
}

View File

@@ -1,15 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"lockFileMaintenance": { "enabled": true },
"nix": {
"enabled": true
},
"packageRules": [
{
"matchManagers": ["npm"],
"matchPaths": ["pkgs/clan-app/ui/**"],
"enabled": false
}
]
}

View File

@@ -25,7 +25,9 @@
]
(system: {
default = clan-core.inputs.nixpkgs.legacyPackages.${system}.mkShell {
packages = [ clan-core.packages.${system}.clan-cli ];
packages = [
clan-core.packages.${system}.clan-autorefresh
];
};
});
};

Some files were not shown because too many files have changed in this diff Show More