22
tempfile::NamedTempFile,
23
};
24
25
+/// Recursively delete symlinks under `root` whose target does not exist,
26
+/// returning the number removed.
27
+///
28
+/// Next.js standalone output on pnpm can contain compat symlinks pointing at a
29
+/// package version that was never traced into the bundle — e.g.
30
+/// `node_modules/.pnpm/node_modules/semver -> ../semver@6.3.1/node_modules/semver`
31
+/// while only `semver@7.x` is actually written. The link's target is missing,
32
+/// so it dangles. We upload `.next/standalone/` with `rsync --copy-links`, which
33
+/// follows every symlink; a dangling one is an IO error → rsync exits 23 and
34
+/// aborts. The dangling package is not part of the traced runtime, so dropping
35
+/// the link is safe. See the `smbcloud-deploy-nextjs` skill for the write-up.
36
+///
37
+/// Real (non-symlink) directories are traversed; a symlinked directory is
38
+/// treated as a leaf and never followed, which also avoids symlink cycles.
39
+fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
40
+ let meta = std::fs::symlink_metadata(root)?;
41
+
42
+ if meta.file_type().is_symlink() {
43
+ // A symlink at the walk root: drop it only if it dangles, never follow.
44
+ if !root.exists() {
45
+ std::fs::remove_file(root)?;
46
+ return Ok(1);
47
+ }
48
+ return Ok(0);
49
+ }
50
+
51
+ if !meta.is_dir() {
52
+ return Ok(0);
53
+ }
54
+
55
+ let mut removed = 0;
56
+ for entry in std::fs::read_dir(root)? {
57
+ let entry = entry?;
58
+ // `read_dir` file types do not follow symlinks: a link reports as a
59
+ // symlink here, and only real directories report as directories.
60
+ let file_type = entry.file_type()?;
61
+ let path = entry.path();
62
+
63
+ if file_type.is_symlink() {
64
+ // `exists()` follows the link; `false` means the target is missing.
65
+ if !path.exists() {
66
+ std::fs::remove_file(&path)?;
67
+ removed += 1;
68
+ }
69
+ } else if file_type.is_dir() {
70
+ removed += prune_dangling_symlinks(&path)?;
71
+ }
72
+ }
73
+
74
+ Ok(removed)
75
+}
76
+
77
/// Deploys a Next.js SSR app using standalone output mode.
78
///
79
/// Requires `output: 'standalone'` in next.config.js. This produces a
212
None
213
};
214
215
+ // ── Step 3b: prune dangling symlinks from the standalone tree ─────────────
216
+ //
217
+ // The standalone upload uses `rsync --copy-links` (see the Transfer for
218
+ // `.next/standalone/`), which follows every symlink. pnpm can leave compat
219
+ // symlinks whose target was never traced into the bundle; following one is
220
+ // an IO error that makes rsync exit 23 and abort the whole deploy. Drop them
221
+ // now — after the build, before upload — since they are not runtime files.
222
+ match prune_dangling_symlinks(standalone_path) {
223
+ Ok(0) => {}
224
+ Ok(n) => println!(
225
+ "{}",
226
+ succeed_message(&format!(
227
+ "Pruned {} dangling symlink{} from .next/standalone before upload.",
228
+ n,
229
+ if n == 1 { "" } else { "s" }
230
+ ))
231
+ ),
232
+ Err(e) => {
233
+ return Err(anyhow!(fail_message(&format!(
234
+ "Failed to prune dangling symlinks from .next/standalone: {}",
235
+ e
236
+ ))));
237
+ }
238
+ }
239
+
240
// ── Step 4: record deployment as Started ─────────────────────────────────
241
242
let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
324
remote_rel: String::new(),
325
// pnpm leaves symlinked package entries in standalone output. The
326
// server only receives this tree, so those symlinks must be
250
- // dereferenced during upload.
327
+ // dereferenced during upload. Dangling links (targets never traced
328
+ // into the bundle) are pruned in step 3b so this does not exit 23.
329
copy_links: true,
330
// The server copy of ecosystem.config.cjs (or .js) is operator-managed
331
// runtime config and must survive deploys. Without this protection,
776
.await;
777
}
778
}
779
+
780
+#[cfg(all(test, unix))]
781
+mod tests {
782
+ use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir};
783
+
784
+ /// Mirrors the real failure: a pnpm compat link points at a version dir that
785
+ /// was never traced into the bundle, so it dangles and must be pruned, while
786
+ /// a valid link and real files are left untouched.
787
+ #[test]
788
+ fn prunes_dangling_but_keeps_valid_links_and_files() {
789
+ let root = tempdir().unwrap();
790
+ let base = root.path();
791
+
792
+ // Nested real dir mirroring node_modules/.pnpm/node_modules/
793
+ let nm = base.join("node_modules/.pnpm/node_modules");
794
+ std::fs::create_dir_all(&nm).unwrap();
795
+
796
+ // A traced package dir + a valid symlink to it.
797
+ let real_pkg = base.join("node_modules/.pnpm/semver@7.7.3/node_modules/semver");
798
+ std::fs::create_dir_all(&real_pkg).unwrap();
799
+ std::fs::write(real_pkg.join("index.js"), "module.exports = {}").unwrap();
800
+ let valid_link = nm.join("valid");
801
+ symlink("../semver@7.7.3/node_modules/semver", &valid_link).unwrap();
802
+
803
+ // The dangling compat link: target version was never written.
804
+ let dangling = nm.join("semver");
805
+ symlink("../semver@6.3.1/node_modules/semver", &dangling).unwrap();
806
+
807
+ // A plain file that must survive.
808
+ let keeper = base.join("server.js");
809
+ std::fs::write(&keeper, "// server").unwrap();
810
+
811
+ let removed = prune_dangling_symlinks(base).unwrap();
812
+
813
+ assert_eq!(removed, 1, "exactly the dangling link should be removed");
814
+ assert!(!dangling.exists(), "dangling link is gone");
815
+ assert!(
816
+ std::fs::symlink_metadata(&valid_link).is_ok(),
817
+ "valid link is preserved"
818
+ );
819
+ assert!(keeper.exists(), "real files are untouched");
820
+ }
821
+
822
+ #[test]
823
+ fn clean_tree_removes_nothing() {
824
+ let root = tempdir().unwrap();
825
+ std::fs::create_dir_all(root.path().join("a/b")).unwrap();
826
+ std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap();
827
+ assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0);
828
+ }
829
+}