1use std::path::{Path, PathBuf};
35use std::process::Command;
36
37use anyhow::{bail, Context, Result};
38
39use crate::scripts_lane_d::CheckMode;
40
41const TS_CLIENT_DIR: &str = "crates/shared/codex-app-server-client/clients/typescript";
42
43const USAGE: &str = "Usage: cargo xtask check-ts-client [--write] [--check]
44
45 --write Regenerate src/generated/openapi-types.ts from ../../openapi.json
46 (via `pnpm run generate`) and overwrite the checked-in file.
47 --check (default) Verify src/generated/openapi-types.ts is byte-identical
48 to what `pnpm run generate` would produce, then run `pnpm run
49 typecheck` (`tsc --noEmit`) and `pnpm test` over the whole package.
50
51Requires `node` and `pnpm` on PATH. If either is missing, this prints a skip
52message and exits 0 rather than failing - see xtask/src/ts_client.rs's module
53docs for why.";
54
55pub fn run(args: &[String]) -> Result<()> {
56 let Some(mode) = CheckMode::parse(args, USAGE)? else {
62 return Ok(());
63 };
64 let root = current_dir()?;
65 let package_dir = root.join(TS_CLIENT_DIR);
66 if !package_dir.join("package.json").is_file() {
67 bail!(
68 "{} is missing package.json - expected the checked-in TypeScript REST client \
69 package (see crates/shared/codex-app-server-client/clients/typescript/README.md)",
70 package_dir.display()
71 );
72 }
73
74 if let Some(reason) = missing_toolchain_reason() {
75 println!(
76 "check-ts-client: skipped: {reason}. Cannot verify \
77 {TS_CLIENT_DIR}/src/generated/openapi-types.ts is in sync with openapi.json. \
78 Install Node.js and pnpm (e.g. via mise) to run this check locally or in CI."
79 );
80 return Ok(());
81 }
82
83 run_pnpm(&package_dir, &["install", "--frozen-lockfile"]).context(
84 "`pnpm install --frozen-lockfile` failed for the TypeScript REST client package",
85 )?;
86
87 if mode.should_write() {
91 run_pnpm(&package_dir, &["run", "generate"]).context("`pnpm run generate` failed")?;
92 println!("check-ts-client: wrote {TS_CLIENT_DIR}/src/generated/openapi-types.ts");
93 }
94 if mode.should_check() {
95 run_pnpm(&package_dir, &["run", "check-sync"]).context(
96 "TypeScript REST client types are out of sync with openapi.json - regenerate \
97 with `cargo xtask check-ts-client --write` and commit the diff",
98 )?;
99 run_pnpm(&package_dir, &["run", "typecheck"])
100 .context("TypeScript REST client failed to typecheck (`pnpm run typecheck`)")?;
101 run_pnpm(&package_dir, &["test"])
109 .context("TypeScript REST client test suite failed (`pnpm test`)")?;
110 println!(
111 "check-ts-client: {TS_CLIENT_DIR} is in sync with openapi.json, type-checks, and \
112 passes its tests"
113 );
114 }
115 Ok(())
116}
117
118fn missing_toolchain_reason() -> Option<String> {
121 for bin in ["node", "pnpm"] {
122 if !crate::command_exists(bin) {
123 return Some(format!("`{bin}` not found on PATH"));
124 }
125 }
126 None
127}
128
129fn run_pnpm(package_dir: &Path, args: &[&str]) -> Result<()> {
130 let status = Command::new("pnpm")
131 .args(args)
132 .current_dir(package_dir)
133 .status()
134 .with_context(|| format!("failed to run `pnpm {}`", args.join(" ")))?;
135 if !status.success() {
136 bail!("`pnpm {}` exited with status {status}", args.join(" "));
137 }
138 Ok(())
139}
140
141fn current_dir() -> Result<PathBuf> {
142 std::env::current_dir().context("failed to read current directory")
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 fn parse(args: &[&str]) -> Result<Option<CheckMode>> {
156 let owned: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
157 CheckMode::parse(&owned, USAGE)
158 }
159
160 #[test]
161 fn defaults_to_check() {
162 let mode = parse(&[]).unwrap().expect("no --help was passed");
163 assert!(mode.should_check());
164 assert!(!mode.should_write());
165 }
166
167 #[test]
168 fn accepts_write_flag() {
169 let mode = parse(&["--write"]).unwrap().expect("no --help was passed");
170 assert!(mode.should_write());
171 assert!(!mode.should_check());
172 }
173
174 #[test]
175 fn write_and_check_together_regenerate_then_verify() {
176 let mode = parse(&["--write", "--check"])
177 .unwrap()
178 .expect("no --help was passed");
179 assert!(mode.should_write());
180 assert!(mode.should_check());
181 }
182
183 #[test]
184 fn help_short_circuits_without_running_the_command() {
185 assert_eq!(parse(&["--help"]).unwrap(), None);
186 assert_eq!(parse(&["-h"]).unwrap(), None);
187 }
188
189 #[test]
190 fn rejects_unknown_flag() {
191 let error = parse(&["--bogus"]).unwrap_err();
192 assert!(error.to_string().contains("unknown option"));
193 }
194
195 #[test]
196 fn ts_client_dir_points_at_the_checked_in_package() {
197 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
198 .parent()
199 .expect("xtask/Cargo.toml must have a parent directory");
200 assert!(
201 root.join(TS_CLIENT_DIR).join("package.json").is_file(),
202 "{TS_CLIENT_DIR}/package.json must exist"
203 );
204 assert!(
205 root.join(TS_CLIENT_DIR)
206 .join("src/generated/openapi-types.ts")
207 .is_file(),
208 "{TS_CLIENT_DIR}/src/generated/openapi-types.ts must exist (checked in)"
209 );
210 }
211}