1use std::env;
2use std::ffi::{OsStr, OsString};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6
7use serde_json::Value;
8
9const DEFAULT_CLIENT_DIR: &str = ".nextrs/client";
10
11pub fn main_with_args(command_name: &str, args: impl IntoIterator<Item = OsString>) -> ExitCode {
16 match run_with_args(args) {
17 Ok(()) => ExitCode::SUCCESS,
18 Err(error) => {
19 eprintln!("{command_name}: {error}");
20 ExitCode::FAILURE
21 }
22 }
23}
24
25pub fn run_with_args(args: impl IntoIterator<Item = OsString>) -> Result<(), String> {
27 let command = CommandLine::parse(args)?;
28 match command {
29 CommandLine::Help => {
30 print_help();
31 Ok(())
32 }
33 CommandLine::New(args) => create_app(args),
34 CommandLine::Dev(args) => {
35 prepare_generated_client_for_dev()?;
36 cargo_nextrs_dev::run_with_args(args).map_err(io_error)
37 }
38 CommandLine::ClientGenerate(options) => generate_client(options),
39 }
40}
41
42#[derive(Debug, PartialEq, Eq)]
43enum CommandLine {
44 Help,
45 New(Vec<OsString>),
46 Dev(Vec<OsString>),
47 ClientGenerate(GenerateOptions),
48}
49
50#[derive(Debug, PartialEq, Eq)]
51struct GenerateOptions {
52 root: PathBuf,
53 client_dir: PathBuf,
54 config: Option<PathBuf>,
55}
56
57impl CommandLine {
58 fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, String> {
59 let mut args = args.into_iter().peekable();
60 if matches!(args.peek().map(OsString::as_os_str), Some(arg) if arg == "nextrs") {
61 args.next();
62 }
63
64 let Some(first) = args.next() else {
65 return Ok(Self::Help);
66 };
67 if matches!(first.to_str(), Some("-h" | "--help" | "help")) {
68 return Ok(Self::Help);
69 }
70 if first == "new" {
71 return Ok(Self::New(args.collect()));
72 }
73 if first == "dev" {
74 return Ok(Self::Dev(args.collect()));
75 }
76 if first != "client" {
77 return Err(format!("unknown command `{}`", first.to_string_lossy()));
78 }
79
80 let Some(action) = args.next() else {
81 return Err("missing client command; expected `generate`".into());
82 };
83 if action != "generate" {
84 return Err(format!(
85 "unknown client command `{}`; expected `generate`",
86 action.to_string_lossy()
87 ));
88 }
89
90 let mut root = PathBuf::from(".");
91 let mut client_dir = PathBuf::from(DEFAULT_CLIENT_DIR);
92 let mut config = None;
93 while let Some(arg) = args.next() {
94 match arg.to_str() {
95 Some("--root") => root = required_path(&mut args, "--root")?,
96 Some("--client-dir") => client_dir = required_path(&mut args, "--client-dir")?,
97 Some("--config") => config = Some(required_path(&mut args, "--config")?),
98 Some("-h" | "--help") => return Ok(Self::Help),
99 Some(flag) if flag.starts_with('-') => {
100 return Err(format!("unknown option `{flag}`"));
101 }
102 _ => return Err(format!("unexpected argument `{}`", arg.to_string_lossy())),
103 }
104 }
105
106 Ok(Self::ClientGenerate(GenerateOptions {
107 root,
108 client_dir,
109 config,
110 }))
111 }
112}
113
114fn create_app(args: Vec<OsString>) -> Result<(), String> {
115 let args = args
116 .into_iter()
117 .map(|arg| {
118 arg.into_string()
119 .map_err(|arg| format!("new arguments must be valid UTF-8: {arg:?}"))
120 })
121 .collect::<Result<Vec<_>, _>>()?;
122 create_nextrs_app::run_with_args_named("nextrs new", args).map_err(io_error)
123}
124
125fn required_path(args: &mut impl Iterator<Item = OsString>, flag: &str) -> Result<PathBuf, String> {
126 args.next()
127 .map(PathBuf::from)
128 .ok_or_else(|| format!("{flag} requires a path"))
129}
130
131fn generate_client(options: GenerateOptions) -> Result<(), String> {
132 let root = absolutize(&env::current_dir().map_err(io_error)?, &options.root);
133 let root_package_json = root.join("package.json");
134 if !root_package_json.is_file() {
135 return Err(format!(
136 "{} does not exist; run this from a nextrs app root or pass --root",
137 root_package_json.display()
138 ));
139 }
140
141 let custom_client_dir = options.client_dir != Path::new(DEFAULT_CLIENT_DIR);
142 let requested_client_dir = absolutize(&root, &options.client_dir);
143 if !custom_client_dir
144 && !requested_client_dir.join("package.json").is_file()
145 && root_declares_generated_client(&root_package_json)?
146 {
147 eprintln!("nextrs: materializing the ignored generated-client package");
148 execute(&root, "npm", &["run", "client:ensure"], None)?;
149 }
150 let legacy_client_dir = root.join("client");
151 let client_dir = if !custom_client_dir
152 && !requested_client_dir.join("package.json").is_file()
153 && legacy_client_dir.join("package.json").is_file()
154 {
155 eprintln!(
156 "nextrs: using legacy client directory {}; regenerate the app scaffold to move it to {}",
157 legacy_client_dir.display(),
158 requested_client_dir.display()
159 );
160 legacy_client_dir
161 } else {
162 requested_client_dir
163 };
164 let package_json = client_dir.join("package.json");
165 if !package_json.is_file() {
166 return Err(format!(
167 "{} does not exist after client materialization; restore `.nextrs/ensure-client.mjs` and `.nextrs/template/client` from a fresh `nextrs new` app, or pass --client-dir for a legacy client",
168 package_json.display(),
169 ));
170 }
171
172 let modern_package = if !custom_client_dir && client_dir == root.join(DEFAULT_CLIENT_DIR) {
173 let package = read_client_package(&package_json)?;
174 validate_root_client_contract(&root_package_json, &package.name)?;
175 warn_on_mixed_package_managers(&root);
176 ensure_root_client_install(&root, &client_dir, &package)?;
177 Some(package)
178 } else {
179 if !root.join("node_modules").is_dir() {
180 eprintln!("nextrs: installing application dependencies at the app root");
181 execute(&root, "npm", &["install"], None)?;
182 }
183 None
184 };
185
186 let default_config = client_dir.join("nextrs.client.json");
187 let config = options
188 .config
189 .map(|path| absolutize(&root, &path))
190 .or_else(|| default_config.is_file().then_some(default_config));
191
192 if let Some(config) = config {
193 if !config.is_file() {
194 return Err(format!(
195 "external client config not found: {}",
196 config.display()
197 ));
198 }
199 eprintln!(
200 "nextrs: generating internal client and publishing external client from {}",
201 config.display()
202 );
203 let config_arg = config.as_os_str();
204 execute(
205 &client_dir,
206 "npm",
207 &[
208 OsStr::new("run"),
209 OsStr::new("generate:external"),
210 OsStr::new("--"),
211 config_arg,
212 ],
213 None,
214 )?;
215 } else {
216 eprintln!("nextrs: generating client from the current Rust contract");
217 let (cwd, script) = normal_generation_target(&root, &client_dir, custom_client_dir);
218 execute(cwd, "npm", &["run", script], None)?;
219 }
220 if let Some(package) = modern_package {
221 validate_generated_client(&root, &client_dir, &package)?;
222 eprintln!(
223 "nextrs: verified {} through the root workspace link (JavaScript + declarations)",
224 package.name
225 );
226 }
227 Ok(())
228}
229
230fn prepare_generated_client_for_dev() -> Result<(), String> {
231 let root = env::current_dir().map_err(io_error)?;
232 let client_package = root.join(DEFAULT_CLIENT_DIR).join("package.json");
233 if client_package.is_file() {
234 eprintln!("nextrs: refreshing the generated client before starting dev");
235 generate_client(GenerateOptions {
236 root: PathBuf::from("."),
237 client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
238 config: None,
239 })?;
240 } else if root_declares_generated_client(&root.join("package.json"))? {
241 eprintln!("nextrs: materializing and refreshing the generated client before starting dev");
242 generate_client(GenerateOptions {
243 root: PathBuf::from("."),
244 client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
245 config: None,
246 })?;
247 }
248 Ok(())
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252struct ClientPackage {
253 name: String,
254 exports: Vec<ClientExport>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258struct ClientExport {
259 subpath: &'static str,
260 types: PathBuf,
261 import: PathBuf,
262}
263
264fn read_client_package(path: &Path) -> Result<ClientPackage, String> {
265 let json = read_json(path)?;
266 let name = json
267 .get("name")
268 .and_then(Value::as_str)
269 .filter(|name| !name.is_empty())
270 .ok_or_else(|| format!("{} must contain a non-empty package name", path.display()))?
271 .to_string();
272 package_install_path(Path::new("node_modules"), &name)?;
273
274 let exports = [".", "./react-query"]
275 .into_iter()
276 .map(|subpath| {
277 let entry = json
278 .get("exports")
279 .and_then(|exports| exports.get(subpath))
280 .ok_or_else(|| {
281 format!(
282 "{} is missing the `{subpath}` package export",
283 path.display()
284 )
285 })?;
286 Ok(ClientExport {
287 subpath,
288 types: safe_export_path(path, subpath, entry, "types")?,
289 import: safe_export_path(path, subpath, entry, "import")?,
290 })
291 })
292 .collect::<Result<Vec<_>, String>>()?;
293
294 Ok(ClientPackage { name, exports })
295}
296
297fn safe_export_path(
298 package_json: &Path,
299 subpath: &str,
300 entry: &Value,
301 condition: &str,
302) -> Result<PathBuf, String> {
303 let value = entry
304 .get(condition)
305 .and_then(Value::as_str)
306 .ok_or_else(|| {
307 format!(
308 "{} export `{subpath}` must declare a `{condition}` target",
309 package_json.display()
310 )
311 })?;
312 let relative = value.strip_prefix("./").ok_or_else(|| {
313 format!(
314 "{} export `{subpath}` has unsafe `{condition}` target `{value}`",
315 package_json.display()
316 )
317 })?;
318 let path = PathBuf::from(relative);
319 if path.components().any(|component| {
320 matches!(
321 component,
322 std::path::Component::ParentDir
323 | std::path::Component::RootDir
324 | std::path::Component::Prefix(_)
325 )
326 }) {
327 return Err(format!(
328 "{} export `{subpath}` has unsafe `{condition}` target `{value}`",
329 package_json.display()
330 ));
331 }
332 Ok(path)
333}
334
335fn validate_root_client_contract(
336 root_package_json: &Path,
337 client_name: &str,
338) -> Result<(), String> {
339 let root = read_json(root_package_json)?;
340 let has_workspace = workspace_paths(&root)
341 .iter()
342 .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR);
343 if !has_workspace {
344 return Err(format!(
345 "{} must list `{DEFAULT_CLIENT_DIR}` in `workspaces` so editors and Node resolve the generated client",
346 root_package_json.display()
347 ));
348 }
349
350 let dependency = ["dependencies", "devDependencies", "optionalDependencies"]
351 .into_iter()
352 .find_map(|section| {
353 root.get(section)
354 .and_then(|dependencies| dependencies.get(client_name))
355 .and_then(Value::as_str)
356 });
357 let valid_dependency = dependency
358 .and_then(|value| value.strip_prefix("file:"))
359 .is_some_and(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR);
360 if !valid_dependency {
361 return Err(format!(
362 "{} must depend on `{client_name}` via `file:./{DEFAULT_CLIENT_DIR}`",
363 root_package_json.display()
364 ));
365 }
366 Ok(())
367}
368
369fn root_declares_generated_client(root_package_json: &Path) -> Result<bool, String> {
370 if !root_package_json.is_file() {
371 return Ok(false);
372 }
373 let root = read_json(root_package_json)?;
374 if workspace_paths(&root)
375 .iter()
376 .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR)
377 {
378 return Ok(true);
379 }
380 Ok(["dependencies", "devDependencies", "optionalDependencies"]
381 .into_iter()
382 .filter_map(|section| root.get(section).and_then(Value::as_object))
383 .flat_map(|dependencies| dependencies.values())
384 .filter_map(Value::as_str)
385 .filter_map(|value| value.strip_prefix("file:"))
386 .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR))
387}
388
389fn workspace_paths(root: &Value) -> Vec<&str> {
390 let Some(workspaces) = root.get("workspaces") else {
391 return Vec::new();
392 };
393 let packages = workspaces
394 .as_array()
395 .or_else(|| workspaces.get("packages").and_then(Value::as_array));
396 packages
397 .into_iter()
398 .flatten()
399 .filter_map(Value::as_str)
400 .collect()
401}
402
403fn normalized_package_path(path: &str) -> &str {
404 path.trim().trim_start_matches("./").trim_end_matches('/')
405}
406
407fn warn_on_mixed_package_managers(root: &Path) {
408 if !root.join("package-lock.json").is_file() {
409 return;
410 }
411 let alternatives = ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]
412 .into_iter()
413 .filter(|lock| root.join(lock).is_file())
414 .collect::<Vec<_>>();
415 if !alternatives.is_empty() {
416 eprintln!(
417 "nextrs: warning: found package-lock.json and {}; generated apps use npm, so keep one package manager and remove stale lockfiles",
418 alternatives.join(", ")
419 );
420 }
421}
422
423fn ensure_root_client_install(
424 root: &Path,
425 client_dir: &Path,
426 package: &ClientPackage,
427) -> Result<(), String> {
428 ensure_root_client_install_with(root, client_dir, package, || {
429 execute(root, "npm", &["install"], None)
430 })
431}
432
433fn ensure_root_client_install_with(
434 root: &Path,
435 client_dir: &Path,
436 package: &ClientPackage,
437 install: impl FnOnce() -> Result<(), String>,
438) -> Result<(), String> {
439 if client_install_is_valid(root, client_dir, package)? {
440 return Ok(());
441 }
442
443 eprintln!(
444 "nextrs: generated client link is missing or stale; repairing it with a root npm install"
445 );
446 install()?;
447 if client_install_is_valid(root, client_dir, package)? {
448 Ok(())
449 } else {
450 let install_dir = package_install_path(&root.join("node_modules"), &package.name)?;
451 Err(format!(
452 "npm install completed, but {} is still missing, dangling, or stale. Regenerate `{DEFAULT_CLIENT_DIR}` from the app root and do not install inside it",
453 install_dir.display()
454 ))
455 }
456}
457
458fn client_install_is_valid(
459 root: &Path,
460 client_dir: &Path,
461 package: &ClientPackage,
462) -> Result<bool, String> {
463 let installed_dir = package_install_path(&root.join("node_modules"), &package.name)?;
464 let installed_package_json = installed_dir.join("package.json");
465 if !installed_package_json.is_file() {
466 return Ok(false);
467 }
468 let source_root = fs::canonicalize(client_dir).map_err(|error| {
469 format!(
470 "failed to resolve generated client directory {}: {error}",
471 client_dir.display()
472 )
473 })?;
474 let Ok(installed_root) = fs::canonicalize(&installed_dir) else {
475 return Ok(false);
476 };
477 if source_root != installed_root {
478 return Ok(false);
479 }
480 let installed = read_client_package(&installed_package_json)?;
481 if installed.name != package.name || installed.exports != package.exports {
482 return Ok(false);
483 }
484
485 let source_manifest = fs::read(client_dir.join("package.json")).map_err(io_error)?;
488 let installed_manifest = fs::read(installed_package_json).map_err(io_error)?;
489 Ok(source_manifest == installed_manifest)
490}
491
492fn validate_generated_client(
493 root: &Path,
494 client_dir: &Path,
495 package: &ClientPackage,
496) -> Result<(), String> {
497 let installed_dir = package_install_path(&root.join("node_modules"), &package.name)?;
498 for export in &package.exports {
499 for (condition, relative) in [("types", &export.types), ("import", &export.import)] {
500 let source = client_dir.join(relative);
501 if !source.is_file() {
502 return Err(format!(
503 "generated client export `{}` is missing its {condition} output: {}",
504 export.subpath,
505 source.display()
506 ));
507 }
508 let installed = installed_dir.join(relative);
509 if !installed.is_file() {
510 return Err(format!(
511 "root package link does not expose the generated {condition} output for `{}`: {}",
512 export.subpath,
513 installed.display()
514 ));
515 }
516 }
517 }
518
519 let package_name = serde_json::to_string(&package.name).map_err(|error| error.to_string())?;
520 let script =
521 format!("await import({package_name}); await import({package_name} + '/react-query')");
522 execute(
523 root,
524 "node",
525 &[
526 OsStr::new("--input-type=module"),
527 OsStr::new("--eval"),
528 OsStr::new(&script),
529 ],
530 None,
531 )
532 .map_err(|error| {
533 format!(
534 "generated client files were built, but the consuming app cannot import `{}`: {error}",
535 package.name
536 )
537 })
538}
539
540fn package_install_path(node_modules: &Path, package_name: &str) -> Result<PathBuf, String> {
541 let parts = package_name.split('/').collect::<Vec<_>>();
542 let valid_part =
543 |part: &str| !part.is_empty() && part != "." && part != ".." && !part.contains('\\');
544 let valid = match parts.as_slice() {
545 [name] => !name.starts_with('@') && valid_part(name),
546 [scope, name] => {
547 scope.starts_with('@') && scope.len() > 1 && valid_part(scope) && valid_part(name)
548 }
549 _ => false,
550 };
551 if !valid {
552 return Err(format!(
553 "invalid generated client package name `{package_name}`"
554 ));
555 }
556 Ok(parts
557 .into_iter()
558 .fold(node_modules.to_path_buf(), |path, part| path.join(part)))
559}
560
561fn read_json(path: &Path) -> Result<Value, String> {
562 let contents = fs::read_to_string(path)
563 .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
564 serde_json::from_str(&contents)
565 .map_err(|error| format!("failed to parse {}: {error}", path.display()))
566}
567
568fn normal_generation_target<'a>(
569 root: &'a Path,
570 client_dir: &'a Path,
571 custom_client_dir: bool,
572) -> (&'a Path, &'static str) {
573 if custom_client_dir {
574 (client_dir, "gen")
577 } else {
578 (root, "client:generate")
579 }
580}
581
582fn execute<S: AsRef<OsStr>>(
583 cwd: &Path,
584 program: &str,
585 args: &[S],
586 envs: Option<&[(&str, &str)]>,
587) -> Result<(), String> {
588 let mut command = Command::new(program);
589 command.current_dir(cwd).args(args);
590 if let Some(envs) = envs {
591 command.envs(envs.iter().copied());
592 }
593 let status = command
594 .status()
595 .map_err(|error| format!("failed to run `{program}` in {}: {error}", cwd.display()))?;
596 if status.success() {
597 Ok(())
598 } else {
599 Err(format!("`{program}` exited with {status}"))
600 }
601}
602
603fn absolutize(base: &Path, path: &Path) -> PathBuf {
604 if path.is_absolute() {
605 path.to_path_buf()
606 } else {
607 base.join(path)
608 }
609}
610
611fn io_error(error: std::io::Error) -> String {
612 error.to_string()
613}
614
615fn print_help() {
616 println!(
617 "nextrs\n\nUSAGE:\n nextrs new <PATH> [OPTIONS]\n nextrs dev [--bin <NAME>] [-- <APP_ARGS>]\n nextrs client generate [OPTIONS]\n\nRun the same commands as `cargo nextrs ...` or `nextrs ...`.\n\nCLIENT OPTIONS:\n --root <PATH> nextrs application root (default: current directory)\n --client-dir <PATH> generated package relative to the app root (default: .nextrs/client)\n --config <PATH> external-client config; defaults to .nextrs/client/nextrs.client.json when present\n -h, --help Print help\n\nClient dependencies are installed once at the application root; never run\n`npm install` inside the generated client directory. Generation validates and\nrepairs the root workspace link, then verifies both JS and declaration exports.\n\nOne `cargo install cargo-nextrs` provides both launchers, the dev server,\nthe legacy `cargo-nextrs-dev` compatibility binary, and client generation."
618 );
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 const CLIENT_PACKAGE_JSON: &str = r#"{
626 "name": "@demo/client",
627 "exports": {
628 ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
629 "./react-query": {
630 "types": "./dist/react-query.d.ts",
631 "import": "./dist/react-query.js"
632 }
633 }
634 }"#;
635
636 fn test_dir(name: &str) -> PathBuf {
637 let path = std::env::temp_dir().join(format!("cargo-nextrs-{name}-{}", std::process::id()));
638 let _ = fs::remove_dir_all(&path);
639 fs::create_dir_all(&path).unwrap();
640 path
641 }
642
643 fn write_modern_package(root: &Path) -> (PathBuf, ClientPackage) {
644 let client_dir = root.join(DEFAULT_CLIENT_DIR);
645 fs::create_dir_all(&client_dir).unwrap();
646 let package_json = client_dir.join("package.json");
647 fs::write(&package_json, CLIENT_PACKAGE_JSON).unwrap();
648 let package = read_client_package(&package_json).unwrap();
649 (client_dir, package)
650 }
651
652 fn parse(args: &[&str]) -> Result<CommandLine, String> {
653 CommandLine::parse(args.iter().map(OsString::from))
654 }
655
656 #[test]
657 fn parses_cargo_subcommand_prefix() {
658 assert_eq!(
659 parse(&["nextrs", "client", "generate"]).unwrap(),
660 CommandLine::ClientGenerate(GenerateOptions {
661 root: PathBuf::from("."),
662 client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
663 config: None,
664 })
665 );
666 }
667
668 #[test]
669 fn parses_new_from_both_launchers() {
670 let expected = CommandLine::New(vec![
671 OsString::from("demo"),
672 OsString::from("--nextrs-path"),
673 OsString::from("../nextrs"),
674 ]);
675 assert_eq!(
676 parse(&["new", "demo", "--nextrs-path", "../nextrs"]).unwrap(),
677 expected
678 );
679 assert_eq!(
680 parse(&["nextrs", "new", "demo", "--nextrs-path", "../nextrs"]).unwrap(),
681 expected
682 );
683 }
684
685 #[test]
686 fn parses_generation_paths() {
687 assert_eq!(
688 parse(&[
689 "client",
690 "generate",
691 "--root",
692 "server",
693 "--client-dir",
694 "web-client",
695 "--config",
696 "publish.json",
697 ])
698 .unwrap(),
699 CommandLine::ClientGenerate(GenerateOptions {
700 root: PathBuf::from("server"),
701 client_dir: PathBuf::from("web-client"),
702 config: Some(PathBuf::from("publish.json")),
703 })
704 );
705 }
706
707 #[test]
708 fn normal_generation_uses_the_root_script() {
709 let root = Path::new("/app");
710 let generated_client = root.join(DEFAULT_CLIENT_DIR);
711 assert_eq!(
712 normal_generation_target(root, &generated_client, false),
713 (root, "client:generate")
714 );
715 assert_eq!(
716 normal_generation_target(root, Path::new("/custom-client"), true),
717 (Path::new("/custom-client"), "gen")
718 );
719 }
720
721 #[test]
722 fn passes_dev_arguments_to_the_dev_runner() {
723 assert_eq!(
724 parse(&["nextrs", "dev", "--bin", "demo"]).unwrap(),
725 CommandLine::Dev(vec![OsString::from("--bin"), OsString::from("demo")])
726 );
727 }
728
729 #[test]
730 fn rejects_unknown_commands() {
731 assert!(parse(&["client", "wat"]).is_err());
732 assert!(parse(&["wat"]).is_err());
733 }
734
735 #[test]
736 fn validates_the_root_workspace_and_file_dependency() {
737 let root = test_dir("root-contract");
738 let package_json = root.join("package.json");
739 fs::write(
740 &package_json,
741 r#"{
742 "workspaces": [".nextrs/client"],
743 "dependencies": { "@demo/client": "file:./.nextrs/client" }
744 }"#,
745 )
746 .unwrap();
747 validate_root_client_contract(&package_json, "@demo/client").unwrap();
748
749 fs::write(
750 &package_json,
751 r#"{ "dependencies": { "@demo/client": "file:./.nextrs/client" } }"#,
752 )
753 .unwrap();
754 assert!(
755 validate_root_client_contract(&package_json, "@demo/client")
756 .unwrap_err()
757 .contains("workspaces")
758 );
759 fs::remove_dir_all(root).unwrap();
760 }
761
762 #[cfg(unix)]
763 #[test]
764 fn repairs_a_missing_root_client_install_even_when_node_modules_exists() {
765 let root = test_dir("repair-link");
766 let (client_dir, package) = write_modern_package(&root);
767 fs::create_dir_all(root.join("node_modules/unrelated-package")).unwrap();
768
769 let mut installed = false;
770 ensure_root_client_install_with(&root, &client_dir, &package, || {
771 installed = true;
772 let install_dir = package_install_path(&root.join("node_modules"), &package.name)?;
773 fs::create_dir_all(install_dir.parent().unwrap()).map_err(io_error)?;
774 std::os::unix::fs::symlink(&client_dir, &install_dir).map_err(io_error)?;
775 Ok(())
776 })
777 .unwrap();
778
779 assert!(installed, "the missing client link was not repaired");
780 assert!(client_install_is_valid(&root, &client_dir, &package).unwrap());
781 fs::remove_dir_all(root).unwrap();
782 }
783
784 #[test]
785 fn detects_a_declared_client_whose_package_skeleton_is_missing() {
786 let root = test_dir("missing-skeleton");
787 let package_json = root.join("package.json");
788 fs::write(
789 &package_json,
790 r#"{
791 "workspaces": [".nextrs/client"],
792 "dependencies": { "@demo/client": "file:./.nextrs/client" }
793 }"#,
794 )
795 .unwrap();
796 assert!(root_declares_generated_client(&package_json).unwrap());
797 fs::remove_dir_all(root).unwrap();
798 }
799
800 #[test]
801 fn maps_scoped_package_names_under_root_node_modules() {
802 assert_eq!(
803 package_install_path(Path::new("/app/node_modules"), "@demo/client").unwrap(),
804 Path::new("/app/node_modules/@demo/client")
805 );
806 assert!(package_install_path(Path::new("node_modules"), "@demo/../client").is_err());
807 }
808}