Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md

Large diffs are not rendered by default.

93 changes: 78 additions & 15 deletions crates/frameshift-cli/src/migrate_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub(crate) struct Options {
output: PathBuf,
selection: Selection,
dry_run: bool,
clean_staging: bool,
replace_managed: bool,
strict: bool,
show_files: bool,
plan: Option<PathBuf>,
}

Expand All @@ -51,7 +55,7 @@ enum Show {
Tasks,
}

pub(crate) const HELP: &str = "Usage: frameshift migrate [<source>] [--to astro-preact] --out <dir> [--package <dir>]\n [--config <file>|--no-config] [--plan <plan.json>] [--dry-run]\n\
pub(crate) const HELP: &str = "Usage: frameshift migrate [<source>] [--to astro-preact] --out <dir> [--package <dir>]\n [--config <file>|--no-config] [--plan <plan.json>] [--dry-run] [--show-files] [--strict]\n [--clean-staging] [--on-existing fail|replace-managed]\n\
Migrate the Next.js App Router project at <source>, by default the current directory, into a\n\
new Astro project with Preact at <dir> (migrate v0, D-101). Static pages, layouts,\n\
and components that the analysis rates auto or review are converted; everything else is\n\
Expand All @@ -60,7 +64,12 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY
staging directory next to <dir> and renamed into place when complete. The source is never\n\
modified, and no project code or external tool runs. --plan refuses to continue unless the\n\
recomputed plan document equals the given one byte for byte; the given file is compared, never\n\
executed. --dry-run lists the planned files and writes nothing. frameshift.jsonc in <source>\n\
executed. --dry-run lists the planned files and writes nothing, and --show-files lists them in\n\
a real run. --strict writes nothing and exits with 1 if any unit is unknown or unsupported.\n\
--clean-staging first removes\n\
the marked staging directory of an interrupted run next to <dir>. --on-existing replace-managed\n\
replaces an existing <dir> only if its manifest records it exactly, and moves the old tree next\n\
to it instead of deleting it. frameshift.jsonc in <source>\n\
or the --config file can set the target and the package; flags override it. Exit status: 0 on\n\
success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan\n\
document differs, 4 when the output location is refused or cannot be written.\n";
Expand All @@ -79,14 +88,7 @@ configuration cannot be read, 4 when the plan document cannot be written.\n";
const OVERVIEW_OPERATIONS: usize = 8;

// Options that CLI.md specifies for later milestones.
const PLANNED: &[&str] = &[
"--strict",
"--on-existing",
"--clean-staging",
"--report",
"--show-files",
"--env-names",
];
const PLANNED: &[&str] = &["--report", "--env-names"];

const VALUE_OPTIONS: &[&str] = &[
"--to",
Expand All @@ -96,6 +98,7 @@ const VALUE_OPTIONS: &[&str] = &[
"--plan",
"--show",
"--config",
"--on-existing",
];

fn command(args: &[OsString]) -> Option<(usize, &str)> {
Expand Down Expand Up @@ -154,8 +157,9 @@ fn parse_command(
let mut seen = BTreeSet::new();
let mut operands = Vec::new();
let (mut target, mut output, mut dry_run, mut help) = (None, None, false, false);
let (mut package, mut config, mut no_config) = (None, None, false);
let (mut package, mut config, mut no_config, mut clean_staging) = (None, None, false, false);
let (mut approved, mut show, mut format) = (None, Show::Overview, Format::Text);
let (mut replace_managed, mut strict, mut show_files) = (false, false, false);
let mut i = 0;
while i < args.len() {
if i == command {
Expand All @@ -182,16 +186,38 @@ fn parse_command(
if PLANNED.contains(&key) {
return Err("option is not available in migrate v0");
}
if (planning && matches!(key, "--dry-run" | "--plan")) || (!planning && key == "--show") {
if (planning
&& matches!(
key,
"--dry-run"
| "--plan"
| "--clean-staging"
| "--on-existing"
| "--strict"
| "--show-files"
))
|| (!planning && key == "--show")
{
return Err("unknown option; run 'frameshift help' for usage");
}
if matches!(key, "--help" | "--dry-run" | "--no-config") {
if matches!(
key,
"--help"
| "--dry-run"
| "--no-config"
| "--clean-staging"
| "--strict"
| "--show-files"
) {
if inline.is_some() {
return Err("flag does not accept a value");
}
match key {
"--help" => help = true,
"--dry-run" => dry_run = true,
"--clean-staging" => clean_staging = true,
"--strict" => strict = true,
"--show-files" => show_files = true,
_ => no_config = true,
}
continue;
Expand Down Expand Up @@ -222,6 +248,13 @@ fn parse_command(
let value = value.to_str().ok_or("option values must be valid UTF-8")?;
match key {
"--to" => target = Some(value.to_owned()),
"--on-existing" => {
replace_managed = match value {
"fail" => false,
"replace-managed" => true,
_ => return Err("--on-existing requires fail or replace-managed"),
};
}
"--format" if value == "text" => {}
"--format" if planning && value == "json" => format = Format::Json,
"--format" if planning => return Err("--format requires text or json"),
Expand Down Expand Up @@ -286,11 +319,18 @@ fn parse_command(
}));
}
let output = output.ok_or("migrate requires --out <dir>")?;
if dry_run && clean_staging {
return Err("--clean-staging writes, so it cannot be part of a dry run");
}
Ok(Request::Migrate(Options {
source,
output,
selection,
dry_run,
clean_staging,
replace_managed,
strict,
show_files,
plan: approved,
}))
}
Expand Down Expand Up @@ -493,7 +533,25 @@ pub(crate) fn run(
Err(message) => return fail(stderr, 3, message),
}
}
let destination = match migrate::check_output(source, &options.output) {
if options.strict && plan.unresolved_units > 0 {
writeln!(
stdout,
"Nothing written: {} units are unknown or unsupported (--strict).",
plan.unresolved_units
)?;
return Ok(1);
}
if options.clean_staging
&& let Err(error) = migrate::clean_staging(&options.output)
{
return fail(stderr, code(error.kind()), &error.to_string());
}
let checked = if options.replace_managed {
migrate::check_replacement(source, &options.output, &Limits::default())
} else {
migrate::check_output(source, &options.output)
};
let destination = match checked {
Ok(destination) => destination,
Err(error) => return fail(stderr, code(error.kind()), &error.to_string()),
};
Expand All @@ -515,6 +573,11 @@ pub(crate) fn run(
if let Err(error) = migrate::execute(&plan, &destination) {
return fail(stderr, code(error.kind()), &error.to_string());
}
if options.show_files {
for operation in &plan.operations {
write_operation(stdout, operation)?;
}
}
writeln!(
stdout,
"Migrated: {} files written ({} converted to Astro, {} kept as scripts, {} copied, {} generated, {} preserved).",
Expand Down Expand Up @@ -597,7 +660,7 @@ mod tests {
"the only available target is astro-preact",
),
(
vec!["migrate", "--to", "astro-preact", "--out", "o", "--strict"],
vec!["migrate", "--to", "astro-preact", "--out", "o", "--report"],
"option is not available in migrate v0",
),
(
Expand Down
2 changes: 1 addition & 1 deletion crates/frameshift-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ fn invalid_arguments_and_planned_commands_fail_without_echoing_input() {
&["plan", "--to", "astro-preact", "--show", "units"],
&["plan", "--to", "astro-preact", "--dry-run"],
&["migrate", "--to", "astro-preact"],
&["migrate", "--to", "astro-preact", "--out", "o", "--strict"],
&["migrate", "--to", "astro-preact", "--out", "o", "--report"],
&["version", "extra"],
&["help", "help", "extra"],
&["--version", "version"],
Expand Down
10 changes: 8 additions & 2 deletions crates/frameshift-cli/tests/golden/help-migrate.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Usage: frameshift migrate [<source>] [--to astro-preact] --out <dir> [--package <dir>]
[--config <file>|--no-config] [--plan <plan.json>] [--dry-run]
[--config <file>|--no-config] [--plan <plan.json>] [--dry-run] [--show-files] [--strict]
[--clean-staging] [--on-existing fail|replace-managed]
Migrate the Next.js App Router project at <source>, by default the current directory, into a
new Astro project with Preact at <dir> (migrate v0, D-101). Static pages, layouts,
and components that the analysis rates auto or review are converted; everything else is
Expand All @@ -8,7 +9,12 @@ copied unchanged below .frameshift/unmigrated/ and listed in .frameshift/SUMMARY
staging directory next to <dir> and renamed into place when complete. The source is never
modified, and no project code or external tool runs. --plan refuses to continue unless the
recomputed plan document equals the given one byte for byte; the given file is compared, never
executed. --dry-run lists the planned files and writes nothing. frameshift.jsonc in <source>
executed. --dry-run lists the planned files and writes nothing, and --show-files lists them in
a real run. --strict writes nothing and exits with 1 if any unit is unknown or unsupported.
--clean-staging first removes
the marked staging directory of an interrupted run next to <dir>. --on-existing replace-managed
replaces an existing <dir> only if its manifest records it exactly, and moves the old tree next
to it instead of deleting it. frameshift.jsonc in <source>
or the --config file can set the target and the package; flags override it. Exit status: 0 on
success, 2 for invalid arguments, 3 when the source or configuration cannot be read or the plan
document differs, 4 when the output location is refused or cannot be written.
106 changes: 104 additions & 2 deletions crates/frameshift-cli/tests/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ fn workspace(case: &str, label: &str) -> PathBuf {
root
}

// The manifest holds release-dependent members; the golden has placeholders.
fn normalize_manifest(files: &mut BTreeMap<String, Vec<u8>>) {
let key = ".frameshift/manifest.json";
let text = String::from_utf8(files[key].clone()).unwrap();
let start = text.find("\"planHash\": \"").unwrap() + "\"planHash\": \"".len();
let hash = &text[start..start + text[start..].find('"').unwrap()];
let version = frameshift_engine::version::info().engine_version;
let text = text.replace(hash, "@PLAN_HASH@").replace(
&format!("\"engineVersion\": \"{version}\""),
"\"engineVersion\": \"@ENGINE_VERSION@\"",
);
files.insert(key.to_owned(), text.into_bytes());
}

fn call(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_frameshift"))
.current_dir(root)
Expand Down Expand Up @@ -115,7 +129,9 @@ fn migration_writes_the_golden_tree_and_refuses_unsafe_outputs() {
assert_eq!(output.status.code(), Some(0), "{case}");
assert!(output.stdout.starts_with(b"Migrated: "));
let golden = read_tree(&fixtures().join(&case).join("expected/output"));
assert_eq!(read_tree(&root.join("out")), golden, "{case}");
let mut written = read_tree(&root.join("out"));
normalize_manifest(&mut written);
assert_eq!(written, golden, "{case}");
// An existing output and an output inside the source are refused.
for out in ["out", "source/out"] {
let args = ["migrate", "source", "--to", "astro-preact", "--out", out];
Expand Down Expand Up @@ -256,7 +272,9 @@ fn plan_documents_approve_exactly_the_run_they_describe() {
assert_eq!(verified.status.code(), Some(0), "{case}");
assert!(String::from_utf8_lossy(&verified.stdout).starts_with("Plan "));
let golden = read_tree(&fixtures().join(&case).join("expected/output"));
assert_eq!(read_tree(&root.join("verified")), golden, "{case}");
let mut written = read_tree(&root.join("verified"));
normalize_manifest(&mut written);
assert_eq!(written, golden, "{case}");

fs::write(root.join("source/added.txt"), "changed").unwrap();
let stale = approved("stale", "plan.json");
Expand All @@ -266,6 +284,90 @@ fn plan_documents_approve_exactly_the_run_they_describe() {
}
}

#[test]
fn replace_managed_replaces_only_unmodified_output() {
let root = workspace("static-site", "replace");
let mut args = MIGRATE.to_vec();
args.extend(["--on-existing", "replace-managed"]);
// An absent location is simply written.
assert_eq!(call(&root, &args).status.code(), Some(0));
let golden = read_tree(&root.join("out"));
let previous = root.join(".out.frameshift-previous");
// Unmodified output is replaced, and the old tree moves aside.
assert_eq!(call(&root, &args).status.code(), Some(0));
assert_eq!(read_tree(&root.join("out")), golden);
assert_eq!(read_tree(&previous), golden);
// The default still refuses, and a moved tree is never overwritten.
assert_eq!(call(&root, MIGRATE).status.code(), Some(4));
assert_eq!(call(&root, &args).status.code(), Some(4));
fs::remove_dir_all(&previous).unwrap();
// An edited, added, or removed file protects the whole tree.
let edited = root.join("out/src/pages/index.astro");
let original = fs::read(&edited).unwrap();
fs::write(&edited, "edited").unwrap();
assert_eq!(call(&root, &args).status.code(), Some(4));
assert_eq!(fs::read(&edited).unwrap(), b"edited");
fs::write(&edited, &original).unwrap();
fs::write(root.join("out/notes.txt"), "mine").unwrap();
assert_eq!(call(&root, &args).status.code(), Some(4));
fs::remove_file(root.join("out/notes.txt")).unwrap();
fs::remove_file(root.join("out/public/robots.txt")).unwrap();
assert_eq!(call(&root, &args).status.code(), Some(4));
assert!(!previous.exists());
// A directory without a manifest is not Frameshift's to replace.
fs::remove_dir_all(root.join("out")).unwrap();
fs::create_dir_all(root.join("out")).unwrap();
fs::write(root.join("out/mine.txt"), "mine").unwrap();
assert_eq!(call(&root, &args).status.code(), Some(4));
assert!(root.join("out/mine.txt").exists());
let mut invalid = MIGRATE.to_vec();
invalid.extend(["--on-existing", "overwrite"]);
assert_eq!(call(&root, &invalid).status.code(), Some(2));
fs::remove_dir_all(root).unwrap();
}

#[test]
fn strict_refuses_unresolved_units_and_show_files_lists_a_real_run() {
let root = workspace("static-site", "strict");
let mut strict = MIGRATE.to_vec();
strict.push("--strict");
// The fixture preserves a dynamic route that the analysis rates unsupported.
let refused = call(&root, &strict);
assert_eq!(refused.status.code(), Some(1));
assert!(String::from_utf8_lossy(&refused.stdout).starts_with("Nothing written: "));
assert!(!root.join("out").exists());
let mut show = MIGRATE.to_vec();
show.push("--show-files");
let listed = call(&root, &show);
assert_eq!(listed.status.code(), Some(0));
let text = String::from_utf8(listed.stdout).unwrap();
assert!(text.contains("layout.chain from app/layout.tsx"), "{text}");
assert!(text.contains("Migrated: "), "{text}");
fs::remove_dir_all(root).unwrap();
}

#[test]
fn clean_staging_recovers_from_an_interrupted_run() {
let root = workspace("static-site", "staging");
let staging = root.join(".out.frameshift-staging");
fs::create_dir(&staging).unwrap();
fs::write(staging.join("notes.txt"), "kept").unwrap();
let mut args = MIGRATE.to_vec();
assert_eq!(call(&root, &args).status.code(), Some(4));
args.push("--clean-staging");
// Without the marker, the directory is not Frameshift's to remove.
assert_eq!(call(&root, &args).status.code(), Some(4));
assert!(staging.join("notes.txt").exists());
fs::write(staging.join(".frameshift-staging"), "").unwrap();
assert_eq!(call(&root, &args).status.code(), Some(0));
assert!(!staging.exists());
assert!(root.join("out/.frameshift/manifest.json").exists());
// A dry run writes nothing, so it cannot clean.
args.push("--dry-run");
assert_eq!(call(&root, &args).status.code(), Some(2));
fs::remove_dir_all(root).unwrap();
}

#[test]
fn plan_text_lists_operations_files_and_tasks() {
let case = "static-site";
Expand Down
9 changes: 8 additions & 1 deletion crates/frameshift-emit/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ pub fn convert_component(
(Vec::new(), body, None)
};
let root_span = checker.span(root)?;
let root_kind = checker.kind(checker.unparenthesize(root)?)?;
let markup_root = checker.unparenthesize(root)?;
let markup_span = checker.span(markup_root)?;
let root_kind = checker.kind(markup_root)?;
if !matches!(
root_kind,
SyntaxNodeKind::JsxElement { .. } | SyntaxNodeKind::JsxFragment { .. }
Expand Down Expand Up @@ -204,6 +206,11 @@ pub fn convert_component(
if substitutions.iter().any(within) {
return Err(checker.error(ConvertErrorKind::CommentInMarkup, span));
}
// The markup conversion checks only what the parentheses around the
// root enclose, so a comment beside the root would be lost.
if within(&root_span) && !within(&markup_span) {
return Err(checker.error(ConvertErrorKind::CommentInMarkup, span));
}
if within(&root_span) || statement_spans.iter().any(within) || pattern_copied {
continue;
}
Expand Down
Loading
Loading