diff --git a/.gitignore b/.gitignore index 5fe2642..748356a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ *.rbxlx *.rbxl.lock *.rbxlx.lock + +*.png diff --git a/Cargo.lock b/Cargo.lock index 8f7940b..4917441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "ansi_term" version = "0.11.0" @@ -11,6 +20,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "ascii" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97be891acc47ca214468e09425d02cef3af2c94d0d82081cd02061f996802f14" + [[package]] name = "atty" version = "0.2.14" @@ -22,12 +37,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + [[package]] name = "autocfg" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "base64" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" +dependencies = [ + "byteorder", + "safemem", +] + [[package]] name = "base64" version = "0.13.0" @@ -40,12 +71,37 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "buf_redux" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "bumpalo" version = "3.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "cc" version = "1.0.71" @@ -58,6 +114,25 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "chunked_transfer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498d20a7aaf62625b9bf26e637cf7736417cde1d0c99f1d04d1170229a85cf87" + [[package]] name = "chunked_transfer" version = "1.4.0" @@ -79,6 +154,39 @@ dependencies = [ "vec_map", ] +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "dtoa" version = "0.4.8" @@ -92,7 +200,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" dependencies = [ "matches", - "percent-encoding", + "percent-encoding 2.1.0", +] + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "generic-array" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "typenum", + "version_check 0.9.3", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -101,6 +236,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +[[package]] +name = "groupable" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32619942b8be646939eaf3db0602b39f5229b74575b67efc897811ded1db4e57" + [[package]] name = "hashbrown" version = "0.11.2" @@ -116,6 +257,42 @@ dependencies = [ "libc", ] +[[package]] +name = "httparse" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" + +[[package]] +name = "hyper" +version = "0.10.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0652d9a2609a968c14be1a9ea00bf4b1d64e2e1f53a1b51b6fff3a6e829273" +dependencies = [ + "base64 0.9.3", + "httparse", + "language-tags", + "log 0.3.9", + "mime 0.2.6", + "num_cpus", + "time", + "traitobject", + "typeable", + "unicase 1.4.2", + "url 1.7.2", +] + +[[package]] +name = "idna" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.2.3" @@ -133,10 +310,26 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" dependencies = [ - "autocfg", + "autocfg 1.0.1", "hashbrown", ] +[[package]] +name = "iron" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6d308ca2d884650a8bf9ed2ff4cb13fbb2207b71f64cda11dc9b892067295e8" +dependencies = [ + "hyper", + "log 0.3.9", + "mime_guess 1.8.8", + "modifier", + "num_cpus", + "plugin", + "typemap", + "url 1.7.2", +] + [[package]] name = "itoa" version = "0.4.8" @@ -152,6 +345,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "language-tags" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" + [[package]] name = "lazy_static" version = "1.4.0" @@ -170,6 +369,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +[[package]] +name = "log" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" +dependencies = [ + "log 0.4.14", +] + [[package]] name = "log" version = "0.4.14" @@ -185,18 +393,215 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "mime" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0" +dependencies = [ + "log 0.3.9", +] + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "mime_guess" +version = "1.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216929a5ee4dd316b1702eedf5e74548c123d370f47841ceaac38ca154690ca3" +dependencies = [ + "mime 0.2.6", + "phf", + "phf_codegen", + "unicase 1.4.2", +] + +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime 0.3.16", + "unicase 2.6.0", +] + +[[package]] +name = "modifier" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f5c9112cb662acd3b204077e0de5bc66305fa8df65c8019d5adb10e9ab6e58" + +[[package]] +name = "multipart" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" +dependencies = [ + "buf_redux", + "httparse", + "hyper", + "iron", + "log 0.4.14", + "mime 0.3.16", + "mime_guess 2.0.3", + "nickel", + "quick-error", + "rand 0.8.4", + "safemem", + "tempfile", + "tiny_http", + "twoway", +] + +[[package]] +name = "mustache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51956ef1c5d20a1384524d91e616fb44dfc7d8f249bf696d49c97dd3289ecab5" +dependencies = [ + "log 0.3.9", + "serde", +] + +[[package]] +name = "nickel" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5061a832728db2dacb61cefe0ce303b58f85764ec680e71d9138229640a46d9" +dependencies = [ + "groupable", + "hyper", + "lazy_static", + "log 0.3.9", + "modifier", + "mustache", + "plugin", + "regex", + "serde", + "serde_json", + "time", + "typemap", + "url 1.7.2", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg 1.0.1", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg 1.0.1", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "percent-encoding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" + [[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "phf" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03e85129e324ad4166b06b2c7491ae27fe3ec353af72e72cd1654c7225d517e" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662" +dependencies = [ + "phf_shared", + "rand 0.6.5", +] + +[[package]] +name = "phf_shared" +version = "0.7.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0" +dependencies = [ + "siphasher", + "unicase 1.4.2", +] + +[[package]] +name = "plugin" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6a0dc3910bc8db877ffed8e457763b317cf880df4ae19109b9f77d277cf6e0" +dependencies = [ + "typemap", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0cfbc8191465bed66e1718596ee0b0b35d5ee1f41c5df2189d0fe8bde535ba" + [[package]] name = "proc-macro2" version = "1.0.30" @@ -206,6 +611,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.10" @@ -215,6 +626,196 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d71dacdc3c88c1fde3885a3be3fbab9f35724e6ce99467f7d9c5026132184ca" +dependencies = [ + "autocfg 0.1.7", + "libc", + "rand_chacha 0.1.1", + "rand_core 0.4.2", + "rand_hc 0.1.0", + "rand_isaac", + "rand_jitter", + "rand_os", + "rand_pcg", + "rand_xorshift", + "winapi", +] + +[[package]] +name = "rand" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.3", + "rand_hc 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556d3a1ca6600bfcbab7c7c91ccb085ac7fbbcd70e008a98742e7847f4f7bcef" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b40677c7be09ae76218dc623efbf7b18e34bced3f38883af07bb75630a21bc4" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_hc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" +dependencies = [ + "rand_core 0.6.3", +] + +[[package]] +name = "rand_isaac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded997c9d5f13925be2a6fd7e66bf1872597f759fd9dd93513dd7e92e5a5ee08" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rand_jitter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1166d5c91dc97b88d1decc3285bb0a99ed84b05cfd0bc2341bdf2d43fc41e39b" +dependencies = [ + "libc", + "rand_core 0.4.2", + "winapi", +] + +[[package]] +name = "rand_os" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b75f676a1e053fc562eafbb47838d67c84801e38fc1ba459e8f180deabd5071" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.4.2", + "rdrand", + "winapi", +] + +[[package]] +name = "rand_pcg" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf9b09b01790cfe0364f52bf32995ea3c39f4d2dd011eac241d2914146d0b44" +dependencies = [ + "autocfg 0.1.7", + "rand_core 0.4.2", +] + +[[package]] +name = "rand_xorshift" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf7e9e623549b0e21f6e97cf8ecf247c1a8fd2e8a992ae265314300b2455d5c" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "ring" version = "0.16.20" @@ -235,10 +836,14 @@ name = "rocat" version = "0.4.0" dependencies = [ "clap", + "difference", "glob", + "mime_guess 2.0.3", + "multipart", "serde", "serde_json", "serde_yaml", + "sha2", "ureq", ] @@ -248,7 +853,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b5ac6078ca424dc1d3ae2328526a76787fecc7f8011f520e3276730e711fc95" dependencies = [ - "log", + "log 0.4.14", "ring", "sct", "webpki", @@ -260,6 +865,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + [[package]] name = "sct" version = "0.7.0" @@ -313,6 +924,25 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "sha2" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b69f9a4c9740d74c5baa3fd2e547f9525fa8088a8a958e0ca2409a514e33f5fa" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "siphasher" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac" + [[package]] name = "spin" version = "0.5.2" @@ -336,6 +966,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if", + "libc", + "rand 0.8.4", + "redox_syscall", + "remove_dir_all", + "winapi", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -345,6 +989,29 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "tiny_http" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e22cb179b63e5fc2d0b5be237dc107da072e2407809ac70a8ce85b93fe8f562" +dependencies = [ + "ascii", + "chrono", + "chunked_transfer 0.3.1", + "log 0.4.14", + "url 1.7.2", +] + [[package]] name = "tinyvec" version = "1.5.0" @@ -360,6 +1027,60 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" +[[package]] +name = "traitobject" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" + +[[package]] +name = "twoway" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" +dependencies = [ + "memchr", +] + +[[package]] +name = "typeable" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887" + +[[package]] +name = "typemap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653be63c80a3296da5551e1bfd2cca35227e13cdd08c6668903ae2f4f77aa1f6" +dependencies = [ + "unsafe-any", +] + +[[package]] +name = "typenum" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63708a265f51345575b27fe43f9500ad611579e764c79edbc2037b1121959ec" + +[[package]] +name = "unicase" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4765f83163b74f957c797ad9253caf97f103fb064d3999aea9568d09fc8a33" +dependencies = [ + "version_check 0.1.5", +] + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check 0.9.3", +] + [[package]] name = "unicode-bidi" version = "0.3.7" @@ -387,6 +1108,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "unsafe-any" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30360d7979f5e9c6e6cea48af192ea8fab4afb3cf72597154b8f08935bc9c7f" +dependencies = [ + "traitobject", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -399,18 +1129,29 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd912a3d096959150c4d71ac752e13f1683085922658c205b89b40fe8ebe07f" dependencies = [ - "base64", - "chunked_transfer", - "log", + "base64 0.13.0", + "chunked_transfer 1.4.0", + "log 0.4.14", "once_cell", "rustls", "serde", "serde_json", - "url", + "url 2.2.2", "webpki", "webpki-roots", ] +[[package]] +name = "url" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" +dependencies = [ + "idna 0.1.5", + "matches", + "percent-encoding 1.0.1", +] + [[package]] name = "url" version = "2.2.2" @@ -418,9 +1159,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.2.3", "matches", - "percent-encoding", + "percent-encoding 2.1.0", ] [[package]] @@ -429,6 +1170,24 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" +[[package]] +name = "version_check" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + [[package]] name = "wasm-bindgen" version = "0.2.78" @@ -447,7 +1206,7 @@ checksum = "a317bf8f9fba2476b4b2c85ef4c4af8ff39c3c7f0cdfeed4f82c34a880aa837b" dependencies = [ "bumpalo", "lazy_static", - "log", + "log 0.4.14", "proc-macro2", "quote", "syn", diff --git a/Cargo.toml b/Cargo.toml index 40a9065..9976eff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,7 @@ serde = { version = "1.0", features = ["derive"] } clap = "2.33.0" ureq = { version = "2.3.0", features = ["json"] } glob = "0.3.0" +mime_guess = "2.0.3" +multipart = { version = "0.18.0", features = ["client"] } +sha2 = "0.9.8" +difference = "2.0.0" diff --git a/README.md b/README.md index ef1d833..af20325 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Rocat šŸš€ -Early development of a new tool for deploying projects to Roblox using the new [Open Cloud -APIs](https://devforum.roblox.com/t/open-cloud-publishing-your-places-with-api-keys-is-now-live/1485135). +An infrastructure-as-code and deployment tool for Roblox. -āš  Please note that this is an early release and the API is unstable āš  +āš  Please note that this is an early release and the API is unstable. Releases follow pre-release +semantic versioning (minor updates indicate breaking changes) āš  ## Installation @@ -41,76 +41,22 @@ If you are using the `templates` feature you will also need to provide a `ROBLOS the `ROBLOSECURITY` environment variable. You can get the cookie from your browser dev tools on roblox.com. -### Manually save/publish a place to Roblox - -You can use the `save` and `publish` commands to manually save or publish a Roblox place file -(`rbxl` or `rbxlx`) to a pre-existing place. - -```sh -# Save -rocat save place.rbxl - -# Publish -rocat publish place.rbxl -``` - ### Configure deployments -You can configure reusable Roblox deployments by creating a YAML config file and using the `deploy` -command. - -By default, Rocat will look for a `rocat.yml` file but you may specifiy an alternate file with the -`--config` argument. +Rocat configuration is typically defined in a `rocat.yml` file. Rocat will look for a configuration +file in the provided directory. ```yml # rocat.yml -placeFiles: - start: start-place.rbxlx - -deployments: - - name: staging - branches: [dev, experiments/*] - deployMode: Save # optional; defaults to Publish - experienceId: 7067418676 - placeIds: - start: 8468630367 - - name: production - branches: [main] - tagCommit: true # optional; defaults to false - experienceId: 6428418832 - placeIds: - start: 4927604916 -``` - -With the above configuration, we are telling Rocat that when the `deploy` command is run on the -`dev` branch, it should save the `start-place.rbxl` file to the experience/place specified in the -`staging` environment, and when it is run on the `main` branch, it should publish the -`start-place.rbxl` file to the experience/place specified in the `production` environment. - -You can perform the deployment by running the `deploy` command: - -```sh -rocat deploy -``` - -If the current git branch does not match any of the provided configurations, the tool will return a -success exit code but will not do anything. -### Multi-file projects - -If your project consists of more than just a start place, you can simply add new keys to the -`placeFiles` and `placeIds` fields: - -```yml -# rocat.yml placeFiles: - start: start-place.rbxl + start: start-place.rbxlx world: world-place.rbxl deployments: - name: staging - branches: [dev, experiments/*] - deployMode: Save # optional; defaults to Publish + branches: [dev, dev/*] + deployMode: save # optional; defaults to Publish experienceId: 7067418676 placeIds: start: 8468630367 @@ -122,34 +68,6 @@ deployments: placeIds: start: 4927604916 world: 7618543001 -``` - -When the `deploy` command is run with this configuration, the same deployments will be made as with -the above single-file configuration, except that both the `start-place.rbxl` and `world-place.rbxl` -files will be uploaded to their respective places. - -### Configuration templates - -You can additionally configure templates which will be applied to the experience and places: - -```yml -# rocat.yml -placeFiles: - start: start-place.rbxlx - -deployments: - - name: staging - branches: [dev, experiments/*] - deployMode: save # optional; defaults to Publish - experienceId: 7067418676 - placeIds: - start: 8468630367 - - name: production - branches: [main] - tagCommit: true # optional; defaults to false - experienceId: 6428418832 - placeIds: - start: 4927604916 templates: experience: @@ -164,6 +82,11 @@ templates: avatarType: r15 # or `r6` or `playerChoice` avatarAnimationType: playerChoice # or `standard` avatarCollisionType: outerBox # or `innerBox` + icon: game-icon.png + thumbnails: + - game-thumbnail-1.png + - game-thumbnail-2.png + - game-thumbnail-3.png places: start: name: The Best Experience Ever @@ -175,14 +98,32 @@ templates: allowCopying: false ``` -When this deployment is run, the experience will be configured on Roblox with the provided settings, -and the start place will be configured on Roblox with the provided settings. This feature requires -the `ROBLOSECURITY` authentication (see above for details). +To deploy the above configuration with Rocat, run `rocat deploy` from the file's directory. + +If the current git branch does not match any of the provided configurations, the tool will return a +success exit code but will not do anything. + +Rocat outputs a `.rocat-state.yml` file which is required by future runs of `rocat deploy` to ensure +the appropriate changes are applied. See [workflows](#workflows) for more information on how to use +this file. + +### Workflows + +Since Rocat requires the state file to be present, there are currently two recommended workflows: + +1. Manual: Include your `.rocat-state.yml` file in your git repo, and only deploy with Rocat by + manually running `rocat deploy`, then check in any changes to the file to your repo. +2. Automated: Do not include your `.rocat-state.yml` file in your git repo, and never deploy with + Rocat by manually running `rocat deploy`. Instead, use a CI tool like GitHub Actions to deploy + with Rocat, and cache the `.rocat-state.yml` file between runs. TODO: create an example GH + Workflow. ### GitHub Actions Combined with the [Roblox/setup-forman](https://github.com/Roblox/setup-foreman) Action, it is easy -to create a workflow to deploy your places using Rocat. +to create a workflow to deploy your places using Rocat. Note that this example does not currently +cache the `.rocat-state.yml` file and so it will not work as expected. See [workflows](#workflows) +for more info. Here is an example for a fully-managed Rojo project: diff --git a/project-fixtures/multi-places/.rocat-state.yml b/project-fixtures/multi-places/.rocat-state.yml new file mode 100644 index 0000000..e41db52 --- /dev/null +++ b/project-fixtures/multi-places/.rocat-state.yml @@ -0,0 +1,16 @@ +--- +version: "1" +state: + experience: + assetId: 3028808377 + icon: ~ + thumbnails: ~ + places: + world: + assetId: 7844085774 + hash: 4d3f78c80cbcf5ba8f104555f2bbd9c8f7485d3970de9fbbba5d2c7bc1219ffc + version: 4 + start: + assetId: 7818935418 + hash: 4d3f78c80cbcf5ba8f104555f2bbd9c8f7485d3970de9fbbba5d2c7bc1219ffc + version: 12 diff --git a/project-fixtures/single-place/.rocat-state.yml b/project-fixtures/single-place/.rocat-state.yml new file mode 100644 index 0000000..6898935 --- /dev/null +++ b/project-fixtures/single-place/.rocat-state.yml @@ -0,0 +1,147 @@ +--- +- resourceType: experience + id: singleton + inputs: + configuration: + value: + genre: Pirate + playableDevices: + - computer + isFriendsOnly: true + allowPrivateServers: ~ + privateServerPrice: ~ + isForSale: ~ + price: ~ + studioAccessToApisAllowed: true + permissions: ~ + universeAvatarType: MorphToR15 + universeAnimationType: playerChoice + universeCollisionType: outerBox + outputs: + assetId: 3028808377 +- resourceType: experience_activation + id: singleton + inputs: + experienceId: + ref: + - experience + - singleton + - assetId + isActive: + value: true + outputs: ~ +- resourceType: experience_icon + id: game-icon.png + inputs: + experienceId: + ref: + - experience + - singleton + - assetId + fileHash: + value: 787f02689d554fd858b6db2e912179524d348a74ba23cffcc9415815e2a27b33 + filePath: + value: game-icon.png + outputs: + assetId: 34487854 +- resourceType: experience_thumbnail + id: game-thumbnail-1.png + inputs: + experienceId: + ref: + - experience + - singleton + - assetId + fileHash: + value: c1811300860fcd79a178142a4f4f7aa73198afa3b64a1b3ae19fc50235e7fa75 + filePath: + value: game-thumbnail-1.png + outputs: + assetId: 50476156 +- resourceType: experience_thumbnail + id: game-thumbnail-2.png + inputs: + experienceId: + ref: + - experience + - singleton + - assetId + fileHash: + value: d36757cf3312ca2683eb597bed3359367861cd3e4f1c71668fef24f86edb3a12 + filePath: + value: game-thumbnail-2.png + outputs: + assetId: 50476158 +- resourceType: experience_thumbnail + id: game-thumbnail-3.png + inputs: + experienceId: + ref: + - experience + - singleton + - assetId + fileHash: + value: 66c276e730608cac1c95be4dabd9e12303b477fe6091772b3e998067ed6e3da0 + filePath: + value: game-thumbnail-3.png + outputs: + assetId: 50476181 +- resourceType: experience_thumbnail_order + id: singleton + inputs: + assetIds: + refList: + - - experience_thumbnail + - game-thumbnail-1.png + - assetId + - - experience_thumbnail + - game-thumbnail-2.png + - assetId + - - experience_thumbnail + - game-thumbnail-3.png + - assetId + experienceId: + ref: + - experience + - singleton + - assetId + outputs: ~ +- resourceType: place_configuration + id: start + inputs: + assetId: + ref: + - place_file + - start + - assetId + configuration: + value: + name: Start name + description: Start description + maxPlayerCount: 50 + allowCopying: false + socialSlotType: Custom + customSocialSlotCount: 10 + experienceId: + ref: + - experience + - singleton + - assetId + outputs: ~ +- resourceType: place_file + id: start + inputs: + deployMode: + value: publish + experienceId: + ref: + - experience + - singleton + - assetId + fileHash: + value: 4d3f78c80cbcf5ba8f104555f2bbd9c8f7485d3970de9fbbba5d2c7bc1219ffc + filePath: + value: start.rbxlx + outputs: + assetId: 7818935418 + version: 16 diff --git a/project-fixtures/single-place/rocat.yml b/project-fixtures/single-place/rocat.yml index 4f8bed9..048837c 100644 --- a/project-fixtures/single-place/rocat.yml +++ b/project-fixtures/single-place/rocat.yml @@ -24,6 +24,11 @@ templates: avatarType: r15 avatarAnimationType: playerChoice avatarCollisionType: outerBox + icon: game-icon.png + thumbnails: + - game-thumbnail-1.png + - game-thumbnail-2.png + - game-thumbnail-3.png places: start: name: Start name diff --git a/src/cli.rs b/src/cli.rs index 9843c12..3c06bb6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,50 +8,6 @@ fn get_app() -> App<'static, 'static> { .version(crate_version!()) .about("Manages Roblox deployments") .setting(AppSettings::ArgRequiredElseHelp) - .subcommand( - SubCommand::with_name("save") - .about("Saves a project file to a Roblox place") - .arg( - Arg::with_name("FILE") - .required(true) - .index(1) - .takes_value(true), - ) - .arg( - Arg::with_name("EXPERIENCE_ID") - .required(true) - .index(2) - .takes_value(true), - ) - .arg( - Arg::with_name("PLACE_ID") - .required(true) - .index(3) - .takes_value(true), - ), - ) - .subcommand( - SubCommand::with_name("publish") - .about("Publishes a project file to a Roblox place") - .arg( - Arg::with_name("FILE") - .required(true) - .index(1) - .takes_value(true), - ) - .arg( - Arg::with_name("EXPERIENCE_ID") - .required(true) - .index(2) - .takes_value(true), - ) - .arg( - Arg::with_name("PLACE_ID") - .required(true) - .index(3) - .takes_value(true), - ), - ) .subcommand( SubCommand::with_name("deploy") .about("Saves a project file to a Roblox place") @@ -68,16 +24,6 @@ pub fn run_with(args: Vec) -> Result<(), String> { let app = get_app(); let matches = app.get_matches_from(args); match matches.subcommand() { - ("save", Some(save_matches)) => commands::save::run( - save_matches.value_of("FILE").unwrap(), - save_matches.value_of("EXPERIENCE_ID").unwrap(), - save_matches.value_of("PLACE_ID").unwrap(), - ), - ("publish", Some(publish_matches)) => commands::publish::run( - publish_matches.value_of("FILE").unwrap(), - publish_matches.value_of("EXPERIENCE_ID").unwrap(), - publish_matches.value_of("PLACE_ID").unwrap(), - ), ("deploy", Some(deploy_matches)) => { commands::deploy::run(deploy_matches.value_of("PROJECT")) } diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 28581b4..f615148 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -1,59 +1,67 @@ -use crate::roblox_api::{ - DeployMode, ExperienceAnimationType, ExperienceAvatarType, ExperienceCollisionType, - ExperienceConfigurationModel, ExperienceGenre, ExperiencePermissionsModel, - ExperiencePlayableDevice, PlaceConfigurationModel, RobloxApi, SocialSlotType, +use crate::{ + resource_manager::{resource_types, AssetId, RobloxResourceManager, SINGLETON_RESOURCE_ID}, + resources::{InputRef, Resource, ResourceGraph, ResourceManager}, + roblox_api::{ + DeployMode, ExperienceAnimationType, ExperienceAvatarType, ExperienceCollisionType, + ExperienceConfigurationModel, ExperienceGenre, ExperiencePermissionsModel, + ExperiencePlayableDevice, PlaceConfigurationModel, SocialSlotType, + }, }; -use crate::roblox_auth::RobloxAuth; use serde::Deserialize; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::str; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + process::Command, + str, +}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct Config { +pub struct Config { #[serde(default = "HashMap::new")] - place_files: HashMap, + pub place_files: HashMap, #[serde(default = "Vec::new")] - deployments: Vec, + pub deployments: Vec, #[serde(default)] - templates: TemplateConfig, + pub templates: TemplateConfig, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] -struct DeploymentConfig { - name: Option, +pub struct DeploymentConfig { + pub name: String, #[serde(default = "Vec::new")] - branches: Vec, + pub branches: Vec, - deploy_mode: Option, + #[serde(default)] + pub deploy_mode: DeployMode, - tag_commit: Option, + #[serde(default)] + pub tag_commit: bool, - experience_id: Option, + pub experience_id: u64, - place_ids: Option>, + pub place_ids: HashMap, } #[derive(Deserialize, Default)] #[serde(rename_all = "camelCase")] -struct TemplateConfig { - experience: Option, +pub struct TemplateConfig { + pub experience: Option, #[serde(default = "HashMap::new")] - places: HashMap, + pub places: HashMap, } //isFriendsOnly: true/false //setActive(true/false) -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub enum GenreConfig { All, @@ -75,49 +83,49 @@ pub enum GenreConfig { #[derive(Deserialize, Clone, Copy)] #[serde(rename_all = "camelCase")] -enum PlayabilityConfig { +pub enum PlayabilityConfig { Private, Public, Friends, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] -enum AvatarTypeConfig { +pub enum AvatarTypeConfig { R6, R15, PlayerChoice, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ExperienceTemplateConfig { // basic info - genre: Option, - playable_devices: Option>, - // icon: Option, // TODO: call the upload icon api - // thumbnails: Option> // TODO: call the upload thumbnails api + pub genre: Option, + pub playable_devices: Option>, + pub icon: Option, + pub thumbnails: Option>, // permissions - playability: Option, + pub playability: Option, // monetization // badges: // TODO: create badges - paid_access_price: Option, - private_server_price: Option, + pub paid_access_price: Option, + pub private_server_price: Option, // developer products: // TODO: create developer products // security - enable_studio_access_to_apis: Option, - allow_third_party_sales: Option, - allow_third_party_teleports: Option, + pub enable_studio_access_to_apis: Option, + pub allow_third_party_sales: Option, + pub allow_third_party_teleports: Option, // localization: // TODO: localization // avatar - avatar_type: Option, - avatar_animation_type: Option, - avatar_collision_type: Option, + pub avatar_type: Option, + pub avatar_animation_type: Option, + pub avatar_collision_type: Option, // avatar_asset_overrides: Option>, // TODO: figure out api // avatar_scale_constraints: Option>, // TODO: figure out api @@ -194,26 +202,26 @@ impl From<&ExperienceTemplateConfig> for ExperienceConfigurationModel { } } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] -enum ServerFillConfig { +pub enum ServerFillConfig { RobloxOptimized, Maximum, ReservedSlots(u32), } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct PlaceTemplateConfig { - name: Option, - description: Option, - max_player_count: Option, - allow_copying: Option, - server_fill: Option, + pub name: Option, + pub description: Option, + pub max_player_count: Option, + pub allow_copying: Option, + pub server_fill: Option, } -impl From<&PlaceTemplateConfig> for PlaceConfigurationModel { - fn from(config: &PlaceTemplateConfig) -> Self { +impl From for PlaceConfigurationModel { + fn from(config: PlaceTemplateConfig) -> Self { PlaceConfigurationModel { name: config.name.clone(), description: config.description.clone(), @@ -297,12 +305,7 @@ fn parse_project(project: Option<&str>) -> Result<(PathBuf, PathBuf), String> { )) } -pub fn run(project: Option<&str>) -> Result<(), String> { - let (project_path, config_file) = parse_project(project)?; - println!("šŸ“ƒ Config file: {}", config_file.display()); - - let config = load_config_file(&config_file)?; - +fn get_current_branch() -> Result { let output = run_command("git symbolic-ref --short HEAD"); let result = match output { Ok(v) => v, @@ -323,115 +326,245 @@ pub fn run(project: Option<&str>) -> Result<(), String> { return Err("Unable to determine git branch. Are you in a git repository?".to_string()); } - println!("šŸŒæ Git branch: {}", current_branch); + Ok(current_branch.to_owned()) +} - let deployment = config - .deployments - .iter() - .find(|deployment| match_branch(current_branch, &deployment.branches)); +fn get_state_file_path(project_path: &Path) -> PathBuf { + project_path.join(".rocat-state.yml") +} - let deployment = match deployment { - Some(v) => v, - None => { - println!("āœ… No deployment configuration found for branch; no deployment necessary."); - return Ok(()); - } - }; +pub fn get_hash(data: &[u8]) -> String { + let digest = Sha256::digest(data); + format!("{:x}", digest) +} - let deployment_name = match &deployment.name { - Some(v) => v, - None => return Err("Deployment configuration does not contain a name.".to_string()), - }; +pub fn get_file_hash(file_path: &Path) -> Result { + let buffer = fs::read(file_path).map_err(|e| { + format!( + "Failed to read file {} for hashing: {}", + file_path.display(), + e + ) + })?; + Ok(get_hash(&buffer)) +} - let mode = match deployment.deploy_mode.unwrap_or(DeployMode::Publish) { - DeployMode::Publish => DeployMode::Publish, - DeployMode::Save => DeployMode::Save, - }; +pub fn get_previous_graph( + project_path: &Path, + config: &Config, + deployment_config: &DeploymentConfig, +) -> Result { + let state_file_path = get_state_file_path(project_path); + + if !state_file_path.exists() { + let mut resources: Vec = Vec::new(); + + let mut experience = Resource::new(resource_types::EXPERIENCE, SINGLETON_RESOURCE_ID) + .add_output::("assetId", &deployment_config.experience_id.clone())? + .clone(); + let experience_asset_id_ref = experience.get_input_ref("assetId"); + if config.templates.experience.is_some() { + experience.add_value_stub_input("configuration"); + } + resources.push(experience.clone()); + + for (name, id) in deployment_config.place_ids.iter() { + let place_file = config + .place_files + .get(name) + .ok_or(format!("No place file configured for place {}", name))?; + let place_file_resource = Resource::new(resource_types::PLACE_FILE, name) + .add_output("assetId", &id)? + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_value_input("filePath", &place_file)? + .add_value_stub_input("fileHash") + .add_value_stub_input("version") + .add_value_stub_input("deployMode") + .clone(); + let place_file_asset_id_ref = place_file_resource.get_input_ref("assetId"); + resources.push(place_file_resource); + if config.templates.places.contains_key(name) { + resources.push( + Resource::new(resource_types::PLACE_CONFIGURATION, name) + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_ref_input("assetId", &place_file_asset_id_ref) + .add_value_stub_input("configuration") + .clone(), + ); + } + } - let should_tag = deployment.tag_commit.unwrap_or(false); + return Ok(ResourceGraph::new(&resources)); + } - let experience_id = match deployment.experience_id { - Some(v) => v, - None => { - return Err(format!( - "No experience_id configuration found for branch {}", - current_branch - )) - } - }; + let data = fs::read_to_string(&state_file_path).map_err(|e| { + format!( + "Unable to read state file: {}\n\t{}", + state_file_path.display(), + e + ) + })?; + + let resources = serde_yaml::from_str::>(&data).map_err(|e| { + format!( + "Unable to parse state file {}\n\t{}", + state_file_path.display(), + e + ) + })?; + + Ok(ResourceGraph::new(&resources)) +} - let place_ids = match &deployment.place_ids { - Some(v) => v, - None => { - return Err(format!( - "No place_ids configuration found for branch {}.", - current_branch - )) +pub fn get_desired_graph( + project_path: &Path, + config: &Config, + deployment_config: &DeploymentConfig, +) -> Result { + let mut resources: Vec = Vec::new(); + + let mut experience = Resource::new(resource_types::EXPERIENCE, SINGLETON_RESOURCE_ID) + .add_output::("assetId", &deployment_config.experience_id.clone())? + .clone(); + let experience_asset_id_ref = experience.get_input_ref("assetId"); + if let Some(experience_configuration) = &config.templates.experience { + experience.add_value_input::( + "configuration", + &experience_configuration.into(), + )?; + resources.push( + Resource::new(resource_types::EXPERIENCE_ACTIVATION, SINGLETON_RESOURCE_ID) + .add_value_input( + "isActive", + &!matches!( + experience_configuration.playability, + Some(PlayabilityConfig::Private) + ), + )? + .add_ref_input("experienceId", &experience_asset_id_ref) + .clone(), + ); + } + resources.push(experience.clone()); + + for (name, id) in deployment_config.place_ids.iter() { + let place_file = config + .place_files + .get(name) + .ok_or(format!("No place file configured for place {}", name))?; + let place_file_resource = Resource::new(resource_types::PLACE_FILE, name) + .add_output("assetId", &id)? + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_value_input("filePath", &place_file)? + .add_value_input( + "fileHash", + &get_file_hash(project_path.join(place_file).as_path())?, + )? + .add_value_input("deployMode", &deployment_config.deploy_mode)? + .clone(); + let place_file_asset_id_ref = place_file_resource.get_input_ref("assetId"); + resources.push(place_file_resource); + if let Some(place_configuration) = config.templates.places.get(name) { + resources.push( + Resource::new(resource_types::PLACE_CONFIGURATION, name) + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_ref_input("assetId", &place_file_asset_id_ref) + .add_value_input::( + "configuration", + &place_configuration.clone().into(), + )? + .clone(), + ); } - }; + } - println!("šŸŒŽ Deployment configuration:"); - println!("\tName: {}", deployment_name); - println!("\tDeploy mode: {}", mode); - println!( - "\tTag commit: {}", - match should_tag { - true => "Yes", - false => "No", + if let Some(experience_configuration) = &config.templates.experience { + if let Some(file_path) = &experience_configuration.icon { + resources.push( + Resource::new(resource_types::EXPERIENCE_ICON, file_path) + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_value_input("filePath", file_path)? + .add_value_input( + "fileHash", + &get_file_hash(project_path.join(file_path).as_path())?, + )? + .clone(), + ); + } + if let Some(thumbnails) = &experience_configuration.thumbnails { + let mut thumbnail_asset_id_refs: Vec = Vec::new(); + for file_path in thumbnails { + let thumbnail_resource = + Resource::new(resource_types::EXPERIENCE_THUMBNAIL, file_path) + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_value_input("filePath", file_path)? + .add_value_input( + "fileHash", + &get_file_hash(project_path.join(file_path).as_path())?, + )? + .clone(); + thumbnail_asset_id_refs.push(thumbnail_resource.get_input_ref("assetId")); + resources.push(thumbnail_resource); + } + resources.push( + Resource::new( + resource_types::EXPERIENCE_THUMBNAIL_ORDER, + SINGLETON_RESOURCE_ID, + ) + .add_ref_input("experienceId", &experience_asset_id_ref) + .add_ref_input_list("assetIds", &thumbnail_asset_id_refs) + .clone(), + ); } - ); - println!("\tExperience ID: {}", experience_id); - println!("\tPlace IDs:"); - for (name, place_id) in place_ids.iter() { - println!("\t\t{}: {}", name, place_id); } - let mut roblox_api = RobloxApi::new(RobloxAuth::new()); + Ok(ResourceGraph::new(&resources)) +} - if let Some(experience_template) = &config.templates.experience { - println!("šŸ”§ Configuring experience"); - roblox_api.configure_experience(experience_id, &experience_template.into())?; - if let Some(playability) = experience_template.playability { - roblox_api.set_experience_active( - experience_id, - !matches!(playability, PlayabilityConfig::Private), - )?; - } - } +fn save_state(project_path: &Path, resources: &[Resource]) -> Result<(), String> { + let state_file_path = get_state_file_path(project_path); - for (name, place_file) in config.place_files.iter() { - println!("šŸš€ Deploying place: {}", name); + let data = serde_yaml::to_vec(&resources) + .map_err(|e| format!("Unable to serialize state\n\t{}", e))?; - let place_id = match place_ids.get(name) { - Some(v) => v, - None => return Err(format!("No place ID found for configured place {}", name)), - }; + fs::write(&state_file_path, data).map_err(|e| { + format!( + "Unable to write state file: {}\n\t{}", + state_file_path.display(), + e + ) + })?; - let place_template = config.templates.places.get(name); - if place_template.is_some() { - println!("\tšŸ”§ Configuring place"); - roblox_api.configure_place(*place_id, &place_template.unwrap().into())?; - } + Ok(()) +} - let upload_result = roblox_api.upload_place( - &project_path.join(place_file), - experience_id, - *place_id, - mode, - )?; +pub fn run(project: Option<&str>) -> Result<(), String> { + let (project_path, config_file) = parse_project(project)?; + + let config = load_config_file(&config_file)?; + + let current_branch = get_current_branch()?; - if should_tag { - let tag = format!("{}-v{}", name, upload_result.place_version); - println!("\tšŸ”– Tagging commit with: {}", tag); + let deployment_config = config + .deployments + .iter() + .find(|deployment| match_branch(¤t_branch, &deployment.branches)); - run_command(&format!("git tag {}", tag)) - .map_err(|e| format!("Unable to tag the current commit\n\t{}", e))?; + let deployment_config = match deployment_config { + Some(v) => v, + None => { + println!("No deployment configuration found for branch; no deployment necessary."); + return Ok(()); } - } + }; - if should_tag { - run_command("git push --tags").map_err(|e| format!("Unable to push the tags\n\t{}", e))?; - } + let mut resource_manager = + ResourceManager::new(Box::new(RobloxResourceManager::new(&project_path))); + let previous_graph = get_previous_graph(project_path.as_path(), &config, deployment_config)?; + let mut next_graph = get_desired_graph(project_path.as_path(), &config, deployment_config)?; + next_graph.resolve(&mut resource_manager, &previous_graph)?; + let resources = next_graph.get_resource_list(); + save_state(&project_path, &resources)?; Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a3179aa..61d7d29 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1 @@ pub mod deploy; -pub mod publish; -pub mod save; diff --git a/src/commands/publish.rs b/src/commands/publish.rs deleted file mode 100644 index 381af32..0000000 --- a/src/commands/publish.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::path::Path; - -use crate::{ - roblox_api::{DeployMode, RobloxApi}, - roblox_auth::RobloxAuth, -}; - -pub fn run(place_file: &str, experience_id: &str, place_id: &str) -> Result<(), String> { - let parsed_experience_id = match experience_id.parse::() { - Ok(v) => v, - Err(e) => return Err(format!("Invalid EXPERIENCE_ID: {}\n\t{}", experience_id, e)), - }; - - let parsed_place_id = match place_id.parse::() { - Ok(v) => v, - Err(e) => return Err(format!("Invalid PLACE_ID: {}\n\t{}", place_id, e)), - }; - - println!("āœ… Configuration:"); - println!("\tExperience ID: {}", experience_id); - println!("\tPlace ID: {}", place_id); - - println!("šŸš€ Publishing place"); - - let mut roblox_api = RobloxApi::new(RobloxAuth::new()); - - roblox_api.upload_place( - Path::new(place_file), - parsed_experience_id, - parsed_place_id, - DeployMode::Publish, - )?; - - Ok(()) -} diff --git a/src/commands/save.rs b/src/commands/save.rs deleted file mode 100644 index fe84696..0000000 --- a/src/commands/save.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::path::Path; - -use crate::{ - roblox_api::{DeployMode, RobloxApi}, - roblox_auth::RobloxAuth, -}; - -pub fn run(place_file: &str, experience_id: &str, place_id: &str) -> Result<(), String> { - let parsed_experience_id = match experience_id.parse::() { - Ok(v) => v, - Err(e) => return Err(format!("Invalid EXPERIENCE_ID: {}\n\t{}", experience_id, e)), - }; - - let parsed_place_id = match place_id.parse::() { - Ok(v) => v, - Err(e) => return Err(format!("Invalid PLACE_ID: {}\n\t{}", place_id, e)), - }; - - println!("āœ… Configuration:"); - println!("\tExperience ID: {}", experience_id); - println!("\tPlace ID: {}", place_id); - - println!("šŸš€ Saving place"); - - let mut roblox_api = RobloxApi::new(RobloxAuth::new()); - - roblox_api.upload_place( - Path::new(place_file), - parsed_experience_id, - parsed_place_id, - DeployMode::Save, - )?; - - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index a96f2e2..86571e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ mod cli; mod commands; +mod resource_manager; +mod resources; mod roblox_api; mod roblox_auth; diff --git a/src/resource_manager.rs b/src/resource_manager.rs new file mode 100644 index 0000000..2da114f --- /dev/null +++ b/src/resource_manager.rs @@ -0,0 +1,277 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + resources::ResourceManagerBackend, + roblox_api::{ + DeployMode, ExperienceConfigurationModel, PlaceConfigurationModel, RobloxApi, + UploadImageResult, UploadPlaceResult, + }, + roblox_auth::RobloxAuth, +}; + +pub type AssetId = u64; + +pub mod resource_types { + pub const EXPERIENCE: &str = "experience"; + pub const EXPERIENCE_ACTIVATION: &str = "experience_activation"; + pub const EXPERIENCE_ICON: &str = "experience_icon"; + pub const EXPERIENCE_THUMBNAIL: &str = "experience_thumbnail"; + pub const EXPERIENCE_THUMBNAIL_ORDER: &str = "experience_thumbnail_order"; + pub const PLACE_FILE: &str = "place_file"; + pub const PLACE_CONFIGURATION: &str = "place_configuration"; +} + +pub const SINGLETON_RESOURCE_ID: &str = "singleton"; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceInputs { + configuration: ExperienceConfigurationModel, +} +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceOutputs { + asset_id: AssetId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceActivationInputs { + experience_id: AssetId, + is_active: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceThumbnailInputs { + experience_id: AssetId, + file_path: String, + file_hash: String, +} +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceThumbnailOutputs { + asset_id: AssetId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceIconInputs { + experience_id: AssetId, + file_path: String, + file_hash: String, +} +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceIconOutputs { + asset_id: AssetId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ExperienceThumbnailOrderInputs { + experience_id: AssetId, + asset_ids: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct PlaceFileInputs { + experience_id: AssetId, + file_path: String, + file_hash: String, + deploy_mode: DeployMode, +} +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct PlaceFileOutputs { + #[serde(default)] + version: u32, + asset_id: AssetId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct PlaceConfigurationInputs { + experience_id: AssetId, + asset_id: AssetId, + configuration: PlaceConfigurationModel, +} + +pub struct RobloxResourceManager { + roblox_api: RobloxApi, + project_path: PathBuf, +} + +impl RobloxResourceManager { + pub fn new(project_path: &Path) -> Self { + Self { + roblox_api: RobloxApi::new(RobloxAuth::new()), + project_path: project_path.to_path_buf(), + } + } +} + +impl ResourceManagerBackend for RobloxResourceManager { + fn create( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + ) -> Result, String> { + // println!( + // "CREATE: {} {}", + // resource_type, + // serde_yaml::to_string(&resource_inputs).map_err(|_| "".to_owned())? + // ); + match resource_type { + resource_types::EXPERIENCE_ACTIVATION => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + + self.roblox_api + .set_experience_active(inputs.experience_id, inputs.is_active)?; + + Ok(None) + } + resource_types::EXPERIENCE_ICON => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + + let UploadImageResult { asset_id } = self.roblox_api.upload_icon( + inputs.experience_id, + self.project_path.join(inputs.file_path).as_path(), + )?; + + Ok(Some( + serde_yaml::to_value(ExperienceIconOutputs { asset_id }) + .map_err(|e| format!("Failed to serialize outputs: {}", e))?, + )) + } + resource_types::EXPERIENCE_THUMBNAIL => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + + let UploadImageResult { asset_id } = self.roblox_api.upload_thumbnail( + inputs.experience_id, + self.project_path.join(inputs.file_path).as_path(), + )?; + + Ok(Some( + serde_yaml::to_value(ExperienceThumbnailOutputs { asset_id }) + .map_err(|e| format!("Failed to serialize outputs: {}", e))?, + )) + } + resource_types::EXPERIENCE_THUMBNAIL_ORDER => { + let inputs = + serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + + self.roblox_api + .set_experience_thumbnail_order(inputs.experience_id, &inputs.asset_ids)?; + + Ok(None) + } + _ => panic!( + "Create not implemented for resource type: {}", + resource_type + ), + } + } + + fn update( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result, String> { + // println!("UPDATE: {} {:?}", resource_type, resource_inputs); + match resource_type { + resource_types::EXPERIENCE => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + let outputs = serde_yaml::from_value::(resource_outputs) + .map_err(|e| format!("Failed to deserialize outputs: {}", e))?; + + self.roblox_api + .configure_experience(outputs.asset_id, &inputs.configuration)?; + + Ok(None) + } + resource_types::EXPERIENCE_ACTIVATION => self.create(resource_type, resource_inputs), + resource_types::EXPERIENCE_ICON => self.create(resource_type, resource_inputs), + resource_types::EXPERIENCE_THUMBNAIL => { + self.delete(resource_type, resource_inputs.clone(), resource_outputs)?; + self.create(resource_type, resource_inputs) + } + resource_types::EXPERIENCE_THUMBNAIL_ORDER => { + self.create(resource_type, resource_inputs) + } + resource_types::PLACE_FILE => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + let outputs = serde_yaml::from_value::(resource_outputs) + .map_err(|e| format!("Failed to deserialize outputs: {}", e))?; + + let UploadPlaceResult { place_version } = self.roblox_api.upload_place( + self.project_path.join(inputs.file_path).as_path(), + inputs.experience_id, + outputs.asset_id, + inputs.deploy_mode, + )?; + + Ok(Some( + serde_yaml::to_value(PlaceFileOutputs { + version: place_version, + asset_id: outputs.asset_id, + }) + .map_err(|e| format!("Failed to serialize outputs: {}", e))?, + )) + } + resource_types::PLACE_CONFIGURATION => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + + self.roblox_api + .configure_place(inputs.asset_id, &inputs.configuration)?; + + Ok(None) + } + _ => panic!( + "Update not implemented for resource type: {}", + resource_type + ), + } + } + + fn delete( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result<(), String> { + // println!("DELETE: {} {:?}", resource_type, resource_outputs); + match resource_type { + resource_types::EXPERIENCE_ICON => { + // TODO: figure out which endpoint to use to delete an icon + Ok(()) + } + resource_types::EXPERIENCE_THUMBNAIL => { + let inputs = serde_yaml::from_value::(resource_inputs) + .map_err(|e| format!("Failed to deserialize inputs: {}", e))?; + let outputs = + serde_yaml::from_value::(resource_outputs) + .map_err(|e| format!("Failed to deserialize outputs: {}", e))?; + + self.roblox_api + .delete_experience_thumbnail(inputs.experience_id, outputs.asset_id) + } + resource_types::EXPERIENCE_THUMBNAIL_ORDER => Ok(()), + _ => panic!( + "Delete not implemented for resource type: {}", + resource_type + ), + } + } +} diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..b11560a --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,512 @@ +use std::collections::{BTreeMap, HashMap}; + +use difference::{Changeset, Difference}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Resource { + pub resource_type: String, + pub id: String, + pub inputs: BTreeMap, + pub outputs: Option>, +} + +pub struct ResourceDiff { + pub previous_hash: Option, + pub resource: Resource, +} + +impl Resource { + pub fn new(resource_type: &str, id: &str) -> Self { + Resource { + resource_type: resource_type.to_owned(), + id: id.to_owned(), + inputs: BTreeMap::new(), + outputs: None, + } + } + + pub fn add_value_stub_input(&mut self, name: &str) -> &mut Self { + self.inputs + .insert(name.to_owned(), Input::Value(serde_yaml::Value::Null)); + self + } + + pub fn add_value_input(&mut self, name: &str, input_value: &T) -> Result<&mut Self, String> + where + T: serde::Serialize, + { + let serialized_value = serde_yaml::to_value(input_value) + .map_err(|e| format!("Failed to serialize input value:\n\t{}", e))?; + self.inputs + .insert(name.to_owned(), Input::Value(serialized_value)); + Ok(self) + } + + pub fn add_ref_input(&mut self, name: &str, input_ref: &InputRef) -> &mut Self { + self.inputs + .insert(name.to_owned(), Input::Ref(input_ref.clone())); + self + } + + pub fn add_ref_input_list(&mut self, name: &str, input_ref_list: &[InputRef]) -> &mut Self { + self.inputs + .insert(name.to_owned(), Input::RefList(input_ref_list.to_owned())); + self + } + + pub fn add_output(&mut self, name: &str, output_value: &T) -> Result<&mut Self, String> + where + T: serde::Serialize, + { + if self.outputs.is_none() { + self.outputs = Some(BTreeMap::new()); + } + let serialized_value = serde_yaml::to_value(output_value) + .map_err(|e| format!("Failed to serialize output value:\n\t{}", e))?; + self.outputs + .as_mut() + .unwrap() + .insert(name.to_owned(), serialized_value); + Ok(self) + } + + pub fn get_ref(&self) -> ResourceRef { + (self.resource_type.clone(), self.id.clone()) + } + + pub fn get_input_ref(&self, input_ref_output: &str) -> InputRef { + ( + self.resource_type.clone(), + self.id.clone(), + input_ref_output.to_owned(), + ) + } + + fn get_output_from_input_ref(&self, input_ref: &InputRef) -> Result { + if let Some(outputs) = &self.outputs { + let value = outputs + .get(&output_name_from_input_ref(input_ref)) + .ok_or(format!("No output with ref: {:?}", input_ref))?; + Ok(value.clone()) + } else { + return Err(format!( + "Resource {}.{} has no outputs", + self.resource_type, self.id + )); + } + } +} + +pub type InputRef = (String, String, String); +pub type InputValue = serde_yaml::Value; +pub type OutputValue = serde_yaml::Value; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub enum Input { + Ref(InputRef), + RefList(Vec), + Value(InputValue), +} + +pub type ResourceRef = (String, String); + +pub fn resource_ref_from_input_ref(input_ref: &InputRef) -> ResourceRef { + let (input_ref_type, input_ref_id, _) = input_ref; + (input_ref_type.clone(), input_ref_id.clone()) +} + +pub fn output_name_from_input_ref(input_ref: &InputRef) -> String { + let (_, _, input_ref_output) = input_ref; + input_ref_output.clone() +} +pub trait ResourceManagerBackend { + fn create( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + ) -> Result, String>; + + fn update( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result, String>; + + fn delete( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result<(), String>; +} + +pub struct ResourceManager { + implementation: Box, +} + +impl ResourceManager { + pub fn new(implementation: Box) -> Self { + ResourceManager { implementation } + } + + fn create( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + ) -> Result, String> { + self.implementation.create(resource_type, resource_inputs) + } + + fn update( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result, String> { + self.implementation + .update(resource_type, resource_inputs, resource_outputs) + } + + fn delete( + &mut self, + resource_type: &str, + resource_inputs: serde_yaml::Value, + resource_outputs: serde_yaml::Value, + ) -> Result<(), String> { + self.implementation + .delete(resource_type, resource_inputs, resource_outputs) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ResourceGraph { + pub resources: HashMap, +} + +impl ResourceGraph { + pub fn new(resources: &[Resource]) -> Self { + let mut graph = ResourceGraph { + resources: HashMap::new(), + }; + for resource in resources { + graph = graph.add_resource(resource); + } + graph + } + + pub fn add_resource(mut self, resource: &Resource) -> Self { + self.resources.insert(resource.get_ref(), resource.clone()); + self + } + + pub fn get_resource_list(&self) -> Vec { + let mut resources: Vec = self.resources.values().cloned().collect(); + resources.sort_by_key(|a| a.get_ref()); + resources + } + + pub fn get_resource_from_ref(&self, resource_ref: &ResourceRef) -> Option { + self.resources.get(resource_ref).cloned() + } + + fn get_resource_from_input_ref(&self, input_ref: &InputRef) -> Option { + self.get_resource_from_ref(&resource_ref_from_input_ref(input_ref)) + } + + fn resolve_inputs( + &self, + resource: &Resource, + ) -> Result>, String> { + let mut resolved_inputs: BTreeMap = BTreeMap::new(); + for (name, input) in &resource.inputs { + match input { + Input::Value(value) => { + resolved_inputs.insert(name.clone(), value.clone()); + } + Input::Ref(value_ref) => { + let referenced_resource = self + .get_resource_from_input_ref(value_ref) + .ok_or(format!("Reference not found: {:?}", value_ref))?; + if referenced_resource.outputs.is_none() { + return Ok(None); + } + resolved_inputs.insert( + name.clone(), + referenced_resource.get_output_from_input_ref(value_ref)?, + ); + } + Input::RefList(ref_list) => { + let mut resolved_values = Vec::new(); + for value_ref in ref_list { + let referenced_resource = self + .get_resource_from_input_ref(value_ref) + .ok_or(format!("Reference not found: {:?}", value_ref))?; + if referenced_resource.outputs.is_none() { + return Ok(None); + } + resolved_values + .push(referenced_resource.get_output_from_input_ref(value_ref)?); + } + resolved_inputs.insert( + name.clone(), + serde_yaml::to_value(resolved_values).map_err(|e| { + format!("Failed to serialize resolved ref list\n\t{}", e) + })?, + ); + } + } + } + Ok(Some(resolved_inputs)) + } + + pub fn get_inputs_hash(&self, inputs: &BTreeMap) -> Result { + // TODO: should we actually hash this to make comparisons snappier? + serde_yaml::to_string(&inputs).map_err(|e| format!("Failed to compute input hash\n\t{}", e)) + } + + fn log_diff(&self, message: String, changeset: &Changeset, add_pipes: bool) { + let mut lines: Vec = Vec::new(); + for diff in &changeset.diffs { + let diff_lines: Vec = match diff { + Difference::Same(diff) => diff + .split('\n') + .map(|line| Difference::Same(line.to_owned())) + .collect(), + Difference::Add(diff) => diff + .split('\n') + .map(|line| Difference::Add(line.to_owned())) + .collect(), + Difference::Rem(diff) => diff + .split('\n') + .map(|line| Difference::Rem(line.to_owned())) + .collect(), + }; + lines.extend(diff_lines); + } + let prefix = if add_pipes { "ā”‚" } else { " " }; + print!( + "{}\n{}", + message, + lines + .iter() + .map(|d| match d { + Difference::Same(x) => format!(" {} \x1b[90m{}\x1b[0m\n", prefix, x), + Difference::Add(x) => + format!(" {} \x1b[92m+\x1b[0m \x1b[92m{}\x1b[0m\n", prefix, x), + Difference::Rem(x) => + format!(" {} \x1b[91m-\x1b[0m \x1b[91m{}\x1b[0m\n", prefix, x), + }) + .collect::>() + .join("") + ); + } + + fn log_create(&self, resource: &Resource, new_inputs_hash: &str) { + let changeset = Changeset::new("", new_inputs_hash.replace("---", "").trim(), "\n"); + self.log_diff( + format!( + "\x1b[92m+\x1b[0m Creating {} {}:\n ā•·", + resource.resource_type, resource.id + ), + &changeset, + true, + ); + } + + fn log_update(&self, resource: &Resource, previous_inputs_hash: &str, new_inputs_hash: &str) { + let changeset = Changeset::new( + previous_inputs_hash.replace("---", "").trim(), + new_inputs_hash.replace("---", "").trim(), + "\n", + ); + self.log_diff( + format!( + "\x1b[93m~\x1b[0m Updating {} {}:\n ā•·", + resource.resource_type, resource.id, + ), + &changeset, + true, + ); + } + + fn log_delete(&self, resource: &Resource, previous_inputs_hash: &str) { + let changeset = Changeset::new(previous_inputs_hash.replace("---", "").trim(), "", "\n"); + self.log_diff( + format!( + "\x1b[91m-\x1b[0m Deleting {} {}:\n ā•·", + resource.resource_type, resource.id + ), + &changeset, + true, + ); + } + + fn log_success(&self, outputs: &Option) -> Result<(), String> { + println!(" ā”‚"); + if let Some(outputs) = outputs { + let outputs_hash = serde_yaml::to_string(outputs) + .map_err(|e| format!("Failed to serialize outputs:\n\t{}", e))?; + let outputs_hash = outputs_hash.replace("---", ""); + let outputs_hash = outputs_hash.trim(); + let changeset = Changeset::new(outputs_hash, outputs_hash, "\n"); + self.log_diff(" ā•°ā”€ Succeeded with outputs:".to_owned(), &changeset, false); + } else { + println!(" ā•°ā”€ Succeeded!"); + } + println!(); + Ok(()) + } + + fn log_error(&self, error: String) { + println!(" ā”‚"); + println!(" ā•°ā”€ Failed: \x1b[91m{}\x1b[0m", error); + } + + fn get_resource_diffs( + &self, + previous_graph: &ResourceGraph, + ) -> Result, String> { + let mut resource_diffs: Vec = Vec::new(); + for (resource_ref, resource) in &self.resources { + match previous_graph.get_resource_from_ref(resource_ref) { + Some(previous_resource) => { + let previous_inputs = previous_graph + .resolve_inputs(&previous_resource)? + .ok_or("Previous graph should be complete.")?; + resource_diffs.push(ResourceDiff { + previous_hash: Some(previous_graph.get_inputs_hash(&previous_inputs)?), + resource: Resource { + id: resource.id.clone(), + inputs: resource.inputs.clone(), + outputs: previous_resource.outputs.clone(), + resource_type: resource.resource_type.clone(), + }, + }); + } + None => { + resource_diffs.push(ResourceDiff { + previous_hash: None, + resource: resource.clone(), + }); + } + }; + } + Ok(resource_diffs) + } + + pub fn resolve( + &mut self, + resource_manager: &mut ResourceManager, + previous_graph: &ResourceGraph, + ) -> Result<(), String> { + // TODO: Something more elegant than this loop (i.e. build dependency graph) + // TODO: Catch circular dependencies (currently inifinte loops) + // TODO: Print planned changes before actually applying them (for a dry run option) + // TODO: Return Ok even if changes fail so that the state can be updated for requests that did succeed + let mut resource_diffs = self.get_resource_diffs(previous_graph)?; + while !resource_diffs.is_empty() { + let mut next_resource_diffs: Vec = Vec::new(); + + for ResourceDiff { + resource, + previous_hash, + } in resource_diffs.iter() + { + // println!("Resolving resource {:?}", resource.get_ref()); + let resolved_inputs = self.resolve_inputs(resource)?; + match resolved_inputs { + None => { + next_resource_diffs.push(ResourceDiff { + resource: resource.clone(), + previous_hash: previous_hash.clone(), + }); + } + Some(inputs) => { + let inputs_hash = self.get_inputs_hash(&inputs)?; + let outputs = match previous_hash { + None => { + self.log_create(resource, &inputs_hash); + Some(resource_manager.create( + &resource.resource_type, + serde_yaml::to_value(&inputs).map_err(|e| { + format!("Failed to serialize inputs: {}", e) + })?, + )) + } + Some(previous_hash) if *previous_hash != inputs_hash => { + self.log_update(resource, previous_hash, &inputs_hash); + let outputs = resource.outputs.clone().unwrap_or_default(); + Some(resource_manager.update( + &resource.resource_type, + serde_yaml::to_value(inputs).map_err(|e| { + format!("Failed to serialize inputs: {}", e) + })?, + serde_yaml::to_value(outputs).map_err(|e| { + format!("Failed to serialize inputs: {}", e) + })?, + )) + } + _ => None, + }; + let mut new_resource = resource.clone(); + if let Some(outputs) = outputs { + if let Ok(outputs) = outputs { + self.log_success(&outputs)?; + if let Some(outputs) = outputs { + let outputs = serde_yaml::from_value::< + BTreeMap, + >(outputs) + .map_err(|e| format!("Failed to deserialize outputs: {}", e))?; + for (key, value) in outputs { + new_resource.add_output(&key, &value)?; + } + } + } else { + self.log_error(outputs.unwrap_err()); + return Err(format!( + "Failed to create or update resource {} {}", + resource.resource_type, resource.id + )); + } + } + self.resources.insert(new_resource.get_ref(), new_resource); + } + }; + } + + resource_diffs.clear(); + resource_diffs.extend(next_resource_diffs); + } + + for (resource_ref, resource) in previous_graph.resources.iter() { + let resolved_inputs = previous_graph.resolve_inputs(resource)?.unwrap_or_default(); + if self.get_resource_from_ref(resource_ref).is_none() { + self.log_delete(resource, &previous_graph.get_inputs_hash(&resolved_inputs)?); + let outputs = resource.outputs.clone().unwrap_or_default(); + let result = resource_manager.delete( + &resource.resource_type, + serde_yaml::to_value(resolved_inputs) + .map_err(|e| format!("Failed to serialize inputs: {}", e))?, + serde_yaml::to_value(outputs) + .map_err(|e| format!("Failed to serialize outputs: {}", e))?, + ); + if let Err(error) = result { + self.log_error(error); + return Err(format!( + "Failed to delete resource {} {}", + resource.resource_type, resource.id + )); + } else { + self.log_success(&None)?; + } + } + } + + Ok(()) + } +} diff --git a/src/roblox_api.rs b/src/roblox_api.rs index 0e21e06..7a024ad 100644 --- a/src/roblox_api.rs +++ b/src/roblox_api.rs @@ -1,5 +1,7 @@ +use multipart::client::lazy::{Multipart, PreparedFields}; use serde::{Deserialize, Serialize}; -use std::{clone::Clone, ffi::OsStr, fmt, fs, path::Path}; +use serde_json::json; +use std::{clone::Clone, default, ffi::OsStr, fmt, fs, path::Path}; use crate::roblox_auth::RobloxAuth; @@ -26,12 +28,17 @@ impl RequestExt for ureq::Request { } } -#[derive(Deserialize, Copy, Clone)] +#[derive(Serialize, Deserialize, Copy, Clone)] #[serde(rename_all = "camelCase")] pub enum DeployMode { Publish, Save, } +impl default::Default for DeployMode { + fn default() -> Self { + DeployMode::Publish + } +} impl fmt::Display for DeployMode { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { @@ -66,7 +73,13 @@ struct RobloxApiErrorModel { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PlaceManagementResponse { - version_number: i32, + version_number: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UploadImageResponse { + target_id: u64, } pub static INVALID_API_KEY_HELP: &str = "\ @@ -77,11 +90,15 @@ pub static INVALID_API_KEY_HELP: &str = "\ \tthat your API key's IP whitelist includes the machine you are running this on. You can set it \n\ \tto '0.0.0.0/0' to whitelist all IPs but this should only be used for testing purposes."; -pub struct UploadResult { - pub place_version: i32, +pub struct UploadPlaceResult { + pub place_version: u32, +} + +pub struct UploadImageResult { + pub asset_id: u64, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub enum ExperienceGenre { All, Adventure, @@ -111,7 +128,7 @@ pub enum ExperiencePlayableDevice { Console, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub enum ExperienceAvatarType { MorphToR6, MorphToR15, @@ -132,14 +149,14 @@ pub enum ExperienceCollisionType { InnerBox, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "PascalCase")] pub struct ExperiencePermissionsModel { pub is_third_party_purchase_allowed: Option, pub is_third_party_teleport_allowed: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ExperienceConfigurationModel { pub genre: Option, @@ -159,14 +176,14 @@ pub struct ExperienceConfigurationModel { pub universe_collision_type: Option, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] pub enum SocialSlotType { Automatic, Empty, Custom, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct PlaceConfigurationModel { pub name: Option, @@ -237,7 +254,9 @@ impl RobloxApi { experience_id: u64, place_id: u64, mode: DeployMode, - ) -> Result { + ) -> Result { + // println!("TRACE: upload_place {}", place_file.display()); + let project_type = match place_file.extension().and_then(OsStr::to_str) { Some("rbxlx") => ProjectType::Xml, Some("rbxl") => ProjectType::Binary, @@ -280,7 +299,6 @@ impl RobloxApi { )) } }; - println!("\tšŸ“¦ Uploading file: {}", place_file.display()); req.send_string(&data) } ProjectType::Binary => { @@ -294,23 +312,15 @@ impl RobloxApi { )) } }; - println!("šŸ“¦ Uploading file: {}", place_file.display()); req.send_bytes(&data) } }; let response = Self::handle_response(res)?; - let model = response.into_json::().unwrap(); - println!( - "\ - \tšŸŽ‰ Successfully {} to Roblox! \n\ - \t\tView place at https://www.roblox.com/games/{} \n\ - \t\tVersion Number: {}", - version_type.to_lowercase(), - place_id, - model.version_number - ); - Ok(UploadResult { + let model = response + .into_json::() + .map_err(|e| format!("Failed to deserialize upload place response: {}", e))?; + Ok(UploadPlaceResult { place_version: model.version_number, }) } @@ -320,6 +330,8 @@ impl RobloxApi { experience_id: u64, experience_configuration: &ExperienceConfigurationModel, ) -> Result<(), String> { + // println!("TRACE: configure_experience {}", experience_id); + let json_data = match serde_json::to_value(&experience_configuration) { Ok(v) => v, Err(e) => { @@ -338,7 +350,6 @@ impl RobloxApi { ), ) .set_auth(AuthType::CookieWithCsrfToken, &mut self.roblox_auth)? - .set("Content-Type", "application/json") .send_json(json_data); Self::handle_response(res)?; @@ -351,6 +362,8 @@ impl RobloxApi { place_id: u64, place_configuration: &PlaceConfigurationModel, ) -> Result<(), String> { + // println!("TRACE: configure_place {}", place_id); + let json_data = match serde_json::to_value(&place_configuration) { Ok(v) => v, Err(e) => return Err(format!("Failed to serialize place configuration\n\t{}", e)), @@ -374,6 +387,8 @@ impl RobloxApi { experience_id: u64, active: bool, ) -> Result<(), String> { + // println!("TRACE: set_experience_active {}", active); + let endpoint = if active { "activate" } else { "deactivate" }; let res = ureq::post(&format!( "https://develop.roblox.com/v1/universes/{}/{}", @@ -386,4 +401,124 @@ impl RobloxApi { Ok(()) } + + fn get_image_from_data(image_file: &Path) -> Result { + let stream = fs::File::open(image_file) + .map_err(|e| format!("Failed to open image file {}: {}", image_file.display(), e))?; + let file_name = Some( + image_file + .file_name() + .and_then(OsStr::to_str) + .ok_or("Unable to determine image name")?, + ); + let mime = Some(mime_guess::from_path(image_file).first_or_octet_stream()); + + let mut multipart = Multipart::new(); + multipart.add_stream("request.files", stream, file_name, mime); + + multipart + .prepare() + .map_err(|e| format!("Failed to load image file {}: {}", image_file.display(), e)) + } + + pub fn upload_icon( + &mut self, + experience_id: u64, + icon_file: &Path, + ) -> Result { + // println!("TRACE: upload_icon {}", icon_file.display()); + + let multipart = Self::get_image_from_data(icon_file)?; + + let res = ureq::post(&format!( + "https://publish.roblox.com/v1/games/{}/icon", + experience_id + )) + .set( + "Content-Type", + &format!("multipart/form-data; boundary={}", multipart.boundary()), + ) + .set_auth(AuthType::CookieWithCsrfToken, &mut self.roblox_auth)? + .send(multipart); + + let response = Self::handle_response(res)?; + let model = response + .into_json::() + .map_err(|e| format!("Failed to deserialize upload image response: {}", e))?; + + Ok(UploadImageResult { + asset_id: model.target_id, + }) + } + + pub fn upload_thumbnail( + &mut self, + experience_id: u64, + thumbnail_file: &Path, + ) -> Result { + // println!("TRACE: upload_thumbnail {}", thumbnail_file.display()); + + let multipart = Self::get_image_from_data(thumbnail_file)?; + + let res = ureq::post(&format!( + "https://publish.roblox.com/v1/games/{}/thumbnail/image", + experience_id + )) + .set( + "Content-Type", + &format!("multipart/form-data; boundary={}", multipart.boundary()), + ) + .set_auth(AuthType::CookieWithCsrfToken, &mut self.roblox_auth)? + .send(multipart); + + let response = Self::handle_response(res)?; + let model = response + .into_json::() + .map_err(|e| format!("Failed to deserialize upload image response: {}", e))?; + + Ok(UploadImageResult { + asset_id: model.target_id, + }) + } + + pub fn set_experience_thumbnail_order( + &mut self, + experience_id: u64, + new_thumbnail_order: &[u64], + ) -> Result<(), String> { + // println!( + // "TRACE: set_experience_thumbnail_order {:?}", + // new_thumbnail_order + // ); + + let res = ureq::post(&format!( + "https://develop.roblox.com/v1/universes/{}/thumbnails/order", + experience_id + )) + .set_auth(AuthType::CookieWithCsrfToken, &mut self.roblox_auth)? + .send_json(json!({ "thumbnailIds": new_thumbnail_order })); + + Self::handle_response(res)?; + + Ok(()) + } + + pub fn delete_experience_thumbnail( + &mut self, + experience_id: u64, + thumbnail_id: u64, + ) -> Result<(), String> { + // println!("TRACE: delete_experience_thumbnail {}", thumbnail_id); + + let res = ureq::delete(&format!( + "https://develop.roblox.com/v1/universes/{}/thumbnails/{}", + experience_id, thumbnail_id + )) + .set_auth(AuthType::CookieWithCsrfToken, &mut self.roblox_auth)? + .send_string(""); + + Self::handle_response(res)?; + + Ok(()) + } }