From eb70fb5d6950f784be59f88574cfa89028c36762 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Tue, 27 Oct 2026 18:27:47 +0100 Subject: [PATCH 1/4] MCP: add_example + create_app {examples} with a shared apply engine (#apply-examples) One tool call turns an app into a working feature: create_app seeds examples before the first commit; add_example resolves requires, copies files, merges nav/bindings/deps and commits through the same commit + gate + deploy path as commit. examples {name} now returns the README and file list (contents with files: true); contents travel in the text block only, never duplicated. Rust engine crates/outer/src/examples/apply.rs follows the contract exactly; golden fixtures in crates/outer/tests/fixtures/apply/ pin the formatting rules for the TS engine to match. --- crates/outer/src/examples/apply.rs | 606 ++++++++++++++++++ crates/outer/src/examples/apply/tests.rs | 505 +++++++++++++++ .../src/{examples.rs => examples/mod.rs} | 141 +++- crates/outer/src/mcp/mod.rs | 2 +- crates/outer/src/mcp/registry.rs | 23 +- crates/outer/src/mcp/tests.rs | 381 ++++++++++- crates/outer/src/mcp/tools/account.rs | 4 +- crates/outer/src/mcp/tools/add_example.rs | 217 +++++++ crates/outer/src/mcp/tools/apps.rs | 166 ++++- crates/outer/src/mcp/tools/code.rs | 5 + crates/outer/src/mcp/tools/examples.rs | 177 +++-- crates/outer/src/mcp/tools/mod.rs | 4 + crates/outer/src/mcp/tools/open_app.rs | 2 +- crates/outer/src/templates.rs | 36 +- crates/outer/tests/fixtures/apply/README.md | 45 ++ .../outer/tests/fixtures/apply/combined.json | 1 + .../outer/tests/fixtures/apply/conflict.json | 1 + .../tests/fixtures/apply/missing-marker.json | 1 + web/docs/agents/start-here/examples.md | 55 +- web/docs/agents/start-here/quickstart.md | 2 +- web/docs/agents/start-here/templates.md | 4 +- web/docs/reference/tools.md | 19 +- 22 files changed, 2280 insertions(+), 117 deletions(-) create mode 100644 crates/outer/src/examples/apply.rs create mode 100644 crates/outer/src/examples/apply/tests.rs rename crates/outer/src/{examples.rs => examples/mod.rs} (85%) create mode 100644 crates/outer/src/mcp/tools/add_example.rs create mode 100644 crates/outer/tests/fixtures/apply/README.md create mode 100644 crates/outer/tests/fixtures/apply/combined.json create mode 100644 crates/outer/tests/fixtures/apply/conflict.json create mode 100644 crates/outer/tests/fixtures/apply/missing-marker.json diff --git a/crates/outer/src/examples/apply.rs b/crates/outer/src/examples/apply.rs new file mode 100644 index 00000000..0c1a0f7f --- /dev/null +++ b/crates/outer/src/examples/apply.rs @@ -0,0 +1,606 @@ +//! Plan an example apply onto an app tree: the pure half of `add_example`. +//! +//! [`plan_apply`] takes the app's head tree (path → full text), the app's +//! template id, the known examples, and the requested names, and returns the +//! file writes for one commit — or a refusal naming what to fix by hand. +//! No filesystem or git here, so the TypeScript apply engine (slice T) and +//! this one stay byte-identical on the same inputs: every formatting rule is +//! pinned below and by the golden fixtures in `tests/fixtures/apply/`. +//! +//! Formatting rules (both engines must match exactly): +//! +//! - Nav items insert above the `// outer:nav-items` marker at the marker's +//! own indentation, one per line: +//! `{ href: "/photos", label: "Photos", icon: Camera },`. +//! - Nav icons merge into the braces on the `// outer:nav-icons` line, +//! deduplicated and sorted byte-wise, joined with `", "`. +//! - Binding lines insert above the `// outer:bindings` marker at the +//! marker's own indentation, one key per line: +//! `r2_buckets: [{ binding: "PHOTOS", bucket_name: "photos" }],`. +//! Object keys sort byte-wise; entries join with `", "`. Keys insert in +//! byte-wise order. +//! - `package.json` is re-serialized whole: all keys sorted byte-wise, +//! 2-space indent, one trailing newline. New dependencies keep their given +//! versions; an existing different version refuses. +//! - Migrations (`db/migrations/NNNN_*.sql`) renumber to the next free +//! numbers after the app's highest existing migration number, keeping +//! their relative (byte-wise path) order. Numbers pad to at least 4 +//! digits (`1` → `0001`). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt; + +/// One nav entry an example declares in `example.json`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavItem { + pub href: String, + pub label: String, + pub icon: String, +} + +/// One example as the apply engine sees it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExampleInput { + pub name: String, + /// `"app"` or `"website"`. + pub template: String, + /// Other examples, applied first when missing. + pub requires: Vec, + /// App-only nav entries. + pub nav: Vec, + /// `d1_databases` / `r2_buckets`: key → entries of string pairs. + pub bindings: BTreeMap>>, + pub dependencies: BTreeMap, + /// App-relative path → full text, byte-wise sorted. + pub files: Vec<(String, String)>, +} + +/// What one successful apply writes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApplyPlan { + /// Newly applied example names, requires first. + pub added: Vec, + /// Every written path, byte-wise sorted. + pub files: Vec, + /// Nav hrefs inserted by this apply. + pub nav: Vec, + /// Path → new full text. + pub writes: Vec<(String, String)>, +} + +/// A refusal: invalid input or a tree the engine will not touch. The message +/// names the file and the manual step; tools surface it as `invalid_argument`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal(pub String); + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Refusal {} + +fn refuse(message: impl Into) -> Refusal { + Refusal(message.into()) +} + +pub fn plan_apply( + tree: &BTreeMap, + app_template: &str, + examples: &[ExampleInput], + requested: &[&str], +) -> Result { + let known: HashMap<&str, &ExampleInput> = examples + .iter() + .map(|example| (example.name.as_str(), example)) + .collect(); + for name in requested { + if !known.contains_key(name) { + let mut valid: Vec<&str> = known.keys().copied().collect(); + valid.sort_unstable(); + return Err(refuse(format!( + "unknown example {name:?}; pick one of: {}", + valid.join(", ") + ))); + } + } + let order = resolve(requested, &known)?; + for example in &order { + if example.template != app_template { + return Err(refuse(format!( + "example {:?} is for template {:?}, not {:?}", + example.name, example.template, app_template + ))); + } + } + let fresh: Vec<&ExampleInput> = order + .iter() + .copied() + .filter(|example| !is_applied(tree, example)) + .collect(); + if fresh.is_empty() { + let names: Vec<&str> = order.iter().map(|example| example.name.as_str()).collect(); + return Err(refuse(format!( + "{} {} already applied: every file matches, nothing to do", + names.join(", "), + if names.len() == 1 { "is" } else { "are" }, + ))); + } + let mut writes: BTreeMap = BTreeMap::new(); + let mut nav: Vec = Vec::new(); + // Migrations renumber against the app's highest existing number, shared + // across every example in this apply. + let mut next_migration = highest_migration(tree) + 1; + for example in &fresh { + copy_files(tree, example, &mut writes, &mut next_migration)?; + } + for example in &fresh { + if !example.nav.is_empty() { + if app_template != "app" { + return Err(refuse(format!( + "example {:?} declares nav but the {:?} template has no nav file", + example.name, app_template + ))); + } + // Earlier examples in this apply win the read: prefer the + // accumulated writes over the head tree. + let current = writes + .get("pages/_nav.ts") + .or_else(|| tree.get("pages/_nav.ts")) + .ok_or_else(|| refuse("pages/_nav.ts is missing: restore it, then retry"))?; + let (updated, inserted) = apply_nav(current, &example.nav, &example.name)?; + if !inserted.is_empty() { + writes.insert("pages/_nav.ts".to_string(), updated); + nav.extend(inserted); + } + } + if !example.bindings.is_empty() { + let current = writes + .get("void.config.ts") + .or_else(|| tree.get("void.config.ts")) + .ok_or_else(|| refuse("void.config.ts is missing: restore it, then retry"))?; + let updated = apply_bindings(current, &example.bindings, &example.name)?; + writes.insert("void.config.ts".to_string(), updated); + } + if !example.dependencies.is_empty() { + let current = writes + .get("package.json") + .or_else(|| tree.get("package.json")) + .ok_or_else(|| refuse("package.json is missing: restore it, then retry"))?; + let updated = apply_package_json(current, &example.dependencies)?; + writes.insert("package.json".to_string(), updated); + } + } + let files: Vec = writes.keys().cloned().collect(); + Ok(ApplyPlan { + added: fresh.iter().map(|example| example.name.clone()).collect(), + files, + nav, + writes: writes.into_iter().collect(), + }) +} + +/// Depth-first `requires` resolution: dependencies first, de-duplicated, +/// request order kept. Refuses cycles and chains deeper than 3. +fn resolve<'a>( + requested: &[&str], + known: &HashMap<&str, &'a ExampleInput>, +) -> Result, Refusal> { + fn visit<'a>( + name: &str, + known: &HashMap<&str, &'a ExampleInput>, + order: &mut Vec<&'a ExampleInput>, + seen: &mut HashSet, + stack: &mut Vec, + depth: usize, + ) -> Result<(), Refusal> { + if seen.contains(name) { + return Ok(()); + } + if stack.contains(&name.to_string()) { + let mut cycle = stack.clone(); + cycle.push(name.to_string()); + return Err(refuse(format!("requires cycle: {}", cycle.join(" -> ")))); + } + if depth > 3 { + return Err(refuse(format!( + "requires chain for {name:?} is deeper than 3" + ))); + } + let example = known + .get(name) + .ok_or_else(|| refuse(format!("unknown example {name:?}")))?; + stack.push(name.to_string()); + for dep in &example.requires { + visit(dep, known, order, seen, stack, depth + 1)?; + } + stack.pop(); + seen.insert(name.to_string()); + order.push(example); + Ok(()) + } + let mut order = Vec::new(); + let mut seen = HashSet::new(); + let mut stack = Vec::new(); + for name in requested { + visit(name, known, &mut order, &mut seen, &mut stack, 0)?; + } + Ok(order) +} + +/// An example counts as applied when every one of its non-migration files +/// already exists in the tree with identical content. +fn is_applied(tree: &BTreeMap, example: &ExampleInput) -> bool { + example + .files + .iter() + .filter(|(path, _)| !is_migration_path(path)) + .all(|(path, text)| tree.get(path).is_some_and(|have| have == text)) +} + +fn is_migration_path(path: &str) -> bool { + migration_number(path).is_some() +} + +/// The leading number of a `db/migrations/NNNN_*.sql` path, if it is one. +fn migration_number(path: &str) -> Option { + let rest = path.strip_prefix("db/migrations/")?; + if rest.contains('/') || !rest.ends_with(".sql") { + return None; + } + let stem = rest.strip_suffix(".sql")?; + let (digits, _) = stem.split_once('_')?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn highest_migration(tree: &BTreeMap) -> u64 { + tree.keys() + .filter_map(|path| migration_number(path)) + .max() + .unwrap_or(0) +} + +/// Copy an example's files: migrations renumber, other paths must be new or +/// byte-identical. +fn copy_files( + tree: &BTreeMap, + example: &ExampleInput, + writes: &mut BTreeMap, + next_migration: &mut u64, +) -> Result<(), Refusal> { + let mut migrations: Vec<&(String, String)> = Vec::new(); + let mut plain: Vec<&(String, String)> = Vec::new(); + for file in &example.files { + if is_migration_path(&file.0) { + migrations.push(file); + } else { + plain.push(file); + } + } + for (path, text) in plain { + if let Some(have) = tree.get(path).or_else(|| writes.get(path)) { + if have != text { + return Err(refuse(format!( + "{path} already exists with different content: move it aside or merge it by hand, then retry" + ))); + } + } else { + writes.insert(path.clone(), text.clone()); + } + } + migrations.sort_by(|a, b| a.0.cmp(&b.0)); + for (path, text) in migrations { + let suffix = path + .strip_prefix("db/migrations/") + .unwrap_or(path) + .split_once('_') + .map(|(_, rest)| rest) + .unwrap_or(path.as_str()); + let dest = format!("db/migrations/{:04}_{suffix}", *next_migration); + *next_migration += 1; + if let Some(have) = tree.get(&dest).or_else(|| writes.get(&dest)) { + if have != text { + return Err(refuse(format!( + "{dest} already exists with different content: move it aside or merge it by hand, then retry" + ))); + } + } else { + writes.insert(dest, text.clone()); + } + } + Ok(()) +} + +/// Is `name` a valid Lucide export for the nav-icons line. +pub fn is_valid_icon(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.len() < 2 || bytes.len() > 41 || !bytes[0].is_ascii_uppercase() { + return false; + } + bytes.iter().all(|b| b.is_ascii_alphanumeric()) +} + +/// Merge `items` into `pages/_nav.ts` at its marker lines. +fn apply_nav( + nav_ts: &str, + items: &[NavItem], + example: &str, +) -> Result<(String, Vec), Refusal> { + let manual = |marker: &str| { + refuse(format!( + "pages/_nav.ts has no {marker} marker (the agent restructured it): add the {example} nav items by hand — see the {example} README's \"Without add_example\" section" + )) + }; + let icons_idx = nav_ts + .lines() + .position(|line| line.contains("outer:nav-icons")) + .ok_or_else(|| manual("// outer:nav-icons"))?; + let items_idx = nav_ts + .lines() + .position(|line| line.contains("outer:nav-items")) + .ok_or_else(|| manual("// outer:nav-items"))?; + let mut lines: Vec = nav_ts.lines().map(str::to_string).collect(); + let trailing_newline = nav_ts.ends_with('\n'); + // Hrefs already in the file are skipped. + let mut have_hrefs: HashSet = HashSet::new(); + for line in &lines { + for href in scan_hrefs(line) { + have_hrefs.insert(href); + } + } + let fresh: Vec<&NavItem> = items + .iter() + .filter(|item| !have_hrefs.contains(&item.href)) + .collect(); + if fresh.is_empty() { + return Ok((nav_ts.to_string(), Vec::new())); + } + // Merge icons into the braces on the marker line, deduplicated + sorted. + let icons_line = lines[icons_idx].clone(); + let (open, close) = (icons_line.find('{'), icons_line.find('}')); + let (Some(open), Some(close)) = (open, close) else { + return Err(manual("// outer:nav-icons")); + }; + if open >= close { + return Err(manual("// outer:nav-icons")); + } + let mut icons: HashSet = icons_line[open + 1..close] + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .collect(); + for item in &fresh { + icons.insert(item.icon.clone()); + } + let mut sorted: Vec = icons.into_iter().collect(); + sorted.sort(); + lines[icons_idx] = format!( + "{}{{ {} }}{}", + &icons_line[..open], + sorted.join(", "), + &icons_line[close + 1..] + ); + // Insert items above the marker at the marker's own indentation. + let indent: String = lines[items_idx] + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let mut inserted = Vec::with_capacity(fresh.len()); + for (i, item) in fresh.iter().enumerate() { + lines.insert( + items_idx + i, + format!( + "{indent}{{ href: {:?}, label: {:?}, icon: {} }},", + item.href, item.label, item.icon + ), + ); + inserted.push(item.href.clone()); + } + let mut out = lines.join("\n"); + if trailing_newline { + out.push('\n'); + } + Ok((out, inserted)) +} + +/// Scan a line for `href: "..."` values. +fn scan_hrefs(line: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = line; + while let Some(pos) = rest.find("href") { + rest = &rest[pos + 4..]; + let value = rest.trim_start().strip_prefix(':'); + let Some(value) = value else { continue }; + let value = value.trim_start(); + if let Some(quoted) = value.strip_prefix('"') { + if let Some(end) = quoted.find('"') { + out.push(quoted[..end].to_string()); + rest = "ed[end + 1..]; + } else { + break; + } + } + } + out +} + +/// Merge `bindings` into `void.config.ts` at its marker line. An existing +/// key wins: skipped when equal, refused when different. +fn apply_bindings( + config: &str, + bindings: &BTreeMap>>, + example: &str, +) -> Result { + let marker_idx = config + .lines() + .position(|line| line.contains("outer:bindings")) + .ok_or_else(|| { + refuse(format!( + "void.config.ts has no // outer:bindings marker (the agent restructured it): add the {example} bindings by hand — see the {example} README's \"Without add_example\" section" + )) + })?; + let mut lines: Vec = config.lines().map(str::to_string).collect(); + let trailing_newline = config.ends_with('\n'); + let indent: String = lines[marker_idx] + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let mut fresh: Vec<(String, String)> = Vec::new(); + for (key, entries) in bindings { + let rendered = render_binding(key, entries); + match find_key_value(&lines, key) { + None => fresh.push((key.clone(), rendered)), + Some(have) => { + // Compare bracket-to-bracket: the stored value against the + // rendered entries, ignoring surrounding whitespace. + let want = rendered.split_once('[').map(|(_, rest)| rest).unwrap_or(""); + let want = want.trim_end_matches(','); + if normalize(&have) != normalize(&format!("[{want}")) { + return Err(refuse(format!( + "void.config.ts already declares {key} with a different value ({have}): the one-D1 / one-R2 cap allows a single entry — align it by hand, then retry" + ))); + } + } + } + } + fresh.sort_by(|a, b| a.0.cmp(&b.0)); + for (i, (_, rendered)) in fresh.iter().enumerate() { + lines.insert(marker_idx + i, format!("{indent}{rendered}")); + } + let mut out = lines.join("\n"); + if trailing_newline { + out.push('\n'); + } + Ok(out) +} + +fn render_binding(key: &str, entries: &[BTreeMap]) -> String { + let parts: Vec = entries + .iter() + .map(|entry| { + let fields: Vec = entry.iter().map(|(k, v)| format!("{k}: {v:?}")).collect(); + format!("{{ {} }}", fields.join(", ")) + }) + .collect(); + format!("{key}: [{}],", parts.join(", ")) +} + +/// The value after `key:` in `lines`, from its `[` to the matching `]`, +/// if the key appears at all. +fn find_key_value(lines: &[String], key: &str) -> Option { + let mut start: Option<(usize, usize)> = None; + for (i, line) in lines.iter().enumerate() { + if let Some(pos) = find_key(line, key) { + if line[pos..].contains('[') { + start = Some((i, pos)); + break; + } + } + } + let (row, _) = start?; + // Bracket-match from the first `[` on the key's line. + let mut depth = 0; + let mut begun = false; + let mut value = String::new(); + for (offset, line) in lines[row..].iter().enumerate() { + let from = if offset == 0 { + lines[row].find('[').unwrap_or(0) + } else { + 0 + }; + for c in line[from..].chars() { + if c == '[' { + depth += 1; + begun = true; + } + if begun { + value.push(c); + } + if c == ']' { + depth -= 1; + if begun && depth == 0 { + return Some(value); + } + } + } + if begun { + value.push('\n'); + } + } + None +} + +/// `key` as an object key on `line`: word-boundary match followed by `:`. +fn find_key(line: &str, key: &str) -> Option { + let mut from = 0; + while let Some(pos) = line[from..].find(key) { + let abs = from + pos; + let before_ok = abs == 0 + || !line[..abs] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$'); + let after: Vec = line[abs + key.len()..].chars().collect(); + let mut after = after.into_iter().skip_while(|c| c.is_whitespace()); + if before_ok && after.next() == Some(':') { + return Some(abs); + } + from = abs + 1; + } + None +} + +fn normalize(value: &str) -> String { + value.chars().filter(|c| !c.is_whitespace()).collect() +} + +/// Merge `deps` into `package.json`'s `dependencies`. An existing different +/// version refuses. The file re-serializes whole: keys sorted, 2-space +/// indent, one trailing newline. +fn apply_package_json( + package_json: &str, + deps: &BTreeMap, +) -> Result { + let mut value: serde_json::Value = serde_json::from_str(package_json) + .map_err(|err| refuse(format!("package.json is not valid JSON: {err}")))?; + let Some(obj) = value.as_object_mut() else { + return Err(refuse("package.json is not a JSON object".to_string())); + }; + let entry = obj + .entry("dependencies".to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); + let Some(current) = entry.as_object_mut() else { + return Err(refuse( + "package.json \"dependencies\" is not an object".to_string(), + )); + }; + for (name, version) in deps { + match current.get(name) { + None => { + current.insert(name.clone(), serde_json::Value::String(version.clone())); + } + Some(have) => { + if have.as_str() != Some(version.as_str()) { + return Err(refuse(format!( + "package.json already depends on {name}@{have} (want {version}): align it by hand, then retry", + have = have + .as_str() + .unwrap_or(&have.to_string()) + .trim_matches('"') + ))); + } + } + } + } + let mut out = serde_json::to_string_pretty(&value) + .map_err(|err| refuse(format!("package.json failed to serialize: {err}")))?; + out.push('\n'); + Ok(out) +} + +#[cfg(test)] +mod tests; diff --git a/crates/outer/src/examples/apply/tests.rs b/crates/outer/src/examples/apply/tests.rs new file mode 100644 index 00000000..5668121a --- /dev/null +++ b/crates/outer/src/examples/apply/tests.rs @@ -0,0 +1,505 @@ +//! Behaviour tests for the apply engine: the contract's formatting rules. +//! +//! The `golden/` directory holds the same cases as JSON so the TypeScript +//! engine (slice T) tests against identical inputs; [`golden_cases`] runs +//! them through [`plan_apply`](super::plan_apply). + +use super::*; +use std::collections::BTreeMap; + +fn tree(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(path, text)| (path.to_string(), text.to_string())) + .collect() +} + +fn example(name: &str, template: &str, files: &[(&str, &str)]) -> ExampleInput { + ExampleInput { + name: name.to_string(), + template: template.to_string(), + requires: Vec::new(), + nav: Vec::new(), + bindings: BTreeMap::new(), + dependencies: BTreeMap::new(), + files: files + .iter() + .map(|(path, text)| (path.to_string(), text.to_string())) + .collect(), + } +} + +const NAV_TS: &str = "import { House, Settings } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n // outer:nav-items — add_example inserts pages above this line\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n"; +const VOID_CONFIG: &str = "export default {\n cloudflare: {\n // outer:bindings — add_example inserts bindings above this line\n },\n};\n"; +const PACKAGE_JSON: &str = "{\n \"name\": \"seed\",\n \"private\": true\n}\n"; + +fn app_tree() -> BTreeMap { + tree(&[ + ("pages/_nav.ts", NAV_TS), + ("void.config.ts", VOID_CONFIG), + ("package.json", PACKAGE_JSON), + ]) +} + +#[test] +fn copies_files_and_tracks_added() { + let tree = app_tree(); + let example = example( + "list-form", + "app", + &[("pages/list.tsx", "export default 1;\n")], + ); + let plan = plan_apply(&tree, "app", &[example], &["list-form"]).unwrap(); + assert_eq!(plan.added, vec!["list-form".to_string()]); + assert_eq!(plan.files, vec!["pages/list.tsx".to_string()]); + assert!(plan.nav.is_empty()); + assert_eq!( + plan.writes, + vec![( + "pages/list.tsx".to_string(), + "export default 1;\n".to_string() + )] + ); +} + +#[test] +fn wrong_template_refuses() { + let tree = app_tree(); + let example = example("blog", "website", &[("src/x.astro", "x\n")]); + let err = plan_apply(&tree, "app", &[example], &["blog"]).unwrap_err(); + assert!(err.0.contains("website"), "unexpected: {}", err.0); +} + +#[test] +fn conflicting_file_refuses_with_path() { + let mut tree = app_tree(); + tree.insert("pages/list.tsx".to_string(), "different\n".to_string()); + let example = example( + "list-form", + "app", + &[("pages/list.tsx", "export default 1;\n")], + ); + let err = plan_apply(&tree, "app", &[example], &["list-form"]).unwrap_err(); + assert!(err.0.contains("pages/list.tsx"), "unexpected: {}", err.0); +} + +#[test] +fn identical_files_count_as_applied() { + let mut tree = app_tree(); + tree.insert( + "pages/list.tsx".to_string(), + "export default 1;\n".to_string(), + ); + let example = example( + "list-form", + "app", + &[("pages/list.tsx", "export default 1;\n")], + ); + let err = plan_apply(&tree, "app", &[example], &["list-form"]).unwrap_err(); + assert!(err.0.contains("already applied"), "unexpected: {}", err.0); +} + +#[test] +fn requires_resolve_depth_first_and_deduped() { + let tree = app_tree(); + let passkeys = example("passkeys", "app", &[("pages/sign-in.tsx", "s\n")]); + let mut photos = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + photos.requires = vec!["passkeys".to_string()]; + let plan = plan_apply(&tree, "app", &[passkeys, photos], &["photos"]).unwrap(); + assert_eq!( + plan.added, + vec!["passkeys".to_string(), "photos".to_string()] + ); + // Requesting both keeps one copy, requires first. + let passkeys = example("passkeys", "app", &[("pages/sign-in.tsx", "s\n")]); + let mut photos = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + photos.requires = vec!["passkeys".to_string()]; + let plan = plan_apply(&tree, "app", &[passkeys, photos], &["photos", "passkeys"]).unwrap(); + assert_eq!( + plan.added, + vec!["passkeys".to_string(), "photos".to_string()] + ); +} + +#[test] +fn requires_cycle_refuses() { + let tree = app_tree(); + let mut one = example("one", "app", &[("a.txt", "a\n")]); + let mut two = example("two", "app", &[("b.txt", "b\n")]); + one.requires = vec!["two".to_string()]; + two.requires = vec!["one".to_string()]; + let err = plan_apply(&tree, "app", &[one, two], &["one"]).unwrap_err(); + assert!(err.0.contains("cycle"), "unexpected: {}", err.0); +} + +#[test] +fn requires_chain_past_depth_3_refuses() { + let tree = app_tree(); + let mk = |name: &str, dep: Option<&str>| { + let mut example = example(name, "app", &[(name, "x\n")]); + example.requires = dep.map(|dep| vec![dep.to_string()]).unwrap_or_default(); + example + }; + let examples = vec![ + mk("e0", Some("e1")), + mk("e1", Some("e2")), + mk("e2", Some("e3")), + mk("e3", Some("e4")), + mk("e4", None), + ]; + let err = plan_apply(&tree, "app", &examples, &["e0"]).unwrap_err(); + assert!(err.0.contains("deeper than 3"), "unexpected: {}", err.0); +} + +#[test] +fn unknown_example_names_valid_choice() { + let tree = app_tree(); + let example = example("passkeys", "app", &[("a.txt", "a\n")]); + let err = plan_apply(&tree, "app", &[example], &["nope"]).unwrap_err(); + assert!(err.0.contains("passkeys"), "unexpected: {}", err.0); +} + +#[test] +fn migrations_renumber_after_highest() { + let mut tree = app_tree(); + tree.insert( + "db/migrations/0002_first.sql".to_string(), + "CREATE TABLE t (id);\n".to_string(), + ); + tree.insert( + "db/migrations/0005_second.sql".to_string(), + "ALTER TABLE t;\n".to_string(), + ); + let example = example( + "newsletter", + "app", + &[ + ("db/migrations/0001_sub.sql", "A\n"), + ("db/migrations/0003_unsub.sql", "B\n"), + ("src/sub.ts", "s\n"), + ], + ); + let plan = plan_apply(&tree, "app", &[example], &["newsletter"]).unwrap(); + let writes: HashMap = plan.writes.into_iter().collect(); + assert_eq!( + writes.get("db/migrations/0006_sub.sql").map(String::as_str), + Some("A\n") + ); + assert_eq!( + writes + .get("db/migrations/0007_unsub.sql") + .map(String::as_str), + Some("B\n") + ); + assert_eq!(writes.get("src/sub.ts").map(String::as_str), Some("s\n")); +} + +#[test] +fn nav_inserts_items_and_merges_icons() { + let tree = app_tree(); + let mut example = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + example.nav = vec![NavItem { + href: "/photos".to_string(), + label: "Photos".to_string(), + icon: "Camera".to_string(), + }]; + let plan = plan_apply(&tree, "app", &[example], &["photos"]).unwrap(); + assert_eq!(plan.nav, vec!["/photos".to_string()]); + let nav = plan + .writes + .iter() + .find(|(path, _)| path == "pages/_nav.ts") + .expect("nav write") + .1 + .clone(); + assert!( + nav.contains("import { Camera, House, Settings }"), + "icons: {nav}" + ); + assert!( + nav.contains( + " { href: \"/photos\", label: \"Photos\", icon: Camera },\n // outer:nav-items" + ), + "items: {nav}" + ); +} + +#[test] +fn nav_skips_hrefs_already_present() { + let mut tree = app_tree(); + tree.insert( + "pages/_nav.ts".to_string(), + NAV_TS.replace( + " // outer:nav-items", + " { href: \"/photos\", label: \"Pics\", icon: Camera },\n // outer:nav-items", + ), + ); + let mut example = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + example.nav = vec![NavItem { + href: "/photos".to_string(), + label: "Photos".to_string(), + icon: "Camera".to_string(), + }]; + let plan = plan_apply(&tree, "app", &[example], &["photos"]).unwrap(); + assert!(plan.nav.is_empty()); + assert!(!plan.writes.iter().any(|(path, _)| path == "pages/_nav.ts")); +} + +#[test] +fn nav_missing_marker_refuses_with_file_and_manual_step() { + let mut tree = app_tree(); + tree.insert( + "pages/_nav.ts".to_string(), + "export const NAV = [];\n".to_string(), + ); + let mut example = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + example.nav = vec![NavItem { + href: "/photos".to_string(), + label: "Photos".to_string(), + icon: "Camera".to_string(), + }]; + let err = plan_apply(&tree, "app", &[example], &["photos"]).unwrap_err(); + assert!(err.0.contains("pages/_nav.ts"), "unexpected: {}", err.0); + assert!( + err.0.contains("Without add_example"), + "unexpected: {}", + err.0 + ); +} + +#[test] +fn bindings_insert_skip_and_refuse() { + let tree = app_tree(); + let mut example = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + example.bindings = BTreeMap::from([( + "r2_buckets".to_string(), + vec![BTreeMap::from([ + ("binding".to_string(), "PHOTOS".to_string()), + ("bucket_name".to_string(), "photos".to_string()), + ])], + )]); + let plan = plan_apply(&tree, "app", &[example.clone()], &["photos"]).unwrap(); + let config = plan + .writes + .iter() + .find(|(path, _)| path == "void.config.ts") + .expect("bindings write") + .1 + .clone(); + assert!( + config.contains(" r2_buckets: [{ binding: \"PHOTOS\", bucket_name: \"photos\" }],\n // outer:bindings"), + "bindings: {config}" + ); + // Applying onto the updated tree skips the equal key (files still fresh here, so force via tree). + let mut tree2 = tree.clone(); + tree2.insert("void.config.ts".to_string(), config.clone()); + let plan2 = plan_apply(&tree2, "app", &[example.clone()], &["photos"]).unwrap(); + assert!(plan2 + .writes + .iter() + .any(|(path, _)| path == "pages/photos.tsx")); + // A different value for the same key refuses. + let mut tree3 = tree.clone(); + tree3.insert( + "void.config.ts".to_string(), + VOID_CONFIG.replace( + " // outer:bindings", + " r2_buckets: [{ binding: \"OTHER\", bucket_name: \"other\" }],\n // outer:bindings", + ), + ); + let err = plan_apply(&tree3, "app", &[example], &["photos"]).unwrap_err(); + assert!(err.0.contains("r2_buckets"), "unexpected: {}", err.0); +} + +#[test] +fn bindings_missing_marker_refuses() { + let mut tree = app_tree(); + tree.insert( + "void.config.ts".to_string(), + "export default {};\n".to_string(), + ); + let mut ex = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + ex.bindings = BTreeMap::from([( + "r2_buckets".to_string(), + vec![BTreeMap::from([( + "binding".to_string(), + "PHOTOS".to_string(), + )])], + )]); + let err = plan_apply(&tree, "app", &[ex], &["photos"]).unwrap_err(); + assert!(err.0.contains("void.config.ts"), "unexpected: {}", err.0); +} + +#[test] +fn dependencies_merge_and_refuse_on_conflict() { + let tree = app_tree(); + let mut ex = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + ex.dependencies = BTreeMap::from([("some-pkg".to_string(), "^1.0.0".to_string())]); + let plan = plan_apply(&tree, "app", &[ex.clone()], &["photos"]).unwrap(); + let pkg = plan + .writes + .iter() + .find(|(path, _)| path == "package.json") + .expect("package write") + .1 + .clone(); + assert_eq!( + pkg, + "{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n },\n \"name\": \"seed\",\n \"private\": true\n}\n", + "package: {pkg}" + ); + // Same version is fine; a different one refuses. + let mut tree2 = tree.clone(); + tree2.insert("package.json".to_string(), pkg); + let plan2 = plan_apply(&tree2, "app", &[ex.clone()], &["photos"]).unwrap(); + assert!(plan2 + .writes + .iter() + .any(|(path, _)| path == "pages/photos.tsx")); + let mut ex3 = example("photos", "app", &[("pages/photos.tsx", "p\n")]); + ex3.dependencies = BTreeMap::from([("some-pkg".to_string(), "^2.0.0".to_string())]); + let mut tree3 = tree.clone(); + tree3.insert( + "package.json".to_string(), + "{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n }\n}\n".to_string(), + ); + let err = plan_apply(&tree3, "app", &[ex3], &["photos"]).unwrap_err(); + assert!(err.0.contains("some-pkg"), "unexpected: {}", err.0); +} + +#[test] +fn icon_names_validate() { + assert!(is_valid_icon("Camera")); + assert!(is_valid_icon("A1")); + assert!(!is_valid_icon("camera")); + assert!(!is_valid_icon("C")); + assert!(!is_valid_icon("Has-Dash")); + assert!(!is_valid_icon("Has Space")); +} + +/// The JSON golden fixtures both engines test against: input tree + +/// examples + request → expected writes or refusal. +#[test] +fn golden_cases() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/apply"); + let mut cases: Vec<_> = std::fs::read_dir(&dir) + .unwrap_or_else(|_| panic!("missing golden fixtures at {}", dir.display())) + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + cases.sort(); + assert!(!cases.is_empty(), "no golden cases in {}", dir.display()); + for path in cases { + let raw = std::fs::read(&path).unwrap(); + let case: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + let tree: BTreeMap = case["tree"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(); + let examples: Vec = case["examples"] + .as_array() + .unwrap() + .iter() + .map(|example| ExampleInput { + name: example["name"].as_str().unwrap().to_string(), + template: example["template"].as_str().unwrap().to_string(), + requires: example["requires"] + .as_array() + .unwrap() + .iter() + .map(|dep| dep.as_str().unwrap().to_string()) + .collect(), + nav: example["nav"] + .as_array() + .unwrap() + .iter() + .map(|item| NavItem { + href: item["href"].as_str().unwrap().to_string(), + label: item["label"].as_str().unwrap().to_string(), + icon: item["icon"].as_str().unwrap().to_string(), + }) + .collect(), + bindings: example["bindings"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| { + ( + k.clone(), + v.as_array() + .unwrap() + .iter() + .map(|entry| { + entry + .as_object() + .unwrap() + .iter() + .map(|(fk, fv)| { + (fk.clone(), fv.as_str().unwrap().to_string()) + }) + .collect() + }) + .collect(), + ) + }) + .collect(), + dependencies: example["dependencies"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(), + files: example["files"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(), + }) + .collect(); + let request: Vec = case["request"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap().to_string()) + .collect(); + let borrowed: Vec<&str> = request.iter().map(String::as_str).collect(); + let template = case["template"].as_str().unwrap(); + match plan_apply(&tree, template, &examples, &borrowed) { + Ok(plan) => { + let expect = &case["expect"]; + assert!( + expect.get("writes").is_some(), + "{}: planned but fixture expects refusal", + path.display() + ); + let mut writes: BTreeMap = plan.writes.into_iter().collect(); + let mut expected: BTreeMap = expect["writes"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) + .collect(); + // Fixture JSON escapes are exact; compare directly. + assert_eq!(writes, expected, "{}: writes differ", path.display()); + let _ = &mut expected; + let _ = &mut writes; + } + Err(refusal) => { + let expect = &case["expect"]; + let want = expect["refuse_contains"].as_str().unwrap_or_else(|| { + panic!("{}: refused but fixture expects writes", path.display()) + }); + assert!( + refusal.0.contains(want), + "{}: refusal {:?} lacks {want:?}", + path.display(), + refusal.0 + ); + } + } + } +} diff --git a/crates/outer/src/examples.rs b/crates/outer/src/examples/mod.rs similarity index 85% rename from crates/outer/src/examples.rs rename to crates/outer/src/examples/mod.rs index f1482c76..181b8fc1 100644 --- a/crates/outer/src/examples.rs +++ b/crates/outer/src/examples/mod.rs @@ -1,4 +1,5 @@ -//! Copy-paste patterns served over the public `examples` MCP tool. +//! Copy-paste patterns served over the public `examples` MCP tool, and +//! applied by `add_example` / `create_app {examples}`. //! //! The patterns live in `examples/` in the `outer-templates` repo, vendored //! into the store as a git bundle at the pinned sha (see [`crate::templates`]). @@ -6,6 +7,7 @@ //! Outer's data dir, once per sha, and serve from there. A pinned sha with no //! `examples/` yields an empty list. A bad example is skipped with a warn and //! never fails the whole list. +pub mod apply; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -41,7 +43,16 @@ pub struct ExampleFile { pub text: String, } -/// One full example: the README plus every file's text. +/// One full example: the README plus every file's text, plus the apply +/// metadata `add_example` plans from (`requires`, `nav`, `bindings`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavEntry { + pub href: String, + pub label: String, + pub icon: String, +} + +/// One full example: the README, every file's text, and the apply metadata. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Example { pub name: String, @@ -49,6 +60,9 @@ pub struct Example { pub summary: String, pub template: String, pub dependencies: BTreeMap, + pub requires: Vec, + pub nav: Vec, + pub bindings: BTreeMap>>, pub readme: String, pub files: Vec, } @@ -68,14 +82,28 @@ pub fn is_valid_name(name: &str) -> bool { .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-') } -/// The `example.json` contract: name, title, summary, template, plus optional -/// dependencies. Unknown fields are ignored. +/// The `example.json` contract: title, summary, template, plus optional +/// `requires`, `nav`, `bindings` and `dependencies`. Unknown fields are +/// ignored. +#[derive(Debug, Deserialize)] +struct NavJson { + href: String, + label: String, + icon: String, +} + #[derive(Debug, Deserialize)] struct ExampleJson { title: String, summary: String, template: String, #[serde(default)] + requires: Vec, + #[serde(default)] + nav: Vec, + #[serde(default)] + bindings: BTreeMap>>, + #[serde(default)] dependencies: BTreeMap, } @@ -145,6 +173,31 @@ impl Example { .collect(), } } + + /// The apply engine's view of this example. + pub fn apply_input(&self) -> apply::ExampleInput { + apply::ExampleInput { + name: self.name.clone(), + template: self.template.clone(), + requires: self.requires.clone(), + nav: self + .nav + .iter() + .map(|item| apply::NavItem { + href: item.href.clone(), + label: item.label.clone(), + icon: item.icon.clone(), + }) + .collect(), + bindings: self.bindings.clone(), + dependencies: self.dependencies.clone(), + files: self + .files + .iter() + .map(|file| (file.path.clone(), file.text.clone())) + .collect(), + } + } } /// Extract `examples/` for the current [`crate::templates::TemplateRef`] @@ -350,6 +403,35 @@ fn load_example(dir: &Path, name: &str) -> Option { if crate::templates::Template::from_id(&meta.template).is_none() { return reject(&format!("bad template {:?}", meta.template)); } + for dep in &meta.requires { + if !is_valid_name(dep) { + return reject(&format!("bad requires entry {dep:?}")); + } + } + let mut nav = Vec::with_capacity(meta.nav.len()); + for item in meta.nav { + if item.href.is_empty() || !item.href.starts_with('/') { + return reject(&format!("bad nav href {:?}", item.href)); + } + if item.label.is_empty() { + return reject(&format!("bad nav label for {:?}", item.href)); + } + if !apply::is_valid_icon(&item.icon) { + return reject(&format!("bad nav icon {:?}", item.icon)); + } + nav.push(NavEntry { + href: item.href, + label: item.label, + icon: item.icon, + }); + } + for key in meta.bindings.keys() { + if key != "d1_databases" && key != "r2_buckets" { + return reject(&format!( + "bad bindings key {key:?}: want d1_databases or r2_buckets" + )); + } + } let readme = match std::fs::read_to_string(dir.join("README.md")) { Ok(readme) => readme, Err(_) => return reject("missing README.md"), @@ -382,6 +464,9 @@ fn load_example(dir: &Path, name: &str) -> Option { summary: meta.summary, template: meta.template, dependencies: meta.dependencies, + requires: meta.requires, + nav, + bindings: meta.bindings, readme, files: out, }) @@ -621,6 +706,7 @@ mod tests { ExamplesInput { name: Some("nope".to_string()), template: None, + files: None, }, &ctx, ) @@ -654,6 +740,7 @@ mod tests { ExamplesInput { name: Some(bad.clone()), template: None, + files: None, }, &ctx, ) @@ -666,6 +753,7 @@ mod tests { ExamplesInput { name: Some("list-form".to_string()), template: None, + files: None, }, &ctx, ) @@ -736,6 +824,7 @@ mod tests { ExamplesInput { name: Some("big".to_string()), template: None, + files: Some(true), }, &ctx, ) @@ -751,6 +840,50 @@ mod tests { "{text}" ); assert!(text.starts_with("# Big\n"), "{text}"); + // Contents travel in the text block only, never duplicated. + assert!( + !text.contains("\"text\""), + "no JSON envelope in the text block" + ); + for entry in structured["files"].as_array().unwrap() { + assert!(entry.get("text").is_none(), "no contents in structured"); + } + } + + #[tokio::test] + async fn without_files_lists_without_contents() { + let state = two_example_state().await; + let ctx = Ctx { + state: &state, + principal: None, + client_ip: None, + }; + let value = examples_tool( + ExamplesInput { + name: Some("list-form".to_string()), + template: None, + files: None, + }, + &ctx, + ) + .await + .unwrap(); + let text = value["content"][0]["text"].as_str().unwrap(); + assert!(text.len() < 6 * 1024, "stays small: {}", text.len()); + assert!(text.contains("pages/list.tsx"), "{text}"); + assert!( + !text.contains("export default function List"), + "no file contents: {text}" + ); + assert!(text.contains("add_example"), "{text}"); + let structured = &value["structuredContent"]; + assert_eq!(structured["name"], "list-form"); + assert_eq!(structured["requires"].as_array().unwrap().len(), 0); + assert_eq!(structured["files"].as_array().unwrap().len(), 2); + assert!(structured.get("readme").is_some()); + for entry in structured["files"].as_array().unwrap() { + assert!(entry.get("text").is_none(), "no contents in structured"); + } } #[tokio::test] diff --git a/crates/outer/src/mcp/mod.rs b/crates/outer/src/mcp/mod.rs index 860bb39a..23c0fb06 100644 --- a/crates/outer/src/mcp/mod.rs +++ b/crates/outer/src/mcp/mod.rs @@ -44,7 +44,7 @@ pub const LATEST_PROTOCOL: &str = "2025-11-25"; /// Exactly the §8.1 text (Codex reads only this much). The length test pins /// it at or under 512 chars. -pub const INSTRUCTIONS: &str = "New apps are empty branded shells: build the request, nothing to delete. Start `open_app`, read (`read_files` batches, `search_code`), copy patterns from `examples`, iterate `stage`→`check`→`ship` (`commit` ships at once). Relay `user_message` verbatim. Guides: `docs {topic}`, /llms.txt."; +pub const INSTRUCTIONS: &str = "Start with `create_app {examples}` or `add_example`, then customise with `commit`. New apps are empty shells until you build. Begin `open_app`, read (`read_files` batches, `search_code`), iterate `stage`→`check`→`ship`. Relay `user_message` verbatim. Guides: `docs {topic}`, /llms.txt."; /// Largest `POST /mcp` body: one `commit` at the caps fits (spec §8.1). pub const MAX_BODY_BYTES: usize = 32 * 1024 * 1024; diff --git a/crates/outer/src/mcp/registry.rs b/crates/outer/src/mcp/registry.rs index df4f1703..fe4dbe68 100644 --- a/crates/outer/src/mcp/registry.rs +++ b/crates/outer/src/mcp/registry.rs @@ -5,11 +5,11 @@ use serde_json::Value; use crate::mcp::tools::{ - account, apps as app_inputs, backups as backup_inputs, code as code_inputs, - collaborators as collab_inputs, data as data_inputs, domains as domain_inputs, - drafts as draft_inputs, examples as examples_inputs, export as export_inputs, - look as look_inputs, open_app as open_app_inputs, preview as preview_inputs, - runtime as runtime_inputs, secrets as secrets_inputs, NoArgs, + account, add_example as add_example_inputs, apps as app_inputs, backups as backup_inputs, + code as code_inputs, collaborators as collab_inputs, data as data_inputs, + domains as domain_inputs, drafts as draft_inputs, examples as examples_inputs, + export as export_inputs, look as look_inputs, open_app as open_app_inputs, + preview as preview_inputs, runtime as runtime_inputs, secrets as secrets_inputs, NoArgs, }; /// Who may call the tool. `Public` tools run without a token; anything else @@ -97,7 +97,7 @@ pub fn hints_for(name: &str) -> Option { "verify_code" | "create_api_key" | "create_app" | "commit" | "rollback" | "rename_app" | "set_secret" | "resolve_error" | "ignore_error" | "reopen_error" | "add_domain" | "backup_database" | "promote_preview" | "upgrade" | "manage_billing" | "stage" - | "check" | "ship" => Hints::WRITE, + | "check" | "ship" | "add_example" => Hints::WRITE, // Emails an invite to an address outside Outer. "add_collaborator" => Hints { open_world: true, @@ -233,7 +233,7 @@ pub fn registry() -> Vec { ), tool::( "create_app", - "Create an app and deploy it: {slug, name?, description?, template?, wait?}. Name (1-60 chars) and description (1-160 chars) brand the app's brand.json; both default from the slug. Template \"app\" is an interactive Ilha + Void app, \"website\" an Astro content site (default \"app\"). Waits for the first deploy by default.", + "Create an app and deploy it: {slug, name?, description?, template?, examples?, wait?}. Examples like [\"passkeys\"] seed working features in the first commit. Name (1-60 chars) and description (1-160 chars) brand the app; both default from the slug. Template \"app\" is an interactive Ilha + Void app, \"website\" an Astro content site (default \"app\"). Waits for the first deploy by default.", Account, false, ), tool::( @@ -463,9 +463,14 @@ pub fn registry() -> Vec { ), tool::( "examples", - "Copy-paste patterns for apps and sites: `examples` lists them, `examples {name}` returns the README and files to copy.", + "Ready features for apps and sites: `examples` lists them, `examples {name}` shows the README and file list (contents with `files: true`). Apply one with `add_example`.", Public, false, ), + tool::( + "add_example", + "Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and deps in one change and deploys. Customise with commit after.", + Account, false, + ), ] } @@ -492,7 +497,7 @@ mod tests { #[test] fn registry_shapes() { let tools = registry(); - assert_eq!(tools.len(), 60); + assert_eq!(tools.len(), 61); } } diff --git a/crates/outer/src/mcp/tests.rs b/crates/outer/src/mcp/tests.rs index c192d6ff..1f6505a4 100644 --- a/crates/outer/src/mcp/tests.rs +++ b/crates/outer/src/mcp/tests.rs @@ -305,9 +305,10 @@ async fn tools_list_filters_per_caller() { .iter() .map(|t| t["name"].as_str().unwrap().to_string()) .collect(); - assert_eq!(anon.len(), 60); + assert_eq!(anon.len(), 61); assert!(anon.contains(&"sign_in".to_string())); assert!(anon.contains(&"create_app".to_string())); + assert!(anon.contains(&"add_example".to_string())); let token = api_key_for(&state, "ana@example.com").await; let (_, _, authed) = post( @@ -322,7 +323,7 @@ async fn tools_list_filters_per_caller() { .iter() .map(|t| t["name"].as_str().unwrap().to_string()) .collect(); - assert_eq!(authed.len(), 58); + assert_eq!(authed.len(), 59); assert!(!authed.contains(&"sign_in".to_string())); assert!(!authed.contains(&"verify_code".to_string())); assert!(authed.contains(&"upgrade".to_string())); @@ -7948,3 +7949,379 @@ async fn draft_stale_expect_blob_conflicts_at_stage() { 1 ); } + +/// apply-examples behaviour tests: `add_example`, `create_app {examples}` +/// and the slimmer `examples` shape, against a vendored templates repo that +/// carries markers plus `passkeys`/`photos` (app) and `newsletter` (website). +const APPLY_NAV: &str = "import { House, Settings } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n // outer:nav-items\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n"; +const APPLY_VOID: &str = "export default {\n cloudflare: {\n // outer:bindings\n },\n};\n"; +const APPLY_PACKAGE: &str = "{\n \"name\": \"seed\",\n \"private\": true\n}\n"; + +async fn setup_apply() -> (McpState, Codes) { + let dir = tempfile::tempdir().unwrap(); + let pool = crate::db::open(&dir.path().join("outer.sqlite")) + .await + .unwrap(); + std::mem::forget(dir); + let (auth_cfg, codes) = auth_ts::test_config(); + let auth_state = crate::auth::AuthState { + pool: pool.clone(), + config: auth_cfg, + }; + let state = McpState::for_tests(pool, auth_state); + crate::templates::vendor_test_templates_with( + &state.store, + &[ + ("package.json", APPLY_PACKAGE), + ("pages/_nav.ts", APPLY_NAV), + ("void.config.ts", APPLY_VOID), + ( + "pages/index.tsx", + "export default function Home() {\n return

Hi

;\n}\n", + ), + ], + &[ + ("package.json", APPLY_PACKAGE), + ("void.config.ts", APPLY_VOID), + ("src/pages/index.astro", "---\n---\n

Hi

\n"), + ], + &[ + ( + "passkeys", + r#"{"title": "Passkeys", "summary": "Sign in with passkeys.", "template": "app", "requires": [], "nav": [{"href": "/sign-in", "label": "Sign in", "icon": "KeyRound"}, {"href": "/members", "label": "Members", "icon": "Users"}], "bindings": {}, "dependencies": {}}"#, + &[ + ("README.md", "# Passkeys\n\nSign in.\n"), + ( + "pages/sign-in.tsx", + "export default function SignIn() {\n return

Sign in

;\n}\n", + ), + ( + "pages/_session.ts", + "export async function getViewer() {\n return null;\n}\n", + ), + ], + ), + ( + "photos", + r#"{"title": "Photos", "summary": "A photo feed.", "template": "app", "requires": ["passkeys"], "nav": [{"href": "/photos", "label": "Photos", "icon": "Camera"}], "bindings": {"r2_buckets": [{"binding": "PHOTOS", "bucket_name": "photos"}]}, "dependencies": {"some-pkg": "^1.0.0"}}"#, + &[ + ("README.md", "# Photos\n\nPost photos.\n"), + ( + "pages/photos.tsx", + "export default function Photos() {\n return

Photos

;\n}\n", + ), + ( + "db/migrations/0001_photos.sql", + "CREATE TABLE photos (\n id TEXT PRIMARY KEY\n);\n", + ), + ], + ), + ( + "newsletter", + r#"{"title": "Newsletter", "summary": "Email signup.", "template": "website", "requires": [], "nav": [], "bindings": {"d1_databases": [{"binding": "DB", "database_name": "newsletter"}]}, "dependencies": {}}"#, + &[ + ("README.md", "# Newsletter\n\nSubscribe.\n"), + ("src/pages/subscribe.astro", "---\n---\n

Subscribe

\n"), + ( + "db/migrations/0001_sub.sql", + "CREATE TABLE subs (\n id TEXT PRIMARY KEY\n);\n", + ), + ], + ), + ], + ) + .await; + (state, codes) +} + +/// The head tree's file texts, keyed by path. +async fn head_texts(state: &McpState, token: &str, app: &str, paths: &[&str]) -> Value { + let files: Vec = paths.iter().map(|path| json!({"path": path})).collect(); + let (_, read) = call( + state, + Some(token), + "read_files", + json!({"app": app, "files": files}), + ) + .await; + result(&read)["structuredContent"]["files"].clone() +} + +#[tokio::test] +async fn add_example_applies_requires_nav_bindings_and_deploys() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + create_app(&state, &token, "familygram").await; + let (_, added) = call( + &state, + Some(&token), + "add_example", + json!({"app": "familygram", "name": "photos"}), + ) + .await; + let structured = &result(&added)["structuredContent"]; + // `requires` pulls `passkeys` in, requires first. + assert_eq!(structured["added"], json!(["passkeys", "photos"])); + assert_eq!( + structured["nav"], + json!(["/sign-in", "/members", "/photos"]) + ); + assert_eq!(structured["deploy"]["status"], "success"); + for path in [ + "pages/sign-in.tsx", + "pages/_session.ts", + "pages/photos.tsx", + "db/migrations/0001_photos.sql", + ] { + assert!( + structured["files"] + .as_array() + .unwrap() + .iter() + .any(|file| file == path), + "missing {path}: {}", + structured["files"] + ); + } + let texts = head_texts( + &state, + &token, + "familygram", + &[ + "pages/_nav.ts", + "void.config.ts", + "package.json", + "pages/photos.tsx", + "db/migrations/0001_photos.sql", + ], + ) + .await; + // `read_files` numbers lines (`{n}\u{2502} {text}`): strip the numbers. + let by_path = |path: &str| { + let numbered = texts + .as_array() + .unwrap() + .iter() + .find(|file| file["path"] == path) + .unwrap_or_else(|| panic!("no {path}")) + .get("text") + .unwrap() + .as_str() + .unwrap(); + let out: Vec<&str> = numbered + .lines() + .map(|line| { + line.split_once('│') + .map(|(_, rest)| rest.strip_prefix(' ').unwrap_or(rest)) + .unwrap_or(line) + }) + .collect(); + let mut text = out.join("\n"); + text.push('\n'); + text + }; + let nav = by_path("pages/_nav.ts"); + assert!( + nav.contains("import { Camera, House, KeyRound, Settings, Users }"), + "{nav}" + ); + for href in ["/sign-in", "/members", "/photos"] { + assert!(nav.contains(&format!("href: \"{href}\"")), "{nav}"); + } + let config = by_path("void.config.ts"); + assert!( + config.contains("r2_buckets: [{ binding: \"PHOTOS\", bucket_name: \"photos\" }],"), + "{config}" + ); + let package: Value = serde_json::from_str(&by_path("package.json")).unwrap(); + assert_eq!(package["dependencies"]["some-pkg"], "^1.0.0"); + assert_eq!( + by_path("pages/photos.tsx"), + "export default function Photos() {\n return

Photos

;\n}\n" + ); + assert_eq!( + by_path("db/migrations/0001_photos.sql"), + "CREATE TABLE photos (\n id TEXT PRIMARY KEY\n);\n" + ); + // Seed plus the one apply commit. + assert_eq!(history_len(&state, &token, "familygram").await, 2); +} + +#[tokio::test] +async fn add_example_reapply_is_a_noop_refusal() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + create_app(&state, &token, "familygram").await; + let (_, first) = call( + &state, + Some(&token), + "add_example", + json!({"app": "familygram", "name": "passkeys"}), + ) + .await; + assert_eq!( + result(&first)["structuredContent"]["added"], + json!(["passkeys"]) + ); + let (_, again) = call( + &state, + Some(&token), + "add_example", + json!({"app": "familygram", "name": "passkeys"}), + ) + .await; + let err = is_error(&again); + assert_eq!(err["error"], "invalid_argument"); + assert!( + err["message"].as_str().unwrap().contains("already applied"), + "{}", + err["message"] + ); + assert_eq!(history_len(&state, &token, "familygram").await, 2); +} + +#[tokio::test] +async fn add_example_conflicting_file_refuses_without_a_commit() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + create_app(&state, &token, "familygram").await; + let (_, committed) = call( + &state, + Some(&token), + "commit", + json!({"app": "familygram", "message": "own photos page", + "writes": [{"path": "pages/photos.tsx", "content": "mine\n"}]}), + ) + .await; + assert_eq!( + result(&committed)["structuredContent"]["deploy"]["status"], + "success" + ); + let (_, refused) = call( + &state, + Some(&token), + "add_example", + json!({"app": "familygram", "name": "photos"}), + ) + .await; + let err = is_error(&refused); + assert_eq!(err["error"], "invalid_argument"); + assert!( + err["message"] + .as_str() + .unwrap() + .contains("pages/photos.tsx"), + "{}", + err["message"] + ); + assert_eq!(history_len(&state, &token, "familygram").await, 2); +} + +#[tokio::test] +async fn add_example_wrong_template_is_refused() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + let (_, created) = call( + &state, + Some(&token), + "create_app", + json!({"slug": "site", "template": "website"}), + ) + .await; + assert_eq!( + result(&created)["structuredContent"]["deploy"]["status"], + "success" + ); + let (_, refused) = call( + &state, + Some(&token), + "add_example", + json!({"app": "site", "name": "passkeys"}), + ) + .await; + let err = is_error(&refused); + assert_eq!(err["error"], "invalid_argument"); + assert!( + err["message"].as_str().unwrap().contains("website"), + "{}", + err["message"] + ); + assert_eq!(history_len(&state, &token, "site").await, 1); +} + +#[tokio::test] +async fn create_app_with_examples_makes_exactly_one_commit() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + let (_, created) = call( + &state, + Some(&token), + "create_app", + json!({"slug": "familygram", "examples": ["photos"]}), + ) + .await; + let body = result(&created); + let text = body["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("with passkeys, photos"), "{text}"); + assert!(text.contains("/photos"), "{text}"); + let structured = &body["structuredContent"]; + assert_eq!(structured["added"], json!(["passkeys", "photos"])); + assert_eq!(structured["deploy"]["status"], "success"); + // One commit holds the seed and the examples together. + assert_eq!(history_len(&state, &token, "familygram").await, 1); + let texts = head_texts(&state, &token, "familygram", &["pages/photos.tsx"]).await; + let photos = texts[0]["text"].as_str().unwrap(); + assert!( + photos.contains("export default function Photos()"), + "{photos}" + ); + assert!(photos.contains("return

Photos

;"), "{photos}"); +} + +#[tokio::test] +async fn create_app_unknown_example_refuses_before_creation() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + let (_, refused) = call( + &state, + Some(&token), + "create_app", + json!({"slug": "familygram", "examples": ["nope"]}), + ) + .await; + let err = is_error(&refused); + assert_eq!(err["error"], "invalid_argument"); + assert!( + err["message"].as_str().unwrap().contains("nope"), + "{}", + err["message"] + ); + let (_, missing) = call( + &state, + Some(&token), + "get_app", + json!({"app": "familygram"}), + ) + .await; + assert_eq!(is_error(&missing)["error"], "not_found"); +} + +#[tokio::test] +async fn examples_without_files_is_small_and_text_only() { + let (state, _) = setup_apply().await; + let token = api_key_for(&state, "ana@example.com").await; + let (_, body) = call(&state, Some(&token), "examples", json!({"name": "photos"})).await; + let text = result(&body)["content"][0]["text"].as_str().unwrap(); + assert!(text.len() < 6 * 1024, "stays small: {}", text.len()); + assert!( + !text.contains("return

Photos

"), + "no contents: {text}" + ); + let structured = &result(&body)["structuredContent"]; + assert_eq!(structured["requires"], json!(["passkeys"])); + assert_eq!(structured["nav"][0]["href"], "/photos"); + assert_eq!(structured["bindings"]["r2_buckets"][0]["binding"], "PHOTOS"); + for entry in structured["files"].as_array().unwrap() { + assert!(entry.get("text").is_none(), "no contents in structured"); + } +} diff --git a/crates/outer/src/mcp/tools/account.rs b/crates/outer/src/mcp/tools/account.rs index 5d92a997..77027684 100644 --- a/crates/outer/src/mcp/tools/account.rs +++ b/crates/outer/src/mcp/tools/account.rs @@ -70,8 +70,8 @@ pub async fn about(_input: NoArgs, _ctx: &Ctx<'_>) -> Result { "connect": "Claude, Codex and ChatGPT sign in through OAuth automatically. Without OAuth: sign_in {email}, read the emailed code, verify_code {email, code, accept_terms: true}, then send Authorization: Bearer .", "rules": [ "Call docs {topic: \"ilha\"} before writing code.", - "create_app, then commit (prefer edits). Every code tool waits for the deploy.", - "New apps are empty shells: build the request, copying proven patterns from `examples`.", + "create_app {examples}, then add_example, then commit (prefer edits). Every code tool waits for the deploy.", + "New apps are empty shells until you build: start from create_app {examples} or add_example, then customise.", "Relay user_message to your human verbatim.", ], "plans": "Free: 2 apps, 40 code changes and 30 build minutes/day. Pro ($8/month): 20 apps, 1,000 changes and 300 build minutes/day. See docs {topic: \"limits\"}.", diff --git a/crates/outer/src/mcp/tools/add_example.rs b/crates/outer/src/mcp/tools/add_example.rs new file mode 100644 index 00000000..9fd28d23 --- /dev/null +++ b/crates/outer/src/mcp/tools/add_example.rs @@ -0,0 +1,217 @@ +//! The `add_example` write tool: plan an example onto an app's head tree and +//! commit the result through the same commit + gate + awaited deploy path as +//! `commit` (one code change, charged to the app owner). + +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ + authed, check_build_quota, check_storage_writable, claim_change, internal, ok_deploy, + refund_change, run_deploy, visible_app, Ctx, +}; +use crate::git; +use crate::mcp::error::ToolError; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct AddExampleInput { + /// The app slug. + pub app: String, + /// The example name (`examples` lists them). Its `requires` apply first. + pub name: String, + /// Wait for the deploy (default true). False returns immediately; poll + /// deploy_status. + pub wait: Option, +} + +pub async fn add_example(input: AddExampleInput, ctx: &Ctx<'_>) -> Result { + let (app, _) = visible_app(ctx, &input.app).await?; + let caller = authed(ctx)?; + let author = crate::ops::noreply_author(&caller.account_id, &caller.email, &ctx.state.domain); + let data_dir = &ctx.state.delete_cfg.data_dir; + // Load the closure before claiming anything: an unknown name refuses + // before the app is touched. + let closure = super::examples::load_closure( + &ctx.state.store, + data_dir, + std::slice::from_ref(&input.name), + ) + .await?; + let inputs: Vec = closure + .iter() + .map(|example| example.apply_input()) + .collect(); + // The same gates and quota as `commit`, charged to the app owner whoever + // calls: a collaborator's apply counts against the owner's daily changes. + check_build_quota(&ctx.state.pool, &ctx.state.billing, &app.owner_id).await?; + check_storage_writable(&ctx.state.pool, &ctx.state.billing, &app).await?; + let claim = claim_change(&ctx.state.pool, &ctx.state.billing, &app.owner_id).await?; + let planned = async { + let bare = super::code::mirror_for(ctx, &app.slug).await?; + let tree = head_tree(&bare, &inputs).await?; + // The app's template is what the tree shows: the app starter owns + // `pages/_nav.ts`, the website starter has no nav file. + let app_template = if tree.contains_key("pages/_nav.ts") { + "app" + } else { + "website" + }; + crate::examples::apply::plan_apply(&tree, app_template, &inputs, &[input.name.as_str()]) + .map_err(|refusal| ToolError::invalid_argument(refusal.to_string())) + } + .await; + let plan = match planned { + Ok(plan) => plan, + Err(err) => { + if let Err(refund) = refund_change(&ctx.state.pool, &app.owner_id).await { + tracing::warn!("add_example refund failed: {refund:#}"); + } + return Err(err); + } + }; + let request = git::CommitRequest { + message: format!("add_example: {}", plan.added.join(", ")), + base: None, + changes: git::CommitChanges { + writes: plan + .writes + .iter() + .map(|(path, text)| git::FileWrite { + path: path.clone(), + body: git::WriteBody::Text(text.clone()), + expect_blob: None, + }) + .collect(), + ..Default::default() + }, + author, + }; + let outcome = match git::commit::commit_with_outcome( + &ctx.state.store, + &ctx.state.git, + &ctx.state.sync, + &app.slug, + request, + ) + .await + { + Ok(outcome) => outcome, + Err(e) => { + if let Err(refund) = refund_change(&ctx.state.pool, &app.owner_id).await { + tracing::warn!("add_example refund failed: {refund:#}"); + } + return Err(ToolError::from_commit(e)); + } + }; + let sha = outcome.sha; + let url = ctx.state.app_url(&app.slug); + let deploy = run_deploy(ctx.state, &app, &sha, input.wait.unwrap_or(true)).await?; + super::spawn_quota_email(ctx.state, &app.owner_id, claim.used, claim.limit); + let quota = + json!({"limit": claim.limit, "used": claim.used, "resets_at": super::resets_at_utc()}); + let summary = add_summary(&input.app, &plan, &deploy, &url); + let structured = json!({ + "commit": {"sha": sha}, + "added": plan.added, + "files": plan.files, + "nav": plan.nav, + "deploy": deploy, + "quota": quota, + "next": "Customise the copy with commit; check with look_at_app.", + }); + Ok(ok_deploy( + summary, + &structured["deploy"].clone(), + structured, + )) +} + +fn add_summary( + slug: &str, + plan: &crate::examples::apply::ApplyPlan, + deploy: &Value, + url: &str, +) -> String { + let status = deploy["status"].as_str().unwrap_or("unknown"); + let added = plan.added.join(", "); + let live = if plan.nav.is_empty() { + format!("{} file(s) committed", plan.files.len()) + } else { + format!("{} are live", plan.nav.join(", ")) + }; + match status { + "success" => format!( + "Added {added} to {slug}: {live}. Customise the copy with commit; check with look_at_app." + ), + "failed" | "blocked" => { + let step = deploy["failed_step"] + .as_str() + .map(|step| format!(" at {step}")) + .unwrap_or_default(); + let error = deploy["error"] + .as_str() + .map(|error| format!(": {error}")) + .unwrap_or_default(); + format!( + "Added {added} to {slug}, but its deploy {status}{step}{error}. It is not live yet: fix it with commit (deploy_log {{app}} has the full log)." + ) + } + _ => format!( + "Added {added} to {slug}; its deploy is {status}. Call deploy_status {{app, wait: true}} until it reports success at {url}." + ), + } +} + +/// The head tree as the apply engine sees it: every non-migration file the +/// closure touches (with text), plus the `db/migrations/` names (numbers +/// only) and the three edited files. +async fn head_tree( + bare: &std::path::Path, + inputs: &[crate::examples::apply::ExampleInput], +) -> Result, ToolError> { + let mut need: BTreeSet<&str> = BTreeSet::new(); + for input in inputs { + for (path, _) in &input.files { + need.insert(path.as_str()); + } + } + need.insert("pages/_nav.ts"); + need.insert("void.config.ts"); + need.insert("package.json"); + let mut tree = BTreeMap::new(); + for path in need { + match crate::git::local::read_blob(bare, None, path).await { + Ok((_, bytes)) => match String::from_utf8(bytes) { + Ok(text) => { + tree.insert(path.to_string(), text); + } + Err(_) => { + return Err(ToolError::invalid_argument(format!( + "{path} is not UTF-8: move it aside or convert it by hand, then retry" + ))); + } + }, + Err(err) => { + if !err.to_string().contains("not found") { + return Err(super::rev_err(err)); + } + } + } + } + // Migration numbers only: renumbered writes never collide with them. + let (_, migrations, _) = + crate::git::local::list_files(bare, None, Some("db/migrations"), usize::MAX) + .await + .map_err(|err| { + if err.to_string().contains("no commits") { + ToolError::not_found("app has no commits yet") + } else { + internal(err) + } + })?; + for entry in migrations { + tree.entry(entry.path).or_insert_with(String::new); + } + Ok(tree) +} diff --git a/crates/outer/src/mcp/tools/apps.rs b/crates/outer/src/mcp/tools/apps.rs index 749ad214..dfd8635b 100644 --- a/crates/outer/src/mcp/tools/apps.rs +++ b/crates/outer/src/mcp/tools/apps.rs @@ -37,6 +37,10 @@ pub struct CreateAppInput { /// portfolios, blogs and event pages; "app" for anything with accounts, /// live state or dashboards. pub template: Option, + /// Ready features to seed in the first commit: applied onto the empty + /// template before it, so the app is born with working pages. Unknown + /// names are refused before anything is created. + pub examples: Option>, /// Wait for the first deploy (default true). False returns immediately; /// poll deploy_status. pub wait: Option, @@ -91,16 +95,30 @@ pub struct RenameAppInput { /// Seed `slug` from the vendored template and create its repo. Mirrors /// `ops::create_app` without the deploy: the MCP layer runs deploys through /// its own [`super::Deploys`] so tests can substitute a fake. +/// Everything `seed_app` needs beyond the shared state. +struct SeedRequest<'a> { + owner_id: &'a str, + owner_email: &'a str, + slug: &'a str, + name: Option<&'a str>, + description: Option<&'a str>, + template: crate::templates::Template, + examples: &'a [crate::examples::Example], +} + async fn seed_app( ctx: &Ctx<'_>, - owner_id: &str, - owner_email: &str, - slug: &str, - name: Option<&str>, - description: Option<&str>, - template: crate::templates::Template, -) -> Result<(App, String), ToolError> { - // Cheap brand-shape checks before template bytes move or any row exists, + seed: SeedRequest<'_>, +) -> Result<(App, String, Vec, Vec), ToolError> { + let SeedRequest { + owner_id, + owner_email, + slug, + name, + description, + template, + examples, + } = seed; // so a refused name or description costs nothing. let display_name = crate::brand::resolve_brand(slug, name, description) .map(|brand| brand.name) @@ -114,6 +132,32 @@ async fn seed_app( // `brand.json` with the new app's values. crate::templates::write_brand(&tree, slug, name, description) .map_err(|err| ToolError::invalid_argument(err.to_string()))?; + // Optional examples apply into the seed tree before the first commit, + // so the app is born with working features: one commit, one deploy. + let mut added = Vec::new(); + let mut nav = Vec::new(); + if !examples.is_empty() { + let inputs: Vec = examples + .iter() + .map(|example| example.apply_input()) + .collect(); + let requested: Vec<&str> = examples + .iter() + .map(|example| example.name.as_str()) + .collect(); + let seed = read_tree(&tree).map_err(internal)?; + let applied = crate::examples::apply::plan_apply(&seed, template.id(), &inputs, &requested) + .map_err(|refusal| ToolError::invalid_argument(refusal.to_string()))?; + for (path, text) in &applied.writes { + let dest = tree.join(path); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(internal)?; + } + std::fs::write(&dest, text).map_err(internal)?; + } + added = applied.added; + nav = applied.nav; + } let plan = super::plan_of(&ctx.state.pool, owner_id).await?; let mut app = apps::create( &ctx.state.pool, @@ -197,7 +241,7 @@ async fn seed_app( .await .map_err(internal)? .ok_or_else(|| ToolError::internal(format!("seeded {slug} has no commits")))?; - Ok((app, sha)) + Ok((app, sha, added, nav)) } pub async fn create_app(input: CreateAppInput, ctx: &Ctx<'_>) -> Result { @@ -211,6 +255,15 @@ pub async fn create_app(input: CreateAppInput, ctx: &Ctx<'_>) -> Result = input.examples.clone().unwrap_or_default(); + let examples = if wanted.is_empty() { + Vec::new() + } else { + super::examples::load_closure(&ctx.state.store, &ctx.state.delete_cfg.data_dir, &wanted) + .await? + }; check_app_quota(&ctx.state.pool, &ctx.state.billing, &caller.account_id).await?; // The name and description checks run before any row exists and before // the quota claim, so refused text costs nothing and Jev never sees a @@ -223,14 +276,17 @@ pub async fn create_app(input: CreateAppInput, ctx: &Ctx<'_>) -> Result) -> Result) -> Result>() .join(", "); - format!("For ready patterns ({names}) call examples {{name}} for the README and files to copy.") + format!("For ready features ({names}) call add_example {{app, name}}.") } - _ => "For ready patterns call examples.".to_string(), + _ => "For ready features call add_example {app, name}.".to_string(), }; let url = ctx.state.app_url(&app.slug); let lead = seed_deploy_lead(&app.slug, &url, &deploy); - let summary = match template { - crate::templates::Template::App => format!( - "{lead} It's an empty app shell: build what your user asked for. Pages go in pages/ (list them in pages/_nav.ts). {hint}", - ), - crate::templates::Template::Website => format!( - "{lead} It's an empty website shell: build what your user asked for. Content lives in Astro pages (islands for interactive parts). {hint}", - ), + let summary = if added.is_empty() { + let shell = match template { + crate::templates::Template::App => format!( + "{lead} It's an empty app shell: build what your user asked for. Pages go in pages/ (list them in pages/_nav.ts). {hint}", + ), + crate::templates::Template::Website => format!( + "{lead} It's an empty website shell: build what your user asked for. Content lives in Astro pages (islands for interactive parts). {hint}", + ), + }; + format!( + "{shell} Nothing the user asked for exists yet: add_example or commit before telling them it's built." + ) + } else { + let live = if nav.is_empty() { + "working files".to_string() + } else { + nav.join(", ") + }; + match deploy["status"].as_str().unwrap_or("unknown") { + "success" => format!( + "{} is live at {} with {}: {} are live. Customise the copy with commit; check with look_at_app. {}", + app.slug, + url, + added.join(", "), + live, + hint, + ), + _ => format!("{lead} with {}. {hint}", added.join(", ")), + } }; let structured = json!({ "app": {"slug": app.slug, "url": ctx.state.app_url(&app.slug), "visibility": app.visibility.as_str()}, "deploy": deploy, - "next": "open_app {app} to orient, then read_files (batched ranges), search_code, examples for ready patterns, commit edits", + "added": added, + "next": "open_app {app} to orient, then read_files (batched ranges), search_code, add_example for ready features, commit edits", }); Ok(super::ok_deploy( summary, @@ -282,6 +361,35 @@ pub async fn create_app(input: CreateAppInput, ctx: &Ctx<'_>) -> Result anyhow::Result> { + fn visit( + dir: &std::path::Path, + base: &std::path::Path, + out: &mut std::collections::BTreeMap, + ) -> anyhow::Result<()> { + let mut entries: Vec<_> = std::fs::read_dir(dir)?.collect::>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + visit(&path, base, out)?; + } else if path.is_file() { + let rel = path + .strip_prefix(base) + .map_err(|err| anyhow::anyhow!("{err:#}"))? + .to_string_lossy() + .replace('\\', "/"); + out.insert(rel, std::fs::read_to_string(&path)?); + } + } + Ok(()) + } + let mut out = std::collections::BTreeMap::new(); + visit(dir, dir, &mut out)?; + Ok(out) +} + /// The first sentence of `create_app`'s result, true to the seed deploy: /// only a `success` says "live". A failed or blocked first deploy names the /// step and error so the agent fixes it instead of reporting a dead link; diff --git a/crates/outer/src/mcp/tools/code.rs b/crates/outer/src/mcp/tools/code.rs index 5a51f9ac..1ab69855 100644 --- a/crates/outer/src/mcp/tools/code.rs +++ b/crates/outer/src/mcp/tools/code.rs @@ -215,6 +215,11 @@ async fn mirror(ctx: &Ctx<'_>, slug: &str) -> Result, slug: &str) -> Result { + mirror(ctx, slug).await +} + pub async fn list_files(input: ListFilesInput, ctx: &Ctx<'_>) -> Result { let (app, _) = visible_app(ctx, &input.app).await?; // Validate before git runs: bad shape is `invalid_argument` with a diff --git a/crates/outer/src/mcp/tools/examples.rs b/crates/outer/src/mcp/tools/examples.rs index 564cc69d..fc7d12ca 100644 --- a/crates/outer/src/mcp/tools/examples.rs +++ b/crates/outer/src/mcp/tools/examples.rs @@ -1,15 +1,23 @@ -//! The public `examples` tool: list the copy-paste patterns vendored from -//! the templates repo, or return one pattern's README plus its files. +//! The public `examples` tool: list the ready patterns vendored from the +//! templates repo, or return one pattern's README plus its file list. //! -//! Our own content, so no untrusted notice: agents copy the files onto a -//! fresh template and adapt them. +//! Our own content, so no untrusted notice: agents apply patterns with +//! `add_example` and customise afterwards, instead of hand-copying files. +//! +//! The large text is never duplicated: file contents travel in the text +//! block only (like `read_files` shows them), while `structuredContent` +//! carries metadata plus the file list. `examples {name}` without `files` +//! stays small: README plus the file list with line counts. use schemars::JsonSchema; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; use super::{internal, ok, Ctx}; use crate::mcp::error::ToolError; +use crate::store::Store; /// The output budget, same shape as `read_files`: file bytes past this are /// cut, and the result carries `truncated`. @@ -21,6 +29,9 @@ pub struct ExamplesInput { pub name: Option, /// Only examples for one template: "app" or "website". Omit for both. pub template: Option, + /// Include every file's contents in the text block (default false: + /// README plus the file list with line counts). + pub files: Option, } fn truncate_utf8(s: &str, cap: usize) -> &str { @@ -42,7 +53,7 @@ pub async fn examples(input: ExamplesInput, ctx: &Ctx<'_>) -> Result list_examples(ctx, template).await, - Some(name) => get_example(ctx, template, &name).await, + Some(name) => get_example(ctx, template, &name, input.files.unwrap_or(false)).await, } } @@ -87,7 +98,7 @@ async fn list_examples( text, json!({ "examples": rows, - "next": "examples {name} returns the README and files to copy", + "next": "add_example {app, name} commits an example's working pages, nav and bindings; customise with commit. examples {name} shows its README and file list (files: true adds the contents).", }), )) } @@ -96,6 +107,7 @@ async fn get_example( ctx: &Ctx<'_>, template: Option, name: &str, + with_files: bool, ) -> Result { if !crate::examples::is_valid_name(name) { return Err(ToolError::invalid_argument(format!( @@ -128,27 +140,117 @@ async fn get_example( template.map(|template| template.id()) ))); } - Ok(render_example(&example)) + Ok(render_example(&example, with_files)) } } } -/// The README, then each file as a fenced block headed by its path, cut at -/// the output budget exactly like `read_files`. -fn render_example(example: &crate::examples::Example) -> Value { +/// Load `names` plus their transitive `requires`, in request order. Unknown +/// names refuse with the valid list, before any caller creates anything. +pub(crate) async fn load_closure( + store: &Store, + data_dir: &Path, + names: &[String], +) -> Result, ToolError> { + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + let mut queue: Vec = names.to_vec(); + while let Some(name) = queue.pop() { + if !seen.insert(name.clone()) { + continue; + } + if !crate::examples::is_valid_name(&name) { + return Err(ToolError::invalid_argument(format!( + "bad example name {name:?}: names match ^[a-z0-9][a-z0-9-]{{0,38}}[a-z0-9]$" + ))); + } + match crate::examples::get(store, data_dir, &name) + .await + .map_err(internal)? + { + None => { + let valid = crate::examples::list(store, data_dir, None) + .await + .map_err(internal)?; + return Err(ToolError::invalid_argument(format!( + "unknown example {name:?}; pick one of: {}", + valid + .iter() + .map(|example| example.name.as_str()) + .collect::>() + .join(", ") + ))); + } + Some(example) => { + queue.extend(example.requires.iter().cloned()); + out.push(example); + } + } + } + // Request order first, then newly discovered requires in BFS order. + let mut ordered: Vec = Vec::with_capacity(out.len()); + let mut emitted = BTreeSet::new(); + for name in names { + if let Some(pos) = out.iter().position(|example| &example.name == name) { + if emitted.insert(name.clone()) { + ordered.push(out[pos].clone()); + } + } + } + for example in out { + if emitted.insert(example.name.clone()) { + ordered.push(example); + } + } + Ok(ordered) +} + +/// The README, then the file list. With `files`, each file follows as a +/// fenced block headed by its path, cut at the output budget exactly like +/// `read_files`. Contents live in the text block only; `structuredContent` +/// carries metadata plus the file list with a per-file `truncated` flag. +fn render_example(example: &crate::examples::Example, with_files: bool) -> Value { + let mut files = Vec::with_capacity(example.files.len()); + let mut list = Vec::with_capacity(example.files.len()); + for file in &example.files { + let lines = file.text.lines().count(); + files.push(json!({"path": file.path, "lines": lines})); + list.push(format!("- {} ({lines} lines)", file.path)); + } + let mut structured = json!({ + "name": example.name, + "title": example.title, + "summary": example.summary, + "template": example.template, + "requires": example.requires, + "nav": example.nav.iter().map(|item| { + json!({"href": item.href, "label": item.label, "icon": item.icon}) + }).collect::>(), + "bindings": example.bindings, + "dependencies": example.dependencies, + "readme": example.readme, + "files": files, + "truncated": false, + }); + if !with_files { + let text = format!( + "{}\n\nFiles ({}):\n{}\n\nCall `examples {{name: {:?}, files: true}}` for the contents, or `add_example {{app, name: {:?}}}` to commit it.", + example.readme, + example.files.len(), + list.join("\n"), + example.name, + example.name, + ); + return ok(text, structured); + } let mut used = 0usize; let mut spent = false; let mut truncated = false; - let mut files = Vec::with_capacity(example.files.len()); - let mut blocks = Vec::with_capacity(example.files.len() + 1); - blocks.push(example.readme.clone()); - for file in &example.files { + let mut truncated_flags = vec![false; example.files.len()]; + let mut blocks = vec![example.readme.clone()]; + for (i, file) in example.files.iter().enumerate() { if spent { - files.push(json!({ - "path": file.path, - "code": "invalid_argument", - "error": "skipped: the 200 KB output budget is exhausted by earlier files", - })); + truncated_flags[i] = true; blocks.push(format!( "## {}\n(skipped: output budget exhausted)", file.path @@ -162,21 +264,13 @@ fn render_example(example: &crate::examples::Example) -> Value { let cut = truncate_utf8(&block, room); // A file cut to nothing is an inline skip, not an empty read. if cut.is_empty() { - files.push(json!({ - "path": file.path, - "code": "invalid_argument", - "error": "skipped: the 200 KB output budget is exhausted by earlier files", - })); + truncated_flags[i] = true; blocks.push(format!( "## {}\n(skipped: output budget exhausted)", file.path )); } else { - files.push(json!({ - "path": file.path, - "text": cut, - "truncated": true, - })); + truncated_flags[i] = true; blocks.push(format!("{cut}\n(truncated at the 200 KB output budget)")); } used = MAX_EXAMPLE_OUTPUT_BYTES; @@ -184,20 +278,19 @@ fn render_example(example: &crate::examples::Example) -> Value { truncated = true; } else { used += block.len(); - files.push(json!({"path": file.path, "text": file.text})); blocks.push(block); } } - ok( - blocks.join("\n\n"), - json!({ - "name": example.name, - "title": example.title, - "template": example.template, - "dependencies": example.dependencies, - "readme": example.readme, - "files": files, - "truncated": truncated, - }), - ) + for (entry, flag) in structured["files"] + .as_array_mut() + .iter_mut() + .flat_map(|entries| entries.iter_mut()) + .zip(truncated_flags) + { + if flag { + entry["truncated"] = json!(true); + } + } + structured["truncated"] = json!(truncated); + ok(blocks.join("\n\n"), structured) } diff --git a/crates/outer/src/mcp/tools/mod.rs b/crates/outer/src/mcp/tools/mod.rs index 90822fb2..29f6921a 100644 --- a/crates/outer/src/mcp/tools/mod.rs +++ b/crates/outer/src/mcp/tools/mod.rs @@ -6,6 +6,7 @@ //! and surface as JSON-RPC `-32602`, never as `isError`. pub(crate) mod account; +pub(crate) mod add_example; pub(crate) mod apps; pub(crate) mod backups; pub(crate) mod billing; @@ -1244,6 +1245,9 @@ pub async fn dispatch(tool: &str, args: Option, ctx: &Ctx<'_>) -> Result< "examples" => examples::examples(parse_args(args)?, ctx) .await .map_err(CallError::from), + "add_example" => add_example::add_example(parse_args(args)?, ctx) + .await + .map_err(CallError::from), _ => Err(CallError::Rpc(-32602, format!("unknown tool {tool:?}"))), } } diff --git a/crates/outer/src/mcp/tools/open_app.rs b/crates/outer/src/mcp/tools/open_app.rs index ec0f7b59..74f470e7 100644 --- a/crates/outer/src/mcp/tools/open_app.rs +++ b/crates/outer/src/mcp/tools/open_app.rs @@ -880,7 +880,7 @@ pub async fn open_app_impl(input: OpenAppInput, ctx: &Ctx<'_>) -> Result TemplateRef { + vendor_test_templates_with(store, files, files, &[]).await +} + +/// Like [`vendor_test_templates`], with separate seed trees per template and +/// an `examples/` dir: each entry is `(name, example.json body, app files)`. +/// A `README.md` is included in the files when given; examples without one +/// are skipped by the reader like invalid ones. +#[cfg(test)] +type TestExample<'a> = (&'a str, &'a str, &'a [(&'a str, &'a str)]); + +#[cfg(test)] +pub(crate) async fn vendor_test_templates_with( + store: &Store, + app_files: &[(&str, &str)], + site_files: &[(&str, &str)], + examples: &[TestExample<'_>], +) -> TemplateRef { let src = tempfile::tempdir().unwrap(); - for template in Template::ALL { - for (rel, body) in files { - let path = src.path().join(template.dir()).join(rel); + for (rel, body) in app_files { + let path = src.path().join(Template::App.dir()).join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } + for (rel, body) in site_files { + let path = src.path().join(Template::Website.dir()).join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, body).unwrap(); + } + for (name, meta, files) in examples { + let dir = src.path().join("examples").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("example.json"), meta).unwrap(); + for (rel, body) in *files { + let path = dir.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, body).unwrap(); } diff --git a/crates/outer/tests/fixtures/apply/README.md b/crates/outer/tests/fixtures/apply/README.md new file mode 100644 index 00000000..5d7ebe72 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/README.md @@ -0,0 +1,45 @@ +# Apply golden fixtures + +One JSON case per file. Both apply engines (Rust `crates/outer/src/examples/apply.rs`, +TypeScript `scripts/apply-example.ts` in outer-templates) run these inputs and must +produce byte-identical outputs. + +## Format + +```json +{ + "template": "app", + "tree": { "path": "full text\n", "...": "..." }, + "examples": [ + { + "name": "photos", + "template": "app", + "requires": ["passkeys"], + "nav": [{ "href": "/photos", "label": "Photos", "icon": "Camera" }], + "bindings": { "r2_buckets": [{ "binding": "PHOTOS", "bucket_name": "photos" }] }, + "dependencies": { "some-pkg": "^1.0.0" }, + "files": { "pages/photos.tsx": "...", "db/migrations/0001_photos.sql": "..." } + } + ], + "request": ["photos"], + "expect": { + "writes": { "pages/_nav.ts": "...", "...": "..." } + } +} +``` + +or `"expect": { "refuse_contains": "pages/_nav.ts" }`. + +## Pinned formatting rules + +- Nav items insert above the `// outer:nav-items` marker at the marker's own + indentation: `{ href: "/photos", label: "Photos", icon: Camera },`. +- Nav icons merge into the braces on the `// outer:nav-icons` line, deduplicated + and sorted byte-wise, joined with `", "`. +- Binding lines insert above the `// outer:bindings` marker at the marker's own + indentation: `r2_buckets: [{ binding: "PHOTOS", bucket_name: "photos" }],`. + Object keys sort byte-wise; entries join with `", "`. Keys insert byte-wise. +- `package.json` re-serializes whole: all keys sorted byte-wise, 2-space indent, + one trailing newline. +- Migrations renumber to the next free numbers after the app's highest existing + migration, keeping byte-wise path order; numbers pad to at least 4 digits. diff --git a/crates/outer/tests/fixtures/apply/combined.json b/crates/outer/tests/fixtures/apply/combined.json new file mode 100644 index 00000000..66dd0c63 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/combined.json @@ -0,0 +1 @@ +{"examples":[{"bindings":{},"dependencies":{},"files":{"pages/_session.ts":"export async function getViewer() {}\n","pages/sign-in.tsx":"export default function SignIn() {}\n"},"name":"passkeys","nav":[{"href":"/sign-in","icon":"KeyRound","label":"Sign in"},{"href":"/members","icon":"Users","label":"Members"}],"requires":[],"template":"app"},{"bindings":{"r2_buckets":[{"binding":"PHOTOS","bucket_name":"photos"}]},"dependencies":{"some-pkg":"^1.0.0"},"files":{"db/migrations/0001_photos.sql":"CREATE TABLE photos (id);\n","pages/photos.tsx":"export default function Photos() {}\n"},"name":"photos","nav":[{"href":"/photos","icon":"Camera","label":"Photos"}],"requires":["passkeys"],"template":"app"}],"expect":{"writes":{"db/migrations/0001_photos.sql":"CREATE TABLE photos (id);\n","package.json":"{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n },\n \"name\": \"seed\",\n \"private\": true\n}\n","pages/_nav.ts":"import { Camera, House, KeyRound, Settings, Users } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n { href: \"/sign-in\", label: \"Sign in\", icon: KeyRound },\n { href: \"/members\", label: \"Members\", icon: Users },\n { href: \"/photos\", label: \"Photos\", icon: Camera },\n // outer:nav-items — add_example inserts pages above this line\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n","pages/_session.ts":"export async function getViewer() {}\n","pages/photos.tsx":"export default function Photos() {}\n","pages/sign-in.tsx":"export default function SignIn() {}\n","void.config.ts":"export default {\n cloudflare: {\n r2_buckets: [{ binding: \"PHOTOS\", bucket_name: \"photos\" }],\n // outer:bindings — add_example inserts bindings above this line\n },\n};\n"}},"request":["photos"],"template":"app","tree":{"package.json":"{\n \"name\": \"seed\",\n \"private\": true\n}\n","pages/_nav.ts":"import { House, Settings } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n // outer:nav-items — add_example inserts pages above this line\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n","void.config.ts":"export default {\n cloudflare: {\n // outer:bindings — add_example inserts bindings above this line\n },\n};\n"}} \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/conflict.json b/crates/outer/tests/fixtures/apply/conflict.json new file mode 100644 index 00000000..cb90bb78 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict.json @@ -0,0 +1 @@ +{"examples":[{"bindings":{},"dependencies":{},"files":{"pages/photos.tsx":"export default function Photos() {}\n"},"name":"photos","nav":[],"requires":[],"template":"app"}],"expect":{"refuse_contains":"pages/photos.tsx"},"request":["photos"],"template":"app","tree":{"package.json":"{\n \"name\": \"seed\"\n}\n","pages/_nav.ts":"import { House } from \"lucide\"; // outer:nav-icons\nexport const NAV = [];\n // outer:nav-items\n","pages/photos.tsx":"something else entirely\n","void.config.ts":"export default {};\n"}} \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/missing-marker.json b/crates/outer/tests/fixtures/apply/missing-marker.json new file mode 100644 index 00000000..899f07ef --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker.json @@ -0,0 +1 @@ +{"examples": [{"bindings": {}, "dependencies": {}, "files": {"pages/photos.tsx": "export default function Photos() {}\n"}, "name": "photos", "nav": [{"href": "/photos", "icon": "Camera", "label": "Photos"}], "requires": [], "template": "app"}], "expect": {"refuse_contains": "pages/_nav.ts"}, "request": ["photos"], "template": "app", "tree": {"package.json": "{\n \"name\": \"seed\"\n}\n", "pages/_nav.ts": "export const NAV = [];\n", "void.config.ts": "export default {};\n"}} \ No newline at end of file diff --git a/web/docs/agents/start-here/examples.md b/web/docs/agents/start-here/examples.md index 8b7b857e..36fc1a31 100644 --- a/web/docs/agents/start-here/examples.md +++ b/web/docs/agents/start-here/examples.md @@ -1,11 +1,29 @@ --- title: Examples -description: Copy proven patterns onto the empty shell with the examples tool. +description: Apply proven features with add_example, then customise. --- New apps are empty branded shells: layout, brand, settings, and nothing to delete. Real features live in the examples, served over MCP from the pinned -templates repo. +templates repo. One tool call applies one into your app as a committed, +deployed change — you only customise copy and design afterwards. + +## Apply one + +Call `add_example {app, name}`. It resolves the example's `requires` first, +copies every file to the same path, adds nav entries to `pages/_nav.ts`, +merges `dependencies` into `package.json`, inserts `d1`/`r2` bindings into +`void.config.ts`, renumbers migrations past the app's highest, and commits +all of it in one change through the normal commit + gate + deploy path. +The result names the added examples, the written files, the live hrefs, and +the deploy. + +Or start with features: `create_app {slug, examples: ["passkeys", "photos"]}` +seeds the template and applies the examples before the first commit, so the +app is born with working pages and one deploy. + +After applying, customise copy and design with `commit`, and verify with +`look_at_app`. ## List them @@ -14,25 +32,24 @@ Call `examples` with no arguments. Filter with `template: "app"` or dependencies, and its files with line counts. The result ends with its own `next` step. -## Copy one - -Call `examples {name}`. The result is the pattern's README first, then each -file as a fenced block headed by its path. The README is written for you: -what the pattern shows, then numbered steps. Follow them in order: +## Inspect one -1. Copy each file to the same path in the app repo. -2. Add the page to `pages/_nav.ts`. -3. Add any dependency to `package.json`, or any binding to `void.config.ts`. -4. Rename the migration when the app already has migrations. -5. Finish with the README's gotchas, then verify with `look_at_app`. - -Files are complete and working when copied onto a fresh copy of their -template, and assume no other example is present. Results past the 200 KB -output budget arrive truncated: read the rest file by file, or copy what -arrived and fetch the remainder. - -## Unknown names +Call `examples {name}`. The result is the pattern's README first, then its +file list with line counts — small enough to stay under 6 KB. Pass +`files: true` for the full contents in the text block (cut at the 200 KB +output budget exactly like `read_files`; `structuredContent` carries +metadata plus the file list, never the contents twice). An unknown name refuses with `invalid_argument` and lists the valid names. A misspelled template (`"Website"`, `"static"`) refuses the same way: only `"app"` and `"website"` exist. + +## Without add_example + +Prefer `add_example`. If you must hand-copy (the app's files diverged and +the engine refused), the README's "What add_example does" section is the +checklist: copy each file to the same path, add the page to `pages/_nav.ts` +at the `// outer:nav-items` marker (icons into `// outer:nav-icons`), add +any dependency to `package.json` or binding to `void.config.ts` at the +`// outer:bindings` marker, and renumber migrations past the app's highest. +Finish with the README's gotchas, then verify with `look_at_app`. diff --git a/web/docs/agents/start-here/quickstart.md b/web/docs/agents/start-here/quickstart.md index 2979ecc5..ede3e518 100644 --- a/web/docs/agents/start-here/quickstart.md +++ b/web/docs/agents/start-here/quickstart.md @@ -20,7 +20,7 @@ Keep the first prompt to one screen: no login, no database yet. > Build a grocery list app for my family on Outer. One page: an input to add items, a list with checkboxes, a filter for bought vs. still needed. No login, no sharing yet — just the list. -Before writing code, read the Ilha guide and the Void guide ([Void](/agents/frontend/void), [Ilha](/agents/frontend/ilha)). Then call `create_app` with a caller-picked slug, plus `name` and `description` so the app is branded from the first commit (see [Templates](/agents/start-here/templates)). New apps are empty branded shells with nothing to delete: call [`examples`](/agents/start-here/examples) for ready patterns (forms + D1, passkey accounts, photo upload, blog, newsletter) and copy what the build needs. A live app serves at `https://.outer.now` in seconds. Apps are unlisted by default: reachable by link, not indexed. For a landing page, portfolio, or blog, pass `template: "website"`. Then orient with `open_app {app}`: routes, tables, brand and rules in one call. +Before writing code, read the Ilha guide and the Void guide ([Void](/agents/frontend/void), [Ilha](/agents/frontend/ilha)). Then call `create_app` with a caller-picked slug, plus `name` and `description` so the app is branded from the first commit (see [Templates](/agents/start-here/templates)). To start from working features, pass `examples: [...]` (or call [`add_example`](/agents/start-here/examples) right after): passkey accounts, photo upload, forms + D1, blog and newsletter land as committed, deployed pages you then customise. A live app serves at `https://.outer.now` in seconds. Apps are unlisted by default: reachable by link, not indexed. For a landing page, portfolio, or blog, pass `template: "website"`. Then orient with `open_app {app}`: routes, tables, brand and rules in one call. ## Make one small change diff --git a/web/docs/agents/start-here/templates.md b/web/docs/agents/start-here/templates.md index 11b9385b..6f0cd5f0 100644 --- a/web/docs/agents/start-here/templates.md +++ b/web/docs/agents/start-here/templates.md @@ -29,9 +29,9 @@ The `app` shell serves server-rendered Ilha pages in `pages/` with loaders + act The `website` shell serves mostly static, prerendered Astro pages with Ilha islands for interactive parts, at most one D1 database and one R2 bucket. Call `open_app {app}` first (it carries the `AGENTS.md` rules digest), then read [Website](/agents/frontend/website). -## Copy patterns from the examples +## Apply features from the examples -Real features live in [Examples](/agents/start-here/examples): `examples` lists them, `examples {name}` returns the README and files to copy onto the fresh shell. Copy each file to the same path, add the page to `pages/_nav.ts`, add any dependency or binding, and rename the migration when the app already has migrations. +Real features live in [Examples](/agents/start-here/examples): `examples` lists them, `add_example {app, name}` commits one onto the app (nav, dependencies, bindings and migrations included), or pass `examples: [...]` to `create_app` to seed them in the first commit. Customise the copy with `commit` afterwards. ## Rebuild, never stretch diff --git a/web/docs/reference/tools.md b/web/docs/reference/tools.md index 2e47d1bd..d4200b15 100644 --- a/web/docs/reference/tools.md +++ b/web/docs/reference/tools.md @@ -133,13 +133,14 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated ## `create_app` - **Access:** signed in -- **Description:** Create an app and deploy it: {slug, name?, description?, template?, wait?}. Name (1-60 chars) and description (1-160 chars) brand the app's brand.json; both default from the slug. Template "app" is an interactive Ilha + Void app, "website" an Astro content site (default "app"). Waits for the first deploy by default. +- **Description:** Create an app and deploy it: {slug, name?, description?, template?, examples?, wait?}. Examples like ["passkeys"] seed working features in the first commit. Name (1-60 chars) and description (1-160 chars) brand the app; both default from the slug. Template "app" is an interactive Ilha + Void app, "website" an Astro content site (default "app"). Waits for the first deploy by default. **Input:** | Property | Type | Required | Description | | --- | --- | --- | --- | | `description` | string | no | One-line pitch, 1–160 chars, no control chars (defaults to the name). Written to the repo's `brand.json`. Checked by the same content check as the slug, with the same refusal. | +| `examples` | array of string | no | Ready features to seed in the first commit: applied onto the empty template before it, so the app is born with working pages. Unknown names are refused before anything is created. | | `name` | string | no | Display name, 1–60 chars, no control chars (defaults to the slug title-cased: `kalkulator-pizzy` → `Kalkulator Pizzy`). Written to the repo's `brand.json` with the accent; the app row keeps the same name. Checked by the same content check as the slug. | | `slug` | string | yes | URL name: lowercase letters, digits and dashes, 1-48 chars. Brand and account-page names (paypal, apple, bank, login, …) and flagged names are refused; without a Jev client (localhost dev) the content check is skipped. | | `template` | string | no | Which starter to seed from: "app" (interactive Ilha + Void app) or "website" (Astro + Ilha-islands content site). Defaults to "app"; any other value is refused. Pick "website" for landing pages, portfolios, blogs and event pages; "app" for anything with accounts, live state or dashboards. | @@ -724,12 +725,26 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated ## `examples` - **Access:** anyone (no token needed) -- **Description:** Copy-paste patterns for apps and sites: `examples` lists them, `examples {name}` returns the README and files to copy. +- **Description:** Ready features for apps and sites: `examples` lists them, `examples {name}` shows the README and file list (contents with `files: true`). Apply one with `add_example`. **Input:** | Property | Type | Required | Description | | --- | --- | --- | --- | +| `files` | boolean | no | Include every file's contents in the text block (default false: README plus the file list with line counts). | | `name` | string | no | An example name (`examples` lists them). Omit to list. | | `template` | string | no | Only examples for one template: "app" or "website". Omit for both. | +## `add_example` + +- **Access:** signed in +- **Description:** Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and deps in one change and deploys. Customise with commit after. + +**Input:** + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `app` | string | yes | The app slug. | +| `name` | string | yes | The example name (`examples` lists them). Its `requires` apply first. | +| `wait` | boolean | no | Wait for the deploy (default true). False returns immediately; poll deploy_status. | + From dd7aceaa65e3e62b898412ff43f7cdba2c4e92c0 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Tue, 27 Oct 2026 18:52:39 +0100 Subject: [PATCH 2/4] Apply engine: shared golden fixtures with outer-templates; drop dependencies --- crates/outer/src/examples/apply.rs | 224 +++++++++--------- crates/outer/src/examples/apply/tests.rs | 217 +++++------------ crates/outer/src/examples/mod.rs | 49 +++- crates/outer/src/mcp/registry.rs | 2 +- crates/outer/src/mcp/tests.rs | 10 +- crates/outer/src/mcp/tools/add_example.rs | 3 +- crates/outer/src/mcp/tools/examples.rs | 2 - crates/outer/tests/fixtures/apply/README.md | 45 ---- .../app-combined/examples/alpha/README.md | 3 + .../alpha/db/migrations/0001_alpha.sql | 1 + .../app-combined/examples/alpha/example.json | 8 + .../examples/alpha/pages/alpha.ts | 1 + .../app-combined/examples/beta/README.md | 3 + .../examples/beta/db/migrations/0001_beta.sql | 1 + .../app-combined/examples/beta/example.json | 8 + .../app-combined/examples/beta/pages/beta.ts | 1 + .../expected/db/migrations/0001_alpha.sql | 1 + .../expected/db/migrations/0002_beta.sql | 1 + .../apply/app-combined/expected/pages/_nav.ts | 17 ++ .../app-combined/expected/pages/alpha.ts | 1 + .../apply/app-combined/expected/pages/beta.ts | 1 + .../app-combined/expected/void.config.ts | 10 + .../fixtures/apply/app-combined/request.json | 1 + .../app-combined/tree/db/migrations/.gitkeep | 0 .../apply/app-combined/tree/pages/_nav.ts | 15 ++ .../apply/app-combined/tree/void.config.ts | 9 + .../binding-skip/examples/base/README.md | 3 + .../binding-skip/examples/base/example.json | 6 + .../binding-skip/examples/base/pages/base.ts | 1 + .../binding-skip/examples/child/README.md | 3 + .../binding-skip/examples/child/example.json | 11 + .../examples/child/pages/child.ts | 1 + .../apply/binding-skip/expected/pages/base.ts | 1 + .../binding-skip/expected/pages/child.ts | 1 + .../binding-skip/expected/void.config.ts | 10 + .../fixtures/apply/binding-skip/request.json | 1 + .../apply/binding-skip/tree/void.config.ts | 9 + .../outer/tests/fixtures/apply/combined.json | 1 - .../outer/tests/fixtures/apply/conflict.json | 1 - .../apply/conflict/examples/clash/README.md | 3 + .../conflict/examples/clash/example.json | 6 + .../conflict/examples/clash/pages/thing.ts | 1 + .../apply/conflict/expected-error.txt | 1 + .../fixtures/apply/conflict/request.json | 1 + .../apply/conflict/tree/pages/thing.ts | 1 + .../examples/extra/README.md | 3 + .../examples/extra/db/migrations/0001_a.sql | 1 + .../examples/extra/db/migrations/0007_b.sql | 1 + .../examples/extra/example.json | 6 + .../examples/extra/pages/extra.ts | 1 + .../expected/db/migrations/0003_a.sql | 1 + .../expected/db/migrations/0004_b.sql | 1 + .../expected/pages/extra.ts | 1 + .../apply/migration-renumber/request.json | 1 + .../tree/db/migrations/0001_first.sql | 1 + .../tree/db/migrations/0002_second.sql | 1 + .../migration-renumber/tree/package.json | 1 + .../migration-renumber/tree/pages/_nav.ts | 15 ++ .../migration-renumber/tree/void.config.ts | 9 + .../tests/fixtures/apply/missing-marker.json | 1 - .../missing-marker/examples/navvy/README.md | 3 + .../examples/navvy/example.json | 7 + .../examples/navvy/pages/navvy.ts | 1 + .../apply/missing-marker/expected-error.txt | 1 + .../apply/missing-marker/request.json | 1 + .../apply/missing-marker/tree/pages/_nav.ts | 7 + .../examples/signup/README.md | 3 + .../signup/db/migrations/0001_signup.sql | 1 + .../examples/signup/example.json | 9 + .../examples/signup/src/pages/api/signup.ts | 1 + .../examples/signup/src/pages/signup.astro | 1 + .../expected/db/migrations/0001_signup.sql | 1 + .../expected/src/pages/api/signup.ts | 1 + .../expected/src/pages/signup.astro | 1 + .../website-bindings/expected/void.config.ts | 10 + .../apply/website-bindings/request.json | 1 + .../apply/website-bindings/tree/package.json | 1 + .../website-bindings/tree/void.config.ts | 9 + web/docs/agents/start-here/examples.md | 11 +- web/docs/agents/start-here/templates.md | 2 +- web/docs/reference/tools.md | 2 +- 81 files changed, 469 insertions(+), 345 deletions(-) delete mode 100644 crates/outer/tests/fixtures/apply/README.md create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/alpha/README.md create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/alpha/db/migrations/0001_alpha.sql create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/alpha/example.json create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/alpha/pages/alpha.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/beta/README.md create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/beta/db/migrations/0001_beta.sql create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/beta/example.json create mode 100644 crates/outer/tests/fixtures/apply/app-combined/examples/beta/pages/beta.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0001_alpha.sql create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0002_beta.sql create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/pages/_nav.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/pages/alpha.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/pages/beta.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/expected/void.config.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/request.json create mode 100644 crates/outer/tests/fixtures/apply/app-combined/tree/db/migrations/.gitkeep create mode 100644 crates/outer/tests/fixtures/apply/app-combined/tree/pages/_nav.ts create mode 100644 crates/outer/tests/fixtures/apply/app-combined/tree/void.config.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/base/README.md create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/base/example.json create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/base/pages/base.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/child/README.md create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/child/example.json create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/examples/child/pages/child.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/expected/pages/base.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/expected/pages/child.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/expected/void.config.ts create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/request.json create mode 100644 crates/outer/tests/fixtures/apply/binding-skip/tree/void.config.ts delete mode 100644 crates/outer/tests/fixtures/apply/combined.json delete mode 100644 crates/outer/tests/fixtures/apply/conflict.json create mode 100644 crates/outer/tests/fixtures/apply/conflict/examples/clash/README.md create mode 100644 crates/outer/tests/fixtures/apply/conflict/examples/clash/example.json create mode 100644 crates/outer/tests/fixtures/apply/conflict/examples/clash/pages/thing.ts create mode 100644 crates/outer/tests/fixtures/apply/conflict/expected-error.txt create mode 100644 crates/outer/tests/fixtures/apply/conflict/request.json create mode 100644 crates/outer/tests/fixtures/apply/conflict/tree/pages/thing.ts create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/README.md create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0001_a.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0007_b.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/example.json create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/pages/extra.ts create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0003_a.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0004_b.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/expected/pages/extra.ts create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/request.json create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0001_first.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0002_second.sql create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/tree/package.json create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/tree/pages/_nav.ts create mode 100644 crates/outer/tests/fixtures/apply/migration-renumber/tree/void.config.ts delete mode 100644 crates/outer/tests/fixtures/apply/missing-marker.json create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/README.md create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/example.json create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/pages/navvy.ts create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/expected-error.txt create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/request.json create mode 100644 crates/outer/tests/fixtures/apply/missing-marker/tree/pages/_nav.ts create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/examples/signup/README.md create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/examples/signup/db/migrations/0001_signup.sql create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/examples/signup/example.json create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/api/signup.ts create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/signup.astro create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/expected/db/migrations/0001_signup.sql create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/api/signup.ts create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/signup.astro create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/expected/void.config.ts create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/request.json create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/tree/package.json create mode 100644 crates/outer/tests/fixtures/apply/website-bindings/tree/void.config.ts diff --git a/crates/outer/src/examples/apply.rs b/crates/outer/src/examples/apply.rs index 0c1a0f7f..18f6965c 100644 --- a/crates/outer/src/examples/apply.rs +++ b/crates/outer/src/examples/apply.rs @@ -3,9 +3,11 @@ //! [`plan_apply`] takes the app's head tree (path → full text), the app's //! template id, the known examples, and the requested names, and returns the //! file writes for one commit — or a refusal naming what to fix by hand. -//! No filesystem or git here, so the TypeScript apply engine (slice T) and -//! this one stay byte-identical on the same inputs: every formatting rule is -//! pinned below and by the golden fixtures in `tests/fixtures/apply/`. +//! No filesystem or git here, so the TypeScript apply engine (slice T, +//! `scripts/apply-example.ts`) and this one stay byte-identical on the same +//! inputs: every formatting rule is pinned below and by the shared golden +//! fixtures in `tests/fixtures/apply/` (a verbatim copy of outer-templates' +//! `scripts/apply-fixtures/`), including every refusal message byte for byte. //! //! Formatting rules (both engines must match exactly): //! @@ -13,15 +15,19 @@ //! own indentation, one per line: //! `{ href: "/photos", label: "Photos", icon: Camera },`. //! - Nav icons merge into the braces on the `// outer:nav-icons` line, -//! deduplicated and sorted byte-wise, joined with `", "`. +//! deduplicated and sorted byte-wise, joined with `", "`. An href already +//! in the file is skipped. Nav is app-only: a website apply with nav +//! refuses. //! - Binding lines insert above the `// outer:bindings` marker at the //! marker's own indentation, one key per line: //! `r2_buckets: [{ binding: "PHOTOS", bucket_name: "photos" }],`. //! Object keys sort byte-wise; entries join with `", "`. Keys insert in -//! byte-wise order. -//! - `package.json` is re-serialized whole: all keys sorted byte-wise, -//! 2-space indent, one trailing newline. New dependencies keep their given -//! versions; an existing different version refuses. +//! byte-wise order per example. A key already in the file skips when its +//! normalised value is equal, refuses when it differs. +//! - `dependencies` is not supported: an `example.json` carrying it is +//! rejected by the loader, and neither engine touches `package.json` +//! (merging it needs order-preserving edits neither engine does, and +//! re-serialising would reorder the user's file). //! - Migrations (`db/migrations/NNNN_*.sql`) renumber to the next free //! numbers after the app's highest existing migration number, keeping //! their relative (byte-wise path) order. Numbers pad to at least 4 @@ -50,7 +56,6 @@ pub struct ExampleInput { pub nav: Vec, /// `d1_databases` / `r2_buckets`: key → entries of string pairs. pub bindings: BTreeMap>>, - pub dependencies: BTreeMap, /// App-relative path → full text, byte-wise sorted. pub files: Vec<(String, String)>, } @@ -109,7 +114,7 @@ pub fn plan_apply( for example in &order { if example.template != app_template { return Err(refuse(format!( - "example {:?} is for template {:?}, not {:?}", + "example {:?} is for the {} template and cannot apply to a {} app", example.name, example.template, app_template ))); } @@ -119,6 +124,14 @@ pub fn plan_apply( .copied() .filter(|example| !is_applied(tree, example)) .collect(); + if let Some(navved) = order.iter().find(|example| !example.nav.is_empty()) { + if app_template != "app" { + return Err(refuse(format!( + "example {:?} declares nav but the {:?} template has no nav file", + navved.name, app_template + ))); + } + } if fresh.is_empty() { let names: Vec<&str> = order.iter().map(|example| example.name.as_str()).collect(); return Err(refuse(format!( @@ -128,49 +141,54 @@ pub fn plan_apply( ))); } let mut writes: BTreeMap = BTreeMap::new(); + let mut sources: HashMap = HashMap::new(); let mut nav: Vec = Vec::new(); // Migrations renumber against the app's highest existing number, shared // across every example in this apply. let mut next_migration = highest_migration(tree) + 1; for example in &fresh { - copy_files(tree, example, &mut writes, &mut next_migration)?; + copy_files( + tree, + example, + &mut writes, + &mut sources, + &mut next_migration, + )?; } + // Nav first, then bindings: every fresh example reads the accumulated + // writes, so shared hrefs insert once and shared keys compare equal. for example in &fresh { - if !example.nav.is_empty() { - if app_template != "app" { - return Err(refuse(format!( - "example {:?} declares nav but the {:?} template has no nav file", - example.name, app_template - ))); - } - // Earlier examples in this apply win the read: prefer the - // accumulated writes over the head tree. - let current = writes - .get("pages/_nav.ts") - .or_else(|| tree.get("pages/_nav.ts")) - .ok_or_else(|| refuse("pages/_nav.ts is missing: restore it, then retry"))?; - let (updated, inserted) = apply_nav(current, &example.nav, &example.name)?; - if !inserted.is_empty() { - writes.insert("pages/_nav.ts".to_string(), updated); - nav.extend(inserted); - } + if example.nav.is_empty() { + continue; } - if !example.bindings.is_empty() { - let current = writes - .get("void.config.ts") - .or_else(|| tree.get("void.config.ts")) - .ok_or_else(|| refuse("void.config.ts is missing: restore it, then retry"))?; - let updated = apply_bindings(current, &example.bindings, &example.name)?; - writes.insert("void.config.ts".to_string(), updated); + let current = writes + .get("pages/_nav.ts") + .or_else(|| tree.get("pages/_nav.ts")) + .ok_or_else(|| { + refuse( + "refusing to apply nav: pages/_nav.ts is missing (add the nav lines by hand — see the example README \"Without add_example\")", + ) + })?; + let (updated, inserted) = apply_nav(current, &example.nav)?; + if !inserted.is_empty() { + writes.insert("pages/_nav.ts".to_string(), updated); + nav.extend(inserted); } - if !example.dependencies.is_empty() { - let current = writes - .get("package.json") - .or_else(|| tree.get("package.json")) - .ok_or_else(|| refuse("package.json is missing: restore it, then retry"))?; - let updated = apply_package_json(current, &example.dependencies)?; - writes.insert("package.json".to_string(), updated); + } + for example in &fresh { + if example.bindings.is_empty() { + continue; } + let current = writes + .get("void.config.ts") + .or_else(|| tree.get("void.config.ts")) + .ok_or_else(|| { + refuse( + "refusing to apply bindings: void.config.ts is missing (add the bindings by hand — see the example README \"Without add_example\")", + ) + })?; + let updated = apply_bindings(current, &example.bindings, &example.name)?; + writes.insert("void.config.ts".to_string(), updated); } let files: Vec = writes.keys().cloned().collect(); Ok(ApplyPlan { @@ -181,7 +199,7 @@ pub fn plan_apply( }) } -/// Depth-first `requires` resolution: dependencies first, de-duplicated, +/// Depth-first `requires` resolution: requires first, de-duplicated, /// request order kept. Refuses cycles and chains deeper than 3. fn resolve<'a>( requested: &[&str], @@ -193,7 +211,6 @@ fn resolve<'a>( order: &mut Vec<&'a ExampleInput>, seen: &mut HashSet, stack: &mut Vec, - depth: usize, ) -> Result<(), Refusal> { if seen.contains(name) { return Ok(()); @@ -203,17 +220,24 @@ fn resolve<'a>( cycle.push(name.to_string()); return Err(refuse(format!("requires cycle: {}", cycle.join(" -> ")))); } - if depth > 3 { + if stack.len() >= 3 { + let mut chain = stack.clone(); + chain.push(name.to_string()); return Err(refuse(format!( - "requires chain for {name:?} is deeper than 3" + "requires chain deeper than 3: {}", + chain.join(" -> ") ))); } - let example = known - .get(name) - .ok_or_else(|| refuse(format!("unknown example {name:?}")))?; + let example = known.get(name).ok_or_else(|| { + refuse(format!( + "example {:?} requires unknown example {:?}", + stack.last().map(String::as_str).unwrap_or(name), + name + )) + })?; stack.push(name.to_string()); for dep in &example.requires { - visit(dep, known, order, seen, stack, depth + 1)?; + visit(dep, known, order, seen, stack)?; } stack.pop(); seen.insert(name.to_string()); @@ -224,7 +248,7 @@ fn resolve<'a>( let mut seen = HashSet::new(); let mut stack = Vec::new(); for name in requested { - visit(name, known, &mut order, &mut seen, &mut stack, 0)?; + visit(name, known, &mut order, &mut seen, &mut stack)?; } Ok(order) } @@ -265,11 +289,13 @@ fn highest_migration(tree: &BTreeMap) -> u64 { } /// Copy an example's files: migrations renumber, other paths must be new or -/// byte-identical. +/// byte-identical. `sources` remembers which earlier example planned each +/// path, so a clash between two examples names both. fn copy_files( tree: &BTreeMap, example: &ExampleInput, writes: &mut BTreeMap, + sources: &mut HashMap, next_migration: &mut u64, ) -> Result<(), Refusal> { let mut migrations: Vec<&(String, String)> = Vec::new(); @@ -284,12 +310,20 @@ fn copy_files( for (path, text) in plain { if let Some(have) = tree.get(path).or_else(|| writes.get(path)) { if have != text { + if let Some(first) = sources.get(path) { + return Err(refuse(format!( + "refusing to apply {:?}: {path} is also shipped by {:?} with different content", + example.name, first + ))); + } return Err(refuse(format!( - "{path} already exists with different content: move it aside or merge it by hand, then retry" + "refusing to apply {:?}: {path} already exists with different content (delete or merge it by hand, then re-run)", + example.name ))); } } else { writes.insert(path.clone(), text.clone()); + sources.insert(path.clone(), example.name.clone()); } } migrations.sort_by(|a, b| a.0.cmp(&b.0)); @@ -302,14 +336,24 @@ fn copy_files( .unwrap_or(path.as_str()); let dest = format!("db/migrations/{:04}_{suffix}", *next_migration); *next_migration += 1; - if let Some(have) = tree.get(&dest).or_else(|| writes.get(&dest)) { + if let Some(have) = writes.get(&dest) { + if have != text { + return Err(refuse(format!( + "refusing to apply {:?}: {dest} collides (also shipped by {:?})", + example.name, + sources.get(&dest).map(String::as_str).unwrap_or("?") + ))); + } + } else if let Some(have) = tree.get(&dest) { if have != text { return Err(refuse(format!( - "{dest} already exists with different content: move it aside or merge it by hand, then retry" + "refusing to apply {:?}: {dest} already exists with different content (delete or merge it by hand, then re-run)", + example.name ))); } } else { - writes.insert(dest, text.clone()); + writes.insert(dest.clone(), text.clone()); + sources.insert(dest, example.name.clone()); } } Ok(()) @@ -325,16 +369,13 @@ pub fn is_valid_icon(name: &str) -> bool { } /// Merge `items` into `pages/_nav.ts` at its marker lines. -fn apply_nav( - nav_ts: &str, - items: &[NavItem], - example: &str, -) -> Result<(String, Vec), Refusal> { +fn apply_nav(nav_ts: &str, items: &[NavItem]) -> Result<(String, Vec), Refusal> { let manual = |marker: &str| { refuse(format!( - "pages/_nav.ts has no {marker} marker (the agent restructured it): add the {example} nav items by hand — see the {example} README's \"Without add_example\" section" + "refusing to apply nav: pages/_nav.ts has no {marker} marker (the nav was restructured). Add the nav lines by hand — see the example README \"Without add_example\"" )) }; + let icons_idx = nav_ts .lines() .position(|line| line.contains("outer:nav-icons")) @@ -394,7 +435,7 @@ fn apply_nav( lines.insert( items_idx + i, format!( - "{indent}{{ href: {:?}, label: {:?}, icon: {} }},", + "{indent}{{ href: \"{}\", label: \"{}\", icon: {} }},", item.href, item.label, item.icon ), ); @@ -439,9 +480,9 @@ fn apply_bindings( .lines() .position(|line| line.contains("outer:bindings")) .ok_or_else(|| { - refuse(format!( - "void.config.ts has no // outer:bindings marker (the agent restructured it): add the {example} bindings by hand — see the {example} README's \"Without add_example\" section" - )) + refuse( + "refusing to apply bindings: void.config.ts has no // outer:bindings marker (the config was restructured). Add the bindings by hand — see the example README \"Without add_example\"", + ) })?; let mut lines: Vec = config.lines().map(str::to_string).collect(); let trailing_newline = config.ends_with('\n'); @@ -461,7 +502,8 @@ fn apply_bindings( let want = want.trim_end_matches(','); if normalize(&have) != normalize(&format!("[{want}")) { return Err(refuse(format!( - "void.config.ts already declares {key} with a different value ({have}): the one-D1 / one-R2 cap allows a single entry — align it by hand, then retry" + "refusing to apply {example:?}: void.config.ts already declares {key} with a different value (existing entry wins: {})", + have.trim() ))); } } @@ -482,7 +524,7 @@ fn render_binding(key: &str, entries: &[BTreeMap]) -> String { let parts: Vec = entries .iter() .map(|entry| { - let fields: Vec = entry.iter().map(|(k, v)| format!("{k}: {v:?}")).collect(); + let fields: Vec = entry.iter().map(|(k, v)| format!("{k}: \"{v}\"")).collect(); format!("{{ {} }}", fields.join(", ")) }) .collect(); @@ -558,49 +600,5 @@ fn normalize(value: &str) -> String { value.chars().filter(|c| !c.is_whitespace()).collect() } -/// Merge `deps` into `package.json`'s `dependencies`. An existing different -/// version refuses. The file re-serializes whole: keys sorted, 2-space -/// indent, one trailing newline. -fn apply_package_json( - package_json: &str, - deps: &BTreeMap, -) -> Result { - let mut value: serde_json::Value = serde_json::from_str(package_json) - .map_err(|err| refuse(format!("package.json is not valid JSON: {err}")))?; - let Some(obj) = value.as_object_mut() else { - return Err(refuse("package.json is not a JSON object".to_string())); - }; - let entry = obj - .entry("dependencies".to_string()) - .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); - let Some(current) = entry.as_object_mut() else { - return Err(refuse( - "package.json \"dependencies\" is not an object".to_string(), - )); - }; - for (name, version) in deps { - match current.get(name) { - None => { - current.insert(name.clone(), serde_json::Value::String(version.clone())); - } - Some(have) => { - if have.as_str() != Some(version.as_str()) { - return Err(refuse(format!( - "package.json already depends on {name}@{have} (want {version}): align it by hand, then retry", - have = have - .as_str() - .unwrap_or(&have.to_string()) - .trim_matches('"') - ))); - } - } - } - } - let mut out = serde_json::to_string_pretty(&value) - .map_err(|err| refuse(format!("package.json failed to serialize: {err}")))?; - out.push('\n'); - Ok(out) -} - #[cfg(test)] mod tests; diff --git a/crates/outer/src/examples/apply/tests.rs b/crates/outer/src/examples/apply/tests.rs index 5668121a..1e2bcdbe 100644 --- a/crates/outer/src/examples/apply/tests.rs +++ b/crates/outer/src/examples/apply/tests.rs @@ -1,8 +1,8 @@ //! Behaviour tests for the apply engine: the contract's formatting rules. //! -//! The `golden/` directory holds the same cases as JSON so the TypeScript -//! engine (slice T) tests against identical inputs; [`golden_cases`] runs -//! them through [`plan_apply`](super::plan_apply). +//! `tests/fixtures/apply/` is a verbatim copy of outer-templates' +//! `scripts/apply-fixtures/`, the cases the TypeScript engine is tested on; +//! [`shared_golden_cases_match_the_typescript_engine`] runs them here. use super::*; use std::collections::BTreeMap; @@ -21,7 +21,6 @@ fn example(name: &str, template: &str, files: &[(&str, &str)]) -> ExampleInput { requires: Vec::new(), nav: Vec::new(), bindings: BTreeMap::new(), - dependencies: BTreeMap::new(), files: files .iter() .map(|(path, text)| (path.to_string(), text.to_string())) @@ -330,43 +329,6 @@ fn bindings_missing_marker_refuses() { assert!(err.0.contains("void.config.ts"), "unexpected: {}", err.0); } -#[test] -fn dependencies_merge_and_refuse_on_conflict() { - let tree = app_tree(); - let mut ex = example("photos", "app", &[("pages/photos.tsx", "p\n")]); - ex.dependencies = BTreeMap::from([("some-pkg".to_string(), "^1.0.0".to_string())]); - let plan = plan_apply(&tree, "app", &[ex.clone()], &["photos"]).unwrap(); - let pkg = plan - .writes - .iter() - .find(|(path, _)| path == "package.json") - .expect("package write") - .1 - .clone(); - assert_eq!( - pkg, - "{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n },\n \"name\": \"seed\",\n \"private\": true\n}\n", - "package: {pkg}" - ); - // Same version is fine; a different one refuses. - let mut tree2 = tree.clone(); - tree2.insert("package.json".to_string(), pkg); - let plan2 = plan_apply(&tree2, "app", &[ex.clone()], &["photos"]).unwrap(); - assert!(plan2 - .writes - .iter() - .any(|(path, _)| path == "pages/photos.tsx")); - let mut ex3 = example("photos", "app", &[("pages/photos.tsx", "p\n")]); - ex3.dependencies = BTreeMap::from([("some-pkg".to_string(), "^2.0.0".to_string())]); - let mut tree3 = tree.clone(); - tree3.insert( - "package.json".to_string(), - "{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n }\n}\n".to_string(), - ); - let err = plan_apply(&tree3, "app", &[ex3], &["photos"]).unwrap_err(); - assert!(err.0.contains("some-pkg"), "unexpected: {}", err.0); -} - #[test] fn icon_names_validate() { assert!(is_valid_icon("Camera")); @@ -377,129 +339,78 @@ fn icon_names_validate() { assert!(!is_valid_icon("Has Space")); } -/// The JSON golden fixtures both engines test against: input tree + -/// examples + request → expected writes or refusal. +/// Every file under `dir` as app-relative path → text, byte-wise sorted. +fn read_tree(dir: &std::path::Path) -> BTreeMap { + fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut BTreeMap) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + walk(root, &path, out); + } else { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .into_owned(); + out.insert(rel, std::fs::read_to_string(&path).unwrap()); + } + } + } + let mut out = BTreeMap::new(); + if dir.is_dir() { + walk(dir, dir, &mut out); + } + out +} + +/// The golden cases shared with the TypeScript engine in outer-templates +/// (`scripts/apply-fixtures/`, vendored verbatim into +/// `tests/fixtures/apply/`): each case is `tree/` + `examples//` + +/// `request.json` → `expected/` (exactly the written files) or +/// `expected-error.txt` (the refusal, byte for byte). Examples load through +/// the real `example.json` loader, so both engines read the same inputs. #[test] -fn golden_cases() { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/apply"); - let mut cases: Vec<_> = std::fs::read_dir(&dir) - .unwrap_or_else(|_| panic!("missing golden fixtures at {}", dir.display())) +fn shared_golden_cases_match_the_typescript_engine() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/apply"); + let mut cases: Vec<_> = std::fs::read_dir(&root) + .unwrap_or_else(|_| panic!("missing golden fixtures at {}", root.display())) .flatten() .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .filter(|path| path.is_dir()) .collect(); cases.sort(); - assert!(!cases.is_empty(), "no golden cases in {}", dir.display()); - for path in cases { - let raw = std::fs::read(&path).unwrap(); - let case: serde_json::Value = serde_json::from_slice(&raw).unwrap(); - let tree: BTreeMap = case["tree"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(); - let examples: Vec = case["examples"] + assert!(cases.len() >= 5, "golden cases in {}", root.display()); + for case in cases { + let request: serde_json::Value = + serde_json::from_slice(&std::fs::read(case.join("request.json")).unwrap()).unwrap(); + let template = request["template"].as_str().unwrap(); + let names: Vec<&str> = request["names"] .as_array() .unwrap() .iter() - .map(|example| ExampleInput { - name: example["name"].as_str().unwrap().to_string(), - template: example["template"].as_str().unwrap().to_string(), - requires: example["requires"] - .as_array() - .unwrap() - .iter() - .map(|dep| dep.as_str().unwrap().to_string()) - .collect(), - nav: example["nav"] - .as_array() - .unwrap() - .iter() - .map(|item| NavItem { - href: item["href"].as_str().unwrap().to_string(), - label: item["label"].as_str().unwrap().to_string(), - icon: item["icon"].as_str().unwrap().to_string(), - }) - .collect(), - bindings: example["bindings"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| { - ( - k.clone(), - v.as_array() - .unwrap() - .iter() - .map(|entry| { - entry - .as_object() - .unwrap() - .iter() - .map(|(fk, fv)| { - (fk.clone(), fv.as_str().unwrap().to_string()) - }) - .collect() - }) - .collect(), - ) - }) - .collect(), - dependencies: example["dependencies"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(), - files: example["files"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(), - }) + .map(|name| name.as_str().unwrap()) .collect(); - let request: Vec = case["request"] - .as_array() + let examples: Vec = std::fs::read_dir(case.join("examples")) .unwrap() - .iter() - .map(|name| name.as_str().unwrap().to_string()) + .flatten() + .map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + crate::examples::load_example(&entry.path(), &name) + .unwrap_or_else(|| panic!("{}: example {name} fails to load", case.display())) + .apply_input() + }) .collect(); - let borrowed: Vec<&str> = request.iter().map(String::as_str).collect(); - let template = case["template"].as_str().unwrap(); - match plan_apply(&tree, template, &examples, &borrowed) { - Ok(plan) => { - let expect = &case["expect"]; - assert!( - expect.get("writes").is_some(), - "{}: planned but fixture expects refusal", - path.display() - ); - let mut writes: BTreeMap = plan.writes.into_iter().collect(); - let mut expected: BTreeMap = expect["writes"] - .as_object() - .unwrap() - .iter() - .map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string())) - .collect(); - // Fixture JSON escapes are exact; compare directly. - assert_eq!(writes, expected, "{}: writes differ", path.display()); - let _ = &mut expected; - let _ = &mut writes; - } - Err(refusal) => { - let expect = &case["expect"]; - let want = expect["refuse_contains"].as_str().unwrap_or_else(|| { - panic!("{}: refused but fixture expects writes", path.display()) - }); - assert!( - refusal.0.contains(want), - "{}: refusal {:?} lacks {want:?}", - path.display(), - refusal.0 - ); - } + let tree = read_tree(&case.join("tree")); + let result = plan_apply(&tree, template, &examples, &names); + let error_file = case.join("expected-error.txt"); + if error_file.is_file() { + let want = std::fs::read_to_string(&error_file).unwrap(); + let got = result.expect_err(&format!("{}: expected a refusal", case.display())); + assert_eq!(got.0.trim_end(), want.trim_end(), "{}", case.display()); + } else { + let plan = result.unwrap_or_else(|e| panic!("{}: {e}", case.display())); + let got: BTreeMap = plan.writes.into_iter().collect(); + assert_eq!(got, read_tree(&case.join("expected")), "{}", case.display()); } } } diff --git a/crates/outer/src/examples/mod.rs b/crates/outer/src/examples/mod.rs index 181b8fc1..df587a3e 100644 --- a/crates/outer/src/examples/mod.rs +++ b/crates/outer/src/examples/mod.rs @@ -32,7 +32,6 @@ pub struct Summary { pub title: String, pub summary: String, pub template: String, - pub dependencies: BTreeMap, pub files: Vec, } @@ -59,7 +58,6 @@ pub struct Example { pub title: String, pub summary: String, pub template: String, - pub dependencies: BTreeMap, pub requires: Vec, pub nav: Vec, pub bindings: BTreeMap>>, @@ -83,8 +81,8 @@ pub fn is_valid_name(name: &str) -> bool { } /// The `example.json` contract: title, summary, template, plus optional -/// `requires`, `nav`, `bindings` and `dependencies`. Unknown fields are -/// ignored. +/// `requires`, `nav` and `bindings`. `dependencies` is not supported and +/// rejects the example; unknown fields are ignored. #[derive(Debug, Deserialize)] struct NavJson { href: String, @@ -104,7 +102,7 @@ struct ExampleJson { #[serde(default)] bindings: BTreeMap>>, #[serde(default)] - dependencies: BTreeMap, + dependencies: Option, } /// Largest single file an example may carry (contract: 64 KiB). @@ -162,7 +160,6 @@ impl Example { title: self.title.clone(), summary: self.summary.clone(), template: self.template.clone(), - dependencies: self.dependencies.clone(), files: self .files .iter() @@ -190,7 +187,6 @@ impl Example { }) .collect(), bindings: self.bindings.clone(), - dependencies: self.dependencies.clone(), files: self .files .iter() @@ -403,6 +399,9 @@ fn load_example(dir: &Path, name: &str) -> Option { if crate::templates::Template::from_id(&meta.template).is_none() { return reject(&format!("bad template {:?}", meta.template)); } + if meta.dependencies.is_some() { + return reject("example.json \"dependencies\" is not supported (examples ship files only; add_example never touches package.json)"); + } for dep in &meta.requires { if !is_valid_name(dep) { return reject(&format!("bad requires entry {dep:?}")); @@ -463,7 +462,6 @@ fn load_example(dir: &Path, name: &str) -> Option { title: meta.title, summary: meta.summary, template: meta.template, - dependencies: meta.dependencies, requires: meta.requires, nav, bindings: meta.bindings, @@ -581,7 +579,6 @@ mod tests { "title": title, "summary": summary, "template": template, - "dependencies": {"left-pad": "^1.3.0"}, }) .to_string() } @@ -646,10 +643,6 @@ mod tests { assert_eq!(all[1].title, "List form"); assert_eq!(all[1].summary, "A D1-backed list with add and complete."); assert_eq!(all[1].template, "app"); - assert_eq!( - all[1].dependencies.get("left-pad").map(String::as_str), - Some("^1.3.0") - ); let paths: Vec<_> = all[1].files.iter().map(|file| file.path.as_str()).collect(); assert_eq!(paths, ["pages/list.server.ts", "pages/list.tsx"]); assert!(all[1].files.iter().all(|file| file.lines > 0)); @@ -673,6 +666,36 @@ mod tests { ); } + #[tokio::test] + async fn an_example_carrying_dependencies_is_skipped() { + let store = Store::memory(); + let json = serde_json::json!({ + "name": "need", + "title": "Need", + "summary": "Wants a package.", + "template": "app", + "dependencies": {"some-pkg": "^1.0.0"}, + }) + .to_string(); + fixture( + &store, + &[( + "need", + &[ + ("example.json", json.as_str()), + ("README.md", "# Need\n"), + ("pages/need.ts", "export const need = 1;\n"), + ], + )], + &[], + ) + .await; + let state = tool_state(store).await; + let dir = &state.delete_cfg.data_dir; + assert!(list(&state.store, dir, None).await.unwrap().is_empty()); + assert!(get(&state.store, dir, "need").await.unwrap().is_none()); + } + #[tokio::test] async fn get_returns_the_readme_and_files() { let state = two_example_state().await; diff --git a/crates/outer/src/mcp/registry.rs b/crates/outer/src/mcp/registry.rs index fe4dbe68..dcfce616 100644 --- a/crates/outer/src/mcp/registry.rs +++ b/crates/outer/src/mcp/registry.rs @@ -468,7 +468,7 @@ pub fn registry() -> Vec { ), tool::( "add_example", - "Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and deps in one change and deploys. Customise with commit after.", + "Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and migrations in one change and deploys. Customise with commit after.", Account, false, ), ] diff --git a/crates/outer/src/mcp/tests.rs b/crates/outer/src/mcp/tests.rs index 1f6505a4..0e5423b7 100644 --- a/crates/outer/src/mcp/tests.rs +++ b/crates/outer/src/mcp/tests.rs @@ -7988,7 +7988,7 @@ async fn setup_apply() -> (McpState, Codes) { &[ ( "passkeys", - r#"{"title": "Passkeys", "summary": "Sign in with passkeys.", "template": "app", "requires": [], "nav": [{"href": "/sign-in", "label": "Sign in", "icon": "KeyRound"}, {"href": "/members", "label": "Members", "icon": "Users"}], "bindings": {}, "dependencies": {}}"#, + r#"{"title": "Passkeys", "summary": "Sign in with passkeys.", "template": "app", "requires": [], "nav": [{"href": "/sign-in", "label": "Sign in", "icon": "KeyRound"}, {"href": "/members", "label": "Members", "icon": "Users"}], "bindings": {}}"#, &[ ("README.md", "# Passkeys\n\nSign in.\n"), ( @@ -8003,7 +8003,7 @@ async fn setup_apply() -> (McpState, Codes) { ), ( "photos", - r#"{"title": "Photos", "summary": "A photo feed.", "template": "app", "requires": ["passkeys"], "nav": [{"href": "/photos", "label": "Photos", "icon": "Camera"}], "bindings": {"r2_buckets": [{"binding": "PHOTOS", "bucket_name": "photos"}]}, "dependencies": {"some-pkg": "^1.0.0"}}"#, + r#"{"title": "Photos", "summary": "A photo feed.", "template": "app", "requires": ["passkeys"], "nav": [{"href": "/photos", "label": "Photos", "icon": "Camera"}], "bindings": {"r2_buckets": [{"binding": "PHOTOS", "bucket_name": "photos"}]}}"#, &[ ("README.md", "# Photos\n\nPost photos.\n"), ( @@ -8018,7 +8018,7 @@ async fn setup_apply() -> (McpState, Codes) { ), ( "newsletter", - r#"{"title": "Newsletter", "summary": "Email signup.", "template": "website", "requires": [], "nav": [], "bindings": {"d1_databases": [{"binding": "DB", "database_name": "newsletter"}]}, "dependencies": {}}"#, + r#"{"title": "Newsletter", "summary": "Email signup.", "template": "website", "requires": [], "nav": [], "bindings": {"d1_databases": [{"binding": "DB", "database_name": "newsletter"}]}}"#, &[ ("README.md", "# Newsletter\n\nSubscribe.\n"), ("src/pages/subscribe.astro", "---\n---\n

Subscribe

\n"), @@ -8133,8 +8133,8 @@ async fn add_example_applies_requires_nav_bindings_and_deploys() { config.contains("r2_buckets: [{ binding: \"PHOTOS\", bucket_name: \"photos\" }],"), "{config}" ); - let package: Value = serde_json::from_str(&by_path("package.json")).unwrap(); - assert_eq!(package["dependencies"]["some-pkg"], "^1.0.0"); + // add_example never touches package.json: the seed file is byte-identical. + assert_eq!(by_path("package.json"), APPLY_PACKAGE); assert_eq!( by_path("pages/photos.tsx"), "export default function Photos() {\n return

Photos

;\n}\n" diff --git a/crates/outer/src/mcp/tools/add_example.rs b/crates/outer/src/mcp/tools/add_example.rs index 9fd28d23..16d8aebc 100644 --- a/crates/outer/src/mcp/tools/add_example.rs +++ b/crates/outer/src/mcp/tools/add_example.rs @@ -165,7 +165,7 @@ fn add_summary( /// The head tree as the apply engine sees it: every non-migration file the /// closure touches (with text), plus the `db/migrations/` names (numbers -/// only) and the three edited files. +/// only) and the two edited files. async fn head_tree( bare: &std::path::Path, inputs: &[crate::examples::apply::ExampleInput], @@ -178,7 +178,6 @@ async fn head_tree( } need.insert("pages/_nav.ts"); need.insert("void.config.ts"); - need.insert("package.json"); let mut tree = BTreeMap::new(); for path in need { match crate::git::local::read_blob(bare, None, path).await { diff --git a/crates/outer/src/mcp/tools/examples.rs b/crates/outer/src/mcp/tools/examples.rs index fc7d12ca..df887481 100644 --- a/crates/outer/src/mcp/tools/examples.rs +++ b/crates/outer/src/mcp/tools/examples.rs @@ -73,7 +73,6 @@ async fn list_examples( "title": example.title, "summary": example.summary, "template": example.template, - "dependencies": example.dependencies, "files": example.files.iter().map(|file| { json!({"path": file.path, "lines": file.lines}) }).collect::>(), @@ -227,7 +226,6 @@ fn render_example(example: &crate::examples::Example, with_files: bool) -> Value json!({"href": item.href, "label": item.label, "icon": item.icon}) }).collect::>(), "bindings": example.bindings, - "dependencies": example.dependencies, "readme": example.readme, "files": files, "truncated": false, diff --git a/crates/outer/tests/fixtures/apply/README.md b/crates/outer/tests/fixtures/apply/README.md deleted file mode 100644 index 5d7ebe72..00000000 --- a/crates/outer/tests/fixtures/apply/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# Apply golden fixtures - -One JSON case per file. Both apply engines (Rust `crates/outer/src/examples/apply.rs`, -TypeScript `scripts/apply-example.ts` in outer-templates) run these inputs and must -produce byte-identical outputs. - -## Format - -```json -{ - "template": "app", - "tree": { "path": "full text\n", "...": "..." }, - "examples": [ - { - "name": "photos", - "template": "app", - "requires": ["passkeys"], - "nav": [{ "href": "/photos", "label": "Photos", "icon": "Camera" }], - "bindings": { "r2_buckets": [{ "binding": "PHOTOS", "bucket_name": "photos" }] }, - "dependencies": { "some-pkg": "^1.0.0" }, - "files": { "pages/photos.tsx": "...", "db/migrations/0001_photos.sql": "..." } - } - ], - "request": ["photos"], - "expect": { - "writes": { "pages/_nav.ts": "...", "...": "..." } - } -} -``` - -or `"expect": { "refuse_contains": "pages/_nav.ts" }`. - -## Pinned formatting rules - -- Nav items insert above the `// outer:nav-items` marker at the marker's own - indentation: `{ href: "/photos", label: "Photos", icon: Camera },`. -- Nav icons merge into the braces on the `// outer:nav-icons` line, deduplicated - and sorted byte-wise, joined with `", "`. -- Binding lines insert above the `// outer:bindings` marker at the marker's own - indentation: `r2_buckets: [{ binding: "PHOTOS", bucket_name: "photos" }],`. - Object keys sort byte-wise; entries join with `", "`. Keys insert byte-wise. -- `package.json` re-serializes whole: all keys sorted byte-wise, 2-space indent, - one trailing newline. -- Migrations renumber to the next free numbers after the app's highest existing - migration, keeping byte-wise path order; numbers pad to at least 4 digits. diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/README.md b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/README.md new file mode 100644 index 00000000..ca45d454 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/README.md @@ -0,0 +1,3 @@ +# alpha + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/db/migrations/0001_alpha.sql b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/db/migrations/0001_alpha.sql new file mode 100644 index 00000000..f803cf49 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/db/migrations/0001_alpha.sql @@ -0,0 +1 @@ +CREATE TABLE alpha (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/example.json b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/example.json new file mode 100644 index 00000000..aae01a2b --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/example.json @@ -0,0 +1,8 @@ +{ + "name": "alpha", + "title": "Alpha", + "summary": "First fixture example.", + "template": "app", + "nav": [{ "href": "/alpha", "label": "Alpha", "icon": "Sparkles" }], + "bindings": { "r2_buckets": [{ "binding": "ALPHA_BUCKET", "bucket_name": "alpha" }] } +} diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/pages/alpha.ts b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/pages/alpha.ts new file mode 100644 index 00000000..eb2d7c53 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/alpha/pages/alpha.ts @@ -0,0 +1 @@ +export const alpha = 1; diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/beta/README.md b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/README.md new file mode 100644 index 00000000..411769bc --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/README.md @@ -0,0 +1,3 @@ +# beta + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/beta/db/migrations/0001_beta.sql b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/db/migrations/0001_beta.sql new file mode 100644 index 00000000..9228f845 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/db/migrations/0001_beta.sql @@ -0,0 +1 @@ +CREATE TABLE beta (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/beta/example.json b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/example.json new file mode 100644 index 00000000..e0daf780 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/example.json @@ -0,0 +1,8 @@ +{ + "name": "beta", + "title": "Beta", + "summary": "Second fixture example.", + "template": "app", + "requires": ["alpha"], + "nav": [{ "href": "/beta", "label": "Beta", "icon": "Camera" }] +} diff --git a/crates/outer/tests/fixtures/apply/app-combined/examples/beta/pages/beta.ts b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/pages/beta.ts new file mode 100644 index 00000000..58514254 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/examples/beta/pages/beta.ts @@ -0,0 +1 @@ +export const beta = 2; diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0001_alpha.sql b/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0001_alpha.sql new file mode 100644 index 00000000..f803cf49 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0001_alpha.sql @@ -0,0 +1 @@ +CREATE TABLE alpha (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0002_beta.sql b/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0002_beta.sql new file mode 100644 index 00000000..9228f845 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/db/migrations/0002_beta.sql @@ -0,0 +1 @@ +CREATE TABLE beta (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/pages/_nav.ts b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/_nav.ts new file mode 100644 index 00000000..74ae9d44 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/_nav.ts @@ -0,0 +1,17 @@ +// The nav in one place. +import { Camera, House, Settings, Sparkles } from "lucide"; // outer:nav-icons +import type { IconNode } from "lucide"; + +export interface NavItem { + href: string; + label: string; + icon: IconNode; +} + +export const NAV: readonly NavItem[] = [ + { href: "/", label: "Home", icon: House }, + { href: "/alpha", label: "Alpha", icon: Sparkles }, + { href: "/beta", label: "Beta", icon: Camera }, + // outer:nav-items — add_example inserts pages above this line + { href: "/settings", label: "Settings", icon: Settings }, +]; diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/pages/alpha.ts b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/alpha.ts new file mode 100644 index 00000000..eb2d7c53 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/alpha.ts @@ -0,0 +1 @@ +export const alpha = 1; diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/pages/beta.ts b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/beta.ts new file mode 100644 index 00000000..58514254 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/pages/beta.ts @@ -0,0 +1 @@ +export const beta = 2; diff --git a/crates/outer/tests/fixtures/apply/app-combined/expected/void.config.ts b/crates/outer/tests/fixtures/apply/app-combined/expected/void.config.ts new file mode 100644 index 00000000..f70f9659 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/expected/void.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-app", + d1_databases: [{ binding: "DB", database_name: "app-db", migrations_dir: "db/migrations" }], + r2_buckets: [{ binding: "ALPHA_BUCKET", bucket_name: "alpha" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/app-combined/request.json b/crates/outer/tests/fixtures/apply/app-combined/request.json new file mode 100644 index 00000000..8f12bfd9 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/request.json @@ -0,0 +1 @@ +{"template": "app", "names": ["beta"]} diff --git a/crates/outer/tests/fixtures/apply/app-combined/tree/db/migrations/.gitkeep b/crates/outer/tests/fixtures/apply/app-combined/tree/db/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/crates/outer/tests/fixtures/apply/app-combined/tree/pages/_nav.ts b/crates/outer/tests/fixtures/apply/app-combined/tree/pages/_nav.ts new file mode 100644 index 00000000..6992ae72 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/tree/pages/_nav.ts @@ -0,0 +1,15 @@ +// The nav in one place. +import { House, Settings } from "lucide"; // outer:nav-icons +import type { IconNode } from "lucide"; + +export interface NavItem { + href: string; + label: string; + icon: IconNode; +} + +export const NAV: readonly NavItem[] = [ + { href: "/", label: "Home", icon: House }, + // outer:nav-items — add_example inserts pages above this line + { href: "/settings", label: "Settings", icon: Settings }, +]; diff --git a/crates/outer/tests/fixtures/apply/app-combined/tree/void.config.ts b/crates/outer/tests/fixtures/apply/app-combined/tree/void.config.ts new file mode 100644 index 00000000..37291ff5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/app-combined/tree/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-app", + d1_databases: [{ binding: "DB", database_name: "app-db", migrations_dir: "db/migrations" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/base/README.md b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/README.md new file mode 100644 index 00000000..b162194e --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/README.md @@ -0,0 +1,3 @@ +# base + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/base/example.json b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/example.json new file mode 100644 index 00000000..e7d3a39b --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/example.json @@ -0,0 +1,6 @@ +{ + "name": "base", + "title": "Base", + "summary": "Fixture base example.", + "template": "app" +} diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/base/pages/base.ts b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/pages/base.ts new file mode 100644 index 00000000..f561bf76 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/base/pages/base.ts @@ -0,0 +1 @@ +export const base = 1; diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/child/README.md b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/README.md new file mode 100644 index 00000000..aefe8440 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/README.md @@ -0,0 +1,3 @@ +# child + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/child/example.json b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/example.json new file mode 100644 index 00000000..4059659e --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/example.json @@ -0,0 +1,11 @@ +{ + "name": "child", + "title": "Child", + "summary": "Fixture child example.", + "template": "app", + "requires": ["base"], + "bindings": { + "r2_buckets": [{ "bucket_name": "pics", "binding": "PICS" }], + "d1_databases": [{ "binding": "DB", "database_name": "app-db", "migrations_dir": "db/migrations" }] + } +} diff --git a/crates/outer/tests/fixtures/apply/binding-skip/examples/child/pages/child.ts b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/pages/child.ts new file mode 100644 index 00000000..174ed848 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/examples/child/pages/child.ts @@ -0,0 +1 @@ +export const child = 1; diff --git a/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/base.ts b/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/base.ts new file mode 100644 index 00000000..f561bf76 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/base.ts @@ -0,0 +1 @@ +export const base = 1; diff --git a/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/child.ts b/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/child.ts new file mode 100644 index 00000000..174ed848 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/expected/pages/child.ts @@ -0,0 +1 @@ +export const child = 1; diff --git a/crates/outer/tests/fixtures/apply/binding-skip/expected/void.config.ts b/crates/outer/tests/fixtures/apply/binding-skip/expected/void.config.ts new file mode 100644 index 00000000..5e06d993 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/expected/void.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-app", + d1_databases: [{ binding: "DB", database_name: "app-db", migrations_dir: "db/migrations" }], + r2_buckets: [{ binding: "PICS", bucket_name: "pics" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/binding-skip/request.json b/crates/outer/tests/fixtures/apply/binding-skip/request.json new file mode 100644 index 00000000..244b5401 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/request.json @@ -0,0 +1 @@ +{"template": "app", "names": ["child"]} diff --git a/crates/outer/tests/fixtures/apply/binding-skip/tree/void.config.ts b/crates/outer/tests/fixtures/apply/binding-skip/tree/void.config.ts new file mode 100644 index 00000000..37291ff5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/binding-skip/tree/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-app", + d1_databases: [{ binding: "DB", database_name: "app-db", migrations_dir: "db/migrations" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/combined.json b/crates/outer/tests/fixtures/apply/combined.json deleted file mode 100644 index 66dd0c63..00000000 --- a/crates/outer/tests/fixtures/apply/combined.json +++ /dev/null @@ -1 +0,0 @@ -{"examples":[{"bindings":{},"dependencies":{},"files":{"pages/_session.ts":"export async function getViewer() {}\n","pages/sign-in.tsx":"export default function SignIn() {}\n"},"name":"passkeys","nav":[{"href":"/sign-in","icon":"KeyRound","label":"Sign in"},{"href":"/members","icon":"Users","label":"Members"}],"requires":[],"template":"app"},{"bindings":{"r2_buckets":[{"binding":"PHOTOS","bucket_name":"photos"}]},"dependencies":{"some-pkg":"^1.0.0"},"files":{"db/migrations/0001_photos.sql":"CREATE TABLE photos (id);\n","pages/photos.tsx":"export default function Photos() {}\n"},"name":"photos","nav":[{"href":"/photos","icon":"Camera","label":"Photos"}],"requires":["passkeys"],"template":"app"}],"expect":{"writes":{"db/migrations/0001_photos.sql":"CREATE TABLE photos (id);\n","package.json":"{\n \"dependencies\": {\n \"some-pkg\": \"^1.0.0\"\n },\n \"name\": \"seed\",\n \"private\": true\n}\n","pages/_nav.ts":"import { Camera, House, KeyRound, Settings, Users } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n { href: \"/sign-in\", label: \"Sign in\", icon: KeyRound },\n { href: \"/members\", label: \"Members\", icon: Users },\n { href: \"/photos\", label: \"Photos\", icon: Camera },\n // outer:nav-items — add_example inserts pages above this line\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n","pages/_session.ts":"export async function getViewer() {}\n","pages/photos.tsx":"export default function Photos() {}\n","pages/sign-in.tsx":"export default function SignIn() {}\n","void.config.ts":"export default {\n cloudflare: {\n r2_buckets: [{ binding: \"PHOTOS\", bucket_name: \"photos\" }],\n // outer:bindings — add_example inserts bindings above this line\n },\n};\n"}},"request":["photos"],"template":"app","tree":{"package.json":"{\n \"name\": \"seed\",\n \"private\": true\n}\n","pages/_nav.ts":"import { House, Settings } from \"lucide\"; // outer:nav-icons\nimport type { LucideIcon } from \"lucide\";\n\nexport const NAV: readonly NavItem[] = [\n { href: \"/\", label: \"Home\", icon: House },\n // outer:nav-items — add_example inserts pages above this line\n { href: \"/settings\", label: \"Settings\", icon: Settings },\n];\n","void.config.ts":"export default {\n cloudflare: {\n // outer:bindings — add_example inserts bindings above this line\n },\n};\n"}} \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/conflict.json b/crates/outer/tests/fixtures/apply/conflict.json deleted file mode 100644 index cb90bb78..00000000 --- a/crates/outer/tests/fixtures/apply/conflict.json +++ /dev/null @@ -1 +0,0 @@ -{"examples":[{"bindings":{},"dependencies":{},"files":{"pages/photos.tsx":"export default function Photos() {}\n"},"name":"photos","nav":[],"requires":[],"template":"app"}],"expect":{"refuse_contains":"pages/photos.tsx"},"request":["photos"],"template":"app","tree":{"package.json":"{\n \"name\": \"seed\"\n}\n","pages/_nav.ts":"import { House } from \"lucide\"; // outer:nav-icons\nexport const NAV = [];\n // outer:nav-items\n","pages/photos.tsx":"something else entirely\n","void.config.ts":"export default {};\n"}} \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/conflict/examples/clash/README.md b/crates/outer/tests/fixtures/apply/conflict/examples/clash/README.md new file mode 100644 index 00000000..fed321a5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/examples/clash/README.md @@ -0,0 +1,3 @@ +# clash + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/conflict/examples/clash/example.json b/crates/outer/tests/fixtures/apply/conflict/examples/clash/example.json new file mode 100644 index 00000000..c1629742 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/examples/clash/example.json @@ -0,0 +1,6 @@ +{ + "name": "clash", + "title": "Clash", + "summary": "Fixture conflict example.", + "template": "app" +} diff --git a/crates/outer/tests/fixtures/apply/conflict/examples/clash/pages/thing.ts b/crates/outer/tests/fixtures/apply/conflict/examples/clash/pages/thing.ts new file mode 100644 index 00000000..7fb7dd74 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/examples/clash/pages/thing.ts @@ -0,0 +1 @@ +export const thing = "new"; diff --git a/crates/outer/tests/fixtures/apply/conflict/expected-error.txt b/crates/outer/tests/fixtures/apply/conflict/expected-error.txt new file mode 100644 index 00000000..0b450cd2 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/expected-error.txt @@ -0,0 +1 @@ +refusing to apply "clash": pages/thing.ts already exists with different content (delete or merge it by hand, then re-run) \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/conflict/request.json b/crates/outer/tests/fixtures/apply/conflict/request.json new file mode 100644 index 00000000..94a355a0 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/request.json @@ -0,0 +1 @@ +{"template": "app", "names": ["clash"]} diff --git a/crates/outer/tests/fixtures/apply/conflict/tree/pages/thing.ts b/crates/outer/tests/fixtures/apply/conflict/tree/pages/thing.ts new file mode 100644 index 00000000..e07adbb5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/conflict/tree/pages/thing.ts @@ -0,0 +1 @@ +export const thing = "old"; diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/README.md b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/README.md new file mode 100644 index 00000000..97b4c0ae --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/README.md @@ -0,0 +1,3 @@ +# extra + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0001_a.sql b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0001_a.sql new file mode 100644 index 00000000..aa8793a4 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0001_a.sql @@ -0,0 +1 @@ +CREATE TABLE a (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0007_b.sql b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0007_b.sql new file mode 100644 index 00000000..6e9cf94e --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/db/migrations/0007_b.sql @@ -0,0 +1 @@ +CREATE TABLE b (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/example.json b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/example.json new file mode 100644 index 00000000..10c4f5cc --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/example.json @@ -0,0 +1,6 @@ +{ + "name": "extra", + "title": "Extra", + "summary": "Fixture renumber example.", + "template": "app" +} diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/pages/extra.ts b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/pages/extra.ts new file mode 100644 index 00000000..9e2bd429 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/examples/extra/pages/extra.ts @@ -0,0 +1 @@ +export const extra = 3; diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0003_a.sql b/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0003_a.sql new file mode 100644 index 00000000..aa8793a4 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0003_a.sql @@ -0,0 +1 @@ +CREATE TABLE a (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0004_b.sql b/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0004_b.sql new file mode 100644 index 00000000..6e9cf94e --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/expected/db/migrations/0004_b.sql @@ -0,0 +1 @@ +CREATE TABLE b (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/expected/pages/extra.ts b/crates/outer/tests/fixtures/apply/migration-renumber/expected/pages/extra.ts new file mode 100644 index 00000000..9e2bd429 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/expected/pages/extra.ts @@ -0,0 +1 @@ +export const extra = 3; diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/request.json b/crates/outer/tests/fixtures/apply/migration-renumber/request.json new file mode 100644 index 00000000..76a9f712 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/request.json @@ -0,0 +1 @@ +{"template": "app", "names": ["extra"]} diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0001_first.sql b/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0001_first.sql new file mode 100644 index 00000000..4c8ea03f --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0001_first.sql @@ -0,0 +1 @@ +CREATE TABLE first (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0002_second.sql b/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0002_second.sql new file mode 100644 index 00000000..1e53824f --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/tree/db/migrations/0002_second.sql @@ -0,0 +1 @@ +CREATE TABLE second (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/tree/package.json b/crates/outer/tests/fixtures/apply/migration-renumber/tree/package.json new file mode 100644 index 00000000..9b11b462 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/tree/package.json @@ -0,0 +1 @@ +{"name": "fixture", "private": true} diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/tree/pages/_nav.ts b/crates/outer/tests/fixtures/apply/migration-renumber/tree/pages/_nav.ts new file mode 100644 index 00000000..6992ae72 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/tree/pages/_nav.ts @@ -0,0 +1,15 @@ +// The nav in one place. +import { House, Settings } from "lucide"; // outer:nav-icons +import type { IconNode } from "lucide"; + +export interface NavItem { + href: string; + label: string; + icon: IconNode; +} + +export const NAV: readonly NavItem[] = [ + { href: "/", label: "Home", icon: House }, + // outer:nav-items — add_example inserts pages above this line + { href: "/settings", label: "Settings", icon: Settings }, +]; diff --git a/crates/outer/tests/fixtures/apply/migration-renumber/tree/void.config.ts b/crates/outer/tests/fixtures/apply/migration-renumber/tree/void.config.ts new file mode 100644 index 00000000..37291ff5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/migration-renumber/tree/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-app", + d1_databases: [{ binding: "DB", database_name: "app-db", migrations_dir: "db/migrations" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/missing-marker.json b/crates/outer/tests/fixtures/apply/missing-marker.json deleted file mode 100644 index 899f07ef..00000000 --- a/crates/outer/tests/fixtures/apply/missing-marker.json +++ /dev/null @@ -1 +0,0 @@ -{"examples": [{"bindings": {}, "dependencies": {}, "files": {"pages/photos.tsx": "export default function Photos() {}\n"}, "name": "photos", "nav": [{"href": "/photos", "icon": "Camera", "label": "Photos"}], "requires": [], "template": "app"}], "expect": {"refuse_contains": "pages/_nav.ts"}, "request": ["photos"], "template": "app", "tree": {"package.json": "{\n \"name\": \"seed\"\n}\n", "pages/_nav.ts": "export const NAV = [];\n", "void.config.ts": "export default {};\n"}} \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/README.md b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/README.md new file mode 100644 index 00000000..f38f6d0c --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/README.md @@ -0,0 +1,3 @@ +# navvy + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/example.json b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/example.json new file mode 100644 index 00000000..8ad83379 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/example.json @@ -0,0 +1,7 @@ +{ + "name": "navvy", + "title": "Navvy", + "summary": "Fixture nav example.", + "template": "app", + "nav": [{ "href": "/navvy", "label": "Navvy", "icon": "Camera" }] +} diff --git a/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/pages/navvy.ts b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/pages/navvy.ts new file mode 100644 index 00000000..cd676745 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/examples/navvy/pages/navvy.ts @@ -0,0 +1 @@ +export const navvy = 4; diff --git a/crates/outer/tests/fixtures/apply/missing-marker/expected-error.txt b/crates/outer/tests/fixtures/apply/missing-marker/expected-error.txt new file mode 100644 index 00000000..c8563fa8 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/expected-error.txt @@ -0,0 +1 @@ +refusing to apply nav: pages/_nav.ts has no // outer:nav-icons marker (the nav was restructured). Add the nav lines by hand — see the example README "Without add_example" \ No newline at end of file diff --git a/crates/outer/tests/fixtures/apply/missing-marker/request.json b/crates/outer/tests/fixtures/apply/missing-marker/request.json new file mode 100644 index 00000000..023b3a94 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/request.json @@ -0,0 +1 @@ +{"template": "app", "names": ["navvy"]} diff --git a/crates/outer/tests/fixtures/apply/missing-marker/tree/pages/_nav.ts b/crates/outer/tests/fixtures/apply/missing-marker/tree/pages/_nav.ts new file mode 100644 index 00000000..2cca0aed --- /dev/null +++ b/crates/outer/tests/fixtures/apply/missing-marker/tree/pages/_nav.ts @@ -0,0 +1,7 @@ +// Restructured by the agent: no markers left. +import { House, Settings } from "lucide"; + +export const NAV = [ + { href: "/", label: "Home", icon: House }, + { href: "/settings", label: "Settings", icon: Settings }, +]; diff --git a/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/README.md b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/README.md new file mode 100644 index 00000000..b3d54709 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/README.md @@ -0,0 +1,3 @@ +# signup + +Golden-fixture example. diff --git a/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/db/migrations/0001_signup.sql b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/db/migrations/0001_signup.sql new file mode 100644 index 00000000..2e8c95d5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/db/migrations/0001_signup.sql @@ -0,0 +1 @@ +CREATE TABLE signup (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/example.json b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/example.json new file mode 100644 index 00000000..e4a27b58 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/example.json @@ -0,0 +1,9 @@ +{ + "name": "signup", + "title": "Signup", + "summary": "Fixture newsletter-style example.", + "template": "website", + "bindings": { + "d1_databases": [{ "binding": "DB", "database_name": "site-db", "migrations_dir": "db/migrations" }] + } +} diff --git a/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/api/signup.ts b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/api/signup.ts new file mode 100644 index 00000000..07a8559b --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/api/signup.ts @@ -0,0 +1 @@ +export const POST = 1; diff --git a/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/signup.astro b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/signup.astro new file mode 100644 index 00000000..977c944c --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/examples/signup/src/pages/signup.astro @@ -0,0 +1 @@ +

Signup

diff --git a/crates/outer/tests/fixtures/apply/website-bindings/expected/db/migrations/0001_signup.sql b/crates/outer/tests/fixtures/apply/website-bindings/expected/db/migrations/0001_signup.sql new file mode 100644 index 00000000..2e8c95d5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/expected/db/migrations/0001_signup.sql @@ -0,0 +1 @@ +CREATE TABLE signup (id INTEGER PRIMARY KEY); diff --git a/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/api/signup.ts b/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/api/signup.ts new file mode 100644 index 00000000..07a8559b --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/api/signup.ts @@ -0,0 +1 @@ +export const POST = 1; diff --git a/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/signup.astro b/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/signup.astro new file mode 100644 index 00000000..977c944c --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/expected/src/pages/signup.astro @@ -0,0 +1 @@ +

Signup

diff --git a/crates/outer/tests/fixtures/apply/website-bindings/expected/void.config.ts b/crates/outer/tests/fixtures/apply/website-bindings/expected/void.config.ts new file mode 100644 index 00000000..c8ca499a --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/expected/void.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-site", + assets: { not_found_handling: "404-page" }, + d1_databases: [{ binding: "DB", database_name: "site-db", migrations_dir: "db/migrations" }], + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/crates/outer/tests/fixtures/apply/website-bindings/request.json b/crates/outer/tests/fixtures/apply/website-bindings/request.json new file mode 100644 index 00000000..bdbc47ef --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/request.json @@ -0,0 +1 @@ +{"template": "website", "names": ["signup"]} diff --git a/crates/outer/tests/fixtures/apply/website-bindings/tree/package.json b/crates/outer/tests/fixtures/apply/website-bindings/tree/package.json new file mode 100644 index 00000000..763d4342 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/tree/package.json @@ -0,0 +1 @@ +{"name": "fixture-site", "private": true} diff --git a/crates/outer/tests/fixtures/apply/website-bindings/tree/void.config.ts b/crates/outer/tests/fixtures/apply/website-bindings/tree/void.config.ts new file mode 100644 index 00000000..2c39efe5 --- /dev/null +++ b/crates/outer/tests/fixtures/apply/website-bindings/tree/void.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "void/config"; + +export default defineConfig({ + cloudflare: { + name: "fixture-site", + assets: { not_found_handling: "404-page" }, + // outer:bindings — add_example inserts bindings above this line + }, +}); diff --git a/web/docs/agents/start-here/examples.md b/web/docs/agents/start-here/examples.md index 36fc1a31..69350e2a 100644 --- a/web/docs/agents/start-here/examples.md +++ b/web/docs/agents/start-here/examples.md @@ -12,9 +12,9 @@ deployed change — you only customise copy and design afterwards. Call `add_example {app, name}`. It resolves the example's `requires` first, copies every file to the same path, adds nav entries to `pages/_nav.ts`, -merges `dependencies` into `package.json`, inserts `d1`/`r2` bindings into -`void.config.ts`, renumbers migrations past the app's highest, and commits -all of it in one change through the normal commit + gate + deploy path. +inserts `d1`/`r2` bindings into `void.config.ts`, renumbers migrations past +the app's highest, and commits all of it in one change through the normal +commit + gate + deploy path. The result names the added examples, the written files, the live hrefs, and the deploy. @@ -28,9 +28,8 @@ After applying, customise copy and design with `commit`, and verify with ## List them Call `examples` with no arguments. Filter with `template: "app"` or -`template: "website"`. Each row names the pattern, its template, its -dependencies, and its files with line counts. The result ends with its own -`next` step. +`template: "website"`. Each row names the pattern, its template, and its +files with line counts. The result ends with its own `next` step. ## Inspect one diff --git a/web/docs/agents/start-here/templates.md b/web/docs/agents/start-here/templates.md index 6f0cd5f0..b1a61e6a 100644 --- a/web/docs/agents/start-here/templates.md +++ b/web/docs/agents/start-here/templates.md @@ -31,7 +31,7 @@ The `website` shell serves mostly static, prerendered Astro pages with Ilha isla ## Apply features from the examples -Real features live in [Examples](/agents/start-here/examples): `examples` lists them, `add_example {app, name}` commits one onto the app (nav, dependencies, bindings and migrations included), or pass `examples: [...]` to `create_app` to seed them in the first commit. Customise the copy with `commit` afterwards. +Real features live in [Examples](/agents/start-here/examples): `examples` lists them, `add_example {app, name}` commits one onto the app (nav, bindings and migrations included), or pass `examples: [...]` to `create_app` to seed them in the first commit. Customise the copy with `commit` afterwards. ## Rebuild, never stretch diff --git a/web/docs/reference/tools.md b/web/docs/reference/tools.md index d4200b15..3671c11e 100644 --- a/web/docs/reference/tools.md +++ b/web/docs/reference/tools.md @@ -738,7 +738,7 @@ Every Outer tool in `tools/list` order, rendered from the registry. Generated ## `add_example` - **Access:** signed in -- **Description:** Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and deps in one change and deploys. Customise with commit after. +- **Description:** Add a ready feature to your app: {app, name} (+ optional wait). Commits the example's pages, nav, bindings and migrations in one change and deploys. Customise with commit after. **Input:** From 3414ef696dd7127a97ccd1dd112d0165b57e8028 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Wed, 28 Oct 2026 08:20:42 +0100 Subject: [PATCH 3/4] Pin outer-templates 5ff03ff: apply metadata, markers, gated photos --- crates/outer/src/templates.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/outer/src/templates.rs b/crates/outer/src/templates.rs index 88e4d7a9..5c2f51df 100644 --- a/crates/outer/src/templates.rs +++ b/crates/outer/src/templates.rs @@ -21,7 +21,7 @@ use crate::store::Store; pub const TEMPLATES_URL: &str = "https://github.com/ryuzcorp/outer-templates"; /// The pinned commit `outer templates sync` vendors. Repinning is a /// one-line change, then `outer templates sync`. -pub const TEMPLATES_SHA: &str = "368cc59fa6704e5c3c7662ad84df83b3ae1eb24b"; +pub const TEMPLATES_SHA: &str = "5ff03ffffc139b2e33feb94cc828cdaea89efe36"; /// The store pointer to the vendored bundle. const CURRENT_KEY: &str = "outer/templates/current.json"; From baf64fb1d530910749ab97d135cb31ed7713b797 Mon Sep 17 00:00:00 2001 From: Ryuz Date: Wed, 28 Oct 2026 08:32:46 +0100 Subject: [PATCH 4/4] add_example: commit against the head the plan was read from --- crates/outer/src/mcp/tools/add_example.rs | 48 +++++++++++++---------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/outer/src/mcp/tools/add_example.rs b/crates/outer/src/mcp/tools/add_example.rs index 16d8aebc..4414161c 100644 --- a/crates/outer/src/mcp/tools/add_example.rs +++ b/crates/outer/src/mcp/tools/add_example.rs @@ -49,7 +49,7 @@ pub async fn add_example(input: AddExampleInput, ctx: &Ctx<'_>) -> Result) -> Result plan, + let (head, plan) = match planned { + Ok(planned) => planned, Err(err) => { if let Err(refund) = refund_change(&ctx.state.pool, &app.owner_id).await { tracing::warn!("add_example refund failed: {refund:#}"); @@ -72,7 +73,10 @@ pub async fn add_example(input: AddExampleInput, ctx: &Ctx<'_>) -> Result Result, ToolError> { +) -> Result<(String, BTreeMap), ToolError> { + // Migration numbers only: renumbered writes never collide with them. + let (head, migrations, _) = + crate::git::local::list_files(bare, None, Some("db/migrations"), usize::MAX) + .await + .map_err(|err| { + if err.to_string().contains("no commits") { + ToolError::not_found("app has no commits yet") + } else { + internal(err) + } + })?; let mut need: BTreeSet<&str> = BTreeSet::new(); for input in inputs { for (path, _) in &input.files { @@ -180,7 +197,7 @@ async fn head_tree( need.insert("void.config.ts"); let mut tree = BTreeMap::new(); for path in need { - match crate::git::local::read_blob(bare, None, path).await { + match crate::git::local::read_blob(bare, Some(&head), path).await { Ok((_, bytes)) => match String::from_utf8(bytes) { Ok(text) => { tree.insert(path.to_string(), text); @@ -198,19 +215,8 @@ async fn head_tree( } } } - // Migration numbers only: renumbered writes never collide with them. - let (_, migrations, _) = - crate::git::local::list_files(bare, None, Some("db/migrations"), usize::MAX) - .await - .map_err(|err| { - if err.to_string().contains("no commits") { - ToolError::not_found("app has no commits yet") - } else { - internal(err) - } - })?; for entry in migrations { tree.entry(entry.path).or_insert_with(String::new); } - Ok(tree) + Ok((head, tree)) }