Skip to content

Commit be7ee7c

Browse files
committed
Merge branch 'development' of github.com:smbcloudXYZ/smbcloud-cli into development
2 parents b770530 + 319ab8c commit be7ee7c

2 files changed

Lines changed: 198 additions & 10 deletions

File tree

.agents/skills/smbcloud-deploy-nextjs/SKILL.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -449,14 +449,20 @@ find .next/standalone/ -type l ! -exec test -e {} \; -print
449449
450450
Fix:
451451
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:
453461
`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.
460466
461467
### Misleading deploy output
462468
@@ -527,8 +533,9 @@ Use the smallest checks that prove the contract.
527533
- keeping an old git `post-receive` hook and expecting it to manage SSR deploys
528534
- `alias`-ing `/_next/static` for a nested (monorepo) standalone build — static
529535
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
532539
- giving two hostnames one `server` block without a shared SAN cert
533540
- using static-file Nginx config for an SSR app
534541
- rsyncing `.next/standalone/` without `--copy-links` and shipping broken `pnpm` symlinks to the server

crates/cli/src/deploy/process_deploy_nextjs_ssr.rs

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,69 @@ use {
2222
tempfile::NamedTempFile,
2323
};
2424

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. This
39+
/// relies on pnpm's layout, where every package directory is a real directory
40+
/// reachable directly in the walk (symlinks only ever point *into* it): a
41+
/// dangling link nested under a real dir is always visited and pruned. A
42+
/// dangling link reachable *only* through a valid directory symlink would be
43+
/// skipped, but rsync would follow the same valid link and hit it — that shape
44+
/// does not occur in standalone output.
45+
fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
46+
let meta = std::fs::symlink_metadata(root)?;
47+
48+
if meta.file_type().is_symlink() {
49+
// A symlink at the walk root: drop it only if it dangles, never follow.
50+
// `try_exists` follows the link and reports Ok(false) only when the
51+
// target is genuinely absent; a real IO error propagates instead of
52+
// being misread as "dangling".
53+
if !root.try_exists()? {
54+
std::fs::remove_file(root)?;
55+
return Ok(1);
56+
}
57+
return Ok(0);
58+
}
59+
60+
if !meta.is_dir() {
61+
return Ok(0);
62+
}
63+
64+
let mut removed = 0;
65+
for entry in std::fs::read_dir(root)? {
66+
let entry = entry?;
67+
// `read_dir` file types do not follow symlinks: a link reports as a
68+
// symlink here, and only real directories report as directories.
69+
let file_type = entry.file_type()?;
70+
let path = entry.path();
71+
72+
if file_type.is_symlink() {
73+
// `try_exists` follows the link; Ok(false) means the target is
74+
// missing (dangling). An IO error propagates rather than deleting a
75+
// link we merely failed to stat.
76+
if !path.try_exists()? {
77+
std::fs::remove_file(&path)?;
78+
removed += 1;
79+
}
80+
} else if file_type.is_dir() {
81+
removed += prune_dangling_symlinks(&path)?;
82+
}
83+
}
84+
85+
Ok(removed)
86+
}
87+
2588
/// Deploys a Next.js SSR app using standalone output mode.
2689
///
2790
/// Requires `output: 'standalone'` in next.config.js. This produces a
@@ -160,6 +223,32 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
160223
None
161224
};
162225

226+
// ── Step 3b: prune dangling symlinks from the standalone tree ─────────────
227+
//
228+
// The standalone upload uses `rsync --copy-links` (see the Transfer for
229+
// `.next/standalone/`), which follows every symlink. pnpm can leave compat
230+
// symlinks whose target was never traced into the bundle; following one is
231+
// an IO error that makes rsync exit 23 and abort the whole deploy. Drop them
232+
// now — after the build, before upload — since they are not runtime files.
233+
match prune_dangling_symlinks(standalone_path) {
234+
Ok(0) => {}
235+
Ok(n) => println!(
236+
"{} {}",
237+
succeed_symbol(),
238+
succeed_message(&format!(
239+
"Pruned {} dangling symlink{} from .next/standalone before upload.",
240+
n,
241+
if n == 1 { "" } else { "s" }
242+
))
243+
),
244+
Err(e) => {
245+
return Err(anyhow!(fail_message(&format!(
246+
"Failed to prune dangling symlinks from .next/standalone: {}",
247+
e
248+
))));
249+
}
250+
}
251+
163252
// ── Step 4: record deployment as Started ─────────────────────────────────
164253

165254
let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
@@ -281,7 +370,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
281370
remote_rel: String::new(),
282371
// pnpm leaves symlinked package entries in standalone output. The
283372
// server only receives this tree, so those symlinks must be
284-
// dereferenced during upload.
373+
// dereferenced during upload. Dangling links (targets never traced
374+
// into the bundle) are pruned in step 3b so this does not exit 23.
285375
copy_links: true,
286376
// The server copy of ecosystem.config.cjs (or .js) is operator-managed
287377
// runtime config and must survive deploys. Without this protection,
@@ -735,3 +825,94 @@ async fn mark_failed(
735825
.await;
736826
}
737827
}
828+
829+
#[cfg(all(test, unix))]
830+
mod tests {
831+
use {super::prune_dangling_symlinks, std::os::unix::fs::symlink, tempfile::tempdir};
832+
833+
/// Mirrors the real failure: a pnpm compat link points at a version dir that
834+
/// was never traced into the bundle, so it dangles and must be pruned, while
835+
/// a valid link and real files are left untouched.
836+
#[test]
837+
fn prunes_dangling_but_keeps_valid_links_and_files() {
838+
let root = tempdir().unwrap();
839+
let base = root.path();
840+
841+
// Nested real dir mirroring node_modules/.pnpm/node_modules/
842+
let nm = base.join("node_modules/.pnpm/node_modules");
843+
std::fs::create_dir_all(&nm).unwrap();
844+
845+
// A traced package dir + a valid symlink to it.
846+
let real_pkg = base.join("node_modules/.pnpm/semver@7.7.3/node_modules/semver");
847+
std::fs::create_dir_all(&real_pkg).unwrap();
848+
std::fs::write(real_pkg.join("index.js"), "module.exports = {}").unwrap();
849+
let valid_link = nm.join("valid");
850+
symlink("../semver@7.7.3/node_modules/semver", &valid_link).unwrap();
851+
852+
// The dangling compat link: target version was never written.
853+
let dangling = nm.join("semver");
854+
symlink("../semver@6.3.1/node_modules/semver", &dangling).unwrap();
855+
856+
// A plain file that must survive.
857+
let keeper = base.join("server.js");
858+
std::fs::write(&keeper, "// server").unwrap();
859+
860+
let removed = prune_dangling_symlinks(base).unwrap();
861+
862+
assert_eq!(removed, 1, "exactly the dangling link should be removed");
863+
assert!(!dangling.exists(), "dangling link is gone");
864+
assert!(
865+
std::fs::symlink_metadata(&valid_link).is_ok(),
866+
"valid link is preserved"
867+
);
868+
assert!(keeper.exists(), "real files are untouched");
869+
}
870+
871+
#[test]
872+
fn clean_tree_removes_nothing() {
873+
let root = tempdir().unwrap();
874+
std::fs::create_dir_all(root.path().join("a/b")).unwrap();
875+
std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap();
876+
assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0);
877+
}
878+
879+
/// A valid symlink *to a directory* is treated as a leaf and never followed.
880+
/// The link here points back at the walk root, so following it would recurse
881+
/// forever; terminating (and leaving the link in place) proves it is not
882+
/// followed — the same property that keeps pnpm's symlink graph cycle-free.
883+
#[test]
884+
fn does_not_follow_valid_directory_symlink() {
885+
let root = tempdir().unwrap();
886+
let base = root.path();
887+
888+
std::fs::write(base.join("server.js"), "// server").unwrap();
889+
// A valid directory symlink that points back at the root: following it
890+
// would loop. `read_dir` reports it as a symlink, so the leaf branch
891+
// keeps it without descending.
892+
let dir_link = base.join("cycle");
893+
symlink(".", &dir_link).unwrap();
894+
895+
let removed = prune_dangling_symlinks(base).unwrap();
896+
897+
assert_eq!(removed, 0, "no dangling links, nothing removed");
898+
assert!(
899+
std::fs::symlink_metadata(&dir_link).is_ok(),
900+
"the valid directory symlink itself is preserved"
901+
);
902+
}
903+
904+
/// The walk root being a dangling symlink is handled by the top-level branch:
905+
/// it is removed and counted.
906+
#[test]
907+
fn dangling_symlink_at_root_is_removed() {
908+
let root = tempdir().unwrap();
909+
let link = root.path().join("root_link");
910+
symlink("./missing_target", &link).unwrap();
911+
912+
assert_eq!(prune_dangling_symlinks(&link).unwrap(), 1);
913+
assert!(
914+
std::fs::symlink_metadata(&link).is_err(),
915+
"the dangling root symlink is gone"
916+
);
917+
}
918+
}

0 commit comments

Comments
 (0)