Compare commits

...

13 Commits

Author SHA1 Message Date
pure_sagacity 4285d0ae4a added website 2026-07-17 01:22:52 -05:00
pure_sagacity fac8a535de added a bun profile for devenv 2026-07-17 00:49:38 -05:00
pure_sagacity 34a046fb9a added the server with a health check
added .env.example, also removed the old empty config file
2026-07-16 11:49:24 -05:00
pure_sagacity c234d62d53 changed a spelling error 2026-07-16 11:29:11 -05:00
pure_sagacity 5d5b0ae89a switched to using the Store trait
also added another dependency for expanding ~ paths
2026-07-16 11:25:17 -05:00
pure_sagacity 767fb43ee2 fixed tests, and added the deletion and overwriting confirmation 2026-07-14 15:07:46 -05:00
pure_sagacity 72ee5a918b added color 2026-07-14 14:50:45 -05:00
pure_sagacity aab1d94388 bug fixes, but every cmd works! 2026-07-14 14:48:47 -05:00
pure_sagacity a850f70e3b Merge pull request 'added tests' (#1) from tests into main
Reviewed-on: #1
2026-07-14 04:44:46 +00:00
pure_sagacity be860b657a added tests 2026-07-13 23:44:24 -05:00
pure_sagacity 920f104c2e completed all commands, literally nothing tested. 2026-07-13 23:38:47 -05:00
pure_sagacity 8487a612eb forgot to commit, a lot of stuff happened 2026-07-13 23:25:45 -05:00
pure_sagacity 1da8ea923b added the deletion of projects 2026-07-13 20:27:27 -05:00
47 changed files with 3650 additions and 133 deletions
+4
View File
@@ -0,0 +1,4 @@
# Set it to the current working directory of the harbor project
DATABASE_URL=
# Set it to the current working directory of the harbor project (it will default to /var/lib/harbor if not)
HARBOR_DATA_DIR="/Users/Maaz/Documents/Git/harbor/config/"
+4
View File
@@ -8,6 +8,8 @@ devenv.local.yaml
# Nix
result
harbor.db
# direnv
.direnv
@@ -17,3 +19,5 @@ result
# Environment Variables
.env*
!.env.example
config/
+1 -1
View File
@@ -1,3 +1,3 @@
{
"cSpell.words": ["chrono"]
"cSpell.words": ["chrono", "ciphertext"]
}
Generated
+497 -7
View File
@@ -245,6 +245,61 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -349,7 +404,7 @@ dependencies = [
"cfg-if",
"cipher 0.5.2",
"cpufeatures 0.3.0",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -448,10 +503,12 @@ dependencies = [
"colored",
"crypto",
"diesel",
"dirs",
"dotenvy",
"hex",
"keyring",
"tokio",
"serde",
"toml",
"uuid",
]
@@ -530,7 +587,7 @@ name = "crypto"
version = "0.1.0"
dependencies = [
"chacha20poly1305",
"rand",
"rand 0.10.2",
]
[[package]]
@@ -551,7 +608,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"getrandom 0.4.3",
"hybrid-array",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -598,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"
@@ -651,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"
@@ -771,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"
@@ -807,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"
@@ -821,6 +929,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@@ -847,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"
@@ -855,8 +976,8 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
@@ -910,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"
@@ -919,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"
@@ -984,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"
@@ -1022,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"
@@ -1053,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"
@@ -1068,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"
@@ -1169,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"
@@ -1208,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"
@@ -1261,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"
@@ -1288,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"
@@ -1302,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]]
@@ -1320,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"
@@ -1378,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"
@@ -1456,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"
@@ -1467,6 +1792,46 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
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"
@@ -1551,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"
@@ -1642,6 +2013,33 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
@@ -1672,12 +2070,47 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
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",
@@ -1703,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"
@@ -1772,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"
@@ -1907,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"
@@ -1979,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"
+5 -3
View File
@@ -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,15 +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"] }
toml = "1.1.2"
hex = "0.4"
dirs = "6"
[[bin]]
name = "harbor"
@@ -10,7 +10,8 @@ CREATE TABLE secrets (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
project_id TEXT NOT NULL,
config TEXT NOT NULL,
config TEXT NOT NULL
CHECK (config IN ('dev', 'prod', 'staging')),
secret BLOB NOT NULL,
nonce BLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+81
View File
@@ -0,0 +1,81 @@
use crate::Environment;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
const MAIN_CONFIG_FILE: &str = ".harbor.toml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub name: String,
pub version: String,
pub default_env: Environment,
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Toml(toml::de::Error),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io(err) => write!(f, "IO error: {}", err),
ConfigError::Toml(err) => write!(f, "TOML error: {}", err),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::Io(err) => Some(err),
ConfigError::Toml(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for ConfigError {
fn from(err: std::io::Error) -> Self {
ConfigError::Io(err)
}
}
impl From<toml::de::Error> for ConfigError {
fn from(err: toml::de::Error) -> Self {
ConfigError::Toml(err)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct HarborToml {
#[serde(alias = "project")]
name: String,
version: String,
config: Environment,
}
impl Config {
pub fn from_repo_root(root: impl AsRef<Path>) -> Result<Self, ConfigError> {
let root = root.as_ref();
let main = Self::read_main_file(root.join(MAIN_CONFIG_FILE))?;
Ok(Config {
name: main.name,
version: main.version,
default_env: main.config,
})
}
pub fn read_main_file(path: impl AsRef<Path>) -> Result<HarborToml, ConfigError> {
let contents = fs::read_to_string(path)?;
let parsed: HarborToml = toml::from_str(&contents)?;
Ok(parsed)
}
pub fn merged_env_vars(&self, store_vars: &HashMap<String, String>) -> HashMap<String, String> {
store_vars.clone()
}
}
+7 -5
View File
@@ -1,14 +1,16 @@
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;
pub mod models;
pub mod schema;
pub fn establish_connection() -> SqliteConnection {
pub fn establish_connection(url: String) -> SqliteConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
let resolved_path = std::fs::canonicalize(&url)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(url);
SqliteConnection::establish(&resolved_path)
.unwrap_or_else(|_| panic!("Error connecting to {}", &resolved_path))
}
+2 -8
View File
@@ -1,3 +1,4 @@
use crate::Environment;
use crate::db::schema::{projects, secrets};
use chrono::NaiveDateTime;
use diesel::prelude::*;
@@ -11,13 +12,6 @@ pub struct Project {
pub created_at: NaiveDateTime,
}
#[derive(Insertable)]
#[diesel(table_name = projects)]
pub struct NewProject<'a> {
pub id: &'a str,
pub name: &'a str,
}
#[derive(Debug, Queryable, Selectable)]
#[diesel(table_name = secrets)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
@@ -25,7 +19,7 @@ pub struct Secret {
pub id: String,
pub name: String,
pub project_id: String,
pub config: String,
pub config: Environment,
pub secret: Vec<u8>,
pub nonce: Vec<u8>,
pub created_at: NaiveDateTime,
+151 -64
View File
@@ -1,11 +1,87 @@
use base64::{Engine, engine::general_purpose::STANDARD};
use clap::{Parser, Subcommand};
use crypto::helper::gen_key;
use diesel::backend::Backend;
use diesel::deserialize::{self, FromSql};
use diesel::serialize::{self, IsNull, Output, ToSql};
use diesel::sql_types::Text;
use diesel::sqlite::Sqlite;
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,
)]
#[diesel(sql_type = Text)]
#[serde(rename_all = "kebab-case")]
pub enum Environment {
Dev,
Prod,
Staging,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentParseError(String);
impl Environment {
pub const fn as_str(self) -> &'static str {
match self {
Environment::Dev => "dev",
Environment::Prod => "prod",
Environment::Staging => "staging",
}
}
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Environment {
type Err = EnvironmentParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"dev" => Ok(Environment::Dev),
"prod" => Ok(Environment::Prod),
"staging" => Ok(Environment::Staging),
_ => Err(EnvironmentParseError(
"Environment must be one of: dev, prod, staging".to_string(),
)),
}
}
}
impl std::fmt::Display for EnvironmentParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for EnvironmentParseError {}
impl ToSql<Text, Sqlite> for Environment {
fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Sqlite>) -> serialize::Result {
out.set_value(self.as_str());
Ok(IsNull::No)
}
}
impl FromSql<Text, Sqlite> for Environment {
fn from_sql(bytes: <Sqlite as Backend>::RawValue<'_>) -> deserialize::Result<Self> {
let raw = <String as FromSql<Text, Sqlite>>::from_sql(bytes)?;
raw.parse::<Environment>()
.map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)
}
}
#[derive(Parser)]
#[clap(
@@ -25,24 +101,79 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
Inject {
#[arg(short, long)]
verbose: bool,
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
after: Vec<String>,
},
Shell {},
Add {},
Shell {
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
#[arg(short = 's', long = "shell", default_value = "sh")]
shell: Option<String>,
#[arg(short = 'c', long = "command")]
command: Option<String>,
},
#[command(alias = "add")]
Set {
#[arg(short = 'e', long = "environment")]
environment: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
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 {
#[command(subcommand)]
command: ProjectCommands,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
}
pub fn parse_secret_pairs(raw: &[String]) -> Result<Vec<(String, String)>, Box<dyn Error>> {
let mut pairs = Vec::new();
for item in raw {
let trimmed = item.trim();
if trimmed.is_empty() {
continue;
}
let (key, value) = trimmed.split_once('=').ok_or_else(|| {
Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid secret format: {}", item),
)) as Box<dyn Error>
})?;
if key.trim().is_empty() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Secret key cannot be empty",
)));
}
pairs.push((key.to_string(), value.to_string()));
}
Ok(pairs)
}
#[derive(Subcommand)]
@@ -52,62 +183,14 @@ pub enum ProjectCommands {
Delete { name: String },
}
#[derive(Subcommand)]
pub enum ConfigCommands {
List {},
Create { project: String },
Delete { project: String, name: String },
}
pub mod interactions {
use super::db::models::{NewProject, Project};
use super::db::{
establish_connection,
schema::projects::dsl::{name as project_name, projects},
};
use diesel::result::{DatabaseErrorKind, Error as DieselError};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper};
use std::error::Error;
use uuid::Uuid;
fn construct_error(message: &str) -> Result<(), Box<dyn Error>> {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
message.to_string(),
)))
}
pub fn create_project(proj_name: &str) -> Result<(), Box<dyn Error>> {
let mut conn = establish_connection();
// 1. Check if the project already exists
let existing_project = projects
.filter(project_name.eq(proj_name))
.select(Project::as_select())
.first::<Project>(&mut conn)
.optional()?;
if let Some(_) = existing_project {
return construct_error("Project already exists");
}
let project_id = Uuid::new_v4().to_string();
let new_project = NewProject {
id: &project_id,
name: proj_name,
};
match diesel::insert_into(projects)
.values(&new_project)
.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();
}
}
path.to_string()
}
pub fn gen_or_get_key() -> Result<crypto::Key, Box<dyn Error>> {
@@ -135,7 +218,11 @@ pub fn gen_or_get_key() -> Result<crypto::Key, Box<dyn Error>> {
}
}
pub fn get_input(message: &str, prompt: char, new_line: bool) -> Result<String, Box<dyn Error>> {
pub fn get_input(
message: impl std::fmt::Display,
prompt: char,
new_line: bool,
) -> Result<String, Box<dyn Error>> {
let mut input = String::new();
if new_line {
+686 -49
View File
@@ -1,18 +1,33 @@
use std::collections::HashMap;
use std::process;
use clap::Parser;
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::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();
if cli.version {
let top_msg = format!("Harbor version {}", crate_version!().green()).bright_blue();
let top_msg = format!("Harbor version {}", crate_version!().green())
.bright_cyan()
.bold();
let bottom_msg =
format!("An open source secrets management and distribution platform.").blue();
let commit_msg = format!("Git commit {}", GIT_VERSION).dimmed();
@@ -21,66 +36,662 @@ fn main() {
process::exit(0);
}
match cli.command {
Some(c) => match c {
cli::Commands::Add {} => {
println!("Add command executed");
}
cli::Commands::Config { command } => match command {
cli::ConfigCommands::List {} => {
println!("Config list command executed");
}
cli::ConfigCommands::Create { project } => {
println!("Config create command executed for project: {}", project);
}
cli::ConfigCommands::Delete { project, name } => {
println!(
"Config delete command executed for project: {}, name: {}",
project, name
);
}
},
cli::Commands::Inject { verbose, after } => {
println!(
"Inject command executed with verbose: {}, after: {:?}",
verbose, after
);
// for harbor inject -- bun dev
// after = ["bun", "dev"]
}
cli::Commands::List {} => {
println!("List command executed");
}
cli::Commands::Project { command } => match command {
cli::ProjectCommands::List {} => {
println!("Project list command executed");
}
cli::ProjectCommands::Create {} => {
let project_name = match get_input("Project name", ':', false) {
Ok(name) => name,
Err(e) => {
eprintln!("Error getting project name: {}", e);
let root = match std::env::current_dir() {
Ok(path) => path,
Err(err) => {
print_error(format!("Error resolving current directory: {}", err));
process::exit(1);
}
};
match cli::interactions::create_project(&project_name) {
Ok(()) => println!("{}", "Project created successfully".blue()),
let db_path = expand_tilde("~/.local/share/harbor/database.db");
let config_path = root.join(".harbor.toml");
let has_config = config_path.exists();
let store = SqliteStore::new(db_path);
match cli.command {
Some(c) => match c {
_ if requires_config(&c) && !has_config => {
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) => {
eprintln!("Error creating project: {}", 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);
}
}
}
cli::ProjectCommands::Delete { name } => {
println!("Project delete command executed for name: {}", name);
Commands::Set { environment, vars } => {
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);
}
},
cli::Commands::Shell {} => {
println!("Shell command executed");
None => config.default_env.into(),
};
let pairs = match cli::parse_secret_pairs(&vars) {
Ok(pairs) => pairs,
Err(e) => {
print_error(format!("Error parsing secrets: {}", e));
process::exit(1);
}
};
if pairs.is_empty() {
print_error("No secrets provided. Use KEY=VALUE pairs.");
process::exit(1);
}
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);
}
};
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(_) => {
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) => {
print_error(format!(
"Error encrypting secret for key '{}': {}",
pair.0, e
));
process::exit(1);
}
};
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) => {
print_error(format!(
"Error setting secret for key '{}': {}",
pair.0, e
));
process::exit(1);
}
}
}
}
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 {
let trimmed = key.trim();
if !trimmed.is_empty() {
cleaned.push(trimmed.to_string());
}
}
// Redefining as immutable
let cleaned = cleaned;
if cleaned.is_empty() {
print_error("No secret keys provided. Use one or more keys.");
process::exit(1);
}
for key in cleaned {
match store.delete_secret(&proj_id, &key, environment) {
Ok(()) => {
println!("Deleted secret for key '{}'", key);
}
Err(e) => {
print_error(format!("Error deleting secret for key '{}': {}", key, e));
process::exit(1);
}
};
}
}
Commands::Inject { environment, after } => {
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);
}
};
// for harbor inject -- bun dev
// after = ["bun", "dev"]
let mut cmd = process::Command::new(&after[0]);
if after.len() > 1 {
cmd.args(&after[1..]);
}
let secrets = match store.get_project_secrets(&proj_id, environment) {
Ok(s) => s,
Err(e) => {
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
let mut uncrypted_envs: HashMap<String, String> = HashMap::new();
for (name, ciphertext, nonce) in secrets {
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 for key '{}': {}",
name, 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 for key '{}': {}",
name, e
));
process::exit(1);
}
};
uncrypted_envs.insert(name, decrypted_str);
}
for (key, value) in uncrypted_envs {
cmd.env(key, value);
}
if cmd.status().is_err() {
print_error(format!("Error waiting for command to finish: {:?}", after));
process::exit(1);
} else {
process::exit(0);
}
}
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 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(&proj_id, Environment::Dev) {
Ok(s) => s,
Err(e) => {
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
if secrets.is_empty() {
println!("No secrets found for project '{}'.", config.name);
process::exit(0);
}
println!(
"{}",
format!("Secrets for project '{}':", config.name)
.bright_cyan()
.bold()
);
for (name, _, _) in secrets {
println!(" - {}", name.blue().bold());
}
}
Commands::Project { command } => match command {
ProjectCommands::List {} => {
let projects = match store.get_projects() {
Ok(projects) => projects,
Err(e) => {
print_error(format!("Error getting projects: {}", e));
process::exit(1);
}
};
for project in projects {
println!(" - {}", project.name.blue().bold());
}
}
ProjectCommands::Create {} => {
let project_name = match get_input("Project name", ':', false) {
Ok(name) => name,
Err(e) => {
print_error(format!("Error getting project name: {}", e));
process::exit(1);
}
};
match store.create_project(&project_name) {
Ok(()) => {
println!("{}", "Project created successfully.".bright_green().bold())
}
Err(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 store.project_exists(&name) {
Ok(exists) => exists,
Err(e) => {
print_error(format!("Error checking if project exists: {}", e));
process::exit(1);
}
};
if !exists {
print_error(format!("Project '{}' does not exist.", name));
process::exit(1);
}
// Prompt for confirmation
let confirmation = match get_input(
format!(
"Are you sure you want to delete the project '{}'? (y/N)",
name
)
.red(),
':',
false,
) {
Ok(input) => input,
Err(e) => {
print_error(format!("Error getting confirmation: {}", e));
process::exit(1);
}
};
if confirmation.to_lowercase() == "y" {
match store.delete_project(&name) {
Ok(()) => {
println!(
"{}",
"Project deleted successfully.".bright_green().bold()
)
}
Err(e) => {
print_error(format!("Error deleting project: {}", e));
process::exit(1);
}
}
} else {
println!("Project deletion canceled.");
}
}
},
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(_) => {
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);
}
};
// for harbor inject -- bun dev
// after = ["bun", "dev"]
let shell = shell
.or_else(|| std::env::var("SHELL").ok())
.or_else(|| Some("/bin/sh".into()))
.unwrap();
let mut cmd = process::Command::new(&shell);
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) => {
print_error(format!("Error getting secrets: {}", e));
process::exit(1);
}
};
let mut uncrypted_envs: HashMap<String, String> = HashMap::new();
for (name, ciphertext, nonce) in secrets {
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 for key '{}': {}",
name, 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 for key '{}': {}",
name, e
));
process::exit(1);
}
};
uncrypted_envs.insert(name, decrypted_str);
}
for (key, value) in uncrypted_envs {
cmd.env(key, value);
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(_) => {
print_error(format!("Error executing command: {:?}", &shell));
process::exit(1);
}
};
if child.wait().is_err() {
print_error(format!("Error waiting for command to finish: {:?}", &shell));
process::exit(1);
} else {
process::exit(0);
}
}
Commands::Setup {} => {
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 store.get_projects() {
Ok(projects) => projects,
Err(e) => {
print_error(format!("Error getting projects: {}", e));
process::exit(1);
}
};
if projects.is_empty() {
print_error("No projects found. Please create a project first.");
process::exit(1);
}
// We'll just use fzf, and pipe the project names to it, and get the selected project name back.
let project_names: Vec<String> = projects.iter().map(|p| p.name.clone()).collect();
let mut fzf = match process::Command::new("fzf")
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
print_error(format!("Error spawning fzf: {}", e));
process::exit(1);
}
};
{
let mut stdin = fzf.stdin.take().expect("Failed to open stdin");
for name in &project_names {
use std::io::Write;
writeln!(stdin, "{}", name).expect("Failed to write to stdin");
}
drop(stdin);
}
let output = match fzf.wait_with_output() {
Ok(o) => o,
Err(e) => {
print_error(format!("Error waiting for fzf: {}", e));
process::exit(1);
}
};
if !output.status.success() {
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() {
print_error("No project selected.");
process::exit(1);
}
let new_config = format!(
r#"version = "1"
name = "{}"
config = "dev""#,
project
);
let config_path = root.join(".harbor.toml");
match std::fs::write(&config_path, new_config) {
Ok(_) => {
println!(
"{}",
format!("Created .harbor.toml for project '{}'.", project)
.bright_green()
.bold()
);
}
Err(e) => {
print_error(format!(
"Error creating .harbor.toml for project '{}': {}",
project, e
));
process::exit(1);
}
}
cli::Commands::Setup {} => {
println!("Setup command executed");
}
},
None => {
@@ -93,3 +704,29 @@ fn main() {
}
}
}
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 => {
print_error("Missing .harbor.toml. Run `harbor config create` first.");
process::exit(1);
}
Err(err) => {
print_error(format!("Error reading config: {}", err));
process::exit(1);
}
}
}
fn requires_config(command: &cli::Commands) -> bool {
matches!(
command,
cli::Commands::Inject { .. }
| cli::Commands::Show { .. }
| cli::Commands::Set { .. }
| cli::Commands::Delete { .. }
| cli::Commands::Shell { .. }
| cli::Commands::List { .. }
)
}
+32 -2
View File
@@ -3,5 +3,35 @@
// With this trait, we can implement other savers like Postgres, MySQL, etc.
// Or even third party services like Hashicorp Vault, AWS Secrets Manager, etc.
// For now, I'll just get an MVP in place.
pub trait Store {}
use std::error::Error;
pub mod sqlite;
use crate::{Environment, db::models::Project};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
pub trait Store {
fn project_exists(&self, proj_name: &str) -> Result<bool>;
fn create_project(&self, proj_name: &str) -> Result<()>;
fn delete_project(&self, proj_name: &str) -> Result<()>;
fn get_projects(&self) -> Result<Vec<Project>>;
fn get_project_id(&self, proj_name: &str) -> Result<String>;
fn secret_exists(&self, proj_id: &str, secret_name: &str, environment: Environment)
-> Result<bool>;
fn set_secret(
&self,
proj_id: &str,
secret_name: &str,
secret_value: Vec<u8>,
conf: Environment,
non: crypto::Nonce,
) -> Result<()>;
fn get_project_secrets(
&self,
proj_id: &str,
environment: Environment,
) -> Result<Vec<(String, Vec<u8>, Vec<u8>)>>;
fn delete_secret(&self, proj_id: &str, secret_name: &str, environment: Environment)
-> Result<()>;
}
+220
View File
@@ -0,0 +1,220 @@
use crate::db::models::Project;
use crate::store::Store;
use diesel::dsl::{insert_into, update};
use diesel::result::{DatabaseErrorKind, Error as DieselError};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper};
use uuid::Uuid;
pub struct SqliteStore {
database_url: String,
}
impl SqliteStore {
pub fn new(database_url: String) -> Self {
SqliteStore { database_url }
}
fn db_url(&self) -> String {
if self.database_url.is_empty() {
std::env::var("DATABASE_URL").expect("NO DATABASE URL WAS SET")
} else {
self.database_url.clone()
}
}
}
fn construct_error(message: &str) -> super::Result<()> {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
message.to_string(),
)))
}
impl Store for SqliteStore {
fn create_project(&self, proj_name: &str) -> super::Result<()> {
use crate::db::schema::projects::dsl::{created_at, id, name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
if self.project_exists(proj_name)? {
return construct_error("Project already exists");
}
let proj_id = Uuid::new_v4().to_string();
match insert_into(projects)
.values((
id.eq(proj_id),
name.eq(proj_name),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)
{
Ok(_) => Ok(()),
Err(DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
construct_error("Project already exists")
}
Err(err) => Err(Box::new(err)),
}
}
fn delete_project(&self, proj_name: &str) -> super::Result<()> {
use crate::db::schema::projects::dsl::{name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
if !self.project_exists(proj_name)? {
return construct_error("Project does not exist");
}
diesel::delete(projects.filter(name.eq(proj_name))).execute(&mut conn)?;
Ok(())
}
fn delete_secret(
&self,
proj_id: &str,
secret_name: &str,
environment: crate::Environment,
) -> super::Result<()> {
use crate::db::schema::secrets::dsl::{config, id, name, project_id, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_secret = secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment))
.select(id)
.first::<String>(&mut conn)
.optional()?;
if existing_secret.is_none() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Secret does not exist",
)));
}
diesel::delete(
secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment)),
)
.execute(&mut conn)?;
Ok(())
}
fn get_project_id(&self, proj_name: &str) -> super::Result<String> {
use crate::db::schema::projects::dsl::{id, name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
let project_id = projects
.filter(name.eq(proj_name))
.select(id)
.first::<String>(&mut conn)
.optional()?;
match project_id {
Some(pid) => Ok(pid),
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Project not found",
))),
}
}
fn get_project_secrets(
&self,
proj_id: &str,
environment: crate::Environment,
) -> super::Result<Vec<(String, Vec<u8>, Vec<u8>)>> {
use crate::db::schema::secrets::dsl::{config, name, nonce, project_id, secret, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let results = secrets
.filter(project_id.eq(proj_id))
.filter(config.eq(environment))
.select((name, secret, nonce))
.load::<(String, Vec<u8>, Vec<u8>)>(&mut conn)?;
Ok(results)
}
fn get_projects(&self) -> super::Result<Vec<Project>> {
use crate::db::schema::projects::dsl::projects;
let mut conn = crate::db::establish_connection(self.db_url());
let results = projects
.select(Project::as_select())
.load::<Project>(&mut conn)?;
Ok(results)
}
fn project_exists(&self, proj_name: &str) -> super::Result<bool> {
use crate::db::schema::projects::dsl::{name, projects};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_project = projects
.filter(name.eq(proj_name))
.select(Project::as_select())
.first::<Project>(&mut conn)
.optional()?;
Ok(existing_project.is_some())
}
fn secret_exists(
&self,
proj_id: &str,
secret_name: &str,
environment: crate::Environment,
) -> super::Result<bool> {
use crate::db::schema::secrets::dsl::{config, id, name, project_id, secrets};
let mut conn = crate::db::establish_connection(self.db_url());
let existing_secret = secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(environment))
.select(id)
.first::<String>(&mut conn)
.optional()?;
Ok(existing_secret.is_some())
}
fn set_secret(
&self,
proj_id: &str,
secret_name: &str,
secret_value: Vec<u8>,
conf: crate::Environment,
non: crypto::Nonce,
) -> super::Result<()> {
use crate::db::schema::secrets::dsl::{
config, created_at, id, name, nonce, project_id, secret, secrets,
};
let mut conn = crate::db::establish_connection(self.db_url());
if self.secret_exists(proj_id, secret_name, conf)? {
update(
secrets
.filter(project_id.eq(proj_id))
.filter(name.eq(secret_name))
.filter(config.eq(conf)),
)
.set((secret.eq(secret_value), nonce.eq(non.to_vec())))
.execute(&mut conn)?;
} else {
insert_into(secrets)
.values((
id.eq(Uuid::new_v4().to_string()),
name.eq(secret_name),
secret.eq(secret_value),
project_id.eq(proj_id),
config.eq(conf),
nonce.eq(non.to_vec()),
created_at.eq(chrono::Utc::now().naive_utc()),
))
.execute(&mut conn)?;
}
Ok(())
}
}
+218
View File
@@ -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);
}
+35
View File
@@ -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());
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.9", features = ["tokio", "ws"] }
tokio = { version = "1.52.3", features = ["full"] }
+36
View File
@@ -0,0 +1,36 @@
use std::path::PathBuf;
use axum::{Router, routing::get};
use tokio::net::TcpListener;
fn data_dir() -> PathBuf {
std::env::var("HARBOR_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/var/lib/harbor"))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(health_check));
let address = {
let port = std::env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.expect("PORT must be a valid u16");
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
format!("{}:{}", host, port)
};
let listener = TcpListener::bind(&address)
.await
.expect("Failed to bind to address");
println!("Server running on http://{}", address);
axum::serve(listener, app).await.unwrap();
}
async fn health_check() -> &'static str {
"OK"
}
+35
View File
@@ -1,4 +1,5 @@
{
config,
pkgs,
...
}:
@@ -12,6 +13,8 @@
sqlite
];
env.RUST_BACKTRACE = "1";
languages.rust = {
enable = true;
lsp.enable = true;
@@ -24,12 +27,44 @@
];
};
processes.server =
let
port = config.processes.server.ports.server.value;
in
{
ports.server.allocate = 8080;
exec = "PORT=${toString port} HOST=127.0.0.1 cargo watch -s' cargo run -p server'";
ready = {
http.get = {
port = port;
path = "/health";
host = "127.0.0.1";
scheme = "http";
};
initial_delay = 5;
period = 5;
probe_timeout = 3;
success_threshold = 1;
failure_threshold = 3;
};
};
dotenv.enable = true;
enterTest = ''
cargo test
'';
profiles.website.module = {
languages.javascript = {
enable = true;
bun = {
enable = true;
install.enable = true;
};
};
};
git-hooks.hooks = {
shellcheck.enable = true;
prettier.enable = true;
@@ -0,0 +1 @@
../../CLAUDE.md
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+111
View File
@@ -0,0 +1,111 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+21
View File
@@ -0,0 +1,21 @@
# bun-react-tailwind-shadcn-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.3.13. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+24
View File
@@ -0,0 +1,24 @@
import tailwind from "bun-plugin-tailwind";
import { rm } from "node:fs/promises";
import path from "node:path";
const outdir = path.join(process.cwd(), "dist");
await rm(outdir, { recursive: true, force: true });
const entrypoints = [...new Bun.Glob("src/**/*.html").scanSync()];
const result = await Bun.build({
entrypoints,
outdir,
plugins: [tailwind],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
});
for (const output of result.outputs) {
console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`);
}
+19
View File
@@ -0,0 +1,19 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.css" {}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}
+177
View File
@@ -0,0 +1,177 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4.1.11",
},
},
},
"packages": {
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="],
"@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="],
"@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="],
"@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="],
"@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="],
"@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.3.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"lucide-react": ["lucide-react@1.24.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
}
}
+3
View File
@@ -0,0 +1,3 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun src/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11"
}
}
+377
View File
@@ -0,0 +1,377 @@
import { useEffect } from "react";
import {
ArrowDown,
ArrowRight,
Cloud,
Key,
Layers,
Lock,
Package,
Server,
Settings,
Shield,
Terminal,
} from "lucide-react";
import "./index.css";
export function App() {
useEffect(() => {
const elements = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div className="min-h-screen text-foreground">
<div className="relative">
<header className="mx-auto flex max-w-6xl items-center justify-between px-6 pt-8" data-reveal>
<div className="reveal flex items-center gap-3" data-delay="1">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)]">
<Layers className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="font-display text-lg tracking-tight">Harbor</p>
<p className="text-[0.65rem] uppercase tracking-[0.32em] text-muted-foreground">Local-first secrets</p>
</div>
</div>
<nav className="hidden items-center gap-6 text-sm font-medium text-muted-foreground md:flex reveal" data-delay="2">
<a href="#local" className="transition hover:text-foreground">
Local
</a>
<a href="#server" className="transition hover:text-foreground">
Server
</a>
<a href="#install" className="transition hover:text-foreground">
Nix Flake
</a>
<a href="#capabilities" className="transition hover:text-foreground">
Capabilities
</a>
</nav>
<a
href="/install"
className="hidden rounded-full border border-foreground/15 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80 shadow-sm md:inline-flex reveal"
data-delay="3"
>
Install
</a>
</header>
<main className="mx-auto flex max-w-6xl flex-col gap-20 px-6 pb-24 pt-12">
<section className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-6" data-reveal>
<div className="reveal float-slow inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/70">
Local + server in sync
</div>
<h1 className="font-display reveal text-4xl leading-tight tracking-tight sm:text-5xl lg:text-6xl" data-delay="1">
Harbor keeps local config and team secrets in lockstep.
</h1>
<p className="reveal text-base text-muted-foreground sm:text-lg" data-delay="2">
Run the CLI locally to read configuration straight from your code. Sync only the projects you want, and
let the server manage secrets, configs, and shared updates for the whole team.
</p>
<div className="reveal flex flex-wrap gap-3" data-delay="3">
<a
href="/install"
className="rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)] transition hover:translate-y-[-1px]"
>
Install
</a>
<a
href="#capabilities"
className="rounded-full border border-foreground/15 bg-card/80 px-5 py-2.5 text-sm font-semibold text-foreground/80 shadow-sm transition hover:text-foreground"
>
View capabilities
</a>
</div>
</div>
<div className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-6 shadow-[0_40px_120px_-80px_rgba(16,24,40,0.6)]" data-reveal>
<div className="flex items-center justify-between">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Sync map</p>
<span className="rounded-full bg-foreground/5 px-3 py-1 text-xs text-foreground/70">Team-wide</span>
</div>
<ol className="mt-6 space-y-5 text-sm">
<li className="flex gap-3">
<Terminal className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Local reads the code</p>
<p className="text-muted-foreground">The CLI inspects configuration directly in your repo.</p>
</div>
</li>
<li className="flex gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Selective project sync</p>
<p className="text-muted-foreground">Only the projects you choose are sent to the server.</p>
</div>
</li>
<li className="flex gap-3">
<Server className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Server control plane</p>
<p className="text-muted-foreground">Manage secrets, configs, and shared updates.</p>
</div>
</li>
<li className="flex gap-3">
<Shield className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold text-foreground">Everyone stays aligned</p>
<p className="text-muted-foreground">Updates remain in sync across your entire team.</p>
</div>
</li>
</ol>
</div>
</section>
<section id="local" className="scroll-mt-24" data-reveal>
<div className="reveal grid gap-8 rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm lg:grid-cols-[1.1fr_0.9fr]">
<div>
<div className="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
<Terminal className="h-4 w-4 text-primary" aria-hidden="true" />
Local side
</div>
<h2 className="font-display mt-3 text-3xl">Local reads the code first.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Harbor runs next to your repo. Locally, you can just read the code, and the CLI does not require a
server to operate.
</p>
<div className="mt-6 space-y-4 text-sm">
<div className="flex items-start gap-3">
<Shield className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">No server required</p>
<p className="text-muted-foreground">Everything runs locally until you choose to sync.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Settings className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Understands your config</p>
<p className="text-muted-foreground">Reads configuration straight from your codebase.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Selective project sync</p>
<p className="text-muted-foreground">Choose exactly which projects get shared.</p>
</div>
</div>
</div>
<a
href="#server"
className="mt-6 inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-background px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80"
>
Switch to server
<ArrowDown className="h-4 w-4" aria-hidden="true" />
</a>
</div>
<div className="flex flex-col justify-between gap-6 rounded-2xl border border-foreground/10 bg-background/60 p-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Local workflow</p>
<h3 className="font-display mt-3 text-xl">CLI stays close to the repo.</h3>
<p className="mt-3 text-sm text-muted-foreground">
Read configuration where it lives, keep secrets local, and sync only when needed.
</p>
</div>
<div className="rounded-xl bg-foreground/5 p-4 text-xs text-foreground/70">
<p className="font-semibold text-foreground">Local-first cadence</p>
<p className="mt-2">Inspect config -> Select projects -> Sync to server</p>
</div>
</div>
</div>
</section>
<section id="server" className="scroll-mt-24" data-reveal>
<div className="reveal grid gap-8 rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm lg:grid-cols-[0.9fr_1.1fr]">
<div className="order-2 lg:order-1 flex flex-col justify-between gap-6 rounded-2xl border border-foreground/10 bg-background/60 p-5">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Server overview</p>
<h3 className="font-display mt-3 text-xl">Teamwide management hub.</h3>
<p className="mt-3 text-sm text-muted-foreground">
Use the local CLI to sync specific projects, then manage them on the server with full control.
</p>
</div>
<div className="rounded-xl bg-foreground/5 p-4 text-xs text-foreground/70">
<p className="font-semibold text-foreground">Server control</p>
<p className="mt-2">Edit secrets -> Create configs -> Sync to everyone</p>
</div>
</div>
<div className="order-1 lg:order-2">
<div className="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
<Server className="h-4 w-4 text-primary" aria-hidden="true" />
Server side
</div>
<h2 className="font-display mt-3 text-3xl">Manage secrets for the whole team.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Use the local CLI to sync selected projects. The server then manages those projects, including secrets
and configuration.
</p>
<div className="mt-6 space-y-4 text-sm">
<div className="flex items-start gap-3">
<Key className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Edit secrets and configs</p>
<p className="text-muted-foreground">Update values and create new configuration sets.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Lock className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Generate new secrets</p>
<p className="text-muted-foreground">Create fresh values whenever you need them.</p>
</div>
</div>
<div className="flex items-start gap-3">
<Cloud className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Stay in sync</p>
<p className="text-muted-foreground">Every update stays aligned across your entire team.</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="install" className="scroll-mt-24" data-reveal>
<div className="reveal flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Install</p>
<h2 className="font-display mt-3 text-3xl">Add Harbor as a flake input</h2>
</div>
<p className="max-w-md text-sm text-muted-foreground">
Keep the flake input on your repo, then use the install page for Cargo and manual builds.
</p>
</div>
<div className="reveal mt-8 rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="1">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-display text-xl">Nix flake input</h3>
</div>
<pre className="mt-4 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>{`inputs = {
harbor = {
url = "git+https://gitea.maariz.org/pure_sagacity/harbor.git";
inputs.nixpkgs.follows = "nixpkgs";
};
};`}</code>
</pre>
<div className="mt-5 flex flex-wrap items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground">Need Cargo or manual?</span>
<a href="/install" className="inline-flex items-center gap-2 font-semibold text-primary">
View other install methods
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
</section>
<section id="capabilities" className="scroll-mt-24" data-reveal>
<div className="reveal flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Capabilities</p>
<h2 className="font-display mt-3 text-3xl">What Harbor can do</h2>
</div>
<p className="max-w-md text-sm text-muted-foreground">
Local-first by design, with server-side controls that keep your team aligned.
</p>
</div>
<div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{[
{
title: "Read config from code",
description: "The CLI parses configuration directly from your repo.",
icon: Terminal,
},
{
title: "Project-level control",
description: "Sync only the projects you choose to share.",
icon: Package,
},
{
title: "Manage projects",
description: "Organize synced projects and environments.",
icon: Layers,
},
{
title: "Edit and update",
description: "Change secrets from the server and share updates.",
icon: Key,
},
{
title: "Create new configs",
description: "Build new configuration sets server-side.",
icon: Settings,
},
{
title: "Always aligned",
description: "Generate fresh secrets and keep every teammate in sync.",
icon: Shield,
},
].map((item, index) => {
const Icon = item.icon;
return (
<div
key={item.title}
className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-5 shadow-sm"
data-reveal
data-delay={index % 3 === 0 ? "1" : index % 3 === 1 ? "2" : "3"}
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-primary" aria-hidden="true" />
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Capability</p>
</div>
<h3 className="font-display mt-3 text-lg">{item.title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{item.description}</p>
</div>
);
})}
</div>
</section>
<section className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-8 shadow-sm" data-reveal>
<div className="max-w-2xl">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Teamwide consistency</p>
<h2 className="font-display mt-3 text-3xl">Every update stays in sync across your entire team.</h2>
<p className="mt-3 text-sm text-muted-foreground">
Sync a project once, then manage secrets and configs on the server. Harbor keeps local and server states
aligned for everyone using the CLI.
</p>
<div className="mt-5 flex flex-wrap gap-3 text-xs font-semibold uppercase tracking-[0.24em] text-foreground/70">
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Local-first</span>
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Selective sync</span>
<span className="rounded-full border border-foreground/15 bg-background px-3 py-1">Shared secrets</span>
</div>
</div>
</section>
</main>
<footer className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-6 pb-10 text-xs text-muted-foreground">
<span>Harbor CLI + Server</span>
<span>Local-first secrets and configuration sync.</span>
</footer>
</div>
</div>
);
}
export default App;
+150
View File
@@ -0,0 +1,150 @@
import { useEffect } from "react";
import { ArrowLeft, ArrowRight, Layers, Package, Terminal, Wrench } from "lucide-react";
import "./index.css";
export function InstallApp() {
useEffect(() => {
const elements = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.2 }
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
return (
<div className="min-h-screen text-foreground">
<div className="relative">
<header className="mx-auto flex max-w-6xl items-center justify-between px-6 pt-8" data-reveal>
<a href="/" className="reveal flex items-center gap-3" data-delay="1">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-primary text-primary-foreground shadow-[0_12px_30px_-16px_rgba(16,24,40,0.6)]">
<Layers className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<p className="font-display text-lg tracking-tight">Harbor</p>
<p className="text-[0.65rem] uppercase tracking-[0.32em] text-muted-foreground">Install</p>
</div>
</a>
<a
href="/"
className="hidden items-center gap-2 rounded-full border border-foreground/15 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80 shadow-sm md:inline-flex reveal"
data-delay="2"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Back to home
</a>
</header>
<main className="mx-auto flex max-w-6xl flex-col gap-16 px-6 pb-24 pt-12">
<section className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]" data-reveal>
<div className="reveal space-y-6">
<div className="float-slow inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-card/80 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/70">
All install methods
</div>
<h1 className="font-display text-4xl leading-tight tracking-tight sm:text-5xl lg:text-6xl">
Install Harbor locally, the way your team prefers.
</h1>
<p className="text-base text-muted-foreground sm:text-lg">
Pick Cargo for a quick install or build manually with the Rust toolchain. The Nix flake input lives on
the main landing page.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/"
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-background px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-foreground/80"
>
View flake input
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</a>
</div>
</div>
<div className="reveal rounded-3xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="2">
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground">Install checklist</p>
<ul className="mt-5 space-y-4 text-sm">
<li className="flex items-start gap-3">
<Terminal className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Rust toolchain</p>
<p className="text-muted-foreground">Ensure Rust is installed before running commands.</p>
</div>
</li>
<li className="flex items-start gap-3">
<Package className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Cargo install</p>
<p className="text-muted-foreground">Grab the CLI directly from crates.io.</p>
</div>
</li>
<li className="flex items-start gap-3">
<Wrench className="mt-1 h-4 w-4 text-primary" aria-hidden="true" />
<div>
<p className="font-semibold">Manual build</p>
<p className="text-muted-foreground">Build from source with one command.</p>
</div>
</li>
</ul>
</div>
</section>
<section className="rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm reveal" data-reveal>
<div className="flex items-center gap-3">
<Layers className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Nix flake input</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Add Harbor as a flake input in your repo.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>{`inputs = {
harbor = {
url = "git+https://gitea.maariz.org/pure_sagacity/harbor.git";
inputs.nixpkgs.follows = "nixpkgs";
};
};`}</code>
</pre>
</section>
<section className="grid gap-6 md:grid-cols-2" data-reveal>
<div className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="1">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Cargo install</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Install from crates.io.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>cargo install harbor</code>
</pre>
</div>
<div className="reveal rounded-2xl border border-foreground/10 bg-card/80 p-6 shadow-sm" data-delay="2">
<div className="flex items-center gap-3">
<Wrench className="h-5 w-5 text-primary" aria-hidden="true" />
<h2 className="font-display text-2xl">Manual build</h2>
</div>
<p className="mt-3 text-sm text-muted-foreground">Build the CLI directly from the repo.</p>
<pre className="mt-5 rounded-xl bg-foreground/5 p-4 text-xs text-foreground/80">
<code>cargo build -p cli --release</code>
</pre>
</div>
</section>
</main>
<footer className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-6 pb-10 text-xs text-muted-foreground">
<span>Harbor install</span>
<span>Choose Cargo or manual builds.</span>
</footer>
</div>
</div>
);
}
export default InstallApp;
+52
View File
@@ -0,0 +1,52 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+162
View File
@@ -0,0 +1,162 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+20
View File
@@ -0,0 +1,20 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
+80
View File
@@ -0,0 +1,80 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
@import "../styles/globals.css";
:root {
--font-body: "Inter", "Source Sans 3", sans-serif;
--font-display: "Inter", "Source Sans 3", sans-serif;
--harbor-ink: oklch(0.18 0.03 230);
--harbor-sea: oklch(0.46 0.12 215);
--harbor-sea-deep: oklch(0.34 0.1 230);
--harbor-sand: oklch(0.97 0.02 90);
--harbor-coral: oklch(0.75 0.12 45);
}
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply m-0 min-h-screen bg-background text-foreground antialiased;
font-family: var(--font-body);
letter-spacing: 0.01em;
}
}
.font-display {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
}
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 0.7s ease, transform 0.7s ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.reveal[data-delay="1"] {
transition-delay: 120ms;
}
.reveal[data-delay="2"] {
transition-delay: 240ms;
}
.reveal[data-delay="3"] {
transition-delay: 360ms;
}
.float-slow {
animation: float 6s ease-in-out infinite;
}
.float-fast {
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
transition: none !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Harbor</title>
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import { serve } from "bun";
import index from "./index.html";
import install from "./install.html";
const server = serve({
routes: {
"/*": index,
"/install": install,
"/install/": install,
},
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>Install Harbor CLI</title>
<script type="module" src="./install.tsx" async></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
/**
* Install page entry point.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { InstallApp } from "./InstallApp";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<InstallApp />
</StrictMode>
);
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1
View File
@@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 338 B

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