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