diff --git a/Cargo.lock b/Cargo.lock index b53de5d6026..7f3d803d5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.7.8" @@ -179,6 +190,9 @@ name = "arbitrary" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arrayref" @@ -594,6 +608,27 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "camino" version = "1.1.9" @@ -623,7 +658,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -703,6 +738,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "3.2.23" @@ -837,15 +882,15 @@ dependencies = [ [[package]] name = "console" -version = "0.15.8" +version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", - "lazy_static", "libc", - "unicode-width", - "windows-sys 0.52.0", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", ] [[package]] @@ -1035,6 +1080,21 @@ dependencies = [ "wasmtime-types", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32c" version = "0.6.8" @@ -1182,7 +1242,7 @@ dependencies = [ "log", "signal-hook", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1201,7 +1261,7 @@ dependencies = [ "owning_ref", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "xi-unicode", ] @@ -1303,6 +1363,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "deranged" version = "0.3.11" @@ -1313,6 +1379,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "derive_more" version = "0.99.18" @@ -1326,6 +1403,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror 1.0.69", +] + [[package]] name = "diff" version = "0.1.13" @@ -1449,9 +1537,9 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" @@ -2519,14 +2607,15 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.16.2" +version = "0.17.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" dependencies = [ "console", - "lazy_static", "number_prefix", - "regex", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", ] [[package]] @@ -2549,6 +2638,15 @@ dependencies = [ "str_stack", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.41.1" @@ -2639,7 +2737,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror", + "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] @@ -2668,6 +2766,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "junction" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72bbdfd737a243da3dfc1f99ee8d6e166480f17ab4ac84d7c34aacd73fc7bd16" +dependencies = [ + "scopeguard", + "windows-sys 0.52.0", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2772,6 +2880,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.22" @@ -2791,6 +2905,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "mach2" version = "0.4.2" @@ -3238,7 +3362,7 @@ checksum = "a2ccbe15f2b6db62f9a9871642746427e297b0ceb85f9a7f1ee5ff47d184d0c8" dependencies = [ "bytecount", "fnv", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -3295,6 +3419,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.4" @@ -3437,6 +3571,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + [[package]] name = "postcard" version = "1.0.10" @@ -3571,7 +3711,7 @@ dependencies = [ "memchr", "parking_lot 0.12.3", "protobuf", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -3656,7 +3796,7 @@ dependencies = [ "indexmap 2.6.0", "quick-xml 0.31.0", "strip-ansi-escapes", - "thiserror", + "thiserror 1.0.69", "uuid", ] @@ -3842,7 +3982,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4228,7 +4368,7 @@ dependencies = [ "radix_trie", "scopeguard", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", "utf8parse", "winapi", ] @@ -4503,6 +4643,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -4539,6 +4685,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simdutf8" version = "0.1.5" @@ -4559,7 +4711,7 @@ checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -4754,7 +4906,7 @@ dependencies = [ "tar", "tempfile", "termcolor", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-tungstenite", "toml 0.8.19", @@ -4832,7 +4984,7 @@ dependencies = [ "spacetimedb-primitives", "spacetimedb-sats", "strum", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4855,7 +5007,7 @@ dependencies = [ "spacetimedb-primitives", "spacetimedb-sats", "tempfile", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -4948,7 +5100,7 @@ dependencies = [ "strum", "tempfile", "thin-vec", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tokio-util", @@ -4976,7 +5128,7 @@ dependencies = [ "nohash-hasher", "serde", "smallvec", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5020,7 +5172,7 @@ dependencies = [ "spacetimedb-sats", "spacetimedb-schema", "spacetimedb-sql-parser", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5031,7 +5183,7 @@ dependencies = [ "hex", "rand 0.8.5", "tempdir", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5059,7 +5211,7 @@ dependencies = [ "reqwest 0.11.27", "serde", "spacetimedb-jsonwebtoken", - "thiserror", + "thiserror 1.0.69", "tokio", ] @@ -5088,7 +5240,7 @@ dependencies = [ "spacetimedb-metrics", "spacetimedb-primitives", "spacetimedb-sats", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5110,10 +5262,10 @@ dependencies = [ "dirs", "fs2", "itoa", - "semver", + "junction", "serde", "tempfile", - "thiserror", + "thiserror 1.0.69", "xdg", ] @@ -5195,7 +5347,7 @@ dependencies = [ "spacetimedb-bindings-macro", "spacetimedb-metrics", "spacetimedb-primitives", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5221,7 +5373,7 @@ dependencies = [ "spacetimedb-sats", "spacetimedb-sql-parser", "spacetimedb-testing", - "thiserror", + "thiserror 1.0.69", "unicode-ident", "unicode-normalization", ] @@ -5247,7 +5399,7 @@ dependencies = [ "spacetimedb-lib", "spacetimedb-sats", "spacetimedb-testing", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-tungstenite", ] @@ -5266,7 +5418,7 @@ dependencies = [ "spacetimedb-primitives", "spacetimedb-sats", "spacetimedb-table", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5275,7 +5427,7 @@ version = "1.0.0-rc4" dependencies = [ "derive_more", "sqlparser", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5304,7 +5456,7 @@ dependencies = [ "spacetimedb-lib", "spacetimedb-paths", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "toml 0.8.19", "tower-http", @@ -5345,7 +5497,7 @@ dependencies = [ "spacetimedb-primitives", "spacetimedb-sats", "spacetimedb-schema", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -5379,11 +5531,22 @@ name = "spacetimedb-update" version = "1.0.0-rc4" dependencies = [ "anyhow", + "bytes", "clap 4.5.20", + "dialoguer", + "flate2", + "http-body-util", + "indicatif", + "reqwest 0.12.9", "semver", + "serde", "spacetimedb-paths", + "tar", + "tokio", + "toml 0.8.19", "tracing", "windows-sys 0.59.0", + "zip", ] [[package]] @@ -5404,7 +5567,7 @@ dependencies = [ "spacetimedb-schema", "spacetimedb-table", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", "typed-arena", ] @@ -5441,7 +5604,7 @@ dependencies = [ "similar", "subst", "tempfile", - "thiserror", + "thiserror 1.0.69", "tracing", ] @@ -5462,7 +5625,7 @@ dependencies = [ "serde", "serde_json", "sqllogictest", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-postgres", "tokio-util", @@ -5585,7 +5748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a3c1ba4fd019bc866333a61fe205fc9b686e3cf5971dd8dfc116657d933031c" dependencies = [ "memchr", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -5671,7 +5834,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror", + "thiserror 1.0.69", "walkdir", "yaml-rust", ] @@ -5726,7 +5889,7 @@ checksum = "dfe9c3632da101aba5131ed63f9eed38665f8b3c68703a6bb18124835c1a5d22" dependencies = [ "papergrid", "tabled_derive", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -5850,7 +6013,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", ] [[package]] @@ -5864,6 +6036,17 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "thread_local" version = "1.1.8" @@ -6173,7 +6356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 1.0.69", "time", "tracing-subscriber", ] @@ -6317,7 +6500,7 @@ dependencies = [ "native-tls", "rand 0.8.5", "sha1", - "thiserror", + "thiserror 1.0.69", "url", "utf-8", ] @@ -6379,6 +6562,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -6640,7 +6829,7 @@ dependencies = [ "custom_debug", "leb128", "once_cell", - "thiserror", + "thiserror 1.0.69", "wasmbin-derive", ] @@ -6654,7 +6843,7 @@ dependencies = [ "quote", "syn 2.0.87", "synstructure 0.13.1", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -6795,7 +6984,7 @@ dependencies = [ "object", "smallvec", "target-lexicon", - "thiserror", + "thiserror 1.0.69", "wasmparser", "wasmtime-environ", "wasmtime-versioned-export-macros", @@ -6891,6 +7080,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webbrowser" version = "1.0.2" @@ -7428,6 +7627,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] [[package]] name = "zerovec" @@ -7451,6 +7664,49 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "hmac", + "indexmap 2.6.0", + "lzma-rs", + "memchr", + "pbkdf2", + "rand 0.8.5", + "sha1", + "thiserror 2.0.11", + "time", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.2" diff --git a/Cargo.toml b/Cargo.toml index 91076872602..441ba36c15c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ members = [ "crates/sdk/tests/connect_disconnect_client", "tools/upgrade-version", ] -default-members = ["crates/cli"] +default-members = ["crates/cli", "crates/standalone", "crates/update"] # cargo feature graph resolver. v3 is default in edition2024 but workspace # manifests don't have editions. resolver = "3" @@ -149,6 +149,7 @@ crossbeam-channel = "0.5" cursive = { version = "0.20", default-features = false, features = ["crossterm-backend"] } decorum = { version = "0.3.1", default-features = false, features = ["std"] } derive_more = "0.99" +dialoguer = { version = "0.11", default-features = false } dirs = "5.0.1" duct = "0.13.5" either = "1.9" @@ -176,12 +177,13 @@ hyper = "1.0" hyper-util = { version = "0.1", features = ["tokio"] } imara-diff = "0.1.3" indexmap = "2.0.0" -indicatif = "0.16" +indicatif = "0.17" insta = { version = "1.21.0", features = ["toml"] } is-terminal = "0.4" itertools = "0.12" itoa = "1" jsonwebtoken = { package = "spacetimedb-jsonwebtoken", version = "9.3.0" } +junction = "1" lazy_static = "1.4.0" log = "0.4.17" memchr = "2" diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 2e00b8d3959..a836c01e464 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -7,19 +7,19 @@ mod errors; mod subcommands; mod tasks; pub mod util; +pub mod version; use std::process::ExitCode; use clap::{ArgMatches, Command}; pub use config::Config; -use spacetimedb_paths::SpacetimePaths; +use spacetimedb_paths::{RootDir, SpacetimePaths}; pub use subcommands::*; pub use tasks::build; pub fn get_subcommands() -> Vec { vec![ - version::cli(), publish::cli(), delete::cli(), logs::cli(), @@ -35,20 +35,20 @@ pub fn get_subcommands() -> Vec { init::cli(), build::cli(), server::cli(), - upgrade::cli(), subscribe::cli(), start::cli(), + subcommands::version::cli(), ] } pub async fn exec_subcommand( config: Config, paths: &SpacetimePaths, + root_dir: Option<&RootDir>, cmd: &str, args: &ArgMatches, ) -> anyhow::Result { match cmd { - "version" => version::exec(config, args).await, "call" => call::exec(config, args).await, "describe" => describe::exec(config, args).await, "energy" => energy::exec(config, args).await, @@ -66,7 +66,7 @@ pub async fn exec_subcommand( "start" => return start::exec(paths, args).await, "login" => login::exec(config, args).await, "logout" => logout::exec(config, args).await, - "upgrade" => upgrade::exec(config, args).await, + "version" => return subcommands::version::exec(paths, root_dir, args).await, unknown => Err(anyhow::anyhow!("Invalid subcommand: {}", unknown)), } .map(|()| ExitCode::SUCCESS) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8cac03c14f3..4fcdfb633ba 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -17,7 +17,8 @@ async fn main() -> anyhow::Result { let matches = get_command().get_matches(); let (cmd, subcommand_args) = matches.subcommand().unwrap(); - let paths = match matches.get_one::("root_dir") { + let root_dir = matches.get_one::("root_dir"); + let paths = match root_dir { Some(dir) => SpacetimePaths::from_root_dir(dir), None => SpacetimePaths::platform_defaults()?, }; @@ -27,11 +28,13 @@ async fn main() -> anyhow::Result { .unwrap_or_else(|| paths.cli_config_dir.cli_toml()); let config = Config::load(cli_toml)?; - exec_subcommand(config, &paths, cmd, subcommand_args).await + exec_subcommand(config, &paths, root_dir, cmd, subcommand_args).await } fn get_command() -> Command { Command::new("spacetime") + .version(version::CLI_VERSION) + .long_version(version::long_version()) .arg_required_else_help(true) .subcommand_required(true) .arg( diff --git a/crates/cli/src/subcommands/generate/mod.rs b/crates/cli/src/subcommands/generate/mod.rs index 1e205a7cec5..79693fc55ea 100644 --- a/crates/cli/src/subcommands/generate/mod.rs +++ b/crates/cli/src/subcommands/generate/mod.rs @@ -126,7 +126,7 @@ pub async fn exec(config: Config, args: &clap::ArgMatches) -> anyhow::Result<()> build::exec_with_argstring(config.clone(), project_path, build_options).await? }; let spinner = indicatif::ProgressBar::new_spinner(); - spinner.enable_steady_tick(60); + spinner.enable_steady_tick(std::time::Duration::from_millis(60)); spinner.set_message("Compiling wasm..."); let module = compile_wasm(&wasm_path)?; spinner.set_message("Extracting schema from wasm..."); diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index dfc56c4e0ed..debdac865e3 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -16,5 +16,4 @@ pub mod server; pub mod sql; pub mod start; pub mod subscribe; -pub mod upgrade; pub mod version; diff --git a/crates/cli/src/subcommands/start.rs b/crates/cli/src/subcommands/start.rs index 44e32228b5b..6fb93549895 100644 --- a/crates/cli/src/subcommands/start.rs +++ b/crates/cli/src/subcommands/start.rs @@ -79,7 +79,7 @@ pub async fn exec(paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result io::Result { +pub(crate) fn exec_replace(cmd: &mut Command) -> io::Result { #[cfg(unix)] { use std::os::unix::process::CommandExt; diff --git a/crates/cli/src/subcommands/upgrade.rs b/crates/cli/src/subcommands/upgrade.rs deleted file mode 100644 index 22c5bbbab63..00000000000 --- a/crates/cli/src/subcommands/upgrade.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::io::Write; -use std::{env, fs}; - -extern crate regex; - -use crate::util::y_or_n; -use crate::{common_args, version, Config}; -use clap::{Arg, ArgMatches}; -use flate2::read::GzDecoder; -use futures::stream::StreamExt; -use indicatif::{ProgressBar, ProgressStyle}; -use regex::Regex; -use serde::Deserialize; -use serde_json::Value; -use std::path::Path; -use tar::Archive; - -pub fn cli() -> clap::Command { - clap::Command::new("upgrade") - .about("Checks for updates for the currently running spacetime CLI tool") - .arg(Arg::new("version").help("The specific version to upgrade to")) - .arg(common_args::yes()) - .after_help("Run `spacetime help upgrade` for more detailed information.\n") -} - -#[derive(Deserialize)] -struct ReleaseAsset { - name: String, - browser_download_url: String, -} - -#[derive(Deserialize)] -struct Release { - tag_name: String, - assets: Vec, -} - -fn get_download_name() -> String { - let os = env::consts::OS; - let arch = env::consts::ARCH; - - let os_str = match os { - "macos" => "darwin", - "windows" => return "spacetime.exe".to_string(), - "linux" => "linux", - _ => panic!("Unsupported OS"), - }; - - let arch_str = match arch { - "x86_64" => "amd64", - "aarch64" => "arm64", - _ => panic!("Unsupported architecture"), - }; - - format!("spacetime.{}-{}.tar.gz", os_str, arch_str) -} - -fn clean_version(version: &str) -> Option { - let re = Regex::new(r"v?(\d+\.\d+\.\d+)").unwrap(); - re.captures(version) - .and_then(|cap| cap.get(1)) - .map(|match_| match_.as_str().to_string()) -} - -async fn get_release_tag_from_version(release_version: &str) -> Result, reqwest::Error> { - let release_version = format!("v{}-beta", release_version); - let url = "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases"; - let client = reqwest::Client::builder() - .user_agent(format!("SpacetimeDB CLI/{}", version::CLI_VERSION)) - .build()?; - let releases: Vec = client - .get(url) - .header( - reqwest::header::USER_AGENT, - format!("SpacetimeDB CLI/{}", version::CLI_VERSION).as_str(), - ) - .send() - .await? - .json() - .await?; - - for release in releases.iter() { - if let Some(release_tag) = release["tag_name"].as_str() { - if release_tag.starts_with(&release_version) { - return Ok(Some(release_tag.to_string())); - } - } - } - Ok(None) -} - -async fn download_with_progress(client: &reqwest::Client, url: &str, temp_path: &Path) -> Result<(), anyhow::Error> { - let response = client.get(url).send().await?; - let total_size = match response.headers().get(reqwest::header::CONTENT_LENGTH) { - Some(size) => size.to_str().unwrap().parse::().unwrap(), - None => 0, - }; - - let pb = ProgressBar::new(total_size); - pb.set_style( - ProgressStyle::default_bar().template("{spinner} Downloading update... {bytes}/{total_bytes} ({eta})"), - ); - - let mut file = fs::File::create(temp_path)?; - let mut downloaded_bytes = 0; - - let mut response_stream = response.bytes_stream(); - while let Some(chunk) = response_stream.next().await { - let chunk = chunk?; - downloaded_bytes += chunk.len(); - pb.set_position(downloaded_bytes as u64); - file.write_all(&chunk)?; - } - - pb.finish_with_message("Download complete."); - Ok(()) -} - -pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let version = args.get_one::("version"); - let current_exe_path = env::current_exe()?; - let force = args.get_flag("force"); - - let url = match version { - None => "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases/latest".to_string(), - Some(release_version) => { - let release_tag = get_release_tag_from_version(release_version).await?; - if release_tag.is_none() { - return Err(anyhow::anyhow!("No release found for version {}", release_version)); - } - format!( - "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases/tags/{}", - release_tag.unwrap() - ) - } - }; - - let client = reqwest::Client::builder() - .user_agent(format!("SpacetimeDB CLI/{}", version::CLI_VERSION)) - .build()?; - - print!("Finding version..."); - std::io::stdout().flush()?; - let release: Release = client.get(url).send().await?.json().await?; - let release_version = clean_version(&release.tag_name).unwrap(); - println!("done."); - - if release_version == version::CLI_VERSION { - println!("You're already running the latest version: {}", version::CLI_VERSION); - if !y_or_n(force, "Do you want to reinstall? ")? { - return Ok(()); - } - } - - let download_name = get_download_name(); - let asset = release.assets.iter().find(|&asset| asset.name == download_name); - - if asset.is_none() { - return Err(anyhow::anyhow!( - "No assets available for the detected OS and architecture." - )); - } - - println!( - "You are currently running version {} of spacetime. The version you're upgrading to is {}.", - version::CLI_VERSION, - release_version, - ); - println!( - "This will replace the current executable at {}.", - current_exe_path.display() - ); - - if !y_or_n(force, "Do you want to continue?")? { - println!("Aborting upgrade."); - return Ok(()); - } - - let temp_dir = tempfile::tempdir()?.into_path(); - let temp_path = &temp_dir.join(download_name.clone()); - download_with_progress(&client, &asset.unwrap().browser_download_url, temp_path).await?; - - if download_name.to_lowercase().ends_with(".tar.gz") || download_name.to_lowercase().ends_with("tgz") { - let tar_gz = fs::File::open(temp_path)?; - let tar = GzDecoder::new(tar_gz); - let mut archive = Archive::new(tar); - let mut spacetime_found = false; - for mut file in archive.entries()?.filter_map(|e| e.ok()) { - if let Ok(path) = file.path() { - if path.ends_with("spacetime") { - spacetime_found = true; - file.unpack(temp_dir.join("spacetime"))?; - } - } - } - - if !spacetime_found { - fs::remove_dir_all(&temp_dir)?; - return Err(anyhow::anyhow!("Spacetime executable not found in archive")); - } - } - - let new_exe_path = if temp_path.to_str().unwrap().ends_with(".exe") { - temp_path.clone() - } else if download_name.ends_with(".tar.gz") { - temp_dir.join("spacetime") - } else { - fs::remove_dir_all(&temp_dir)?; - return Err(anyhow::anyhow!("Unsupported download type")); - }; - - // Move the current executable into a temporary directory, which will later be deleted by the OS - let current_exe_temp_dir = env::temp_dir(); - let current_exe_to_temp = current_exe_temp_dir.join("spacetime_old"); - fs::rename(¤t_exe_path, current_exe_to_temp)?; - fs::rename(new_exe_path, ¤t_exe_path)?; - fs::remove_dir_all(&temp_dir)?; - - println!("spacetime has been updated to version {}", release_version); - - Ok(()) -} diff --git a/crates/cli/src/subcommands/version.rs b/crates/cli/src/subcommands/version.rs index c354e3a3acf..28b956b47eb 100644 --- a/crates/cli/src/subcommands/version.rs +++ b/crates/cli/src/subcommands/version.rs @@ -1,33 +1,76 @@ -use clap::{Arg, ArgAction::SetTrue, ArgMatches}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::process::{Command, ExitCode}; -pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); - -use crate::config::Config; +use anyhow::Context; +use clap::{ArgMatches, Args}; +use spacetimedb_paths::cli::BinFile; +use spacetimedb_paths::{FromPathUnchecked, RootDir, SpacetimePaths}; pub fn cli() -> clap::Command { - clap::Command::new("version") - .about("Print the version of the command line tool") - .after_help("Run `spacetime help version` for more detailed information.\n") - .arg( - Arg::new("cli") - .long("cli") - .action(SetTrue) - .help("Prints only the CLI version"), - ) + Version::augment_args(clap::Command::new("version")) +} + +/// Manage installed spacetime versions +/// +/// Run `spacetime version --help` to see all options. +#[derive(clap::Args)] +#[command(disable_help_flag = true)] +struct Version { + /// The args to pass to spacetimedb-update + #[arg(allow_hyphen_values = true, num_args = 0..)] + args: Vec, } -pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - if args.get_flag("cli") { - println!("{}", CLI_VERSION); - return Ok(()); +pub async fn exec(paths: &SpacetimePaths, root_dir: Option<&RootDir>, args: &ArgMatches) -> anyhow::Result { + let args = args.get_many::("args").unwrap_or_default(); + let bin_path; + let bin_path = if let Some(artifact_dir) = running_from_target_dir() { + let update_path = artifact_dir + .join("spacetimedb-update") + .with_extension(std::env::consts::EXE_EXTENSION); + anyhow::ensure!( + update_path.exists(), + "running `spacetime version` from a target/ directory, but the spacetimedb-update + binary doesn't exist. try running `cargo build -p spacetimedb-update`" + ); + bin_path = BinFile::from_path_unchecked(update_path); + &bin_path + } else { + &paths.cli_bin_file + }; + let mut cmd = Command::new(bin_path); + if let Some(root_dir) = root_dir { + cmd.arg("--root-dir").arg(root_dir); } + cmd.arg("version").args(args); + let applet = "spacetimedb-update"; + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.arg0(applet); + } + #[cfg(windows)] + cmd.env("SPACETIMEDB_UPDATE_MULTICALL_APPLET", applet); + super::start::exec_replace(&mut cmd).with_context(|| format!("exec failed for {}", bin_path.display())) +} - println!("Path: {}", std::env::current_exe()?.display()); - println!("Commit: {}", env!("GIT_HASH")); - println!( - "spacetimedb tool version {}; spacetimedb-lib version {};", - CLI_VERSION, - spacetimedb_lib::version::spacetimedb_lib_version() - ); - Ok(()) +/// Checks to see if we're running from a subdirectory of a `target` dir that has a `Cargo.toml` +/// as a sibling, and returns the containing directory of the current executable if so. +fn running_from_target_dir() -> Option { + let mut exe_path = std::env::current_exe().ok()?; + exe_path.pop(); + let artifact_dir = exe_path; + // check for target/debug/spacetimedb-update and target/x86_64-unknown-foobar/debug/spacetimedb-update + let target_dir = artifact_dir + .ancestors() + .skip(1) + .take(2) + .find(|p| p.file_name() == Some("target".as_ref()))?; + target_dir + .parent()? + .join("Cargo.toml") + .try_exists() + .ok() + .map(|_| artifact_dir) } diff --git a/crates/cli/src/version.rs b/crates/cli/src/version.rs new file mode 100644 index 00000000000..006c8f9ae71 --- /dev/null +++ b/crates/cli/src/version.rs @@ -0,0 +1,13 @@ +pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn long_version() -> String { + format!( + "\ +Path: {path} +Commit: {commit} +spacetimedb tool version {CLI_VERSION}; spacetimedb-lib version {lib_ver};", + path = std::env::current_exe().unwrap_or_else(|_| "".into()).display(), + commit = env!("GIT_HASH"), + lib_ver = spacetimedb_lib::version::spacetimedb_lib_version() + ) +} diff --git a/crates/paths/Cargo.toml b/crates/paths/Cargo.toml index 5a3c77bcbb8..fdc75d472e7 100644 --- a/crates/paths/Cargo.toml +++ b/crates/paths/Cargo.toml @@ -11,12 +11,12 @@ anyhow.workspace = true chrono = { workspace = true, features = ["now"] } fs2.workspace = true itoa.workspace = true -semver.workspace = true serde.workspace = true thiserror.workspace = true [target.'cfg(windows)'.dependencies] dirs.workspace = true +junction.workspace = true [target.'cfg(not(windows))'.dependencies] xdg.workspace = true diff --git a/crates/paths/src/cli.rs b/crates/paths/src/cli.rs index f0f092fc4fc..889aada32c4 100644 --- a/crates/paths/src/cli.rs +++ b/crates/paths/src/cli.rs @@ -1,3 +1,7 @@ +use std::path::Path; + +use anyhow::Context; + use crate::utils::{path_type, PathBufExt}; path_type! { @@ -21,13 +25,48 @@ impl ConfigDir { path_type!(#[non_exhaustive(any())] PrivKeyPath: file); path_type!(#[non_exhaustive(any())] PubKeyPath: file); +path_type!(CliTomlPath: file); + path_type!(BinFile: file); path_type!(BinDir: dir); impl BinDir { - pub fn version_dir(&self, version: semver::Version) -> VersionBinDir { - VersionBinDir(self.0.join(version.to_string())) + pub fn version_dir(&self, version: &str) -> VersionBinDir { + VersionBinDir(self.0.join(version)) + } + + pub const CURRENT_VERSION_DIR_NAME: &str = "current"; + pub fn current_version_dir(&self) -> VersionBinDir { + VersionBinDir(self.0.join(Self::CURRENT_VERSION_DIR_NAME)) + } + + pub fn set_current_version(&self, version: &str) -> anyhow::Result<()> { + self.current_version_dir().link_to(self.version_dir(version).as_ref()) + } + + pub fn current_version(&self) -> anyhow::Result> { + match std::fs::read_link(self.current_version_dir()) { + Ok(path) => path.into_os_string().into_string().ok().context("not utf8").map(Some), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn installed_versions(&self) -> anyhow::Result> { + self.read_dir()? + .filter_map(|r| match r { + Ok(entry) => { + let name = entry.file_name(); + if name == Self::CURRENT_VERSION_DIR_NAME { + None + } else { + entry.file_name().into_string().ok().map(Ok) + } + } + Err(e) => Some(Err(e.into())), + }) + .collect() } } @@ -37,8 +76,32 @@ impl VersionBinDir { pub fn spacetimedb_cli(self) -> SpacetimedbCliBin { SpacetimedbCliBin(self.0.joined("spacetimedb-cli").with_exe_ext()) } + + pub fn create_custom(&self, path: &Path) -> anyhow::Result<()> { + if std::fs::symlink_metadata(self).is_ok_and(|m| m.file_type().is_dir()) { + anyhow::bail!("version already exists"); + } + self.link_to(path) + } + + fn link_to(&self, path: &Path) -> anyhow::Result<()> { + let rel_path = path.strip_prefix(self).unwrap_or(path); + #[cfg(unix)] + { + // remove the link if it already exists + std::fs::remove_file(self).ok(); + std::os::unix::fs::symlink(rel_path, self)?; + } + #[cfg(windows)] + { + junction::delete(self).ok(); + // We won't be able to create a junction if the fs isn't NTFS, so fall back to trying + // to make a symlink. + junction::create(path, self) + .or_else(|err| std::os::windows::fs::symlink_dir(rel_path, self).or(Err(err)))?; + } + Ok(()) + } } path_type!(SpacetimedbCliBin: file); - -path_type!(CliTomlPath: file); diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index a1e89d647a9..adbba99a6d1 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -3,9 +3,10 @@ mod energy_monitor; pub mod routes; pub mod subcommands; pub mod util; +pub mod version; use crate::control_db::ControlDb; -use crate::subcommands::{start, version}; +use crate::subcommands::start; use anyhow::{ensure, Context}; use async_trait::async_trait; use clap::{ArgMatches, Command}; @@ -440,13 +441,12 @@ fn withdraw_energy(control_db: &ControlDb, identity: &Identity, amount: EnergyQu pub async fn exec_subcommand(cmd: &str, args: &ArgMatches) -> Result<(), anyhow::Error> { match cmd { "start" => start::exec(args).await, - "version" => version::exec(args).await, unknown => Err(anyhow::anyhow!("Invalid subcommand: {}", unknown)), } } pub fn get_subcommands() -> Vec { - vec![start::cli(), version::cli()] + vec![start::cli()] } pub async fn start_server(data_dir: &ServerDataDir, cert_dir: Option<&std::path::Path>) -> anyhow::Result<()> { diff --git a/crates/standalone/src/main.rs b/crates/standalone/src/main.rs index 2dd5e92d4eb..6bef94ab08f 100644 --- a/crates/standalone/src/main.rs +++ b/crates/standalone/src/main.rs @@ -17,6 +17,8 @@ fn get_command() -> Command { Command::new("spacetimedb") .args_conflicts_with_subcommands(true) .arg_required_else_help(true) + .version(version::CLI_VERSION) + .long_version(version::long_version()) .subcommand_required(true) .subcommands(get_subcommands()) .help_expected(true) diff --git a/crates/standalone/src/subcommands/mod.rs b/crates/standalone/src/subcommands/mod.rs index ec6c6e055aa..17381ce2f44 100644 --- a/crates/standalone/src/subcommands/mod.rs +++ b/crates/standalone/src/subcommands/mod.rs @@ -2,4 +2,3 @@ #![allow(clippy::disallowed_macros)] pub mod start; -pub mod version; diff --git a/crates/standalone/src/subcommands/start.rs b/crates/standalone/src/subcommands/start.rs index 9d135964f6c..60cd6738f21 100644 --- a/crates/standalone/src/subcommands/start.rs +++ b/crates/standalone/src/subcommands/start.rs @@ -16,7 +16,7 @@ pub fn cli() -> clap::Command { clap::Command::new("start") .about("Starts a standalone SpacetimeDB instance") .args_override_self(true) - .override_usage("spacetimedb start [OPTIONS]") + .override_usage("spacetime start [OPTIONS]") .arg( Arg::new("listen_addr") .long("listen-addr") diff --git a/crates/standalone/src/subcommands/version.rs b/crates/standalone/src/subcommands/version.rs deleted file mode 100644 index 67c990afef9..00000000000 --- a/crates/standalone/src/subcommands/version.rs +++ /dev/null @@ -1,31 +0,0 @@ -use clap::{Arg, ArgAction::SetTrue, ArgMatches}; - -const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub fn cli() -> clap::Command { - clap::Command::new("version") - .about("Print the version of the command line tool") - .after_help("Run `spacetimedb help version` for more detailed information.\n") - .arg( - Arg::new("cli") - .short('c') - .long("cli") - .action(SetTrue) - .help("Prints only the CLI version"), - ) -} - -pub async fn exec(args: &ArgMatches) -> Result<(), anyhow::Error> { - // e.g. kubeadm version: &version.Info{Major:"1", Minor:"24", GitVersion:"v1.24.2", GitCommit:"f66044f4361b9f1f96f0053dd46cb7dce5e990a8", GitTreeState:"clean", BuildDate:"2022-06-15T14:20:54Z", GoVersion:"go1.18.3", Compiler:"gc", Platform:"linux/arm64"} - if args.get_flag("cli") { - println!("{}", CLI_VERSION); - return Ok(()); - } - - println!( - "spacetimedb tool version {}; spacetimedb-lib version {};", - CLI_VERSION, - spacetimedb_lib::version::spacetimedb_lib_version() - ); - Ok(()) -} diff --git a/crates/standalone/src/version.rs b/crates/standalone/src/version.rs new file mode 100644 index 00000000000..afe32f961ea --- /dev/null +++ b/crates/standalone/src/version.rs @@ -0,0 +1,8 @@ +pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn long_version() -> String { + format!( + "spacetimedb tool version {CLI_VERSION}; spacetimedb-lib version {};", + spacetimedb_lib::version::spacetimedb_lib_version() + ) +} diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs index 535650663d1..123c9547d6e 100644 --- a/crates/testing/src/lib.rs +++ b/crates/testing/src/lib.rs @@ -20,6 +20,6 @@ pub fn invoke_cli(paths: &SpacetimePaths, args: &[&str]) { let (cmd, args) = args.subcommand().expect("Could not split subcommand and args"); RUNTIME - .block_on(spacetimedb_cli::exec_subcommand(config, paths, cmd, args)) + .block_on(spacetimedb_cli::exec_subcommand(config, paths, None, cmd, args)) .unwrap(); } diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index 2a3f8722e2d..85aa491a381 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -8,9 +8,20 @@ rust-version.workspace = true spacetimedb-paths.workspace = true anyhow.workspace = true +bytes.workspace = true clap.workspace = true -semver.workspace = true +dialoguer = { workspace = true, default-features = false } +flate2.workspace = true +http-body-util = "0.1.2" +indicatif.workspace = true +reqwest.workspace = true +semver = { workspace = true, features = ["serde"] } +serde.workspace = true +tar.workspace = true +tokio.workspace = true +toml.workspace = true tracing = { workspace = true, features = ["release_max_level_off"] } +zip = "2" [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_System_Console"] } diff --git a/crates/update/build.rs b/crates/update/build.rs new file mode 100644 index 00000000000..8d65a747b34 --- /dev/null +++ b/crates/update/build.rs @@ -0,0 +1,5 @@ +#![allow(clippy::disallowed_macros)] +fn main() { + let target = std::env::var("TARGET").unwrap(); + println!("cargo::rustc-env=BUILD_TARGET={target}"); +} diff --git a/crates/update/src/cli.rs b/crates/update/src/cli.rs new file mode 100644 index 00000000000..842f42a7bd5 --- /dev/null +++ b/crates/update/src/cli.rs @@ -0,0 +1,138 @@ +#![allow(clippy::disallowed_macros)] + +use std::ffi::OsString; +use std::future::Future; +use std::process::ExitCode; + +use anyhow::Context; +use spacetimedb_paths::{RootDir, SpacetimePaths}; + +mod install; +mod link; +mod list; +mod uninstall; +mod upgrade; +mod r#use; + +/// Manage installed spacetime versions +#[derive(clap::Parser)] +#[command(bin_name = "spacetime")] +pub struct Args { + #[arg(long)] + root_dir: Option, + #[command(subcommand)] + cmd: Subcommand, +} + +impl Args { + pub fn exec(self) -> anyhow::Result { + let paths = match &self.root_dir { + Some(root_dir) => SpacetimePaths::from_root_dir(root_dir), + None => SpacetimePaths::platform_defaults()?, + }; + match self.cmd { + Subcommand::Cli { args: mut cli_args } => { + if let Some(root_dir) = &self.root_dir { + cli_args.insert(0, OsString::from_iter(["--root-dir=".as_ref(), root_dir.as_ref()])); + } + crate::proxy::run_cli(Some(&paths), None, cli_args) + } + Subcommand::Version(version) => version.exec(&paths).map(|()| ExitCode::SUCCESS), + Subcommand::SelfInstall { install_latest } => { + let current_exe = std::env::current_exe().context("could not get current exe")?; + let suppress_eexists = |r: std::io::Result<()>| { + r.or_else(|e| (e.kind() == std::io::ErrorKind::AlreadyExists).then_some(()).ok_or(e)) + }; + suppress_eexists(paths.cli_bin_dir.create()).context("could not create bin dir")?; + suppress_eexists(paths.cli_config_dir.create()).context("could not create config dir")?; + suppress_eexists(paths.data_dir.create()).context("could not create data dir")?; + paths + .cli_bin_file + .create_parent() + .and_then(|()| std::fs::copy(¤t_exe, &paths.cli_bin_file)) + .context("could not install binary")?; + + if install_latest { + upgrade::Upgrade {}.exec(&paths)?; + } + + Ok(ExitCode::SUCCESS) + } + } + } +} + +#[derive(clap::Subcommand)] +enum Subcommand { + Version(Version), + #[command(hide = true)] + Cli { + #[clap(allow_hyphen_values = true)] + args: Vec, + }, + SelfInstall { + /// Download and install the latest CLI version after self-installing. + #[arg(long)] + install_latest: bool, + }, +} + +#[derive(clap::Args)] +#[command(arg_required_else_help = true)] +struct Version { + #[command(subcommand)] + subcmd: VersionSubcommand, +} + +impl Version { + fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + use VersionSubcommand::*; + match self.subcmd { + List(subcmd) => subcmd.exec(paths), + Use(subcmd) => subcmd.exec(paths), + Upgrade(subcmd) => subcmd.exec(paths), + Install(subcmd) => subcmd.exec(paths), + Uninstall(subcmd) => subcmd.exec(paths), + Link(subcmd) => subcmd.exec(paths), + } + } +} + +#[derive(clap::Subcommand)] +enum VersionSubcommand { + List(list::List), + Use(r#use::Use), + Upgrade(upgrade::Upgrade), + Install(install::Install), + Uninstall(uninstall::Uninstall), + #[command(hide = true)] + Link(link::Link), +} + +fn reqwest_client() -> anyhow::Result { + Ok(reqwest::Client::builder() + .user_agent(format!("SpacetimeDB CLI/{}", env!("CARGO_PKG_VERSION"))) + .build()?) +} + +fn tokio_block_on(fut: Fut) -> anyhow::Result { + Ok(tokio::runtime::Runtime::new()?.block_on(fut)) +} + +#[derive(clap::Args)] +struct ForceYes { + /// Skip the confirmation dialog. + #[arg(long, short)] + yes: bool, +} + +impl ForceYes { + fn confirm(self, prompt: String) -> anyhow::Result { + let yes = self.yes + || dialoguer::Confirm::new() + .with_prompt(prompt) + .default(false) + .interact()?; + Ok(yes) + } +} diff --git a/crates/update/src/cli/install.rs b/crates/update/src/cli/install.rs new file mode 100644 index 00000000000..5eb2fc06abf --- /dev/null +++ b/crates/update/src/cli/install.rs @@ -0,0 +1,202 @@ +use std::io; + +use anyhow::Context; +use bytes::{Buf, Bytes}; +use http_body_util::BodyExt; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::Deserialize; +use spacetimedb_paths::SpacetimePaths; + +use super::ForceYes; + +/// Install a specific SpacetimeDB version. +#[derive(clap::Args)] +pub(super) struct Install { + /// The SpacetimeDB version to install. + version: semver::Version, + + /// The SpacetimeDB edition(s) to install, separated by commas. + #[arg(long, value_delimiter = ',', action = clap::ArgAction::Set, default_value = "standalone")] + edition: Vec, + + /// Switch to this version after it is installed. + #[arg(long)] + r#use: bool, + + /// The name of the release artifact to download from github. + #[arg(long, hide = true)] + artifact_name: Option, + + #[command(flatten)] + yes: ForceYes, +} + +impl Install { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + super::tokio_block_on(async { + anyhow::ensure!( + self.edition == ["standalone"], + "can only install spacetimedb-standalone at the moment" + ); + let client = super::reqwest_client()?; + let version = download_and_install(&client, Some(self.version), self.artifact_name, paths).await?; + if self.r#use { + paths.cli_bin_dir.set_current_version(&version.to_string())?; + } + Ok(()) + })? + } +} + +pub(super) async fn download_and_install( + client: &reqwest::Client, + version: Option, + artifact_name: Option, + paths: &SpacetimePaths, +) -> anyhow::Result { + let custom_artifact = artifact_name.is_some(); + let download_name = artifact_name.as_deref().unwrap_or(DOWNLOAD_NAME); + let artifact_type = ArtifactType::deduce(download_name).context("Unknown archive type")?; + + let pb_style = ProgressStyle::with_template("{spinner} {prefix}{msg}").unwrap(); + let pb = ProgressBar::new(0).with_style(pb_style.clone()); + pb.enable_steady_tick(std::time::Duration::from_millis(60)); + + pb.set_message("Resolving version..."); + let releases_url = "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases"; + let url = match &version { + Some(version) => format!("{releases_url}/tags/v{version}"), + None => [releases_url, "/latest"].concat(), + }; + let release: Release = client + .get(url) + .send() + .await? + .error_for_status() + .map_err(|e| { + if e.status() == Some(reqwest::StatusCode::NOT_FOUND) { + if let Some(version) = &version { + return anyhow::anyhow!(e).context(format!("No release found for version {version}")); + } + } + anyhow::anyhow!(e).context("Could not fetch release info") + })? + .json() + .await?; + let release_version = match version { + Some(version) => version, + None => release.version().context("Could not parse version number")?, + }; + + let asset = release + .assets + .iter() + .find(|&asset| asset.name == download_name) + .ok_or_else(|| { + let err = anyhow::anyhow!("artifact named {download_name} not found in version {release_version}"); + if custom_artifact { + err + } else { + err.context("no prebuilt binaries available for the detected OS and architecture") + } + })?; + + pb.set_style(ProgressStyle::with_template("{spinner} {prefix}{msg} {bytes}/{total_bytes} ({eta})").unwrap()); + pb.set_prefix(format!("Installing v{release_version}: ")); + pb.set_message("downloading..."); + let archive = download_with_progress(&pb, client, &asset.browser_download_url).await?; + + pb.set_style(pb_style); + pb.set_message("unpacking..."); + + let version_dir = paths.cli_bin_dir.version_dir(&release_version.to_string()); + match artifact_type { + ArtifactType::TarGz => { + let tgz = archive.aggregate().reader(); + tar::Archive::new(flate2::bufread::GzDecoder::new(tgz)).unpack(&version_dir)?; + } + ArtifactType::Zip => { + let zip = archive.to_bytes(); + let zip = io::Cursor::new(&*zip); + zip::ZipArchive::new(zip)?.extract(&version_dir)?; + } + } + + pb.finish_with_message("done!"); + + Ok(release_version) +} + +enum ArtifactType { + TarGz, + Zip, +} + +impl ArtifactType { + fn deduce(filename: &str) -> Option { + if filename.ends_with(".tar.gz") { + Some(Self::TarGz) + } else if filename.ends_with(".zip") { + Some(Self::Zip) + } else { + None + } + } +} + +pub(super) async fn available_releases(client: &reqwest::Client) -> anyhow::Result> { + let url = "https://api.github.com/repos/clockworklabs/SpacetimeDB/releases"; + let releases: Vec = client.get(url).send().await?.json().await?; + + releases + .into_iter() + .map(|release| Ok(release.version()?.to_string())) + .collect() +} + +#[derive(Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, +} + +#[derive(Deserialize)] +struct Release { + tag_name: String, + assets: Vec, +} + +impl Release { + fn version(&self) -> anyhow::Result { + let ver = self.tag_name.strip_prefix('v').unwrap_or(&self.tag_name); + Ok(semver::Version::parse(ver)?) + } +} + +async fn download_with_progress( + pb: &ProgressBar, + client: &reqwest::Client, + url: &str, +) -> Result, anyhow::Error> { + let response = client.get(url).send().await?.error_for_status()?; + + pb.set_length(response.content_length().unwrap_or(0)); + + let body = reqwest::Body::from(response) + .map_frame(|f| { + if let Some(data) = f.data_ref() { + pb.inc(data.len() as u64); + } + f + }) + .collect() + .await?; + + Ok(body) +} + +const DOWNLOAD_NAME: &str = if cfg!(windows) { + concat!("spacetime-", env!("BUILD_TARGET"), ".zip") +} else { + concat!("spacetime-", env!("BUILD_TARGET"), ".tar.gz") +}; diff --git a/crates/update/src/cli/link.rs b/crates/update/src/cli/link.rs new file mode 100644 index 00000000000..2e7d0412775 --- /dev/null +++ b/crates/update/src/cli/link.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +use anyhow::Context; +use spacetimedb_paths::cli::BinDir; +use spacetimedb_paths::SpacetimePaths; + +/// Set a local installation of SpacetimeDB as a custom version. +#[derive(clap::Args)] +pub(super) struct Link { + /// The name of the custom installation, e.g. `dev`. + name: String, + /// The path to the directory with the SpacetimeDB binaries. + path: PathBuf, + + /// Switch to this version after it's created. + #[arg(long)] + r#use: bool, +} + +impl Link { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + anyhow::ensure!( + self.name != BinDir::CURRENT_VERSION_DIR_NAME, + "name cannot be `current`" + ); + let mut path = std::env::current_dir()?; + path.push(self.path); + paths + .cli_bin_dir + .version_dir(&self.name) + .create_custom(&path) + .context("could not link custom version")?; + if self.r#use { + paths.cli_bin_dir.set_current_version(&self.name)?; + } + Ok(()) + } +} diff --git a/crates/update/src/cli/list.rs b/crates/update/src/cli/list.rs new file mode 100644 index 00000000000..dc3eaced020 --- /dev/null +++ b/crates/update/src/cli/list.rs @@ -0,0 +1,29 @@ +use spacetimedb_paths::SpacetimePaths; + +/// List installed SpacetimeDB versions. +#[derive(clap::Args)] +pub(super) struct List { + /// List all versions available to download and install. + #[arg(long)] + all: bool, +} + +impl List { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + let current = paths.cli_bin_dir.current_version()?; + let versions = if self.all { + let client = super::reqwest_client()?; + super::tokio_block_on(super::install::available_releases(&client))?? + } else { + paths.cli_bin_dir.installed_versions()? + }; + for ver in versions { + print!("{ver}"); + if Some(&ver) == current.as_ref() { + print!(" (current)"); + } + println!(); + } + Ok(()) + } +} diff --git a/crates/update/src/cli/uninstall.rs b/crates/update/src/cli/uninstall.rs new file mode 100644 index 00000000000..0ec1f69caf1 --- /dev/null +++ b/crates/update/src/cli/uninstall.rs @@ -0,0 +1,37 @@ +use anyhow::Context; +use spacetimedb_paths::cli::BinDir; +use spacetimedb_paths::SpacetimePaths; + +use super::ForceYes; + +/// Uninstall an installed SpacetimeDB version. +#[derive(clap::Args)] +pub(super) struct Uninstall { + version: String, + #[command(flatten)] + yes: ForceYes, +} + +impl Uninstall { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + let Self { version, yes } = self; + anyhow::ensure!( + version != BinDir::CURRENT_VERSION_DIR_NAME, + "cannot remove `current` version" + ); + match paths + .cli_bin_dir + .current_version() + .context("couldn't read current version") + { + Ok(Some(current)) => anyhow::ensure!(version != current, "cannot uninstall currently used version"), + Ok(None) => {} + Err(e) => tracing::warn!("{e:#}"), + } + if yes.confirm(format!("Uninstall v{version}?"))? { + let dir = paths.cli_bin_dir.version_dir(&version); + std::fs::remove_dir_all(dir)?; + } + Ok(()) + } +} diff --git a/crates/update/src/cli/upgrade.rs b/crates/update/src/cli/upgrade.rs new file mode 100644 index 00000000000..cc0d96a93d1 --- /dev/null +++ b/crates/update/src/cli/upgrade.rs @@ -0,0 +1,31 @@ +use spacetimedb_paths::SpacetimePaths; + +/// Upgrade and switch to the latest available version of SpacetimeDB. +#[derive(clap::Args)] +pub(super) struct Upgrade {} + +impl Upgrade { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + super::tokio_block_on(async { + let client = super::reqwest_client()?; + let version = super::install::download_and_install(&client, None, None, paths).await?; + paths.cli_bin_dir.set_current_version(&version.to_string())?; + + let cur_version = semver::Version::parse(env!("CARGO_PKG_VERSION")).unwrap(); + if version > cur_version { + let mut new_update_binary = paths + .cli_bin_dir + .version_dir(&version.to_string()) + .0 + .join("spacetimedb-update"); + new_update_binary.set_extension(std::env::consts::EXE_EXTENSION); + if new_update_binary.exists() { + tokio::fs::copy(new_update_binary, &paths.cli_bin_file).await?; + eprintln!("Self-updated `spacetime version` command") + } + } + + Ok(()) + })? + } +} diff --git a/crates/update/src/cli/use.rs b/crates/update/src/cli/use.rs new file mode 100644 index 00000000000..bf69a0d59a0 --- /dev/null +++ b/crates/update/src/cli/use.rs @@ -0,0 +1,13 @@ +use spacetimedb_paths::SpacetimePaths; + +/// Set the global default SpacetimeDB version. +#[derive(clap::Args)] +pub(super) struct Use { + version: semver::Version, +} + +impl Use { + pub(super) fn exec(self, paths: &SpacetimePaths) -> anyhow::Result<()> { + paths.cli_bin_dir.set_current_version(&self.version.to_string()) + } +} diff --git a/crates/update/src/main.rs b/crates/update/src/main.rs index 4fe5befb43b..8f7a0af7db5 100644 --- a/crates/update/src/main.rs +++ b/crates/update/src/main.rs @@ -1,95 +1,40 @@ -use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::process::ExitCode; use anyhow::Context; use clap::Parser; -use spacetimedb_paths::{RootDir, SpacetimePaths}; +mod cli; mod proxy; fn main() -> anyhow::Result { let mut args = std::env::args_os(); let argv0: PathBuf = args.next().unwrap().into(); - let file_stem = argv0.file_stem().context("argv0 must have a filename")?; - if file_stem == "spacetimedb-update" { + let env_cmd = std::env::var_os("SPACETIMEDB_UPDATE_MULTICALL_APPLET"); + let cmd = if let Some(cmd) = &env_cmd { + cmd + } else { + argv0.file_stem().context("argv0 must have a filename")? + }; + if cmd == "spacetimedb-update" { spacetimedb_update_main() - } else if file_stem == "spacetime" { - proxy::spacetimedb_cli_proxy(Some(argv0.as_os_str()), args.collect()) + } else if cmd == "spacetime" { + let args = args.collect::>(); + if args.first().is_some_and(|s| s == "version") { + // if the first arg is unambiguously `version`, go straight to `spacetime version` + spacetimedb_update_main() + } else { + proxy::run_cli(None, Some(argv0.as_os_str()), args) + } } else { anyhow::bail!( "unknown command name for spacetimedb-update multicall binary: {}", - Path::new(file_stem).display() + Path::new(cmd).display() ) } } -#[derive(clap::Parser)] -struct Args { - #[arg(long)] - root_dir: Option, - #[command(subcommand)] - cmd: Subcommand, -} - -#[derive(clap::Subcommand)] -enum Subcommand { - Version(Version), - UseVersion(UseVersion), - Upgrade, - Install(Install), - Uninstall(Uninstall), - #[command(hide = true)] - Cli { - #[clap(allow_hyphen_values = true)] - args: Vec, - }, -} - -#[derive(clap::Args)] -struct Version { - #[command(subcommand)] - subcmd: Option, -} - -#[derive(clap::Subcommand)] -enum VersionSubcommand { - List, -} - -#[derive(clap::Args)] -struct UseVersion { - #[arg(long)] - edition: String, - #[arg(long)] - version: semver::Version, -} - -#[derive(clap::Args)] -struct Install { - edition: String, - version: semver::Version, -} - -#[derive(clap::Args)] -struct Uninstall { - edition: String, - version: semver::Version, -} - fn spacetimedb_update_main() -> anyhow::Result { - let args = Args::parse(); - let paths = match &args.root_dir { - Some(root_dir) => SpacetimePaths::from_root_dir(root_dir), - None => SpacetimePaths::platform_defaults()?, - }; - match args.cmd { - Subcommand::Cli { args: mut cli_args } => { - if let Some(root_dir) = &args.root_dir { - cli_args.insert(0, OsString::from_iter(["--root-dir=".as_ref(), root_dir.as_ref()])); - } - proxy::run_cli(&paths, None, cli_args) - } - _ => unimplemented!(), - } + let args = cli::Args::parse(); + args.exec() } diff --git a/crates/update/src/proxy.rs b/crates/update/src/proxy.rs index dd9b91fc2b9..ec5811bc8f2 100644 --- a/crates/update/src/proxy.rs +++ b/crates/update/src/proxy.rs @@ -7,14 +7,26 @@ use std::path::PathBuf; use std::process::Command; use std::process::ExitCode; -pub(super) fn spacetimedb_cli_proxy(argv0: Option<&OsStr>, args: Vec) -> anyhow::Result { - let paths = match extract_root_dir_arg(&args)? { - Some(root_dir) => SpacetimePaths::from_root_dir(&root_dir), - None => SpacetimePaths::platform_defaults()?, +pub(crate) fn run_cli( + paths: Option<&SpacetimePaths>, + argv0: Option<&OsStr>, + args: Vec, +) -> anyhow::Result { + let parse_args = || PartialCliArgs::parse(&args); + let mut is_version_subcommand = None; + let paths_; + let paths = match paths { + Some(paths) => paths, + None => { + let partial_args = parse_args()?; + is_version_subcommand = Some(partial_args.is_version_subcommand()); + paths_ = match partial_args.root_dir { + Some(root_dir) => SpacetimePaths::from_root_dir(&root_dir), + None => SpacetimePaths::platform_defaults()?, + }; + &paths_ + } }; - run_cli(&paths, argv0, args) -} -pub(crate) fn run_cli(paths: &SpacetimePaths, argv0: Option<&OsStr>, args: Vec) -> anyhow::Result { let cli_path = if let Some(artifact_dir) = running_from_target_dir() { let cli_path = spacetimedb_paths::cli::VersionBinDir::from_path_unchecked(artifact_dir).spacetimedb_cli(); anyhow::ensure!( @@ -24,19 +36,34 @@ pub(crate) fn run_cli(paths: &SpacetimePaths, argv0: Option<&OsStr>, args: Vec return Ok(status), + Err(err) => err, + }; + // if we failed to exec cli and it seems like the user is trying to run `spacetime version`, + // patch them through directly. + if is_version_subcommand.unwrap_or_else(|| parse_args().is_ok_and(|a| a.is_version_subcommand())) { + return crate::spacetimedb_update_main(); } - exec_replace(&mut cmd).with_context(|| format!("exec failed for {}", cli_path.display())) + Err(exec_err) + .context(format!("exec failed for {}", cli_path.display())) + .context( + "It seems like the spacetime version set as current may not exist. Try using `spacetime version`\n\ + to set a different version as default or to install a new version altogether.", + ) } // implementation based on and docs taken verbatim from `cargo_util::ProcessBuilder::exec_replace` @@ -86,7 +113,7 @@ fn exec_replace(cmd: &mut Command) -> io::Result { /// Checks to see if we're running from a subdirectory of a `target` dir that has a `Cargo.toml` /// as a sibling, and returns the containing directory of the current executable if so. -fn running_from_target_dir() -> Option { +pub(crate) fn running_from_target_dir() -> Option { let mut exe_path = std::env::current_exe().ok()?; exe_path.pop(); let artifact_dir = exe_path; @@ -104,36 +131,49 @@ fn running_from_target_dir() -> Option { .map(|_| artifact_dir) } -fn get_current_version() -> semver::Version { - // TODO: - "1.0.0".parse().unwrap() +struct PartialCliArgs<'a> { + root_dir: Option, + maybe_subcommand: Option<&'a OsStr>, } -fn extract_root_dir_arg(args: &[OsString]) -> anyhow::Result> { - let mut args = args.iter(); - let mut root_dir = None; - while let Some(arg) = args.next() { - let is_arg_value = |s: &OsStr| !os_str_starts_with(arg, "-") || s == "-"; - // "parse" only up to the first subcommand - if is_arg_value(arg) || arg == "--" { - break; +impl<'a> PartialCliArgs<'a> { + fn is_version_subcommand(&self) -> bool { + self.maybe_subcommand.is_some_and(|s| s == "version") + } + + fn parse(args: &'a [OsString]) -> anyhow::Result { + let mut args = args.iter(); + let mut root_dir = None; + let mut maybe_subcommand = None; + while let Some(arg) = args.next() { + let is_arg_value = |s: &OsStr| !os_str_starts_with(arg, "-") || s == "-"; + // "parse" only up to the first subcommand + if is_arg_value(arg) { + maybe_subcommand = Some(&**arg); + break; + } else if arg == "--" { + break; + } + let root_dir_arg = if arg == "--root-dir" { + args.next() + .filter(|s| is_arg_value(s)) + .context("a value is required for '--root-dir ' but none was supplied")? + } else if let Some(arg) = os_str_strip_prefix(arg, "--root-dir=") { + arg + } else { + continue; + }; + anyhow::ensure!( + root_dir.is_none(), + "the argument '--root-dir ' cannot be used multiple times" + ); + root_dir = Some(RootDir(root_dir_arg.into())); } - let root_dir_arg = if arg == "--root-dir" { - args.next() - .filter(|s| is_arg_value(s)) - .context("a value is required for '--root-dir ' but none was supplied")? - } else if let Some(arg) = os_str_strip_prefix(arg, "--root-dir=") { - arg - } else { - continue; - }; - anyhow::ensure!( - root_dir.is_none(), - "the argument '--root-dir ' cannot be used multiple times" - ); - root_dir = Some(RootDir(root_dir_arg.into())); + Ok(Self { + root_dir, + maybe_subcommand, + }) } - Ok(root_dir) } fn os_str_starts_with(s: &OsStr, pref: &str) -> bool {