feat: use builtin profile by default
This commit is contained in:
@@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{PodManifest, PodManifestConfig, ResolveError, paths};
|
||||
|
||||
const PROFILE_FORMAT_V1: &str = "insomnia.nix-profile.v1";
|
||||
const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
|
||||
|
||||
/// Registry source for discovered profiles.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -221,6 +222,24 @@ impl ProfileRegistry {
|
||||
self.default = Some(default);
|
||||
}
|
||||
|
||||
fn set_builtin_default_if_available(&mut self) {
|
||||
if self.default.is_some() {
|
||||
return;
|
||||
}
|
||||
if self
|
||||
.select_named(
|
||||
Some(ProfileRegistrySource::Builtin),
|
||||
BUILTIN_DEFAULT_PROFILE_NAME,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
self.default = Some(ProfileDefault {
|
||||
source: Some(ProfileRegistrySource::Builtin),
|
||||
name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_default_flags(&mut self) {
|
||||
let Some(default) = self.default.clone() else {
|
||||
return;
|
||||
@@ -282,6 +301,7 @@ impl ProfileDiscovery {
|
||||
if let Some(path) = &self.project_config {
|
||||
load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?;
|
||||
}
|
||||
registry.set_builtin_default_if_available();
|
||||
registry.mark_default_flags();
|
||||
Ok(registry)
|
||||
}
|
||||
@@ -323,12 +343,14 @@ pub struct ResolvedProfile {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NixProfileResolver {
|
||||
nix_bin: PathBuf,
|
||||
workspace_base: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for NixProfileResolver {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nix_bin: PathBuf::from("nix"),
|
||||
workspace_base: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,9 +363,15 @@ impl NixProfileResolver {
|
||||
pub fn with_nix_bin(nix_bin: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
nix_bin: nix_bin.into(),
|
||||
workspace_base: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_workspace_base(mut self, workspace_base: impl Into<PathBuf>) -> Self {
|
||||
self.workspace_base = Some(workspace_base.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resolve(&self, selector: &ProfileSelector) -> Result<ResolvedProfile, ProfileError> {
|
||||
match selector {
|
||||
ProfileSelector::Path { path } => self.resolve_path(
|
||||
@@ -351,6 +379,7 @@ impl NixProfileResolver {
|
||||
ProfileSource::Path {
|
||||
path: absolutize(path)?,
|
||||
},
|
||||
None,
|
||||
),
|
||||
ProfileSelector::Named { .. } | ProfileSelector::Default => {
|
||||
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
|
||||
@@ -359,6 +388,11 @@ impl NixProfileResolver {
|
||||
})?;
|
||||
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||
let entry = registry.select(selector)?.clone();
|
||||
let artifact_base = if entry.source == ProfileRegistrySource::Builtin {
|
||||
Some(absolutize(self.workspace_base.as_deref().unwrap_or(&cwd))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.resolve_path(
|
||||
&entry.path,
|
||||
ProfileSource::Registry {
|
||||
@@ -366,6 +400,7 @@ impl NixProfileResolver {
|
||||
name: entry.name,
|
||||
path: absolutize(&entry.path)?,
|
||||
},
|
||||
artifact_base,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -375,15 +410,17 @@ impl NixProfileResolver {
|
||||
&self,
|
||||
path: &Path,
|
||||
source: ProfileSource,
|
||||
manifest_base_override: Option<PathBuf>,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
let absolute_path = absolutize(path)?;
|
||||
let base_dir = absolute_path
|
||||
let file_base_dir = absolute_path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| ProfileError::InvalidPath {
|
||||
path: absolute_path.clone(),
|
||||
message: "profile path has no parent directory".to_string(),
|
||||
})?;
|
||||
let base_dir = manifest_base_override.unwrap_or(file_base_dir);
|
||||
|
||||
let output = Command::new(&self.nix_bin)
|
||||
.arg("eval")
|
||||
@@ -948,6 +985,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_default_profile_is_registered_as_default() {
|
||||
let registry = ProfileDiscovery::with_sources(paths::builtin_profiles_dir(), None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
let default = registry.default_entry().unwrap();
|
||||
assert_eq!(default.source, ProfileRegistrySource::Builtin);
|
||||
assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME);
|
||||
assert!(default.is_default);
|
||||
assert!(default.path.ends_with("resources/nix/profiles/default.nix"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn builtin_profile_relative_paths_resolve_against_workspace_base() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let profile_path = tmp.path().join("default.nix");
|
||||
std::fs::write(&profile_path, "{}").unwrap();
|
||||
let nix_bin = tmp.path().join("fake-nix");
|
||||
std::fs::write(
|
||||
&nix_bin,
|
||||
r#"#!/bin/sh
|
||||
cat <<'JSON'
|
||||
{"profile":{"format":"insomnia.nix-profile.v1","name":"default"},"manifest":{"pod":{"name":"default"},"model":{"scheme":"anthropic","model_id":"claude-sonnet-4-20250514"},"scope":{"allow":[{"target":".","permission":"write"}]}}}
|
||||
JSON
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = std::fs::metadata(&nix_bin).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&nix_bin, perms).unwrap();
|
||||
let resolved = NixProfileResolver::with_nix_bin(&nix_bin)
|
||||
.resolve_path(
|
||||
&profile_path,
|
||||
ProfileSource::Registry {
|
||||
source: ProfileRegistrySource::Builtin,
|
||||
name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(),
|
||||
path: profile_path.clone(),
|
||||
},
|
||||
Some(workspace.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_cwd_reads_profiles_toml_and_ignores_manifest_profiles() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+118
-96
@@ -4,13 +4,13 @@ use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use manifest::{NixProfileResolver, PodManifest, PodManifestConfig, ProfileSelector, paths};
|
||||
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
||||
use pod::{Pod, PodController, PromptLoader};
|
||||
use session_store::{FsStore, PodMetadataStore, SegmentId, Store};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "insomnia-pod",
|
||||
about = "Spawn a Pod process from manifest layers or a single manifest file"
|
||||
about = "Spawn a Pod process from a Nix profile or a single manifest file"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Nix profile to evaluate. Accepts an explicit path, `path:<path>`, a
|
||||
@@ -29,19 +29,20 @@ struct Cli {
|
||||
#[arg(long, value_name = "NAME", requires = "profile", conflicts_with_all = ["pod", "session", "adopt"])]
|
||||
profile_pod_name: Option<String>,
|
||||
|
||||
/// Manifest TOML to use directly, without loading user, project, or
|
||||
/// overlay layers.
|
||||
/// Manifest TOML to use directly as a one-file compatibility/debug input.
|
||||
/// This bypasses profile discovery but still applies builtin defaults and
|
||||
/// the same required-field validation boundary.
|
||||
#[arg(long, value_name = "PATH", conflicts_with_all = ["project", "overlay"])]
|
||||
manifest: Option<PathBuf>,
|
||||
|
||||
/// Start the project-manifest walk from this directory. When
|
||||
/// omitted, the factory walks up from the current working
|
||||
/// directory looking for `.insomnia/manifest.toml`.
|
||||
/// Deprecated manifest-cascade project root flag. Ambient project/user
|
||||
/// manifest discovery has been removed; configure/select a profile instead.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
project: Option<PathBuf>,
|
||||
|
||||
/// Inline TOML string applied as the highest-priority overlay
|
||||
/// layer. Example: `--overlay 'pod.name = "dbg"'`.
|
||||
/// Inline TOML override applied to the implicit default profile. This is
|
||||
/// retained for launchers that need to supply restore/session-time values;
|
||||
/// it does not re-enable user/project manifest discovery.
|
||||
#[arg(long, value_name = "TOML")]
|
||||
overlay: Option<String>,
|
||||
|
||||
@@ -94,7 +95,7 @@ fn resolve_manifest_with_user_manifest_env(
|
||||
|
||||
fn resolve_manifest_with_user_manifest_env_and_profile_loader<F>(
|
||||
cli: &Cli,
|
||||
user_manifest_env: Option<OsString>,
|
||||
_user_manifest_env: Option<OsString>,
|
||||
load_profile_fn: F,
|
||||
) -> Result<(PodManifest, PromptLoader), String>
|
||||
where
|
||||
@@ -105,29 +106,31 @@ where
|
||||
return load_profile_fn(&selector, cli.profile_pod_name.as_deref());
|
||||
}
|
||||
|
||||
let user_manifest = paths::user_manifest_path_from_env(user_manifest_env);
|
||||
|
||||
if let Some(path) = &cli.manifest {
|
||||
if user_manifest.is_some() {
|
||||
return Err(format!(
|
||||
"--manifest cannot be used when {} is set",
|
||||
paths::USER_MANIFEST_ENV
|
||||
));
|
||||
}
|
||||
return load_single_manifest(path, cli.pod.as_deref());
|
||||
}
|
||||
|
||||
let factory = build_factory_with_user_manifest_path(cli, user_manifest)?;
|
||||
factory
|
||||
.resolve()
|
||||
.map_err(|e| format!("failed to resolve manifest cascade: {e}"))
|
||||
if cli.project.is_some() {
|
||||
return Err(
|
||||
"--project is no longer supported; normal startup uses profile discovery/default, \
|
||||
and --manifest <PATH> is the only one-file manifest mode"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let selector = ProfileSelector::Default;
|
||||
let (manifest, loader) = load_profile_fn(&selector, None)?;
|
||||
let manifest = apply_cli_overrides_to_manifest(manifest, cli)?;
|
||||
Ok((manifest, loader))
|
||||
}
|
||||
|
||||
fn load_profile(
|
||||
selector: &ProfileSelector,
|
||||
pod_name_override: Option<&str>,
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let resolver = NixProfileResolver::new();
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|e| format!("failed to resolve current directory for profile: {e}"))?;
|
||||
let resolver = NixProfileResolver::new().with_workspace_base(cwd);
|
||||
let mut resolved = resolver.resolve(selector).map_err(|e| {
|
||||
format!(
|
||||
"failed to resolve profile {}: {e}",
|
||||
@@ -146,28 +149,32 @@ fn load_single_manifest(
|
||||
) -> Result<(PodManifest, PromptLoader), String> {
|
||||
let toml = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
|
||||
let manifest = match pod_name_override {
|
||||
Some(pod_name) => match PodManifest::from_toml(&toml) {
|
||||
Ok(mut manifest) => {
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
manifest
|
||||
}
|
||||
Err(_) => {
|
||||
let base = PodManifestConfig::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?;
|
||||
let overlay = PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
|
||||
.expect("pod name overlay TOML is generated");
|
||||
PodManifest::try_from(base.merge(overlay)).map_err(|e| {
|
||||
format!(
|
||||
"failed to resolve manifest {} with --pod: {e}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
}
|
||||
},
|
||||
None => PodManifest::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?,
|
||||
let absolute_path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("failed to resolve current directory: {e}"))?
|
||||
.join(path)
|
||||
};
|
||||
let base_dir = absolute_path.parent().ok_or_else(|| {
|
||||
format!(
|
||||
"manifest path {} has no parent directory",
|
||||
absolute_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut config = PodManifestConfig::builtin_defaults().merge(
|
||||
PodManifestConfig::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
|
||||
.resolve_paths(base_dir),
|
||||
);
|
||||
if let Some(pod_name) = pod_name_override {
|
||||
config = config.merge(
|
||||
PodManifestConfig::from_toml(&pod_name_overlay_toml(pod_name))
|
||||
.expect("pod name overlay TOML is generated"),
|
||||
);
|
||||
}
|
||||
let manifest = PodManifest::try_from(config)
|
||||
.map_err(|e| format!("failed to resolve manifest {}: {e}", path.display()))?;
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
}
|
||||
|
||||
@@ -179,43 +186,33 @@ fn pod_name_overlay_toml(pod_name: &str) -> String {
|
||||
toml::to_string(&toml::Value::Table(root)).expect("pod name overlay serialisation cannot fail")
|
||||
}
|
||||
|
||||
fn build_factory_with_user_manifest_path(
|
||||
fn manifest_to_config(manifest: &PodManifest) -> Result<PodManifestConfig, String> {
|
||||
let value = serde_json::to_value(manifest)
|
||||
.map_err(|e| format!("failed to serialise resolved manifest for overlay: {e}"))?;
|
||||
serde_json::from_value(value)
|
||||
.map_err(|e| format!("failed to convert resolved manifest for overlay: {e}"))
|
||||
}
|
||||
|
||||
fn apply_cli_overrides_to_manifest(
|
||||
mut manifest: PodManifest,
|
||||
cli: &Cli,
|
||||
user_manifest: Option<PathBuf>,
|
||||
) -> Result<PodFactory, String> {
|
||||
let mut factory = PodFactory::new();
|
||||
|
||||
factory = match user_manifest {
|
||||
Some(path) => factory
|
||||
.with_user_manifest(path)
|
||||
.map_err(|e| format!("failed to load user manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_user_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load user manifest: {e}"))?,
|
||||
};
|
||||
|
||||
factory = match &cli.project {
|
||||
Some(path) => factory
|
||||
.with_project_manifest_from(path)
|
||||
.map_err(|e| format!("failed to load project manifest: {e}"))?,
|
||||
None => factory
|
||||
.with_project_manifest_auto()
|
||||
.map_err(|e| format!("failed to auto-load project manifest: {e}"))?,
|
||||
};
|
||||
|
||||
) -> Result<PodManifest, String> {
|
||||
let profile = manifest.profile.clone();
|
||||
if let Some(overlay) = cli.overlay.as_deref() {
|
||||
factory = factory
|
||||
.with_overlay_toml(overlay)
|
||||
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?;
|
||||
let base_dir = std::env::current_dir()
|
||||
.map_err(|e| format!("failed to resolve current directory for overlay: {e}"))?;
|
||||
let overlay = PodManifestConfig::from_toml(overlay)
|
||||
.map_err(|e| format!("failed to parse overlay TOML: {e}"))?
|
||||
.resolve_paths(&base_dir);
|
||||
let config = manifest_to_config(&manifest)?.merge(overlay);
|
||||
manifest = PodManifest::try_from(config)
|
||||
.map_err(|e| format!("failed to resolve default profile overlay: {e}"))?;
|
||||
manifest.profile = profile;
|
||||
}
|
||||
|
||||
if let Some(pod_name) = cli.pod.as_deref() {
|
||||
factory = factory
|
||||
.with_overlay_toml(&pod_name_overlay_toml(pod_name))
|
||||
.map_err(|e| format!("failed to apply --pod overlay: {e}"))?;
|
||||
manifest.pod.name = pod_name.to_string();
|
||||
}
|
||||
|
||||
Ok(factory)
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -442,18 +439,20 @@ permission = "write"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_conflicts_with_user_manifest_env_when_env_is_non_empty() {
|
||||
fn manifest_ignores_non_empty_user_manifest_env() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(&manifest, &manifest_toml("single", tmp.path()));
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--manifest", manifest.to_str().unwrap()])
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
|
||||
.unwrap_err();
|
||||
let (manifest, loader) =
|
||||
resolve_manifest_with_user_manifest_env(&cli, Some(OsString::from("user.toml")))
|
||||
.unwrap();
|
||||
|
||||
assert!(err.contains("--manifest cannot be used"));
|
||||
assert!(err.contains(paths::USER_MANIFEST_ENV));
|
||||
assert_eq!(manifest.pod.name, "single");
|
||||
assert!(loader.user_dir().is_none());
|
||||
assert!(loader.workspace_dir().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -549,26 +548,38 @@ permission = "write"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_env_overrides_auto_user_manifest_path() {
|
||||
fn normal_startup_uses_default_profile_and_ignores_user_manifest_env() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_manifest = tmp.path().join("custom-user.toml");
|
||||
write(&user_manifest, &manifest_toml("from-env", tmp.path()));
|
||||
let no_project_root = tmp.path().join("no-project");
|
||||
std::fs::create_dir_all(&no_project_root).unwrap();
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
"--project",
|
||||
no_project_root.to_str().unwrap(),
|
||||
])
|
||||
.unwrap();
|
||||
let cli = Cli::try_parse_from(["insomnia-pod"]).unwrap();
|
||||
let mut called = false;
|
||||
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env_and_profile_loader(
|
||||
&cli,
|
||||
Some(user_manifest.as_os_str().to_os_string()),
|
||||
Some(OsString::from("ignored-user-manifest.toml")),
|
||||
|selector, pod_name| {
|
||||
called = true;
|
||||
assert_eq!(selector, &ProfileSelector::Default);
|
||||
assert_eq!(pod_name, None);
|
||||
let manifest =
|
||||
PodManifest::from_toml(&manifest_toml("from-default-profile", tmp.path()))
|
||||
.unwrap();
|
||||
Ok((manifest, PromptLoader::builtins_only()))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "from-env");
|
||||
assert!(called);
|
||||
assert_eq!(manifest.pod.name, "from-default-profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_flag_no_longer_enables_ambient_manifest_cascade() {
|
||||
let cli = Cli::try_parse_from(["insomnia-pod", "--project", "."]).unwrap();
|
||||
let err = resolve_manifest_with_user_manifest_env_and_profile_loader(&cli, None, |_, _| {
|
||||
panic!("default profile loader must not run when deprecated --project is present")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.contains("--project is no longer supported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -605,7 +616,17 @@ permission = "write"
|
||||
let manifest = tmp.path().join("manifest.toml");
|
||||
write(
|
||||
&manifest,
|
||||
&manifest_toml("unused", tmp.path()).replace("name = \"unused\"\n", ""),
|
||||
r#"
|
||||
[pod]
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[[scope.allow]]
|
||||
target = "."
|
||||
permission = "write"
|
||||
"#,
|
||||
);
|
||||
let cli = Cli::try_parse_from([
|
||||
"insomnia-pod",
|
||||
@@ -619,6 +640,7 @@ permission = "write"
|
||||
let (manifest, _loader) = resolve_manifest_with_user_manifest_env(&cli, None).unwrap();
|
||||
|
||||
assert_eq!(manifest.pod.name, "from-flag");
|
||||
assert_eq!(manifest.scope.allow[0].target, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+40
-155
@@ -1,29 +1,23 @@
|
||||
//! Inline-viewport "spawn Pod and attach" UX.
|
||||
//!
|
||||
//! Rendered at the user's current cursor position when `insomnia` is invoked
|
||||
//! with no positional argument. Walks the cwd for a `.insomnia/manifest.toml`
|
||||
//! to seed manifest defaults and `.insomnia/profiles.toml` to discover profile
|
||||
//! choices, prompts for the Pod's name, and on confirmation
|
||||
//! launches the `insomnia-pod` binary as an independent process with a freshly built
|
||||
//! overlay (name + cwd scope when no project manifest exists). Once
|
||||
//! the process reports its socket via the `INSOMNIA-READY` stderr line,
|
||||
//! the dialog hands control back so main can switch the terminal to
|
||||
//! alternate-screen mode.
|
||||
//! with no positional argument. Discovers `.insomnia/profiles.toml` profile
|
||||
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
|
||||
//! the Pod's name, and on confirmation launches the `insomnia-pod` binary as an
|
||||
//! independent process. Once the process reports its socket via the
|
||||
//! `INSOMNIA-READY` stderr line, the dialog hands control back so main can
|
||||
//! switch the terminal to alternate-screen mode.
|
||||
//!
|
||||
//! The viewport's last frame stays in the terminal's scrollback so the
|
||||
//! user has a record of what was spawned (or why a spawn failed).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{SpawnConfig, spawn_pod};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use manifest::{
|
||||
PodManifestConfig, ProfileDiscovery, ScopeConfig, find_project_manifest_from, load_layer,
|
||||
user_manifest_path, user_manifest_path_from_env,
|
||||
};
|
||||
use manifest::{ProfileDiscovery, ScopeConfig};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
@@ -97,7 +91,11 @@ pub async fn run(
|
||||
profile: Option<String>,
|
||||
) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let mut profile_choices = defaults.profile_choices;
|
||||
let mut profile_choices = if resume_from.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
defaults.profile_choices
|
||||
};
|
||||
let profile_index = initial_profile_index(
|
||||
&mut profile_choices,
|
||||
profile.as_deref(),
|
||||
@@ -232,42 +230,6 @@ struct ProfileChoice {
|
||||
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
|
||||
|
||||
// Run the same merge pod itself uses, then read what's missing off the
|
||||
// result. We only look at `scope.allow` here — `pod.name` is an
|
||||
// instance-level identifier and is supplied by the dialog or `--pod`.
|
||||
// TUI must pre-read the same user manifest path that the pod CLI will use,
|
||||
// including a non-empty INSOMNIA_USER_MANIFEST override; empty values fall
|
||||
// back to the auto-discovered path.
|
||||
let user_layer = user_manifest_path_for_spawn(
|
||||
std::env::var_os(manifest::paths::USER_MANIFEST_ENV),
|
||||
user_manifest_path(),
|
||||
)
|
||||
.filter(|p| p.is_file())
|
||||
.and_then(|p| load_layer(&p).ok());
|
||||
let project_layer = find_project_manifest_from(&cwd).and_then(|p| load_layer(&p).ok());
|
||||
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
for layer in [user_layer.as_ref(), project_layer.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
cascade = cascade.merge(layer.clone());
|
||||
}
|
||||
let cascade_has_scope = !cascade.scope.allow.is_empty();
|
||||
|
||||
let scope_origin = match (
|
||||
project_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
user_layer
|
||||
.as_ref()
|
||||
.is_some_and(|l| !l.scope.allow.is_empty()),
|
||||
) {
|
||||
(true, _) => ScopeOrigin::FromProject,
|
||||
(false, true) => ScopeOrigin::FromUser,
|
||||
(false, false) => ScopeOrigin::CwdDefault,
|
||||
};
|
||||
|
||||
let default_name = cwd
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
@@ -279,8 +241,8 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
|
||||
Ok(SpawnDefaults {
|
||||
cwd,
|
||||
cascade_has_scope,
|
||||
scope_origin,
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name,
|
||||
default_profile_index,
|
||||
profile_choices,
|
||||
@@ -288,16 +250,11 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
||||
}
|
||||
|
||||
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
|
||||
let mut choices = vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}];
|
||||
|
||||
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
|
||||
return (choices, 0);
|
||||
return (Vec::new(), 0);
|
||||
};
|
||||
|
||||
let mut choices = Vec::new();
|
||||
for entry in registry.entries() {
|
||||
let mut label = entry.qualified_name();
|
||||
if entry.is_default {
|
||||
@@ -343,13 +300,6 @@ fn initial_profile_index(
|
||||
choices.len() - 1
|
||||
}
|
||||
|
||||
fn user_manifest_path_for_spawn(
|
||||
env_value: Option<OsString>,
|
||||
default_user_manifest: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
user_manifest_path_from_env(env_value).or(default_user_manifest)
|
||||
}
|
||||
|
||||
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
Form {
|
||||
cwd: defaults.cwd,
|
||||
@@ -362,11 +312,7 @@ fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
@@ -519,16 +465,14 @@ enum MessageKind {
|
||||
}
|
||||
|
||||
enum ScopeOrigin {
|
||||
FromUser,
|
||||
FromProject,
|
||||
FromProfile,
|
||||
CwdDefault,
|
||||
}
|
||||
|
||||
struct Form {
|
||||
cwd: PathBuf,
|
||||
/// True when at least one cascade layer (user or project manifest)
|
||||
/// already declares `scope.allow`. Drives whether the overlay
|
||||
/// should add a cwd-write rule.
|
||||
/// True when the launch source already supplies `scope.allow`.
|
||||
/// Drives whether the compatibility overlay should add a cwd-write rule.
|
||||
cascade_has_scope: bool,
|
||||
/// Display label for the scope row in the dialog.
|
||||
scope_origin: ScopeOrigin,
|
||||
@@ -648,7 +592,7 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
|
||||
let layout = Layout::vertical([
|
||||
Constraint::Length(1), // title
|
||||
Constraint::Length(1), // name field
|
||||
Constraint::Length(1), // context (manifest or scope default)
|
||||
Constraint::Length(1), // context (profile or scope default)
|
||||
Constraint::Length(1), // hint
|
||||
Constraint::Length(1), // message
|
||||
Constraint::Length(1), // spacer
|
||||
@@ -716,15 +660,10 @@ fn context_line(form: &Form) -> Line<'_> {
|
||||
}
|
||||
|
||||
match form.scope_origin {
|
||||
ScopeOrigin::FromProject => Line::from(vec![
|
||||
ScopeOrigin::FromProfile => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from project manifest", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::FromUser => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("from user manifest", Style::default().fg(Color::Green)),
|
||||
Span::styled("from default profile", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
ScopeOrigin::CwdDefault => Line::from(vec![
|
||||
Span::raw(" "),
|
||||
@@ -767,7 +706,7 @@ mod tests {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope,
|
||||
scope_origin: if cascade_has_scope {
|
||||
ScopeOrigin::FromProject
|
||||
ScopeOrigin::FromProfile
|
||||
} else {
|
||||
ScopeOrigin::CwdDefault
|
||||
},
|
||||
@@ -778,11 +717,7 @@ mod tests {
|
||||
resume_from: None,
|
||||
resume_by_pod_name: false,
|
||||
resume_scope: None,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
profile_index: 0,
|
||||
}
|
||||
}
|
||||
@@ -792,14 +727,10 @@ mod tests {
|
||||
let defaults = SpawnDefaults {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProject,
|
||||
scope_origin: ScopeOrigin::FromProfile,
|
||||
default_name: "ignored".to_string(),
|
||||
default_profile_index: 0,
|
||||
profile_choices: vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}],
|
||||
profile_choices: Vec::new(),
|
||||
};
|
||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
||||
|
||||
@@ -860,46 +791,6 @@ mod tests {
|
||||
assert_eq!(deny[0]["target"].as_str(), Some("/work/example/child"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_merge_detects_scope_from_any_layer() {
|
||||
let user = PodManifestConfig::from_toml(
|
||||
r#"
|
||||
[[scope.allow]]
|
||||
target = "/from-user"
|
||||
permission = "write"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut cascade = PodManifestConfig::builtin_defaults();
|
||||
cascade = cascade.merge(user);
|
||||
assert!(!cascade.scope.allow.is_empty());
|
||||
|
||||
let empty_cascade = PodManifestConfig::builtin_defaults();
|
||||
assert!(empty_cascade.scope.allow.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_prefers_non_empty_env_override() {
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("/tmp/override.toml")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/tmp/override.toml")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_manifest_path_for_spawn_treats_empty_env_as_unset() {
|
||||
assert_eq!(
|
||||
user_manifest_path_for_spawn(
|
||||
Some(OsString::from("")),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
),
|
||||
Some(PathBuf::from("/default/manifest.toml")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_use_project_registry_default() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -925,7 +816,7 @@ coder = "profiles/coder.nix"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_choices_include_no_profile_source_labels_and_default_marker() {
|
||||
fn profile_choices_include_builtin_and_project_default_marker() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let insomnia = project.join(".insomnia");
|
||||
@@ -942,22 +833,17 @@ description = "Project coder"
|
||||
.unwrap();
|
||||
|
||||
let (choices, default_index) = profile_choices_for_cwd(&project);
|
||||
assert_eq!(choices[0].selector, None);
|
||||
assert_eq!(choices[0].label, "manifest cascade");
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
|
||||
assert_eq!(choices[0].label, "builtin:default");
|
||||
assert_eq!(default_index, 1);
|
||||
assert_eq!(choices[1].selector.as_deref(), Some("project:coder"));
|
||||
assert_eq!(choices[1].label, "project:coder (default) — Project coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cycle_selects_profiles_and_can_opt_out_of_default() {
|
||||
fn profile_cycle_selects_profiles_without_manifest_cascade_opt_out() {
|
||||
let mut form = form("coder", true);
|
||||
form.profile_choices = vec![
|
||||
ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
},
|
||||
ProfileChoice {
|
||||
selector: Some("project:coder".to_string()),
|
||||
label: "project:coder (default)".to_string(),
|
||||
@@ -969,7 +855,7 @@ description = "Project coder"
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
form.profile_index = 1;
|
||||
form.profile_index = 0;
|
||||
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
@@ -981,7 +867,10 @@ description = "Project coder"
|
||||
Some("user:reviewer")
|
||||
);
|
||||
form.cycle_profile_next();
|
||||
assert_eq!(form.selected_profile_selector(), None);
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
Some("project:coder")
|
||||
);
|
||||
form.cycle_profile_prev();
|
||||
assert_eq!(
|
||||
form.selected_profile_selector().as_deref(),
|
||||
@@ -991,15 +880,11 @@ description = "Project coder"
|
||||
|
||||
#[test]
|
||||
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
|
||||
let mut choices = vec![ProfileChoice {
|
||||
selector: None,
|
||||
label: "manifest cascade".to_string(),
|
||||
is_default: false,
|
||||
}];
|
||||
let mut choices = Vec::new();
|
||||
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
|
||||
assert_eq!(selected, 1);
|
||||
assert_eq!(choices[1].selector.as_deref(), Some("coder"));
|
||||
assert_eq!(choices[1].label, "coder");
|
||||
assert_eq!(selected, 0);
|
||||
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
|
||||
assert_eq!(choices[0].label, "coder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user