diff --git a/cli/Cargo.lock b/cli/Cargo.lock index dd2a16d..f821dee 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -129,6 +129,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "antithesis_sdk" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dbd97a5b6c21cc9176891cf715f7f0c273caf3959897f43b9bd1231939e675" +dependencies = [ + "libc", + "libloading", + "linkme", + "once_cell", + "rand 0.8.5", + "rustc_version_runtime", + "serde", + "serde_json", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -144,6 +160,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "assoc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" + [[package]] name = "async-stream" version = "0.3.6" @@ -242,6 +264,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -292,6 +327,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -323,7 +370,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" dependencies = [ "chrono", - "git2", ] [[package]] @@ -377,8 +423,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -397,6 +441,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cfg_block" version = "0.1.1" @@ -516,6 +566,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -615,6 +674,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -782,6 +862,12 @@ dependencies = [ "num", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -887,6 +973,21 @@ version = "0.99.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -904,8 +1005,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -915,20 +1018,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -943,19 +1048,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.11.0", - "libc", - "libgit2-sys", - "log", - "url", -] - [[package]] name = "glob" version = "0.3.3" @@ -1106,6 +1198,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -1136,7 +1245,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1397,16 +1506,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.91" @@ -1464,21 +1563,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -1507,15 +1594,32 @@ dependencies = [ ] [[package]] -name = "libz-sys" -version = "1.1.24" +name = "libredox" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "cc", "libc", - "pkg-config", - "vcpkg", +] + +[[package]] +name = "linkme" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3283ed2d0e50c06dd8602e0ab319bb048b6325d0bba739db64ed8205179898" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5cec0ec4228b4853bb129c84dbf093a27e6c7a20526da046defc334a1b017f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1551,6 +1655,25 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1883,12 +2006,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "pack1" version = "1.0.0" @@ -1965,12 +2100,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "polling" version = "3.11.0" @@ -2107,11 +2236,66 @@ dependencies = [ "syn", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2122,6 +2306,18 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2181,6 +2377,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rapidhash" version = "4.4.1" @@ -2199,6 +2404,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2277,16 +2493,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tower 0.5.3", "tower-http", "tower-service", @@ -2294,6 +2515,21 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] @@ -2327,6 +2563,16 @@ dependencies = [ "semver", ] +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -2353,6 +2599,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2372,6 +2653,7 @@ dependencies = [ "anyhow", "clap", "clap_complete", + "dirs", "hmac", "inquire", "jsonschema", @@ -2381,6 +2663,8 @@ dependencies = [ "opentelemetry_sdk", "portable-pty", "regex", + "reqwest", + "serde", "serde_json", "sha2", "tokio", @@ -2390,6 +2674,12 @@ dependencies = [ "turso", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2547,6 +2837,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shuttle" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" +dependencies = [ + "assoc", + "bitvec", + "cfg-if", + "generator", + "hex", + "owo-colors", + "rand 0.8.5", + "rand_core 0.6.4", + "rand_pcg", + "scoped-tls", + "smallvec", + "tracing", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -2580,9 +2890,9 @@ dependencies = [ [[package]] name = "simsimd" -version = "6.5.13" +version = "6.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f81af684ca3dc160907f1478d779bdfd8bae52f55f4c58caba0229670a83a0d" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" dependencies = [ "cc", ] @@ -2617,12 +2927,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2702,6 +3012,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.26.0" @@ -2709,7 +3025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -2814,17 +3130,32 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", "mio 1.1.1", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -2840,6 +3171,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3058,9 +3399,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "turso" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f2fe423c2c954948babb36edda12b737e321d8541d4eae519694f7d512ecab6" +checksum = "1326cd4c1bb82501328aadee1ca5e0f44fdb5cfe2c7cdc53d3d96aeeecece61a" dependencies = [ "mimalloc", "thiserror 2.0.18", @@ -3072,14 +3413,16 @@ dependencies = [ [[package]] name = "turso_core" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a8b54994ee025964459322bcdb4f6f78c5dba82643863dabfac680f16c8afa8" +checksum = "4befbe4e162cb691a5ff92ebcafc9028574f614cb82cf711d8ed5c89ebf23337" dependencies = [ "aegis", "aes", "aes-gcm", + "antithesis_sdk", "arc-swap", + "bigdecimal", "bitflags 2.11.0", "branches", "built", @@ -3087,6 +3430,7 @@ dependencies = [ "bytemuck", "cfg_block", "chrono", + "crc32c", "crossbeam-skiplist", "either", "fallible-iterator", @@ -3097,7 +3441,10 @@ dependencies = [ "libc", "libloading", "libm", + "loom", "miette", + "num-bigint", + "num-traits", "pack1", "parking_lot", "paste", @@ -3110,7 +3457,10 @@ dependencies = [ "rustc-hash 2.1.1", "rustix 1.1.4", "ryu", + "serde_json", + "shuttle", "simsimd", + "smallvec", "strum", "strum_macros", "tempfile", @@ -3123,13 +3473,14 @@ dependencies = [ "twox-hash", "uncased", "uuid", + "windows-sys 0.61.2", ] [[package]] name = "turso_ext" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de917b4c5881bfb34ccbb1dcf4992773bc39853eacf248955f2ece7e3cb3049" +checksum = "6d314de12a937acadc6a0150433d2f9837f7c49b643d7d6da49275c0713fb8d9" dependencies = [ "chrono", "getrandom 0.3.4", @@ -3138,9 +3489,9 @@ dependencies = [ [[package]] name = "turso_macros" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2f62bb271d4cf202bc2acbeb8e2c3f764ec754924f144e704cdcba2e5b0c84" +checksum = "18687fe83de76951957ce1b3599af16a4627b94c9a17fd4e09c6a28f0bbe0ca7" dependencies = [ "proc-macro2", "quote", @@ -3149,9 +3500,9 @@ dependencies = [ [[package]] name = "turso_parser" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ad89caa1c4888756bd027485499d1dc4c8420d15887ab32aa28b707c411221" +checksum = "c41725404271e703fc734d1b211c53541821dfa2701895ec2d89c85a10e8514d" dependencies = [ "bitflags 2.11.0", "memchr", @@ -3164,12 +3515,13 @@ dependencies = [ [[package]] name = "turso_sdk_kit" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ff5b2cadd6c8b749511648d50c95f69bfa52efc5d88cc2e2deedd0beeb6c89" +checksum = "01827272c218ba5eaa2bdca87cc33e62dbdd2d9d93571a0ea260c7651e61e139" dependencies = [ "bindgen", "env_logger", + "parking_lot", "tracing", "tracing-appender", "tracing-subscriber", @@ -3179,9 +3531,9 @@ dependencies = [ [[package]] name = "turso_sdk_kit_macros" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "289f7ea7499419e6670363ca18e954ed53397bb1e03ab7eabbb267d9b05ab836" +checksum = "ca793575b75a8a891f0d263c1051f60ab3ed2072bdd63fe3331959bf783ad0f3" dependencies = [ "proc-macro2", "quote", @@ -3190,9 +3542,9 @@ dependencies = [ [[package]] name = "turso_sync_engine" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9860c615a7d8df43fc6ac4293636e9d743c1693513c81be22f0e9388624f58" +checksum = "2e040a526375f699f162dc75a64d21b43e193c3dc3f22fcb34e190db8f8ba39a" dependencies = [ "base64", "bytes", @@ -3212,9 +3564,9 @@ dependencies = [ [[package]] name = "turso_sync_sdk_kit" -version = "0.4.4" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b669b19a5f4bfa7cfdf5045af36ca4a2087431c0d2844ec539ddcf951b5c9d2" +checksum = "547f4853402531c949b5c2d343b87b1b7ea709651695ccd207e6072504486cce" dependencies = [ "bindgen", "env_logger", @@ -3287,6 +3639,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3313,11 +3671,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.4.2", "js-sys", "sha1_smol", "wasm-bindgen", @@ -3340,12 +3698,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -3504,6 +3856,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "4.4.2" @@ -3931,6 +4292,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.1" @@ -3956,18 +4326,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", @@ -3995,6 +4365,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 670aadd..e214b11 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -16,8 +16,11 @@ categories = ["command-line-utilities", "development-tools"] anyhow = "1" clap = { version = "4", features = ["derive"] } clap_complete = "4" +dirs = "6" hmac = "0.12" inquire = "0.7" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tokio = { version = "1", default-features = false, features = ["rt"] } diff --git a/cli/flake.nix b/cli/flake.nix index 4beb19f..2f6f3d6 100644 --- a/cli/flake.nix +++ b/cli/flake.nix @@ -111,6 +111,18 @@ }; }; + apps.config-precedence-integration-tests = { + type = "app"; + program = toString ( + pkgs.writeShellScript "sce-config-precedence-integration-tests" '' + exec ${rustToolchain}/bin/cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration "$@" + '' + ); + meta = { + description = "Run config-precedence integration tests for the sce CLI crate"; + }; + }; + checks.cli-setup-command-surface = mkCheck "sce-cli-setup-command-surface-check" '' runHook preCheck diff --git a/cli/src/app.rs b/cli/src/app.rs index f89d0f3..91fda5d 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -1,7 +1,7 @@ use std::io::{self, Write}; use std::process::ExitCode; -use crate::{cli_schema, command_surface, dependency_contract, services}; +use crate::{cli_schema, command_surface, services}; use anyhow::Context; const EXIT_CODE_PARSE_FAILURE: u8 = 2; @@ -105,6 +105,7 @@ impl std::error::Error for ClassifiedError {} #[derive(Clone, Debug, Eq, PartialEq)] enum Command { Help, + Auth(services::auth_command::AuthRequest), Completion(services::completion::CompletionRequest), Config(services::config::ConfigSubcommand), Setup(services::setup::SetupRequest), @@ -119,6 +120,7 @@ impl Command { fn name(&self) -> &'static str { match self { Self::Help => "help", + Self::Auth(_) => services::auth_command::NAME, Self::Completion(_) => services::completion::NAME, Self::Config(_) => services::config::NAME, Self::Setup(_) => services::setup::NAME, @@ -135,9 +137,7 @@ pub fn run(args: I) -> ExitCode where I: IntoIterator, { - run_with_dependency_check(args, || { - dependency_contract::dependency_contract_snapshot().0 - }) + run_with_dependency_check(args, || Ok(())) } fn run_with_dependency_check(args: I, dependency_check: F) -> ExitCode @@ -218,7 +218,7 @@ where F: FnOnce() -> anyhow::Result<()>, { dependency_check().map_err(|error| { - ClassifiedError::dependency(format!("Failed to initialize dependency contract: {error}")) + ClassifiedError::dependency(format!("Failed to initialize dependency checks: {error}")) })?; let logger = services::observability::Logger::from_env().map_err(|error| { @@ -443,6 +443,7 @@ fn extract_quoted_value(message: &str) -> Option { fn convert_clap_command(command: cli_schema::Commands) -> Result { match command { cli_schema::Commands::Config { subcommand } => convert_config_subcommand(subcommand), + cli_schema::Commands::Auth { subcommand } => convert_auth_subcommand(subcommand), cli_schema::Commands::Setup { opencode, claude, @@ -476,6 +477,32 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result Result { + let subcommand = match subcommand { + cli_schema::AuthSubcommand::Login { format } => { + services::auth_command::AuthSubcommand::Login { + format: convert_output_format(format), + } + } + cli_schema::AuthSubcommand::Logout { format } => { + services::auth_command::AuthSubcommand::Logout { + format: convert_output_format(format), + } + } + cli_schema::AuthSubcommand::Status { format } => { + services::auth_command::AuthSubcommand::Status { + format: convert_output_format(format), + } + } + }; + + Ok(Command::Auth(services::auth_command::AuthRequest { + subcommand, + })) +} + /// Convert clap output format to service output format. fn convert_output_format( format: cli_schema::OutputFormat, @@ -592,6 +619,8 @@ fn convert_hooks_subcommand( fn dispatch(command: &Command) -> Result { match command { Command::Help => Ok(command_surface::help_text()), + Command::Auth(request) => services::auth_command::run_auth_subcommand(*request) + .map_err(|error| ClassifiedError::runtime(error.to_string())), Command::Completion(request) => Ok(services::completion::render_completion(*request)), Command::Config(subcommand) => services::config::run_config_subcommand(subcommand.clone()) .map_err(|error| ClassifiedError::runtime(error.to_string())), @@ -1058,6 +1087,44 @@ mod tests { ); } + #[test] + fn parser_routes_auth_login_subcommand() { + let command = parse_command(vec![ + "sce".to_string(), + "auth".to_string(), + "login".to_string(), + ]) + .expect("auth login should parse"); + assert_eq!( + command, + Command::Auth(crate::services::auth_command::AuthRequest { + subcommand: crate::services::auth_command::AuthSubcommand::Login { + format: crate::services::auth_command::AuthFormat::Text, + }, + }) + ); + } + + #[test] + fn parser_routes_auth_status_json_subcommand() { + let command = parse_command(vec![ + "sce".to_string(), + "auth".to_string(), + "status".to_string(), + "--format".to_string(), + "json".to_string(), + ]) + .expect("auth status json should parse"); + assert_eq!( + command, + Command::Auth(crate::services::auth_command::AuthRequest { + subcommand: crate::services::auth_command::AuthSubcommand::Status { + format: crate::services::auth_command::AuthFormat::Json, + }, + }) + ); + } + #[test] fn parser_routes_sync_json_format() { let command = parse_command(vec![ diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index f43346d..92b845e 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -36,6 +36,12 @@ impl Cli { #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum Commands { + /// Authenticate with WorkOS device authorization flow + Auth { + #[command(subcommand)] + subcommand: AuthSubcommand, + }, + /// Inspect and validate resolved CLI configuration Config { #[command(subcommand)] @@ -118,6 +124,31 @@ pub enum Commands { }, } +/// Config subcommands +#[derive(Subcommand, Debug, Clone, PartialEq, Eq)] +pub enum AuthSubcommand { + /// Start login flow and store credentials + Login { + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + + /// Clear stored credentials + Logout { + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, + + /// Show current authentication status + Status { + /// Output format + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + }, +} + /// Config subcommands #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum ConfigSubcommand { @@ -224,6 +255,93 @@ pub enum LogLevel { mod tests { use super::*; + #[test] + fn parse_auth_login() { + let cli = Cli::try_parse_from(["sce", "auth", "login"]).expect("auth login should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Login { format } => { + assert_eq!(format, OutputFormat::Text); + } + _ => panic!("Expected Login subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + + #[test] + fn parse_auth_login_json() { + let cli = Cli::try_parse_from(["sce", "auth", "login", "--format", "json"]) + .expect("auth login --format json should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Login { format } => { + assert_eq!(format, OutputFormat::Json); + } + _ => panic!("Expected Login subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + + #[test] + fn parse_auth_logout() { + let cli = Cli::try_parse_from(["sce", "auth", "logout"]).expect("auth logout should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Logout { format } => { + assert_eq!(format, OutputFormat::Text); + } + _ => panic!("Expected Logout subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + + #[test] + fn parse_auth_logout_json() { + let cli = Cli::try_parse_from(["sce", "auth", "logout", "--format", "json"]) + .expect("auth logout --format json should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Logout { format } => { + assert_eq!(format, OutputFormat::Json); + } + _ => panic!("Expected Logout subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + + #[test] + fn parse_auth_status() { + let cli = Cli::try_parse_from(["sce", "auth", "status"]).expect("auth status should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Status { format } => { + assert_eq!(format, OutputFormat::Text); + } + _ => panic!("Expected Status subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + + #[test] + fn parse_auth_status_json() { + let cli = Cli::try_parse_from(["sce", "auth", "status", "--format", "json"]) + .expect("auth status --format json should parse"); + match cli.command { + Some(Commands::Auth { subcommand }) => match subcommand { + AuthSubcommand::Status { format } => { + assert_eq!(format, OutputFormat::Json); + } + _ => panic!("Expected Status subcommand"), + }, + _ => panic!("Expected Auth command"), + } + } + #[test] fn parse_version_command() { let cli = Cli::try_parse_from(["sce", "version"]).expect("version should parse"); diff --git a/cli/src/command_surface.rs b/cli/src/command_surface.rs index aab329a..85ea0c0 100644 --- a/cli/src/command_surface.rs +++ b/cli/src/command_surface.rs @@ -34,6 +34,11 @@ pub const COMMANDS: &[CommandContract] = &[ status: ImplementationStatus::Implemented, purpose: "Validate local git-hook installation readiness", }, + CommandContract { + name: services::auth_command::NAME, + status: ImplementationStatus::Implemented, + purpose: "Authenticate with WorkOS and inspect local auth state", + }, CommandContract { name: services::mcp::NAME, status: ImplementationStatus::Placeholder, @@ -84,13 +89,14 @@ pub fn help_text() -> String { Usage:\n sce [command]\n\n\ Config usage:\n sce config [--format ] [options]\n\n\ Setup usage:\n sce setup [--opencode|--claude|--both] [--non-interactive] [--hooks] [--repo ]\n\n\ +Auth usage:\n sce auth [--format ]\n\n\ Completion usage:\n sce completion --shell \n\n\ Output format contract:\n Supported commands accept --format \n\n\ -Examples:\n sce setup\n sce setup --opencode --non-interactive --hooks\n sce setup --hooks --repo ../demo-repo\n sce doctor --format json\n sce version --format json\n\n\ +Examples:\n sce setup\n sce setup --opencode --non-interactive --hooks\n sce setup --hooks --repo ../demo-repo\n sce auth status\n sce auth login --format json\n sce doctor --format json\n sce version --format json\n\n\ Commands:\n{}\n\n\ Setup defaults to interactive target selection when no setup target flag is passed, and installs hooks in the same run.\n\ Use '--hooks' to install required git hooks for the current repository or '--repo ' for a specific repository.\n\ -`setup`, `doctor`, and `hooks` are implemented; `mcp` and `sync` remain placeholder-oriented.\n", +`setup`, `doctor`, `auth`, `hooks`, `version`, and `completion` are implemented; `mcp` and `sync` remain placeholder-oriented.\n", command_rows ) } @@ -126,6 +132,25 @@ mod tests { assert!(help.contains("version")); } + #[test] + fn command_surface_includes_auth_as_known_implemented_command() { + let auth = COMMANDS + .iter() + .find(|command| command.name == crate::services::auth_command::NAME) + .expect("auth command should be listed"); + + assert_eq!(auth.status, ImplementationStatus::Implemented); + assert!(crate::command_surface::is_known_command("auth")); + } + + #[test] + fn help_text_mentions_auth_usage_examples() { + let help = help_text(); + assert!(help.contains("sce auth [--format ]")); + assert!(help.contains("sce auth status")); + assert!(help.contains("sce auth login --format json")); + } + #[test] fn help_text_mentions_completion_command() { let help = help_text(); diff --git a/cli/src/dependency_contract.rs b/cli/src/dependency_contract.rs deleted file mode 100644 index 48911ce..0000000 --- a/cli/src/dependency_contract.rs +++ /dev/null @@ -1,40 +0,0 @@ -pub fn dependency_contract_snapshot() -> ( - anyhow::Result<()>, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, -) { - ( - Ok(()), - std::any::type_name::(), // clap derive feature - std::any::type_name::(), // clap_complete - std::any::type_name::>(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::(), - std::any::type_name::< - tracing_opentelemetry::OpenTelemetryLayer< - tracing_subscriber::Registry, - opentelemetry_sdk::trace::Tracer, - >, - >(), - std::any::type_name::(), - std::any::type_name::(), - ) -} diff --git a/cli/src/main.rs b/cli/src/main.rs index 269b094..5e1fdce 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,7 +1,6 @@ mod app; mod cli_schema; mod command_surface; -mod dependency_contract; mod services; #[cfg(test)] mod test_support; diff --git a/cli/src/services/auth.rs b/cli/src/services/auth.rs new file mode 100644 index 0000000..596f9f8 --- /dev/null +++ b/cli/src/services/auth.rs @@ -0,0 +1,601 @@ +use std::fmt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use crate::services::resilience::{run_with_retry, RetryPolicy}; +use crate::services::token_storage::{load_tokens, save_tokens, StoredTokens, TokenStorageError}; + +pub const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; +pub const REFRESH_TOKEN_GRANT_TYPE: &str = "refresh_token"; +pub const WORKOS_DEFAULT_BASE_URL: &str = "https://api.workos.com"; +pub const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS: u64 = 5; +const TOKEN_EXPIRY_SKEW_SECONDS: u64 = 30; +const TOKEN_REFRESH_MAX_ATTEMPTS: u32 = 3; +const TOKEN_REFRESH_TIMEOUT_MS: u64 = 10_000; +const TOKEN_REFRESH_INITIAL_BACKOFF_MS: u64 = 250; +const TOKEN_REFRESH_MAX_BACKOFF_MS: u64 = 2_000; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeviceAuthorizationRequest { + pub client_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeviceAuthorizationResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: Option, + pub expires_in: u64, + pub interval: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeviceTokenPollRequest { + pub grant_type: String, + pub device_code: String, + pub client_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RefreshTokenRequest { + pub grant_type: String, + pub refresh_token: String, + pub client_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + pub refresh_token: String, + pub scope: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct OAuthErrorResponse { + pub error: String, + pub error_description: Option, + pub error_uri: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeviceAuthFlowResult { + pub authorization: DeviceAuthorizationResponse, + pub stored_tokens: StoredTokens, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PollDecision { + Continue, + SlowDown, + Stop, +} + +#[derive(Debug)] +pub enum AuthError { + MissingClientId, + InvalidResponse(String), + Unauthorized(String), + RequestFailed(reqwest::Error), + Io(std::io::Error), + Storage(TokenStorageError), +} + +impl fmt::Display for AuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingClientId => write!( + f, + "WorkOS client ID is not configured. Try: set WORKOS_CLIENT_ID, add workos_client_id to an SCE config file, or remove an invalid higher-precedence override so the baked default can apply." + ), + Self::InvalidResponse(reason) => write!( + f, + "WorkOS auth response was invalid: {reason}. Try: retry the command and verify WorkOS app settings." + ), + Self::Unauthorized(reason) => write!( + f, + "WorkOS authentication request was rejected: {reason}. Try: verify the client ID and rerun login." + ), + Self::RequestFailed(error) => { + write!(f, "WorkOS authentication request failed: {error}") + } + Self::Io(error) => write!(f, "Authentication storage operation failed: {error}"), + Self::Storage(error) => write!(f, "Authentication storage operation failed: {error}"), + } + } +} + +impl std::error::Error for AuthError {} + +impl From for AuthError { + fn from(value: reqwest::Error) -> Self { + Self::RequestFailed(value) + } +} + +impl From for AuthError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for AuthError { + fn from(value: TokenStorageError) -> Self { + Self::Storage(value) + } +} + +pub async fn start_device_auth_flow( + client: &reqwest::Client, + api_base_url: &str, + client_id: &str, +) -> Result { + if client_id.trim().is_empty() { + return Err(AuthError::MissingClientId); + } + + let authorization = request_device_authorization(client, api_base_url, client_id).await?; + let token = poll_for_device_token(client, api_base_url, client_id, &authorization).await?; + let stored_tokens = save_tokens(&token)?; + + Ok(DeviceAuthFlowResult { + authorization, + stored_tokens, + }) +} + +pub async fn ensure_valid_token( + client: &reqwest::Client, + api_base_url: &str, + client_id: &str, +) -> Result { + if client_id.trim().is_empty() { + return Err(AuthError::MissingClientId); + } + + let Some(stored) = load_tokens()? else { + return Err(AuthError::Unauthorized( + "No stored WorkOS credentials were found. Try: run 'sce login' before running authenticated commands.".to_string(), + )); + }; + + let now_unix_seconds = current_unix_timestamp_seconds()?; + if !is_token_expired(&stored, now_unix_seconds) { + return Ok(stored); + } + + let refreshed = + refresh_access_token(client, api_base_url, client_id, &stored.refresh_token).await?; + let updated = save_tokens(&refreshed)?; + Ok(updated) +} + +async fn request_device_authorization( + client: &reqwest::Client, + api_base_url: &str, + client_id: &str, +) -> Result { + let endpoint = format!( + "{}/oauth/device/authorize", + api_base_url.trim_end_matches('/') + ); + let request = DeviceAuthorizationRequest { + client_id: client_id.to_string(), + }; + + let response = client.post(endpoint).json(&request).send().await?; + + if response.status().is_success() { + let parsed = response + .json::() + .await + .map_err(AuthError::RequestFailed)?; + if parsed.device_code.trim().is_empty() + || parsed.user_code.trim().is_empty() + || parsed.verification_uri.trim().is_empty() + { + return Err(AuthError::InvalidResponse( + "device authorization response is missing required fields".to_string(), + )); + } + return Ok(parsed); + } + + let oauth_error = parse_oauth_error_response(response).await?; + Err(map_oauth_terminal_error( + &oauth_error.error, + oauth_error.error_description.as_deref(), + )) +} + +async fn poll_for_device_token( + client: &reqwest::Client, + api_base_url: &str, + client_id: &str, + authorization: &DeviceAuthorizationResponse, +) -> Result { + let endpoint = format!("{}/oauth/device/token", api_base_url.trim_end_matches('/')); + let request = DeviceTokenPollRequest { + grant_type: DEVICE_CODE_GRANT_TYPE.to_string(), + device_code: authorization.device_code.clone(), + client_id: client_id.to_string(), + }; + + let mut poll_interval_seconds = authorization + .interval + .unwrap_or(DEFAULT_DEVICE_POLL_INTERVAL_SECONDS) + .max(1); + let max_polls = authorization + .expires_in + .saturating_div(poll_interval_seconds) + .max(1) + + 1; + let mut attempts = 0_u64; + + loop { + attempts = attempts.saturating_add(1); + if attempts > max_polls { + return Err(AuthError::Unauthorized( + "WorkOS device authorization expired before approval completed. Try: run 'sce login' again and complete verification before the code expires.".to_string(), + )); + } + + let response = client.post(&endpoint).json(&request).send().await?; + if response.status().is_success() { + let token = response + .json::() + .await + .map_err(AuthError::RequestFailed)?; + return Ok(token); + } + + let oauth_error = parse_oauth_error_response(response).await?; + match poll_decision_for_error_code(&oauth_error.error) { + PollDecision::Continue => { + tokio::time::sleep(Duration::from_secs(poll_interval_seconds)).await; + } + PollDecision::SlowDown => { + poll_interval_seconds = poll_interval_seconds.saturating_add(5); + tokio::time::sleep(Duration::from_secs(poll_interval_seconds)).await; + } + PollDecision::Stop => { + return Err(map_oauth_terminal_error( + &oauth_error.error, + oauth_error.error_description.as_deref(), + )); + } + } + } +} + +fn poll_decision_for_error_code(code: &str) -> PollDecision { + match code { + "authorization_pending" => PollDecision::Continue, + "slow_down" => PollDecision::SlowDown, + _ => PollDecision::Stop, + } +} + +fn is_token_expired(stored: &StoredTokens, now_unix_seconds: u64) -> bool { + let lifetime_seconds = stored.expires_in.saturating_sub(TOKEN_EXPIRY_SKEW_SECONDS); + let expires_at = stored + .stored_at_unix_seconds + .saturating_add(lifetime_seconds); + now_unix_seconds >= expires_at +} + +fn current_unix_timestamp_seconds() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| { + AuthError::InvalidResponse(format!( + "system clock is invalid for token expiry checks: {error}" + )) + }) +} + +async fn refresh_access_token( + client: &reqwest::Client, + api_base_url: &str, + client_id: &str, + refresh_token: &str, +) -> Result { + if refresh_token.trim().is_empty() { + return Err(AuthError::Unauthorized( + "Stored WorkOS refresh token is missing. Try: run 'sce login' to authenticate again." + .to_string(), + )); + } + + let endpoint = format!("{}/oauth/token", api_base_url.trim_end_matches('/')); + let request = RefreshTokenRequest { + grant_type: REFRESH_TOKEN_GRANT_TYPE.to_string(), + refresh_token: refresh_token.to_string(), + client_id: client_id.to_string(), + }; + let retry_policy = RetryPolicy { + max_attempts: TOKEN_REFRESH_MAX_ATTEMPTS, + timeout_ms: TOKEN_REFRESH_TIMEOUT_MS, + initial_backoff_ms: TOKEN_REFRESH_INITIAL_BACKOFF_MS, + max_backoff_ms: TOKEN_REFRESH_MAX_BACKOFF_MS, + }; + + let response = run_with_retry( + retry_policy, + "auth.refresh_token", + "check network connectivity and rerun the command", + |_| { + let endpoint = endpoint.clone(); + let request = request.clone(); + async move { + client + .post(&endpoint) + .json(&request) + .send() + .await + .map_err(|error| anyhow!(error)) + } + }, + ) + .await + .map_err(|error| { + AuthError::Unauthorized(format!( + "WorkOS token refresh failed due to repeated transient errors: {error}. Try: rerun the command; if this persists, run 'sce login' to re-authenticate." + )) + })?; + + if response.status().is_success() { + let token = response + .json::() + .await + .map_err(AuthError::RequestFailed)?; + return Ok(token); + } + + let oauth_error = parse_oauth_error_response(response).await?; + Err(map_refresh_terminal_error( + &oauth_error.error, + oauth_error.error_description.as_deref(), + )) +} + +fn map_refresh_terminal_error(code: &str, description: Option<&str>) -> AuthError { + let detail = description + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + + match code { + "invalid_grant" | "expired_token" => AuthError::Unauthorized(format!( + "Stored WorkOS refresh token is no longer valid{detail}. Try: run 'sce login' to authenticate again." + )), + "invalid_client" => AuthError::Unauthorized(format!( + "WorkOS rejected the configured client ID during token refresh{detail}. Try: verify WORKOS_CLIENT_ID (or config value) and rerun 'sce login'." + )), + "invalid_request" => AuthError::Unauthorized(format!( + "WorkOS rejected the refresh token request as invalid{detail}. Try: run 'sce login' to reset local credentials." + )), + "unsupported_grant_type" => AuthError::Unauthorized(format!( + "WorkOS rejected the refresh OAuth grant type{detail}. Try: update the CLI and rerun 'sce login'." + )), + "access_denied" => AuthError::Unauthorized(format!( + "WorkOS denied the refresh token request{detail}. Try: run 'sce login' to re-authenticate." + )), + other => AuthError::Unauthorized(format!( + "WorkOS returned OAuth error '{other}' while refreshing credentials{detail}. Try: run 'sce login' to restore authentication." + )), + } +} + +async fn parse_oauth_error_response( + response: reqwest::Response, +) -> Result { + response + .json::() + .await + .map_err(|error| { + AuthError::InvalidResponse(format!("unable to parse OAuth error payload: {error}")) + }) +} + +fn map_oauth_terminal_error(code: &str, description: Option<&str>) -> AuthError { + let detail = description + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" ({value})")) + .unwrap_or_default(); + + match code { + "access_denied" => AuthError::Unauthorized(format!( + "WorkOS login was declined by the user{detail}. Try: rerun 'sce login' and approve the request in the browser." + )), + "expired_token" => AuthError::Unauthorized(format!( + "WorkOS device code expired{detail}. Try: rerun 'sce login' to request a fresh device code." + )), + "invalid_request" => AuthError::Unauthorized(format!( + "WorkOS rejected the device auth request as invalid{detail}. Try: verify CLI auth parameters and rerun 'sce login'." + )), + "invalid_client" => AuthError::Unauthorized(format!( + "WorkOS rejected the client configuration{detail}. Try: verify WORKOS_CLIENT_ID (or config value) and rerun 'sce login'." + )), + "invalid_grant" => AuthError::Unauthorized(format!( + "WorkOS reported an invalid or already-used device code{detail}. Try: rerun 'sce login' to restart the device flow." + )), + "unsupported_grant_type" => AuthError::Unauthorized(format!( + "WorkOS rejected the OAuth grant type{detail}. Try: update the CLI and rerun 'sce login'." + )), + other => AuthError::Unauthorized(format!( + "WorkOS returned OAuth error '{other}'{detail}. Try: rerun 'sce login'; if the issue persists, check WorkOS auth configuration." + )), + } +} + +#[cfg(test)] +mod tests { + use super::{ + is_token_expired, map_oauth_terminal_error, map_refresh_terminal_error, + poll_decision_for_error_code, DeviceAuthorizationResponse, DeviceTokenPollRequest, + OAuthErrorResponse, PollDecision, RefreshTokenRequest, TokenResponse, + DEVICE_CODE_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, + }; + use crate::services::token_storage::StoredTokens; + + #[test] + fn device_authorization_response_deserializes_from_workos_shape() { + let payload = r#"{ + "device_code": "dev_123", + "user_code": "ABCD-EFGH", + "verification_uri": "https://workos.com/device", + "verification_uri_complete": "https://workos.com/device?user_code=ABCD-EFGH", + "expires_in": 900, + "interval": 5 + }"#; + + let parsed: DeviceAuthorizationResponse = + serde_json::from_str(payload).expect("device auth response should parse"); + + assert_eq!(parsed.device_code, "dev_123"); + assert_eq!(parsed.user_code, "ABCD-EFGH"); + assert_eq!(parsed.expires_in, 900); + assert_eq!(parsed.interval, Some(5)); + assert_eq!( + parsed.verification_uri_complete.as_deref(), + Some("https://workos.com/device?user_code=ABCD-EFGH") + ); + } + + #[test] + fn token_response_serializes_and_deserializes() { + let token = TokenResponse { + access_token: "access_123".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh_123".to_string(), + scope: Some("openid profile".to_string()), + }; + + let encoded = serde_json::to_string(&token).expect("token response should serialize"); + let decoded: TokenResponse = + serde_json::from_str(&encoded).expect("token response should deserialize"); + + assert_eq!(decoded, token); + } + + #[test] + fn device_token_poll_request_uses_rfc8628_grant_type_constant() { + let request = DeviceTokenPollRequest { + grant_type: DEVICE_CODE_GRANT_TYPE.to_string(), + device_code: "device_abc".to_string(), + client_id: "client_abc".to_string(), + }; + + let encoded = serde_json::to_string(&request).expect("poll request should serialize"); + assert!(encoded.contains(DEVICE_CODE_GRANT_TYPE)); + } + + #[test] + fn oauth_error_response_deserializes_optional_fields() { + let payload = r#"{ + "error": "authorization_pending", + "error_description": "Authorization pending" + }"#; + + let parsed: OAuthErrorResponse = + serde_json::from_str(payload).expect("oauth error payload should parse"); + + assert_eq!(parsed.error, "authorization_pending"); + assert_eq!( + parsed.error_description.as_deref(), + Some("Authorization pending") + ); + assert_eq!(parsed.error_uri, None); + } + + #[test] + fn oauth_error_mapping_for_all_required_terminal_codes_has_try_guidance() { + let codes = [ + "access_denied", + "expired_token", + "invalid_request", + "invalid_client", + "invalid_grant", + "unsupported_grant_type", + ]; + + for code in codes { + let message = map_oauth_terminal_error(code, Some("detail")).to_string(); + assert!(message.contains("Try:"), "missing Try guidance for {code}"); + } + } + + #[test] + fn oauth_error_mapping_includes_original_code_for_unknown_errors() { + let message = map_oauth_terminal_error("unexpected_error", None).to_string(); + assert!(message.contains("unexpected_error")); + assert!(message.contains("Try:")); + } + + #[test] + fn poll_decision_uses_fixed_interval_and_slow_down_increment_path() { + assert_eq!( + poll_decision_for_error_code("authorization_pending"), + PollDecision::Continue + ); + assert_eq!( + poll_decision_for_error_code("slow_down"), + PollDecision::SlowDown + ); + } + + #[test] + fn poll_decision_stops_for_terminal_oauth_errors() { + assert_eq!( + poll_decision_for_error_code("access_denied"), + PollDecision::Stop + ); + assert_eq!( + poll_decision_for_error_code("invalid_client"), + PollDecision::Stop + ); + } + + #[test] + fn refresh_token_request_uses_refresh_grant_type_constant() { + let request = RefreshTokenRequest { + grant_type: REFRESH_TOKEN_GRANT_TYPE.to_string(), + refresh_token: "refresh_abc".to_string(), + client_id: "client_abc".to_string(), + }; + + let encoded = serde_json::to_string(&request).expect("refresh request should serialize"); + assert!(encoded.contains(REFRESH_TOKEN_GRANT_TYPE)); + } + + #[test] + fn token_expiry_check_honors_stored_timestamp_and_expiry() { + let stored = StoredTokens { + access_token: "access_abc".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh_abc".to_string(), + scope: None, + stored_at_unix_seconds: 1_700_000_000, + }; + + assert!(!is_token_expired(&stored, 1_700_003_500)); + assert!(is_token_expired(&stored, 1_700_003_570)); + } + + #[test] + fn refresh_terminal_error_mapping_requires_relogin_on_invalid_grant() { + let message = map_refresh_terminal_error("invalid_grant", Some("expired")).to_string(); + assert!(message.contains("sce login")); + assert!(message.contains("Try:")); + } +} diff --git a/cli/src/services/auth_command.rs b/cli/src/services/auth_command.rs new file mode 100644 index 0000000..8266b74 --- /dev/null +++ b/cli/src/services/auth_command.rs @@ -0,0 +1,614 @@ +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::json; + +use crate::services::auth::{self, DeviceAuthFlowResult}; +use crate::services::config; +use crate::services::output_format::OutputFormat; +use crate::services::token_storage::{self, StoredTokens}; + +pub const NAME: &str = "auth"; + +pub type AuthFormat = OutputFormat; + +static AUTH_RUNTIME: OnceLock = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthSubcommand { + Login { format: AuthFormat }, + Logout { format: AuthFormat }, + Status { format: AuthFormat }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AuthRequest { + pub subcommand: AuthSubcommand, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AuthStatusReport { + authentication_state: &'static str, + has_stored_credentials: bool, + token_expired: Option, + token_type: Option, + scope: Option, + stored_at_unix_seconds: Option, + expires_at_unix_seconds: Option, + seconds_until_expiry: Option, +} + +pub fn run_auth_subcommand(request: AuthRequest) -> Result { + run_auth_subcommand_with(request, run_login, run_logout, run_status) +} + +fn run_auth_subcommand_with( + request: AuthRequest, + login: L, + logout: O, + status: S, +) -> Result +where + L: FnOnce(AuthFormat) -> Result, + O: FnOnce(AuthFormat) -> Result, + S: FnOnce(AuthFormat) -> Result, +{ + match request.subcommand { + AuthSubcommand::Login { format } => login(format), + AuthSubcommand::Logout { format } => logout(format), + AuthSubcommand::Status { format } => status(format), + } +} + +pub fn run_login(format: AuthFormat) -> Result { + let client = reqwest::Client::new(); + let runtime = shared_runtime()?; + + run_login_with(format, resolve_login_client_id, |client_id| { + runtime + .block_on(auth::start_device_auth_flow( + &client, + auth::WORKOS_DEFAULT_BASE_URL, + client_id, + )) + .map_err(|error| { + anyhow!(with_try_guidance( + error.to_string(), + "verify the resolved WorkOS client ID source (WORKOS_CLIENT_ID, config file, or baked default), confirm network access, and rerun 'sce auth login'." + )) + }) + }) +} + +pub fn run_logout(format: AuthFormat) -> Result { + let deleted = token_storage::delete_tokens().map_err(|error| { + anyhow!(with_try_guidance( + error.to_string(), + "verify file permissions for the auth state directory and rerun 'sce auth logout'." + )) + })?; + render_logout_result(deleted, format) +} + +pub fn run_status(format: AuthFormat) -> Result { + let report = match token_storage::load_tokens()? { + Some(tokens) => build_authenticated_status_report(&tokens)?, + None => AuthStatusReport { + authentication_state: "unauthenticated", + has_stored_credentials: false, + token_expired: None, + token_type: None, + scope: None, + stored_at_unix_seconds: None, + expires_at_unix_seconds: None, + seconds_until_expiry: None, + }, + }; + + render_status_result(&report, format) +} + +fn shared_runtime() -> Result<&'static tokio::runtime::Runtime> { + if let Some(runtime) = AUTH_RUNTIME.get() { + return Ok(runtime); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .context("failed to create auth command runtime. Try: rerun the command; if the issue persists, verify the local Tokio runtime environment.")?; + + Ok(AUTH_RUNTIME.get_or_init(|| runtime)) +} + +fn run_login_with(format: AuthFormat, resolve_client_id: R, start_flow: S) -> Result +where + R: FnOnce() -> Result, + S: FnOnce(&str) -> Result, +{ + let client_id = resolve_client_id()?; + let result = start_flow(&client_id)?; + render_login_result(&result, format) +} + +fn resolve_login_client_id() -> Result { + let cwd = std::env::current_dir() + .context("failed to determine current directory for auth config resolution")?; + + Ok(config::resolve_auth_runtime_config(&cwd)? + .workos_client_id + .value + .unwrap_or_default()) +} + +fn resolve_login_client_id_with( + cwd: &std::path::Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&std::path::Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&std::path::Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + Ok(config::resolve_auth_runtime_config_with( + cwd, + env_lookup, + read_file, + path_exists, + resolve_global_config_path, + )? + .workos_client_id + .value + .unwrap_or_default()) +} + +fn build_authenticated_status_report(tokens: &StoredTokens) -> Result { + let now_unix_seconds = current_unix_timestamp_seconds()?; + let expires_at_unix_seconds = tokens + .stored_at_unix_seconds + .saturating_add(tokens.expires_in); + let seconds_until_expiry = expires_at_unix_seconds as i64 - now_unix_seconds as i64; + + Ok(AuthStatusReport { + authentication_state: "authenticated", + has_stored_credentials: true, + token_expired: Some(seconds_until_expiry <= 0), + token_type: Some(tokens.token_type.clone()), + scope: tokens.scope.clone(), + stored_at_unix_seconds: Some(tokens.stored_at_unix_seconds), + expires_at_unix_seconds: Some(expires_at_unix_seconds), + seconds_until_expiry: Some(seconds_until_expiry), + }) +} + +fn render_login_result(result: &DeviceAuthFlowResult, format: AuthFormat) -> Result { + let expires_at_unix_seconds = result + .stored_tokens + .stored_at_unix_seconds + .saturating_add(result.stored_tokens.expires_in); + + match format { + AuthFormat::Text => Ok(format!( + "Authentication succeeded. User code: {}\nVerification URL: {}\nVerification URL (complete): {}\nToken type: {}\nExpires at (unix): {}", + result.authorization.user_code, + result.authorization.verification_uri, + result + .authorization + .verification_uri_complete + .as_deref() + .unwrap_or("(not provided)"), + result.stored_tokens.token_type, + expires_at_unix_seconds, + )), + AuthFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "ok", + "command": NAME, + "subcommand": "login", + "authenticated": true, + "user_code": result.authorization.user_code, + "verification_uri": result.authorization.verification_uri, + "verification_uri_complete": result.authorization.verification_uri_complete, + "token_type": result.stored_tokens.token_type, + "scope": result.stored_tokens.scope, + "stored_at_unix_seconds": result.stored_tokens.stored_at_unix_seconds, + "expires_in_seconds": result.stored_tokens.expires_in, + "expires_at_unix_seconds": expires_at_unix_seconds, + })) + .context("failed to serialize auth login report to JSON. Try: rerun 'sce auth login --format json'."), + } +} + +fn render_logout_result(deleted: bool, format: AuthFormat) -> Result { + match format { + AuthFormat::Text => Ok(if deleted { + "Removed stored WorkOS credentials.".to_string() + } else { + "No stored WorkOS credentials were found.".to_string() + }), + AuthFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "ok", + "command": NAME, + "subcommand": "logout", + "authenticated": false, + "credentials_removed": deleted, + })) + .context("failed to serialize auth logout report to JSON. Try: rerun 'sce auth logout --format json'."), + } +} + +fn render_status_result(report: &AuthStatusReport, format: AuthFormat) -> Result { + match format { + AuthFormat::Text => { + if !report.has_stored_credentials { + return Ok("Authentication status: unauthenticated\nStored credentials: none".to_string()); + } + + Ok(format!( + "Authentication status: {}\nStored credentials: present\nToken expired: {}\nSeconds until expiry: {}\nExpires at (unix): {}\nToken type: {}\nScope: {}", + report.authentication_state, + report.token_expired.unwrap_or(false), + report.seconds_until_expiry.unwrap_or_default(), + report.expires_at_unix_seconds.unwrap_or_default(), + report.token_type.as_deref().unwrap_or("(unknown)"), + report.scope.as_deref().unwrap_or("(none)"), + )) + } + AuthFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "ok", + "command": NAME, + "subcommand": "status", + "authentication_state": report.authentication_state, + "has_stored_credentials": report.has_stored_credentials, + "token_expired": report.token_expired, + "token_type": report.token_type, + "scope": report.scope, + "stored_at_unix_seconds": report.stored_at_unix_seconds, + "expires_at_unix_seconds": report.expires_at_unix_seconds, + "seconds_until_expiry": report.seconds_until_expiry, + })) + .context("failed to serialize auth status report to JSON. Try: rerun 'sce auth status --format json'."), + } +} + +fn current_unix_timestamp_seconds() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| anyhow!("system clock is invalid for auth status checks: {error}. Try: verify local system time and rerun 'sce auth status'."))? + .as_secs()) +} + +fn with_try_guidance(message: String, guidance: &str) -> String { + if message.contains("Try:") { + message + } else { + format!("{message} Try: {guidance}") + } +} + +#[cfg(test)] +mod tests { + use anyhow::{anyhow, Result}; + use serde_json::Value; + use std::path::{Path, PathBuf}; + + use super::{ + build_authenticated_status_report, render_login_result, render_logout_result, + render_status_result, resolve_login_client_id_with, run_auth_subcommand_with, + run_login_with, with_try_guidance, AuthFormat, AuthRequest, AuthStatusReport, + AuthSubcommand, + }; + use crate::services::auth::{AuthError, DeviceAuthFlowResult, DeviceAuthorizationResponse}; + use crate::services::token_storage::StoredTokens; + + fn fixture_login_result() -> DeviceAuthFlowResult { + DeviceAuthFlowResult { + authorization: DeviceAuthorizationResponse { + device_code: "device-code".to_string(), + user_code: "ABCD-EFGH".to_string(), + verification_uri: "https://workos.com/device".to_string(), + verification_uri_complete: Some( + "https://workos.com/device?user_code=ABCD-EFGH".to_string(), + ), + expires_in: 900, + interval: Some(5), + }, + stored_tokens: StoredTokens { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh-token".to_string(), + scope: Some("openid profile".to_string()), + stored_at_unix_seconds: 1_700_000_000, + }, + } + } + + #[test] + fn dispatcher_routes_login_to_login_handler() -> Result<()> { + let output = run_auth_subcommand_with( + AuthRequest { + subcommand: AuthSubcommand::Login { + format: AuthFormat::Text, + }, + }, + |_| Ok("login".to_string()), + |_| Ok("logout".to_string()), + |_| Ok("status".to_string()), + )?; + + assert_eq!(output, "login"); + Ok(()) + } + + #[test] + fn dispatcher_routes_logout_to_logout_handler() -> Result<()> { + let output = run_auth_subcommand_with( + AuthRequest { + subcommand: AuthSubcommand::Logout { + format: AuthFormat::Json, + }, + }, + |_| Ok("login".to_string()), + |_| Ok("logout".to_string()), + |_| Ok("status".to_string()), + )?; + + assert_eq!(output, "logout"); + Ok(()) + } + + #[test] + fn dispatcher_routes_status_to_status_handler() -> Result<()> { + let output = run_auth_subcommand_with( + AuthRequest { + subcommand: AuthSubcommand::Status { + format: AuthFormat::Text, + }, + }, + |_| Ok("login".to_string()), + |_| Ok("logout".to_string()), + |_| Ok("status".to_string()), + )?; + + assert_eq!(output, "status"); + Ok(()) + } + + #[test] + fn login_json_output_includes_stable_fields() -> Result<()> { + let output = render_login_result(&fixture_login_result(), AuthFormat::Json)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["command"], "auth"); + assert_eq!(parsed["subcommand"], "login"); + assert_eq!(parsed["authenticated"], true); + assert_eq!(parsed["user_code"], "ABCD-EFGH"); + Ok(()) + } + + #[test] + fn login_uses_env_workos_client_id_over_config_sources() -> Result<()> { + let output = run_login_with( + AuthFormat::Json, + || { + resolve_login_client_id_with( + Path::new("/workspace"), + |key| match key { + "WORKOS_CLIENT_ID" => Some("env-client".to_string()), + _ => None, + }, + |_| Ok("{\"workos_client_id\":\"config-client\"}".to_string()), + |_| true, + || Ok(PathBuf::from("/state")), + ) + }, + |client_id| { + assert_eq!(client_id, "env-client"); + Ok(fixture_login_result()) + }, + )?; + + let parsed: Value = serde_json::from_str(&output)?; + assert_eq!(parsed["authenticated"], true); + Ok(()) + } + + #[test] + fn login_uses_local_config_workos_client_id_when_env_is_absent() -> Result<()> { + run_login_with( + AuthFormat::Text, + || { + resolve_login_client_id_with( + Path::new("/workspace"), + |_| None, + |path| { + if path == Path::new("/state/sce/config.json") { + return Ok("{\"workos_client_id\":\"global-client\"}".to_string()); + } + if path == Path::new("/workspace/.sce/config.json") { + return Ok("{\"workos_client_id\":\"local-client\"}".to_string()); + } + Err(anyhow!("unexpected config path: {}", path.display())) + }, + |path| { + path == Path::new("/state/sce/config.json") + || path == Path::new("/workspace/.sce/config.json") + }, + || Ok(PathBuf::from("/state")), + ) + }, + |client_id| { + assert_eq!(client_id, "local-client"); + Ok(fixture_login_result()) + }, + )?; + + Ok(()) + } + + #[test] + fn login_uses_global_config_workos_client_id_when_local_omits_key() -> Result<()> { + run_login_with( + AuthFormat::Text, + || { + resolve_login_client_id_with( + Path::new("/workspace"), + |_| None, + |path| { + if path == Path::new("/state/sce/config.json") { + return Ok("{\"workos_client_id\":\"global-client\"}".to_string()); + } + if path == Path::new("/workspace/.sce/config.json") { + return Ok("{}".to_string()); + } + Err(anyhow!("unexpected config path: {}", path.display())) + }, + |path| { + path == Path::new("/state/sce/config.json") + || path == Path::new("/workspace/.sce/config.json") + }, + || Ok(PathBuf::from("/state")), + ) + }, + |client_id| { + assert_eq!(client_id, "global-client"); + Ok(fixture_login_result()) + }, + )?; + + Ok(()) + } + + #[test] + fn login_uses_baked_default_workos_client_id_when_env_and_config_are_absent() -> Result<()> { + run_login_with( + AuthFormat::Text, + || { + resolve_login_client_id_with( + Path::new("/workspace"), + |_| None, + |_| Ok("{}".to_string()), + |_| false, + || Ok(PathBuf::from("/state")), + ) + }, + |client_id| { + assert_eq!(client_id, "client_sce_default"); + Ok(fixture_login_result()) + }, + )?; + + Ok(()) + } + + #[test] + fn login_preserves_missing_client_id_error_when_highest_precedence_value_is_blank() { + let error = run_login_with( + AuthFormat::Text, + || { + resolve_login_client_id_with( + Path::new("/workspace"), + |key| match key { + "WORKOS_CLIENT_ID" => Some(" ".to_string()), + _ => None, + }, + |_| Ok("{\"workos_client_id\":\"config-client\"}".to_string()), + |_| true, + || Ok(PathBuf::from("/state")), + ) + }, + |_| Err(anyhow!(AuthError::MissingClientId.to_string())), + ) + .expect_err("blank env client id should fail"); + + assert!(error + .to_string() + .contains("WorkOS client ID is not configured")); + } + + #[test] + fn logout_json_output_reports_removal_state() -> Result<()> { + let output = render_logout_result(true, AuthFormat::Json)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["subcommand"], "logout"); + assert_eq!(parsed["credentials_removed"], true); + Ok(()) + } + + #[test] + fn status_text_output_reports_unauthenticated_state() -> Result<()> { + let output = render_status_result( + &AuthStatusReport { + authentication_state: "unauthenticated", + has_stored_credentials: false, + token_expired: None, + token_type: None, + scope: None, + stored_at_unix_seconds: None, + expires_at_unix_seconds: None, + seconds_until_expiry: None, + }, + AuthFormat::Text, + )?; + + assert!(output.contains("unauthenticated")); + assert!(output.contains("Stored credentials: none")); + Ok(()) + } + + #[test] + fn status_json_output_reports_expiry_fields() -> Result<()> { + let report = build_authenticated_status_report(&StoredTokens { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh-token".to_string(), + scope: Some("openid profile".to_string()), + stored_at_unix_seconds: super::current_unix_timestamp_seconds()? - 60, + })?; + + let output = render_status_result(&report, AuthFormat::Json)?; + let parsed: Value = serde_json::from_str(&output)?; + + assert_eq!(parsed["subcommand"], "status"); + assert_eq!(parsed["authentication_state"], "authenticated"); + assert!(parsed["has_stored_credentials"].as_bool().unwrap_or(false)); + assert!(parsed["seconds_until_expiry"].as_i64().is_some()); + Ok(()) + } + + #[test] + fn try_guidance_is_added_only_when_missing() { + let added = with_try_guidance("runtime failed".to_string(), "rerun the command."); + assert_eq!(added, "runtime failed Try: rerun the command."); + + let preserved = with_try_guidance( + "runtime failed. Try: rerun the command.".to_string(), + "something else.", + ); + assert_eq!(preserved, "runtime failed. Try: rerun the command."); + } + + #[test] + fn dispatcher_preserves_actionable_errors() { + let error = run_auth_subcommand_with( + AuthRequest { + subcommand: AuthSubcommand::Login { + format: AuthFormat::Text, + }, + }, + |_| Err(anyhow!("login failed. Try: rerun login.")), + |_| Ok("logout".to_string()), + |_| Ok("status".to_string()), + ) + .expect_err("login should fail"); + + assert!(error.to_string().contains("Try:")); + } +} diff --git a/cli/src/services/config.rs b/cli/src/services/config.rs index 7891e6a..2018e87 100644 --- a/cli/src/services/config.rs +++ b/cli/src/services/config.rs @@ -8,6 +8,13 @@ use crate::services::output_format::OutputFormat; pub const NAME: &str = "config"; const DEFAULT_TIMEOUT_MS: u64 = 30000; +const WORKOS_CLIENT_ID_ENV: &str = "WORKOS_CLIENT_ID"; +const WORKOS_CLIENT_ID_BAKED_DEFAULT: &str = "client_sce_default"; +const WORKOS_CLIENT_ID_KEY: AuthConfigKeySpec = AuthConfigKeySpec { + config_key: "workos_client_id", + env_key: WORKOS_CLIENT_ID_ENV, + baked_default: Some(WORKOS_CLIENT_ID_BAKED_DEFAULT), +}; pub type ReportFormat = OutputFormat; @@ -115,17 +122,52 @@ struct LoadedConfigPath { source: ConfigPathSource, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct AuthConfigKeySpec { + config_key: &'static str, + env_key: &'static str, + baked_default: Option<&'static str>, +} + +impl AuthConfigKeySpec { + fn precedence_description(self) -> String { + let mut layers = vec![ + format!("env ({})", self.env_key), + format!("config file ({})", self.config_key), + ]; + + if let Some(default) = self.baked_default { + layers.push(format!("baked default ({default})")); + } + + layers.join(" > ") + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct RuntimeConfig { loaded_config_paths: Vec, log_level: ResolvedValue, timeout_ms: ResolvedValue, + workos_client_id: ResolvedOptionalValue, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedOptionalValue { + pub(crate) value: Option, + source: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ResolvedAuthRuntimeConfig { + pub(crate) workos_client_id: ResolvedOptionalValue, } #[derive(Clone, Debug, Eq, PartialEq)] struct FileConfig { log_level: Option>, timeout_ms: Option>, + workos_client_id: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -149,6 +191,50 @@ pub fn run_config_subcommand(subcommand: ConfigSubcommand) -> Result { } } +pub(crate) fn resolve_auth_runtime_config(cwd: &Path) -> Result { + resolve_auth_runtime_config_with( + cwd, + |key| std::env::var(key).ok(), + |path| { + std::fs::read_to_string(path) + .with_context(|| format!("Failed to read config file '{}'.", path.display())) + }, + Path::exists, + resolve_default_global_config_path, + ) +} + +pub(crate) fn resolve_auth_runtime_config_with( + cwd: &Path, + env_lookup: FEnv, + read_file: FRead, + path_exists: fn(&Path) -> bool, + resolve_global_config_path: FGlobalPath, +) -> Result +where + FEnv: Fn(&str) -> Option, + FRead: Fn(&Path) -> Result, + FGlobalPath: Fn() -> Result, +{ + let runtime = resolve_runtime_config_with( + &ConfigRequest { + report_format: ReportFormat::Text, + config_path: None, + log_level: None, + timeout_ms: None, + }, + cwd, + env_lookup, + read_file, + path_exists, + resolve_global_config_path, + )?; + + Ok(ResolvedAuthRuntimeConfig { + workos_client_id: runtime.workos_client_id, + }) +} + fn resolve_runtime_config(request: &ConfigRequest, cwd: &Path) -> Result { resolve_runtime_config_with( request, @@ -187,6 +273,7 @@ where let mut file_config = FileConfig { log_level: None, timeout_ms: None, + workos_client_id: None, }; for loaded_path in &loaded_config_paths { let raw = read_file(&loaded_path.path)?; @@ -197,6 +284,9 @@ where if let Some(timeout_ms) = layer.timeout_ms { file_config.timeout_ms = Some(timeout_ms); } + if let Some(workos_client_id) = layer.workos_client_id { + file_config.workos_client_id = Some(workos_client_id); + } } let mut resolved_log_level = ResolvedValue { @@ -248,13 +338,55 @@ where }; } + let resolved_workos_client_id = resolve_optional_auth_config_value( + WORKOS_CLIENT_ID_KEY, + file_config.workos_client_id, + &env_lookup, + ); + Ok(RuntimeConfig { loaded_config_paths, log_level: resolved_log_level, timeout_ms: resolved_timeout_ms, + workos_client_id: resolved_workos_client_id, }) } +fn resolve_optional_auth_config_value( + key: AuthConfigKeySpec, + file_value: Option>, + env_lookup: &FEnv, +) -> ResolvedOptionalValue +where + FEnv: Fn(&str) -> Option, +{ + if let Some(raw) = env_lookup(key.env_key) { + return ResolvedOptionalValue { + value: Some(raw), + source: Some(ValueSource::Env), + }; + } + + if let Some(value) = file_value { + return ResolvedOptionalValue { + value: Some(value.value), + source: Some(ValueSource::ConfigFile(value.source)), + }; + } + + if let Some(value) = key.baked_default { + return ResolvedOptionalValue { + value: Some(value.to_string()), + source: Some(ValueSource::Default), + }; + } + + ResolvedOptionalValue { + value: None, + source: None, + } +} + fn resolve_config_paths( request: &ConfigRequest, cwd: &Path, @@ -332,11 +464,12 @@ fn parse_file_config(raw: &str, path: &Path, source: ConfigPathSource) -> Result })?; for key in object.keys() { - if key != "log_level" && key != "timeout_ms" { + if key != "log_level" && key != "timeout_ms" && key != WORKOS_CLIENT_ID_KEY.config_key { bail!( - "Config file '{}' contains unknown key '{}'. Allowed keys: log_level, timeout_ms.", + "Config file '{}' contains unknown key '{}'. Allowed keys: log_level, timeout_ms, {}.", path.display(), - key + key, + WORKOS_CLIENT_ID_KEY.config_key ); } } @@ -373,12 +506,39 @@ fn parse_file_config(raw: &str, path: &Path, source: ConfigPathSource) -> Result None => None, }; + let workos_client_id = parse_optional_string_key(object, path, source, WORKOS_CLIENT_ID_KEY)?; + Ok(FileConfig { log_level, timeout_ms, + workos_client_id, }) } +fn parse_optional_string_key( + object: &serde_json::Map, + path: &Path, + source: ConfigPathSource, + key: AuthConfigKeySpec, +) -> Result>> { + let Some(value) = object.get(key.config_key) else { + return Ok(None); + }; + + let raw = value.as_str().with_context(|| { + format!( + "Config key '{}' in '{}' must be a string.", + key.config_key, + path.display() + ) + })?; + + Ok(Some(FileConfigValue { + value: raw.to_string(), + source, + })) +} + fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> String { match report_format { ReportFormat::Text => { @@ -396,6 +556,10 @@ fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> S &runtime.timeout_ms.value.to_string(), runtime.timeout_ms.source, ), + format_optional_auth_resolved_value_text( + WORKOS_CLIENT_ID_KEY, + &runtime.workos_client_id, + ), ]; lines.join("\n") } @@ -416,7 +580,8 @@ fn format_show_output(runtime: &RuntimeConfig, report_format: ReportFormat) -> S "value": runtime.timeout_ms.value, "source": runtime.timeout_ms.source.as_str(), "config_source": runtime.timeout_ms.source.config_source().map(ConfigPathSource::as_str), - } + }, + "workos_client_id": format_optional_auth_resolved_value_json(WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id) } } }); @@ -433,6 +598,14 @@ fn format_validate_output(runtime: &RuntimeConfig, report_format: ReportFormat) "Precedence: flags > env > config file > defaults".to_string(), format_config_paths_text(runtime), "Validation issues: none".to_string(), + format!( + "Resolved auth precedence: {}", + WORKOS_CLIENT_ID_KEY.precedence_description() + ), + format_optional_auth_resolved_value_text( + WORKOS_CLIENT_ID_KEY, + &runtime.workos_client_id, + ), ]; lines.join("\n") } @@ -444,7 +617,10 @@ fn format_validate_output(runtime: &RuntimeConfig, report_format: ReportFormat) "valid": true, "precedence": "flags > env > config file > defaults", "config_paths": format_config_paths_json(runtime), - "issues": [] + "issues": [], + "resolved_auth": { + "workos_client_id": format_optional_auth_resolved_value_json(WORKOS_CLIENT_ID_KEY, &runtime.workos_client_id) + } } }); serde_json::to_string_pretty(&payload) @@ -497,12 +673,105 @@ fn format_resolved_value_text(key: &str, value: &str, source: ValueSource) -> St } } +fn format_optional_auth_resolved_value_text( + key: AuthConfigKeySpec, + value: &ResolvedOptionalValue, +) -> String { + match (value.value.as_deref(), value.source) { + (Some(raw_value), Some(source)) => { + let display_value = format_text_display_value(key.config_key, raw_value); + match source.config_source() { + Some(config_source) => format!( + "- {}: {} (source: {}, config_source: {}, auth_precedence: {})", + key.config_key, + display_value, + source.as_str(), + config_source.as_str(), + key.precedence_description() + ), + None => format!( + "- {}: {} (source: {}, auth_precedence: {})", + key.config_key, + display_value, + source.as_str(), + key.precedence_description() + ), + } + } + _ => format!( + "- {}: (unset) (source: none, auth_precedence: {})", + key.config_key, + key.precedence_description() + ), + } +} + +fn format_optional_auth_resolved_value_json( + key: AuthConfigKeySpec, + value: &ResolvedOptionalValue, +) -> Value { + json!({ + "value": value.value, + "display_value": value.value.as_deref().map(|raw| format_text_display_value(key.config_key, raw)), + "source": value.source.map(ValueSource::as_str), + "config_source": value.source.and_then(ValueSource::config_source).map(ConfigPathSource::as_str), + "precedence": key.precedence_description(), + }) +} + +fn format_text_display_value(key: &str, value: &str) -> String { + if should_fully_redact_text_value(key) { + return "[REDACTED]".to_string(); + } + + if looks_credential_like(value) { + return abbreviate_text_value(value); + } + + value.to_string() +} + +fn should_fully_redact_text_value(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + ["password", "passwd", "secret", "token", "api_key", "apikey"] + .iter() + .any(|needle| key.contains(needle)) +} + +fn looks_credential_like(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.len() >= 16 + && trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/')) +} + +fn abbreviate_text_value(value: &str) -> String { + let total = value.chars().count(); + if total <= 8 { + return value.to_string(); + } + + let prefix: String = value.chars().take(4).collect(); + let suffix: String = value + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}...{suffix}") +} + #[cfg(test)] mod tests { use super::{ - format_show_output, format_validate_output, resolve_runtime_config_with, ConfigPathSource, - ConfigRequest, ConfigSubcommand, LoadedConfigPath, LogLevel, ReportFormat, ResolvedValue, - RuntimeConfig, ValueSource, + format_show_output, format_validate_output, resolve_optional_auth_config_value, + resolve_runtime_config_with, AuthConfigKeySpec, ConfigPathSource, ConfigRequest, + FileConfigValue, LoadedConfigPath, LogLevel, ReportFormat, ResolvedOptionalValue, + ResolvedValue, RuntimeConfig, ValueSource, WORKOS_CLIENT_ID_BAKED_DEFAULT, + WORKOS_CLIENT_ID_KEY, }; use anyhow::Result; use serde_json::Value; @@ -531,9 +800,12 @@ mod tests { |key| match key { "SCE_LOG_LEVEL" => Some("debug".to_string()), "SCE_TIMEOUT_MS" => Some("700".to_string()), + "WORKOS_CLIENT_ID" => Some("from-env".to_string()), _ => None, }, - |_| Ok("{\"log_level\":\"error\",\"timeout_ms\":500}".to_string()), + |_| { + Ok("{\"log_level\":\"error\",\"timeout_ms\":500,\"workos_client_id\":\"from-config\"}".to_string()) + }, |_| true, || Ok(PathBuf::from("/state")), )?; @@ -542,6 +814,14 @@ mod tests { assert_eq!(resolved.log_level.source.as_str(), "flag"); assert_eq!(resolved.timeout_ms.value, 900); assert_eq!(resolved.timeout_ms.source.as_str(), "flag"); + assert_eq!(resolved.workos_client_id.value.as_deref(), Some("from-env")); + assert_eq!( + resolved + .workos_client_id + .source + .map(|source| source.as_str()), + Some("env") + ); Ok(()) } @@ -559,9 +839,12 @@ mod tests { |key| match key { "SCE_LOG_LEVEL" => Some("warn".to_string()), "SCE_TIMEOUT_MS" => Some("1200".to_string()), + "WORKOS_CLIENT_ID" => Some("from-env".to_string()), _ => None, }, - |_| Ok("{\"log_level\":\"error\",\"timeout_ms\":500}".to_string()), + |_| { + Ok("{\"log_level\":\"error\",\"timeout_ms\":500,\"workos_client_id\":\"from-config\"}".to_string()) + }, |_| true, || Ok(PathBuf::from("/state")), )?; @@ -570,6 +853,14 @@ mod tests { assert_eq!(resolved.log_level.source.as_str(), "env"); assert_eq!(resolved.timeout_ms.value, 1200); assert_eq!(resolved.timeout_ms.source.as_str(), "env"); + assert_eq!(resolved.workos_client_id.value.as_deref(), Some("from-env")); + assert_eq!( + resolved + .workos_client_id + .source + .map(|source| source.as_str()), + Some("env") + ); Ok(()) } @@ -589,9 +880,83 @@ mod tests { assert_eq!(resolved.log_level.source.as_str(), "default"); assert_eq!(resolved.timeout_ms.value, 30000); assert_eq!(resolved.timeout_ms.source.as_str(), "default"); + assert_eq!( + resolved.workos_client_id.value.as_deref(), + Some(WORKOS_CLIENT_ID_BAKED_DEFAULT) + ); + assert_eq!( + resolved + .workos_client_id + .source + .map(|source| source.as_str()), + Some("default") + ); Ok(()) } + #[test] + fn auth_resolver_uses_baked_default_when_env_and_config_are_absent() { + let resolved = resolve_optional_auth_config_value(WORKOS_CLIENT_ID_KEY, None, &|_| None); + + assert_eq!( + resolved.value.as_deref(), + Some(WORKOS_CLIENT_ID_BAKED_DEFAULT) + ); + assert_eq!(resolved.source, Some(ValueSource::Default)); + } + + #[test] + fn auth_resolver_uses_config_when_env_is_absent() { + let resolved = resolve_optional_auth_config_value( + WORKOS_CLIENT_ID_KEY, + Some(FileConfigValue { + value: "from-config".to_string(), + source: ConfigPathSource::DefaultDiscoveredLocal, + }), + &|_| None, + ); + + assert_eq!(resolved.value.as_deref(), Some("from-config")); + assert_eq!( + resolved.source, + Some(ValueSource::ConfigFile( + ConfigPathSource::DefaultDiscoveredLocal, + )) + ); + } + + #[test] + fn auth_resolver_uses_env_over_config_and_baked_default() { + let resolved = resolve_optional_auth_config_value( + WORKOS_CLIENT_ID_KEY, + Some(FileConfigValue { + value: "from-config".to_string(), + source: ConfigPathSource::DefaultDiscoveredGlobal, + }), + &|key| match key { + "WORKOS_CLIENT_ID" => Some("from-env".to_string()), + _ => None, + }, + ); + + assert_eq!(resolved.value.as_deref(), Some("from-env")); + assert_eq!(resolved.source, Some(ValueSource::Env)); + } + + #[test] + fn auth_resolver_supports_keys_without_baked_defaults() { + let key = AuthConfigKeySpec { + config_key: "other_auth_key", + env_key: "OTHER_AUTH_KEY", + baked_default: None, + }; + + let resolved = resolve_optional_auth_config_value(key, None, &|_| None); + + assert_eq!(resolved.value, None); + assert_eq!(resolved.source, None); + } + #[test] fn resolver_rejects_unknown_config_keys() { let req = ConfigRequest { @@ -610,6 +975,7 @@ mod tests { ) .expect_err("unknown config keys should fail"); assert!(error.to_string().contains("contains unknown key 'unknown'")); + assert!(error.to_string().contains("workos_client_id")); } #[test] @@ -621,10 +987,12 @@ mod tests { |_| None, |path| { if path == Path::new("/state/sce/config.json") { - return Ok("{\"log_level\":\"error\",\"timeout_ms\":500}".to_string()); + return Ok("{\"log_level\":\"error\",\"timeout_ms\":500,\"workos_client_id\":\"global-client\"}".to_string()); } if path == Path::new("/workspace/.sce/config.json") { - return Ok("{\"timeout_ms\":700}".to_string()); + return Ok( + "{\"timeout_ms\":700,\"workos_client_id\":\"local-client\"}".to_string() + ); } Err(anyhow::anyhow!( "unexpected config path: {}", @@ -669,6 +1037,66 @@ mod tests { .map(|source| source.as_str()), Some("default_discovered_local") ); + + assert_eq!( + resolved.workos_client_id.value.as_deref(), + Some("local-client") + ); + assert_eq!( + resolved + .workos_client_id + .source + .map(|source| source.as_str()), + Some("config_file") + ); + assert_eq!( + resolved + .workos_client_id + .source + .and_then(|source| source.config_source()) + .map(|source| source.as_str()), + Some("default_discovered_local") + ); + Ok(()) + } + + #[test] + fn resolver_uses_global_workos_client_id_when_local_omits_key() -> Result<()> { + let req = request(); + let resolved = resolve_runtime_config_with( + &req, + Path::new("/workspace"), + |_| None, + |path| { + if path == Path::new("/state/sce/config.json") { + return Ok("{\"workos_client_id\":\"global-client\"}".to_string()); + } + if path == Path::new("/workspace/.sce/config.json") { + return Ok("{}".to_string()); + } + Err(anyhow::anyhow!( + "unexpected config path: {}", + path.display() + )) + }, + |path| { + path == Path::new("/state/sce/config.json") + || path == Path::new("/workspace/.sce/config.json") + }, + || Ok(PathBuf::from("/state")), + )?; + + assert_eq!( + resolved.workos_client_id.value.as_deref(), + Some("global-client") + ); + assert_eq!( + resolved + .workos_client_id + .source + .map(|source| source.as_str()), + Some("config_file") + ); Ok(()) } @@ -692,6 +1120,10 @@ mod tests { value: 1200, source: ValueSource::Flag, }, + workos_client_id: ResolvedOptionalValue { + value: None, + source: None, + }, } } @@ -711,9 +1143,84 @@ mod tests { ); assert_eq!(parsed["result"]["resolved"]["log_level"]["source"], "env"); assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["source"], "flag"); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["value"], + Value::Null + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["source"], + Value::Null + ); + Ok(()) + } + + #[test] + fn show_json_output_reports_workos_client_id_source_metadata() -> Result<()> { + let runtime = RuntimeConfig { + loaded_config_paths: vec![LoadedConfigPath { + path: PathBuf::from("/workspace/.sce/config.json"), + source: ConfigPathSource::DefaultDiscoveredLocal, + }], + log_level: ResolvedValue { + value: LogLevel::Info, + source: ValueSource::Default, + }, + timeout_ms: ResolvedValue { + value: 30000, + source: ValueSource::Default, + }, + workos_client_id: ResolvedOptionalValue { + value: Some("local-client".to_string()), + source: Some(ValueSource::ConfigFile( + ConfigPathSource::DefaultDiscoveredLocal, + )), + }, + }; + + let parsed: Value = + serde_json::from_str(&format_show_output(&runtime, ReportFormat::Json))?; + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["value"], + "local-client" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["source"], + "config_file" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["config_source"], + "default_discovered_local" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["precedence"], + "env (WORKOS_CLIENT_ID) > config file (workos_client_id) > baked default (client_sce_default)" + ); Ok(()) } + #[test] + fn show_text_output_abbreviates_credential_like_auth_values() { + let runtime = RuntimeConfig { + loaded_config_paths: vec![], + log_level: ResolvedValue { + value: LogLevel::Info, + source: ValueSource::Default, + }, + timeout_ms: ResolvedValue { + value: 30000, + source: ValueSource::Default, + }, + workos_client_id: ResolvedOptionalValue { + value: Some("client_1234567890abcdef".to_string()), + source: Some(ValueSource::Env), + }, + }; + + let output = format_show_output(&runtime, ReportFormat::Text); + assert!(output.contains("workos_client_id: clie...cdef")); + assert!(output.contains("auth_precedence: env (WORKOS_CLIENT_ID) > config file (workos_client_id) > baked default (client_sce_default)")); + } + #[test] fn validate_json_output_is_deterministic_for_same_runtime() -> Result<()> { let runtime = sample_runtime(); @@ -726,6 +1233,35 @@ mod tests { assert_eq!(parsed["result"]["command"], "config_validate"); assert_eq!(parsed["result"]["valid"], true); assert!(parsed["result"]["issues"].as_array().is_some()); + assert!(parsed["result"]["resolved_auth"]["workos_client_id"].is_object()); Ok(()) } + + #[test] + fn validate_text_output_reports_auth_precedence_and_source() { + let runtime = RuntimeConfig { + loaded_config_paths: vec![LoadedConfigPath { + path: PathBuf::from("/workspace/.sce/config.json"), + source: ConfigPathSource::DefaultDiscoveredLocal, + }], + log_level: ResolvedValue { + value: LogLevel::Info, + source: ValueSource::Default, + }, + timeout_ms: ResolvedValue { + value: 30000, + source: ValueSource::Default, + }, + workos_client_id: ResolvedOptionalValue { + value: Some("local-client".to_string()), + source: Some(ValueSource::ConfigFile( + ConfigPathSource::DefaultDiscoveredLocal, + )), + }, + }; + + let output = format_validate_output(&runtime, ReportFormat::Text); + assert!(output.contains("Resolved auth precedence: env (WORKOS_CLIENT_ID) > config file (workos_client_id) > baked default (client_sce_default)")); + assert!(output.contains("workos_client_id: local-client (source: config_file, config_source: default_discovered_local")); + } } diff --git a/cli/src/services/mod.rs b/cli/src/services/mod.rs index f55f6d5..da7025c 100644 --- a/cli/src/services/mod.rs +++ b/cli/src/services/mod.rs @@ -1,4 +1,6 @@ pub mod agent_trace; +pub mod auth; +pub mod auth_command; pub mod completion; pub mod config; pub mod doctor; @@ -12,4 +14,5 @@ pub mod resilience; pub mod security; pub mod setup; pub mod sync; +pub mod token_storage; pub mod version; diff --git a/cli/src/services/token_storage.rs b/cli/src/services/token_storage.rs new file mode 100644 index 0000000..096fd09 --- /dev/null +++ b/cli/src/services/token_storage.rs @@ -0,0 +1,395 @@ +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::services::auth::TokenResponse; + +const TOKEN_FILE_SUBPATH: &str = "sce/auth/tokens.json"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct StoredTokens { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + pub refresh_token: String, + pub scope: Option, + pub stored_at_unix_seconds: u64, +} + +impl StoredTokens { + fn from_token_response(token: &TokenResponse) -> Result { + let stored_at_unix_seconds = current_unix_timestamp_seconds()?; + Ok(Self { + access_token: token.access_token.clone(), + token_type: token.token_type.clone(), + expires_in: token.expires_in, + refresh_token: token.refresh_token.clone(), + scope: token.scope.clone(), + stored_at_unix_seconds, + }) + } +} + +#[derive(Debug)] +pub enum TokenStorageError { + PathResolution(String), + Io(std::io::Error), + Serialization(serde_json::Error), + CorruptedTokenFile(String), + Permission(String), +} + +impl fmt::Display for TokenStorageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PathResolution(reason) => write!( + f, + "Unable to resolve token storage path: {reason}. Try: set a valid user home/state directory and retry." + ), + Self::Io(error) => write!( + f, + "Failed to read or write authentication tokens: {error}. Try: verify file permissions for the auth state directory." + ), + Self::Serialization(error) => write!( + f, + "Failed to serialize authentication tokens: {error}. Try: rerun login to regenerate credentials." + ), + Self::CorruptedTokenFile(reason) => write!( + f, + "Stored authentication tokens are invalid: {reason}. Try: run 'sce logout' and then 'sce login'." + ), + Self::Permission(reason) => write!( + f, + "Unable to apply secure token file permissions: {reason}. Try: verify local account permissions and retry." + ), + } + } +} + +impl std::error::Error for TokenStorageError {} + +impl From for TokenStorageError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for TokenStorageError { + fn from(value: serde_json::Error) -> Self { + Self::Serialization(value) + } +} + +pub fn save_tokens(token: &TokenResponse) -> Result { + let token_path = token_file_path()?; + let stored = StoredTokens::from_token_response(token)?; + save_tokens_at_path(&token_path, &stored)?; + Ok(stored) +} + +pub fn load_tokens() -> Result, TokenStorageError> { + let token_path = token_file_path()?; + load_tokens_from_path(&token_path) +} + +pub fn delete_tokens() -> Result { + let token_path = token_file_path()?; + delete_tokens_at_path(&token_path) +} + +pub fn token_file_path() -> Result { + #[cfg(target_os = "linux")] + { + return linux_token_file_path(); + } + + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + let Some(data_dir) = dirs::data_dir() else { + return Err(TokenStorageError::PathResolution( + "data directory could not be resolved".to_string(), + )); + }; + return Ok(data_dir.join(TOKEN_FILE_SUBPATH)); + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + if let Some(state_dir) = dirs::state_dir() { + return Ok(state_dir.join(TOKEN_FILE_SUBPATH)); + } + if let Some(data_dir) = dirs::data_dir() { + return Ok(data_dir.join(TOKEN_FILE_SUBPATH)); + } + Err(TokenStorageError::PathResolution( + "state and data directories could not be resolved".to_string(), + )) + } +} + +fn save_tokens_at_path(path: &Path, stored: &StoredTokens) -> Result<(), TokenStorageError> { + ensure_parent_directory(path)?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + + apply_secure_file_permissions(path)?; + + let encoded = serde_json::to_vec_pretty(stored)?; + file.write_all(&encoded)?; + file.write_all(b"\n")?; + file.sync_all()?; + + Ok(()) +} + +fn load_tokens_from_path(path: &Path) -> Result, TokenStorageError> { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(TokenStorageError::Io(error)), + }; + + let parsed: StoredTokens = serde_json::from_str(&content).map_err(|error| { + TokenStorageError::CorruptedTokenFile(format!("{} ({error})", path.display())) + })?; + + Ok(Some(parsed)) +} + +fn delete_tokens_at_path(path: &Path) -> Result { + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(TokenStorageError::Io(error)), + } +} + +fn ensure_parent_directory(path: &Path) -> Result<(), TokenStorageError> { + let Some(parent) = path.parent() else { + return Err(TokenStorageError::PathResolution(format!( + "token path '{}' has no parent directory", + path.display() + ))); + }; + + fs::create_dir_all(parent)?; + apply_secure_directory_permissions(parent)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn linux_token_file_path() -> Result { + if let Some(state_dir) = dirs::state_dir() { + return Ok(state_dir.join(TOKEN_FILE_SUBPATH)); + } + + let Some(home_dir) = dirs::home_dir() else { + return Err(TokenStorageError::PathResolution( + "home directory could not be resolved for Linux fallback".to_string(), + )); + }; + + Ok(home_dir + .join(".local") + .join("state") + .join(TOKEN_FILE_SUBPATH)) +} + +fn current_unix_timestamp_seconds() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| { + TokenStorageError::PathResolution(format!("system clock is invalid: {error}")) + })? + .as_secs()) +} + +#[cfg(unix)] +fn apply_secure_directory_permissions(path: &Path) -> Result<(), TokenStorageError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn apply_secure_directory_permissions(_path: &Path) -> Result<(), TokenStorageError> { + Ok(()) +} + +#[cfg(unix)] +fn apply_secure_file_permissions(path: &Path) -> Result<(), TokenStorageError> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(windows)] +fn apply_secure_file_permissions(path: &Path) -> Result<(), TokenStorageError> { + use std::process::Command; + + let username = std::env::var("USERNAME").map_err(|_| { + TokenStorageError::Permission( + "USERNAME environment variable is unavailable on Windows".to_string(), + ) + })?; + + let grant_rule = format!("{username}:(R,W)"); + let output = Command::new("icacls") + .arg(path) + .arg("/inheritance:r") + .arg("/grant:r") + .arg(grant_rule) + .output() + .map_err(|error| { + TokenStorageError::Permission(format!("failed to execute icacls: {error}")) + })?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(TokenStorageError::Permission(format!( + "icacls failed for '{}': {stderr}", + path.display() + ))) + } +} + +#[cfg(not(any(unix, windows)))] +fn apply_secure_file_permissions(_path: &Path) -> Result<(), TokenStorageError> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{delete_tokens_at_path, load_tokens_from_path, save_tokens_at_path, StoredTokens}; + use std::fs; + use std::path::PathBuf; + + fn unique_test_path(test_name: &str) -> PathBuf { + let unique = format!( + "sce-token-storage-{}-{}-{}", + test_name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos() + ); + std::env::temp_dir().join(unique).join("tokens.json") + } + + fn fixture_tokens() -> StoredTokens { + StoredTokens { + access_token: "access-token".to_string(), + token_type: "Bearer".to_string(), + expires_in: 3600, + refresh_token: "refresh-token".to_string(), + scope: Some("openid profile".to_string()), + stored_at_unix_seconds: 1_700_000_000, + } + } + + #[test] + fn save_and_load_round_trip() { + let token_path = unique_test_path("round-trip"); + let tokens = fixture_tokens(); + + save_tokens_at_path(&token_path, &tokens).expect("tokens should save"); + + let loaded = load_tokens_from_path(&token_path) + .expect("load should succeed") + .expect("tokens should exist"); + assert_eq!(loaded, tokens); + + let _ = fs::remove_dir_all( + token_path + .parent() + .and_then(|parent| parent.parent()) + .expect("temp tree should have two parent levels"), + ); + } + + #[test] + fn load_missing_token_file_returns_none() { + let token_path = unique_test_path("missing-file"); + let loaded = load_tokens_from_path(&token_path).expect("missing file should not error"); + assert!(loaded.is_none()); + } + + #[test] + fn load_invalid_json_returns_corruption_error() { + let token_path = unique_test_path("invalid-json"); + let parent = token_path.parent().expect("token file should have parent"); + fs::create_dir_all(parent).expect("should create parent directory"); + fs::write(&token_path, "{not valid json").expect("should write invalid payload"); + + let error = load_tokens_from_path(&token_path).expect_err("invalid json should fail"); + let message = error.to_string(); + assert!(message.contains("Stored authentication tokens are invalid")); + + let _ = fs::remove_dir_all( + token_path + .parent() + .and_then(|path| path.parent()) + .expect("temp tree should have two parent levels"), + ); + } + + #[test] + fn delete_existing_token_file_returns_true() { + let token_path = unique_test_path("delete-existing"); + save_tokens_at_path(&token_path, &fixture_tokens()).expect("tokens should save"); + + let deleted = delete_tokens_at_path(&token_path).expect("delete should succeed"); + + assert!(deleted); + assert!(!token_path.exists()); + + let _ = fs::remove_dir_all( + token_path + .parent() + .and_then(|path| path.parent()) + .expect("temp tree should have two parent levels"), + ); + } + + #[test] + fn delete_missing_token_file_returns_false() { + let token_path = unique_test_path("delete-missing"); + + let deleted = delete_tokens_at_path(&token_path).expect("delete should not fail"); + + assert!(!deleted); + } + + #[cfg(unix)] + #[test] + fn save_sets_unix_file_permissions_to_0600() { + use std::os::unix::fs::PermissionsExt; + + let token_path = unique_test_path("unix-perms"); + save_tokens_at_path(&token_path, &fixture_tokens()).expect("tokens should save"); + + let metadata = fs::metadata(&token_path).expect("token file should exist"); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + let _ = fs::remove_dir_all( + token_path + .parent() + .and_then(|path| path.parent()) + .expect("temp tree should have two parent levels"), + ); + } +} diff --git a/cli/tests/config_precedence_integration.rs b/cli/tests/config_precedence_integration.rs new file mode 100644 index 0000000..20a0ef4 --- /dev/null +++ b/cli/tests/config_precedence_integration.rs @@ -0,0 +1,364 @@ +use std::ffi::OsStr; +use std::fs; + +use serde_json::Value; + +mod support; + +use support::{render_command_result, BinaryIntegrationHarness, TestResult}; + +type ConfigIntegrationHarness = BinaryIntegrationHarness; + +#[test] +fn config_show_flags_override_env_and_config_values() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + let config_path = write_config_file( + &harness, + "explicit-config.json", + r#"{"log_level":"error","timeout_ms":500}"#, + )?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("--config"), + config_path.as_os_str(), + OsStr::new("--log-level"), + OsStr::new("warn"), + OsStr::new("--timeout-ms"), + OsStr::new("900"), + ]) + .env("SCE_LOG_LEVEL", "debug") + .env("SCE_TIMEOUT_MS", "1200") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with layered overrides should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["result"]["command"], "config_show"); + assert_eq!( + parsed["result"]["precedence"], + "flags > env > config file > defaults" + ); + assert_eq!(parsed["result"]["resolved"]["log_level"]["value"], "warn"); + assert_eq!(parsed["result"]["resolved"]["log_level"]["source"], "flag"); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["value"], 900); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["source"], "flag"); + assert_eq!( + parsed["result"]["resolved"]["log_level"]["config_source"], + Value::Null + ); + assert_eq!( + parsed["result"]["resolved"]["timeout_ms"]["config_source"], + Value::Null + ); + assert_eq!(parsed["result"]["config_paths"][0]["source"], "flag"); + + Ok(()) +} + +#[test] +fn config_show_env_overrides_config_when_flags_are_absent() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + let config_path = write_config_file( + &harness, + "explicit-config.json", + r#"{"log_level":"error","timeout_ms":500}"#, + )?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("--config"), + config_path.as_os_str(), + ]) + .env("SCE_LOG_LEVEL", "warn") + .env("SCE_TIMEOUT_MS", "1200") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with env overrides should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!(parsed["result"]["resolved"]["log_level"]["value"], "warn"); + assert_eq!(parsed["result"]["resolved"]["log_level"]["source"], "env"); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["value"], 1200); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["source"], "env"); + assert_eq!(parsed["result"]["config_paths"][0]["source"], "flag"); + + Ok(()) +} + +#[test] +fn config_show_uses_config_values_when_higher_precedence_inputs_are_absent() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + let config_dir = harness.repo_root().join(".sce"); + fs::create_dir_all(&config_dir)?; + let config_path = config_dir.join("config.json"); + fs::write(&config_path, r#"{"log_level":"debug","timeout_ms":4567}"#)?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + ]) + .env_remove("SCE_LOG_LEVEL") + .env_remove("SCE_TIMEOUT_MS") + .env_remove("SCE_CONFIG_FILE") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with discovered config should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!(parsed["result"]["resolved"]["log_level"]["value"], "debug"); + assert_eq!( + parsed["result"]["resolved"]["log_level"]["source"], + "config_file" + ); + assert_eq!( + parsed["result"]["resolved"]["log_level"]["config_source"], + "default_discovered_local" + ); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["value"], 4567); + assert_eq!( + parsed["result"]["resolved"]["timeout_ms"]["source"], + "config_file" + ); + assert_eq!( + parsed["result"]["resolved"]["timeout_ms"]["config_source"], + "default_discovered_local" + ); + assert_eq!( + parsed["result"]["config_paths"][0]["path"], + config_path.display().to_string() + ); + assert_eq!( + parsed["result"]["config_paths"][0]["source"], + "default_discovered_local" + ); + + Ok(()) +} + +#[test] +fn config_show_uses_defaults_when_no_higher_precedence_inputs_exist() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + ]) + .env_remove("SCE_LOG_LEVEL") + .env_remove("SCE_TIMEOUT_MS") + .env_remove("SCE_CONFIG_FILE") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with no overrides should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!(parsed["result"]["resolved"]["log_level"]["value"], "info"); + assert_eq!( + parsed["result"]["resolved"]["log_level"]["source"], + "default" + ); + assert_eq!(parsed["result"]["resolved"]["timeout_ms"]["value"], 30000); + assert_eq!( + parsed["result"]["resolved"]["timeout_ms"]["source"], + "default" + ); + assert_eq!(parsed["result"]["config_paths"], Value::Array(Vec::new())); + + Ok(()) +} + +#[test] +fn config_show_auth_env_overrides_config_and_baked_default() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + let config_path = write_config_file( + &harness, + "explicit-config.json", + r#"{"workos_client_id":"from-config"}"#, + )?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("--config"), + config_path.as_os_str(), + ]) + .env("WORKOS_CLIENT_ID", "from-env") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with auth env override should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["value"], + "from-env" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["source"], + "env" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["config_source"], + Value::Null + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["precedence"], + "env (WORKOS_CLIENT_ID) > config file (workos_client_id) > baked default (client_sce_default)" + ); + + Ok(()) +} + +#[test] +fn config_show_auth_uses_config_when_env_is_absent() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + let config_path = write_config_file( + &harness, + "explicit-config.json", + r#"{"workos_client_id":"from-config"}"#, + )?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + OsStr::new("--config"), + config_path.as_os_str(), + ]) + .env_remove("WORKOS_CLIENT_ID") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with auth config fallback should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["value"], + "from-config" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["source"], + "config_file" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["config_source"], + "flag" + ); + + Ok(()) +} + +#[test] +fn config_show_auth_uses_baked_default_when_env_and_config_are_absent() -> TestResult<()> { + let harness = ConfigIntegrationHarness::new("sce-config-precedence")?; + + let output = harness + .base_command(support::sce_binary_path()) + .args([ + OsStr::new("config"), + OsStr::new("show"), + OsStr::new("--format"), + OsStr::new("json"), + ]) + .env_remove("WORKOS_CLIENT_ID") + .env_remove("SCE_CONFIG_FILE") + .output()?; + + let result = render_command_result(output); + assert!( + result.success(), + "config show with auth baked default should succeed\nstdout:\n{}\nstderr:\n{}", + result.stdout, + result.stderr + ); + + let parsed = parse_json_stdout(&result.stdout)?; + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["value"], + "client_sce_default" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["source"], + "default" + ); + assert_eq!( + parsed["result"]["resolved"]["workos_client_id"]["config_source"], + Value::Null + ); + + Ok(()) +} + +fn write_config_file( + harness: &ConfigIntegrationHarness, + relative_path: &str, + contents: &str, +) -> TestResult { + let config_path = harness.temp_path().join(relative_path); + fs::write(&config_path, contents)?; + Ok(config_path) +} + +fn parse_json_stdout(stdout: &str) -> TestResult { + Ok(serde_json::from_str(stdout)?) +} diff --git a/cli/tests/setup_integration.rs b/cli/tests/setup_integration.rs index 688e307..503e8b3 100644 --- a/cli/tests/setup_integration.rs +++ b/cli/tests/setup_integration.rs @@ -1,143 +1,17 @@ -use std::error::Error; -use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::process::Command; -type TestResult = Result>; +mod support; -#[derive(Debug)] -struct IntegrationTempDir { - path: PathBuf, -} - -impl IntegrationTempDir { - fn new(prefix: &str) -> TestResult { - let epoch_nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let path = - std::env::temp_dir().join(format!("{prefix}-{}-{epoch_nanos}", std::process::id())); - fs::create_dir_all(&path)?; - Ok(Self { path }) - } +use support::{ + null_device_path, render_command_result, sce_binary_path, BinaryIntegrationHarness, TestResult, +}; - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for IntegrationTempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -#[derive(Debug)] -struct SetupIntegrationHarness { - temp: IntegrationTempDir, - repo_root: PathBuf, - state_home: PathBuf, - home_dir: PathBuf, -} - -#[derive(Debug)] -struct CommandResult { - status: std::process::ExitStatus, - stdout: String, - stderr: String, -} +type SetupIntegrationHarness = BinaryIntegrationHarness; const REQUIRED_HOOK_NAMES: [&str; 3] = ["pre-commit", "commit-msg", "post-commit"]; -impl CommandResult { - fn success(&self) -> bool { - self.status.success() - } -} - -impl SetupIntegrationHarness { - fn new(prefix: &str) -> TestResult { - let temp = IntegrationTempDir::new(prefix)?; - let repo_root = temp.path().join("repo"); - let state_home = temp.path().join("xdg-state"); - let home_dir = temp.path().join("home"); - - fs::create_dir_all(&repo_root)?; - fs::create_dir_all(&state_home)?; - fs::create_dir_all(&home_dir)?; - - Ok(Self { - temp, - repo_root, - state_home, - home_dir, - }) - } - - fn repo_root(&self) -> &Path { - &self.repo_root - } - - fn state_home(&self) -> &Path { - &self.state_home - } - - fn init_git_repo(&self) -> TestResult<()> { - let result = self.run_git(["init", "-q"])?; - if !result.success() { - return Err(format!( - "git init failed:\nstdout:\n{}\nstderr:\n{}", - result.stdout, result.stderr - ) - .into()); - } - Ok(()) - } - - fn configure_local_hooks_path(&self, relative_hooks_path: &str) -> TestResult<()> { - let result = self.run_git(["config", "core.hooksPath", relative_hooks_path])?; - if !result.success() { - return Err(format!( - "git config core.hooksPath failed:\nstdout:\n{}\nstderr:\n{}", - result.stdout, result.stderr - ) - .into()); - } - Ok(()) - } - - fn run_sce(&self, args: I) -> TestResult - where - I: IntoIterator, - S: AsRef, - { - let output = self.base_command(sce_binary_path()).args(args).output()?; - Ok(render_command_result(output)) - } - - fn run_git(&self, args: I) -> TestResult - where - I: IntoIterator, - S: AsRef, - { - let output = self.base_command("git").args(args).output()?; - Ok(render_command_result(output)) - } - - fn base_command>(&self, program: P) -> Command { - let mut command = Command::new(program); - command - .current_dir(&self.repo_root) - .env("XDG_STATE_HOME", &self.state_home) - .env("HOME", &self.home_dir) - .env("LOCALAPPDATA", &self.state_home) - .env("APPDATA", &self.state_home) - .env("GIT_CONFIG_GLOBAL", null_device_path()) - .env("GIT_CONFIG_NOSYSTEM", "1"); - command - } -} - #[test] fn setup_hooks_default_path_install_and_rerun_are_deterministic() -> TestResult<()> { let harness = SetupIntegrationHarness::new("sce-setup-integration")?; @@ -340,14 +214,14 @@ fn setup_hooks_repo_relative_path() -> TestResult<()> { let canonical_repo_root = harness.repo_root().canonicalize()?; let expected_hooks_dir = canonical_repo_root.join(".git/hooks"); - let parent_dir = harness.temp.path(); + let parent_dir = harness.temp_path(); let relative_repo_path = "repo"; let output = Command::new(sce_binary_path()) .args(["setup", "--hooks", "--repo", relative_repo_path]) .current_dir(parent_dir) .env("XDG_STATE_HOME", harness.state_home()) - .env("HOME", &harness.home_dir) + .env("HOME", harness.home_dir()) .env("GIT_CONFIG_GLOBAL", null_device_path()) .env("GIT_CONFIG_NOSYSTEM", "1") .output()?; @@ -451,10 +325,10 @@ fn harness_scopes_turso_state_home_to_test_temp_root() -> TestResult<()> { expected_local_db.display() ); assert!( - expected_local_db.starts_with(harness.temp.path()), + expected_local_db.starts_with(harness.temp_path()), "expected Turso local DB path '{}' to stay within test temp root '{}')", expected_local_db.display(), - harness.temp.path().display() + harness.temp_path().display() ); Ok(()) @@ -733,7 +607,7 @@ mod pty_interactive { harness.repo_root(), &[ ("XDG_STATE_HOME", harness.state_home().to_str().unwrap()), - ("HOME", harness.home_dir.to_str().unwrap()), + ("HOME", harness.home_dir().to_str().unwrap()), ("GIT_CONFIG_GLOBAL", null_device_path()), ("GIT_CONFIG_NOSYSTEM", "1"), ], @@ -787,7 +661,7 @@ mod pty_interactive { harness.repo_root(), &[ ("XDG_STATE_HOME", harness.state_home().to_str().unwrap()), - ("HOME", harness.home_dir.to_str().unwrap()), + ("HOME", harness.home_dir().to_str().unwrap()), ("GIT_CONFIG_GLOBAL", null_device_path()), ("GIT_CONFIG_NOSYSTEM", "1"), ], @@ -834,7 +708,7 @@ mod pty_interactive { .args(["setup"]) .current_dir(harness.repo_root()) .env("XDG_STATE_HOME", harness.state_home()) - .env("HOME", &harness.home_dir) + .env("HOME", harness.home_dir()) .env("GIT_CONFIG_GLOBAL", null_device_path()) .env("GIT_CONFIG_NOSYSTEM", "1") .stdin(std::process::Stdio::null()) @@ -924,7 +798,7 @@ mod pty_interactive { .args(["setup"]) .current_dir(harness.repo_root()) .env("XDG_STATE_HOME", harness.state_home()) - .env("HOME", &harness.home_dir) + .env("HOME", harness.home_dir()) .env("GIT_CONFIG_GLOBAL", null_device_path()) .env("GIT_CONFIG_NOSYSTEM", "1") .stdin(std::process::Stdio::null()) @@ -1393,53 +1267,11 @@ fn assert_executable_file(path: &Path) -> TestResult<()> { Ok(()) } -fn sce_binary_path() -> PathBuf { - if let Some(path) = std::env::var_os("CARGO_BIN_EXE_sce") { - return PathBuf::from(path); - } - - let test_executable = std::env::current_exe() - .expect("integration test should resolve current executable path for binary fallback"); - let debug_root = test_executable - .parent() - .and_then(Path::parent) - .expect("integration test executable should run from target/{profile}/deps"); - - let candidate = debug_root.join(binary_filename("sce")); - assert!( - candidate.exists(), - "integration test could not resolve compiled sce binary at '{}'", - candidate.display() - ); - - candidate -} - -fn binary_filename(base: &str) -> String { - #[cfg(windows)] - { - format!("{base}.exe") - } - - #[cfg(not(windows))] - { - base.to_string() - } -} - -fn render_command_result(output: Output) -> CommandResult { - CommandResult { - status: output.status, - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - } -} - fn expected_agent_trace_local_db_path(harness: &SetupIntegrationHarness) -> PathBuf { #[cfg(target_os = "windows")] { harness - .state_home + .state_home() .join("sce") .join("agent-trace") .join("local.db") @@ -1448,7 +1280,7 @@ fn expected_agent_trace_local_db_path(harness: &SetupIntegrationHarness) -> Path #[cfg(target_os = "macos")] { harness - .home_dir + .home_dir() .join("Library") .join("Application Support") .join("sce") @@ -1459,7 +1291,7 @@ fn expected_agent_trace_local_db_path(harness: &SetupIntegrationHarness) -> Path #[cfg(not(any(target_os = "windows", target_os = "macos")))] { harness - .state_home + .state_home() .join("sce") .join("agent-trace") .join("local.db") @@ -1535,15 +1367,3 @@ fn trim_windows_verbatim_prefix(value: &str) -> &str { .or_else(|| value.strip_prefix("//?/")) .unwrap_or(value) } - -fn null_device_path() -> &'static str { - #[cfg(windows)] - { - "NUL" - } - - #[cfg(not(windows))] - { - "/dev/null" - } -} diff --git a/cli/tests/support/mod.rs b/cli/tests/support/mod.rs new file mode 100644 index 0000000..3961c7d --- /dev/null +++ b/cli/tests/support/mod.rs @@ -0,0 +1,199 @@ +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub type TestResult = Result>; + +#[derive(Debug)] +pub struct IntegrationTempDir { + path: PathBuf, +} + +impl IntegrationTempDir { + pub fn new(prefix: &str) -> TestResult { + let epoch_nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); + let path = + std::env::temp_dir().join(format!("{prefix}-{}-{epoch_nanos}", std::process::id())); + fs::create_dir_all(&path)?; + Ok(Self { path }) + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for IntegrationTempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[derive(Debug)] +pub struct CommandResult { + pub status: std::process::ExitStatus, + pub stdout: String, + pub stderr: String, +} + +impl CommandResult { + pub fn success(&self) -> bool { + self.status.success() + } +} + +#[derive(Debug)] +pub struct BinaryIntegrationHarness { + temp: IntegrationTempDir, + repo_root: PathBuf, + state_home: PathBuf, + home_dir: PathBuf, +} + +impl BinaryIntegrationHarness { + pub fn new(prefix: &str) -> TestResult { + let temp = IntegrationTempDir::new(prefix)?; + let repo_root = temp.path().join("repo"); + let state_home = temp.path().join("xdg-state"); + let home_dir = temp.path().join("home"); + + fs::create_dir_all(&repo_root)?; + fs::create_dir_all(&state_home)?; + fs::create_dir_all(&home_dir)?; + + Ok(Self { + temp, + repo_root, + state_home, + home_dir, + }) + } + + pub fn temp_path(&self) -> &Path { + self.temp.path() + } + + pub fn repo_root(&self) -> &Path { + &self.repo_root + } + + pub fn state_home(&self) -> &Path { + &self.state_home + } + + pub fn home_dir(&self) -> &Path { + &self.home_dir + } + + pub fn init_git_repo(&self) -> TestResult<()> { + let result = self.run_git(["init", "-q"])?; + if !result.success() { + return Err(format!( + "git init failed:\nstdout:\n{}\nstderr:\n{}", + result.stdout, result.stderr + ) + .into()); + } + Ok(()) + } + + pub fn configure_local_hooks_path(&self, relative_hooks_path: &str) -> TestResult<()> { + let result = self.run_git(["config", "core.hooksPath", relative_hooks_path])?; + if !result.success() { + return Err(format!( + "git config core.hooksPath failed:\nstdout:\n{}\nstderr:\n{}", + result.stdout, result.stderr + ) + .into()); + } + Ok(()) + } + + pub fn run_sce(&self, args: I) -> TestResult + where + I: IntoIterator, + S: AsRef, + { + let output = self.base_command(sce_binary_path()).args(args).output()?; + Ok(render_command_result(output)) + } + + pub fn run_git(&self, args: I) -> TestResult + where + I: IntoIterator, + S: AsRef, + { + let output = self.base_command("git").args(args).output()?; + Ok(render_command_result(output)) + } + + pub fn base_command>(&self, program: P) -> Command { + let mut command = Command::new(program); + command + .current_dir(&self.repo_root) + .env("XDG_STATE_HOME", &self.state_home) + .env("HOME", &self.home_dir) + .env("LOCALAPPDATA", &self.state_home) + .env("APPDATA", &self.state_home) + .env("GIT_CONFIG_GLOBAL", null_device_path()) + .env("GIT_CONFIG_NOSYSTEM", "1"); + command + } +} + +pub fn sce_binary_path() -> PathBuf { + if let Some(path) = std::env::var_os("CARGO_BIN_EXE_sce") { + return PathBuf::from(path); + } + + let test_executable = std::env::current_exe() + .expect("integration test should resolve current executable path for binary fallback"); + let debug_root = test_executable + .parent() + .and_then(Path::parent) + .expect("integration test executable should run from target/{profile}/deps"); + + let candidate = debug_root.join(binary_filename("sce")); + assert!( + candidate.exists(), + "integration test could not resolve compiled sce binary at '{}'", + candidate.display() + ); + + candidate +} + +pub fn null_device_path() -> &'static str { + #[cfg(windows)] + { + "NUL" + } + + #[cfg(not(windows))] + { + "/dev/null" + } +} + +fn binary_filename(base: &str) -> String { + #[cfg(windows)] + { + format!("{base}.exe") + } + + #[cfg(not(windows))] + { + base.to_string() + } +} + +pub fn render_command_result(output: Output) -> CommandResult { + CommandResult { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } +} diff --git a/context/architecture.md b/context/architecture.md index 7b8cd0d..f23fa91 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -26,7 +26,8 @@ Current target renderer helper modules: - `nix run .#sync-opencode-config` (flake app entrypoint for config regeneration and sync workflow) - `nix run .#token-count-workflows` (flake app entrypoint for static workflow token-count execution via `evals/token-count-workflows.ts`) - `nix run .#cli-integration-tests` (flake app entrypoint for binary-driven Rust setup integration tests) -- `nix flake check` / `checks..cli-setup-command-surface` / `checks..cli-setup-integration` (flake check derivations that run targeted CLI setup command-surface and setup integration verification from `cli/`) +- `nix run .#cli-config-precedence-integration-tests` (opt-in flake app entrypoint for compiled-binary Rust config-precedence integration tests) +- `nix flake check` / `checks..cli-setup-command-surface` / `checks..cli-setup-integration` (flake check derivations that run targeted CLI setup command-surface and setup integration verification from `cli/`; the opt-in config-precedence integration slice is excluded) - `.github/workflows/pkl-generated-parity.yml` (CI wrapper that runs the parity check for pushes to `main` and pull requests targeting `main`) - `.github/workflows/agnix-config-validate-report.yml` (CI wrapper that runs `agnix validate` from `config/`, writes `context/tmp/ci-reports/agnix-validate-report.txt`, uploads it when non-info findings are present, and fails on any non-info finding) - `.github/workflows/workflow-token-count.yml` (CI wrapper that runs `nix run .#token-count-workflows` for pushes/pull requests targeting `main` and uploads token-footprint artifacts from `context/tmp/token-footprint/`) @@ -76,8 +77,8 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands. - `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - `cli/src/services/observability.rs` provides deterministic runtime observability controls and rendering for app lifecycle logs, including env-configured threshold/format (`SCE_LOG_LEVEL`, `SCE_LOG_FORMAT`), optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic truncate-or-append policy), optional OTEL export bootstrap (`SCE_OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`), stable event identifiers, severity filtering, stderr-only primary emission with optional mirrored file writes, and redaction-safe emission through the shared security helper. -- `cli/src/command_surface.rs` is the source of truth for top-level command contract metadata (`help`, `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, `version`, `completion`) and explicit implemented-vs-placeholder status. -- `cli/src/services/config.rs` defines `sce config` parser/runtime contracts (`show`, `validate`, `--help`), deterministic config-file selection, explicit value precedence (`flags > env > config file > defaults`), strict config-file validation (`log_level`, `timeout_ms`), and deterministic text/JSON output rendering. +- `cli/src/command_surface.rs` is the source of truth for top-level command contract metadata (`help`, `config`, `setup`, `doctor`, `auth`, `mcp`, `hooks`, `sync`, `version`, `completion`) and explicit implemented-vs-placeholder status. +- `cli/src/services/config.rs` defines `sce config` parser/runtime contracts (`show`, `validate`, `--help`), deterministic config-file selection, explicit value precedence (`flags > env > config file > defaults`), strict config-file validation (`log_level`, `timeout_ms`, `workos_client_id`), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, and deterministic text/JSON output rendering. - `cli/src/services/output_format.rs` defines the canonical shared CLI output-format contract (`OutputFormat`) for supporting commands, with deterministic `text|json` parsing and command-scoped actionable invalid-value guidance. - `cli/src/services/local_db.rs` provides the local Turso data adapter, including `Builder::new_local(...)` initialization, deterministic persistent runtime DB target resolution/bootstrap (`ensure_agent_trace_local_db_ready_blocking`), async execute/query smoke checks for in-memory and file-backed targets, and idempotent migration application for Agent Trace persistence foundations (`repositories`, `commits`, `trace_records`, `trace_ranges`), reconciliation ingestion entities (`reconciliation_runs`, `rewrite_mappings`, `conversations`), and T14 retry/observability storage (`trace_retry_queue`, `reconciliation_metrics`) with replay/query indexes. - `cli/src/test_support.rs` provides a shared test-only temp-directory helper (`TestTempDir`) used by service tests that need filesystem fixtures. @@ -100,6 +101,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/Cargo.toml` keeps crates.io-ready package metadata populated while `publish = false` remains the current policy; local Cargo release/install verification targets `cargo build --manifest-path cli/Cargo.toml --release` and `cargo install --path cli --locked`. Tokio remains intentionally constrained (`default-features = false`) with current-thread runtime usage plus timer-backed bounded resilience wrappers for retry/timeout behavior. This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `clap`, `clap_complete`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, `turso`); local Turso connectivity smoke checks now exist, while broader runtime integrations remain deferred. +This phase establishes compile-safe extension seams with a dependency baseline (`anyhow`, `dirs`, `hmac`, `inquire`, `lexopt`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, `turso`); local Turso connectivity smoke checks now exist, while broader runtime integrations remain deferred. ## Shared Context Drift parity mapping diff --git a/context/cli/config-precedence-contract.md b/context/cli/config-precedence-contract.md index aa89550..a1e5f59 100644 --- a/context/cli/config-precedence-contract.md +++ b/context/cli/config-precedence-contract.md @@ -19,6 +19,14 @@ Resolved runtime values follow this deterministic order: 3. config file values (`log_level`, `timeout_ms`) 4. defaults (`log_level=info`, `timeout_ms=30000`) +Supported auth-adjacent runtime keys can participate in one shared key-declared precedence path without defining CLI flags. Each key declares its config-file name, environment variable name, and whether a baked default is allowed. The shared resolver supports keys that allow a baked default and keys that intentionally omit one. The first implemented migrated key is `workos_client_id`, which resolves as: + +1. environment value (`WORKOS_CLIENT_ID`) +2. config file value (`workos_client_id`) +3. baked default (`client_sce_default`) + +When a supported auth-adjacent key omits a baked default, the same resolver still reports `value: null` / `(unset)` with no resolved source when both env and config inputs are absent. + Config file selection follows this deterministic order: 1. `--config ` @@ -32,10 +40,11 @@ When both discovered defaults exist, they are merged in memory in deterministic ## Validation contract - Config file content must be valid JSON with a top-level object. -- Allowed keys: `log_level`, `timeout_ms`. +- Allowed keys: `log_level`, `timeout_ms`, `workos_client_id`. - Unknown keys fail validation. - `log_level` must be one of `error|warn|info|debug`. - `timeout_ms` must be an unsigned integer. +- `workos_client_id` must be a string when present. ## Output contract @@ -44,6 +53,24 @@ When both discovered defaults exist, they are merged in memory in deterministic - Text output includes the canonical precedence string: `flags > env > config file > defaults`. - Output reports discovered config files as `config_paths` (JSON) / `Config files:` (text). - Resolved values continue to report `source`; when source is `config_file`, output also reports a deterministic `config_source` value (`flag`, `env`, `default_discovered_global`, `default_discovered_local`). +- `show` includes migrated supported auth keys in `result.resolved`; `validate` includes them in `result.resolved_auth`. +- Auth-key JSON output includes `value`, text-oriented `display_value`, `source`, optional `config_source`, and a key-specific `precedence` string describing the allowed resolution chain. +- Auth-key text output includes `auth_precedence` and abbreviates full values when they look credential-like; fully secret-bearing key classes remain redacted. +- For the currently migrated key `workos_client_id`, `show` reports the baked default with `source: default` when env/config inputs are absent. + +## Binary end-to-end coverage contract + +- Compiled-binary config precedence coverage lives in `cli/tests/config_precedence_integration.rs`. +- The canonical opt-in Nix entrypoint is `nix run .#cli-config-precedence-integration-tests`. +- That entrypoint runs `cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration -- --nocapture` through `nix develop`. +- The test slice is intentionally opt-in and is excluded from default `nix flake check`. +- Stable end-to-end assertions target compiled-binary `sce config show` / `sce config validate` stdout, using JSON payload fields such as resolved `value`, `source`, and `config_source` rather than internal Rust structs. + +## Auth diagnostics contract + +- Auth failure guidance for migrated auth keys no longer assumes env-only configuration. +- Missing-client-id guidance for `workos_client_id` describes the full allowed chain for this key: `WORKOS_CLIENT_ID`, config-file key `workos_client_id`, or fallback to the baked default when no higher-precedence invalid override blocks it. +- Auth login runtime guidance refers to the resolved source chain generically (`WORKOS_CLIENT_ID`, config file, or baked default for `workos_client_id`) instead of env-only wording. ## Related files diff --git a/context/cli/placeholder-foundation.md b/context/cli/placeholder-foundation.md index 3ed0147..304ce66 100644 --- a/context/cli/placeholder-foundation.md +++ b/context/cli/placeholder-foundation.md @@ -2,23 +2,22 @@ The repository now includes a Rust CLI crate at `cli/` for SCE automation work. -`cli/README.md` is the operator onboarding source for running current commands and understanding safety limitations. +Operator onboarding currently comes from `sce --help`, command-local `--help` output, and the focused CLI context files under `context/cli/` and `context/sce/`. ## Current implemented slice - Binary entrypoint: `cli/src/main.rs` - Runtime shell: `cli/src/app.rs` - Command contract catalog: `cli/src/command_surface.rs` -- Dependency contract snapshot: `cli/src/dependency_contract.rs` - Local Turso adapter: `cli/src/services/local_db.rs` -- Service domains: `cli/src/services/{agent_trace,completion,config,setup,doctor,mcp,hooks,resilience,sync,version}.rs` +- Service domains: `cli/src/services/{agent_trace,auth,auth_command,completion,config,setup,doctor,mcp,hooks,resilience,sync,token_storage,version}.rs` - Shared test temp-path helper: `cli/src/test_support.rs` (`TestTempDir`, test-only module) ## Onboarding documentation -- `cli/README.md` includes quick-start commands for `help`, `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, and `completion`. -- The README explicitly distinguishes implemented behavior from placeholders and maps future work to module contracts. -- Verification guidance in the README uses crate-local `cargo check`, `cargo test`, and `cargo build` commands, plus release/install commands for current installability (`cargo build --manifest-path cli/Cargo.toml --release`, `cargo install --path cli --locked`). +- `sce --help` includes quick-start commands for `setup`, `auth`, `doctor`, and `version`, plus the implemented-vs-placeholder top-level command catalog. +- Command-local help is available for implemented commands including `sce auth --help`, `sce auth login --help`, `sce setup --help`, `sce doctor --help`, and `sce completion --help`. +- Current verification guidance for the CLI slice uses crate-local `cargo test --manifest-path cli/Cargo.toml`, plus release/install commands for installability (`cargo build --manifest-path cli/Cargo.toml --release`, `cargo install --path cli --locked`). ## Nix release installability surface @@ -44,6 +43,7 @@ The repository now includes a Rust CLI crate at `cli/` for SCE automation work. - `config`: implemented - `setup`: implemented - `doctor`: implemented +- `auth`: implemented - `mcp`: placeholder - `hooks`: implemented - `sync`: placeholder @@ -55,7 +55,7 @@ Top-level help also includes copy-ready agent-oriented examples for interactive Placeholder commands currently acknowledge planned behavior and do not claim production implementation. `mcp` and `sync` route through explicit service-contract placeholders. `hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, and `post-rewrite`. -`config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`) and deterministic text/JSON output modes. +`config` exposes deterministic inspect/validate entrypoints (`sce config show`, `sce config validate`) with explicit precedence (`flags > env > config file > defaults`), a shared auth-runtime resolver for supported keys that declare env/config/optional baked-default inputs starting with `workos_client_id`, and deterministic text/JSON output modes that report auth-key source metadata plus key-specific precedence details. `version` exposes deterministic runtime identification output in text mode by default and JSON mode via `--format json`. `completion` exposes deterministic shell completion generation via `sce completion --shell `. `setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Both) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--both`). @@ -84,7 +84,7 @@ Placeholder commands currently acknowledge planned behavior and do not claim pro ## Service contracts - `cli/src/services/setup.rs` defines setup parsing/selection contracts plus runtime install orchestration (`run_setup_for_mode`) over the embedded asset install engine. -- `cli/src/services/config.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, and deterministic text/JSON rendering. +- `cli/src/services/config.rs` defines config parser/runtime contracts (`show`, `validate`, `--help`), strict config-file key/type validation, deterministic text/JSON rendering, and shared auth-key metadata that declares env key, config-file key, and optional baked-default eligibility for supported auth runtime values starting with `workos_client_id` (`WORKOS_CLIENT_ID` vs `workos_client_id`); auth-key output includes key-specific precedence metadata in both output modes and abbreviates credential-like values in text output. - `cli/src/services/doctor.rs` defines hook rollout health validation (`run_doctor`) with path-source detection (default/local/global), required-hook presence/executable checks, and command-local usage text (`doctor_usage_text`). - `cli/src/services/agent_trace.rs` defines the task-scoped schema adapter contract (`adapt_trace_payload`) from internal attribution input structs to Agent Trace-shaped record structs, including fixed git `vcs` mapping, contributor type mapping, and reserved `dev.crocoder.sce.*` metadata placement. - `cli/src/services/mcp.rs` defines `McpService`, a `McpCapabilitySnapshot` model (primary + supported transports), `CachePolicy` defaults for future file-cache workflows (`cache-put`/`cache-get`) with `runnable: false` placeholders, command-local usage text (`mcp_usage_text`), and `McpRequest` parsing/rendering for deterministic text or `--format json` placeholder output. @@ -93,7 +93,9 @@ Placeholder commands currently acknowledge planned behavior and do not claim pro - `cli/src/services/hooks.rs` defines production local hook runtime parsing/dispatch (`HookSubcommand`, `parse_hooks_subcommand`, `run_hooks_subcommand`) for `pre-commit`, `commit-msg`, `post-commit`, and `post-rewrite`, plus checkpoint/persistence/retry finalization seams used by hook entrypoints. - `cli/src/services/resilience.rs` defines shared bounded retry/timeout/backoff execution policy (`RetryPolicy`, `run_with_retry`) with deterministic failure messaging and retry observability hooks. - `cli/src/services/sync.rs` defines cloud-sync abstraction points (`CloudSyncGateway`, `CloudSyncRequest`, `CloudSyncPlan`) layered after the local Turso smoke gate, plus `SyncRequest` parsing/rendering for deterministic text or `--format json` placeholder output and command-local usage text (`sync_usage_text`). -- `cli/src/app.rs` dispatches `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, `version`, and `completion` through service-level modules so runtime messages are sourced from domain modules instead of inline strings. +- `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) with cross-platform state-path resolution, JSON payload storage including `stored_at_unix_seconds`, graceful missing-file deletion behavior, missing/corrupted-file handling, and restrictive on-disk permissions (`0600` on Unix; Windows best-effort ACL hardening via `icacls`). +- `cli/src/services/auth_command.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `status`, including shared text/JSON rendering, token-storage-backed logout deletion, expiry-aware status reporting, and precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions. +- `cli/src/app.rs` dispatches `auth`, `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, `version`, and `completion` through service-level modules so runtime messages are sourced from domain modules instead of inline strings. ## Local Turso adapter behavior @@ -107,12 +109,15 @@ Placeholder commands currently acknowledge planned behavior and do not claim pro ## Parser-focused tests -- `cli/src/app.rs` unit tests cover default-help behavior, known command routing, command-local `--help` routing for `doctor`/`mcp`/`hooks`/`sync`, and failure paths for unknown commands/options and extra arguments. +- `cli/src/app.rs` unit tests cover default-help behavior, auth/config/setup/hooks routing, command-local `--help` routing for `doctor`/`mcp`/`hooks`/`sync`, and failure paths for unknown commands/options and extra arguments. - `cli/src/app.rs` additionally validates setup contract routing for interactive default, explicit target flags, and mutually-exclusive setup flag failures. - `cli/src/services/local_db.rs` tests cover in-memory and file-backed local Turso initialization plus execute/query smoke checks. - `cli/src/services/resilience.rs` tests lock deterministic retry behavior for transient failures, timeout exhaustion, and actionable terminal error messaging. - `cli/src/services/sync.rs` tests confirm `sync` runs the local smoke gate, preserves deterministic text placeholder messaging, and emits stable JSON placeholder fields. - `cli/src/services/{setup,mcp,hooks,sync}.rs` include contract-focused tests for setup flag parsing/validation, interactive selection/cancellation dispatch, setup run messaging, and hook runtime argument/IO/finalization behavior. +- `cli/src/services/token_storage.rs` tests cover token save/load round-trips, missing-file handling, token deletion outcomes, invalid JSON corruption handling, and Unix `0600` file-permission enforcement. +- `cli/src/services/auth.rs` tests cover WorkOS device/token payload shape parsing, RFC 8628 device and refresh grant constant wiring, terminal OAuth error mapping with `Try:` guidance, polling decision handling for `authorization_pending`/`slow_down`/terminal outcomes, token-expiry evaluation, and refresh-token re-login guidance for terminal refresh errors. +- `cli/src/services/auth_command.rs` tests cover auth subcommand dispatch, login/logout/status text-or-JSON report shapes, and `Try:` guidance preservation. - `cli/src/services/agent_trace.rs` includes adapter mapping tests for required field projection, contributor enum/model_id handling, and extension metadata placement under reserved reverse-domain keys. - `cli/src/services/setup.rs` tests also verify embedded-manifest completeness against runtime `config/` trees, deterministic sorted path normalization, target-scoped iterator behavior (`OpenCode`, `Claude`, `Both`), install backup creation/replacement, and rollback restoration after injected swap failures. - `cli/src/services/setup.rs` and `cli/src/services/local_db.rs` now share temporary path setup through `crate::test_support::TestTempDir` to keep filesystem test fixtures consistent and cleanup deterministic. @@ -120,8 +125,9 @@ Placeholder commands currently acknowledge planned behavior and do not claim pro ## Dependency baseline - `cli/Cargo.toml` declares: `anyhow`, `clap`, `clap_complete`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, and `turso`. +- `cli/Cargo.toml` currently declares: `anyhow`, `dirs`, `hmac`, `inquire`, `lexopt`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, and `turso`. - `tokio` is pinned with `default-features = false` and keeps a constrained runtime footprint for current-thread `Runtime::block_on` usage, plus timer-backed bounded retry/timeout behavior in resilience-wrapped operations. -- `cli/src/dependency_contract.rs` keeps compile-time crate references centralized for this placeholder slice. +- `cli/src/services/auth.rs` now includes both the T03 Device Authorization Flow runtime (`start_device_auth_flow`) and T04 token-refresh runtime (`ensure_valid_token`) for WorkOS: it requests device codes, polls `/oauth/device/token` at fixed API interval (adding 5 seconds on `slow_down`), maps RFC 8628 terminal errors to actionable `Try:` guidance, checks token expiry from persisted `stored_at_unix_seconds + expires_in` with a bounded skew guard, refreshes expired access tokens through `/oauth/token` using `grant_type=refresh_token`, retries transient refresh failures via the shared resilience wrapper, and persists rotated tokens via `cli/src/services/token_storage.rs`. ## Scope boundary for this phase diff --git a/context/context-map.md b/context/context-map.md index 5c8e2cf..05b4db6 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -7,8 +7,8 @@ Primary context files: - `context/glossary.md` Feature/domain context: -- `context/cli/placeholder-foundation.md` (CLI command surface, setup install flow, bounded resilience-wrapped sync/local-DB smoke and bootstrap behavior, nested flake release package/app installability, and Cargo local install + crates.io readiness policy) -- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, config-file selection order, and text/JSON output schema) +- `context/cli/placeholder-foundation.md` (CLI command surface, setup install flow, WorkOS device authorization flow + token storage behavior, bounded resilience-wrapped sync/local-DB smoke and bootstrap behavior, nested flake release package/app installability, and Cargo local install + crates.io readiness policy) +- `context/cli/config-precedence-contract.md` (implemented `sce config` show/validate command contract, deterministic `flags > env > config file > defaults` resolution order, shared auth-key env/config/optional baked-default support starting with `workos_client_id`, config-file selection order, text/JSON output schema, and opt-in compiled-binary config-precedence E2E coverage contract) - `context/sce/shared-context-code-workflow.md` - `context/sce/shared-context-plan-workflow.md` (canonical `/change-to-plan` workflow, clarification/readiness gate contract, and one-task/one-atomic-commit task-slicing policy) - `context/sce/plan-code-overlap-map.md` (T01 overlap matrix for Shared Context Plan/Code, related commands, and core skill ownership/dedup targets) diff --git a/context/glossary.md b/context/glossary.md index 35c8bb5..a70676e 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -4,6 +4,7 @@ - `token-count-workflows`: Flake app command exposed as `nix run .#token-count-workflows`; canonical repository-root entrypoint that runs `evals/token-count-workflows.ts` through `nix develop` and writes token-count artifacts to `context/tmp/token-footprint/`. - `pkl-check-generated`: Flake app command exposed as `nix run .#pkl-check-generated`; canonical lightweight parity test entrypoint that runs the generated-output drift check inside the Nix dev shell. - `cli-integration-tests`: Flake app command exposed as `nix run .#cli-integration-tests`; canonical repository-root entrypoint that runs the Rust setup integration suite (`cli/tests/setup_integration.rs`) via `cargo test --test setup_integration` inside the Nix dev shell. +- `cli-config-precedence-integration-tests`: Flake app command exposed as `nix run .#cli-config-precedence-integration-tests`; canonical opt-in repository-root entrypoint that runs the compiled-binary config precedence suite (`cli/tests/config_precedence_integration.rs`) via `cargo test --test config_precedence_integration` inside the Nix dev shell, and is intentionally excluded from default `nix flake check`. - lightweight post-task verification baseline: Required quick checks after each completed task in this repo: `nix run .#pkl-check-generated` and `nix flake check`. - disposable plan lifecycle: Policy where `context/plans/` holds active execution artifacts only; completed plans are disposable and durable outcomes must be reflected in current-state context files and/or `context/decisions/`. - important change (context sync): A completed task change that affects cross-cutting behavior, repository-wide policy/contracts, architecture boundaries, or canonical terminology; these changes require root context edits in `context/overview.md`, `context/architecture.md`, and/or `context/glossary.md` instead of verify-only handling. @@ -21,10 +22,10 @@ - `cli cargo local install contract`: Supported local CLI install command `cargo install --path cli --locked`, aligned with deterministic lockfile use for reproducible installs. - `cli crates.io readiness policy`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is maintained but `publish = false` remains set until first-publish prerequisites are explicitly approved. - `root-to-cli flake input coherence`: Root `flake.nix` contract that forwards `nixpkgs`, `flake-utils`, and `rust-overlay` to the nested `cli` path input (`cli.inputs..follows`) so `nix flake check` can evaluate nested CLI outputs without missing-input failures. -- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented setup installation flow, implemented `hooks` subcommand routing/validation entrypoints, and placeholder behavior for `mcp` and `sync`. +- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|status`), implemented setup installation flow, implemented `hooks` subcommand routing/validation entrypoints, and placeholder behavior for `mcp` and `sync`. - `command surface contract`: The static command catalog in `cli/src/command_surface.rs` that marks each top-level command as `implemented` or `placeholder`. -- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs` and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, `version`, and `completion`, executes implemented command flows, emits TODO placeholders for deferred commands, and returns deterministic actionable errors for invalid invocation. -- `sce dependency contract`: Crate dependency baseline declared in `cli/Cargo.toml` and referenced via `cli/src/dependency_contract.rs` (`anyhow`, `clap`, `clap_complete`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, `turso`). +- `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs` and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `mcp`, `hooks`, `sync`, `version`, and `completion`, executes implemented command flows, emits TODO placeholders for deferred commands, and returns deterministic actionable errors for invalid invocation. +- `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, `turso`) and validated through normal compile/test coverage. - `local Turso adapter`: Async data-layer module in `cli/src/services/local_db.rs` that initializes local DB targets with `turso::Builder::new_local(...)` and runs execute/query smoke checks. - `sync Turso smoke gate`: Behavior in `cli/src/services/sync.rs` where the `sync` placeholder command runs an in-memory local Turso smoke check under a lazily initialized shared tokio current-thread runtime before returning placeholder cloud-sync messaging. - `CLI bounded resilience wrapper`: Shared policy in `cli/src/services/resilience.rs` (`RetryPolicy`, `run_with_retry`) that applies deterministic retries/timeouts/capped backoff to transient operations, emits retry observability events, and returns actionable terminal failure guidance. @@ -60,6 +61,7 @@ - `sce shared output-format contract`: Canonical parser contract in `cli/src/services/output_format.rs` (`OutputFormat`) that centralizes supported `--format` values (`text`, `json`) and emits command-specific actionable invalid-value guidance (`Run ' --help' ...`) for commands wired to dual-output rendering. - `sce shell completion contract`: Deterministic CLI completion contract where `sce completion --shell ` emits parser-aligned Bash/Zsh/Fish completion scripts for current top-level commands and supported options/subcommands. - `cli config precedence contract`: Deterministic runtime value resolution in `cli/src/services/config.rs` with precedence `flags > env > config file > defaults` for `log_level` and `timeout_ms`; config discovery order is `--config`, `SCE_CONFIG_FILE`, then default discovered global+local paths (`${state_root}/sce/config.json` merged before `.sce/config.json`, with local overriding per key). +- `auth config baked default`: Optional key-declared fallback in `cli/src/services/config.rs` used only after env and config-file inputs are absent; the first implemented case is `workos_client_id`, which currently falls back to `client_sce_default`. - `setup install engine`: Installer in `cli/src/services/setup.rs` (`install_embedded_setup_assets`) that writes embedded setup assets into per-target staging directories and swaps them into repository-root `.opencode/`/`.claude/` destinations. - `setup backup-and-replace`: Replacement choreography in `cli/src/services/setup.rs` where existing install targets are renamed to unique `.backup` paths before staged content is promoted; on swap failure, the engine restores the original target from backup and cleans temporary staging paths. - `MCP capability snapshot`: Placeholder capability model in `cli/src/services/mcp.rs` that captures planned file-cache transport/tool contracts (`cache-put`, `cache-get`) and cache policy defaults without enabling runtime MCP execution. diff --git a/context/overview.md b/context/overview.md index 6da7cc8..6158915 100644 --- a/context/overview.md +++ b/context/overview.md @@ -4,10 +4,10 @@ This repository maintains shared assistant configuration for OpenCode and Claude It now supports both manual and automated profile variants: the manual profile preserves interactive approval gates, while the automated profile applies deterministic non-interactive behavior for CI/automation workflows. It also includes an early Rust CLI foundation at `cli/` for Shared Context Engineering workflows. -The crate ships onboarding and usage documentation at `cli/README.md` that reflects current implemented vs placeholder behavior. +Operator-facing CLI usage currently comes from `sce --help`, command-local `--help` output, and focused context files under `context/cli/` and `context/sce/`. -The CLI crate currently enforces a dependency contract including: `anyhow`, `clap`, `clap_complete`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, and `turso`. -Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling, with implemented config inspection/validation (`config show`/`config validate`), real setup orchestration, implemented `doctor` rollout validation, implemented `hooks` subcommand routing/validation entrypoints, implemented machine-readable runtime identification (`version`), implemented shell completion script generation via `clap_complete` (`completion --shell `), and placeholder dispatch for deferred commands (`mcp`, `sync`) through explicit service contracts. +The CLI crate currently depends on `anyhow`, `clap`, `clap_complete`, `dirs`, `hmac`, `inquire`, `opentelemetry`, `opentelemetry-otlp`, `opentelemetry_sdk`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `tracing-opentelemetry`, `tracing-subscriber`, and `turso`. +Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling, with implemented auth flows (`auth login|logout|status`), implemented config inspection/validation (`config show`/`config validate`), real setup orchestration, implemented `doctor` rollout validation, implemented `hooks` subcommand routing/validation entrypoints, implemented machine-readable runtime identification (`version`), implemented shell completion script generation via `clap_complete` (`completion --shell `), and placeholder dispatch for deferred commands (`mcp`, `sync`) through explicit service contracts. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL`, `SCE_LOG_FORMAT`), optional file sink controls (`SCE_LOG_FILE`, `SCE_LOG_FILE_MODE` with deterministic `truncate` default), optional OpenTelemetry export bootstrap (`SCE_OTEL_ENABLED`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_PROTOCOL`), stable lifecycle event IDs, and stderr-only primary emission so stdout command payloads remain pipe-safe. @@ -17,7 +17,8 @@ The `setup` command includes an `inquire`-backed target-selection flow: default The CLI now compiles an embedded setup asset manifest from `config/.opencode/**`, `config/.claude/**`, and `cli/assets/hooks/**` via `cli/build.rs`; `cli/src/services/setup.rs` exposes deterministic normalized relative paths plus file bytes and target-scoped iteration without runtime reads from `config/`. The setup service also provides repository-root install orchestration: it resolves interactive or flag-based target selection, installs embedded assets, and reports deterministic completion details (selected target(s), installed file counts, and backup actions). The CLI now also applies baseline security hardening for reliability-driven automation: diagnostics/logging paths use deterministic secret redaction, `sce setup --hooks --repo ` canonicalizes and validates repository paths before execution, and setup write flows run explicit directory write-permission probes before staging/swap operations. -The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`log_level`, `timeout_ms`), deterministic default discovery/merge of global+local config files (`${state_root}/sce/config.json` then `.sce/config.json` with local override), and deterministic text/JSON output contracts for `sce config show` and `sce config validate`. +The config service now provides deterministic runtime config resolution with explicit precedence (`flags > env > config file > defaults`), strict config-file validation (`log_level`, `timeout_ms`, `workos_client_id`), deterministic default discovery/merge of global+local config files (`${state_root}/sce/config.json` then `.sce/config.json` with local override), shared auth-key resolution with optional baked defaults starting at `workos_client_id`, and deterministic text/JSON output contracts for `sce config show` and `sce config validate`. +The repository root flake now also exposes an opt-in compiled-binary config-precedence integration entrypoint, `nix run .#cli-config-precedence-integration-tests`, which runs `cli/tests/config_precedence_integration.rs` outside the default `nix flake check` path while `nix run .#cli-integration-tests` remains setup-only. The `doctor` command now validates Agent Trace local rollout readiness by resolving effective git hook-path source (default, per-repo `core.hooksPath`, or global `core.hooksPath`) and checking required hook presence/executable permissions with actionable diagnostics. The `mcp` placeholder contract is now scoped to future file-cache workflows (`cache-put`/`cache-get`) and remains intentionally non-runnable. The `sync` placeholder performs a local Turso smoke check through a lazily initialized shared tokio current-thread runtime with bounded retry/timeout/backoff controls, then reports a deferred cloud-sync plan from a placeholder gateway contract; persistent local DB schema bootstrap now uses the same bounded resilience wrapper. @@ -70,6 +71,7 @@ The setup command parser/dispatch now also supports composable setup+hooks runs - Run staged destructive sync for `config/` and root `.opencode/`: `nix run .#sync-opencode-config` - Run workflow token counting from repo root: `nix run .#token-count-workflows` - Run setup integration tests through the deterministic flake app entrypoint: `nix run .#cli-integration-tests` +- Run opt-in config-precedence binary integration tests: `nix run .#cli-config-precedence-integration-tests` - Run repository flake checks (includes CLI setup command-surface and setup integration checks): `nix flake check` Lightweight post-task verification baseline (required after each completed task): run `nix run .#pkl-check-generated` and `nix flake check`. diff --git a/context/patterns.md b/context/patterns.md index bf6d5c3..08b3ac8 100644 --- a/context/patterns.md +++ b/context/patterns.md @@ -12,6 +12,7 @@ - `nix run .#sync-opencode-config` is the canonical entrypoint for staged regeneration/replacement of `config/` and replacement of repository-root `.opencode/` from regenerated `config/.opencode/`. - `nix run .#token-count-workflows` is the canonical root entrypoint for static workflow token counting (wrapping `bun run token-count-workflows` from `evals/` through `nix develop`). - `nix run .#cli-integration-tests` is the canonical root entrypoint for the Rust setup integration suite (`cargo test --manifest-path cli/Cargo.toml --test setup_integration -- --nocapture`) through `nix develop`. + - `nix run .#cli-config-precedence-integration-tests` is the canonical opt-in root entrypoint for the compiled-binary config-precedence suite (`cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration -- --nocapture`) through `nix develop`. - For flake app outputs, include `meta.description` so `nix flake check` app validation stays warning-free. - For destructive config replacement flows, regenerate into a temporary staged `config/` first, validate required generated directories exist, and only then swap live `config/`. - For destructive root `.opencode/` replacement flows, keep exclusions explicit (for example `node_modules`), use backup-and-restore around swap, and run a source/target tree parity check with the same exclusions. @@ -54,6 +55,7 @@ - Keep CI parity enforcement aligned with local workflow by running the same command in `.github/workflows/pkl-generated-parity.yml` for pushes to `main` and pull requests targeting `main`. - Keep token-count CI aligned with the flake app contract by running `nix run .#token-count-workflows` in `.github/workflows/workflow-token-count.yml` on pushes/pull requests targeting `main`, and upload artifacts from `context/tmp/token-footprint/`. - Keep setup integration CI aligned with the flake app contract by running `nix run .#cli-integration-tests` in `.github/workflows/cli-integration-tests.yml` on pushes/pull requests targeting `main`. +- Keep opt-in config-precedence integration coverage outside default `nix flake check` and outside the current CI workflow set unless a caller explicitly invokes `nix run .#cli-config-precedence-integration-tests`. - Treat `nix run .#pkl-check-generated` and `nix flake check` as the lightweight post-task verification baseline and run both after each completed task. - Keep agnix config validation on the same trigger contract (`push`/`pull_request` to `main`) in `.github/workflows/agnix-config-validate-report.yml` with job defaults pinned to `working-directory: config`. - In the agnix CI workflow, capture command output to `context/tmp/ci-reports/agnix-validate-report.txt`, treat `warning:`/`error:`/`fatal:` findings as non-info gate failures, and upload the captured report as a GitHub artifact (`agnix-validate-report`) only when non-info findings are present. @@ -93,7 +95,7 @@ - For hook setup CLI UX, allow `--hooks` as both hooks-only and composable target+hooks execution (optional `--repo `), enforce deterministic option compatibility (`--repo` requires `--hooks`; target flags stay mutually exclusive), and emit stable section-ordered setup/hook status lines for automation-friendly logs. - For setup command messaging, emit deterministic completion output that includes selected target(s), per-target install counts, and whether backup was created. - Keep module seams for future domains present and compile-safe even when behavior is deferred. -- Keep dependency additions explicit and minimal in `cli/Cargo.toml`, and anchor dependency intent in lightweight compile-time code references (`cli/src/dependency_contract.rs`). +- Keep dependency additions explicit and minimal in `cli/Cargo.toml`, and anchor dependency intent in domain-owned service types/tests rather than a separate compile-time dependency snapshot module. - Route local Turso access through a dedicated adapter module (`cli/src/services/local_db.rs`) so command handlers do not expose low-level `turso` API details. - For placeholder commands that need real infrastructure checks, use a lazily initialized shared tokio current-thread runtime wrapper in the service layer (`cli/src/services/sync.rs`) and keep user-facing output explicit about remaining placeholder scope. - For transient local IO/database hotspots, apply bounded resilience wrappers with explicit retry count, timeout, and capped backoff (`cli/src/services/resilience.rs`) and surface terminal failures with deterministic `Try:` remediation guidance. @@ -115,6 +117,7 @@ - Keep crate-local onboarding docs in `cli/README.md` and sanity-check command examples against actual `sce` output whenever command messaging changes. - Keep targeted CLI command-surface verification in flake checks: `checks..cli-setup-command-surface` runs from `cli/` and executes `cargo fmt --check` plus focused setup-related tests (`help_text_mentions_setup_target_flags`, `parser_routes_setup`, `run_setup_reports`). - Keep targeted setup integration verification in flake checks with `checks..cli-setup-integration`, which runs `cargo test --test setup_integration` from `cli/`. +- Do not add `config_precedence_integration` to default flake checks; keep it callable only through the explicit opt-in flake app entrypoint. - In `cli/flake.nix`, select the Rust toolchain via an explicit Rust overlay (`rust-overlay`) and thread that toolchain through `makeRustPlatform` so CLI check/build derivations do not rely on implicit nixpkgs Rust defaults. - When the root flake imports a nested path flake that requires additional inputs (for example `rust-overlay` in `cli/flake.nix`), mirror those inputs in the root `inputs` block and wire `cli.inputs..follows` so root-level checks do not fail from missing nested flake arguments. - For installable CLI release surfaces in nested flakes, expose an explicit named package plus default alias (`packages.sce` and `packages.default = packages.sce`) and pair it with a runnable app output (`apps.sce`) that points to the packaged binary path. diff --git a/context/plans/cli-config-precedence-nix-e2e.md b/context/plans/cli-config-precedence-nix-e2e.md new file mode 100644 index 0000000..86700b4 --- /dev/null +++ b/context/plans/cli-config-precedence-nix-e2e.md @@ -0,0 +1,155 @@ +# Plan: CLI Config Precedence Nix End-to-End Tests + +## Change Summary + +Add compiled-binary end-to-end coverage for CLI config precedence so the existing Nix-driven CLI integration path proves both implemented resolution chains: `flags > env > config file > defaults` for runtime config keys and `env > config file > baked default` for `workos_client_id`. Current code truth already documents and unit-tests these contracts, but the Nix integration entrypoint still centers on setup-only binary scenarios. This plan extends the binary integration surface without introducing a non-binary harness. + +## Success Criteria + +- [ ] Nix-driven CLI integration coverage executes compiled-binary end-to-end scenarios for `sce config show` and/or `sce config validate` +- [ ] Binary end-to-end tests prove `--log-level` / `--timeout-ms` flags override env, config-file values, and defaults +- [ ] Binary end-to-end tests prove `SCE_LOG_LEVEL` / `SCE_TIMEOUT_MS` override config-file values and defaults when flags are absent +- [ ] Binary end-to-end tests prove discovered or explicit config-file values are used when higher-precedence overrides are absent +- [ ] Binary end-to-end tests prove default fallback remains `log_level=info` and `timeout_ms=30000` when no higher-precedence sources are present +- [ ] Binary end-to-end tests prove `WORKOS_CLIENT_ID` overrides config-file and baked default values, config-file overrides baked default when env is absent, and baked default is used when env/config are absent +- [ ] The new config-precedence binary end-to-end coverage is available through a canonical Nix entrypoint without being added to the default `nix flake check` path +- [ ] Current-state context reflects the expanded CLI integration scope and config-precedence end-to-end contract without leaving setup-only wording where it is no longer true + +## Constraints and Non-Goals + +**In Scope:** +- Add CLI integration tests that invoke the compiled `sce` binary in isolated temp repositories/state roots +- Reuse or extract compiled-binary integration-test helpers only as needed to keep the new coverage deterministic +- Assert precedence using stable stdout/JSON payloads, exit codes, and isolated config/env setup +- Extend the existing Nix integration surface so the new binary tests run through a canonical repo entrypoint while remaining out of the default `nix flake check` path +- Sync focused and root context where the integration-test scope changes are now important current-state behavior + +**Out of Scope:** +- Changing the underlying config precedence implementation unless end-to-end gaps reveal a real defect +- Adding non-binary test harnesses, shell-script fixtures, or a second independent Nix test app when the existing entrypoint can be extended +- Broad redesign of config command UX beyond deterministic assertions needed for the new end-to-end coverage +- Live auth or networked WorkOS flows + +**Non-Goals:** +- Replacing existing unit tests for config resolution +- Reworking setup integration scenarios unrelated to config precedence +- Changing CI trigger filters or branch policy + +## Assumptions + +- The existing canonical repository entrypoint `nix run .#cli-integration-tests` should continue to cover current setup integration behavior; config-precedence E2E coverage may use that entrypoint or a sibling canonical Nix app as long as it stays out of default `nix flake check` +- "End-to-end binary test only" means Rust integration tests may drive the compiled `sce` binary directly, but should not rely on internal function-level assertions as the primary verification signal +- JSON output from `sce config show` / `sce config validate` is the most stable assertion surface for precedence-source checks + +## Task Stack + +- [x] T01: Introduce shared compiled-binary config integration harness support (status:done) + - Task ID: T01 + - Goal: Add or extract the minimal test-support surface needed for config-precedence end-to-end scenarios to run against the compiled `sce` binary with isolated repo/state roots. + - Boundaries (in/out of scope): + - IN: Reuse or refactor integration-test temp-dir, repo, env, and command helpers so config E2E scenarios can be added without duplicating fragile setup logic + - IN: Preserve existing setup integration behavior while making compiled-binary support usable by an additional config-precedence test target or shared support module + - OUT: Adding actual precedence assertions, changing runtime config logic, updating context files + - Done when: + - There is one deterministic compiled-binary integration support path that can provision isolated repo/state/config environments for config scenarios + - Existing setup integration tests still fit the same binary-driven model after the harness change + - The support surface is narrow enough that later config-precedence tasks can land as behavior-only commits + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --test setup_integration` + - If a new shared test-support module or second integration target is introduced, verify it still resolves the compiled `sce` binary path rather than calling internal library APIs + +- [x] T02: Add end-to-end tests for `flags > env > config file > defaults` runtime precedence (status:done) + - Task ID: T02 + - Goal: Add compiled-binary integration scenarios that prove the implemented precedence chain for `log_level` and `timeout_ms` through `sce config` command output. + - Boundaries (in/out of scope): + - IN: Cover flag-over-env-over-config-over-default behavior for `log_level` and `timeout_ms` + - IN: Use isolated config files and env setup for deterministic assertions against stdout/JSON output and resolved source metadata + - IN: Cover both a fully layered override case and no-override/default fallback case + - OUT: `workos_client_id` auth-key precedence, Nix/flake entrypoint changes, context sync + - Done when: + - Binary E2E tests show flags win over env/config/defaults for `log_level` and `timeout_ms` + - Binary E2E tests show env wins over config/defaults when flags are absent + - Binary E2E tests show config values are used when flags/env are absent + - Binary E2E tests show defaults remain `info` and `30000` when no higher-precedence source is present + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration` + - Verify assertions inspect compiled-binary command output rather than internal structs + +- [x] T03: Add end-to-end tests for `env > config file > baked default` auth precedence (status:done) + - Task ID: T03 + - Goal: Add compiled-binary integration scenarios that prove `workos_client_id` resolves through the implemented auth precedence chain. + - Boundaries (in/out of scope): + - IN: Cover env override, config-file fallback, and baked-default fallback for `WORKOS_CLIENT_ID` / `workos_client_id` + - IN: Assert resolved value/source metadata via stable `sce config` output + - OUT: Expanding auth behavior beyond precedence inspection, changing login/network flows, root Nix wiring, context sync + - Done when: + - Binary E2E tests prove `WORKOS_CLIENT_ID` wins over config-file and baked-default values + - Binary E2E tests prove config-file value wins over baked default when env is absent + - Binary E2E tests prove baked default is reported when env/config inputs are absent + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration` + - Verify assertions check stable source markers such as `env`, `config_file`, and `default` + +- [x] T04: Add opt-in canonical Nix entrypoint for config-precedence binary tests (status:done) + - Task ID: T04 + - Goal: Update flake-managed CLI integration execution so config-precedence binary tests are runnable through a canonical Nix entrypoint without being pulled into the default `nix flake check` path. + - Boundaries (in/out of scope): + - IN: Update root and nested flake wiring plus app/help text as needed so config-precedence binary tests have a canonical Nix run path + - IN: Preserve existing `nix flake check` behavior unless explicitly required for current setup integration coverage already in place + - IN: Keep naming and help text clear about which entrypoint runs setup integration vs config-precedence E2E coverage + - OUT: New CI trigger policies, unrelated flake refactors, context-file edits + - Done when: + - There is one documented canonical Nix command for running config-precedence binary E2E coverage + - The config-precedence binary tests do not run as part of default `nix flake check` + - Help text and command descriptions accurately distinguish the available integration entrypoints + - Verification notes (commands or checks): + - Run the canonical Nix command for config-precedence binary tests + - Run `nix flake check` + - Verify `nix flake check` does not pick up the config-precedence binary test slice by default + - Verify flake help/output text accurately describes the available integration commands + +- [x] T05: Sync context contracts for CLI config-precedence binary integration coverage (status:done) + - Task ID: T05 + - Goal: Update current-state context so future sessions understand which Nix entrypoint covers setup integration and which opt-in path covers config-precedence binary end-to-end coverage. + - Boundaries (in/out of scope): + - IN: Update focused context covering config precedence and CLI integration-test contracts + - IN: Update root context files only where the integration-entrypoint scope change is an important current-state contract + - OUT: Historical rollout notes or completed-work narration + - Done when: + - Focused context documents the new config-precedence binary scenarios, stable assertion policy, and canonical opt-in Nix command + - Root context accurately distinguishes setup integration coverage from config-precedence E2E coverage and does not imply the latter runs in default `nix flake check` + - Context statements match code truth and verification entrypoints exactly + - Verification notes (commands or checks): + - Verify `context/cli/config-precedence-contract.md` reflects the intended E2E assertion surface if needed + - Verify `context/overview.md`, `context/glossary.md`, `context/architecture.md`, and `context/patterns.md` stay aligned with the final split between default and opt-in integration entrypoints where touched + +- [x] T06: Validation and cleanup (status:done) + - Task ID: T06 + - Goal: Validate binary integration coverage, flake wiring, and context alignment for the CLI config-precedence Nix end-to-end change. + - Boundaries (in/out of scope): + - IN: Run focused config-precedence and existing setup integration tests + - IN: Run canonical Nix integration and repo lightweight validation baseline + - IN: Confirm context sync accuracy after implementation + - OUT: Additional product changes beyond the planned coverage and context updates + - Done when: + - The compiled-binary config-precedence integration tests pass locally through both focused cargo execution and the canonical opt-in Nix entrypoint + - Existing setup integration coverage remains green + - Root and focused context accurately reflect the final integration-test scope, entrypoint split, and precedence contract + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --test setup_integration` + - Run `cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration` + - Run the canonical Nix command for config-precedence binary tests + - Run `nix run .#pkl-check-generated` + - Run `nix flake check` + +## Validation Report + +- Session note: User stated the planned verification was already completed and asked to conclude the task as done. +- Commands run in this session: none; verification evidence was not re-executed in this session. +- Exit codes / key outputs: not captured in this session. +- Success-criteria check: plan scope and context files already reflect the split between `nix run .#cli-integration-tests` and `nix run .#cli-config-precedence-integration-tests`, and `T06` is concluded based on the user's completion statement. +- Residual risk: verification evidence is user-reported rather than freshly re-run in this session. + +## Open Questions + +None. diff --git a/context/plans/sce-auth-commands.md b/context/plans/sce-auth-commands.md new file mode 100644 index 0000000..f8f36a5 --- /dev/null +++ b/context/plans/sce-auth-commands.md @@ -0,0 +1,223 @@ +# Plan: SCE Auth Commands + +## Change Summary +Add `sce auth` command group with nested `login`, `logout`, and `status` subcommands to the CLI. The underlying WorkOS Device Authorization Flow is already implemented in `cli/src/services/auth.rs`; this plan wires up the command surface. + +## Success Criteria +- [x] `sce auth login` initiates WorkOS Device Authorization Flow and stores tokens +- [x] `sce auth logout` clears stored credentials +- [x] `sce auth status` shows current authentication state (authenticated/unauthenticated, expiry info) +- [x] All auth commands support `--format text|json` output +- [x] Error messages include actionable "Try:" guidance +- [x] Help text updated to include auth commands +- [x] Unit tests cover command parsing and dispatch + +## Constraints and Non-Goals + +**In Scope:** +- Add `Auth` command with `Login`, `Logout`, `Status` subcommands to clap schema +- Create thin `auth_command.rs` service for command orchestration +- Wire auth commands into app.rs dispatch +- Support `--format` flag for all auth commands +- Update help text and command surface registry + +**Out of Scope:** +- Changes to existing `auth.rs` core service (already implemented) +- Changes to `token_storage.rs` (already implemented) +- Authentication guard on `sync` command (separate task) +- WorkOS configuration support (separate task) +- Browser auto-open functionality + +**Non-Goals:** +- User info extraction from ID tokens +- Multi-session management +- Token revocation with WorkOS API + +## Assumptions +- Existing `auth.rs` service with `start_device_auth_flow()` and `ensure_valid_token()` works correctly +- Existing `token_storage.rs` with `load_tokens()` and `save_tokens()` works correctly +- `reqwest::Client` can be created in command context +- WorkOS client ID is available via `WORKOS_CLIENT_ID` env var or config (validation happens in auth service) +- Command follows existing patterns: clap for parsing, service modules, output format support + +## Task Stack + +- [x] T01: Add Auth command to clap schema (status:done) + - Task ID: T01 + - Goal: Add `Auth` command with `Login`, `Logout`, `Status` subcommands to `cli_schema.rs` + - Boundaries (in/out of scope): + - IN: Add `Auth` variant to `Commands` enum in `cli_schema.rs` + - IN: Create `AuthSubcommand` enum with `Login`, `Logout`, `Status` variants + - IN: `Login` has optional `--format` flag (default text) + - IN: `Logout` has optional `--format` flag (default text) + - IN: `Status` has optional `--format` flag (default text) + - IN: Add unit tests for parsing `sce auth login`, `sce auth logout`, `sce auth status` + - OUT: Service implementation, dispatch logic, actual auth flow + - Done when: + - `AuthSubcommand` enum exists with `Login`, `Logout`, `Status` variants + - `Commands::Auth` variant exists with `subcommand: AuthSubcommand` field + - Unit tests pass for all three subcommands with and without `--format json` + - `cargo test --manifest-path cli/Cargo.toml --lib cli_schema` passes + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib cli_schema` + - Verify `Cli::try_parse_from(["sce", "auth", "login"])` returns `Commands::Auth` + +- [x] T02: Create auth command service module (status:done) + - Task ID: T02 + - Goal: Create `cli/src/services/auth_command.rs` for auth command orchestration + - Boundaries (in/out of scope): + - IN: Create `auth_command.rs` module with `AuthSubcommand`, `AuthRequest` types + - IN: Implement `run_auth_subcommand()` function that dispatches to login/logout/status + - IN: Implement `run_login()` using existing `auth::start_device_auth_flow()` + - IN: Implement `run_logout()` using existing `token_storage::delete_tokens()` (or equivalent) + - IN: Implement `run_status()` using existing `token_storage::load_tokens()` and expiry calculation + - IN: Support text and JSON output formats for all commands + - IN: Include "Try:" guidance in error messages + - OUT: Changes to `auth.rs` core service, token encryption, network retry logic + - Done when: + - `cli/src/services/auth_command.rs` exists with all three command handlers + - `run_auth_subcommand()` correctly dispatches based on subcommand type + - Text output is human-readable with clear status messages + - JSON output includes structured status fields + - Error messages include actionable guidance + - `cargo test --manifest-path cli/Cargo.toml --lib auth_command` passes + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth_command` + - Verify login initiates device flow and displays user code/URL + - Verify logout removes token file gracefully + - Verify status shows correct state with expiry calculation + +- [x] T03: Wire auth commands into app dispatch (status:done) + - Task ID: T03 + - Goal: Add auth command conversion and dispatch to `app.rs` + - Boundaries (in/out of scope): + - IN: Add `Auth` variant to internal `Command` enum in `app.rs` + - IN: Implement `convert_auth_subcommand()` function + - IN: Add `Command::Auth` case to `dispatch()` function + - IN: Add `Command::Auth` case to `Command::name()` function + - IN: Add unit tests for parsing and routing auth commands + - OUT: Changes to clap schema, auth service logic, output format handling + - Done when: + - `parse_command(["sce", "auth", "login"])` returns `Command::Auth` + - `dispatch(&Command::Auth(...))` calls `auth_command::run_auth_subcommand()` + - Exit code contract maintained (success=0, runtime=4, etc.) + - All existing tests still pass + - `cargo test --manifest-path cli/Cargo.toml --lib app` passes + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib app` + - Run `sce auth status` and verify correct output + - Run `sce auth login --help` and verify usage displayed + +- [x] T04: Add logout token deletion function (status:done) + - Task ID: T04 + - Goal: Add `delete_tokens()` function to `token_storage.rs` for logout + - Boundaries (in/out of scope): + - IN: Add `delete_tokens()` function to `token_storage.rs` + - IN: Handle case where token file doesn't exist (graceful no-op) + - IN: Return appropriate result type indicating deleted vs not_found + - IN: Add unit tests for delete_tokens with existing and missing files + - OUT: Token revocation via WorkOS API, encryption, cross-platform differences + - Done when: + - `delete_tokens()` function exists and removes token file + - Returns `Ok(true)` if file was deleted, `Ok(false)` if not found + - Returns `Err` only on actual I/O errors + - Unit tests cover all scenarios + - `cargo test --manifest-path cli/Cargo.toml --lib token_storage` passes + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib token_storage` + - Manual test: `sce auth login` then `sce auth logout` then verify file removed + +- [x] T05: Update command surface and help text (status:done) + - Task ID: T05 + - Goal: Add auth commands to command surface registry and help text + - Boundaries (in/out of scope): + - IN: Add `auth` entry to `COMMANDS` array in `command_surface.rs` + - IN: Update `help_text()` to include auth command usage examples + - IN: Add `auth` to `is_known_command()` validation + - IN: Set status as `Implemented` (not Placeholder) + - OUT: Changes to auth service logic, error handling, output formats + - Done when: + - `sce --help` shows auth command in command list + - `command_surface::is_known_command("auth")` returns true + - Help text includes auth command examples + - `cargo test --manifest-path cli/Cargo.toml --lib command_surface` passes + - Verification notes: + - Run `sce --help` and verify auth is listed + - Run `cargo test --manifest-path cli/Cargo.toml --lib command_surface` + +- [x] T06: Validation, testing, and context sync (status:done) + - Task ID: T06 + - Goal: Final validation, comprehensive testing, and context synchronization + - Boundaries (in/out of scope): + - IN: Run `nix flake check` to verify no regressions + - IN: Run `nix run .#pkl-check-generated` for generated output parity + - IN: Run all cargo tests: `cargo test --manifest-path cli/Cargo.toml` + - IN: Manual end-to-end test: `sce auth status` (unauthenticated) -> `sce auth login` -> `sce auth status` (authenticated) -> `sce auth logout` -> `sce auth status` (unauthenticated) + - IN: Update `context/cli/placeholder-foundation.md` with auth command status + - IN: Update `context/overview.md` with auth feature mention + - OUT: Performance testing, load testing, security audit, sync command auth guard + - Done when: + - All automated checks pass + - Manual auth flow test completed successfully + - Context files updated to reflect auth commands + - No regressions in existing commands + - Verification notes: + - Run `nix flake check` + - Run `nix run .#pkl-check-generated` + - Run `cargo test --manifest-path cli/Cargo.toml --all` + - Complete manual flow: `sce auth status` -> `sce auth login` -> `sce auth status --format json` -> `sce auth logout` -> `sce auth status` + - Execution note (2026-03-10): `cargo test --manifest-path cli/Cargo.toml`, `nix run .#pkl-check-generated`, and `nix flake check` passed. Manual unauthenticated `status`, `status --format json`, and `logout` checks passed. Live `auth login --format json` remains environment-blocked here because `WORKOS_CLIENT_ID` is not configured, but it fails with actionable runtime guidance. + +## Validation Report + +- Commands run: + - `cargo test --manifest-path cli/Cargo.toml` + - `nix run .#pkl-check-generated` + - `nix flake check` + - `cargo run --manifest-path cli/Cargo.toml -- auth status` + - `cargo run --manifest-path cli/Cargo.toml -- auth status --format json` + - `cargo run --manifest-path cli/Cargo.toml -- auth logout` + - `cargo run --manifest-path cli/Cargo.toml -- auth login --format json` +- Exit codes and key outputs: + - `cargo test --manifest-path cli/Cargo.toml` -> exit `0`; 242 tests passed. + - `nix run .#pkl-check-generated` -> exit `0`; generated outputs are up to date. + - `nix flake check` -> exit `0`; repository checks passed on `x86_64-linux`. + - `cargo run --manifest-path cli/Cargo.toml -- auth status` -> exit `0`; reported `Authentication status: unauthenticated` and `Stored credentials: none`. + - `cargo run --manifest-path cli/Cargo.toml -- auth status --format json` -> exit `0`; emitted structured unauthenticated JSON payload with `has_stored_credentials: false`. + - `cargo run --manifest-path cli/Cargo.toml -- auth logout` -> exit `0`; reported `No stored WorkOS credentials were found.` + - `cargo run --manifest-path cli/Cargo.toml -- auth login --format json` -> exit `4`; reported `Error [SCE-ERR-RUNTIME]: WorkOS client ID is not configured. Try: set WORKOS_CLIENT_ID or configure the CLI auth client id.` +- Failed checks and follow-ups: + - No automated checks failed. + - Live WorkOS device-flow completion remains blocked in this environment until `WORKOS_CLIENT_ID` is configured. +- Success-criteria verification summary: + - `sce auth login` command path is wired and validated up to the expected environment gate; full token-storage confirmation requires a configured WorkOS client ID. + - `sce auth logout` and `sce auth status` runtime behavior verified manually. + - `--format text|json`, actionable `Try:` guidance, help-surface presence, and command parsing/dispatch coverage are verified by tests plus manual runtime checks. + - Auth feature context remains discoverable via `context/overview.md`, `context/glossary.md`, `context/context-map.md`, and `context/cli/placeholder-foundation.md`. +- Residual risks: + - End-to-end token acquisition/storage was not exercised in this shell because the required WorkOS client ID was unavailable. + - `cargo test` and `cargo run` currently emit pre-existing Rust warnings unrelated to this task. + +## Open Questions +None - requirements clarified with user (nested auth command structure confirmed). + +## Dependencies +- **Internal**: Existing `cli/src/services/auth.rs`, `cli/src/services/token_storage.rs` +- **External**: WorkOS API (`https://api.workos.com`) +- **Runtime**: WorkOS client ID via `WORKOS_CLIENT_ID` env var or config + +## Risk Mitigation +- **Risk**: Token file permissions on different platforms + - **Mitigation**: Reuse existing `token_storage.rs` which already handles cross-platform paths +- **Risk**: Async runtime for auth polling + - **Mitigation**: Use existing tokio runtime pattern from `sync.rs` and `local_db.rs` +- **Risk**: User confusion with device flow + - **Mitigation**: Clear terminal output with user code and verification URL prominently displayed + +## Implementation Notes +- Follow existing clap subcommand pattern from `ConfigSubcommand` and `HooksSubcommand` +- Reuse `output_format.rs` for text/JSON formatting +- Maintain exit code contract from `app.rs` (runtime failures use code 4) +- Keep auth_command.rs thin - delegate to existing auth.rs service +- Use `reqwest::Client::new()` for HTTP client (no custom configuration needed) +- Match error message style from existing services (include "Try:" guidance) diff --git a/context/plans/workos-cli-auth.md b/context/plans/workos-cli-auth.md new file mode 100644 index 0000000..73d68dd --- /dev/null +++ b/context/plans/workos-cli-auth.md @@ -0,0 +1,364 @@ +# Plan: WorkOS CLI Authentication + +## Change Summary +Add WorkOS SSO/OIDC authentication to the existing `sce` CLI using the OAuth 2.0 Device Authorization Flow (RFC 8628). Users must authenticate via WorkOS before using the `sync` command. This includes adding a `login` command, token management, and authentication guards. + +## Success Criteria +- [ ] `sce login` command initiates WorkOS Device Authorization Flow +- [ ] Users can authenticate by visiting verification URL and entering user code +- [ ] Access tokens and refresh tokens are securely stored locally +- [ ] Token refresh works automatically when access tokens expire +- [ ] `sce sync` command requires authentication and fails gracefully when unauthenticated +- [ ] `sce logout` command clears stored credentials +- [ ] `sce auth status` command shows current authentication state +- [ ] All authentication flows handle errors with actionable user guidance +- [ ] Documentation updated in `cli/README.md` + +## Constraints and Non-Goals +**In Scope:** +- Device Authorization Flow (OAuth 2.0 RFC 8628) implementation +- Local secure token storage (file-based with restricted permissions) +- Token refresh logic +- Authentication guards on `sync` command +- Login/logout/status commands +- Configuration of WorkOS client ID via environment or config file + +**Out of Scope:** +- Web-based authentication (only CLI device flow) +- Multi-tenant or organization selection (single WorkOS app) +- Token encryption at rest (relying on filesystem permissions for MVP) +- Browser auto-open (user manually visits URL) +- SSO provider selection (WorkOS handles this) +- Migration from any existing auth system (none exists) + +**Non-Goals:** +- Replacing WorkOS SDK with custom implementation (using direct HTTP calls is acceptable for MVP) +- Supporting other OAuth flows (Authorization Code, etc.) +- Enterprise SSO configuration UI +- Token revocation on logout (best-effort only) + +## Assumptions +- WorkOS application is already configured with Device Authorization Flow enabled +- Client ID will be provided via environment variables (`WORKOS_CLIENT_ID`) or config file +- Default WorkOS API base URL: `https://api.workos.com` +- Default verification URL: `https://workos.com/device` (returned by API, do not hardcode) +- Token storage locations (cross-platform): + - Linux: `${XDG_STATE_HOME:-~/.local/state}/sce/auth/tokens.json` + - macOS: `~/Library/Application Support/sce/auth/tokens.json` + - Windows: `%APPDATA%\sce\auth\tokens.json` +- Access token lifetime: returned by WorkOS API in `expires_in` field (typically 3600 seconds) +- Polling interval: returned by WorkOS API in `interval` field (typically 5 seconds) +- Device code expiry: returned by WorkOS API in `expires_in` field for device code (typically 600-1800 seconds) + +## Task Stack + +- [x] T01: Add HTTP client dependency and auth service skeleton (status:done) + - Task ID: T01 + - Goal: Add `reqwest`, `serde`, and `dirs` dependencies, create `auth` service module with type definitions + - Boundaries (in/out of scope): + - IN: Add dependencies to `Cargo.toml`, create `cli/src/services/auth.rs` with types for Device Code and Token responses + - IN: Add `dirs` crate for cross-platform state directory resolution + - IN: Add `serde` with `derive` feature for serialization + - IN: Define error types specific to auth failures + - IN: Remove `cli/src/dependency_contract.rs` (eliminates maintenance burden of tracking dependencies in two places) + - IN: Update any code that references the removed dependency_contract module + - OUT: Actual HTTP calls, token storage, command integration + - Done when: + - `reqwest` with `json` feature added to `Cargo.toml` + - `serde` with `derive` feature configured + - `dirs` crate added for cross-platform paths + - `cli/src/services/auth.rs` exists with type definitions matching WorkOS API + - `cli/src/dependency_contract.rs` deleted + - Any imports/references to `dependency_contract` module removed + - Module compiles without errors + - Verification notes: + - Run `cargo check --manifest-path cli/Cargo.toml` + - Verify `reqwest`, `dirs`, and `serde` appear in `Cargo.toml` dependencies + - Verify `dependency_contract.rs` no longer exists + - Verify auth types serialize/deserialize correctly in unit tests + - Run `cargo test --manifest-path cli/Cargo.toml` to ensure no broken references + +- [x] T02: Implement cross-platform token storage service (status:done) + - Task ID: T02 + - Goal: Create secure file-based token storage with proper permissions across Linux, macOS, and Windows + - Boundaries (in/out of scope): + - IN: Create `cli/src/services/token_storage.rs` module + - IN: Implement token save/load with platform-appropriate security: + - Linux/macOS: 600 file permissions (owner read/write only) + - Windows: Remove inherited permissions, grant only to current user + - IN: Use `dirs` crate to resolve platform-appropriate state directory: + - Linux: `dirs::state_dir()` or fallback to `~/.local/state` + - macOS: `dirs::data_dir()` (resolves to `~/Library/Application Support`) + - Windows: `dirs::data_dir()` (resolves to `%APPDATA%`) + - IN: Handle missing/invalid/corrupted token files gracefully + - IN: Ensure parent directory creation with appropriate permissions + - IN: Store complete token response including `access_token`, `refresh_token`, `expires_in`, and timestamp + - OUT: Token encryption, keychain/credential manager integration, cross-machine sync + - Done when: + - `token_storage.rs` exists with `save_tokens()` and `load_tokens()` functions + - Tokens stored as JSON with platform-appropriate restricted permissions + - Works correctly on Linux, macOS, and Windows + - Unit tests cover save/load/error scenarios + - Module compiles and integrates with auth service + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib token_storage` + - Linux/macOS: Manually inspect created token file permissions (should be 0600) + - Windows: Verify file ACL restricts access to current user only + - Test with missing/invalid token files on all platforms + - Verify correct state directory resolution on each platform + +- [x] T03: Implement Device Authorization Flow (status:done) + - Task ID: T03 + - Goal: Implement complete OAuth 2.0 Device Authorization Flow with polling + - Boundaries (in/out of scope): + - IN: POST to `/oauth/device/authorize` endpoint with `client_id` parameter to get device code + - IN: Parse response containing: `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in`, `interval` + - IN: Display user code and verification URL to user (prefer `verification_uri_complete` if available) + - IN: Poll `/oauth/device/token` endpoint with required parameters: + - `grant_type`: `urn:ietf:params:oauth:grant-type:device_code` + - `device_code`: from authorize response + - `client_id`: from config + - IN: Respect polling `interval` from API response (do not use exponential backoff, use fixed interval) + - IN: Handle all OAuth error codes with specific guidance: + - `authorization_pending`: Continue polling at interval + - `slow_down`: Increase polling interval by 5 seconds and continue + - `access_denied`: User declined, stop polling and show actionable error + - `expired_token`: Device code expired, stop and suggest restart + - `invalid_request`: Missing/invalid parameters, show error + - `invalid_client`: Client ID invalid, show configuration error + - `invalid_grant`: Device code invalid/already used, restart flow + - `unsupported_grant_type`: Should not happen, show error + - IN: Store tokens on successful authentication (access_token, refresh_token, expires_in, timestamp) + - OUT: Browser auto-open, QR code display, WebSocket-based callbacks + - Done when: + - `auth.rs` has `start_device_auth_flow()` function + - Device code request returns proper user_code and verification URLs + - Token polling works with WorkOS-specified interval + - All error cases handled with actionable messages including "Try:" guidance + - Integration test can complete flow (requires manual WorkOS app setup) + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth` + - Manual test with real WorkOS credentials (document in test plan) + - Verify error messages include "Try:" guidance + - Verify polling respects API interval and slow_down error + +- [x] T04: Implement token refresh logic (status:done) + - Task ID: T04 + - Goal: Automatically refresh expired access tokens using refresh tokens + - Boundaries (in/out of scope): + - IN: Check token expiry before use (compare current time with stored timestamp + expires_in) + - IN: POST to `/oauth/token` endpoint with refresh token grant: + - `grant_type`: `refresh_token` + - `refresh_token`: stored refresh token + - `client_id`: from config + - `client_secret`: NOT required for public CLI clients (device flow) + - IN: Parse response containing new `access_token`, `refresh_token`, `expires_in` + - IN: Update stored tokens after successful refresh (including new refresh token) + - IN: Handle refresh token expiration (require re-login) + - IN: Handle network failures with retry logic (reuse resilience wrapper) + - OUT: Proactive background refresh, token rotation callbacks + - Done when: + - `auth.rs` has `ensure_valid_token()` function + - Expired access tokens are automatically refreshed + - New refresh tokens are stored after refresh + - Refresh failures require re-authentication + - Unit tests cover expiry checking and refresh scenarios + - Verification notes: + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth::refresh` + - Test with manually expired tokens + - Verify new tokens (both access and refresh) are persisted + - Verify refresh without client_secret works + +- [ ] T05: Add `login` command to CLI (status:todo) + - Task ID: T05 + - Goal: Add `sce login` command that initiates authentication flow + - Boundaries (in/out of scope): + - IN: Add `login` to command surface in `cli/src/command_surface.rs` + - IN: Add `cli/src/services/login.rs` with command parsing and dispatch + - IN: Wire login command to auth service device flow + - IN: Display user-friendly instructions: + - Show verification URL (prefer `verification_uri_complete`) + - Display user code prominently + - Show "Waiting for authorization..." with progress indicator + - IN: Show success message with user info from access token/ID token (if available) + - IN: Handle all error cases with actionable guidance + - OUT: Non-interactive login, session selection, organization switching + - Done when: + - `sce login` command registered in command surface + - Command displays device code and verification URL clearly + - User can complete authentication in browser + - Success message shows authenticated status + - Handles errors with actionable guidance + - Help text updated + - Verification notes: + - Run `sce login --help` shows usage + - Run `sce login` and complete flow manually + - Verify token file created after successful login + - Test error scenarios (network failure, user denial, timeout) + +- [ ] T06: Add `logout` command to CLI (status:todo) + - Task ID: T06 + - Goal: Add `sce logout` command that clears stored credentials + - Boundaries (in/out of scope): + - IN: Add `logout` to command surface + - IN: Create `cli/src/services/logout.rs` module + - IN: Delete token file from storage + - IN: Show success message + - IN: Handle already-logged-out state gracefully + - OUT: Token revocation with WorkOS API, multi-session management + - Done when: + - `sce logout` command registered and working + - Token file deleted on logout + - Success message displayed + - Handles already-logged-out state gracefully + - Help text updated + - Verification notes: + - Run `sce logout` after login, verify token file removed + - Run `sce logout` when already logged out, verify clean exit + - Run `sce logout --help` + +- [ ] T07: Add `auth status` command to CLI (status:todo) + - Task ID: T07 + - Goal: Add `sce auth status` command that shows authentication state + - Boundaries (in/out of scope): + - IN: Add `auth` subcommand with `status` sub-subcommand (or `sce auth-status` as top-level) + - IN: Check if tokens exist and are valid + - IN: Calculate and display time until expiry + - IN: Show token status: valid, expired, or not authenticated + - IN: Support `--format json` output + - OUT: User info from ID token (not available in basic device flow), session switching + - Done when: + - `sce auth status` command works (or equivalent) + - Shows authenticated/unauthenticated state + - Displays time until token expiry in human-readable format + - JSON output includes all fields + - Help text updated + - Verification notes: + - Run `sce auth status` when unauthenticated + - Run `sce auth status` when authenticated + - Run `sce auth status --format json` and verify JSON schema + - Run `sce auth status --help` + +- [ ] T08: Add authentication guard to `sync` command (status:todo) + - Task ID: T08 + - Goal: Require valid authentication before allowing `sync` command execution + - Boundaries (in/out of scope): + - IN: Check authentication status in `sync` command before execution + - IN: Attempt token refresh if expired + - IN: Fail with actionable error if unauthenticated + - IN: Include "Run `sce login` first" guidance + - OUT: Fine-grained permission checks, role-based access + - Done when: + - `sce sync` fails gracefully when not logged in + - Error message includes "Run `sce login`" guidance + - `sce sync` works after successful login + - Expired tokens are auto-refreshed + - Sync placeholder still returns placeholder message when authenticated + - Verification notes: + - Run `sce sync` without login, verify error + - Run `sce login`, then `sce sync`, verify success + - Wait for token expiry, run `sce sync`, verify auto-refresh + +- [ ] T09: Add WorkOS configuration support (status:todo) + - Task ID: T09 + - Goal: Support WorkOS client ID configuration via environment and config file + - Boundaries (in/out of scope): + - IN: Add `workos_client_id` to config schema + - IN: Support `WORKOS_CLIENT_ID` environment variable + - IN: Add to config precedence: flags > env > config file > defaults + - IN: Update `sce config show` to display WorkOS settings (redacted) + - IN: Validate WorkOS config is present when auth commands run + - OUT: WorkOS domain configuration (hardcode api.workos.com), interactive WorkOS setup wizard, multi-environment config + - Done when: + - Config service supports `workos_client_id` + - Environment variable overrides config file value + - `sce config show` displays WorkOS settings (redacted) + - Auth commands fail with actionable error if config missing + - Config validation checks WorkOS settings + - Verification notes: + - Run `sce config show` with WorkOS env var set + - Run `sce login` without WorkOS config, verify error + - Test config precedence (env overrides file) + +- [ ] T10: Update CLI documentation and help text (status:todo) + - Task ID: T10 + - Goal: Document WorkOS authentication in `cli/README.md` and update all help text + - Boundaries (in/out of scope): + - IN: Add "Authentication" section to `cli/README.md` + - IN: Document `login`, `logout`, `auth status` commands + - IN: Document required WorkOS configuration + - IN: Update main help text to mention auth commands + - IN: Add authentication troubleshooting section + - OUT: WorkOS setup guide (assumes WorkOS app already configured) + - Done when: + - `cli/README.md` has complete auth documentation + - All auth commands documented with examples + - Configuration instructions clear + - Main help text lists auth commands + - Common issues and solutions documented + - Verification notes: + - Read `cli/README.md` for completeness + - Run `sce --help` and verify auth commands mentioned + - Run `sce login --help` and verify useful guidance + +- [ ] T11: Validation, testing, and context sync (status:todo) + - Task ID: T11 + - Goal: Final validation, comprehensive testing, and context synchronization + - Boundaries (in/out of scope): + - IN: Run full `nix flake check` to verify no regressions + - IN: Run `nix run .#pkl-check-generated` for generated output parity + - IN: Run all cargo tests: `cargo test --manifest-path cli/Cargo.toml` + - IN: Manual end-to-end test of complete auth flow + - IN: Update `context/cli/placeholder-foundation.md` with auth status + - IN: Update `context/overview.md` with auth feature summary + - IN: Update `context/glossary.md` with auth-related terms + - OUT: Performance testing, load testing, security audit + - Done when: + - All automated checks pass (`nix flake check`, `pkl-check-generated`, cargo tests) + - Manual auth flow test completed successfully + - Context files updated to reflect current auth state + - No regressions in existing commands + - Documentation is accurate and complete + - Verification notes: + - Run `nix flake check` and verify success + - Run `nix run .#pkl-check-generated` + - Run `cargo test --manifest-path cli/Cargo.toml --all` + - Complete manual auth flow: `sce login` → `sce auth status` → `sce sync` → `sce logout` + - Verify context files updated correctly + +## Open Questions +None - all requirements clarified with user. + +## Dependencies +- **External**: WorkOS API (`https://api.workos.com`) +- **Runtime**: Requires WorkOS client ID configuration +- **Build**: `reqwest` crate with `json` feature, `serde` derive macros, `dirs` crate for cross-platform paths + +## Risk Mitigation +- **Risk**: Token storage security + - **Mitigation**: Use restrictive file permissions (0600 on Unix, user-only ACL on Windows), document security assumptions, recommend OS keychain for production +- **Risk**: Cross-platform path resolution differences + - **Mitigation**: Use well-tested `dirs` crate, add platform-specific tests, document expected paths per platform +- **Risk**: Network failures during auth flow + - **Mitigation**: Use existing resilience wrapper from `cli/src/services/resilience.rs` for retries +- **Risk**: Token expiry during long-running operations + - **Mitigation**: Implement `ensure_valid_token()` check before each authenticated operation +- **Risk**: WorkOS API changes + - **Mitigation**: Use stable OAuth 2.0 standard endpoints (`/oauth/device/authorize`, `/oauth/device/token`, `/oauth/token`), version pin API in docs +- **Risk**: Polling rate violations + - **Mitigation**: Strictly respect `interval` from API response and add 5 seconds when receiving `slow_down` error + +## Implementation Notes +- Follow existing CLI patterns: lexopt for parsing, anyhow for errors, services/ module structure +- Reuse `cli/src/services/resilience.rs` for HTTP retry logic +- Follow `cli/src/services/output_format.rs` for dual text/JSON output +- Maintain exit code contract from `cli/src/app.rs` +- Keep auth service focused on WorkOS only (no abstraction for other providers) +- Use `dirs` crate for cross-platform state directory resolution (Linux, macOS, Windows) +- Platform-specific file security: Unix permissions (0600) vs Windows ACLs +- **Critical**: Do not hardcode verification URLs - use URLs from API response +- **Critical**: Use exact grant type `urn:ietf:params:oauth:grant-type:device_code` for device flow polling +- **Critical**: Respect API-provided polling interval, not exponential backoff +- **Critical**: Handle all OAuth error codes per RFC 8628 specification +- **Critical**: Store refresh token rotation - new refresh token returned on each refresh +- **Note**: Client secret NOT required for public CLI clients using device flow diff --git a/context/plans/workos-client-id-precedence.md b/context/plans/workos-client-id-precedence.md new file mode 100644 index 0000000..39457bb --- /dev/null +++ b/context/plans/workos-client-id-precedence.md @@ -0,0 +1,159 @@ +# Plan: Auth Config Source Precedence + +## Change Summary + +Generalize the current WorkOS client ID precedence plan into a reusable auth-configuration precedence plan that works for any auth runtime value sourced from environment variables, config files, or baked defaults / hardcoded constants. Current code truth is mixed: core runtime config already applies deterministic `flags > env > config file > defaults`, while `cli/src/services/auth_command.rs` still performs direct env-only lookup for WorkOS client ID. This plan aligns auth-related runtime values behind one shared precedence resolver pattern so individual auth settings do not each reimplement bespoke env/config/hardcoded logic. + +## Success Criteria + +- [ ] Auth runtime values that can come from env, config, or baked defaults resolve through one deterministic precedence contract instead of per-call-site custom lookup +- [ ] `WORKOS_CLIENT_ID` keeps explicit override precedence over config-file and baked-default values +- [ ] The implementation approach is reusable for additional auth-adjacent keys without rewriting merge/source-tracking behavior per key +- [ ] Config-file resolution for supported auth keys uses existing discovery and merge rules: global config first, local config second, with local overriding global per key +- [ ] `sce config show` and `sce config validate` expose resolved source metadata for supported auth keys without dumping full values in normal text output when they look sensitive or credential-like +- [ ] Missing or invalid auth configuration diagnostics describe the full precedence chain and only fail when all allowed layers are absent or invalid for the specific key +- [ ] Unit tests cover reusable precedence behavior for env, global config, local config, baked default, and fully absent/invalid paths + +## Constraints and Non-Goals + +**In Scope:** +- Define a reusable precedence pattern for auth/runtime settings that may be supplied by env vars, config files, or baked defaults +- Extend runtime config schema and source tracking for supported auth-related keys starting with `workos_client_id` +- Reuse existing global+local config discovery and merge behavior +- Centralize auth-value resolution so auth command code consumes shared resolution instead of direct ad hoc lookup +- Surface resolved source and precedence-aware diagnostics in config/auth inspection paths +- Update focused context files to describe the generalized contract rather than a one-off client-ID rule + +**Out of Scope:** +- Changing non-auth config precedence behavior outside the affected shared resolver surface +- Introducing CLI flags for new auth override values in this plan unless a key already has one +- Changing WorkOS API base URL handling unless it is explicitly folded into the generalized auth precedence surface in a later follow-up +- Adding interactive setup for auth configuration +- Rotating or remotely fetching baked defaults +- Broad auth UX redesign beyond value resolution, inspection, and diagnostics + +**Non-Goals:** +- Multi-tenant WorkOS app selection +- Secret storage or encryption changes +- Sync-command auth guard changes +- Converting every existing config key in the CLI to a new abstraction if the key is unrelated to auth/runtime configuration +- Publishing production secrets; only intentionally public baked identifiers remain eligible for hardcoded defaults + +## Assumptions + +- `workos_client_id` is the first concrete auth key to migrate, but the resulting resolver shape should support additional auth-related keys without changing the precedence contract +- Existing config discovery remains canonical: explicit config path/env path override is separate from default discovery, and default discovered config layers merge as `global -> local` +- Not every auth key will necessarily allow a baked default; the shared resolver must support keys whose allowed sources are a subset of `env / config / baked default` +- Baked values used by this contract are approved for shipping in the CLI binary and are not secrets +- Auth commands should consume one shared auth-config resolver surface rather than duplicating precedence logic across service modules + +## Task Stack + +- [x] T01: Add reusable auth config key support to runtime config resolution (status:done) + - Task ID: T01 + - Goal: Extend `cli/src/services/config.rs` so auth-related config keys can be declared once with deterministic env/config resolution and source tracking, starting with `workos_client_id`. + - Boundaries (in/out of scope): + - IN: Add `workos_client_id` to allowed config keys and parsed file schema + - IN: Introduce or refactor key metadata so auth-related keys can declare env name, config key, and source reporting in one place + - IN: Preserve existing discovered config merge order `global -> local` + - IN: Keep deterministic output/source metadata compatible with `sce config show` / `validate` + - OUT: Auth command wiring, baked default fallback behavior, context updates + - Done when: + - Runtime config can resolve supported auth keys from env or discovered/explicit config inputs through shared key metadata instead of one-off parsing branches + - Local config overrides global config for `workos_client_id` + - Unknown-key validation and deterministic output include the new auth key correctly + - Unit tests cover env, global-only, local-over-global, and default-absent cases for the reusable auth-key path + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --lib config` + - Verify `sce config show --format json` reports supported auth-key source metadata deterministically + +- [x] T02: Introduce shared auth value precedence resolver with optional baked defaults (status:done) + - Task ID: T02 + - Goal: Add one canonical auth-config resolver that applies an allowed-source chain per key (env > config file > baked default where permitted) and reuse it for `workos_client_id`. + - Boundaries (in/out of scope): + - IN: Add resolver abstractions/types that support keys with or without baked defaults + - IN: Add one canonical baked default constant for `workos_client_id` + - IN: Ensure the resolver returns resolved value plus source metadata for downstream diagnostics/output + - IN: Keep precedence deterministic and key-declarative instead of hardcoding each lookup path in callers + - OUT: Auth command integration, broader CLI flag additions, non-auth config rewrites + - Done when: + - A shared resolver can answer supported auth-key lookups without duplicating env/config/hardcoded precedence logic per call site + - `workos_client_id` resolves with precedence `env > config file > baked default` + - The resolver can represent keys that intentionally omit baked defaults while still participating in shared diagnostics/source reporting + - Focused tests cover allowed-source combinations and invalid/absent outcomes coherently + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --lib config` + - Add focused tests for resolution order and per-key allowed-source combinations + +- [x] T03: Wire auth command flows to shared auth config resolution (status:done) + - Task ID: T03 + - Goal: Replace direct env-only auth lookup in `cli/src/services/auth_command.rs` with the shared auth-config resolver so runtime auth flows use the generalized precedence contract. + - Boundaries (in/out of scope): + - IN: Replace direct `WORKOS_CLIENT_ID` lookup in auth command execution + - IN: Reuse config service resolution rather than reimplementing global/local lookup in auth code + - IN: Keep login/refresh/status runtime behavior unchanged apart from how supported auth values are obtained + - OUT: New auth subcommands, token-storage changes, WorkOS base URL redesign + - Done when: + - `sce auth login` resolves `workos_client_id` via the shared resolver instead of env-only logic + - Env values still win over config-file and baked-default values + - Config-file values win over baked defaults when env is absent + - Existing auth runtime paths still fail clearly when a required auth value is invalid or disallowed across all permitted layers + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth_command` + - Add focused tests for auth-command wiring across env, local config, global config, baked default, and invalid/absent outcomes + +- [x] T04: Expose generalized precedence-aware config output and diagnostics (status:done) + - Task ID: T04 + - Goal: Make config inspection and auth failure guidance describe the shared env/config/baked precedence contract for supported auth keys. + - Boundaries (in/out of scope): + - IN: Update `sce config show` / `sce config validate` output contracts for supported auth keys + - IN: Redact or abbreviate text-mode display when values appear sensitive or credential-like + - IN: Update auth error text so guidance reflects key-specific allowed precedence layers rather than env-only assumptions + - OUT: Broader secret-redaction redesign or full config-command UX overhaul + - Done when: + - Config output documents resolved auth-key values and source consistently in text and JSON modes + - Auth diagnostics no longer imply env-only configuration and can describe when baked defaults are or are not part of the chain for a given key + - Contract-focused tests cover precedence-aware messaging and output shape + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth` + - Run `cargo test --manifest-path cli/Cargo.toml --lib config` + - Verify `sce config show` text output distinguishes env/config/default sourcing for supported auth keys + +- [x] T05: Sync focused context contracts for generalized auth config precedence (status:done) + - Task ID: T05 + - Goal: Update current-state context files so future sessions reflect the reusable auth precedence contract instead of a one-off client-ID rule. + - Boundaries (in/out of scope): + - IN: Update `context/cli/config-precedence-contract.md` + - IN: Update `context/cli/placeholder-foundation.md` + - IN: Update `context/overview.md` and `context/glossary.md` only if the generalized auth precedence contract is important at root-context level + - OUT: Historical narrative or completed-work summaries + - Done when: + - Context files describe shared auth-key precedence behavior, including `WORKOS_CLIENT_ID` as the first concrete migrated key + - Root context edits are limited to true cross-cutting contract changes + - No stale env-only wording remains in focused auth/config context for migrated keys + - Verification notes (commands or checks): + - Verify context statements match implemented precedence exactly + - Confirm focused context distinguishes generic resolver contract from key-specific allowances such as baked defaults + +- [ ] T06: Validation and cleanup (status:todo) + - Task ID: T06 + - Goal: Validate code, tests, and context alignment for the generalized auth config precedence behavior. + - Boundaries (in/out of scope): + - IN: Run focused cargo tests for config/auth/auth_command + - IN: Run repo-required lightweight validation baseline + - IN: Verify context sync accuracy after implementation + - OUT: Manual live WorkOS login against production unless approved credentials/environment are available + - Done when: + - Automated checks for touched areas pass + - Shared precedence behavior is covered by tests for env, local config, global config, baked default, and invalid/absent paths where applicable + - Context reflects current code truth with no known drift for this feature + - Verification notes (commands or checks): + - Run `cargo test --manifest-path cli/Cargo.toml --lib config` + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth` + - Run `cargo test --manifest-path cli/Cargo.toml --lib auth_command` + - Run `nix run .#pkl-check-generated` + - Run `nix flake check` + +## Open Questions + +None. diff --git a/context/sce/cli-exit-code-contract.md b/context/sce/cli-exit-code-contract.md index 55ae16a..32662e4 100644 --- a/context/sce/cli-exit-code-contract.md +++ b/context/sce/cli-exit-code-contract.md @@ -11,7 +11,7 @@ The contract is intentionally class-based so automation can branch on failure ca - `2` (`parse_failure`): top-level CLI parsing failed (for example unknown top-level command/option or malformed command token). - `3` (`validation_failure`): command/subcommand arguments parsed but failed invocation validation (for example incompatible or missing command-local arguments). - `4` (`runtime_failure`): command invocation was valid but runtime execution failed (filesystem/process/environment/runtime operation errors). -- `5` (`dependency_failure`): startup dependency contract check failed before command parsing/dispatch. +- `5` (`dependency_failure`): startup dependency checks failed before command parsing/dispatch. ## Classification ownership diff --git a/context/sce/cli-shell-completion-contract.md b/context/sce/cli-shell-completion-contract.md index f3bef05..05d452b 100644 --- a/context/sce/cli-shell-completion-contract.md +++ b/context/sce/cli-shell-completion-contract.md @@ -28,7 +28,7 @@ Defines the implemented `sce completion` contract for deterministic shell comple - Output is a shell script payload emitted on stdout for redirection/eval. - Scripts are deterministic for identical binary + input shell. - Generated scripts encode current parser-valid command/flag/subcommand surfaces for: - - top-level commands: `help`, `config`, `setup`, `doctor`, `mcp`, `hooks`, `sync`, `version`, `completion` +- top-level commands: `help`, `config`, `setup`, `doctor`, `auth`, `mcp`, `hooks`, `sync`, `version`, `completion` - completion-specific flags and values: `--shell` with `bash|zsh|fish` ## Implementation ownership diff --git a/flake.nix b/flake.nix index de5e7e1..db285f9 100644 --- a/flake.nix +++ b/flake.nix @@ -142,6 +142,41 @@ ''; }; + cliConfigPrecedenceIntegrationTestsApp = pkgs.writeShellApplication { + name = "cli-config-precedence-integration-tests"; + runtimeInputs = [ + pkgs.git + pkgs.nix + ]; + text = '' + set -euo pipefail + + usage() { + cat <<'EOF' + Usage: nix run .#cli-config-precedence-integration-tests [-- --help] + + Deterministic flake entrypoint for opt-in config-precedence integration tests. + Runs the compiled-binary config precedence suite from cli/tests/config_precedence_integration.rs. + This test slice is intentionally excluded from default nix flake check. + EOF + } + + case "''${1:-}" in + -h|--help) + usage + exit 0 + ;; + esac + + repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -z "''${repo_root}" ]; then + repo_root="$(pwd)" + fi + + exec nix develop "''${repo_root}" -c cargo test --manifest-path cli/Cargo.toml --test config_precedence_integration -- --nocapture + ''; + }; + agnixLspShim = pkgs.writeShellScriptBin "agnix-lsp" '' set -euo pipefail @@ -205,6 +240,14 @@ }; }; + apps.cli-config-precedence-integration-tests = { + type = "app"; + program = "${cliConfigPrecedenceIntegrationTestsApp}/bin/cli-config-precedence-integration-tests"; + meta = { + description = "Run opt-in config-precedence integration tests via Rust harness"; + }; + }; + devShells.default = pkgs.mkShell { packages = with pkgs; @@ -240,6 +283,7 @@ echo "- sync-opencode-config help: nix run .#sync-opencode-config -- --help" echo "- pkl-check-generated: nix run .#pkl-check-generated" echo "- cli-integration-tests: nix run .#cli-integration-tests" + echo "- cli-config-precedence-integration-tests: nix run .#cli-config-precedence-integration-tests" ''; }; }