返回 CodeWhale
bundle.rs
根目录 / crates / tui / src / integrations / dsh / bundle.rs
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
511 lines RUST