generated from pure_sagacity/rust-starter
Compare commits
10 Commits
920f104c2e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
4285d0ae4a
|
|||
|
fac8a535de
|
|||
|
34a046fb9a
|
|||
|
c234d62d53
|
|||
|
5d5b0ae89a
|
|||
|
767fb43ee2
|
|||
|
72ee5a918b
|
|||
|
aab1d94388
|
|||
| a850f70e3b | |||
|
be860b657a
|
@@ -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/"
|
||||
@@ -19,3 +19,5 @@ harbor.db
|
||||
# Environment Variables
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
config/
|
||||
Generated
+465
-7
@@ -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"
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "cli"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
authors = ["idle_river <khokharmaaz@gmail.com>"]
|
||||
description = "An opensource secrets management and distribution platform"
|
||||
description = "An open source secrets management and distribution platform"
|
||||
license = "GLP-3.0"
|
||||
|
||||
[dependencies]
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+20
-196
@@ -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");
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
pub fn gen_or_get_key() -> Result<crypto::Key, Box<dyn Error>> {
|
||||
let entry = Entry::new("harbor", "encryption-key")?;
|
||||
|
||||
|
||||
+268
-93
@@ -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 { .. }
|
||||
|
||||
@@ -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<()>;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use cli::Environment;
|
||||
use cli::config::Config;
|
||||
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;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn test_lock() -> &'static Mutex<()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn setup_test_db() -> PathBuf {
|
||||
let db_path = std::env::temp_dir().join(format!("harbor_test_{}.db", Uuid::new_v4()));
|
||||
std::fs::File::create(&db_path).expect("create sqlite file");
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("DATABASE_URL", &db_path);
|
||||
}
|
||||
|
||||
let database_url = db_path.to_str().expect("db path");
|
||||
let mut conn = SqliteConnection::establish(database_url).expect("connect sqlite");
|
||||
conn.batch_execute(
|
||||
"PRAGMA foreign_keys = ON;\
|
||||
CREATE TABLE projects (\
|
||||
id TEXT PRIMARY KEY NOT NULL,\
|
||||
name TEXT NOT NULL UNIQUE,\
|
||||
created_at TIMESTAMP NOT NULL\
|
||||
);\
|
||||
CREATE TABLE secrets (\
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(16)))),\
|
||||
name TEXT NOT NULL,\
|
||||
project_id TEXT NOT NULL,\
|
||||
config TEXT NOT NULL,\
|
||||
secret BLOB NOT NULL,\
|
||||
nonce BLOB NOT NULL,\
|
||||
created_at TIMESTAMP NOT NULL,\
|
||||
FOREIGN KEY(project_id) REFERENCES projects(id)\
|
||||
);",
|
||||
)
|
||||
.expect("create schema");
|
||||
|
||||
db_path
|
||||
}
|
||||
|
||||
fn cleanup_test_db(path: PathBuf) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_parsing_and_display() {
|
||||
let dev: Environment = "DEV".parse().expect("parse dev");
|
||||
let staging: Environment = " staging ".parse().expect("parse staging");
|
||||
|
||||
assert_eq!(dev, Environment::Dev);
|
||||
assert_eq!(staging, Environment::Staging);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_secret_pairs_handles_valid_and_invalid_input() {
|
||||
let input = vec![
|
||||
" FOO=bar ".to_string(),
|
||||
"".to_string(),
|
||||
"BAZ=qux".to_string(),
|
||||
"A=B=C".to_string(),
|
||||
];
|
||||
|
||||
let parsed = parse_secret_pairs(&input).expect("parse secret pairs");
|
||||
assert_eq!(parsed.len(), 3);
|
||||
assert_eq!(parsed[0], ("FOO".to_string(), "bar".to_string()));
|
||||
assert_eq!(parsed[1], ("BAZ".to_string(), "qux".to_string()));
|
||||
assert_eq!(parsed[2], ("A".to_string(), "B=C".to_string()));
|
||||
|
||||
let err = parse_secret_pairs(&["NOPE".to_string()]).unwrap_err();
|
||||
assert_eq!(err.to_string(), "Invalid secret format: NOPE");
|
||||
|
||||
let err = parse_secret_pairs(&["=value".to_string()]).unwrap_err();
|
||||
assert_eq!(err.to_string(), "Secret key cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_reads_valid_file_and_reports_errors() {
|
||||
let temp_root = std::env::temp_dir().join(format!("harbor_config_{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&temp_root).expect("create temp dir");
|
||||
|
||||
let config_path = temp_root.join(".harbor.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
"version = \"1\"\nname = \"proj\"\nconfig = \"dev\"\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = Config::from_repo_root(&temp_root).expect("read config");
|
||||
assert_eq!(config.name, "proj");
|
||||
assert_eq!(config.version, "1");
|
||||
assert_eq!(config.default_env, Environment::Dev);
|
||||
|
||||
let invalid_path = temp_root.join("bad.toml");
|
||||
std::fs::write(&invalid_path, "not = [valid").expect("write invalid");
|
||||
let err = Config::read_main_file(&invalid_path).unwrap_err();
|
||||
assert!(err.to_string().contains("TOML error"));
|
||||
|
||||
let missing_path = temp_root.join("missing.toml");
|
||||
let err = Config::read_main_file(&missing_path).unwrap_err();
|
||||
assert!(err.to_string().contains("IO error"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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!(!store.project_exists("alpha").expect("project exists"));
|
||||
|
||||
store.create_project("alpha").expect("create project");
|
||||
assert!(store.project_exists("alpha").expect("project exists"));
|
||||
|
||||
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!(
|
||||
!store
|
||||
.secret_exists(&project_id, "API_KEY", Environment::Dev)
|
||||
.expect("secret exists")
|
||||
);
|
||||
|
||||
let nonce = crypto::helper::gen_nonce();
|
||||
store
|
||||
.set_secret(
|
||||
&project_id,
|
||||
"API_KEY",
|
||||
b"first".to_vec(),
|
||||
Environment::Dev,
|
||||
nonce,
|
||||
)
|
||||
.expect("set secret");
|
||||
|
||||
assert!(
|
||||
store
|
||||
.secret_exists(&project_id, "API_KEY", Environment::Dev)
|
||||
.expect("secret exists")
|
||||
);
|
||||
|
||||
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();
|
||||
store
|
||||
.set_secret(
|
||||
&project_id,
|
||||
"API_KEY",
|
||||
b"second".to_vec(),
|
||||
Environment::Dev,
|
||||
nonce,
|
||||
)
|
||||
.expect("update secret");
|
||||
|
||||
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());
|
||||
|
||||
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")
|
||||
);
|
||||
|
||||
store.delete_project("alpha").expect("delete project");
|
||||
assert!(!store.project_exists("alpha").expect("project exists"));
|
||||
|
||||
cleanup_test_db(db_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 = store.delete_project("missing").unwrap_err();
|
||||
assert_eq!(err.to_string(), "Project does not exist");
|
||||
|
||||
let err = store.get_project_id("missing").unwrap_err();
|
||||
assert_eq!(err.to_string(), "Project not found");
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -63,4 +63,39 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_generates_expected_sizes() {
|
||||
let key = helper::gen_key();
|
||||
let nonce = helper::gen_nonce();
|
||||
|
||||
assert_eq!(key.as_slice().len(), 32);
|
||||
assert_eq!(nonce.as_slice().len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_from_validates_length() {
|
||||
let key = helper::key_from(vec![7u8; 32]).expect("expected valid key");
|
||||
assert_eq!(key.as_slice(), vec![7u8; 32]);
|
||||
|
||||
let err = helper::key_from(vec![1u8; 31]).unwrap_err();
|
||||
assert_eq!(err.to_string(), "key must be exactly 32 bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_fails_with_wrong_key_or_tampered_ciphertext() {
|
||||
let key = helper::gen_key();
|
||||
let other_key = helper::gen_key();
|
||||
let plaintext = b"secret".to_vec();
|
||||
|
||||
let (nonce, mut ciphertext) = encrypt(&key, plaintext.clone()).expect("encrypt");
|
||||
|
||||
assert!(decrypt(&other_key, ciphertext.clone(), &nonce).is_err());
|
||||
|
||||
if let Some(byte) = ciphertext.get_mut(0) {
|
||||
*byte ^= 0b1010_1010;
|
||||
}
|
||||
|
||||
assert!(decrypt(&key, ciphertext, &nonce).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../CLAUDE.md
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`);
|
||||
}
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[serve.static]
|
||||
plugins = ["bun-plugin-tailwind"]
|
||||
env = "BUN_PUBLIC_*"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user