@smbcloud / cli / commits / e894c6a

fix(deploy): prune dangling standalone symlinks before rsync

The nextjs-ssr deploy uploads .next/standalone with `rsync --copy-links`, so rsync follows every symlink. On pnpm monorepos, Next's standalone tracing can leave a compat symlink pointing at a version it never wrote into the bundle, like .pnpm/node_modules/semver -> ../semver@6.3.1/... when only semver@7.x was traced. The link dangles, rsync fails trying to follow it, exits 23, and the deploy aborts with an empty stderr. Add a step 3b that walks .next/standalone after the build and deletes any symlink whose target is missing, before the rsync runs. It recurses into real directories but never follows a symlinked one, so there are no cycles. The dangling package isn't part of the traced runtime, so removing the link is safe. It prints how many it removed. Replaces the manual find/delete workaround for every pnpm monorepo. The smbcloud-deploy-nextjs skill is updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Seto Elkahfi committed Jul 5, 2026 at 20:32 UTC e894c6afe8f3e8469b3a8bad3d1c9f35c7debb5a
2 files changed +146 -10
.agents/skills/smbcloud-deploy-nextjs/SKILL.md
+16 -9
@@ -449,14 +449,20 @@ find .next/standalone/ -type l ! -exec test -e {} \; -print
449
450 Fix:
451
452 -- Remove the dangling link(s) before upload:
452 +- The CLI now handles this automatically. `process_deploy_nextjs_ssr` runs a
453 + step 3b (`prune_dangling_symlinks`) after the build and before the standalone
454 + rsync: it walks `.next/standalone/` and deletes any symlink whose target does
455 + not exist (real dirs are traversed; symlinked dirs are never followed). When
456 + it removes anything it prints `Pruned N dangling symlink(s) …`. No manual step
457 + is needed on current `smbcloud-cli`.
458 +- Older `smb` (≤ 0.4.7) lacks the prune. On those, remove the link(s) before
459 + upload and re-run the deploy — but the rebuild regenerates them, so it must
460 + happen after `next build` and before rsync:
461 `find .next/standalone/ -type l ! -exec test -e {} \; -delete`
454 - then re-run the deploy. (The CLI rebuilds on every run, so this must happen
455 - after `next build` and before rsync — when driving the deploy manually,
456 - delete then run the three rsyncs yourself.)
457 -- Proper fix belongs in the CLI: prune dangling symlinks (or use
458 - `--copy-unsafe-links` semantics that tolerate them) before the standalone
459 - rsync. Until then, the manual `find … -delete` is the workaround.
462 + Driving the deploy by hand, delete then run the three rsyncs yourself. A
463 + repo-local equivalent is a `postbuild` script that runs the same `find …
464 + -delete`, since `smb` invokes `pnpm build` (which fires `postbuild`) in the
465 + right window.
466
467 ### Misleading deploy output
468
@@ -527,8 +533,9 @@ Use the smallest checks that prove the contract.
533 - keeping an old git `post-receive` hook and expecting it to manage SSR deploys
534 - `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static
535 is under `<app>/<source>/.next/static`, so the alias 404s; proxy everything instead
530 -- leaving a dangling pnpm symlink in `.next/standalone/` — `rsync --copy-links`
531 - fails with status 23; `find … -type l ! -exec test -e {} \; -delete` first
536 +- assuming a dangling pnpm symlink in `.next/standalone/` still breaks the
537 + deploy — current `smbcloud-cli` prunes them in step 3b before the
538 + `rsync --copy-links` upload; only pre-prune manually on `smb` ≤ 0.4.7
539 - giving two hostnames one `server` block without a shared SAN cert
540 - using static-file Nginx config for an SSR app
541 - rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+130 -1
@@ -22,6 +22,58 @@ use {
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
@@ -160,6 +212,31 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
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();
@@ -247,7 +324,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
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,
@@ -698,3 +776,54 @@ async fn mark_failed(
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 +}