From 4be3768a7e80ae24bfcf5d631c84cb6b6b9063fe Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 17 Jul 2026 19:27:19 +0700 Subject: [PATCH] feat(registryctl): add safe init output and editor setup Signed-off-by: Jeremi Joslin --- crates/registryctl/CHANGELOG.md | 9 + crates/registryctl/README.md | 10 + .../project-starters/bounded-http/README.md | 5 +- crates/registryctl/src/lib.rs | 89 +- crates/registryctl/src/main.rs | 229 +++- crates/registryctl/src/project_authoring.rs | 1 + .../src/project_authoring/commands.rs | 27 +- .../src/project_authoring/editor.rs | 1063 +++++++++++++++++ crates/registryctl/src/sample.rs | 8 + .../project-authoring/dhis2-tracker/README.md | 5 +- .../fhir-r4-coverage-active/README.md | 5 +- .../project-authoring/opencrvs/README.md | 5 +- .../snapshot-exact/README.md | 5 +- crates/registryctl/tests/init_output.rs | 441 +++++++ crates/registryctl/tests/project_authoring.rs | 405 ++++++- docs/site/scripts/check-tutorial.sh | 2 + .../scripts/generate-project-starters.mjs | 1 + .../generate-project-starters.test.mjs | 15 +- .../components/ProjectStarterSequence.astro | 9 +- .../content/docs/reference/registryctl.mdx | 88 +- .../tutorials/author-registry-project.mdx | 43 +- ...blish-spreadsheet-secured-registry-api.mdx | 14 + .../tutorials/verify-claim-registry-api.mdx | 12 +- .../docs/tutorials/verify-opencrvs-claims.mdx | 12 +- .../src/data/generated/project-starters.json | 5 + 25 files changed, 2358 insertions(+), 150 deletions(-) create mode 100644 crates/registryctl/src/project_authoring/editor.rs create mode 100644 crates/registryctl/tests/init_output.rs diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 2076fe606..6453668b4 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -20,6 +20,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Schema and maintained DHIS2 offline journey cover the same closed grammar. Target attributes are caller-supplied request context, not authenticated identifiers. +- `registryctl init --from` now installs deterministic project-local JSON Schemas and VS Code and + Zed workspace mappings. `registryctl authoring editor` verifies or safely refreshes the same + version-matched setup for an existing project. + +### Changed + +- `registryctl init --from` and `registryctl init relay` now print concise human-readable results + with tailored next commands. Both forms accept `--format json` for the versioned + `registryctl.init.v1` machine-readable report. ### Removed diff --git a/crates/registryctl/README.md b/crates/registryctl/README.md index 8eebbc567..decbc55f1 100644 --- a/crates/registryctl/README.md +++ b/crates/registryctl/README.md @@ -24,6 +24,10 @@ registryctl start registryctl smoke ``` +Initialization prints the created project, notable artifacts, and next commands. Add +`--format json` to either `init relay` or `init --from` for the versioned +`registryctl.init.v1` machine-readable report. + The generated project contains a local Registry Relay configuration, sample XLSX workbook, Compose file, project manifest, local demo credentials, and an optional Bruno API collection. @@ -46,11 +50,17 @@ and Notary inputs. Available starters are `http`, `dhis2-tracker`, ```sh registryctl init --from http --project-dir registry-project +registryctl authoring editor --project-dir registry-project registryctl test --project-dir registry-project registryctl check --project-dir registry-project --environment local --explain registryctl build --project-dir registry-project --environment local ``` +Initialization copies the five schemas embedded in `registryctl`, configures project-relative VS +Code and Zed schema mappings, and reports the generated editor manifest. The explicit +`authoring editor` command verifies the setup and safely refreshes an unchanged generated bundle +after an upgrade. + The authoring contract accepts one to eight exact selector inputs and up to sixteen typed inputs in total. Canonical selectors have a fixed 4096-byte aggregate ceiling. Input names match `[a-z][a-z0-9_]{0,63}` and use a bounded diff --git a/crates/registryctl/assets/project-starters/bounded-http/README.md b/crates/registryctl/assets/project-starters/bounded-http/README.md index 63ec7df85..144af3be8 100644 --- a/crates/registryctl/assets/project-starters/bounded-http/README.md +++ b/crates/registryctl/assets/project-starters/bounded-http/README.md @@ -5,16 +5,17 @@ This starter demonstrates one bounded product-neutral HTTP integration. From this workspace directory: ```bash +registryctl authoring editor --project-dir . registryctl test --project-dir . --integration person-record --fixture active-person --trace registryctl test --project-dir . --integration person-record --fixture active-person --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local -registryctl authoring schema --kind integration > integration.schema.json ``` `check` is human-readable by default. Use `--format json` only for machine -consumers. The generated schema can be selected from an editor modeline. +consumers. Editor setup uses the five schemas copied from this `registryctl` +build for VS Code and Zed. Edit `integrations/person-record/integration.yaml` and its synthetic fixtures. Keep real destinations and credentials only in `environments/` secret bindings. diff --git a/crates/registryctl/src/lib.rs b/crates/registryctl/src/lib.rs index 23c42fccb..a0fabddea 100644 --- a/crates/registryctl/src/lib.rs +++ b/crates/registryctl/src/lib.rs @@ -36,9 +36,10 @@ mod project_authoring; pub use project_authoring::{ build_registry_project, check_registry_project, init_registry_project, - render_project_authoring_diagnostics, test_registry_project, test_registry_project_selected, - ProjectAuthoringDiagnostic, ProjectAuthoringDiagnostics, ProjectBuildOptions, - ProjectCheckOptions, ProjectCommandReport, ProjectInitOptions, ProjectStarter, + render_project_authoring_diagnostics, setup_registry_project_editor, test_registry_project, + test_registry_project_selected, ProjectAuthoringDiagnostic, ProjectAuthoringDiagnostics, + ProjectBuildOptions, ProjectCheckOptions, ProjectCommandReport, ProjectEditorSetupOptions, + ProjectEditorSetupReport, ProjectInitOptions, ProjectSchemaKind, ProjectStarter, ProjectTestOptions, ProjectTestSelection, SemanticChange, }; @@ -73,6 +74,48 @@ const UPDATE_CHECK_CACHE_SECONDS: u64 = 60 * 60 * 24; const PROJECT_SCHEMA_VERSION: &str = "registryctl/v1"; const CONFIG_BUNDLE_SIGNATURE_SCHEMA: &str = "registry.platform.config_bundle_signatures.v1"; const CONFIG_TRUST_ANCHOR_SCHEMA: &str = "registry.platform.config_trust_anchor.v1"; +const INIT_REPORT_SCHEMA_VERSION: &str = "registryctl.init.v1"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InitProjectKind { + RegistryProject, + RelaySpreadsheetApi, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InitSource { + Starter { + id: String, + release: String, + content_digest: String, + content_state: &'static str, + }, + Sample { + id: String, + }, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InitArtifacts { + pub project_file: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub bruno_collection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub editor_manifest: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct InitReport { + pub schema_version: &'static str, + pub status: &'static str, + pub project: String, + pub project_kind: InitProjectKind, + pub output: PathBuf, + pub source: InitSource, + pub artifacts: InitArtifacts, +} #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] @@ -905,7 +948,7 @@ pub fn init_spreadsheet_api( dir: &Path, sample: Sample, image_lock: &RegistryctlImageLock, -) -> Result<()> { +) -> Result { match sample { Sample::Benefits => init_benefits_project(dir, image_lock), } @@ -1249,14 +1292,13 @@ pub fn smoke_project(project_dir: &Path) -> Result<()> { } } -pub fn bruno_generate_project(project_dir: &Path, force: bool) -> Result<()> { +pub fn bruno_generate_project(project_dir: &Path, force: bool) -> Result { let project = Project::load(project_dir)?; let secrets = LocalEnv::load(&project_dir.join(&project.local.secrets_env))?; let collection_dir = project_dir.join(BRUNO_COLLECTION_DIR); let files = bruno_files(&project, &secrets)?; write_generated_files(project_dir, &collection_dir, files, force)?; - println!("Bruno collection: {}", collection_dir.display()); - Ok(()) + Ok(collection_dir) } pub fn bruno_open_project(project_dir: &Path) -> Result<()> { @@ -1732,7 +1774,7 @@ impl SecretRedactor { } } -fn init_benefits_project(dir: &Path, image_lock: &RegistryctlImageLock) -> Result<()> { +fn init_benefits_project(dir: &Path, image_lock: &RegistryctlImageLock) -> Result { if dir.exists() { let mut entries = fs::read_dir(dir).with_context(|| format!("failed to inspect {}", dir.display()))?; @@ -1763,8 +1805,22 @@ fn init_benefits_project(dir: &Path, image_lock: &RegistryctlImageLock) -> Resul write_text(dir.join("secrets/local.env"), &credentials.env_file())?; write_text(dir.join("output/.gitkeep"), "")?; sample::write_benefits_workbook(&dir.join("data/benefits_casework.xlsx"))?; - bruno_generate_project(dir, false)?; - Ok(()) + let bruno_collection = bruno_generate_project(dir, false)?; + Ok(InitReport { + schema_version: INIT_REPORT_SCHEMA_VERSION, + status: "initialized", + project: generated_project_name(dir), + project_kind: InitProjectKind::RelaySpreadsheetApi, + output: dir.to_path_buf(), + source: InitSource::Sample { + id: Sample::Benefits.id().to_string(), + }, + artifacts: InitArtifacts { + project_file: dir.join("registryctl.yaml"), + bruno_collection: Some(bruno_collection), + editor_manifest: None, + }, + }) } fn write_text(path: PathBuf, contents: &str) -> Result<()> { @@ -2773,11 +2829,7 @@ struct LocalSection<'a> { } fn registryctl_manifest(dir: &Path, image_lock: &RegistryctlImageLock) -> Result { - let name = dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("my-first-api") - .to_string(); + let name = generated_project_name(dir); let manifest = ProjectManifest { schema_version: PROJECT_SCHEMA_VERSION, project: ProjectSection { @@ -2804,6 +2856,13 @@ fn registryctl_manifest(dir: &Path, image_lock: &RegistryctlImageLock) -> Result serde_yaml::to_string(&manifest).context("failed to render registryctl manifest") } +fn generated_project_name(dir: &Path) -> String { + dir.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("my-first-api") + .to_string() +} + fn compose_yaml(image_lock: &RegistryctlImageLock) -> String { include_str!("templates/compose.yaml").replace("{{relay_image}}", image_lock.relay_image()) } diff --git a/crates/registryctl/src/main.rs b/crates/registryctl/src/main.rs index 1ee1cfcf3..bbc317783 100644 --- a/crates/registryctl/src/main.rs +++ b/crates/registryctl/src/main.rs @@ -4,8 +4,9 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand, ValueEnum}; use registryctl::{ - BundleSignOptions, DeploymentProfile, DoctorFormat, ProjectBuildOptions, ProjectCheckOptions, - ProjectCommandReport, ProjectInitOptions, ProjectStarter, ProjectTestOptions, + BundleSignOptions, DeploymentProfile, DoctorFormat, InitProjectKind, InitReport, InitSource, + ProjectBuildOptions, ProjectCheckOptions, ProjectCommandReport, ProjectEditorSetupOptions, + ProjectInitOptions, ProjectSchemaKind, ProjectStarter, ProjectTestOptions, ProjectTestSelection, Sample, }; @@ -32,29 +33,34 @@ fn main() -> Result<()> { Commands::Init { from, project_dir, + format, command, - } => match (from, command) { - (Some(starter), None) => { - print_json(®istryctl::init_registry_project(&ProjectInitOptions { + } => { + let report = match (from, command) { + (Some(starter), None) => registryctl::init_registry_project(&ProjectInitOptions { starter, directory: project_dir, - })?)? - } - (None, Some(command)) => { - let image_lock = registryctl::load_registryctl_image_lock()?; - match *command { - InitCommand::Relay { dir, sample } => { - registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?; - } - InitCommand::SpreadsheetApi { dir, sample } => { - registryctl::init_spreadsheet_api(&dir, sample, &image_lock)?; + })?, + (None, Some(command)) => { + let image_lock = registryctl::load_registryctl_image_lock()?; + match *command { + InitCommand::Relay { dir, sample } => { + registryctl::init_spreadsheet_api(&dir, sample, &image_lock)? + } + InitCommand::SpreadsheetApi { dir, sample } => { + registryctl::init_spreadsheet_api(&dir, sample, &image_lock)? + } } } + _ => anyhow::bail!( + "init requires exactly one of --from or a legacy product subcommand" + ), + }; + match format { + OutputFormat::Human => println!("{}", render_init_report(&report)?), + OutputFormat::Json => print_json(&report)?, } - _ => { - anyhow::bail!("init requires exactly one of --from or a legacy product subcommand") - } - }, + } Commands::Test { project_dir, environment, @@ -102,7 +108,7 @@ fn main() -> Result<()> { let report = registryctl::check_registry_project(&ProjectCheckOptions { project_directory: project_dir, environment, - explain: explain || format == ProjectReportFormat::Human, + explain: explain || format == OutputFormat::Human, against, anchor, }); @@ -113,11 +119,11 @@ fn main() -> Result<()> { error.downcast_ref::() { match format { - ProjectReportFormat::Human => println!( + OutputFormat::Human => println!( "{}", registryctl::render_project_authoring_diagnostics(report) ), - ProjectReportFormat::Json => print_json(report)?, + OutputFormat::Json => print_json(report)?, } std::process::exit(1); } @@ -125,10 +131,10 @@ fn main() -> Result<()> { } }; match format { - ProjectReportFormat::Human => { + OutputFormat::Human => { println!("{}", render_check_report(&report, explain)?) } - ProjectReportFormat::Json => print_json(&report)?, + OutputFormat::Json => print_json(&report)?, } } Commands::Authoring { command } => match command { @@ -143,6 +149,11 @@ fn main() -> Result<()> { ), }, AuthoringCommand::Schema { kind } => print!("{}", kind.document()), + AuthoringCommand::Editor { project_dir } => print_json( + ®istryctl::setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project_dir, + })?, + )?, }, Commands::Build { project_dir, @@ -237,7 +248,9 @@ fn main() -> Result<()> { }, Commands::Bruno { command } => match command { BrunoCommand::Generate { force } => { - registryctl::bruno_generate_project(&std::env::current_dir()?, force)?; + let collection = + registryctl::bruno_generate_project(&std::env::current_dir()?, force)?; + println!("Bruno collection: {}", human_path(&collection)); } BrunoCommand::Open => registryctl::bruno_open_project(&std::env::current_dir()?)?, BrunoCommand::Run => registryctl::bruno_run_project(&std::env::current_dir()?)?, @@ -331,6 +344,87 @@ fn print_json(value: &T) -> Result<()> { Ok(()) } +fn render_init_report(report: &InitReport) -> Result { + use std::fmt::Write as _; + + let project_kind = match report.project_kind { + InitProjectKind::RegistryProject => "Registry Stack project", + InitProjectKind::RelaySpreadsheetApi => "Relay spreadsheet API", + }; + let mut output = String::new(); + writeln!(output, "Initialized {project_kind} {:?}.", report.project)?; + writeln!(output, " Directory: {}", human_path(&report.output))?; + match &report.source { + InitSource::Starter { + id, + release, + content_state, + .. + } => { + writeln!(output, " Starter: {id} (Registry Stack {release})")?; + writeln!(output, " Starter content: {content_state} bundled digest")?; + } + InitSource::Sample { id } => writeln!(output, " Sample: {id}")?, + } + if let Some(collection) = &report.artifacts.bruno_collection { + writeln!(output, " Bruno collection: {}", human_path(collection))?; + } + if let Some(manifest) = &report.artifacts.editor_manifest { + writeln!( + output, + " Editor support: VS Code and Zed ({})", + human_path(manifest) + )?; + } + + writeln!(output, "\nNext:")?; + if report.output != std::path::Path::new(".") { + writeln!(output, " cd {}", human_path(&report.output))?; + } + match report.project_kind { + InitProjectKind::RegistryProject => { + writeln!(output, " registryctl test --project-dir .")?; + } + InitProjectKind::RelaySpreadsheetApi => { + writeln!(output, " registryctl doctor --profile local --format json")?; + writeln!(output, " registryctl start")?; + } + } + Ok(output.trim_end().to_string()) +} + +fn human_path(path: &std::path::Path) -> String { + let mut value = path.display().to_string(); + if path.is_relative() && value.starts_with('-') { + value.insert_str(0, "./"); + } + if !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/._-".contains(character)) + { + value + } else { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\\' => escaped.push_str("\\\\"), + '\'' => escaped.push_str("\\'"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + use std::fmt::Write as _; + write!(escaped, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } + character => escaped.push(character), + } + } + format!("$'{escaped}'") + } +} + fn render_test_summary(report: &ProjectCommandReport) -> String { let passed = report .fixtures @@ -664,7 +758,7 @@ fn render_check_report(report: &ProjectCommandReport, expanded: bool) -> Result< } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -enum ProjectReportFormat { +enum OutputFormat { Human, Json, } @@ -675,31 +769,6 @@ enum XwFormat { Editor, } -#[derive(Debug, Clone, Copy, ValueEnum)] -enum ProjectSchemaKind { - Project, - Environment, - Integration, - Fixture, - Entity, -} - -impl ProjectSchemaKind { - const fn document(self) -> &'static str { - match self { - Self::Project => include_str!("../schemas/project-authoring/project.schema.json"), - Self::Environment => { - include_str!("../schemas/project-authoring/environment.schema.json") - } - Self::Integration => { - include_str!("../schemas/project-authoring/integration.schema.json") - } - Self::Fixture => include_str!("../schemas/project-authoring/fixture.schema.json"), - Self::Entity => include_str!("../schemas/project-authoring/entity.schema.json"), - } - } -} - #[derive(Debug, Subcommand)] enum AuthoringCommand { /// Print the generated xw.v1 function reference or editor metadata. @@ -712,6 +781,12 @@ enum AuthoringCommand { #[arg(long, value_enum)] kind: ProjectSchemaKind, }, + /// Install deterministic local schema mappings for VS Code and Zed. + Editor { + /// Project workspace root containing registry-stack.yaml. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + }, } #[derive(Debug, Parser)] @@ -738,6 +813,9 @@ enum Commands { /// Destination for a project workspace initialized with --from. #[arg(long, default_value = ".")] project_dir: PathBuf, + /// Human-readable result, or machine-readable JSON. + #[arg(long, value_enum, default_value = "human", global = true)] + format: OutputFormat, #[command(subcommand)] command: Option>, }, @@ -778,7 +856,7 @@ enum Commands { explain: bool, /// Human-readable review report, or deliberate machine-readable JSON. #[arg(long, value_enum, default_value = "human")] - format: ProjectReportFormat, + format: OutputFormat, /// Previously signed product Config Bundle with review and internal approval state. #[arg(long)] against: Option, @@ -882,10 +960,31 @@ mod tests { Commands::Init { from: Some(ProjectStarter::Http), project_dir, + format: OutputFormat::Human, command: None, } if project_dir == std::path::Path::new("registry-project") )); + let relay_init = Cli::try_parse_from([ + "registryctl", + "init", + "relay", + "my-first-api", + "--format", + "json", + ]) + .unwrap(); + assert!(matches!( + relay_init.command, + Commands::Init { + from: None, + format: OutputFormat::Json, + command: Some(command), + .. + } if matches!(command.as_ref(), InitCommand::Relay { dir, .. } + if dir == std::path::Path::new("my-first-api")) + )); + let test = Cli::try_parse_from([ "registryctl", "test", @@ -966,7 +1065,7 @@ mod tests { project_dir, environment, explain: true, - format: ProjectReportFormat::Human, + format: OutputFormat::Human, against: Some(against), anchor: Some(anchor), } if project_dir == std::path::Path::new("registry-project") @@ -989,7 +1088,7 @@ mod tests { assert!(matches!( json_check.command, Commands::Check { - format: ProjectReportFormat::Json, + format: OutputFormat::Json, explain: false, .. } @@ -1039,6 +1138,28 @@ mod tests { "Registry Stack project integration v1" ); + let editor = Cli::try_parse_from([ + "registryctl", + "authoring", + "editor", + "--project-dir", + "registry-project", + ]) + .unwrap(); + assert!(matches!( + editor.command, + Commands::Authoring { + command: AuthoringCommand::Editor { project_dir } + } if project_dir == std::path::Path::new("registry-project") + )); + let default_editor = Cli::try_parse_from(["registryctl", "authoring", "editor"]).unwrap(); + assert!(matches!( + default_editor.command, + Commands::Authoring { + command: AuthoringCommand::Editor { project_dir } + } if project_dir == std::path::Path::new(".") + )); + let build = Cli::try_parse_from([ "registryctl", "build", diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index ec534f578..b8e6c5cd0 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -59,6 +59,7 @@ const RELEASED_SCRIPT_RUNTIMES: &[ReleasedScriptRuntime] = &[ReleasedScriptRunti // authoring compiler can retain one closed internal type system without a // public API or visibility expansion. include!("project_authoring/model.rs"); +include!("project_authoring/editor.rs"); include!("project_authoring/authoring_contract.rs"); include!("project_authoring/diagnostics.rs"); include!("project_authoring/commands.rs"); diff --git a/crates/registryctl/src/project_authoring/commands.rs b/crates/registryctl/src/project_authoring/commands.rs index dd66f5ac5..26480dd32 100644 --- a/crates/registryctl/src/project_authoring/commands.rs +++ b/crates/registryctl/src/project_authoring/commands.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -pub fn init_registry_project(options: &ProjectInitOptions) -> Result { +pub fn init_registry_project(options: &ProjectInitOptions) -> Result { if options.directory.exists() { let metadata = fs::symlink_metadata(&options.directory) .context("failed to inspect project destination")?; @@ -31,15 +31,26 @@ pub fn init_registry_project(options: &ProjectInitOptions) -> Result &'static str { + self.catalog_entry().document + } + + pub const fn filename(self) -> &'static str { + self.catalog_entry().filename + } + + pub const fn file_glob(self) -> &'static str { + self.catalog_entry().file_glob + } + + const fn name(self) -> &'static str { + self.catalog_entry().name + } + + const fn catalog_entry(self) -> &'static ProjectSchemaCatalogEntry { + match self { + Self::Project => &PROJECT_SCHEMA_CATALOG[0], + Self::Environment => &PROJECT_SCHEMA_CATALOG[1], + Self::Integration => &PROJECT_SCHEMA_CATALOG[2], + Self::Fixture => &PROJECT_SCHEMA_CATALOG[3], + Self::Entity => &PROJECT_SCHEMA_CATALOG[4], + } + } +} + +struct ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind, + name: &'static str, + filename: &'static str, + file_glob: &'static str, + document: &'static str, +} + +// yaml-language-server treats portable fileMatch patterns as suffix matches by +// prepending `**/`. Keep these suffixes limited to Registry Stack's five +// reserved authored layouts. Exact worktree-root matching requires an editor +// extension because the native VS Code and Zed settings expose no root token. +const PROJECT_SCHEMA_CATALOG: [ProjectSchemaCatalogEntry; 5] = [ + ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind::Project, + name: "project", + filename: "project.schema.json", + file_glob: "registry-stack.yaml", + document: include_str!("../../schemas/project-authoring/project.schema.json"), + }, + ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind::Environment, + name: "environment", + filename: "environment.schema.json", + file_glob: "environments/*.yaml", + document: include_str!("../../schemas/project-authoring/environment.schema.json"), + }, + ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind::Integration, + name: "integration", + filename: "integration.schema.json", + file_glob: "integrations/*/integration.yaml", + document: include_str!("../../schemas/project-authoring/integration.schema.json"), + }, + ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind::Fixture, + name: "fixture", + filename: "fixture.schema.json", + file_glob: "integrations/*/fixtures/*.yaml", + document: include_str!("../../schemas/project-authoring/fixture.schema.json"), + }, + ProjectSchemaCatalogEntry { + kind: ProjectSchemaKind::Entity, + name: "entity", + filename: "entity.schema.json", + file_glob: "entities/*.yaml", + document: include_str!("../../schemas/project-authoring/entity.schema.json"), + }, +]; + +#[derive(Debug, Clone)] +pub struct ProjectEditorSetupOptions { + pub project_directory: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectEditorSetupReport { + pub status: &'static str, + pub project_directory: String, + pub files: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProjectEditorManifest { + format: String, + version: u8, + registryctl_version: String, + schemas: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProjectEditorManifestSchema { + kind: String, + path: String, + file_glob: String, + sha256: String, +} + +struct ProjectEditorFile { + relative_path: PathBuf, + bytes: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +enum ProjectEditorTargetState { + Missing, + Existing(Vec), + Conflict, + Symlink(PathBuf), +} + +struct ManagedPriorEditor { + allowed_existing: BTreeMap>, +} + +struct ProjectEditorPublication { + target: PathBuf, + backup: Option, + expected: Vec, + installed: bool, +} + +static EDITOR_TRANSACTION_COUNTER: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[cfg(test)] +std::thread_local! { + static EDITOR_TEST_PUBLISH_FAILURE_AFTER: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; + static EDITOR_TEST_ROLLBACK_FAILURE: std::cell::Cell = const { + std::cell::Cell::new(false) + }; + static EDITOR_TEST_TARGET_CHANGE: std::cell::RefCell)>> = const { + std::cell::RefCell::new(None) + }; +} + +pub fn setup_registry_project_editor( + options: &ProjectEditorSetupOptions, +) -> Result { + let root = canonical_root(&options.project_directory)?; + require_regular_project_marker(&root)?; + let files = project_editor_files()?; + let prior = managed_prior_editor(&root, &files)?; + + let mut states = Vec::with_capacity(files.len()); + let mut conflicts = BTreeSet::new(); + let mut symlinks = BTreeSet::new(); + for file in &files { + validate_relative_authored_path(&file.relative_path)?; + let target = root.join(&file.relative_path); + if !target.starts_with(&root) { + bail!("generated editor path escapes the project root"); + } + let state = inspect_project_editor_target(&root, &target)?; + match &state { + ProjectEditorTargetState::Existing(actual) + if actual != &file.bytes + && !prior.as_ref().is_some_and(|prior| { + prior.allowed_existing.get(&file.relative_path) == Some(actual) + }) => + { + conflicts.insert(file.relative_path.clone()); + } + ProjectEditorTargetState::Conflict => { + conflicts.insert(file.relative_path.clone()); + } + ProjectEditorTargetState::Symlink(path) => { + symlinks.insert(path.clone()); + } + ProjectEditorTargetState::Missing | ProjectEditorTargetState::Existing(_) => {} + } + states.push(state); + } + + if !conflicts.is_empty() || !symlinks.is_empty() { + let mut causes = Vec::new(); + if !conflicts.is_empty() { + causes.push(format!( + "conflicting files: {}", + display_editor_paths(&conflicts) + )); + } + if !symlinks.is_empty() { + causes.push(format!( + "symlink targets or ancestors are not allowed: {}", + display_editor_paths(&symlinks) + )); + } + bail!( + "editor setup preflight failed; {}; no files were changed. Preserve these files and install the Registry Stack schema mappings manually, or restore the expected generated files before rerunning the command", + causes.join("; ") + ); + } + + publish_project_editor_files(&root, &files, &states)?; + + Ok(ProjectEditorSetupReport { + status: "configured", + project_directory: root.display().to_string(), + files: files + .iter() + .map(|file| file.relative_path.display().to_string()) + .collect(), + }) +} + +fn require_regular_project_marker(root: &Path) -> Result<()> { + let path = root.join(PROJECT_FILE); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("project root must contain a regular {PROJECT_FILE}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("project root must contain a regular non-symlink {PROJECT_FILE}"); + } + Ok(()) +} + +fn project_editor_files() -> Result> { + let manifest = current_project_editor_manifest(); + let mut files = Vec::with_capacity(9); + for entry in &PROJECT_SCHEMA_CATALOG { + files.push(ProjectEditorFile { + relative_path: PathBuf::from(EDITOR_ROOT) + .join("schemas") + .join(entry.filename), + bytes: entry.document.as_bytes().to_vec(), + }); + } + files.push(ProjectEditorFile { + relative_path: PathBuf::from(EDITOR_MANIFEST_PATH), + bytes: pretty_json(&manifest)?, + }); + files.extend(project_editor_configuration_files(&manifest.schemas)?); + Ok(files) +} + +fn current_project_editor_manifest() -> ProjectEditorManifest { + ProjectEditorManifest { + format: EDITOR_MANIFEST_FORMAT.to_string(), + version: EDITOR_MANIFEST_VERSION, + registryctl_version: env!("CARGO_PKG_VERSION").to_string(), + schemas: PROJECT_SCHEMA_CATALOG + .iter() + .map(|entry| ProjectEditorManifestSchema { + kind: entry.kind.name().to_string(), + path: format!("schemas/{}", entry.filename), + file_glob: entry.file_glob.to_string(), + sha256: schema_hash(entry.document.as_bytes()), + }) + .collect(), + } +} + +fn project_editor_configuration_files( + schemas: &[ProjectEditorManifestSchema], +) -> Result> { + let schema_mappings = schemas + .iter() + .map(|schema| { + ( + format!("./{EDITOR_ROOT}/{}", schema.path), + schema.file_glob.clone(), + ) + }) + .collect::>(); + Ok(vec![ + ProjectEditorFile { + relative_path: PathBuf::from(".vscode/settings.json"), + bytes: pretty_json(&json!({ "yaml.schemas": schema_mappings.clone() }))?, + }, + ProjectEditorFile { + relative_path: PathBuf::from(".vscode/extensions.json"), + bytes: pretty_json(&json!({ "recommendations": ["redhat.vscode-yaml"] }))?, + }, + ProjectEditorFile { + relative_path: PathBuf::from(".zed/settings.json"), + bytes: pretty_json(&json!({ + "lsp": { + "yaml-language-server": { + "settings": { + "yaml": { + "schemas": schema_mappings + } + } + } + } + }))?, + }, + ]) +} + +fn managed_prior_editor( + root: &Path, + current_files: &[ProjectEditorFile], +) -> Result> { + let manifest_path = root.join(EDITOR_MANIFEST_PATH); + let current_manifest = current_files + .iter() + .find(|file| file.relative_path == Path::new(EDITOR_MANIFEST_PATH)) + .expect("current editor files contain their manifest"); + let ProjectEditorTargetState::Existing(manifest_bytes) = + inspect_project_editor_target(root, &manifest_path)? + else { + return Ok(None); + }; + if manifest_bytes == current_manifest.bytes { + return Ok(None); + } + + validate_managed_prior_editor(root, current_files, manifest_bytes) + .context("existing editor manifest cannot authorize a managed refresh") + .map(Some) +} + +fn validate_managed_prior_editor( + root: &Path, + current_files: &[ProjectEditorFile], + manifest_bytes: Vec, +) -> Result { + let manifest: ProjectEditorManifest = serde_json::from_slice(&manifest_bytes) + .context("prior editor manifest is not the closed JSON format")?; + if manifest.format != EDITOR_MANIFEST_FORMAT || manifest.version != EDITOR_MANIFEST_VERSION { + bail!("prior editor manifest uses an unsupported format or version"); + } + if manifest.registryctl_version.is_empty() + || manifest.registryctl_version.len() > 128 + || !manifest.registryctl_version.bytes().all(|byte| { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'+') + }) + { + bail!("prior editor manifest has an invalid registryctl version"); + } + if manifest.schemas.len() != PROJECT_SCHEMA_CATALOG.len() { + bail!("prior editor manifest must contain the exact five-schema catalog"); + } + for (schema, expected) in manifest.schemas.iter().zip(&PROJECT_SCHEMA_CATALOG) { + if schema.kind != expected.name + || schema.path != format!("schemas/{}", expected.filename) + || schema.file_glob != expected.file_glob + || !is_schema_hash(&schema.sha256) + { + bail!("prior editor manifest schema catalog is not the expected closed catalog"); + } + } + + let current_by_path = current_files + .iter() + .map(|file| (file.relative_path.clone(), file.bytes.as_slice())) + .collect::>(); + let mut allowed_existing = BTreeMap::new(); + allowed_existing.insert(PathBuf::from(EDITOR_MANIFEST_PATH), manifest_bytes); + + for schema in &manifest.schemas { + let relative_path = PathBuf::from(EDITOR_ROOT).join(&schema.path); + match inspect_project_editor_target(root, &root.join(&relative_path))? { + ProjectEditorTargetState::Missing => {} + ProjectEditorTargetState::Existing(bytes) => { + if schema_hash(&bytes) != schema.sha256 { + bail!( + "prior editor schema does not match its manifest hash: {}", + relative_path.display() + ); + } + allowed_existing.insert(relative_path, bytes); + } + ProjectEditorTargetState::Conflict => bail!( + "prior editor schema is not a bounded regular file: {}", + relative_path.display() + ), + ProjectEditorTargetState::Symlink(path) => bail!( + "prior editor schema uses a forbidden symlink target or ancestor: {}", + path.display() + ), + } + } + + for prior_file in project_editor_configuration_files(&manifest.schemas)? { + let current = current_by_path + .get(&prior_file.relative_path) + .expect("current editor files contain every configuration target"); + match inspect_project_editor_target(root, &root.join(&prior_file.relative_path))? { + ProjectEditorTargetState::Missing => {} + ProjectEditorTargetState::Existing(bytes) + if bytes == prior_file.bytes || bytes.as_slice() == *current => + { + allowed_existing.insert(prior_file.relative_path, bytes); + } + ProjectEditorTargetState::Existing(_) | ProjectEditorTargetState::Conflict => bail!( + "editor configuration was customized and cannot be refreshed automatically: {}", + prior_file.relative_path.display() + ), + ProjectEditorTargetState::Symlink(path) => bail!( + "editor configuration uses a forbidden symlink target or ancestor: {}", + path.display() + ), + } + } + Ok(ManagedPriorEditor { allowed_existing }) +} + +fn schema_hash(bytes: &[u8]) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(bytes))) +} + +fn is_schema_hash(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) +} + +fn pretty_json(value: &impl Serialize) -> Result> { + let mut bytes = serde_json::to_vec_pretty(value) + .context("failed to serialize deterministic editor configuration")?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn inspect_project_editor_target(root: &Path, target: &Path) -> Result { + let relative = target + .strip_prefix(root) + .map_err(|_| anyhow!("generated editor path escapes the project root"))?; + let components = relative.components().collect::>(); + if components.is_empty() { + bail!("generated editor path cannot be the project root"); + } + + let mut current = root.to_path_buf(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + bail!("generated editor path is not normalized"); + }; + current.push(component); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ProjectEditorTargetState::Missing) + } + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect {}", current.display())) + } + }; + if metadata.file_type().is_symlink() { + let relative = current + .strip_prefix(root) + .map_err(|_| anyhow!("generated editor path escapes the project root"))?; + return Ok(ProjectEditorTargetState::Symlink(relative.to_path_buf())); + } + let is_target = index + 1 == components.len(); + if !is_target && !metadata.is_dir() { + return Ok(ProjectEditorTargetState::Conflict); + } + if is_target { + if !metadata.is_file() || metadata.len() > MAX_EDITOR_FILE_BYTES { + return Ok(ProjectEditorTargetState::Conflict); + } + let actual = fs::read(¤t) + .with_context(|| format!("failed to read {}", current.display()))?; + return Ok(ProjectEditorTargetState::Existing(actual)); + } + } + unreachable!("a non-empty editor path always returns from the component loop") +} + +fn publish_project_editor_files( + root: &Path, + files: &[ProjectEditorFile], + states: &[ProjectEditorTargetState], +) -> Result<()> { + let changes = files + .iter() + .zip(states) + .filter(|(file, state)| match state { + ProjectEditorTargetState::Missing => true, + ProjectEditorTargetState::Existing(bytes) => bytes != &file.bytes, + ProjectEditorTargetState::Conflict | ProjectEditorTargetState::Symlink(_) => false, + }) + .collect::>(); + if changes.is_empty() { + return Ok(()); + } + + let transaction_root = create_editor_transaction_root(root)?; + let mut publications = Vec::new(); + let mut created_directories = Vec::new(); + let result = (|| -> Result<()> { + for (file, _) in &changes { + write_private_file( + &transaction_root.join("new").join(&file.relative_path), + &file.bytes, + )?; + } + for (file, state) in files.iter().zip(states) { + if !project_editor_state_is_unchanged( + state, + &inspect_project_editor_target(root, &root.join(&file.relative_path))?, + ) { + bail!( + "editor target changed after preflight: {}; inspect it manually before rerunning", + file.relative_path.display() + ); + } + } + + for (file, state) in changes { + maybe_inject_editor_publish_failure()?; + let target = root.join(&file.relative_path); + let immediate = inspect_project_editor_target(root, &target)?; + if !project_editor_state_is_unchanged(state, &immediate) { + bail!( + "editor target changed immediately before publication: {}; inspect it manually before rerunning", + file.relative_path.display() + ); + } + maybe_inject_editor_target_change(root, &target)?; + let parent = target + .parent() + .ok_or_else(|| anyhow!("generated editor file has no parent"))?; + ensure_project_editor_directory(root, parent, &mut created_directories)?; + let staged = transaction_root.join("new").join(&file.relative_path); + let backup = if matches!(state, ProjectEditorTargetState::Existing(_)) { + let backup = transaction_root.join("backup").join(&file.relative_path); + create_dir_owner_only( + backup + .parent() + .ok_or_else(|| anyhow!("editor backup has no parent"))?, + )?; + fs::rename(&target, &backup).with_context(|| { + format!("failed to stage existing editor file {}", target.display()) + })?; + Some(backup) + } else { + None + }; + publications.push(ProjectEditorPublication { + target: target.clone(), + backup, + expected: file.bytes.clone(), + installed: false, + }); + if let (ProjectEditorTargetState::Existing(expected), Some(backup)) = + (state, &publications.last().expect("publication was just recorded").backup) + { + let actual = read_project_editor_transaction_file(backup)?; + if &actual != expected { + bail!( + "editor target changed while being staged for publication: {}; the changed bytes will be restored", + file.relative_path.display() + ); + } + } + // Hard links make publication and restoration create-only. A malicious same-user + // process can still swap a validated ancestor between operations; closing that final + // boundary requires directory-handle/openat-style APIs and is intentionally out of scope. + fs::hard_link(&staged, &target) + .with_context(|| format!("failed to publish editor file {}", target.display()))?; + publications + .last_mut() + .expect("publication was just recorded") + .installed = true; + } + Ok(()) + })(); + + if let Err(error) = result { + if let Err(rollback_error) = + rollback_project_editor_publications(&mut publications, &created_directories) + { + return Err(error.context(format!( + "editor transaction rollback failed: {rollback_error:#}; recoverable backups remain in {}", + transaction_root.display() + ))); + } + if let Err(cleanup_error) = fs::remove_dir_all(&transaction_root) + .with_context(|| format!("failed to clean up {}", transaction_root.display())) + { + return Err(error.context(format!("editor transaction cleanup failed: {cleanup_error:#}"))); + } + return Err(error.context("editor setup transaction was rolled back")); + } + + fs::remove_dir_all(&transaction_root) + .with_context(|| format!("failed to clean up {}", transaction_root.display()))?; + Ok(()) +} + +fn project_editor_state_is_unchanged( + expected: &ProjectEditorTargetState, + actual: &ProjectEditorTargetState, +) -> bool { + match (expected, actual) { + (ProjectEditorTargetState::Missing, ProjectEditorTargetState::Missing) => true, + ( + ProjectEditorTargetState::Existing(expected), + ProjectEditorTargetState::Existing(actual), + ) => expected == actual, + _ => false, + } +} + +fn create_editor_transaction_root(root: &Path) -> Result { + for _ in 0..128 { + let sequence = EDITOR_TRANSACTION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = root.join(format!( + ".registry-stack-editor.transaction-{}-{sequence}", + std::process::id() + )); + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + builder.mode(0o700); + } + match builder.create(&path) { + Ok(()) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("failed to create editor transaction in {}", root.display())) + } + } + } + bail!("failed to reserve a private editor transaction directory") +} + +fn ensure_project_editor_directory( + root: &Path, + directory: &Path, + created: &mut Vec, +) -> Result<()> { + let relative = directory + .strip_prefix(root) + .map_err(|_| anyhow!("generated editor directory escapes the project root"))?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + bail!("generated editor directory is not normalized"); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + bail!("symlink targets or ancestors are not allowed: {}", current.display()) + } + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => bail!("editor output ancestor is not a directory: {}", current.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + builder.mode(0o700); + } + match builder.create(¤t) { + Ok(()) => created.push(current.clone()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(¤t).with_context(|| { + format!("failed to inspect editor output ancestor {}", current.display()) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("editor output ancestor changed during publication"); + } + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to create editor output directory {}", current.display()) + }) + } + } + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to inspect editor output ancestor {}", current.display()) + }) + } + } + } + Ok(()) +} + +fn rollback_project_editor_publications( + publications: &mut [ProjectEditorPublication], + created_directories: &[PathBuf], +) -> Result<()> { + maybe_inject_editor_rollback_failure()?; + let mut failures = Vec::new(); + for publication in publications.iter_mut().rev() { + if publication.installed { + let installed = match read_project_editor_transaction_file(&publication.target) { + Ok(installed) => installed, + Err(error) => { + failures.push(format!( + "failed to verify {} before rollback: {error:#}", + publication.target.display() + )); + continue; + } + }; + if installed != publication.expected { + failures.push(format!( + "refused to remove concurrently changed target {}", + publication.target.display() + )); + continue; + } + if let Err(error) = fs::remove_file(&publication.target) { + failures.push(format!( + "failed to remove {}: {error}", + publication.target.display() + )); + continue; + } + publication.installed = false; + } + if let Some(backup) = &publication.backup { + if let Err(error) = fs::hard_link(backup, &publication.target) { + failures.push(format!( + "failed to restore {} without replacing a concurrent target: {error}", + publication.target.display() + )); + } + } + } + for directory in created_directories.iter().rev() { + if let Err(error) = fs::remove_dir(directory) { + failures.push(format!("failed to remove {}: {error}", directory.display())); + } + } + if !failures.is_empty() { + bail!("{}", failures.join("; ")); + } + Ok(()) +} + +fn read_project_editor_transaction_file(path: &Path) -> Result> { + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("failed to inspect transaction file {}", path.display()))?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > MAX_EDITOR_FILE_BYTES + { + bail!("editor transaction file is not a bounded regular file"); + } + fs::read(path).with_context(|| format!("failed to read transaction file {}", path.display())) +} + +#[cfg(test)] +fn maybe_inject_editor_publish_failure() -> Result<()> { + EDITOR_TEST_PUBLISH_FAILURE_AFTER.with(|remaining| match remaining.get() { + Some(0) => { + remaining.set(None); + bail!("injected editor publication failure") + } + Some(value) => { + remaining.set(Some(value - 1)); + Ok(()) + } + None => Ok(()), + }) +} + +#[cfg(not(test))] +fn maybe_inject_editor_publish_failure() -> Result<()> { + Ok(()) +} + +#[cfg(test)] +fn maybe_inject_editor_rollback_failure() -> Result<()> { + EDITOR_TEST_ROLLBACK_FAILURE.with(|failure| { + if failure.replace(false) { + bail!("injected editor rollback failure") + } + Ok(()) + }) +} + +#[cfg(not(test))] +fn maybe_inject_editor_rollback_failure() -> Result<()> { + Ok(()) +} + +#[cfg(test)] +fn maybe_inject_editor_target_change(root: &Path, target: &Path) -> Result<()> { + let relative = target + .strip_prefix(root) + .map_err(|_| anyhow!("injected editor target escapes the project root"))?; + EDITOR_TEST_TARGET_CHANGE.with(|change| { + let mut change = change.borrow_mut(); + if change + .as_ref() + .is_some_and(|(expected, _)| expected == relative) + { + let (_, bytes) = change.take().expect("matching target change exists"); + fs::write(target, bytes) + .with_context(|| format!("failed to inject target change at {}", target.display()))?; + } + Ok(()) + }) +} + +#[cfg(not(test))] +fn maybe_inject_editor_target_change(_root: &Path, _target: &Path) -> Result<()> { + Ok(()) +} + +fn display_editor_paths(paths: &BTreeSet) -> String { + paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod editor_transaction_tests { + use super::*; + + #[test] + fn publish_failure_restores_prior_files_and_missing_targets() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + fs::create_dir(&project).expect("project directory creates"); + fs::write(project.join(PROJECT_FILE), b"invalid-yaml: [") + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + setup_registry_project_editor(&options).expect("initial editor setup passes"); + + let schema_path = project.join(EDITOR_ROOT).join("schemas/project.schema.json"); + let mut prior_schema = fs::read(&schema_path).expect("project schema reads"); + prior_schema.extend_from_slice(b"\n"); + fs::write(&schema_path, &prior_schema).expect("prior schema writes"); + let manifest_path = project.join(EDITOR_MANIFEST_PATH); + let mut prior_manifest: ProjectEditorManifest = + serde_json::from_slice(&fs::read(&manifest_path).expect("manifest reads")) + .expect("manifest parses"); + prior_manifest.registryctl_version = "0.9.0".to_string(); + prior_manifest.schemas[0].sha256 = schema_hash(&prior_schema); + fs::write(&manifest_path, pretty_json(&prior_manifest).expect("manifest serializes")) + .expect("prior manifest writes"); + fs::remove_dir_all(project.join(".vscode")).expect("VS Code settings remove"); + + let current_files = project_editor_files().expect("current editor files"); + let before = current_files + .iter() + .map(|file| { + let path = project.join(&file.relative_path); + ( + file.relative_path.clone(), + path.exists().then(|| fs::read(path).expect("prior file reads")), + ) + }) + .collect::>(); + EDITOR_TEST_PUBLISH_FAILURE_AFTER.with(|remaining| remaining.set(Some(3))); + let error = setup_registry_project_editor(&options) + .expect_err("injected late publication failure must roll back"); + assert!(format!("{error:#}").contains("rolled back")); + + for (relative, expected) in before { + let path = project.join(relative); + assert_eq!( + path.exists().then(|| fs::read(path).expect("file reads after rollback")), + expected + ); + } + assert!( + !project.join(".vscode").exists(), + "rollback must remove editor directories created during publication" + ); + assert!( + fs::read_dir(&project) + .expect("project directory reads") + .all(|entry| !entry + .expect("project entry reads") + .file_name() + .to_string_lossy() + .starts_with(".registry-stack-editor.transaction-")), + "transaction staging must be cleaned after rollback" + ); + } + + #[test] + fn destination_appearing_after_preflight_is_preserved() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + fs::create_dir(&project).expect("project directory creates"); + fs::write(project.join(PROJECT_FILE), b"invalid-yaml: [") + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + setup_registry_project_editor(&options).expect("initial editor setup passes"); + + let relative = PathBuf::from(".zed/settings.json"); + let target = project.join(&relative); + fs::remove_file(&target).expect("managed target removes"); + let concurrent = b"{\n \"concurrent\": true\n}\n".to_vec(); + EDITOR_TEST_TARGET_CHANGE.with(|change| { + *change.borrow_mut() = Some((relative, concurrent.clone())); + }); + + let error = setup_registry_project_editor(&options) + .expect_err("concurrently appearing destination must not be replaced"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("rolled back"), "{diagnostic}"); + assert_eq!( + fs::read(&target).expect("concurrent destination reads"), + concurrent + ); + assert!( + fs::read_dir(&project) + .expect("project directory reads") + .all(|entry| !entry + .expect("project entry reads") + .file_name() + .to_string_lossy() + .starts_with(".registry-stack-editor.transaction-")), + "successful rollback cleans transaction staging" + ); + } + + #[test] + fn existing_target_changed_after_reinspection_is_restored() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + fs::create_dir(&project).expect("project directory creates"); + fs::write(project.join(PROJECT_FILE), b"invalid-yaml: [") + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + setup_registry_project_editor(&options).expect("initial editor setup passes"); + + let relative = PathBuf::from(EDITOR_ROOT).join("schemas/project.schema.json"); + let schema_path = project.join(&relative); + let mut prior_schema = fs::read(&schema_path).expect("project schema reads"); + prior_schema.extend_from_slice(b"\n"); + fs::write(&schema_path, &prior_schema).expect("prior schema writes"); + let manifest_path = project.join(EDITOR_MANIFEST_PATH); + let mut prior_manifest: ProjectEditorManifest = + serde_json::from_slice(&fs::read(&manifest_path).expect("manifest reads")) + .expect("manifest parses"); + prior_manifest.registryctl_version = "0.9.0".to_string(); + prior_manifest.schemas[0].sha256 = schema_hash(&prior_schema); + let prior_manifest_bytes = pretty_json(&prior_manifest).expect("manifest serializes"); + fs::write(&manifest_path, &prior_manifest_bytes).expect("prior manifest writes"); + + let concurrent = b"concurrent schema bytes\n".to_vec(); + EDITOR_TEST_TARGET_CHANGE.with(|change| { + *change.borrow_mut() = Some((relative, concurrent.clone())); + }); + let error = setup_registry_project_editor(&options) + .expect_err("concurrently changed existing destination must not be replaced"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("rolled back"), "{diagnostic}"); + assert!(diagnostic.contains("changed while being staged"), "{diagnostic}"); + assert_eq!( + fs::read(&schema_path).expect("concurrent schema reads"), + concurrent + ); + assert_eq!( + fs::read(&manifest_path).expect("prior manifest reads"), + prior_manifest_bytes + ); + } + + #[test] + fn rollback_failure_preserves_and_reports_recoverable_backup() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + fs::create_dir(&project).expect("project directory creates"); + fs::write(project.join(PROJECT_FILE), b"invalid-yaml: [") + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + setup_registry_project_editor(&options).expect("initial editor setup passes"); + + let schema_path = project.join(EDITOR_ROOT).join("schemas/project.schema.json"); + let mut prior_schema = fs::read(&schema_path).expect("project schema reads"); + prior_schema.extend_from_slice(b"\n"); + fs::write(&schema_path, &prior_schema).expect("prior schema writes"); + let manifest_path = project.join(EDITOR_MANIFEST_PATH); + let mut prior_manifest: ProjectEditorManifest = + serde_json::from_slice(&fs::read(&manifest_path).expect("manifest reads")) + .expect("manifest parses"); + prior_manifest.registryctl_version = "0.9.0".to_string(); + prior_manifest.schemas[0].sha256 = schema_hash(&prior_schema); + fs::write(&manifest_path, pretty_json(&prior_manifest).expect("manifest serializes")) + .expect("prior manifest writes"); + + EDITOR_TEST_PUBLISH_FAILURE_AFTER.with(|remaining| remaining.set(Some(1))); + EDITOR_TEST_ROLLBACK_FAILURE.with(|failure| failure.set(true)); + let error = setup_registry_project_editor(&options) + .expect_err("injected rollback failure must preserve its transaction"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("rollback failed"), "{diagnostic}"); + assert!(diagnostic.contains("recoverable backups remain"), "{diagnostic}"); + + let transaction_root = fs::read_dir(&project) + .expect("project directory reads") + .map(|entry| entry.expect("project entry reads").path()) + .find(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with( + ".registry-stack-editor.transaction-", + )) + }) + .expect("failed rollback preserves its transaction directory"); + assert!(diagnostic.contains(&transaction_root.display().to_string())); + let backup = transaction_root + .join("backup") + .join(EDITOR_ROOT) + .join("schemas/project.schema.json"); + assert_eq!( + fs::read(&backup).expect("recoverable schema backup reads"), + prior_schema + ); + assert_eq!( + fs::read(&manifest_path).expect("unpublished prior manifest reads"), + pretty_json(&prior_manifest).expect("prior manifest serializes") + ); + } +} diff --git a/crates/registryctl/src/sample.rs b/crates/registryctl/src/sample.rs index fd9d9d99e..da0291a94 100644 --- a/crates/registryctl/src/sample.rs +++ b/crates/registryctl/src/sample.rs @@ -10,6 +10,14 @@ pub enum Sample { Benefits, } +impl Sample { + pub(crate) const fn id(self) -> &'static str { + match self { + Self::Benefits => "benefits", + } + } +} + #[derive(Clone, Copy)] enum Cell { Text(&'static str), diff --git a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md index 3142c419e..d7e0fe563 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/dhis2-tracker/README.md @@ -5,17 +5,18 @@ synthetic DHIS2 Tracker wire shape. Product and version metadata do not select the Rhai runtime. ```bash +registryctl authoring editor --project-dir . registryctl test --project-dir . --integration health-record --fixture complete-health-match --trace registryctl test --project-dir . --integration health-record --fixture complete-health-match --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local registryctl authoring xw --format reference -registryctl authoring schema --kind integration > integration.schema.json ``` `check` is human-readable by default. Use `--format json` only for machine -consumers. The generated schema can be selected from an editor modeline. +consumers. Editor setup uses the five schemas copied from this `registryctl` +build for VS Code and Zed. Edit the reviewed `adapter.rhai`, integration contract, and synthetic fixtures together. Keep source credentials in the environment binding. diff --git a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md index 27986b442..4887712ea 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/fhir-r4-coverage-active/README.md @@ -4,17 +4,18 @@ This starter demonstrates the product-neutral `script` capability with bounded FHIR R4 search-set handling and same-authority continuation fixtures. ```bash +registryctl authoring editor --project-dir . registryctl test --project-dir . --integration coverage --fixture coverage-active --trace registryctl test --project-dir . --integration coverage --fixture coverage-active --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local registryctl authoring xw --format reference -registryctl authoring schema --kind integration > integration.schema.json ``` `check` is human-readable by default. Use `--format json` only for machine -consumers. The generated schema can be selected from an editor modeline. +consumers. Editor setup uses the five schemas copied from this `registryctl` +build for VS Code and Zed. The fixtures are synthetic and offline. Keep source authority, authentication, private-network admission, and TLS bindings in `environments/`. diff --git a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md index 6630206ac..1d7c4c471 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/opencrvs/README.md @@ -4,17 +4,18 @@ This starter demonstrates the product-neutral `script` capability with host-owned signed DCI verification and synthetic OpenCRVS-shaped fixtures. ```bash +registryctl authoring editor --project-dir . registryctl test --project-dir . --integration birth-record --fixture birth-record-match --trace registryctl test --project-dir . --integration birth-record --fixture birth-record-match --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local registryctl authoring xw --format reference -registryctl authoring schema --kind integration > integration.schema.json ``` `check` is human-readable by default. Use `--format json` only for machine -consumers. The generated schema can be selected from an editor modeline. +consumers. Editor setup uses the five schemas copied from this `registryctl` +build for VS Code and Zed. Project-owned Rhai handles traversal and normalization. Relay owns signature, correlation, selector, sender, receiver, and cardinality verification. diff --git a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md index d7a184af2..75bd95c58 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/snapshot-exact/README.md @@ -4,16 +4,17 @@ This starter demonstrates an exact consultation over one Relay-local materialized entity without requiring a records API. ```bash +registryctl authoring editor --project-dir . registryctl test --project-dir . --integration person-snapshot --fixture snapshot-match --trace registryctl test --project-dir . --integration person-snapshot --fixture snapshot-match --watch registryctl test --project-dir . registryctl check --project-dir . --environment local --explain registryctl build --project-dir . --environment local -registryctl authoring schema --kind integration > integration.schema.json ``` `check` is human-readable by default. Use `--format json` only for machine -consumers. The generated schema can be selected from an editor modeline. +consumers. Editor setup uses the five schemas copied from this `registryctl` +build for VS Code and Zed. Add a records service only when the project intentionally publishes the entity through Relay's governed records API. diff --git a/crates/registryctl/tests/init_output.rs b/crates/registryctl/tests/init_output.rs new file mode 100644 index 000000000..46a4295c4 --- /dev/null +++ b/crates/registryctl/tests/init_output.rs @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::process::{Command, Output}; + +use serde_json::{json, Value}; +use tempfile::TempDir; + +const RELAY_IMAGE: &str = "ghcr.io/registrystack/registry-relay@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const NOTARY_IMAGE: &str = "ghcr.io/registrystack/registry-notary@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn write_image_lock(temp: &TempDir) -> std::path::PathBuf { + let path = temp.path().join("release-image-lock.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "schema_version": "registryctl.release_image_lock.v1", + "release_tag": format!("v{}", env!("CARGO_PKG_VERSION")), + "manifest_source_ref": "a".repeat(40), + "tag_target": "b".repeat(40), + "platform": "linux/amd64", + "images": { + "registry-relay": RELAY_IMAGE, + "registry-notary": NOTARY_IMAGE, + } + })) + .expect("image lock serializes"), + ) + .expect("image lock writes"); + path +} + +fn run_registryctl(args: &[&str], image_lock: Option<&std::path::Path>) -> Output { + run_registryctl_in(None, args, image_lock) +} + +fn run_registryctl_in( + current_directory: Option<&std::path::Path>, + args: &[&str], + image_lock: Option<&std::path::Path>, +) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_registryctl")); + command.args(args).env("REGISTRYCTL_NO_UPDATE_CHECK", "1"); + if let Some(current_directory) = current_directory { + command.current_dir(current_directory); + } + if let Some(image_lock) = image_lock { + command.env("REGISTRYCTL_IMAGE_LOCK", image_lock); + } + command.output().expect("registryctl runs") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "registryctl failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +fn control_character_project(temp: &TempDir, leaf: &str) -> std::path::PathBuf { + temp.path() + .join("space \\ single' quote\nline\rreturn\ttab\u{1b}escape\u{1}c0\u{7f}del\u{85}c1") + .join(leaf) +} + +#[cfg(unix)] +fn expected_human_path(path: &std::path::Path) -> String { + let mut escaped = String::new(); + for character in path.to_str().expect("test path is valid UTF-8").chars() { + match character { + '\\' => escaped.push_str("\\\\"), + '\'' => escaped.push_str("\\'"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + use std::fmt::Write as _; + write!(escaped, "\\u{:04x}", character as u32) + .expect("writing to a String cannot fail"); + } + character => escaped.push(character), + } + } + format!("$'{escaped}'") +} + +#[cfg(unix)] +fn assert_stdout_has_no_terminal_controls(stdout: &str) { + for character in stdout.chars() { + assert!( + character == '\n' || !character.is_control(), + "stdout contains raw control U+{:04X}: {stdout:?}", + character as u32 + ); + } +} + +#[cfg(unix)] +fn assert_shell_path_is_usable(rendered: &str) { + let output = Command::new("bash") + .args(["-c", &format!("cd {rendered} && test -d .")]) + .output() + .expect("bash runs rendered next command"); + assert!( + output.status.success(), + "rendered path was not reusable by bash: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn starter_init_defaults_to_a_concise_human_result() { + let temporary = TempDir::new().expect("temporary directory"); + let project = temporary.path().join("registry-project"); + let output = run_registryctl( + &[ + "init", + "--from", + "http", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + ], + None, + ); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + assert_eq!( + stdout, + format!( + "Initialized Registry Stack project \"fictional-citizen-registry\".\n Directory: {}\n Starter: http (Registry Stack {})\n Starter content: matches bundled digest\n Editor support: VS Code and Zed ({})\n\nNext:\n cd {}\n registryctl test --project-dir .\n", + project.display(), + env!("CARGO_PKG_VERSION"), + project + .join(".registry-stack-editor/manifest.json") + .display(), + project.display(), + ) + ); +} + +#[cfg(unix)] +#[test] +fn starter_init_human_paths_are_line_safe_and_shell_usable() { + let temporary = TempDir::new().expect("temporary directory"); + let project = control_character_project(&temporary, "registry-project"); + let output = run_registryctl( + &[ + "init", + "--from", + "http", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + ], + None, + ); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + let rendered_project = expected_human_path(&project); + let rendered_manifest = + expected_human_path(&project.join(".registry-stack-editor/manifest.json")); + assert_eq!( + stdout, + format!( + "Initialized Registry Stack project \"fictional-citizen-registry\".\n Directory: {rendered_project}\n Starter: http (Registry Stack {})\n Starter content: matches bundled digest\n Editor support: VS Code and Zed ({rendered_manifest})\n\nNext:\n cd {rendered_project}\n registryctl test --project-dir .\n", + env!("CARGO_PKG_VERSION"), + ) + ); + assert_stdout_has_no_terminal_controls(&stdout); + for escaped in [ + "\\\\", "\\'", "\\n", "\\r", "\\t", "\\u001b", "\\u0001", "\\u007f", "\\u0085", + ] { + assert!(stdout.contains(escaped), "missing {escaped:?}: {stdout:?}"); + } + assert_shell_path_is_usable(&rendered_project); +} + +#[test] +fn starter_init_prefixes_relative_leading_dash_paths_for_shell_use() { + for (arguments, expected) in [ + ( + vec!["init", "--from", "http", "--project-dir=-foo"], + "./-foo", + ), + (vec!["init", "--from", "http", "--project-dir", "-"], "./-"), + ] { + let temporary = TempDir::new().expect("temporary directory"); + let output = run_registryctl_in(Some(temporary.path()), &arguments, None); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + assert!( + stdout.contains(&format!(" Directory: {expected}\n")), + "{stdout}" + ); + assert!( + stdout.contains(&format!( + " Editor support: VS Code and Zed ({expected}/.registry-stack-editor/manifest.json)\n" + )), + "{stdout}" + ); + assert!( + stdout.contains(&format!("\nNext:\n cd {expected}\n")), + "{stdout}" + ); + let shell = Command::new("bash") + .args(["-c", &format!("cd {expected} && test -d .")]) + .current_dir(temporary.path()) + .output() + .expect("bash runs leading-dash next command"); + assert!( + shell.status.success(), + "rendered leading-dash path was not reusable by bash: {}", + String::from_utf8_lossy(&shell.stderr) + ); + } +} + +#[test] +fn starter_init_json_is_versioned_and_contains_only_init_facts() { + let temporary = TempDir::new().expect("temporary directory"); + let project = temporary.path().join("registry-project-json"); + let output = run_registryctl( + &[ + "init", + "--from", + "http", + "--project-dir", + project.to_str().expect("UTF-8 project path"), + "--format", + "json", + ], + None, + ); + assert_success(&output); + + let report: Value = serde_json::from_slice(&output.stdout).expect("init emits only JSON"); + assert_eq!(report["schema_version"], "registryctl.init.v1"); + assert_eq!(report["status"], "initialized"); + assert_eq!(report["project"], "fictional-citizen-registry"); + assert_eq!(report["project_kind"], "registry_project"); + assert_eq!(report["output"], project.to_string_lossy().as_ref()); + assert_eq!(report["source"]["kind"], "starter"); + assert_eq!(report["source"]["id"], "http"); + assert_eq!(report["source"]["content_state"], "matches"); + assert_eq!( + report["artifacts"]["project_file"], + project + .join("registry-stack.yaml") + .to_string_lossy() + .as_ref() + ); + assert_eq!( + report["artifacts"]["editor_manifest"], + project + .join(".registry-stack-editor/manifest.json") + .to_string_lossy() + .as_ref() + ); + for unrelated in ["environment", "fixtures", "baseline", "explanation"] { + assert!(report.get(unrelated).is_none(), "unexpected {unrelated}"); + } +} + +#[test] +fn relay_init_defaults_to_the_same_human_result_structure() { + let temporary = TempDir::new().expect("temporary directory"); + let image_lock = write_image_lock(&temporary); + let project = temporary.path().join("my-first-api"); + let output = run_registryctl( + &[ + "init", + "relay", + project.to_str().expect("UTF-8 project path"), + "--sample", + "benefits", + ], + Some(&image_lock), + ); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + assert_eq!( + stdout, + format!( + "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {}\n Sample: benefits\n Bruno collection: {}\n\nNext:\n cd {}\n registryctl doctor --profile local --format json\n registryctl start\n", + project.display(), + project.join("bruno/registry-api").display(), + project.display(), + ) + ); +} + +#[cfg(unix)] +#[test] +fn relay_init_human_artifact_paths_are_line_safe_and_shell_usable() { + let temporary = TempDir::new().expect("temporary directory"); + let image_lock = write_image_lock(&temporary); + let project = control_character_project(&temporary, "my-first-api"); + let output = run_registryctl( + &[ + "init", + "relay", + project.to_str().expect("UTF-8 project path"), + "--sample", + "benefits", + ], + Some(&image_lock), + ); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + let rendered_project = expected_human_path(&project); + let rendered_bruno = expected_human_path(&project.join("bruno/registry-api")); + assert_eq!( + stdout, + format!( + "Initialized Relay spreadsheet API \"my-first-api\".\n Directory: {rendered_project}\n Sample: benefits\n Bruno collection: {rendered_bruno}\n\nNext:\n cd {rendered_project}\n registryctl doctor --profile local --format json\n registryctl start\n", + ) + ); + assert_stdout_has_no_terminal_controls(&stdout); + assert_shell_path_is_usable(&rendered_project); +} + +#[cfg(unix)] +#[test] +fn json_init_paths_preserve_control_characters_for_both_forms() { + let temporary = TempDir::new().expect("temporary directory"); + + let starter_project = control_character_project(&temporary, "registry-project-json"); + let starter_output = run_registryctl( + &[ + "init", + "--from", + "http", + "--project-dir", + starter_project.to_str().expect("UTF-8 project path"), + "--format", + "json", + ], + None, + ); + assert_success(&starter_output); + let starter: Value = + serde_json::from_slice(&starter_output.stdout).expect("starter init emits valid JSON"); + assert_eq!( + starter["output"], + starter_project.to_str().expect("UTF-8 project path") + ); + assert_eq!( + starter["artifacts"]["project_file"], + starter_project + .join("registry-stack.yaml") + .to_str() + .expect("UTF-8 artifact path") + ); + assert_eq!( + starter["artifacts"]["editor_manifest"], + starter_project + .join(".registry-stack-editor/manifest.json") + .to_str() + .expect("UTF-8 artifact path") + ); + + let relay_project = control_character_project(&temporary, "relay-project-json"); + let image_lock = write_image_lock(&temporary); + let relay_output = run_registryctl( + &[ + "init", + "relay", + relay_project.to_str().expect("UTF-8 project path"), + "--sample", + "benefits", + "--format", + "json", + ], + Some(&image_lock), + ); + assert_success(&relay_output); + let relay: Value = + serde_json::from_slice(&relay_output.stdout).expect("Relay init emits valid JSON"); + assert_eq!( + relay["output"], + relay_project.to_str().expect("UTF-8 project path") + ); + assert_eq!( + relay["artifacts"]["project_file"], + relay_project + .join("registryctl.yaml") + .to_str() + .expect("UTF-8 artifact path") + ); + assert_eq!( + relay["artifacts"]["bruno_collection"], + relay_project + .join("bruno/registry-api") + .to_str() + .expect("UTF-8 artifact path") + ); +} + +#[test] +fn relay_init_accepts_json_format_after_the_subcommand_without_mixed_output() { + let temporary = TempDir::new().expect("temporary directory"); + let image_lock = write_image_lock(&temporary); + let project = temporary.path().join("my-first-api-json"); + let output = run_registryctl( + &[ + "init", + "relay", + project.to_str().expect("UTF-8 project path"), + "--sample", + "benefits", + "--format", + "json", + ], + Some(&image_lock), + ); + assert_success(&output); + + let report: Value = serde_json::from_slice(&output.stdout).expect("init emits only JSON"); + assert_eq!(report["schema_version"], "registryctl.init.v1"); + assert_eq!(report["status"], "initialized"); + assert_eq!(report["project"], "my-first-api-json"); + assert_eq!(report["project_kind"], "relay_spreadsheet_api"); + assert_eq!( + report["source"], + json!({"kind": "sample", "id": "benefits"}) + ); + assert_eq!( + report["artifacts"]["bruno_collection"], + project + .join("bruno/registry-api") + .to_string_lossy() + .as_ref() + ); + assert!(report["artifacts"].get("editor_manifest").is_none()); +} diff --git a/crates/registryctl/tests/project_authoring.rs b/crates/registryctl/tests/project_authoring.rs index 7c0feb722..edec2c29a 100644 --- a/crates/registryctl/tests/project_authoring.rs +++ b/crates/registryctl/tests/project_authoring.rs @@ -5,10 +5,11 @@ use std::path::{Path, PathBuf}; use registryctl::{ add_config_anchor_key, build_registry_project, check_registry_project, init_config_anchor, - init_registry_project, render_project_authoring_diagnostics, sign_config_bundle, - test_registry_project, test_registry_project_selected, verify_config_bundle_cli, - BundleSignOptions, ProjectAuthoringDiagnostics, ProjectBuildOptions, ProjectCheckOptions, - ProjectInitOptions, ProjectStarter, ProjectTestOptions, ProjectTestSelection, + init_registry_project, render_project_authoring_diagnostics, setup_registry_project_editor, + sign_config_bundle, test_registry_project, test_registry_project_selected, + verify_config_bundle_cli, BundleSignOptions, InitSource, ProjectAuthoringDiagnostics, + ProjectBuildOptions, ProjectCheckOptions, ProjectEditorSetupOptions, ProjectInitOptions, + ProjectSchemaKind, ProjectStarter, ProjectTestOptions, ProjectTestSelection, }; use sha2::{Digest as _, Sha256}; @@ -1457,13 +1458,30 @@ fn all_advertised_starters_initialize_and_test_without_source_access() { }) .expect("starter initializes"); assert_eq!(initialized.status, "initialized"); - let provenance = initialized - .explanation - .as_ref() - .expect("starter initialization reports provenance"); - assert_eq!(provenance["state"], "matches"); - assert!(provenance["id"].as_str().is_some()); - assert_eq!(provenance["release"], env!("CARGO_PKG_VERSION")); + let InitSource::Starter { + id, + release, + content_state, + .. + } = initialized.source + else { + panic!("starter initialization reports starter provenance"); + }; + assert!(!id.is_empty()); + assert_eq!(release, env!("CARGO_PKG_VERSION")); + assert_eq!(content_state, "matches"); + assert_eq!( + initialized.artifacts.editor_manifest, + Some(project.join(".registry-stack-editor/manifest.json")) + ); + for path in [ + ".registry-stack-editor/manifest.json", + ".vscode/settings.json", + ".vscode/extensions.json", + ".zed/settings.json", + ] { + assert!(project.join(path).is_file(), "{starter:?} missing {path}"); + } let tested = test_registry_project(&ProjectTestOptions { project_directory: project, environment: None, @@ -1546,6 +1564,371 @@ fn malformed_target_attribute_mapping_preserves_typed_authoring_diagnostics() { ); } +#[test] +fn editor_setup_writes_exact_local_schema_mappings_and_manifest() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + std::fs::create_dir(&project).expect("project directory creates"); + std::fs::write(project.join("registry-stack.yaml"), b"[not: valid: yaml") + .expect("invalid authored YAML marker writes"); + + let report = setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project.clone(), + }) + .expect("editor setup does not require valid authored YAML"); + assert_eq!(report.status, "configured"); + assert_eq!(report.files.len(), 9); + + let expected_mappings = ProjectSchemaKind::ALL + .into_iter() + .map(|kind| { + ( + format!("./.registry-stack-editor/schemas/{}", kind.filename()), + kind.file_glob().to_string(), + ) + }) + .collect::>(); + let vscode: serde_json::Value = serde_json::from_slice( + &std::fs::read(project.join(".vscode/settings.json")).expect("VS Code settings read"), + ) + .expect("VS Code settings are JSON"); + let zed: serde_json::Value = serde_json::from_slice( + &std::fs::read(project.join(".zed/settings.json")).expect("Zed settings read"), + ) + .expect("Zed settings are JSON"); + let expected_mappings = serde_json::to_value(&expected_mappings).expect("mappings serialize"); + assert_eq!(vscode["yaml.schemas"], expected_mappings); + assert_eq!( + zed.pointer("/lsp/yaml-language-server/settings/yaml/schemas") + .expect("Zed YAML schema settings use the required nested shape"), + &expected_mappings + ); + assert_eq!( + vscode.as_object().expect("VS Code settings object").len(), + 1, + "SchemaStore and formatter settings must remain untouched" + ); + + let extensions: serde_json::Value = serde_json::from_slice( + &std::fs::read(project.join(".vscode/extensions.json")).expect("extensions read"), + ) + .expect("extensions are JSON"); + assert_eq!( + extensions, + serde_json::json!({ "recommendations": ["redhat.vscode-yaml"] }) + ); + + let schema_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("schemas/project-authoring"); + for kind in ProjectSchemaKind::ALL { + let generated = std::fs::read( + project + .join(".registry-stack-editor/schemas") + .join(kind.filename()), + ) + .expect("generated schema reads"); + assert_eq!(generated, kind.document().as_bytes(), "{kind:?}"); + assert_eq!( + generated, + std::fs::read(schema_root.join(kind.filename())).expect("source schema reads"), + "{kind:?} must use the exact release schema bytes" + ); + } + + let manifest: serde_json::Value = serde_json::from_slice( + &std::fs::read(project.join(".registry-stack-editor/manifest.json")) + .expect("manifest reads"), + ) + .expect("manifest is JSON"); + assert_eq!( + manifest["format"], "registry.stack.editor-manifest", + "manifest format is a stable refresh boundary" + ); + assert_eq!(manifest["version"], 1); + assert_eq!(manifest["registryctl_version"], env!("CARGO_PKG_VERSION")); + let schemas = manifest["schemas"].as_array().expect("manifest schemas"); + assert_eq!(schemas.len(), ProjectSchemaKind::ALL.len()); + for kind in ProjectSchemaKind::ALL { + let relative = format!("schemas/{}", kind.filename()); + let schema = schemas + .iter() + .find(|schema| schema["path"] == relative) + .expect("schema has one manifest entry"); + assert_eq!(schema["file_glob"], kind.file_glob()); + assert_eq!( + schema["sha256"], + format!( + "sha256:{}", + hex::encode(Sha256::digest(kind.document().as_bytes())) + ) + ); + } + + for settings_path in [ + ".registry-stack-editor/manifest.json", + ".vscode/settings.json", + ".vscode/extensions.json", + ".zed/settings.json", + ] { + let contents = + std::fs::read_to_string(project.join(settings_path)).expect("generated JSON reads"); + assert!(!contents.contains(&project.display().to_string())); + assert!(!contents.contains("$HOME")); + assert!(!contents.contains("secret")); + assert!(!contents.contains("\"tasks\"")); + assert!(!contents.contains("\"command\"")); + } +} + +#[test] +fn editor_setup_refreshes_a_verified_prior_schema_bundle() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + std::fs::create_dir(&project).expect("project directory creates"); + std::fs::write(project.join("registry-stack.yaml"), b"invalid-yaml: [") + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + setup_registry_project_editor(&options).expect("initial editor setup passes"); + + let schema_path = project.join(".registry-stack-editor/schemas/project.schema.json"); + let mut prior_schema = std::fs::read(&schema_path).expect("schema reads"); + prior_schema.extend_from_slice(b"\n"); + std::fs::write(&schema_path, &prior_schema).expect("prior schema writes"); + let manifest_path = project.join(".registry-stack-editor/manifest.json"); + let mut prior_manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).expect("manifest reads")) + .expect("manifest parses"); + prior_manifest["registryctl_version"] = serde_json::json!("0.9.0"); + let schema = prior_manifest["schemas"] + .as_array_mut() + .expect("manifest schemas") + .iter_mut() + .find(|schema| schema["path"] == "schemas/project.schema.json") + .expect("project schema manifest entry"); + schema["sha256"] = serde_json::json!(format!( + "sha256:{}", + hex::encode(Sha256::digest(&prior_schema)) + )); + let mut prior_manifest_bytes = + serde_json::to_vec_pretty(&prior_manifest).expect("prior manifest serializes"); + prior_manifest_bytes.push(b'\n'); + std::fs::write(&manifest_path, prior_manifest_bytes).expect("prior manifest writes"); + + setup_registry_project_editor(&options).expect("verified prior bundle refreshes"); + assert_eq!( + std::fs::read(&schema_path).expect("refreshed schema reads"), + ProjectSchemaKind::Project.document().as_bytes() + ); + let refreshed: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).expect("refreshed manifest reads")) + .expect("refreshed manifest parses"); + assert_eq!(refreshed["registryctl_version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + refreshed["schemas"] + .as_array() + .expect("refreshed schemas") + .iter() + .find(|schema| schema["path"] == "schemas/project.schema.json") + .expect("refreshed project schema")["sha256"], + format!( + "sha256:{}", + hex::encode(Sha256::digest( + ProjectSchemaKind::Project.document().as_bytes() + )) + ) + ); +} + +#[test] +fn editor_setup_refuses_tampered_schema_or_manifest_evidence() { + let temporary = tempfile::tempdir().expect("temporary directory"); + + let tampered_schema_project = temporary.path().join("tampered-schema"); + std::fs::create_dir(&tampered_schema_project).expect("project directory creates"); + std::fs::write( + tampered_schema_project.join("registry-stack.yaml"), + b"invalid-yaml: [", + ) + .expect("project marker writes"); + let schema_options = ProjectEditorSetupOptions { + project_directory: tampered_schema_project.clone(), + }; + let schema_report = + setup_registry_project_editor(&schema_options).expect("initial editor setup passes"); + let schema_path = + tampered_schema_project.join(".registry-stack-editor/schemas/project.schema.json"); + let mut tampered_schema = std::fs::read(&schema_path).expect("schema reads"); + tampered_schema.extend_from_slice(b"tampered"); + std::fs::write(&schema_path, &tampered_schema).expect("tampered schema writes"); + let before_schema_failure = schema_report + .files + .iter() + .map(|path| { + ( + path.clone(), + std::fs::read(tampered_schema_project.join(path)).expect("managed file reads"), + ) + }) + .collect::>(); + let error = setup_registry_project_editor(&schema_options) + .expect_err("schema changed without its manifest must be preserved"); + assert!( + format!("{error:#}").contains("project.schema.json"), + "{error:#}" + ); + for (path, expected) in before_schema_failure { + assert_eq!( + std::fs::read(tampered_schema_project.join(path)).expect("managed file still reads"), + expected + ); + } + + let tampered_manifest_project = temporary.path().join("tampered-manifest"); + std::fs::create_dir(&tampered_manifest_project).expect("project directory creates"); + std::fs::write( + tampered_manifest_project.join("registry-stack.yaml"), + b"invalid-yaml: [", + ) + .expect("project marker writes"); + let manifest_options = ProjectEditorSetupOptions { + project_directory: tampered_manifest_project.clone(), + }; + setup_registry_project_editor(&manifest_options).expect("initial editor setup passes"); + let manifest_path = tampered_manifest_project.join(".registry-stack-editor/manifest.json"); + let mut tampered_manifest: serde_json::Value = + serde_json::from_slice(&std::fs::read(&manifest_path).expect("manifest reads")) + .expect("manifest parses"); + tampered_manifest["registryctl_version"] = serde_json::json!("0.9.0"); + tampered_manifest["schemas"][0]["sha256"] = + serde_json::json!(format!("sha256:{}", "0".repeat(64))); + let mut tampered_manifest_bytes = + serde_json::to_vec_pretty(&tampered_manifest).expect("tampered manifest serializes"); + tampered_manifest_bytes.push(b'\n'); + std::fs::write(&manifest_path, &tampered_manifest_bytes).expect("tampered manifest writes"); + let project_schema_before = std::fs::read( + tampered_manifest_project.join(".registry-stack-editor/schemas/project.schema.json"), + ) + .expect("project schema reads"); + let error = setup_registry_project_editor(&manifest_options) + .expect_err("manifest hash without matching schema must be preserved"); + assert!(format!("{error:#}").contains("manifest hash"), "{error:#}"); + assert_eq!( + std::fs::read(&manifest_path).expect("tampered manifest still reads"), + tampered_manifest_bytes + ); + assert_eq!( + std::fs::read( + tampered_manifest_project.join(".registry-stack-editor/schemas/project.schema.json") + ) + .expect("project schema still reads"), + project_schema_before + ); +} + +#[test] +fn editor_setup_is_byte_identical_on_explicit_rerun() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + std::fs::create_dir(&project).expect("project directory creates"); + std::fs::write( + project.join("registry-stack.yaml"), + b"invalid-yaml-is-accepted: [", + ) + .expect("project marker writes"); + let options = ProjectEditorSetupOptions { + project_directory: project.clone(), + }; + let first = setup_registry_project_editor(&options).expect("initial editor setup passes"); + let before = first + .files + .iter() + .map(|path| { + ( + path.clone(), + std::fs::read(project.join(path)).expect("generated file reads"), + ) + }) + .collect::>(); + + let second = setup_registry_project_editor(&options).expect("identical rerun passes"); + assert_eq!(second.files, first.files); + for (path, expected) in before { + assert_eq!( + std::fs::read(project.join(&path)).expect("rerun output reads"), + expected, + "{path} changed on rerun" + ); + } +} + +#[test] +fn editor_setup_conflicts_are_preflighted_without_partial_writes() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + std::fs::create_dir_all(project.join(".vscode")).expect("VS Code directory creates"); + std::fs::create_dir_all(project.join(".zed")).expect("Zed directory creates"); + std::fs::write(project.join("registry-stack.yaml"), b"not-valid-yaml: [") + .expect("project marker writes"); + let vscode = b"{\n \"editor.formatOnSave\": true\n}\n"; + let zed = b"{\n \"format_on_save\": \"on\"\n}\n"; + std::fs::write(project.join(".vscode/settings.json"), vscode) + .expect("conflicting VS Code settings write"); + std::fs::write(project.join(".zed/settings.json"), zed) + .expect("conflicting Zed settings write"); + + let error = setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project.clone(), + }) + .expect_err("nonmatching settings must require a manual merge"); + let diagnostic = error.to_string(); + assert!(diagnostic.contains(".vscode/settings.json"), "{diagnostic}"); + assert!(diagnostic.contains(".zed/settings.json"), "{diagnostic}"); + assert!(diagnostic.contains("manually"), "{diagnostic}"); + assert_eq!( + std::fs::read(project.join(".vscode/settings.json")).expect("VS Code settings preserved"), + vscode + ); + assert_eq!( + std::fs::read(project.join(".zed/settings.json")).expect("Zed settings preserved"), + zed + ); + assert!(!project.join(".registry-stack-editor").exists()); + assert!(!project.join(".vscode/extensions.json").exists()); +} + +#[cfg(unix)] +#[test] +fn editor_setup_rejects_symlinked_output_ancestors_without_writes() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().expect("temporary directory"); + let project = temporary.path().join("editor-project"); + let outside = temporary.path().join("outside"); + std::fs::create_dir(&project).expect("project directory creates"); + std::fs::create_dir(&outside).expect("outside directory creates"); + std::fs::write(project.join("registry-stack.yaml"), b"not-valid-yaml: [") + .expect("project marker writes"); + symlink(&outside, project.join(".zed")).expect("Zed ancestor symlink creates"); + + let error = setup_registry_project_editor(&ProjectEditorSetupOptions { + project_directory: project.clone(), + }) + .expect_err("symlinked output ancestor must fail closed"); + let diagnostic = error.to_string(); + assert!(diagnostic.contains("symlink"), "{diagnostic}"); + assert!(diagnostic.contains(".zed"), "{diagnostic}"); + assert!(!project.join(".registry-stack-editor").exists()); + assert!(!project.join(".vscode").exists()); + assert!( + std::fs::read_dir(outside) + .expect("outside directory reads") + .next() + .is_none(), + "symlink destination must remain untouched" + ); +} + #[test] fn check_explain_reports_starter_divergence_and_runtime_abi() { let temporary = tempfile::tempdir().expect("temporary directory"); diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh index 27fc40286..a35238be7 100755 --- a/docs/site/scripts/check-tutorial.sh +++ b/docs/site/scripts/check-tutorial.sh @@ -213,6 +213,8 @@ require_literal() { require_literal "$REPO_ROOT/src/content/docs/tutorials/author-registry-project.mdx" \ 'registryctl init --from http --project-dir registry-project' +require_literal "$REPO_ROOT/src/content/docs/tutorials/author-registry-project.mdx" \ + 'registryctl authoring editor --project-dir registry-project' require_literal "$REPO_ROOT/src/content/docs/tutorials/configure-project-fhir-r4.mdx" \ 'outputs:' require_literal "$REPO_ROOT/src/content/docs/tutorials/configure-project-fhir-r4.mdx" \ diff --git a/docs/site/scripts/generate-project-starters.mjs b/docs/site/scripts/generate-project-starters.mjs index ede35b216..4b5a14c49 100644 --- a/docs/site/scripts/generate-project-starters.mjs +++ b/docs/site/scripts/generate-project-starters.mjs @@ -91,6 +91,7 @@ export async function buildProjectStarterMatrix(repoRoot = defaultRepoRoot) { fixture: fixture.name, commands: [ `registryctl init --from ${source.starter} --project-dir ${projectDir}`, + `registryctl authoring editor --project-dir ${projectDir}`, `registryctl test --project-dir ${projectDir} ${selection} --trace`, `registryctl test --project-dir ${projectDir} ${selection} --watch`, `registryctl test --project-dir ${projectDir}`, diff --git a/docs/site/scripts/generate-project-starters.test.mjs b/docs/site/scripts/generate-project-starters.test.mjs index 5d7ba6941..d0b141c75 100644 --- a/docs/site/scripts/generate-project-starters.test.mjs +++ b/docs/site/scripts/generate-project-starters.test.mjs @@ -31,17 +31,18 @@ test('derives all advertised starter selections from committed golden workspaces ); }); -test('emits one canonical six-command sequence for every starter', async () => { +test('emits one canonical seven-command sequence for every starter', async () => { const starters = await buildProjectStarterMatrix(repoRoot); assert.equal(starters.length, starterSources.length); for (const starter of starters) { - assert.equal(starter.commands.length, 6); + assert.equal(starter.commands.length, 7); assert.match(starter.commands[0], /^registryctl init --from /); - assert.match(starter.commands[1], / --trace$/); - assert.match(starter.commands[2], / --watch$/); - assert.match(starter.commands[3], /^registryctl test --project-dir [^ ]+$/); - assert.match(starter.commands[4], / --environment local --explain$/); - assert.match(starter.commands[5], / --environment local$/); + assert.match(starter.commands[1], /^registryctl authoring editor --project-dir /); + assert.match(starter.commands[2], / --trace$/); + assert.match(starter.commands[3], / --watch$/); + assert.match(starter.commands[4], /^registryctl test --project-dir [^ ]+$/); + assert.match(starter.commands[5], / --environment local --explain$/); + assert.match(starter.commands[6], / --environment local$/); } }); diff --git a/docs/site/src/components/ProjectStarterSequence.astro b/docs/site/src/components/ProjectStarterSequence.astro index f993a66df..0df2bf7ee 100644 --- a/docs/site/src/components/ProjectStarterSequence.astro +++ b/docs/site/src/components/ProjectStarterSequence.astro @@ -13,10 +13,11 @@ if (selected.length === 0) { ---

- Run these commands in order. The focused test prints a safe synthetic trace. The next command - starts the same fixture as a watch smoke; press Ctrl+C after its initial passing - report. The final three commands test every fixture, explain the redacted plan, and build the - unsigned product inputs. + Run these commands in order. Initialization creates the editor files, and the next idempotent + command verifies them against the running registryctl. The focused test prints a safe + synthetic trace. The following command starts the same fixture as a watch smoke; press + Ctrl+C after its initial passing report. The final three commands test every fixture, + explain the redacted plan, and build the unsigned product inputs.

{selected.map((item) => ( diff --git a/docs/site/src/content/docs/reference/registryctl.mdx b/docs/site/src/content/docs/reference/registryctl.mdx index 3b5a3a106..e1c3d739a 100644 --- a/docs/site/src/content/docs/reference/registryctl.mdx +++ b/docs/site/src/content/docs/reference/registryctl.mdx @@ -15,7 +15,7 @@ import ProjectStarterSequence from '../../../components/ProjectStarterSequence.a `registryctl` is the local adopter command-line tool for Registry Stack. It creates a local project, starts and stops the generated services, and runs validation and smoke checks against them. -This page lists commands released in registryctl `v0.10.0`. +This page lists commands in the current `registryctl` source. Run `registryctl --help` for the built-in usage text. Most human-facing commands also perform a once-per-day update check; see [Update check](#update-check). @@ -33,6 +33,7 @@ complete workflow and generated trust boundary. | --- | --- | | `init --from ` | Copy `http`, `dhis2-tracker`, `opencrvs-dci`, `fhir-r4`, or `snapshot` into a local workspace. | | `init --project-dir ` | Destination for `init --from`. Defaults to the current directory. | +| `init --format json` | Emit the versioned machine-readable initialization report instead of the default human-readable result. | | `test --project-dir ` | Compile and run every integration fixture through the Relay decoder and configured claim evaluator offline. Defaults to the current directory. | | `test --integration --fixture ` | Select one integration fixture. `--fixture` requires `--integration`. | | `test --trace` | Include the safe synthetic interaction trace in the JSON report. | @@ -46,12 +47,16 @@ complete workflow and generated trust boundary. | `authoring xw --format reference` | Print the generated `xw.v1` function reference for Script authors. | | `authoring xw --format editor` | Print generated editor metadata for the `xw.v1` function surface. | | `authoring schema --kind ` | Print a strict project, environment, integration, fixture, or entity JSON Schema. | +| `authoring editor --project-dir ` | Create or verify version-matched VS Code and Zed schema configuration. Defaults to the current directory. | | `build --project-dir --environment ` | Emit deterministic unsigned Config Bundle input directories for the products in the selected topology. | | `build --against --anchor ` | Build against an explicitly verified signed baseline. Both flags are required together. | -`init`, non-watch `test`, and `build` print JSON reports. `check` prints a human-readable review -report by default and accepts `--format json` for automation. Watch mode prints one concise -pass/fail summary per run. +`init` prints a concise human-readable result and tailored next commands by default. +Use `--format json` with either initialization form to emit only a `registryctl.init.v1` report on +standard output. The JSON report contains the project identity and kind, destination, +initialization source, and notable artifact paths. Non-watch `test` and `build` print JSON reports. +`check` prints a human-readable review report by default and accepts `--format json` for +automation. Watch mode prints one concise pass/fail summary per run. An initialized starter records its starter ID, Registry Stack release, and authored-content digest in `registry-stack.yaml`. `init` verifies that digest, and `check --explain` reports `matches` or `diverged` together with the current @@ -91,34 +96,50 @@ documentation copy. -### Project authoring schemas - -The pre-1.0 schemas are versioned JSON Schema Draft 2020-12 documents in the matching -Registry Stack source checkout. The binary installer does not install a separate schema bundle. -Use the files from the exact source revision that produced your `registryctl` binary: - -| Authored file | Schema file | Versioned schema id | -| --- | --- | --- | -| `registry-stack.yaml` | `crates/registryctl/schemas/project-authoring/project.schema.json` | `https://registrystack.example/schemas/project-authoring/project.v1.json` | -| `integrations/*/integration.yaml` | `crates/registryctl/schemas/project-authoring/integration.schema.json` | `https://registrystack.example/schemas/project-authoring/integration.v1.json` | -| `integrations/*/fixtures/*.yaml` | `crates/registryctl/schemas/project-authoring/fixture.schema.json` | `https://registrystack.example/schemas/project-authoring/fixture.v1.json` | -| `environments/*.yaml` | `crates/registryctl/schemas/project-authoring/environment.schema.json` | `https://registrystack.example/schemas/project-authoring/environment.v1.json` | -| `entities/*.yaml` | `crates/registryctl/schemas/project-authoring/entity.schema.json` | `https://registrystack.example/schemas/project-authoring/entity.v1.json` | - -For example, a VS Code workspace inside the Registry Stack checkout can associate the project -schema in `.vscode/settings.json`: - -```json -{ - "yaml.schemas": { - "./crates/registryctl/schemas/project-authoring/project.schema.json": [ - "**/registry-stack.yaml" - ] - } -} +### VS Code and Zed editor setup + +`init --from` creates schema-driven editing support with every starter. For a project created by an +earlier `registryctl`, create or verify the same files with: + +```sh +registryctl authoring editor --project-dir ``` -Add the other four schema paths with their authored-file patterns from the table. +The command copies the five JSON Schema Draft 2020-12 documents embedded in the running +`registryctl`. It records the binary version and schema SHA-256 values in +`.registry-stack-editor/manifest.json`, so the project does not depend on a source checkout, a +mutable branch, or network schema retrieval. + +| Authored file | Project-local schema | +| --- | --- | +| `registry-stack.yaml` | `.registry-stack-editor/schemas/project.schema.json` | +| `environments/*.yaml` | `.registry-stack-editor/schemas/environment.schema.json` | +| `integrations/*/integration.yaml` | `.registry-stack-editor/schemas/integration.schema.json` | +| `integrations/*/fixtures/*.yaml` | `.registry-stack-editor/schemas/fixture.schema.json` | +| `entities/*.yaml` | `.registry-stack-editor/schemas/entity.schema.json` | + +The generated `.vscode/settings.json` and `.zed/settings.json` reserve the five path suffixes in the +table. Open the Registry Stack project directory as the editor root. In VS Code, install or accept +the workspace recommendation for Red Hat YAML. Zed includes YAML language-server support. + +The shared YAML language server interprets configured paths as suffix matches at any depth. A file +such as `notes.yaml` keeps its normal schema detection and formatting, but a nested copy such as +`examples/environments/local.yaml` receives the Registry Stack environment schema. Do not reuse +the five reserved layouts for unrelated YAML inside the opened project. Exact worktree-root routing +requires the later editor extensions rather than portable 1.0 workspace settings. + +Completion, hover descriptions, the document outline, and inline validation then use the matching +Registry Stack schemas. To smoke-test the association, add an unknown top-level key to an +integration or change its `version` from the integer `1` to a string. The editor must diagnose the +temporary edit. Remove it before running `registryctl test`. + +Editor setup is idempotent when every target is unchanged. On a `registryctl` upgrade, it refreshes +a prior generated schema bundle only after the manifest shape and every available schema hash have +been verified. Custom editor settings, malformed manifests, and schema files that disagree with +their manifest are preserved as conflicts. The command stages all changes and rolls them back if +publication fails. Preserve custom settings and install the mappings manually, or restore the +expected generated files before rerunning the command. Automatic JSON-with-comments merging is not +part of the 1.0 setup. For a project that deploys Notary, `environments/*.yaml` can set the existing dedicated-worker memory ceiling explicitly: @@ -219,6 +240,13 @@ not need the lock. Registryctl `v0.8.4` predates the image-lock contract. | --- | --- | | `init relay ` | Create a local Relay-backed spreadsheet API project in ``. | | `init relay --sample ` | Sample project to generate. Defaults to `benefits`. | +| `init relay --format json` | Emit the same `registryctl.init.v1` machine-readable report used by `init --from`. | + +The human-readable result identifies the generated Bruno collection and gives the validation and +start commands. The JSON result contains no progress text, so another program can parse standard +output directly. + +{/* Evidence: crates/registryctl/src/main.rs and crates/registryctl/tests/init_output.rs. */} Use the project-authoring `init --from`, `test`, `check`, and `build` commands for Relay-only, Notary-only, and combined deployments. Registry Notary does not have a direct-source project diff --git a/docs/site/src/content/docs/tutorials/author-registry-project.mdx b/docs/site/src/content/docs/tutorials/author-registry-project.mdx index 109f68e83..28a889011 100644 --- a/docs/site/src/content/docs/tutorials/author-registry-project.mdx +++ b/docs/site/src/content/docs/tutorials/author-registry-project.mdx @@ -53,17 +53,22 @@ Create a local project workspace: registryctl init --from http --project-dir registry-project ``` -The JSON report contains: +The human-readable result confirms the project, starter provenance, and next command: ```text -"status": "initialized", -"explanation": { - "id": "http", - "release": "0.10.0", - "state": "matches" -} +Initialized Registry Stack project "fictional-citizen-registry". + Directory: registry-project + Starter: http (Registry Stack 0.10.0) + Starter content: matches bundled digest + Editor support: VS Code and Zed (registry-project/.registry-stack-editor/manifest.json) + +Next: + cd registry-project + registryctl test --project-dir . ``` +Add `--format json` when another program needs the versioned initialization report. + The copied `registry-stack.yaml` keeps the complete starter provenance, including its authored-content digest. Later, `check --explain` reports whether the workspace still matches that starter or has intentionally diverged. @@ -73,6 +78,14 @@ The command creates this authored layout: ```text registry-project/ registry-stack.yaml + .registry-stack-editor/ + manifest.json + schemas/ + .vscode/ + extensions.json + settings.json + .zed/ + settings.json integrations/ person-record/ integration.yaml @@ -84,6 +97,22 @@ registry-project/ The starter is copied into your workspace. Registry Stack does not resolve a remote package or download integration code at runtime. +## Open the editor workspace + +Initialization copies the five schemas embedded in `registryctl` and configures both VS Code and +Zed. The second command in the canonical sequence, +`registryctl authoring editor --project-dir registry-project`, verifies the same deterministic +setup and is also available for existing projects. + +Open `registry-project` itself as the editor root. In VS Code, accept the Red Hat YAML workspace +recommendation. Zed starts its native YAML language server. Open +`integrations/person-record/integration.yaml` and check key completion, field descriptions on +hover, and the document outline. + +For a short validation demo, add an `unexpected: true` top-level field. Both editors must mark the +unknown field as invalid. Remove the field before continuing. A separate YAML file outside the five +reserved Registry Stack path suffixes, such as `notes.yaml`, keeps its normal editor behavior. + ## Trace one fixture offline Run one matching fixture and include its safe synthetic trace before changing the source contract: diff --git a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx index 7048a51b5..579d6ddbb 100644 --- a/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/publish-spreadsheet-secured-registry-api.mdx @@ -73,6 +73,20 @@ registryctl init relay my-first-api --sample benefits cd my-first-api ``` +The initialization step prints this result before the shell changes directory: + +```text +Initialized Relay spreadsheet API "my-first-api". + Directory: my-first-api + Sample: benefits + Bruno collection: my-first-api/bruno/registry-api + +Next: + cd my-first-api + registryctl doctor --profile local --format json + registryctl start +``` + The sample workbook has three sheets: - `Households`: household registration and district attributes. diff --git a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx index 8e1ed5d7e..0ae584c8b 100644 --- a/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx +++ b/docs/site/src/content/docs/tutorials/verify-claim-registry-api.mdx @@ -39,10 +39,18 @@ Create a local Registry Stack project: registryctl init --from http --project-dir registry-claim-project ``` -The report contains: +The result confirms the initialized project and next command: ```text -"status": "initialized" +Initialized Registry Stack project "fictional-citizen-registry". + Directory: registry-claim-project + Starter: http (Registry Stack 0.10.0) + Starter content: matches bundled digest + Editor support: VS Code and Zed (registry-claim-project/.registry-stack-editor/manifest.json) + +Next: + cd registry-claim-project + registryctl test --project-dir . ``` The authored `registry-stack.yaml` defines one evidence service. Its consultation maps a bounded diff --git a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx index 16d456f8f..c7e5be1fe 100644 --- a/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx +++ b/docs/site/src/content/docs/tutorials/verify-opencrvs-claims.mdx @@ -52,10 +52,18 @@ Create the project workspace: registryctl init --from opencrvs-dci --project-dir opencrvs-project ``` -The JSON report contains: +The result confirms the initialized project and next command: ```text -"status": "initialized" +Initialized Registry Stack project "fictional-civil-registry". + Directory: opencrvs-project + Starter: opencrvs-dci (Registry Stack 0.10.0) + Starter content: matches bundled digest + Editor support: VS Code and Zed (opencrvs-project/.registry-stack-editor/manifest.json) + +Next: + cd opencrvs-project + registryctl test --project-dir . ``` The project contains one combined topology. Relay owns the OpenCRVS integration and consultation. diff --git a/docs/site/src/data/generated/project-starters.json b/docs/site/src/data/generated/project-starters.json index 32d2686e6..323a5b3f7 100644 --- a/docs/site/src/data/generated/project-starters.json +++ b/docs/site/src/data/generated/project-starters.json @@ -8,6 +8,7 @@ "fixture": "active-person", "commands": [ "registryctl init --from http --project-dir http-project", + "registryctl authoring editor --project-dir http-project", "registryctl test --project-dir http-project --integration person-record --fixture active-person --trace", "registryctl test --project-dir http-project --integration person-record --fixture active-person --watch", "registryctl test --project-dir http-project", @@ -24,6 +25,7 @@ "fixture": "complete-health-match", "commands": [ "registryctl init --from dhis2-tracker --project-dir dhis2-project", + "registryctl authoring editor --project-dir dhis2-project", "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --trace", "registryctl test --project-dir dhis2-project --integration health-record --fixture complete-health-match --watch", "registryctl test --project-dir dhis2-project", @@ -40,6 +42,7 @@ "fixture": "birth-record-match", "commands": [ "registryctl init --from opencrvs-dci --project-dir opencrvs-project", + "registryctl authoring editor --project-dir opencrvs-project", "registryctl test --project-dir opencrvs-project --integration birth-record --fixture birth-record-match --trace", "registryctl test --project-dir opencrvs-project --integration birth-record --fixture birth-record-match --watch", "registryctl test --project-dir opencrvs-project", @@ -56,6 +59,7 @@ "fixture": "coverage-active", "commands": [ "registryctl init --from fhir-r4 --project-dir fhir-project", + "registryctl authoring editor --project-dir fhir-project", "registryctl test --project-dir fhir-project --integration coverage --fixture coverage-active --trace", "registryctl test --project-dir fhir-project --integration coverage --fixture coverage-active --watch", "registryctl test --project-dir fhir-project", @@ -72,6 +76,7 @@ "fixture": "snapshot-match", "commands": [ "registryctl init --from snapshot --project-dir snapshot-project", + "registryctl authoring editor --project-dir snapshot-project", "registryctl test --project-dir snapshot-project --integration person-snapshot --fixture snapshot-match --trace", "registryctl test --project-dir snapshot-project --integration person-snapshot --fixture snapshot-match --watch", "registryctl test --project-dir snapshot-project",