Skip to main content

xtask/
ts_client.rs

1//! `cargo xtask check-ts-client [--write|--check]` - keeps
2//! `crates/shared/codex-app-server-client/clients/typescript/src/generated/openapi-types.ts`
3//! (a checked-in, `openapi-typescript`-generated TypeScript client for that
4//! crate's `rest` feature - see that directory's own README.md) in sync with
5//! the crate's checked-in `openapi.json`, and (in `--check` mode) verifies
6//! the package still type-checks.
7//!
8//! This is the TypeScript-side analogue of [`super::scripts_lane_d::check_openapi`]
9//! (which keeps `docs/generated/openapi.json` in sync with the main `soma`
10//! binary's own OpenAPI surface) - same `--write` regenerates /
11//! `--check` verifies shape, different source of truth and a different
12//! generator (`openapi-typescript`'s JS API, not a Rust builder function).
13//!
14//! # Why this shells out to `pnpm`, not a Rust HTTP-schema crate
15//!
16//! The TypeScript client's whole point (see bead `rmcp-template-g0qf.5`) is
17//! proving `openapi.json` is consumable by a real, independent TypeScript
18//! toolchain - reimplementing `openapi-typescript`'s logic in Rust here would
19//! defeat that. `xtask` itself has no Node/pnpm dependency; this module only
20//! *drives* the package's own `package.json` scripts
21//! (`clients/typescript/scripts/generate.mjs` and `.../check-sync` /
22//! `.../typecheck`), the same way a human contributor would.
23//!
24//! # Why a graceful skip, not a hard failure, when `node`/`pnpm` are missing
25//!
26//! Mirrors `codex_schema::drift`'s posture on a missing `codex` CLI:
27//! this repo's self-hosted CI runners are not guaranteed to have a
28//! Node/pnpm toolchain provisioned (see docs/CI.md), and a drift check that
29//! hard-fails whenever the runner's toolchain lags reads as CI flakiness, not
30//! a real problem with this PR's diff. A skip is loud (printed, not
31//! swallowed) and still exits `0`, so the check is "yes if we can tell,
32//! silent-not-lying if we can't" rather than a false failure.
33
34use 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    // Shares `CheckMode` with `check-openapi`/`check-schema-docs` rather than
57    // hand-rolling a third parser for the same two flags: they are the same
58    // grammar for the same job (regenerate-or-verify a checked-in generated
59    // artifact), and a divergence between them would be an accident rather
60    // than a decision.
61    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    // Ordered write-then-check so `--write --check` (`CheckMode::CheckAndWrite`)
88    // regenerates and then verifies the result, matching what the same flag
89    // pair does for `check-openapi`.
90    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        // The client hand-rolls SSE wire-format parsing and path-segment
102        // encoding, neither of which `tsc` can say anything about. Both had
103        // real bugs that only a test could catch - a stream that leaked its
104        // connection on early exit, and `..` segments that silently retargeted
105        // a request to a different route. The suite is zero-dependency
106        // (`node:test`) and runs in well under a second, so there's no reason
107        // for the gate to stop at typecheck.
108        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
118/// `None` when both `node` and `pnpm` are usable; otherwise a human-readable
119/// reason naming the first missing tool.
120fn 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    // These pin the flag grammar *as this command consumes it*. The parser
150    // itself is `CheckMode`, shared with `check-openapi`/`check-schema-docs`
151    // and unit-tested there; what's worth pinning here is that this command
152    // agrees with the others - most importantly that `--write --check` means
153    // "regenerate, then verify" rather than being rejected, which is what it
154    // used to do back when this file hand-rolled its own parser.
155    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}