From 74a3813503eddd80a1fd511116fe1c64eded1be1 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:24:37 +0200 Subject: [PATCH 1/3] feat(packages): scaffold from the compatible example collection --- docs/commands.md | 34 ++-- src/bin/main.rs | 4 +- src/cmd/mod.rs | 84 +++++++-- src/cmd/packages/lifecycle.rs | 3 +- src/cmd/packages/mod.rs | 336 +++++++++++++++++++++------------- src/cmd/packages/stamp.rs | 36 ++-- 6 files changed, 317 insertions(+), 180 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index dd5d51d..2755634 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -69,7 +69,7 @@ lyracore packages config NAME [KEY [VALUE]] [--new] lyracore packages disable NAME [--yes] lyracore packages enable NAME lyracore packages list -lyracore packages new NAME +lyracore packages new NAME [--from RUNG] lyracore packages remove NAME [--yes] lyracore packages replay [DATABASE ...] [--check] [--yes] [--force-all] [--client-data PATH] lyracore packages update [NAME] [--yes] @@ -102,7 +102,7 @@ lyracore update | `packages disable` | run Package Teardown on every Shard, then move the Package out of the build's sight, keeping it on disk | | `packages enable` | move a disabled Package back into the build | | `packages list` | every installed Package: enabled or disabled, where it came from, and whether it has drifted | -| `packages new` | scaffold a new Package offline, by copying and renaming the reference Package this checkout ships | +| `packages new` | copy and rename a Reference Package from the collection tag matching this checkout's Package API | | `packages remove` | delete a disabled Package, after a confirmation and a check for local changes | | `packages replay` | reapply every enabled Package's claims and Runtime Scripts onto the named Shards, or the whole recorded fixture topology by default | | `packages update` | advance a Git-backed Package, or every one of them, to the repository's current commit | @@ -229,7 +229,8 @@ for you**, so a plain `./lyracore import` never asks twice. ./lyracore packages add https://host/greeter.git # clone a repository whose root is one Package ./lyracore packages add greeter # bare name: resolve from the Official Package Collection ./lyracore packages list -./lyracore packages new my-package # scaffold one from nothing but this checkout +./lyracore packages new my-package # start with example-script +./lyracore packages new my-package --from example-rust ``` A Package is a drop-in folder under `packages//`. `module/build.rs` discovers it and compiles @@ -298,18 +299,21 @@ Identity, whether the tree on disk still matches it (`clean` or `LOCALLY DRIFTED registers. A Package with no stamp — dropped into `packages/` by hand, or installed before this command existed — renders as unrecorded rather than failing the listing. -**`packages new NAME` scaffolds a Package with no network access and nothing external to review.** -It copies `packages/example/` — the maintained reference Package every LyraCore checkout ships, -including a fresh public clone — to `packages/NAME/`, renaming the reference's own identifiers into -the new name, then writes a Provenance Stamp recording a **scaffold** origin rather than a Package -Source (nothing external was installed, so there is nothing to name) and runs `preflight`. The same -name and shape refusals as `add` apply before anything is written. The reference Package is -Rust-only and inert. Its `src/mod.rs` carries one commented hook pattern, and the scaffold has no -`client/` directory. The printed next steps say to add `client/addons//` for addons or `client/mpq/` -for client-file overrides, and that `client sync` will pack them in once you do. Growing the Rust -half means wiring more hooks from the catalogue in `module/src/hooks.rs`, following the pattern -`packages/NAME/src/mod.rs` already shows. A scaffold ships no Datascript: the authoring toolchain -in `datascripts/` is checkout-wide today, not per-Package. +`packages new NAME [--from RUNG]` fetches a Reference Package from the Official Package Collection. +The checkout's Package API version selects its tag, as with `packages add`. The default is +`example-script`, which needs no Rust. Other rungs are `example-client`, `example-data`, +`example-rust` and `example-all`. A missing tag or rung leaves the Package Inventory unchanged. + +The command copies the source into `packages/NAME/`, renames Package names, Rust identifiers and +client filenames, prints its Trust Review and runs `preflight`. The Provenance Stamp keeps the +`scaffold` kind and records the chosen rung and exact collection revision. `packages update` +does not replace scaffolded code. + +Generated artifacts are omitted because the renamed sources need new Build Identities. Before +using a copy beside another, choose distinct `@id` values for Runtime Scripts or a distinct Package +Spell ID in its Datascript. Run `packages build` after editing the sources. A Datascript also needs +a Base Snapshot from your own client data. Replay the built artifacts through the normal realm +update; use `client sync` for a client half. Scaffolding requires network access to the collection. ## `packages enable`, `disable`, `remove` — taking a Package out of the build diff --git a/src/bin/main.rs b/src/bin/main.rs index 8eec376..bea39f9 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -128,8 +128,8 @@ fn run(args: &[String]) -> Result { ) .map(|_| EXIT_OK), Command::PackagesList => cmd::packages::list(&ProjectLayout::discover()?).map(|_| EXIT_OK), - Command::PackagesNew { name } => { - cmd::packages::new(&ProjectLayout::discover()?, &runner, &name).map(|_| EXIT_OK) + Command::PackagesNew { name, from } => { + cmd::packages::new(&ProjectLayout::discover()?, &runner, &name, &from).map(|_| EXIT_OK) } Command::PackagesBuild => { cmd::packages::build::run(&ProjectLayout::discover()?, &runner).map(|_| EXIT_OK) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index ce7d782..bb3f58c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -177,12 +177,12 @@ USAGE: Package Source, its recorded content identity, whether the installed copy has drifted from it, and what it registers - lyracore packages new NAME scaffold from the maintained reference Package in - this checkout (packages/example/), without fetching - a template. Refuses enabled/disabled collisions, - then runs ordinary preflight (whose Cargo checks may - use its configured cache/network). No client content; - the printed next steps explain how to add it + lyracore packages new NAME [--from RUNG] copy a Reference Package from the collection tag for + this checkout's Package API. Defaults to example-script. + Other rungs: example-client, example-data, example-rust, + example-all. Records the rung and revision, renames + source and runs preflight. Build new artifacts with + `packages build` after choosing distinct IDs lyracore production status --server URI --gateway-log PATH --realm-core DB DATABASE ... read-only production topology, schema, connection, realm-core and listener verdicts @@ -263,6 +263,7 @@ pub enum Command { PackagesList, PackagesNew { name: String, + from: String, }, PackagesBuild, PackagesCheck, @@ -419,15 +420,7 @@ impl Command { ["packages", "list", other, ..] => Err(Error::Usage(format!( "`packages list` takes no arguments (got '{other}')" ))), - ["packages", "new", name] => Ok(Command::PackagesNew { - name: (*name).to_string(), - }), - ["packages", "new"] => Err(Error::Usage( - "`packages new` needs a name, e.g. `packages new my-package`".to_string(), - )), - ["packages", "new", _name, other, ..] => Err(Error::Usage(format!( - "`packages new` takes one name (got a second argument: '{other}')" - ))), + ["packages", "new", rest @ ..] => parse_packages_new(rest), ["packages", "replay", rest @ ..] => parse_packages_replay(rest), ["packages", "config", rest @ ..] => parse_packages_config(rest), ["packages", "build"] => Ok(Command::PackagesBuild), @@ -693,6 +686,41 @@ fn parse_packages_replay(args: &[&str]) -> Result { Ok(Command::PackagesReplay(options)) } +fn parse_packages_new(args: &[&str]) -> Result { + let mut name = None; + let mut from = None; + let mut args = args.iter(); + while let Some(arg) = args.next() { + match *arg { + "--from" if from.is_none() => { + from = Some( + args.next() + .filter(|value| !value.starts_with('-')) + .ok_or_else(|| { + Error::Usage("`--from` needs a Reference Package name".to_string()) + })? + .to_string(), + ); + } + option if option.starts_with('-') => { + return Err(Error::Usage(format!( + "unknown or repeated `packages new` option '{option}'; use NAME [--from RUNG]" + ))) + } + value if name.is_none() => name = Some(value.to_string()), + value => { + return Err(Error::Usage(format!( + "`packages new` takes one name, got '{value}' too" + ))) + } + } + } + Ok(Command::PackagesNew { + name: name.ok_or_else(|| Error::Usage("`packages new` needs a name".to_string()))?, + from: from.unwrap_or_else(|| packages::DEFAULT_REFERENCE.to_string()), + }) +} + /// `client pack --out DIR [--zip]`. /// /// `--out` and `--zip` interleave freely, the same as `packages replay`'s options — but this verb @@ -1692,13 +1720,37 @@ mod tests { parse("packages new greeter").unwrap(), Command::PackagesNew { name: "greeter".to_string(), + from: packages::DEFAULT_REFERENCE.to_string(), } ); } + #[test] + fn packages_new_accepts_from_on_either_side_of_the_name() { + for line in [ + "packages new greeter --from example-rust", + "packages new --from example-rust greeter", + ] { + assert_eq!( + parse(line).unwrap(), + Command::PackagesNew { + name: "greeter".to_string(), + from: "example-rust".to_string(), + } + ); + } + } + #[test] fn packages_new_refuses_no_name_and_a_second_argument() { - for line in ["packages new", "packages new greeter extra"] { + for line in [ + "packages new", + "packages new greeter extra", + "packages new greeter --from", + "packages new greeter --from --bad", + "packages new greeter --from example-rust --from example-data", + "packages new --bad greeter", + ] { let error = parse(line).unwrap_err(); assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{line}"); } diff --git a/src/cmd/packages/lifecycle.rs b/src/cmd/packages/lifecycle.rs index dff5bfc..ba36d98 100644 --- a/src/cmd/packages/lifecycle.rs +++ b/src/cmd/packages/lifecycle.rs @@ -863,7 +863,8 @@ pub(crate) mod tests { // A scaffold was copied out of this checkout and renamed. Re-adding it would not bring // back the Package that is being deleted. - let scaffold = ProvenanceStamp::scaffolded("packages/example/", String::new(), 0); + let scaffold = + ProvenanceStamp::scaffolded("example-script", "abcdef".to_string(), String::new(), 0); let name = PackageName::parse("greeter").unwrap(); assert!(recovery_note(&name, Some(&scaffold)).contains("final")); assert!(recovery_note(&name, Some(&missing_kind)).contains("final")); diff --git a/src/cmd/packages/mod.rs b/src/cmd/packages/mod.rs index 1f80cd8..c934e73 100644 --- a/src/cmd/packages/mod.rs +++ b/src/cmd/packages/mod.rs @@ -78,10 +78,15 @@ pub(crate) fn shard_list(shards: &[String]) -> String { } } -/// The folder name of the maintained reference Package, checked into every LyraCore checkout -/// (including the public mirror) at `packages/example/`. `packages new` copies and renames it; see -/// its own doc comment for what a Package's Rust half looks like. -pub const REFERENCE_PACKAGE: &str = "example"; +/// The default Reference Package needs no Rust. +pub const DEFAULT_REFERENCE: &str = "example-script"; +const REFERENCE_PACKAGES: &[&str] = &[ + "example-script", + "example-client", + "example-data", + "example-rust", + "example-all", +]; /// A Package folder name the server build will accept. /// @@ -779,39 +784,45 @@ pub(crate) fn shell_quote(path: &Path) -> String { // `packages new` // ============================================================================================= -/// Scaffold a new Package by copying and renaming the reference Package (`packages/example/`, -/// checked into this checkout). Fetching the template needs no network; the ordinary preflight at -/// the end still uses Cargo's configured cache/network like `lyracore preflight` itself. Nothing -/// came from outside the checkout, so unlike `add` there is no Package Source or trust review to -/// show. -pub fn new(project: &ProjectLayout, runner: &dyn ProcessRunner, name: &str) -> Result<()> { +/// Copy a Reference Package from the collection tag matching this checkout's Package API. +/// Generated artifacts are omitted because renaming their source invalidates their Build Identity. +pub fn new( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + name: &str, + from: &str, +) -> Result<()> { let name = PackageName::parse(name)?; - check_collision(project, &name)?; - - let reference = project.packages_dir().join(REFERENCE_PACKAGE); - if !reference.is_dir() { + if !REFERENCE_PACKAGES.contains(&from) { return Err(Error::Usage(format!( - "no reference Package at {}. `packages new` scaffolds by copying `packages/{}/` out of \ - this checkout, so a checkout missing it cannot scaffold — that is a broken or partial \ - checkout, not a problem with the name '{}'.", - reference.display(), - REFERENCE_PACKAGE, - name.as_str() + "unknown Reference Package '{from}'. Choose one of: {}", + REFERENCE_PACKAGES.join(", ") ))); } - // Validate the complete maintained tree with the same no-links/no-special-files policy as a - // local install, before an enabled destination exists. + let reference_name = PackageName::parse(from)?; + check_collision(project, &name)?; + let collection = official::fetch(project, runner)?; + let reference = official::resolve(collection.path(), &reference_name)?; stamp::content_identity(&reference)?; validate_shape(&reference)?; let destination = project.packages_dir().join(name.as_str()); let mut staged = StagedPackage::new(project, &name)?; copy_tree(&reference, staged.path())?; - rewrite_reference_name(staged.path(), &name)?; + let generated = staged.path().join("data/.generated"); + if generated.is_dir() { + std::fs::remove_dir_all(generated)?; + } + rewrite_reference_name(staged.path(), &reference_name, &name)?; + println!( + "{}", + review::TrustReview::scan(staged.path())?.render(&reference) + ); let identity = stamp::content_identity(staged.path())?; ProvenanceStamp::scaffolded( - &format!("packages/{REFERENCE_PACKAGE}/ (the reference Package)"), + from, + collection.revision().to_string(), identity.clone(), stamp::now_unix(), ) @@ -819,68 +830,83 @@ pub fn new(project: &ProjectLayout, runner: &dyn ProcessRunner, name: &str) -> R let _claim = PackageClaim::acquire(project, &name)?; check_collision(project, &name)?; staged.install(&destination)?; - println!(); println!("scaffolded {} -> {}", name.as_str(), destination.display()); - println!(" from packages/{REFERENCE_PACKAGE}/ (the reference Package)"); + println!(" from {from} in {}", official::COLLECTION_URL); + println!(" revision {}", collection.revision()); println!(" identity {identity}"); - println!(); - println!("running preflight with the Package compiled in"); preflight::run(project, runner).map_err(|e| { Error::Process(format!( - "preflight failed after '{}' was scaffolded, so it has NOT been published and the \ - module on the node is unchanged.\n The Package remains at {}. The failure below may \ - be in its code or in another preflight prerequisite; fix the reported cause and \ - re-run `lyracore preflight`, or undo the scaffold with:\n rm -rf -- {}\n ({e})", + "preflight failed after '{}' was scaffolded. Nothing was published.\n The Package \ + remains at {}. Fix the reported cause and re-run `lyracore preflight`, or remove \ + the scaffold with:\n rm -rf -- {}\n ({e})", name.as_str(), destination.display(), shell_quote(&destination) )) })?; - println!(); - println!( - "'{}' is scaffolded and preflight is green. It has no client/ directory yet, so:", - name.as_str() - ); - println!(" lyracore publish compile the Package into the module and publish it to every database"); - println!( - " lyracore client sync nothing to install yet — add a client/ directory (addons under" - ); - println!( - " client/addons//, overrides under client/mpq/) and re-run" - ); - println!( - "grow the Rust half in packages/{}/src/: wire more hooks from the catalog in", - name.as_str() - ); - println!("module/src/hooks.rs, following the pattern already in its src/mod.rs."); + println!("'{}' is scaffolded and preflight is green.", name.as_str()); + if destination.join("scripts").is_dir() || destination.join("datascripts").is_dir() { + println!(" Choose distinct Script IDs or Package Spell IDs before using this copy beside another."); + println!(" Run `lyracore packages build` to build artifacts for the renamed source."); + println!(" Datascripts need a Base Snapshot from your own client data."); + } + if destination.join("src").is_dir() { + println!(" Publish through the normal realm update to load the Rust half."); + } + if destination.join("client").is_dir() { + println!(" Run `lyracore client sync` to install the client half."); + } Ok(()) } -/// Replace the reference Package's own name inside the copied tree's file contents, so a scaffold -/// named `greeter` does not keep saying `example` in its identifiers. The reference Package is -/// maintained to use the literal word "example" ONLY inside an identifier, never in prose (see its -/// own doc comment), so a whole-file substring replace is exact rather than approximate. A file that -/// is not valid UTF-8 is left untouched — the reference Package ships none, and a future one that -/// did would not be text this rewrite could safely touch anyway. -fn rewrite_reference_name(destination: &Path, name: &PackageName) -> Result<()> { - let ident = name.rust_ident(); - for entry in tree::collect(destination)? { +/// Rename Package names, Rust identifiers and client filenames. Binary assets stay byte-identical. +fn rewrite_reference_name( + destination: &Path, + reference: &PackageName, + name: &PackageName, +) -> Result<()> { + let entries = tree::collect(destination)?; + for entry in &entries { if entry.kind != tree::EntryKind::File || entry.relative == Path::new(stamp::STAMP_FILE) { continue; } match std::fs::read_to_string(&entry.path) { - Ok(text) if text.contains(REFERENCE_PACKAGE) => { - std::fs::write(&entry.path, text.replace(REFERENCE_PACKAGE, &ident))?; + Ok(text) => { + let renamed = text + .replace(&reference.rust_ident(), &name.rust_ident()) + .replace(reference.as_str(), name.as_str()); + if text != renamed { + std::fs::write(&entry.path, renamed)?; + } } - Ok(_) => {} - // A future reference may carry binary client assets; they have no textual identifier - // to rewrite and are copied byte-for-byte. Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {} Err(error) => return Err(error.into()), } } + // Rename children before their containing directory. + for entry in entries.into_iter().rev() { + let filename = entry + .path + .file_name() + .and_then(|part| part.to_str()) + .ok_or_else(|| { + Error::State("Package traversal returned an invalid filename".to_string()) + })?; + let renamed = filename.replace(reference.as_str(), name.as_str()); + if filename != renamed { + let target = entry.path.with_file_name(renamed); + if target.exists() { + return Err(Error::Usage(format!( + "renaming {} would overwrite {}", + entry.path.display(), + target.display() + ))); + } + std::fs::rename(&entry.path, target)?; + } + } Ok(()) } @@ -932,7 +958,7 @@ pub fn list(project: &ProjectLayout) -> Result<()> { /// Where a Package came from and when, as `packages list` and the lifecycle verbs both print it. A /// Package with no stamp says so rather than printing blank fields, and one with no revision (a -/// local folder, a scaffold) leaves that line out rather than printing an empty one. +/// local folder) leaves that line out rather than printing an empty one. pub(crate) fn provenance_report(stamp: Option<&ProvenanceStamp>) -> String { match stamp { Some(recorded) => format!( @@ -1606,46 +1632,48 @@ pub(super) mod tests { // ---- `packages new` ---- - /// A checkout that also carries a reference Package, standing in for the real - /// `packages/example/` this CLI ships in the LyraCore repo. Its source deliberately spells the - /// literal word "example" only inside an identifier, matching the real reference Package's own - /// constraint, so a scaffold test can assert the rename actually happened. - fn checkout_with_reference(tmp: &TempDir) -> ProjectLayout { - let project = checkout(tmp); - let reference = project.packages_dir().join(REFERENCE_PACKAGE).join("src"); - std::fs::create_dir_all(&reference).unwrap(); - std::fs::write( - reference.join("mod.rs"), - "crate::game_hook!(on_group_invite, fn example_on_group_invite(_ctx, _payload) { });\n", - ) - .unwrap(); - project + const COLLECTION_REVISION: &str = "1234567890abcdef1234567890abcdef12345678"; + + fn reference_collection(tmp: &TempDir, rung: &str, files: &[(&str, &[u8])]) -> FakeStack { + let collection = tmp.path().join("collection"); + for (name, bytes) in files { + let path = collection.join(rung).join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + FakeStack::new() + .with_git_clone(&collection) + .with_stdout("rev-parse HEAD", &format!("{COLLECTION_REVISION}\n")) } #[test] - fn a_scaffold_copies_the_reference_renames_it_stamps_it_and_stops_short_of_publishing() { + fn a_script_scaffold_renames_source_and_records_its_rung_and_revision() { let tmp = TempDir::new().unwrap(); - let project = checkout_with_reference(&tmp); - let stack = FakeStack::new(); - - new(&project, &stack.runner(), "greeter").unwrap(); - - let scaffolded = project.packages_dir().join("greeter"); - let source = std::fs::read_to_string(scaffolded.join("src/mod.rs")).unwrap(); - assert!( - source.contains("greeter_on_group_invite"), - "the reference Package's own name must be renamed: {source}" - ); - assert!(!source.contains("example_on_group_invite"), "{source}"); - - let recorded = ProvenanceStamp::read(&scaffolded).expect("no provenance stamp"); + let project = checkout(&tmp); + let stack = reference_collection(&tmp, DEFAULT_REFERENCE, &[ + ("scripts/welcome.ts", b"// @event on_login\n// @id 100300\nfunction script() { send_chat(event.actor, \"example-script\"); }\n"), + ("data/.generated/example-script.script.json", b"old generated artifact"), + ("data/.generated/script.identity", b"old identity"), + ]); + new(&project, &stack.runner(), "my-greeter", DEFAULT_REFERENCE).unwrap(); + let scaffolded = project.packages_dir().join("my-greeter"); + let source = std::fs::read_to_string(scaffolded.join("scripts/welcome.ts")).unwrap(); + assert!(source.contains("my-greeter"), "{source}"); + assert!(!source.contains("example-script"), "{source}"); + assert!(!scaffolded.join("src").exists()); + assert!(!scaffolded.join("data/.generated").exists()); + let recorded = ProvenanceStamp::read(&scaffolded).unwrap(); assert_eq!(recorded.source_kind, stamp::SOURCE_SCAFFOLD); + assert_eq!(recorded.source, "example-script"); + assert_eq!(recorded.revision, COLLECTION_REVISION); assert_eq!( recorded.content_identity, - stamp::content_identity(&scaffolded).unwrap(), - "the stamp must record the identity of what was actually written" + stamp::content_identity(&scaffolded).unwrap() ); - // The remaining steps are PRINTED, never run: nothing here may touch the node. + assert!(stack + .rendered() + .iter() + .any(|call| call.contains("refs/tags/api-v1"))); for call in stack.rendered() { assert!(!call.contains("spacetime publish"), "{call}"); assert!(!call.contains("--pack-client"), "{call}"); @@ -1653,50 +1681,103 @@ pub(super) mod tests { } #[test] - fn a_scaffold_name_either_inventory_already_holds_is_refused_before_anything_is_written() { + fn a_rust_scaffold_keeps_the_package_name_distinct_from_its_rust_identifier() { let tmp = TempDir::new().unwrap(); - let project = checkout_with_reference(&tmp); - std::fs::create_dir_all(project.packages_dir().join("greeter")).unwrap(); - - let error = new(&project, &FakeStack::new().runner(), "greeter").unwrap_err(); - - assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{error}"); - assert!(error.to_string().contains("enabled"), "{error}"); + let project = checkout(&tmp); + let stack = reference_collection( + &tmp, + "example-all", + &[ + ( + "src/mod.rs", + b"const PACKAGE: &str = \"example-all\";\nfn example_all_on_login() {}\n", + ), + ( + "scripts/welcome.lua", + b"-- @event example-all.welcome\n-- @id 100302\nreturn 1\n", + ), + ], + ); + new(&project, &stack.runner(), "my-greeter", "example-all").unwrap(); + let folder = project.packages_dir().join("my-greeter"); + let source = std::fs::read_to_string(folder.join("src/mod.rs")).unwrap(); + assert!(source.contains("\"my-greeter\""), "{source}"); + assert!(source.contains("fn my_greeter_on_login"), "{source}"); + assert!(std::fs::read_to_string(folder.join("scripts/welcome.lua")) + .unwrap() + .contains("@event my-greeter.welcome")); } #[test] - fn an_invalid_scaffold_name_is_refused_before_anything_is_written() { + fn a_client_scaffold_renames_addon_paths_and_preserves_binary_assets() { let tmp = TempDir::new().unwrap(); - let project = checkout_with_reference(&tmp); - - let error = new(&project, &FakeStack::new().runner(), "2fast").unwrap_err(); - - assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{error}"); - assert!(!project.packages_dir().join("2fast").exists(), "{error}"); + let project = checkout(&tmp); + let stack = reference_collection( + &tmp, + "example-client", + &[ + ( + "client/addons/example-client/example-client.toc", + b"example-client.lua\n", + ), + ( + "client/addons/example-client/example-client.lua", + b"print(\"example-client\")\n", + ), + ("client/art/image.blp", &[0xff, 0xfe, 1]), + ], + ); + new(&project, &stack.runner(), "my-greeter", "example-client").unwrap(); + let client = project.packages_dir().join("my-greeter/client"); + assert_eq!( + std::fs::read_to_string(client.join("addons/my-greeter/my-greeter.toc")).unwrap(), + "my-greeter.lua\n" + ); + assert!(client.join("addons/my-greeter/my-greeter.lua").is_file()); + assert_eq!( + std::fs::read(client.join("art/image.blp")).unwrap(), + &[0xff, 0xfe, 1] + ); } #[test] - fn a_checkout_missing_the_reference_package_cannot_scaffold() { - // `checkout()`, not `checkout_with_reference()` — a checkout without `packages/example/` is - // exactly the broken/partial state this error names, not something `new` can paper over. + fn a_scaffold_collision_or_invalid_name_or_rung_never_fetches() { let tmp = TempDir::new().unwrap(); let project = checkout(&tmp); - - let error = new(&project, &FakeStack::new().runner(), "greeter").unwrap_err(); - - assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{error}"); - assert!(error.to_string().contains("reference Package"), "{error}"); - assert!(!project.packages_dir().join("greeter").exists(), "{error}"); + std::fs::create_dir_all(project.packages_dir().join("greeter")).unwrap(); + for (name, rung) in [ + ("greeter", DEFAULT_REFERENCE), + ("2fast", DEFAULT_REFERENCE), + ("fresh", "dungeons"), + ] { + let stack = FakeStack::new(); + let error = new(&project, &stack.runner(), name, rung).unwrap_err(); + assert_eq!(error.exit_code(), crate::error::EXIT_USAGE, "{error}"); + assert!(stack.calls().is_empty()); + } } #[test] - fn a_failed_preflight_after_scaffolding_publishes_nothing_and_says_how_to_undo_it() { + fn a_missing_collection_rung_leaves_no_scaffold() { let tmp = TempDir::new().unwrap(); - let project = checkout_with_reference(&tmp); - let stack = FakeStack::new().fail_on("cargo check", "the scaffold does not compile"); - - let error = new(&project, &stack.runner(), "greeter").unwrap_err(); + let project = checkout(&tmp); + let stack = reference_collection(&tmp, "example-rust", &[("src/mod.rs", b"")]); + let error = new(&project, &stack.runner(), "greeter", DEFAULT_REFERENCE).unwrap_err(); + assert!(error.to_string().contains("example-script"), "{error}"); + assert!(!project.packages_dir().join("greeter").exists()); + } + #[test] + fn a_failed_preflight_keeps_the_scaffold_and_explains_how_to_remove_it() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let stack = reference_collection( + &tmp, + "example-rust", + &[("src/mod.rs", b"fn example_rust() {}")], + ) + .fail_on("cargo check", "the scaffold does not compile"); + let error = new(&project, &stack.runner(), "greeter", "example-rust").unwrap_err(); let scaffolded = project.packages_dir().join("greeter"); assert!(error.to_string().contains("preflight failed"), "{error}"); assert!( @@ -1705,10 +1786,7 @@ pub(super) mod tests { .contains(&format!("rm -rf -- {}", shell_quote(&scaffolded))), "{error}" ); - assert!( - scaffolded.is_dir(), - "the scaffold stays so it can be fixed in place" - ); + assert!(scaffolded.is_dir()); } // ---- `packages list` ---- diff --git a/src/cmd/packages/stamp.rs b/src/cmd/packages/stamp.rs index b47c89c..81c5ba9 100644 --- a/src/cmd/packages/stamp.rs +++ b/src/cmd/packages/stamp.rs @@ -29,10 +29,8 @@ pub const SOURCE_LOCAL: &str = "local"; /// is the repository URL and its [`revision`](ProvenanceStamp::revision) is the exact commit that /// was installed, which is what `packages update` advances from. pub const SOURCE_GIT: &str = "git"; -/// The kind `packages new` records. A scaffolded Package has no Package Source — nothing the -/// operator chose and could drift from — so this is a distinct kind from [`SOURCE_LOCAL`] rather -/// than a `local()` stamp pointed at the reference Package, which would wrongly claim the operator -/// picked and reviewed a source folder. +/// A scaffold records its Reference Package and collection revision. It is authored locally after +/// copying, so `packages update` must not replace it from the collection. pub const SOURCE_SCAFFOLD: &str = "scaffold"; /// The kind an install from the Official Package Collection records. Its /// [`source`](ProvenanceStamp::source) is the collection repository's URL (see @@ -51,11 +49,10 @@ pub const SOURCE_OFFICIAL: &str = "official"; pub struct ProvenanceStamp { /// [`SOURCE_LOCAL`], [`SOURCE_GIT`], [`SOURCE_SCAFFOLD`] or [`SOURCE_OFFICIAL`]. pub source_kind: String, - /// The absolute folder the Package was copied FROM, or the repository URL it was cloned from. + /// The source folder, repository URL, or Reference Package rung for a scaffold. pub source: String, /// The exact commit a Git Package Source or an Official Package Source was installed at. Empty - /// for a local folder and a scaffold: neither has a revision, so there is nothing for - /// `packages update` to advance from and nothing to report. + /// for a local folder. A scaffold records its collection revision but cannot be updated. pub revision: String, /// [`content_identity`] of the copied tree at install time. pub content_identity: String, @@ -106,14 +103,17 @@ impl ProvenanceStamp { } } - /// The stamp a scaffold (`packages new`) writes. `reference` names what it was scaffolded from - /// (e.g. `"packages/example/ (the reference Package)"`) — descriptive text, not a location the - /// operator chose, which is what [`SOURCE_LOCAL`] would wrongly imply. - pub fn scaffolded(reference: &str, content_identity: String, now: u64) -> Self { + /// A scaffold retains the Reference Package name and exact collection revision. + pub fn scaffolded( + reference: &str, + revision: String, + content_identity: String, + now: u64, + ) -> Self { Self { source_kind: SOURCE_SCAFFOLD.to_string(), source: reference.to_string(), - revision: String::new(), + revision, content_identity, installed_at: utc_rfc3339(now), } @@ -138,8 +138,7 @@ impl ProvenanceStamp { Ok(()) } - /// The file's own text. `revision` is written only when there is one, so a local install and a - /// scaffold keep the four keys they have always had rather than gaining an empty fifth. + /// Write `revision` only when one is recorded. Local folders have no revision. pub fn render(&self) -> String { let written_by = match self.source_kind.as_str() { SOURCE_LOCAL => "`lyracore packages add`", @@ -338,7 +337,8 @@ mod tests { fn a_scaffold_stamp_is_a_distinct_kind_from_a_local_install() { let tmp = TempDir::new().unwrap(); let stamp = ProvenanceStamp::scaffolded( - "packages/example/ (the reference Package)", + "example-rust", + "1234567890abcdef".to_string(), "fnv1a64-tree-v1:0123456789abcdef".to_string(), 1_756_000_000, ); @@ -348,7 +348,8 @@ mod tests { assert_eq!(ProvenanceStamp::read(tmp.path()), Some(stamp.clone())); assert_eq!(stamp.source_kind, SOURCE_SCAFFOLD); assert_ne!(stamp.source_kind, SOURCE_LOCAL); - assert_eq!(stamp.source, "packages/example/ (the reference Package)"); + assert_eq!(stamp.source, "example-rust"); + assert_eq!(stamp.revision, "1234567890abcdef"); } #[test] @@ -412,7 +413,8 @@ mod tests { fn the_rendered_stamp_names_the_command_that_actually_wrote_it() { // A scaffold was never installed by `packages add` — the file's own header must say so, // not just its `source_kind` key, since the header is what a human reads first. - let scaffolded = ProvenanceStamp::scaffolded("packages/example/", String::new(), 0); + let scaffolded = + ProvenanceStamp::scaffolded("example-script", "abcdef".to_string(), String::new(), 0); assert!(scaffolded.render().contains("lyracore packages new")); assert!(!scaffolded.render().contains("lyracore packages add")); From 820ec1a82fcd209589c1eb62988158b76f8bdddb Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:26:04 +0200 Subject: [PATCH 2/3] fix(packages): give scaffolds their own build instructions --- src/cmd/packages/mod.rs | 46 +++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/src/cmd/packages/mod.rs b/src/cmd/packages/mod.rs index c934e73..8fa11c1 100644 --- a/src/cmd/packages/mod.rs +++ b/src/cmd/packages/mod.rs @@ -814,6 +814,16 @@ pub fn new( std::fs::remove_dir_all(generated)?; } rewrite_reference_name(staged.path(), &reference_name, &name)?; + let next_steps = scaffold_next_steps(staged.path()); + std::fs::write( + staged.path().join("README.md"), + format!( + "# {}\n\nScaffolded from [{}]({}/tree/{}/{}) at the collection revision matching this \ + checkout's Package API. Read that Reference Package for how its code works.\n\n\ + This Package is already installed locally. Edit the source in this folder.\n\n{next_steps}", + name.as_str(), from, official::COLLECTION_URL, collection.revision(), from + ), + )?; println!( "{}", review::TrustReview::scan(staged.path())?.render(&reference) @@ -847,18 +857,31 @@ pub fn new( })?; println!("'{}' is scaffolded and preflight is green.", name.as_str()); - if destination.join("scripts").is_dir() || destination.join("datascripts").is_dir() { - println!(" Choose distinct Script IDs or Package Spell IDs before using this copy beside another."); - println!(" Run `lyracore packages build` to build artifacts for the renamed source."); - println!(" Datascripts need a Base Snapshot from your own client data."); + print!("{next_steps}"); + Ok(()) +} + +fn scaffold_next_steps(package_dir: &Path) -> String { + let mut text = String::new(); + if package_dir.join("scripts").is_dir() || package_dir.join("datascripts").is_dir() { + text.push_str("Generated artifacts are omitted. Choose distinct Script IDs or Package Spell IDs \ + before using this copy beside another. Run `lyracore packages build` to build artifacts \ + for the renamed source, then apply them with `lyracore packages replay` on your development \ + topology.\n\n"); + } + if package_dir.join("datascripts").is_dir() { + text.push_str( + "The Datascript needs a Base Snapshot from your own client data. Keep its \ + generated Delta local.\n\n", + ); } - if destination.join("src").is_dir() { - println!(" Publish through the normal realm update to load the Rust half."); + if package_dir.join("src").is_dir() { + text.push_str("Publish through the normal realm update to load the Rust half.\n\n"); } - if destination.join("client").is_dir() { - println!(" Run `lyracore client sync` to install the client half."); + if package_dir.join("client").is_dir() { + text.push_str("Run `lyracore client sync` to install the client half.\n\n"); } - Ok(()) + text } /// Rename Package names, Rust identifiers and client filenames. Binary assets stay byte-identical. @@ -1662,6 +1685,11 @@ pub(super) mod tests { assert!(!source.contains("example-script"), "{source}"); assert!(!scaffolded.join("src").exists()); assert!(!scaffolded.join("data/.generated").exists()); + let readme = std::fs::read_to_string(scaffolded.join("README.md")).unwrap(); + assert!(readme.contains("packages build")); + assert!(readme.contains(&format!("/tree/{COLLECTION_REVISION}/example-script"))); + assert!(!readme.contains("packages add")); + let recorded = ProvenanceStamp::read(&scaffolded).unwrap(); assert_eq!(recorded.source_kind, stamp::SOURCE_SCAFFOLD); assert_eq!(recorded.source, "example-script"); From 950a03ff593722109482ab6dd496e94148ecf709 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:27:39 +0200 Subject: [PATCH 3/3] fix(packages): check scaffold names against artifact limits --- docs/commands.md | 3 ++ src/cmd/packages/mod.rs | 79 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 2755634..c121962 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -309,6 +309,9 @@ client filenames, prints its Trust Review and runs `preflight`. The Provenance S `scaffold` kind and records the chosen rung and exact collection revision. `packages update` does not replace scaffolded code. +Rungs with Runtime Scripts or Datascripts need a lowercase Package name of at most 64 characters. +Each `.