Compare commits

..

8 Commits

Author SHA1 Message Date
pure_sagacity 4285d0ae4a added website 2026-07-17 01:22:52 -05:00
pure_sagacity fac8a535de added a bun profile for devenv 2026-07-17 00:49:38 -05:00
pure_sagacity 34a046fb9a added the server with a health check
added .env.example, also removed the old empty config file
2026-07-16 11:49:24 -05:00
pure_sagacity c234d62d53 changed a spelling error 2026-07-16 11:29:11 -05:00
pure_sagacity 5d5b0ae89a switched to using the Store trait
also added another dependency for expanding ~ paths
2026-07-16 11:25:17 -05:00
pure_sagacity 767fb43ee2 fixed tests, and added the deletion and overwriting confirmation 2026-07-14 15:07:46 -05:00
pure_sagacity 72ee5a918b added color 2026-07-14 14:50:45 -05:00
pure_sagacity aab1d94388 bug fixes, but every cmd works! 2026-07-14 14:48:47 -05:00
43 changed files with 2801 additions and 374 deletions
+4
View File
@@ -0,0 +1,4 @@
# Set it to the current working directory of the harbor project
DATABASE_URL=
# Set it to the current working directory of the harbor project (it will default to /var/lib/harbor if not)
HARBOR_DATA_DIR="/Users/Maaz/Documents/Git/harbor/config/"
+2
View File
@@ -19,3 +19,5 @@ harbor.db
# Environment Variables
.env*
!.env.example
config/
Generated
+465 -7
View File
@@ -245,6 +245,61 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -349,7 +404,7 @@ dependencies = [
"cfg-if",
"cipher 0.5.2",
"cpufeatures 0.3.0",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -448,11 +503,11 @@ dependencies = [
"colored",
"crypto",
"diesel",
"dirs",
"dotenvy",
"hex",
"keyring",
"serde",
"tokio",
"toml",
"uuid",
]
@@ -532,7 +587,7 @@ name = "crypto"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"rand",
"rand 0.10.2",
]
[[package]]
@@ -553,7 +608,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"getrandom 0.4.3",
"hybrid-array",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -600,6 +655,12 @@ dependencies = [
"syn",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -653,6 +714,27 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -773,6 +855,24 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -809,6 +909,12 @@ dependencies = [
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
@@ -823,6 +929,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@@ -849,6 +956,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -857,8 +976,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
@@ -912,6 +1031,51 @@ dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
@@ -921,6 +1085,41 @@ dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -986,6 +1185,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
@@ -1024,6 +1229,15 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.37.0"
@@ -1055,6 +1269,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1070,6 +1290,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.2"
@@ -1171,6 +1397,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -1210,6 +1442,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1263,6 +1501,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -1290,12 +1537,28 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
@@ -1304,7 +1567,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
@@ -1322,6 +1604,17 @@ dependencies = [
"bitflags",
]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.13.0"
@@ -1380,6 +1673,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1458,6 +1757,30 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -1478,6 +1801,37 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "server"
version = "0.1.0"
dependencies = [
"axum",
"tokio",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -1562,6 +1916,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -1653,6 +2013,18 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
@@ -1704,12 +2076,41 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -1735,6 +2136,22 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror",
]
[[package]]
name = "typenum"
version = "1.20.1"
@@ -1804,6 +2221,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
@@ -1939,6 +2365,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zbus"
version = "5.17.0"
@@ -2011,12 +2443,38 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.13.0"
+4 -4
View File
@@ -13,17 +13,17 @@ diesel = { version = "2.2.0", features = [
"chrono",
] }
clap = { version = "4.6.1", features = ["derive", "cargo"] }
tokio = { version = "1.52.3", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] }
uuid = { version = "1.23.5", features = ["v4"] }
crypto = { path = "../crypto" }
keyring = "4.1.4"
colored = "3.1.1"
base64 = "0.22.1"
chrono = "0.4.45"
dotenvy = "0.15"
uuid = { version = "1.23.5", features = ["v4"] }
hex = "0.4"
serde = { version = "1.0.228", features = ["derive"] }
toml = "1.1.2"
hex = "0.4"
dirs = "6"
[[bin]]
name = "harbor"
+7 -5
View File
@@ -1,14 +1,16 @@
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;
pub mod models;
pub mod schema;
pub fn establish_connection() -> SqliteConnection {
pub fn establish_connection(url: String) -> SqliteConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
let resolved_path = std::fs::canonicalize(&url)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(url);
SqliteConnection::establish(&resolved_path)
.unwrap_or_else(|_| panic!("Error connecting to {}", &resolved_path))
}
+19 -195
View File
@@ -10,9 +10,10 @@ use keyring::Entry;
use serde::Deserialize;
use std::error::Error;
use std::io::{self, Write};
use std::path::PathBuf;
pub mod config;
mod db;
mod store;
pub mod store;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Deserialize, diesel::AsExpression, diesel::FromSqlRow,
@@ -112,6 +113,9 @@ pub enum Commands {
#[arg(short = 's', long = "shell", default_value = "sh")]
shell: Option<String>,
#[arg(short = 'c', long = "command")]
command: Option<String>,
},
#[command(alias = "add")]
Set {
@@ -122,9 +126,18 @@ pub enum Commands {
vars: Vec<String>,
},
Delete {
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
keys: Vec<String>,
},
Show {
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
key: String,
},
Setup {},
List {},
Project {
@@ -163,30 +176,6 @@ pub fn parse_secret_pairs(raw: &[String]) -> Result<Vec<(String, String)>, Box<d
Ok(pairs)
}
pub fn format_doppler_set_command(secrets: &[(String, String)]) -> String {
let mut command = String::from("doppler secrets set");
if secrets.is_empty() {
return command;
}
command.push_str(" \\");
for (index, (key, value)) in secrets.iter().enumerate() {
command.push_str("\n ");
command.push_str(key);
command.push_str("=\"");
command.push_str(value);
command.push('"');
if index + 1 != secrets.len() {
command.push_str(" \\");
}
}
command
}
#[derive(Subcommand)]
pub enum ProjectCommands {
List {},
@@ -194,181 +183,16 @@ pub enum ProjectCommands {
Delete { name: String },
}
pub mod interactions {
use super::db::establish_connection;
use super::db::models::Project;
use crate::Environment;
use crate::db::schema::secrets::config;
use diesel::dsl::{insert_into, update};
use diesel::result::{DatabaseErrorKind, Error as DieselError};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper};
use std::error::Error;
use uuid::Uuid;
type Result<T> = std::result::Result<T, Box<dyn Error>>;
fn construct_error(message: &str) -> Result<()> {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
message.to_string(),
)))
}
pub fn project_exists(proj_name: &str) -> Result<bool> {
use super::db::schema::projects::dsl::{name, projects};
let mut conn = establish_connection();
let existing_project = projects
.filter(name.eq(proj_name))
.select(Project::as_select())
.first::<Project>(&mut conn)
.optional()?;
Ok(existing_project.is_some())
}
pub fn create_project(proj_name: &str) -> Result<()> {
use super::db::schema::projects::dsl::{created_at, id, name, projects};
let mut conn = establish_connection();
if project_exists(proj_name)? {
return construct_error("Project already exists");
}
let proj_id = Uuid::new_v4().to_string();
match insert_into(projects)
.values((
id.eq(proj_id),
name.eq(proj_name),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)
{
Ok(_) => Ok(()),
Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
construct_error("Project already exists")
}
Err(err) => Err(Box::new(err)),
pub fn expand_tilde(path: &str) -> String {
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped).to_string_lossy().to_string();
}
}
pub fn delete_project(proj_name: &str) -> Result<()> {
use super::db::schema::projects::dsl::{name, projects};
let mut conn = establish_connection();
if !project_exists(proj_name)? {
return construct_error("Project does not exist");
path.to_string()
}
diesel::delete(projects.filter(name.eq(proj_name))).execute(&mut conn)?;
Ok(())
}
pub fn get_projects() -> Result<Vec<Project>> {
use super::db::schema::projects::dsl::projects;
let mut conn = establish_connection();
let results = projects
.select(Project::as_select())
.load::<Project>(&mut conn)?;
Ok(results)
}
pub fn secret_exists() -> Result<bool> {
use super::db::schema::secrets::dsl::{id, secrets};
let mut conn = establish_connection();
let existing_secret = secrets.select(id).first::<String>(&mut conn).optional()?;
Ok(existing_secret.is_some())
}
pub fn get_project_id(proj_name: &str) -> Result<String> {
use super::db::schema::projects::dsl::{id, name, projects};
let mut conn = establish_connection();
let project_id = projects
.filter(name.eq(proj_name))
.select(id)
.first::<String>(&mut conn)
.optional()?;
match project_id {
Some(pid) => Ok(pid),
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Project not found",
))),
}
}
pub fn set_secret(
proj_id: &str,
secret_name: &str,
secret_value: Vec<u8>,
conf: Environment,
non: crypto::Nonce,
) -> Result<()> {
use super::db::schema::secrets::dsl::{
config, created_at, name, nonce, project_id, secret, secrets,
};
let mut conn = establish_connection();
if secret_exists()? {
update(secrets.filter(name.eq(secret_name)))
.set(secret.eq(secret_value))
.execute(&mut conn)?;
} else {
insert_into(secrets)
.values((
name.eq(secret_name),
secret.eq(secret_value),
project_id.eq(proj_id),
config.eq(conf),
nonce.eq(non.to_vec()),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)?;
}
Ok(())
}
pub fn get_project_secrets(
proj_id: &str,
environment: Environment,
) -> Result<Vec<(String, Vec<u8>, Vec<u8>)>> {
use super::db::schema::secrets::dsl::{config, name, nonce, project_id, secret, secrets};
let mut conn = establish_connection();
let results = secrets
.filter(project_id.eq(proj_id))
.filter(config.eq(environment))
.select((name, secret, nonce))
.load::<(String, Vec<u8>, Vec<u8>)>(&mut conn)?;
Ok(results)
}
pub fn delete_secret(secret_name: &str) -> Result<()> {
use super::db::schema::secrets::dsl::{name, secrets};
let mut conn = establish_connection();
if !secret_exists()? {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Secret does not exist",
)));
}
diesel::delete(secrets.filter(name.eq(secret_name))).execute(&mut conn)?;
Ok(())
}
}
pub fn gen_or_get_key() -> Result<crypto::Key, Box<dyn Error>> {
let entry = Entry::new("harbor", "encryption-key")?;
+268 -93
View File
@@ -6,18 +6,20 @@ use clap::crate_version;
use cli::Cli;
use cli::Environment;
use cli::config::{Config, ConfigError};
use cli::expand_tilde;
use cli::gen_or_get_key;
use cli::get_input;
use cli::interactions::delete_secret;
use cli::interactions::get_project_id;
use cli::interactions::get_project_secrets;
use cli::interactions::get_projects;
use cli::interactions::set_secret;
use cli::store::Store;
use cli::store::sqlite::SqliteStore;
use colored::*;
use crypto::encrypt;
const GIT_VERSION: &str = env!("GIT_VERSION");
fn print_error(message: impl std::fmt::Display) {
eprintln!("{}", message.to_string().bright_red().bold());
}
fn main() {
use cli::{Commands, ProjectCommands};
let cli = Cli::parse();
@@ -37,37 +39,103 @@ fn main() {
let root = match std::env::current_dir() {
Ok(path) => path,
Err(err) => {
eprintln!("Error resolving current directory: {}", err);
print_error(format!("Error resolving current directory: {}", err));
process::exit(1);
}
};
let db_path = expand_tilde("~/.local/share/harbor/database.db");
let config_path = root.join(".harbor.toml");
let has_config = config_path.exists();
if !has_config {
eprintln!(
"{}",
"Warning: .harbor.toml not found. Some commands are disabled.".yellow()
);
}
let store = SqliteStore::new(db_path);
match cli.command {
Some(c) => match c {
_ if requires_config(&c) && !has_config => {
eprintln!(
"{}",
"Missing .harbor.toml. Run `harbor config create` first.".yellow()
);
print_error("Missing .harbor.toml. Run `harbor config create` first.");
process::exit(1);
}
Commands::Show { environment, key } => {
let config = require_config(&root);
let environment: Environment = match environment {
Some(env) => match env.parse::<Environment>() {
Ok(parsed) => parsed,
Err(_) => {
print_error(format!("Invalid environment '{}'.", env));
process::exit(1);
}
},
None => config.default_env.into(),
};
let project = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
let secrets = match store.get_project_secrets(&project, environment) {
Ok(s) => s,
Err(e) => {
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
let secret = secrets.into_iter().find(|(name, _, _)| name == &key);
match secret {
Some((_, ciphertext, nonce)) => {
let key = match gen_or_get_key() {
Ok(k) => k,
Err(_) => {
print_error("Error generating or getting key");
process::exit(1);
}
};
let nonce = crypto::Nonce::from_slice(&nonce);
let decrypted = match crypto::decrypt(&key, ciphertext, nonce) {
Ok(d) => d,
Err(e) => {
print_error(format!("Error decrypting secret: {}", e));
process::exit(1);
}
};
let decrypted_str = match String::from_utf8(decrypted) {
Ok(s) => s,
Err(e) => {
print_error(format!(
"Error converting decrypted secret to string: {}",
e
));
process::exit(1);
}
};
println!("{}", decrypted_str);
}
None => {
print_error(format!(
"Secret with key '{}' not found in project '{}'.",
key, config.name
));
process::exit(1);
}
}
}
Commands::Set { environment, vars } => {
let config = require_config(&root);
let environment: Environment = match environment {
Some(env) => match env.parse::<Environment>() {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Invalid environment '{}'.", env);
print_error(format!("Invalid environment '{}'.", env));
process::exit(1);
}
},
@@ -77,49 +145,126 @@ fn main() {
let pairs = match cli::parse_secret_pairs(&vars) {
Ok(pairs) => pairs,
Err(e) => {
eprintln!("Error parsing secrets: {}", e);
print_error(format!("Error parsing secrets: {}", e));
process::exit(1);
}
};
if pairs.is_empty() {
eprintln!("No secrets provided. Use KEY=VALUE pairs.");
print_error("No secrets provided. Use KEY=VALUE pairs.");
process::exit(1);
}
let project = match get_project_id(&config.name) {
let project = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
eprintln!("Unable to get project ID for '{}': {}", config.name, e);
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
for pair in pairs {
let existed = match store.secret_exists(&project, &pair.0, environment) {
Ok(exists) => exists,
Err(e) => {
print_error(format!(
"Error checking existing secret for key '{}': {}",
pair.0, e
));
process::exit(1);
}
};
if existed {
let confirmation = match get_input(
format!("Secret '{}' already exists. Overwrite? (y/N)", pair.0)
.yellow(),
':',
false,
) {
Ok(input) => input,
Err(e) => {
print_error(format!("Error getting confirmation: {}", e));
process::exit(1);
}
};
if confirmation.to_lowercase() != "y" {
println!(
"{}",
format!("Skipped secret '{}' in {}.", pair.0, environment.as_str())
.dimmed()
);
continue;
}
}
let key = match gen_or_get_key() {
Ok(k) => k,
Err(_) => {
eprintln!("Error generating or getting key");
print_error("Error generating or getting key");
process::exit(1);
}
};
let (nonce, encrypted) = match encrypt(&key, pair.1.as_bytes().to_vec()) {
Ok(result) => result,
Err(e) => {
eprintln!("Error encrypting secret for key '{}': {}", pair.0, e);
print_error(format!(
"Error encrypting secret for key '{}': {}",
pair.0, e
));
process::exit(1);
}
};
match set_secret(&project, &pair.0, encrypted, environment, nonce) {
Ok(()) => {}
match store.set_secret(&project, &pair.0, encrypted, environment, nonce) {
Ok(()) => {
let verb = if existed { "Updated" } else { "Set" };
println!(
"{}",
format!(
"{} secret '{}' in {}.",
verb,
pair.0,
environment.as_str()
)
.bright_green()
.bold()
);
}
Err(e) => {
eprintln!("Error setting secret for key '{}': {}", pair.0, e);
print_error(format!(
"Error setting secret for key '{}': {}",
pair.0, e
));
process::exit(1);
}
}
}
}
Commands::Delete { keys } => {
Commands::Delete { environment, keys } => {
let config = require_config(&root);
let environment: Environment = match environment {
Some(env) => match env.parse::<Environment>() {
Ok(parsed) => parsed,
Err(_) => {
print_error(format!("Invalid environment '{}'.", env));
process::exit(1);
}
},
None => config.default_env.into(),
};
let proj_id = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
let mut cleaned = Vec::new();
for key in keys {
@@ -133,17 +278,17 @@ fn main() {
let cleaned = cleaned;
if cleaned.is_empty() {
eprintln!("No secret keys provided. Use one or more keys.");
print_error("No secret keys provided. Use one or more keys.");
process::exit(1);
}
for key in cleaned {
match delete_secret(&key) {
match store.delete_secret(&proj_id, &key, environment) {
Ok(()) => {
println!("Deleted secret for key '{}'", key);
}
Err(e) => {
eprintln!("Error deleting secret for key '{}': {}", key, e);
print_error(format!("Error deleting secret for key '{}': {}", key, e));
process::exit(1);
}
};
@@ -155,16 +300,19 @@ fn main() {
Some(env) => match env.parse::<Environment>() {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Invalid environment '{}'.", env);
print_error(format!("Invalid environment '{}'.", env));
process::exit(1);
}
},
None => config.default_env.into(),
};
let proj_id = match get_project_id(&config.name) {
let proj_id = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
eprintln!("Unable to get project ID for '{}': {}", config.name, e);
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
@@ -178,10 +326,10 @@ fn main() {
cmd.args(&after[1..]);
}
let secrets = match get_project_secrets(&proj_id, environment) {
let secrets = match store.get_project_secrets(&proj_id, environment) {
Ok(s) => s,
Err(e) => {
eprintln!("Error getting secrets: {}", e);
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
@@ -192,7 +340,7 @@ fn main() {
let key = match gen_or_get_key() {
Ok(k) => k,
Err(_) => {
eprintln!("Error generating or getting key");
print_error("Error generating or getting key");
process::exit(1);
}
};
@@ -200,7 +348,10 @@ fn main() {
let decrypted = match crypto::decrypt(&key, ciphertext, nonce) {
Ok(d) => d,
Err(e) => {
eprintln!("Error decrypting secret for key '{}': {}", name, e);
print_error(format!(
"Error decrypting secret for key '{}': {}",
name, e
));
process::exit(1);
}
};
@@ -208,10 +359,10 @@ fn main() {
let decrypted_str = match String::from_utf8(decrypted) {
Ok(s) => s,
Err(e) => {
eprintln!(
print_error(format!(
"Error converting decrypted secret to string for key '{}': {}",
name, e
);
));
process::exit(1);
}
};
@@ -223,13 +374,8 @@ fn main() {
cmd.env(key, value);
}
if cmd.spawn().is_err() {
eprintln!("Error executing command: {:?}", after);
process::exit(1);
}
if cmd.status().is_err() {
eprintln!("Error waiting for command to finish: {:?}", after);
print_error(format!("Error waiting for command to finish: {:?}", after));
process::exit(1);
} else {
process::exit(0);
@@ -238,18 +384,21 @@ fn main() {
Commands::List {} => {
// Get all secrets, then use colorize and split them between environments, and print them out in a table format.
let config = require_config(&root);
let proj_id = match get_project_id(&config.name) {
let proj_id = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
eprintln!("Unable to get project ID for '{}': {}", config.name, e);
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
let secrets = match get_project_secrets(&proj_id, Environment::Dev) {
let secrets = match store.get_project_secrets(&proj_id, Environment::Dev) {
Ok(s) => s,
Err(e) => {
eprintln!("Error getting secrets: {}", e);
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
@@ -272,10 +421,10 @@ fn main() {
}
Commands::Project { command } => match command {
ProjectCommands::List {} => {
let projects = match get_projects() {
let projects = match store.get_projects() {
Ok(projects) => projects,
Err(e) => {
eprintln!("Error getting projects: {}", e);
print_error(format!("Error getting projects: {}", e));
process::exit(1);
}
};
@@ -287,31 +436,33 @@ fn main() {
let project_name = match get_input("Project name", ':', false) {
Ok(name) => name,
Err(e) => {
eprintln!("Error getting project name: {}", e);
print_error(format!("Error getting project name: {}", e));
process::exit(1);
}
};
match cli::interactions::create_project(&project_name) {
Ok(()) => println!("{}", "Project created successfully.".cyan()),
match store.create_project(&project_name) {
Ok(()) => {
println!("{}", "Project created successfully.".bright_green().bold())
}
Err(e) => {
eprintln!("Error creating project: {}", e);
print_error(format!("Error creating project: {}", e));
process::exit(1);
}
}
}
ProjectCommands::Delete { name } => {
// We'll check if the project exists before prompting for confirmation
let exists = match cli::interactions::project_exists(&name) {
let exists = match store.project_exists(&name) {
Ok(exists) => exists,
Err(e) => {
eprintln!("Error checking if project exists: {}", e);
print_error(format!("Error checking if project exists: {}", e));
process::exit(1);
}
};
if !exists {
eprintln!("Project '{}' does not exist.", name);
print_error(format!("Project '{}' does not exist.", name));
process::exit(1);
}
@@ -327,18 +478,21 @@ fn main() {
) {
Ok(input) => input,
Err(e) => {
eprintln!("Error getting confirmation: {}", e);
print_error(format!("Error getting confirmation: {}", e));
process::exit(1);
}
};
if confirmation.to_lowercase() == "y" {
match cli::interactions::delete_project(&name) {
match store.delete_project(&name) {
Ok(()) => {
println!("{}", "Project deleted successfully.".cyan())
println!(
"{}",
"Project deleted successfully.".bright_green().bold()
)
}
Err(e) => {
eprintln!("Error deleting project: {}", e);
print_error(format!("Error deleting project: {}", e));
process::exit(1);
}
}
@@ -347,22 +501,29 @@ fn main() {
}
}
},
Commands::Shell { environment, shell } => {
Commands::Shell {
environment,
shell,
command,
} => {
let config = require_config(&root);
let environment: Environment = match environment {
Some(env) => match env.parse::<Environment>() {
Ok(parsed) => parsed,
Err(_) => {
eprintln!("Invalid environment '{}'.", env);
print_error(format!("Invalid environment '{}'.", env));
process::exit(1);
}
},
None => config.default_env.into(),
};
let proj_id = match get_project_id(&config.name) {
let proj_id = match store.get_project_id(&config.name) {
Ok(p) => p,
Err(e) => {
eprintln!("Unable to get project ID for '{}': {}", config.name, e);
print_error(format!(
"Unable to get project ID for '{}': {}",
config.name, e
));
process::exit(1);
}
};
@@ -372,15 +533,20 @@ fn main() {
let shell = shell
.or_else(|| std::env::var("SHELL").ok())
.or_else(|| Some("sh".into()))
.or_else(|| Some("/bin/sh".into()))
.unwrap();
let mut cmd = process::Command::new(&shell);
let secrets = match get_project_secrets(&proj_id, environment) {
if let Some(command) = command {
cmd.arg("-c");
cmd.arg(command);
}
let secrets = match store.get_project_secrets(&proj_id, environment) {
Ok(s) => s,
Err(e) => {
eprintln!("Error getting secrets: {}", e);
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
@@ -391,7 +557,7 @@ fn main() {
let key = match gen_or_get_key() {
Ok(k) => k,
Err(_) => {
eprintln!("Error generating or getting key");
print_error("Error generating or getting key");
process::exit(1);
}
};
@@ -399,7 +565,10 @@ fn main() {
let decrypted = match crypto::decrypt(&key, ciphertext, nonce) {
Ok(d) => d,
Err(e) => {
eprintln!("Error decrypting secret for key '{}': {}", name, e);
print_error(format!(
"Error decrypting secret for key '{}': {}",
name, e
));
process::exit(1);
}
};
@@ -407,10 +576,10 @@ fn main() {
let decrypted_str = match String::from_utf8(decrypted) {
Ok(s) => s,
Err(e) => {
eprintln!(
print_error(format!(
"Error converting decrypted secret to string for key '{}': {}",
name, e
);
));
process::exit(1);
}
};
@@ -422,13 +591,16 @@ fn main() {
cmd.env(key, value);
}
if cmd.spawn().is_err() {
eprintln!("Error executing command: {:?}", &shell);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(_) => {
print_error(format!("Error executing command: {:?}", &shell));
process::exit(1);
}
};
if cmd.status().is_err() {
eprintln!("Error waiting for command to finish: {:?}", &shell);
if child.wait().is_err() {
print_error(format!("Error waiting for command to finish: {:?}", &shell));
process::exit(1);
} else {
process::exit(0);
@@ -438,16 +610,16 @@ fn main() {
use std::process;
// Setup will get all projects and prompt the user to select one,
// then create a .harbor.toml file in the current directory with the selected project name.
let projects = match get_projects() {
let projects = match store.get_projects() {
Ok(projects) => projects,
Err(e) => {
eprintln!("Error getting projects: {}", e);
print_error(format!("Error getting projects: {}", e));
process::exit(1);
}
};
if projects.is_empty() {
eprintln!("No projects found. Please create a project first.");
print_error("No projects found. Please create a project first.");
process::exit(1);
}
@@ -461,7 +633,7 @@ fn main() {
{
Ok(c) => c,
Err(e) => {
eprintln!("Error spawning fzf: {}", e);
print_error(format!("Error spawning fzf: {}", e));
process::exit(1);
}
};
@@ -478,20 +650,20 @@ fn main() {
let output = match fzf.wait_with_output() {
Ok(o) => o,
Err(e) => {
eprintln!("Error waiting for fzf: {}", e);
print_error(format!("Error waiting for fzf: {}", e));
process::exit(1);
}
};
if !output.status.success() {
eprintln!("fzf exited with non-zero status");
print_error("fzf exited with non-zero status");
process::exit(1);
}
let project = String::from_utf8_lossy(&output.stdout).trim().to_string();
if project.is_empty() {
eprintln!("No project selected.");
print_error("No project selected.");
process::exit(1);
}
@@ -505,13 +677,18 @@ config = "dev""#,
let config_path = root.join(".harbor.toml");
match std::fs::write(&config_path, new_config) {
Ok(_) => {
println!("Created .harbor.toml for project '{}'.", project);
println!(
"{}",
format!("Created .harbor.toml for project '{}'.", project)
.bright_green()
.bold()
);
}
Err(e) => {
eprintln!(
print_error(format!(
"Error creating .harbor.toml for project '{}': {}",
project, e
);
));
process::exit(1);
}
}
@@ -532,14 +709,11 @@ fn require_config(root: &std::path::Path) -> Config {
match Config::from_repo_root(root) {
Ok(config) => config,
Err(ConfigError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
eprintln!(
"{}",
"Missing .harbor.toml. Run `harbor config create` first.".yellow()
);
print_error("Missing .harbor.toml. Run `harbor config create` first.");
process::exit(1);
}
Err(err) => {
eprintln!("Error reading config: {}", err);
print_error(format!("Error reading config: {}", err));
process::exit(1);
}
}
@@ -549,6 +723,7 @@ fn requires_config(command: &cli::Commands) -> bool {
matches!(
command,
cli::Commands::Inject { .. }
| cli::Commands::Show { .. }
| cli::Commands::Set { .. }
| cli::Commands::Delete { .. }
| cli::Commands::Shell { .. }
+32 -2
View File
@@ -3,5 +3,35 @@
// With this trait, we can implement other savers like Postgres, MySQL, etc.
// Or even third party services like Hashicorp Vault, AWS Secrets Manager, etc.
// For now, I'll just get an MVP in place.
pub trait Store {}
use std::error::Error;
pub mod sqlite;
use crate::{Environment, db::models::Project};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
pub trait Store {
fn project_exists(&self, proj_name: &str) -> Result<bool>;
fn create_project(&self, proj_name: &str) -> Result<()>;
fn delete_project(&self, proj_name: &str) -> Result<()>;
fn get_projects(&self) -> Result<Vec<Project>>;
fn get_project_id(&self, proj_name: &str) -> Result<String>;
fn secret_exists(&self, proj_id: &str, secret_name: &str, environment: Environment)
-> Result<bool>;
fn set_secret(
&self,
proj_id: &str,
secret_name: &str,
secret_value: Vec<u8>,
conf: Environment,
non: crypto::Nonce,
) -> Result<()>;
fn get_project_secrets(
&self,
proj_id: &str,
environment: Environment,
) -> Result<Vec<(String, Vec<u8>, Vec<u8>)>>;
fn delete_secret(&self, proj_id: &str, secret_name: &str, environment: Environment)
-> Result<()>;
}
+220
View File
@@ -0,0 +1,220 @@
use crate::db::models::Project;
use crate::store::Store;
use diesel::dsl::{insert_into, update};
use diesel::result::{DatabaseErrorKind, Error as DieselError};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper};
use uuid::Uuid;
pub struct SqliteStore {
database_url: String,
}
impl SqliteStore {
pub fn new(database_url: String) -> Self {
SqliteStore { database_url }
}
fn db_url(&self) -> String {
if self.database_url.is_empty() {
std::env::var("DATABASE_URL").expect("NO DATABASE URL WAS SET")
} else {
self.database_url.clone()
}
}
}
fn construct_error(message: &str) -> super::Result<()> {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
message.to_string(),
)))
}
impl Store for SqliteStore {
fn create_project(&self, proj_name: &str) -> super::Result<()> {
use crate::db::schema::projects::dsl::{created_at, id, name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
if self.project_exists(proj_name)? {
return construct_error("Project already exists");
}
let proj_id = Uuid::new_v4().to_string();
match insert_into(projects)
.values((
id.eq(proj_id),
name.eq(proj_name),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)
{
Ok(_) => Ok(()),
Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
construct_error("Project already exists")
}
Err(err) => Err(Box::new(err)),
}
}
fn delete_project(&self, proj_name: &str) -> super::Result<()> {
use crate::db::schema::projects::dsl::{name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
if !self.project_exists(proj_name)? {
return construct_error("Project does not exist");
}
diesel::delete(projects.filter(name.eq(proj_name))).execute(&mut conn)?;
Ok(())
}
fn delete_secret(
&self,
proj_id: &str,
secret_name: &str,
environment: crate::Environment,
) -> super::Result<()> {
use crate::db::schema::secrets::dsl::{config, id, name, project_id, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_secret = secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment))
.select(id)
.first::<String>(&mut conn)
.optional()?;
if existing_secret.is_none() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Secret does not exist",
)));
}
diesel::delete(
secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment)),
)
.execute(&mut conn)?;
Ok(())
}
fn get_project_id(&self, proj_name: &str) -> super::Result<String> {
use crate::db::schema::projects::dsl::{id, name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
let project_id = projects
.filter(name.eq(proj_name))
.select(id)
.first::<String>(&mut conn)
.optional()?;
match project_id {
Some(pid) => Ok(pid),
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Project not found",
))),
}
}
fn get_project_secrets(
&self,
proj_id: &str,
environment: crate::Environment,
) -> super::Result<Vec<(String, Vec<u8>, Vec<u8>)>> {
use crate::db::schema::secrets::dsl::{config, name, nonce, project_id, secret, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let results = secrets
.filter(project_id.eq(proj_id))
.filter(config.eq(environment))
.select((name, secret, nonce))
.load::<(String, Vec<u8>, Vec<u8>)>(&mut conn)?;
Ok(results)
}
fn get_projects(&self) -> super::Result<Vec<Project>> {
use crate::db::schema::projects::dsl::projects;
let mut conn = crate::db::establish_connection(self.db_url());
let results = projects
.select(Project::as_select())
.load::<Project>(&mut conn)?;
Ok(results)
}
fn project_exists(&self, proj_name: &str) -> super::Result<bool> {
use crate::db::schema::projects::dsl::{name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_project = projects
.filter(name.eq(proj_name))
.select(Project::as_select())
.first::<Project>(&mut conn)
.optional()?;
Ok(existing_project.is_some())
}
fn secret_exists(
&self,
proj_id: &str,
secret_name: &str,
environment: crate::Environment,
) -> super::Result<bool> {
use crate::db::schema::secrets::dsl::{config, id, name, project_id, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_secret = secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment))
.select(id)
.first::<String>(&mut conn)
.optional()?;
Ok(existing_secret.is_some())
}
fn set_secret(
&self,
proj_id: &str,
secret_name: &str,
secret_value: Vec<u8>,
conf: crate::Environment,
non: crypto::Nonce,
) -> super::Result<()> {
use crate::db::schema::secrets::dsl::{
config, created_at, id, name, nonce, project_id, secret, secrets,
};
let mut conn = crate::db::establish_connection(self.db_url());
if self.secret_exists(proj_id, secret_name, conf)? {
update(
secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(conf)),
)
.set((secret.eq(secret_value), nonce.eq(non.to_vec())))
.execute(&mut conn)?;
} else {
insert_into(secrets)
.values((
id.eq(Uuid::new_v4().to_string()),
name.eq(secret_name),
secret.eq(secret_value),
project_id.eq(proj_id),
config.eq(conf),
nonce.eq(non.to_vec()),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)?;
}
Ok(())
}
}
+50 -50
View File
@@ -1,17 +1,8 @@
use cli::Environment;
use cli::config::Config;
use cli::interactions::{
create_project,
delete_project,
delete_secret,
get_project_id,
get_project_secrets,
get_projects,
project_exists,
secret_exists,
set_secret,
};
use cli::{format_doppler_set_command, parse_secret_pairs};
use cli::parse_secret_pairs;
use cli::store::Store;
use cli::store::sqlite::SqliteStore;
use diesel::connection::SimpleConnection;
use diesel::{Connection, SqliteConnection};
use std::path::PathBuf;
@@ -70,7 +61,10 @@ fn environment_parsing_and_display() {
assert_eq!(format!("{}", Environment::Prod), "prod");
let err = "unknown".parse::<Environment>().unwrap_err();
assert_eq!(err.to_string(), "Environment must be one of: dev, prod, staging");
assert_eq!(
err.to_string(),
"Environment must be one of: dev, prod, staging"
);
}
#[test]
@@ -95,25 +89,6 @@ fn parse_secret_pairs_handles_valid_and_invalid_input() {
assert_eq!(err.to_string(), "Secret key cannot be empty");
}
#[test]
fn format_doppler_set_command_outputs_expected_format() {
let empty = format_doppler_set_command(&[]);
assert_eq!(empty, "doppler secrets set");
let one = format_doppler_set_command(&[("API_KEY".to_string(), "abc".to_string())]);
assert_eq!(one, "doppler secrets set \\\n API_KEY=\"abc\"");
let many = format_doppler_set_command(&[
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
]);
assert_eq!(
many,
"doppler secrets set \\\n A=\"1\" \\\n B=\"2\""
);
}
#[test]
fn config_reads_valid_file_and_reports_errors() {
let temp_root = std::env::temp_dir().join(format!("harbor_config_{}", Uuid::new_v4()));
@@ -147,21 +122,27 @@ fn config_reads_valid_file_and_reports_errors() {
fn project_and_secret_lifecycle() {
let _guard = test_lock().lock().expect("lock tests");
let db_path = setup_test_db();
let store = SqliteStore::new(String::new());
assert!(!project_exists("alpha").expect("project exists"));
assert!(!store.project_exists("alpha").expect("project exists"));
create_project("alpha").expect("create project");
assert!(project_exists("alpha").expect("project exists"));
store.create_project("alpha").expect("create project");
assert!(store.project_exists("alpha").expect("project exists"));
let project_id = get_project_id("alpha").expect("get project id");
let projects = get_projects().expect("get projects");
let project_id = store.get_project_id("alpha").expect("get project id");
let projects = store.get_projects().expect("get projects");
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "alpha");
assert!(!secret_exists().expect("secret exists"));
assert!(
!store
.secret_exists(&project_id, "API_KEY", Environment::Dev)
.expect("secret exists")
);
let nonce = crypto::helper::gen_nonce();
set_secret(
store
.set_secret(
&project_id,
"API_KEY",
b"first".to_vec(),
@@ -170,15 +151,22 @@ fn project_and_secret_lifecycle() {
)
.expect("set secret");
assert!(secret_exists().expect("secret exists"));
assert!(
store
.secret_exists(&project_id, "API_KEY", Environment::Dev)
.expect("secret exists")
);
let secrets = get_project_secrets(&project_id, Environment::Dev).expect("get secrets");
let secrets = store
.get_project_secrets(&project_id, Environment::Dev)
.expect("get secrets");
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].0, "API_KEY");
assert_eq!(secrets[0].1, b"first".to_vec());
let nonce = crypto::helper::gen_nonce();
set_secret(
store
.set_secret(
&project_id,
"API_KEY",
b"second".to_vec(),
@@ -187,15 +175,23 @@ fn project_and_secret_lifecycle() {
)
.expect("update secret");
let updated = get_project_secrets(&project_id, Environment::Dev).expect("get secrets");
let updated = store
.get_project_secrets(&project_id, Environment::Dev)
.expect("get secrets");
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].1, b"second".to_vec());
delete_secret("API_KEY").expect("delete secret");
assert!(!secret_exists().expect("secret exists"));
store
.delete_secret(&project_id, "API_KEY", Environment::Dev)
.expect("delete secret");
assert!(
!store
.secret_exists(&project_id, "API_KEY", Environment::Dev)
.expect("secret exists")
);
delete_project("alpha").expect("delete project");
assert!(!project_exists("alpha").expect("project exists"));
store.delete_project("alpha").expect("delete project");
assert!(!store.project_exists("alpha").expect("project exists"));
cleanup_test_db(db_path);
}
@@ -204,14 +200,18 @@ fn project_and_secret_lifecycle() {
fn errors_for_missing_project_or_secret() {
let _guard = test_lock().lock().expect("lock tests");
let db_path = setup_test_db();
let store = SqliteStore::new(String::new());
let err = delete_project("missing").unwrap_err();
let err = store.delete_project("missing").unwrap_err();
assert_eq!(err.to_string(), "Project does not exist");
let err = get_project_id("missing").unwrap_err();
let err = store.get_project_id("missing").unwrap_err();
assert_eq!(err.to_string(), "Project not found");
let err = delete_secret("missing").unwrap_err();
let missing_project_id = Uuid::new_v4().to_string();
let err = store
.delete_secret(&missing_project_id, "missing", Environment::Dev)
.unwrap_err();
assert_eq!(err.to_string(), "Secret does not exist");
cleanup_test_db(db_path);
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.9", features = ["tokio", "ws"] }
tokio = { version = "1.52.3", features = ["full"] }
+36
View File
@@ -0,0 +1,36 @@
use std::path::PathBuf;
use axum::{Router, routing::get};
use tokio::net::TcpListener;
fn data_dir() -> PathBuf {
std::env::var("HARBOR_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/harbor"))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health_check));
let address = {
let port = std::env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.expect("PORT must be a valid u16");
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
format!("{}:{}", host, port)
};
let listener = TcpListener::bind(&address)
.await
.expect("Failed to bind to address");
println!("Server running on http://{}", address);
axum::serve(listener, app).await.unwrap();
}
async fn health_check() -> &'static str {
"OK"
}
+35
View File
@@ -1,4 +1,5 @@
{
config,
pkgs,
...
}:
@@ -12,6 +13,8 @@
sqlite
];
env.RUST_BACKTRACE = "1";
languages.rust = {
enable = true;
lsp.enable = true;
@@ -24,12 +27,44 @@
];
};
processes.server =
let
port = config.processes.server.ports.server.value;
in
{
ports.server.allocate = 8080;
exec = "PORT=${toString port} HOST=127.0.0.1 cargo watch -s' cargo run -p server'";
ready = {
http.get = {
port = port;
path = "/health";
host = "127.0.0.1";
scheme = "http";
};
initial_delay = 5;
period = 5;
probe_timeout = 3;
success_threshold = 1;
failure_threshold = 3;
};
};
dotenv.enable = true;
enterTest = ''
cargo test
'';
profiles.website.module = {
languages.javascript = {
enable = true;
bun = {
enable = true;
install.enable = true;
};
};
};
git-hooks.hooks = {
shellcheck.enable = true;
prettier.enable = true;
View File
@@ -0,0 +1 @@
../../CLAUDE.md
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+111
View File
@@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+21
View File
@@ -0,0 +1,21 @@
# bun-react-tailwind-shadcn-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.13. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+24
View File
@@ -0,0 +1,24 @@
import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import path from "node:path";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
const entrypoints = [...new Bun.Glob("src/**/*.html").scanSync()];
const result = await Bun.build({
entrypoints,
outdir,
plugins: [tailwind],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
});
for (const output of result.outputs) {
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
}
+19
View File
@@ -0,0 +1,19 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.css" {}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}
+177
View File
@@ -0,0 +1,177 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.11",
},
},
},
"packages": {
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
"@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
"@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
"@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
"@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
}
}
+3
View File
@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11"
}
}
+377
View File
@@ -0,0 +1,377 @@
import { useEffect } from "react";
import {
ArrowDown,
ArrowRight,
Cloud,
Key,
Layers,
Lock,
Package,
Server,
Settings,
Shield,
Terminal,
} from "lucide-react";
import "./index.css";
export function App() {
useEffect(() => {
const elements = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div className="min-h-screen text-foreground">
<div className="relative">
<header className="mx-auto flex max-w-6xl items-center justify-between px-6 pt-8" data-reveal>
<div className="reveal flex items-center gap-3" data-delay="1">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)]">
<Layers className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="font-display text-lg tracking-tight">Harbor</p>
<p className="text-[0.65rem] uppercase tracking-[0.32em] text-muted-foreground">Local-first secrets</p>
</div>
</div>
<nav className="hidden items-center gap-6 text-sm font-medium text-muted-foreground md:flex reveal" data-delay="2">
<a href="#local" className="transition hover:text-foreground">
Local
</a>
<a href="#server" className="transition hover:text-foreground">
Server
</a>
<a href="#install" className="transition hover:text-foreground">
Nix Flake
</a>
<a href="#capabilities" className="transition hover:text-foreground">
Capabilities
</a>
</nav>
<a
href="/install"
className="hidden rounded-full border border-foreground/15 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80 shadow-sm md:inline-flex reveal"
data-delay="3"
>
Install
</a>
</header>
<main className="mx-auto flex max-w-6xl flex-col gap-20 px-6 pb-24 pt-12">
<section className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-6" data-reveal>
<div className="reveal float-slow inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/70">
Local + server in sync
</div>
<h1 className="font-display reveal text-4xl leading-tight tracking-tight sm:text-5xl lg:text-6xl" data-delay="1">
Harbor keeps local config and team secrets in lockstep.
</h1>
<p className="reveal text-base text-muted-foreground sm:text-lg" data-delay="2">
Run the CLI locally to read configuration straight from your code. Sync only the projects you want, and
let the server manage secrets, configs, and shared updates for the whole team.
</p>
<div className="reveal flex flex-wrap gap-3" data-delay="3">
<a
href="/install"
className="rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)] transition hover:translate-y-[-1px]"
>
Install
</a>
<a
href="#capabilities"
className="rounded-full border border-foreground/15 bg-card/80 px-5 py-2.5 text-sm font-semibold text-foreground/80 shadow-sm transition hover:text-foreground"
>
View capabilities
</a>
</div>
</div>
<div className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-6 shadow-[0_40px_120px_-80px_rgba(16,24,40,0.6)]" data-reveal>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Sync map</p>
<span className="rounded-full bg-foreground/5 px-3 py-1 text-xs text-foreground/70">Team-wide</span>
</div>
<ol className="mt-6 space-y-5 text-sm">
<li className="flex gap-3">
<Terminal className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Local reads the code</p>
<p className="text-muted-foreground">The CLI inspects configuration directly in your repo.</p>
</div>
</li>
<li className="flex gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Selective project sync</p>
<p className="text-muted-foreground">Only the projects you choose are sent to the server.</p>
</div>
</li>
<li className="flex gap-3">
<Server className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Server control plane</p>
<p className="text-muted-foreground">Manage secrets, configs, and shared updates.</p>
</div>
</li>
<li className="flex gap-3">
<Shield className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Everyone stays aligned</p>
<p className="text-muted-foreground">Updates remain in sync across your entire team.</p>
</div>
</li>
</ol>
</div>
</section>
<section id="local" className="scroll-mt-24" data-reveal>
<div className="reveal grid gap-8 rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm lg:grid-cols-[1.1fr_0.9fr]">
<div>
<div className="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
<Terminal className="h-4 w-4 text-primary" aria-hidden="true" />
Local side
</div>
<h2 className="font-display mt-3 text-3xl">Local reads the code first.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Harbor runs next to your repo. Locally, you can just read the code, and the CLI does not require a
server to operate.
</p>
<div className="mt-6 space-y-4 text-sm">
<div className="flex items-start gap-3">
<Shield className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">No server required</p>
<p className="text-muted-foreground">Everything runs locally until you choose to sync.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Settings className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Understands your config</p>
<p className="text-muted-foreground">Reads configuration straight from your codebase.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Selective project sync</p>
<p className="text-muted-foreground">Choose exactly which projects get shared.</p>
</div>
</div>
</div>
<a
href="#server"
className="mt-6 inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-background px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80"
>
Switch to server
<ArrowDown className="h-4 w-4" aria-hidden="true" />
</a>
</div>
<div className="flex flex-col justify-between gap-6 rounded-2xl border border-foreground/10 bg-background/60 p-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Local workflow</p>
<h3 className="font-display mt-3 text-xl">CLI stays close to the repo.</h3>
<p className="mt-3 text-sm text-muted-foreground">
Read configuration where it lives, keep secrets local, and sync only when needed.
</p>
</div>
<div className="rounded-xl bg-foreground/5 p-4 text-xs text-foreground/70">
<p className="font-semibold text-foreground">Local-first cadence</p>
<p className="mt-2">Inspect config -> Select projects -> Sync to server</p>
</div>
</div>
</div>
</section>
<section id="server" className="scroll-mt-24" data-reveal>
<div className="reveal grid gap-8 rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm lg:grid-cols-[0.9fr_1.1fr]">
<div className="order-2 lg:order-1 flex flex-col justify-between gap-6 rounded-2xl border border-foreground/10 bg-background/60 p-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Server overview</p>
<h3 className="font-display mt-3 text-xl">Teamwide management hub.</h3>
<p className="mt-3 text-sm text-muted-foreground">
Use the local CLI to sync specific projects, then manage them on the server with full control.
</p>
</div>
<div className="rounded-xl bg-foreground/5 p-4 text-xs text-foreground/70">
<p className="font-semibold text-foreground">Server control</p>
<p className="mt-2">Edit secrets -> Create configs -> Sync to everyone</p>
</div>
</div>
<div className="order-1 lg:order-2">
<div className="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
<Server className="h-4 w-4 text-primary" aria-hidden="true" />
Server side
</div>
<h2 className="font-display mt-3 text-3xl">Manage secrets for the whole team.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Use the local CLI to sync selected projects. The server then manages those projects, including secrets
and configuration.
</p>
<div className="mt-6 space-y-4 text-sm">
<div className="flex items-start gap-3">
<Key className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Edit secrets and configs</p>
<p className="text-muted-foreground">Update values and create new configuration sets.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Lock className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Generate new secrets</p>
<p className="text-muted-foreground">Create fresh values whenever you need them.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Cloud className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Stay in sync</p>
<p className="text-muted-foreground">Every update stays aligned across your entire team.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="install" className="scroll-mt-24" data-reveal>
<div className="reveal flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Install</p>
<h2 className="font-display mt-3 text-3xl">Add Harbor as a flake input</h2>
</div>
<p className="max-w-md text-sm text-muted-foreground">
Keep the flake input on your repo, then use the install page for Cargo and manual builds.
</p>
</div>
<div className="reveal mt-8 rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="1">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-display text-xl">Nix flake input</h3>
</div>
<pre className="mt-4 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>{`inputs = {
harbor = {
url = "git+https://gitea.maariz.org/pure_sagacity/harbor.git";
inputs.nixpkgs.follows = "nixpkgs";
};
};`}</code>
</pre>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground">Need Cargo or manual?</span>
<a href="/install" className="inline-flex items-center gap-2 font-semibold text-primary">
View other install methods
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
</section>
<section id="capabilities" className="scroll-mt-24" data-reveal>
<div className="reveal flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Capabilities</p>
<h2 className="font-display mt-3 text-3xl">What Harbor can do</h2>
</div>
<p className="max-w-md text-sm text-muted-foreground">
Local-first by design, with server-side controls that keep your team aligned.
</p>
</div>
<div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[
{
title: "Read config from code",
description: "The CLI parses configuration directly from your repo.",
icon: Terminal,
},
{
title: "Project-level control",
description: "Sync only the projects you choose to share.",
icon: Package,
},
{
title: "Manage projects",
description: "Organize synced projects and environments.",
icon: Layers,
},
{
title: "Edit and update",
description: "Change secrets from the server and share updates.",
icon: Key,
},
{
title: "Create new configs",
description: "Build new configuration sets server-side.",
icon: Settings,
},
{
title: "Always aligned",
description: "Generate fresh secrets and keep every teammate in sync.",
icon: Shield,
},
].map((item, index) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-5 shadow-sm"
data-reveal
data-delay={index % 3 === 0 ? "1" : index % 3 === 1 ? "2" : "3"}
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Capability</p>
</div>
<h3 className="font-display mt-3 text-lg">{item.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
</div>
);
})}
</div>
</section>
<section className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm" data-reveal>
<div className="max-w-2xl">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Teamwide consistency</p>
<h2 className="font-display mt-3 text-3xl">Every update stays in sync across your entire team.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Sync a project once, then manage secrets and configs on the server. Harbor keeps local and server states
aligned for everyone using the CLI.
</p>
<div className="mt-5 flex flex-wrap gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-foreground/70">
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Local-first</span>
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Selective sync</span>
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Shared secrets</span>
</div>
</div>
</section>
</main>
<footer className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-6 pb-10 text-xs text-muted-foreground">
<span>Harbor CLI + Server</span>
<span>Local-first secrets and configuration sync.</span>
</footer>
</div>
</div>
);
}
export default App;
+150
View File
@@ -0,0 +1,150 @@
import { useEffect } from "react";
import { ArrowLeft, ArrowRight, Layers, Package, Terminal, Wrench } from "lucide-react";
import "./index.css";
export function InstallApp() {
useEffect(() => {
const elements = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div className="min-h-screen text-foreground">
<div className="relative">
<header className="mx-auto flex max-w-6xl items-center justify-between px-6 pt-8" data-reveal>
<a href="/" className="reveal flex items-center gap-3" data-delay="1">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)]">
<Layers className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="font-display text-lg tracking-tight">Harbor</p>
<p className="text-[0.65rem] uppercase tracking-[0.32em] text-muted-foreground">Install</p>
</div>
</a>
<a
href="/"
className="hidden items-center gap-2 rounded-full border border-foreground/15 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80 shadow-sm md:inline-flex reveal"
data-delay="2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to home
</a>
</header>
<main className="mx-auto flex max-w-6xl flex-col gap-16 px-6 pb-24 pt-12">
<section className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]" data-reveal>
<div className="reveal space-y-6">
<div className="float-slow inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/70">
All install methods
</div>
<h1 className="font-display text-4xl leading-tight tracking-tight sm:text-5xl lg:text-6xl">
Install Harbor locally, the way your team prefers.
</h1>
<p className="text-base text-muted-foreground sm:text-lg">
Pick Cargo for a quick install or build manually with the Rust toolchain. The Nix flake input lives on
the main landing page.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/"
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-background px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80"
>
View flake input
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
<div className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="2">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Install checklist</p>
<ul className="mt-5 space-y-4 text-sm">
<li className="flex items-start gap-3">
<Terminal className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Rust toolchain</p>
<p className="text-muted-foreground">Ensure Rust is installed before running commands.</p>
</div>
</li>
<li className="flex items-start gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Cargo install</p>
<p className="text-muted-foreground">Grab the CLI directly from crates.io.</p>
</div>
</li>
<li className="flex items-start gap-3">
<Wrench className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Manual build</p>
<p className="text-muted-foreground">Build from source with one command.</p>
</div>
</li>
</ul>
</div>
</section>
<section className="rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm reveal" data-reveal>
<div className="flex items-center gap-3">
<Layers className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Nix flake input</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Add Harbor as a flake input in your repo.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>{`inputs = {
harbor = {
url = "git+https://gitea.maariz.org/pure_sagacity/harbor.git";
inputs.nixpkgs.follows = "nixpkgs";
};
};`}</code>
</pre>
</section>
<section className="grid gap-6 md:grid-cols-2" data-reveal>
<div className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="1">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Cargo install</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Install from crates.io.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>cargo install harbor</code>
</pre>
</div>
<div className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="2">
<div className="flex items-center gap-3">
<Wrench className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Manual build</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Build the CLI directly from the repo.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>cargo build -p cli --release</code>
</pre>
</div>
</section>
</main>
<footer className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-6 pb-10 text-xs text-muted-foreground">
<span>Harbor install</span>
<span>Choose Cargo or manual builds.</span>
</footer>
</div>
</div>
);
}
export default InstallApp;
+52
View File
@@ -0,0 +1,52 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+162
View File
@@ -0,0 +1,162 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+20
View File
@@ -0,0 +1,20 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
+80
View File
@@ -0,0 +1,80 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
@import "../styles/globals.css";
:root {
--font-body: "Inter", "Source Sans 3", sans-serif;
--font-display: "Inter", "Source Sans 3", sans-serif;
--harbor-ink: oklch(0.18 0.03 230);
--harbor-sea: oklch(0.46 0.12 215);
--harbor-sea-deep: oklch(0.34 0.1 230);
--harbor-sand: oklch(0.97 0.02 90);
--harbor-coral: oklch(0.75 0.12 45);
}
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply m-0 min-h-screen bg-background text-foreground antialiased;
font-family: var(--font-body);
letter-spacing: 0.01em;
}
}
.font-display {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
}
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.reveal[data-delay="1"] {
transition-delay: 120ms;
}
.reveal[data-delay="2"] {
transition-delay: 240ms;
}
.reveal[data-delay="3"] {
transition-delay: 360ms;
}
.float-slow {
animation: float 6s ease-in-out infinite;
}
.float-fast {
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
transition: none !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Harbor</title>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import { serve } from "bun";
import index from "./index.html";
import install from "./install.html";
const server = serve({
routes: {
"/*": index,
"/install": install,
"/install/": install,
},
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Install Harbor CLI</title>
<script type="module" src="./install.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
/**
* Install page entry point.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { InstallApp } from "./InstallApp";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<InstallApp />
</StrictMode>
);
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

+120
View File
@@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.75rem;
--background: oklch(0.9 0.04 222);
--foreground: oklch(0.18 0.03 230);
--card: oklch(0.98 0.015 220);
--card-foreground: oklch(0.18 0.03 230);
--popover: oklch(0.98 0.015 220);
--popover-foreground: oklch(0.18 0.03 230);
--primary: oklch(0.42 0.12 222);
--primary-foreground: oklch(0.98 0.02 220);
--secondary: oklch(0.92 0.03 220);
--secondary-foreground: oklch(0.26 0.03 230);
--muted: oklch(0.94 0.02 220);
--muted-foreground: oklch(0.46 0.03 230);
--accent: oklch(0.84 0.07 205);
--accent-foreground: oklch(0.2 0.03 230);
--destructive: oklch(0.58 0.22 25);
--border: oklch(0.84 0.03 220);
--input: oklch(0.9 0.02 220);
--ring: oklch(0.58 0.1 220);
--chart-1: oklch(0.6 0.14 220);
--chart-2: oklch(0.66 0.13 160);
--chart-3: oklch(0.55 0.1 45);
--chart-4: oklch(0.72 0.14 80);
--chart-5: oklch(0.62 0.16 20);
--sidebar: oklch(0.9 0.04 222);
--sidebar-foreground: oklch(0.18 0.03 230);
--sidebar-primary: oklch(0.42 0.12 222);
--sidebar-primary-foreground: oklch(0.98 0.02 220);
--sidebar-accent: oklch(0.92 0.03 220);
--sidebar-accent-foreground: oklch(0.26 0.03 230);
--sidebar-border: oklch(0.84 0.03 220);
--sidebar-ring: oklch(0.58 0.1 220);
}
.dark {
--background: oklch(0.2 0.03 230);
--foreground: oklch(0.95 0.015 90);
--card: oklch(0.23 0.03 230);
--card-foreground: oklch(0.95 0.015 90);
--popover: oklch(0.23 0.03 230);
--popover-foreground: oklch(0.95 0.015 90);
--primary: oklch(0.82 0.08 80);
--primary-foreground: oklch(0.2 0.03 230);
--secondary: oklch(0.3 0.03 230);
--secondary-foreground: oklch(0.95 0.015 90);
--muted: oklch(0.3 0.03 230);
--muted-foreground: oklch(0.74 0.02 90);
--accent: oklch(0.32 0.04 230);
--accent-foreground: oklch(0.95 0.015 90);
--destructive: oklch(0.62 0.2 25);
--border: oklch(0.95 0.015 90 / 12%);
--input: oklch(0.95 0.015 90 / 18%);
--ring: oklch(0.62 0.08 215);
--chart-1: oklch(0.62 0.14 220);
--chart-2: oklch(0.68 0.13 160);
--chart-3: oklch(0.72 0.12 70);
--chart-4: oklch(0.66 0.12 40);
--chart-5: oklch(0.64 0.16 20);
--sidebar: oklch(0.23 0.03 230);
--sidebar-foreground: oklch(0.95 0.015 90);
--sidebar-primary: oklch(0.72 0.1 200);
--sidebar-primary-foreground: oklch(0.2 0.03 230);
--sidebar-accent: oklch(0.3 0.03 230);
--sidebar-accent-foreground: oklch(0.95 0.015 90);
--sidebar-border: oklch(0.95 0.015 90 / 12%);
--sidebar-ring: oklch(0.62 0.08 215);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"types": ["bun", "react"],
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}