diff --git a/crates/outer/src/examples/apply.rs b/crates/outer/src/examples/apply.rs new file mode 100644 index 00000000..18f6965c --- /dev/null +++ b/crates/outer/src/examples/apply.rs @@ -0,0 +1,604 @@ +//! 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, +//! `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): +//! +//! - 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 `", "`. 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 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 +//! 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>>, + /// 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 the {} template and cannot apply to a {} app", + example.name, example.template, app_template + ))); + } + } + let fresh: Vec<&ExampleInput> = order + .iter() + .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!( + "{} {} 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 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 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() { + continue; + } + 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); + } + } + 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 { + added: fresh.iter().map(|example| example.name.clone()).collect(), + files, + nav, + writes: writes.into_iter().collect(), + }) +} + +/// Depth-first `requires` resolution: requires 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, + ) -> 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 stack.len() >= 3 { + let mut chain = stack.clone(); + chain.push(name.to_string()); + return Err(refuse(format!( + "requires chain deeper than 3: {}", + chain.join(" -> ") + ))); + } + 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)?; + } + 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)?; + } + 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. `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(); + 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 { + 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!( + "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)); + 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) = 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!( + "refusing to apply {:?}: {dest} already exists with different content (delete or merge it by hand, then re-run)", + example.name + ))); + } + } else { + writes.insert(dest.clone(), text.clone()); + sources.insert(dest, example.name.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]) -> Result<(String, Vec), Refusal> { + let manual = |marker: &str| { + refuse(format!( + "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")) + .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( + "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'); + 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!( + "refusing to apply {example:?}: void.config.ts already declares {key} with a different value (existing entry wins: {})", + have.trim() + ))); + } + } + } + } + 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() +} + +#[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..1e2bcdbe --- /dev/null +++ b/crates/outer/src/examples/apply/tests.rs @@ -0,0 +1,416 @@ +//! Behaviour tests for the apply engine: the contract's formatting rules. +//! +//! `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; + +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(), + 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 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")); +} + +/// 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 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.is_dir()) + .collect(); + cases.sort(); + 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(|name| name.as_str().unwrap()) + .collect(); + let examples: Vec = std::fs::read_dir(case.join("examples")) + .unwrap() + .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 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.rs b/crates/outer/src/examples/mod.rs similarity index 81% rename from crates/outer/src/examples.rs rename to crates/outer/src/examples/mod.rs index f1482c76..df587a3e 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}; @@ -30,7 +32,6 @@ pub struct Summary { pub title: String, pub summary: String, pub template: String, - pub dependencies: BTreeMap, pub files: Vec, } @@ -41,14 +42,25 @@ 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, pub title: String, 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,15 +80,29 @@ 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` and `bindings`. `dependencies` is not supported and +/// rejects the example; 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)] - dependencies: BTreeMap, + requires: Vec, + #[serde(default)] + nav: Vec, + #[serde(default)] + bindings: BTreeMap>>, + #[serde(default)] + dependencies: Option, } /// Largest single file an example may carry (contract: 64 KiB). @@ -134,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() @@ -145,6 +170,30 @@ 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(), + files: self + .files + .iter() + .map(|file| (file.path.clone(), file.text.clone())) + .collect(), + } + } } /// Extract `examples/` for the current [`crate::templates::TemplateRef`] @@ -350,6 +399,38 @@ 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:?}")); + } + } + 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"), @@ -381,7 +462,9 @@ 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, readme, files: out, }) @@ -496,7 +579,6 @@ mod tests { "title": title, "summary": summary, "template": template, - "dependencies": {"left-pad": "^1.3.0"}, }) .to_string() } @@ -561,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)); @@ -588,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; @@ -621,6 +729,7 @@ mod tests { ExamplesInput { name: Some("nope".to_string()), template: None, + files: None, }, &ctx, ) @@ -654,6 +763,7 @@ mod tests { ExamplesInput { name: Some(bad.clone()), template: None, + files: None, }, &ctx, ) @@ -666,6 +776,7 @@ mod tests { ExamplesInput { name: Some("list-form".to_string()), template: None, + files: None, }, &ctx, ) @@ -736,6 +847,7 @@ mod tests { ExamplesInput { name: Some("big".to_string()), template: None, + files: Some(true), }, &ctx, ) @@ -751,6 +863,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..dcfce616 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 migrations 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..0e5423b7 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": {}}"#, + &[ + ("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"}]}}"#, + &[ + ("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"}]}}"#, + &[ + ("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}" + ); + // 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" + ); + 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..4414161c --- /dev/null +++ b/crates/outer/src/mcp/tools/add_example.rs @@ -0,0 +1,222 @@ +//! 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 (head, 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(|plan| (head, plan)) + .map_err(|refusal| ToolError::invalid_argument(refusal.to_string())) + } + .await; + 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:#}"); + } + return Err(err); + } + }; + let request = git::CommitRequest { + message: format!("add_example: {}", plan.added.join(", ")), + // Full-file writes planned from `head`: a commit that landed since + // would be silently overwritten, so the CAS refuses with `conflict` + // (current sha included) and the agent re-runs add_example. + base: Some(head), + 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 sha and the tree as the apply engine sees it at that sha: every +/// non-migration file the closure touches (with text), plus the +/// `db/migrations/` names (numbers only) and the two edited files. Every +/// read pins the sha the listing resolved, so the plan and the commit's CAS +/// base are the same commit. +async fn head_tree( + bare: &std::path::Path, + inputs: &[crate::examples::apply::ExampleInput], +) -> 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 { + need.insert(path.as_str()); + } + } + need.insert("pages/_nav.ts"); + need.insert("void.config.ts"); + let mut tree = BTreeMap::new(); + for path in need { + 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); + } + 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)); + } + } + } + } + for entry in migrations { + tree.entry(entry.path).or_insert_with(String::new); + } + Ok((head, 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..df887481 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, } } @@ -62,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::>(), @@ -87,7 +97,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 +106,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 +139,116 @@ 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, + "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 +262,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 +276,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/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/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/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 8b7b857e..69350e2a 100644 --- a/web/docs/agents/start-here/examples.md +++ b/web/docs/agents/start-here/examples.md @@ -1,38 +1,54 @@ --- 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. -## List them +## Apply one -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. +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`, +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. -## Copy one +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. -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: +After applying, customise copy and design with `commit`, and verify with +`look_at_app`. -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`. +## List them -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. +Call `examples` with no arguments. Filter with `template: "app"` or +`template: "website"`. Each row names the pattern, its template, and its +files with line counts. The result ends with its own `next` step. -## Unknown names +## Inspect one + +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..b1a61e6a 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, 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..3671c11e 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 migrations 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. | +