| 1 | //! The documented DSH plugin path: a Codewhale bundle package installed into |
| 2 | //! a dedicated `codewhale` DSH profile with `dsh plugin --profile codewhale |
| 3 | //! add <absolute path>` (pnpm required). |
| 4 | //! |
| 5 | //! The bundle is an npm-shaped package under |
| 6 | //! `$CODEWHALE_HOME/integrations/dsh/bundle/` whose `cordis.patch.yml` |
| 7 | //! carries the same identity rows as the `--patch` overlay. Because |
| 8 | //! `dsh plugin add <path>` records a `link:` dependency, `update` only has to |
| 9 | //! rewrite the patch file. The dedicated profile also gets DSH's own shipped |
| 10 | //! app bundle (`@deepseek-ai/dsh-web-app` or `dsh-headless`) linked from the |
| 11 | //! installed launcher, so `dsh --profile codewhale` boots without `--patch`. |
| 12 | //! The user's `web`/`headless` profiles are never touched. The profile |
| 13 | //! directory itself is DSH-owned and is left in place on removal. |
| 14 | |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | |
| 17 | use anyhow::{Context, Result}; |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | |
| 20 | use super::detect::{DshDetection, DshRunner}; |
| 21 | use super::identity::sha256_hex; |
| 22 | use super::receipt::write_atomic; |
| 23 | use super::skin::{self, SKIN_SOURCE}; |
| 24 | use super::{brand, scene}; |
| 25 | |
| 26 | pub(crate) const BUNDLE_DIR: &str = "bundle"; |
| 27 | pub(crate) const BUNDLE_PACKAGE_NAME: &str = "codewhale-dsh-bundle"; |
| 28 | pub(crate) const BUNDLE_PATCH_FILE: &str = "cordis.patch.yml"; |
| 29 | pub(crate) const BUNDLE_PROFILE: &str = "codewhale"; |
| 30 | pub(crate) const BUNDLE_CLIENT_FILE: &str = "lib/client.js"; |
| 31 | pub(crate) const BUNDLE_INDEX_FILE: &str = "lib/index.js"; |
| 32 | /// Last row of `cordis.patch.yml` when the skin is enabled. Appended after the |
| 33 | /// identity overlay so the `--patch` overlay file stays byte-identical. |
| 34 | pub(crate) const SKIN_INSERT_YAML: &str = |
| 35 | "- insert: [{ id: codewhale-skin, name: codewhale-dsh-bundle }]\n"; |
| 36 | |
| 37 | /// Which shipped DSH app bundle the dedicated profile boots. |
| 38 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 39 | #[serde(rename_all = "kebab-case")] |
| 40 | pub(crate) enum DshAppBundle { |
| 41 | Web, |
| 42 | Headless, |
| 43 | } |
| 44 | |
| 45 | impl DshAppBundle { |
| 46 | pub(crate) fn parse(value: &str) -> Option<Self> { |
| 47 | match value { |
| 48 | "web" => Some(Self::Web), |
| 49 | "headless" => Some(Self::Headless), |
| 50 | _ => None, |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | pub(crate) fn package_name(self) -> &'static str { |
| 55 | match self { |
| 56 | Self::Web => "@deepseek-ai/dsh-web-app", |
| 57 | Self::Headless => "@deepseek-ai/dsh-headless", |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Durable facts about an installed bundle. |
| 63 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 64 | pub(crate) struct DshBundleRecord { |
| 65 | pub(crate) installed_at: String, |
| 66 | pub(crate) updated_at: String, |
| 67 | pub(crate) profile: String, |
| 68 | /// `$DSH_HOME/profiles/codewhale` (DSH-owned). |
| 69 | pub(crate) profile_dir: PathBuf, |
| 70 | /// `$CODEWHALE_HOME/integrations/dsh/bundle` (Codewhale-owned). |
| 71 | pub(crate) bundle_dir: PathBuf, |
| 72 | pub(crate) package_name: String, |
| 73 | pub(crate) package_version: String, |
| 74 | /// SHA-256 of `cordis.patch.yml` (identical to the overlay text). |
| 75 | pub(crate) patch_sha256: String, |
| 76 | pub(crate) app_bundle: DshAppBundle, |
| 77 | /// Where the app bundle was linked from (the installed launcher). |
| 78 | pub(crate) app_bundle_source: PathBuf, |
| 79 | pub(crate) pnpm_version: String, |
| 80 | /// SHA-256 of the combined `dsh plugin` output (never the text itself). |
| 81 | pub(crate) pnpm_output_sha256: String, |
| 82 | } |
| 83 | |
| 84 | /// Availability of the plugin path on this machine. |
| 85 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 86 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 87 | pub(crate) enum BundleAvailability { |
| 88 | Available { pnpm_version: String }, |
| 89 | NotAvailable { reason: String }, |
| 90 | } |
| 91 | |
| 92 | impl BundleAvailability { |
| 93 | pub(crate) fn label(&self) -> String { |
| 94 | match self { |
| 95 | Self::Available { pnpm_version } => format!("available (pnpm {pnpm_version})"), |
| 96 | Self::NotAvailable { reason } => format!("not available: {reason}"), |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | fn find_on_path(path: Option<&std::ffi::OsString>, names: &[&str]) -> Option<PathBuf> { |
| 102 | let path = path?; |
| 103 | for dir in std::env::split_paths(path) { |
| 104 | if dir.as_os_str().is_empty() { |
| 105 | continue; |
| 106 | } |
| 107 | for name in names { |
| 108 | let candidate = dir.join(name); |
| 109 | if candidate.is_file() { |
| 110 | return Some(candidate); |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | None |
| 115 | } |
| 116 | |
| 117 | /// Probe pnpm on `PATH` (dsh shells out to it by name). |
| 118 | pub(crate) fn bundle_availability( |
| 119 | path: Option<&std::ffi::OsString>, |
| 120 | runner: &dyn DshRunner, |
| 121 | ) -> BundleAvailability { |
| 122 | let Some(pnpm) = find_on_path(path, &["pnpm", "pnpm.cmd", "pnpm.exe"]) else { |
| 123 | return BundleAvailability::NotAvailable { |
| 124 | reason: "pnpm missing from PATH (dsh plugin shells out to pnpm)".to_string(), |
| 125 | }; |
| 126 | }; |
| 127 | match runner.run(&pnpm, &["--version"]) { |
| 128 | Ok((true, text)) => { |
| 129 | let version = text |
| 130 | .lines() |
| 131 | .map(str::trim) |
| 132 | .rfind(|l| !l.is_empty() && l.chars().next().is_some_and(|c| c.is_ascii_digit())) |
| 133 | .unwrap_or("") |
| 134 | .to_string(); |
| 135 | if version.is_empty() { |
| 136 | BundleAvailability::NotAvailable { |
| 137 | reason: "pnpm --version printed no version".to_string(), |
| 138 | } |
| 139 | } else { |
| 140 | BundleAvailability::Available { |
| 141 | pnpm_version: version, |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | Ok((false, _)) => BundleAvailability::NotAvailable { |
| 146 | reason: "pnpm --version exited non-zero".to_string(), |
| 147 | }, |
| 148 | Err(error) => BundleAvailability::NotAvailable { |
| 149 | reason: format!("pnpm could not be run: {error}"), |
| 150 | }, |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | /// Resolve the installed launcher's package root (`…/@deepseek-ai/dsh`) |
| 155 | /// from the `dsh` binary path (an npm bin shim symlink to `lib/bin.js`). |
| 156 | pub(crate) fn launcher_package_root(binary: &Path) -> Option<PathBuf> { |
| 157 | let resolved = std::fs::canonicalize(binary).ok()?; |
| 158 | // …/@deepseek-ai/dsh/lib/bin.js → …/@deepseek-ai/dsh |
| 159 | let mut dir = resolved.parent()?.to_path_buf(); |
| 160 | for _ in 0..4 { |
| 161 | if is_dsh_package_root(&dir) { |
| 162 | return Some(dir); |
| 163 | } |
| 164 | let Some(parent) = dir.parent() else { break }; |
| 165 | dir = parent.to_path_buf(); |
| 166 | } |
| 167 | // npm on Windows (and some Unix wrappers) install a copied `.cmd`/`.ps1` |
| 168 | // shim next to the prefix's `node_modules` instead of a symlink into the |
| 169 | // package, so the shim's directory owns the launcher package directly. |
| 170 | let sibling = resolved |
| 171 | .parent()? |
| 172 | .join("node_modules") |
| 173 | .join("@deepseek-ai") |
| 174 | .join("dsh"); |
| 175 | is_dsh_package_root(&sibling).then_some(sibling) |
| 176 | } |
| 177 | |
| 178 | fn is_dsh_package_root(dir: &Path) -> bool { |
| 179 | dir.join("package.json").is_file() |
| 180 | && std::fs::read_to_string(dir.join("package.json")) |
| 181 | .ok() |
| 182 | .is_some_and(|text| text.contains("\"@deepseek-ai/dsh\"")) |
| 183 | } |
| 184 | |
| 185 | /// The shipped app bundle directory inside the installed launcher, if it |
| 186 | /// declares `dsh.bundle.patch`. |
| 187 | pub(crate) fn app_bundle_source(binary: &Path, app: DshAppBundle) -> Result<PathBuf> { |
| 188 | let root = launcher_package_root(binary).ok_or_else(|| { |
| 189 | anyhow::anyhow!( |
| 190 | "cannot locate the installed dsh package root from {}", |
| 191 | binary.display() |
| 192 | ) |
| 193 | })?; |
| 194 | let dir = root.join("node_modules").join(app.package_name()); |
| 195 | let manifest = std::fs::read_to_string(dir.join("package.json")) |
| 196 | .with_context(|| format!("read {}", dir.join("package.json").display()))?; |
| 197 | if !manifest.contains("\"bundle\"") || !manifest.contains("\"patch\"") { |
| 198 | anyhow::bail!( |
| 199 | "{} does not declare dsh.bundle.patch; cannot link it as a profile bundle", |
| 200 | dir.display() |
| 201 | ); |
| 202 | } |
| 203 | Ok(dir) |
| 204 | } |
| 205 | |
| 206 | pub(crate) fn bundle_version(codewhale_version: &str, patch_sha256: &str) -> String { |
| 207 | format!( |
| 208 | "{codewhale_version}+dsh.{}", |
| 209 | &patch_sha256[..12.min(patch_sha256.len())] |
| 210 | ) |
| 211 | } |
| 212 | |
| 213 | /// Identity overlay plus the optional skin insert row. The `--patch` overlay |
| 214 | /// file is never this text — only the dedicated profile's `cordis.patch.yml`. |
| 215 | pub(crate) fn render_bundle_patch(overlay_text: &str, skin: bool) -> String { |
| 216 | if !skin { |
| 217 | return overlay_text.to_string(); |
| 218 | } |
| 219 | let mut out = overlay_text.to_string(); |
| 220 | if !out.ends_with('\n') && !out.is_empty() { |
| 221 | out.push('\n'); |
| 222 | } |
| 223 | out.push_str(SKIN_INSERT_YAML); |
| 224 | out |
| 225 | } |
| 226 | |
| 227 | /// Files the bundle package consists of (all Codewhale-owned). `ocean` only |
| 228 | /// matters with `skin`: it splices the ambient scene into `lib/client.js` |
| 229 | /// (dsh serves one client file per plugin, so there is no `lib/scene.js`). |
| 230 | pub(crate) fn render_bundle_files( |
| 231 | codewhale_version: &str, |
| 232 | overlay_text: &str, |
| 233 | skin: bool, |
| 234 | ocean: bool, |
| 235 | ) -> Vec<(&'static str, String)> { |
| 236 | let patch_sha = sha256_hex(overlay_text.as_bytes()); |
| 237 | let version = bundle_version(codewhale_version, &patch_sha); |
| 238 | let mut dsh = serde_json::json!({ |
| 239 | "bundle": { "patch": format!("./{BUNDLE_PATCH_FILE}") } |
| 240 | }); |
| 241 | let mut package_json = serde_json::json!({ |
| 242 | "name": BUNDLE_PACKAGE_NAME, |
| 243 | "version": version, |
| 244 | "private": true, |
| 245 | "description": "DeepSeek Harness connected through Codewhale: identity overlay bundle (generated; do not edit)", |
| 246 | "license": "MIT", |
| 247 | "dsh": dsh.clone(), |
| 248 | "codewhale": { |
| 249 | "generated_by": "codewhale integrations dsh install-bundle", |
| 250 | "patch_sha256": patch_sha, |
| 251 | "skin": skin, |
| 252 | "ocean": skin && ocean, |
| 253 | } |
| 254 | }); |
| 255 | if skin { |
| 256 | dsh["client"] = serde_json::json!({ |
| 257 | "platform": "web", |
| 258 | "immediately": true, |
| 259 | "inject": ["@deepseek-ai/dsh-client-ui-theme"], |
| 260 | }); |
| 261 | package_json["dsh"] = dsh; |
| 262 | package_json["type"] = serde_json::json!("module"); |
| 263 | package_json["main"] = serde_json::json!("./lib/index.js"); |
| 264 | // Node's exports map is exhaustive: the cordis loader imports the bare |
| 265 | // package name (needs ".") and dsh-client-modules resolves |
| 266 | // `<name>/package.json` (needs "./package.json"); without both, the |
| 267 | // insert row fails with ERR_PACKAGE_PATH_NOT_EXPORTED and the client |
| 268 | // half is silently never served. |
| 269 | package_json["exports"] = serde_json::json!({ |
| 270 | ".": { "default": format!("./{BUNDLE_INDEX_FILE}") }, |
| 271 | "./client": { "default": format!("./{BUNDLE_CLIENT_FILE}") }, |
| 272 | "./package.json": "./package.json" |
| 273 | }); |
| 274 | package_json["codewhale"]["skin_sha256"] = serde_json::json!(skin::skin_tokens_sha256()); |
| 275 | package_json["codewhale"]["skin_source"] = serde_json::json!(SKIN_SOURCE); |
| 276 | package_json["codewhale"]["brand_sha256"] = serde_json::json!(brand::brand_sha256()); |
| 277 | if ocean { |
| 278 | package_json["codewhale"]["ocean_scene_sha256"] = |
| 279 | serde_json::json!(scene::scene_sha256()); |
| 280 | } |
| 281 | } |
| 282 | let readme = format!( |
| 283 | "# {BUNDLE_PACKAGE_NAME}\n\nDeepSeek Harness connected through Codewhale.\n\nGenerated bundle: `{BUNDLE_PATCH_FILE}` carries the exact Codewhale provider/model/endpoint identity (no credentials). Installed into the dedicated DSH profile `{BUNDLE_PROFILE}` with `dsh plugin --profile {BUNDLE_PROFILE} add <this directory>`. Regenerated by `codewhale integrations dsh update`; removed by `codewhale integrations dsh remove-bundle`. Do not edit by hand.\n" |
| 284 | ); |
| 285 | let notice = "This bundle is generated by Codewhale and configures the official DeepSeek Harness (dsh).\n\nDeepSeek Harness — MIT License, Copyright (c) 2026 DeepSeek. The DeepSeek Harness copyright and permission notice apply to the harness packages this bundle references; this bundle redistributes none of them.\n\nThis generated bundle is provided under the MIT License.\n".to_string(); |
| 286 | let mut files = vec![ |
| 287 | ( |
| 288 | "package.json", |
| 289 | format!( |
| 290 | "{}\n", |
| 291 | serde_json::to_string_pretty(&package_json).expect("json") |
| 292 | ), |
| 293 | ), |
| 294 | (BUNDLE_PATCH_FILE, render_bundle_patch(overlay_text, skin)), |
| 295 | ("README.md", readme), |
| 296 | ("NOTICE.md", notice), |
| 297 | ]; |
| 298 | if skin { |
| 299 | files.push((BUNDLE_INDEX_FILE, skin::bundle_index_js())); |
| 300 | files.push((BUNDLE_CLIENT_FILE, skin::bundle_client_js(ocean))); |
| 301 | } |
| 302 | files |
| 303 | } |
| 304 | |
| 305 | /// Write (or rewrite) the bundle package. Returns the identity-overlay SHA-256 |
| 306 | /// (not the on-disk patch hash, which may include the skin insert row). |
| 307 | pub(crate) fn write_bundle( |
| 308 | bundle_dir: &Path, |
| 309 | codewhale_version: &str, |
| 310 | overlay_text: &str, |
| 311 | skin: bool, |
| 312 | ocean: bool, |
| 313 | ) -> Result<String> { |
| 314 | std::fs::create_dir_all(bundle_dir) |
| 315 | .with_context(|| format!("create {}", bundle_dir.display()))?; |
| 316 | if skin { |
| 317 | std::fs::create_dir_all(bundle_dir.join("lib")) |
| 318 | .with_context(|| format!("create {}", bundle_dir.join("lib").display()))?; |
| 319 | } |
| 320 | for (name, text) in render_bundle_files(codewhale_version, overlay_text, skin, ocean) { |
| 321 | write_atomic(&bundle_dir.join(name), text.as_bytes())?; |
| 322 | } |
| 323 | if !skin { |
| 324 | remove_client_half(bundle_dir)?; |
| 325 | } |
| 326 | Ok(sha256_hex(overlay_text.as_bytes())) |
| 327 | } |
| 328 | |
| 329 | fn remove_client_half(bundle_dir: &Path) -> Result<()> { |
| 330 | for name in [BUNDLE_CLIENT_FILE, BUNDLE_INDEX_FILE] { |
| 331 | let path = bundle_dir.join(name); |
| 332 | match std::fs::remove_file(&path) { |
| 333 | Ok(()) => {} |
| 334 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} |
| 335 | Err(error) => return Err(error).with_context(|| format!("remove {}", path.display())), |
| 336 | } |
| 337 | } |
| 338 | let _ = std::fs::remove_dir(bundle_dir.join("lib")); |
| 339 | Ok(()) |
| 340 | } |
| 341 | |
| 342 | pub(crate) fn remove_bundle_files(bundle_dir: &Path) -> Result<Vec<PathBuf>> { |
| 343 | let mut removed = Vec::new(); |
| 344 | for name in [ |
| 345 | "package.json", |
| 346 | BUNDLE_PATCH_FILE, |
| 347 | "README.md", |
| 348 | "NOTICE.md", |
| 349 | BUNDLE_CLIENT_FILE, |
| 350 | BUNDLE_INDEX_FILE, |
| 351 | ] { |
| 352 | let path = bundle_dir.join(name); |
| 353 | match std::fs::remove_file(&path) { |
| 354 | Ok(()) => removed.push(path), |
| 355 | Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} |
| 356 | Err(error) => return Err(error).with_context(|| format!("remove {}", path.display())), |
| 357 | } |
| 358 | } |
| 359 | let _ = std::fs::remove_dir(bundle_dir.join("lib")); |
| 360 | // Only remove the directory when Codewhale left nothing else in it. |
| 361 | let _ = std::fs::remove_dir(bundle_dir); |
| 362 | Ok(removed) |
| 363 | } |
| 364 | |
| 365 | /// On-disk client half vs the receipt's skin/ocean decision. `None` means in |
| 366 | /// sync. The scene lives inside `lib/client.js`, so the byte comparison covers |
| 367 | /// it: an ocean toggle or a drifted scene reads as a modified client half. |
| 368 | pub(crate) fn client_half_stale(bundle_dir: &Path, skin: bool, ocean: bool) -> Option<String> { |
| 369 | let client = bundle_dir.join(BUNDLE_CLIENT_FILE); |
| 370 | let present = client.is_file(); |
| 371 | if skin { |
| 372 | if !present { |
| 373 | return Some("bundle lib/client.js is missing; run `update`".to_string()); |
| 374 | } |
| 375 | match std::fs::read_to_string(&client) { |
| 376 | Ok(text) if text == skin::bundle_client_js(ocean) => None, |
| 377 | Ok(_) => Some( |
| 378 | "bundle lib/client.js was modified outside Codewhale; run `update`".to_string(), |
| 379 | ), |
| 380 | Err(_) => Some("bundle lib/client.js is unreadable; run `update`".to_string()), |
| 381 | } |
| 382 | } else if present { |
| 383 | Some("bundle lib/client.js is present but skin is disabled; run `update`".to_string()) |
| 384 | } else { |
| 385 | None |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | /// Outcome of one `dsh plugin …` invocation (output is hashed, not kept). |
| 390 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 391 | pub(crate) struct PluginCommandOutcome { |
| 392 | pub(crate) args: Vec<String>, |
| 393 | pub(crate) success: bool, |
| 394 | pub(crate) output_sha256: String, |
| 395 | pub(crate) output_excerpt: String, |
| 396 | } |
| 397 | |
| 398 | fn run_dsh_plugin( |
| 399 | runner: &dyn DshRunner, |
| 400 | binary: &Path, |
| 401 | profile: &str, |
| 402 | verb_and_args: &[&str], |
| 403 | ) -> Result<PluginCommandOutcome> { |
| 404 | let mut args = vec!["plugin", "--profile", profile]; |
| 405 | args.extend_from_slice(verb_and_args); |
| 406 | let (success, output) = runner.run(binary, &args).with_context(|| { |
| 407 | format!( |
| 408 | "run dsh plugin --profile {profile} {}", |
| 409 | verb_and_args.join(" ") |
| 410 | ) |
| 411 | })?; |
| 412 | let excerpt: String = output |
| 413 | .lines() |
| 414 | .filter(|l| { |
| 415 | !l.trim().is_empty() |
| 416 | && !l.contains("pnpm.io") |
| 417 | && !l.contains('│') |
| 418 | && !l.contains('╭') |
| 419 | && !l.contains('╰') |
| 420 | }) |
| 421 | .rev() |
| 422 | .take(4) |
| 423 | .collect::<Vec<_>>() |
| 424 | .into_iter() |
| 425 | .rev() |
| 426 | .collect::<Vec<_>>() |
| 427 | .join(" | "); |
| 428 | Ok(PluginCommandOutcome { |
| 429 | args: args.iter().map(|s| (*s).to_string()).collect(), |
| 430 | success, |
| 431 | output_sha256: sha256_hex(output.as_bytes()), |
| 432 | output_excerpt: excerpt.chars().take(400).collect(), |
| 433 | }) |
| 434 | } |
| 435 | |
| 436 | /// Install the app bundle (linked from the installed launcher) and then the |
| 437 | /// Codewhale bundle, in that order so Codewhale's rows patch last. |
| 438 | pub(crate) fn install_into_profile( |
| 439 | runner: &dyn DshRunner, |
| 440 | detection: &DshDetection, |
| 441 | app: DshAppBundle, |
| 442 | bundle_dir: &Path, |
| 443 | ) -> Result<(PathBuf, Vec<PluginCommandOutcome>)> { |
| 444 | let binary = detection |
| 445 | .binary |
| 446 | .as_ref() |
| 447 | .ok_or_else(|| anyhow::anyhow!("dsh binary is unknown"))?; |
| 448 | let app_source = app_bundle_source(binary, app)?; |
| 449 | let mut outcomes = Vec::new(); |
| 450 | let app_source_str = app_source.display().to_string(); |
| 451 | let first = run_dsh_plugin(runner, binary, BUNDLE_PROFILE, &["add", &app_source_str])?; |
| 452 | let first_ok = first.success; |
| 453 | let first_excerpt = first.output_excerpt.clone(); |
| 454 | outcomes.push(first); |
| 455 | if !first_ok { |
| 456 | anyhow::bail!( |
| 457 | "dsh plugin add {} failed: {}", |
| 458 | app.package_name(), |
| 459 | first_excerpt |
| 460 | ); |
| 461 | } |
| 462 | let bundle_str = bundle_dir.display().to_string(); |
| 463 | let second = run_dsh_plugin(runner, binary, BUNDLE_PROFILE, &["add", &bundle_str])?; |
| 464 | let second_ok = second.success; |
| 465 | let second_excerpt = second.output_excerpt.clone(); |
| 466 | outcomes.push(second); |
| 467 | if !second_ok { |
| 468 | anyhow::bail!("dsh plugin add {BUNDLE_PACKAGE_NAME} failed: {second_excerpt}"); |
| 469 | } |
| 470 | Ok((app_source, outcomes)) |
| 471 | } |
| 472 | |
| 473 | pub(crate) fn remove_from_profile( |
| 474 | runner: &dyn DshRunner, |
| 475 | detection: &DshDetection, |
| 476 | ) -> Result<PluginCommandOutcome> { |
| 477 | let binary = detection |
| 478 | .binary |
| 479 | .as_ref() |
| 480 | .ok_or_else(|| anyhow::anyhow!("dsh binary is unknown"))?; |
| 481 | let outcome = run_dsh_plugin( |
| 482 | runner, |
| 483 | binary, |
| 484 | BUNDLE_PROFILE, |
| 485 | &["remove", BUNDLE_PACKAGE_NAME], |
| 486 | )?; |
| 487 | if !outcome.success { |
| 488 | anyhow::bail!( |
| 489 | "dsh plugin remove {BUNDLE_PACKAGE_NAME} failed: {}", |
| 490 | outcome.output_excerpt |
| 491 | ); |
| 492 | } |
| 493 | Ok(outcome) |
| 494 | } |
| 495 | |
| 496 | /// Read the dedicated profile's `dsh.profile.bundles` (DSH-owned manifest, |
| 497 | /// read-only) so status can prove the bundle is actually composed. |
| 498 | pub(crate) fn profile_bundles(profile_dir: &Path) -> Option<Vec<String>> { |
| 499 | let text = std::fs::read_to_string(profile_dir.join("package.json")).ok()?; |
| 500 | let json: serde_json::Value = serde_json::from_str(&text).ok()?; |
| 501 | Some( |
| 502 | json.get("dsh")? |
| 503 | .get("profile")? |
| 504 | .get("bundles")? |
| 505 | .as_array()? |
| 506 | .iter() |
| 507 | .filter_map(|v| v.as_str().map(str::to_string)) |
| 508 | .collect(), |
| 509 | ) |
| 510 | } |
| 511 |