1use std::collections::{BTreeMap, BTreeSet};
11use std::path::{Path, PathBuf};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use serde_json::{json, Value};
16
17use crate::capture::{host_port, locate_model, read_config, render_config, tree_digest};
18use crate::error::{Error, Result};
19use crate::lockfile::{Lockfile, Native, Runtime};
20use crate::profiles::{probe_harness, probe_runtime, python_package_versions, run_capture, Ctx, HarnessProfile, Registry};
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(default)]
25pub struct SyncOptions {
26 pub prefix: Option<PathBuf>,
29 pub docker: Option<PathBuf>,
31 pub install: bool,
33 pub pull: bool,
35 pub all_models: bool,
37 pub exact: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43#[serde(tag = "type", rename_all = "kebab-case")]
44pub enum Action {
45 WriteConfig {
49 path: PathBuf,
50 vendored: PathBuf,
51 format: String,
52 pointers: Vec<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 fallback: Option<PathBuf>,
55 },
56 CopyDir { from: PathBuf, to: PathBuf },
58 CopyFile { from: PathBuf, to: PathBuf },
60 GitSkill { url: String, commit: String, subpath: String, to: PathBuf },
62 Run { argv: Vec<String>, env: BTreeMap<String, String> },
64 Remove { path: PathBuf },
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69#[serde(default)]
70pub struct Step {
71 pub id: String,
72 pub kind: String,
74 pub title: String,
75 pub status: String,
77 pub reason: String,
79 pub action: Option<Action>,
80 pub command: Option<String>,
82 pub verify: Option<String>,
84 pub optional: bool,
86 pub size_bytes: Option<u64>,
87}
88
89impl Default for Step {
90 fn default() -> Self {
91 Step {
92 id: String::new(),
93 kind: String::new(),
94 title: String::new(),
95 status: "satisfied".into(),
96 reason: String::new(),
97 action: None,
98 command: None,
99 verify: None,
100 optional: false,
101 size_bytes: None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107#[serde(default)]
108pub struct Plan {
109 pub harness: String,
110 pub harness_name: String,
111 pub binary: Option<String>,
113 pub version: Option<String>,
114 pub mode: String,
116 pub root: PathBuf,
118 pub isolation: BTreeMap<String, String>,
120 pub steps: Vec<Step>,
121}
122
123impl Plan {
124 pub fn count(&self, status: &str) -> usize {
125 self.steps.iter().filter(|s| s.status == status && !s.optional).count()
126 }
127}
128
129const PH_OPEN: &str = "${HLOCK_SECRET_";
134
135pub(crate) fn placeholders(s: &str) -> Vec<String> {
137 let mut out = Vec::new();
138 let mut rest = s;
139 while let Some(i) = rest.find(PH_OPEN) {
140 let after = &rest[i + 2..];
141 match after.find('}') {
142 Some(j) => {
143 out.push(after[..j].to_string());
144 rest = &after[j + 1..];
145 }
146 None => break,
147 }
148 }
149 out
150}
151
152fn collect_placeholders(v: &Value, out: &mut BTreeSet<String>) {
153 match v {
154 Value::String(s) => out.extend(placeholders(s)),
155 Value::Array(a) => a.iter().for_each(|x| collect_placeholders(x, out)),
156 Value::Object(m) => m.values().for_each(|x| collect_placeholders(x, out)),
157 _ => {}
158 }
159}
160
161fn fill(desired: &Value, actual: Option<&Value>, env: &BTreeMap<String, String>, missing: &mut BTreeSet<String>) -> Value {
165 match desired {
166 Value::String(s) if s.contains(PH_OPEN) => {
167 let mut out = s.clone();
168 let mut unfilled = Vec::new();
169 for name in placeholders(s) {
170 match env.get(&name).filter(|v| !v.is_empty()) {
171 Some(v) => out = out.replace(&format!("${{{name}}}"), v),
172 None => unfilled.push(name),
173 }
174 }
175 if unfilled.is_empty() {
176 return Value::String(out);
177 }
178 match actual.and_then(Value::as_str) {
179 Some(a) if !a.contains(PH_OPEN) && !a.is_empty() => Value::String(a.to_string()),
180 _ => {
181 missing.extend(unfilled);
182 Value::String(out)
183 }
184 }
185 }
186 Value::Object(m) => {
187 let am = actual.and_then(Value::as_object);
188 Value::Object(m.iter().map(|(k, v)| (k.clone(), fill(v, am.and_then(|a| a.get(k)), env, missing))).collect())
189 }
190 Value::Array(items) => {
191 let aa = actual.and_then(Value::as_array);
192 Value::Array(items.iter().enumerate().map(|(i, v)| fill(v, aa.and_then(|a| a.get(i)), env, missing)).collect())
193 }
194 other => other.clone(),
195 }
196}
197
198pub(crate) fn set_pointer(root: &mut Value, ptr: &str, v: Value) {
199 if ptr.is_empty() {
200 *root = v;
201 return;
202 }
203 let parts: Vec<String> = ptr.trim_start_matches('/').split('/').map(|p| p.replace("~1", "/").replace("~0", "~")).collect();
204 let mut cur = root;
205 for (i, part) in parts.iter().enumerate() {
206 if !cur.is_object() {
207 *cur = json!({});
208 }
209 let obj = cur.as_object_mut().expect("object");
210 if i == parts.len() - 1 {
211 obj.insert(part.clone(), v);
212 return;
213 }
214 cur = obj.entry(part.clone()).or_insert_with(|| json!({}));
215 }
216}
217
218fn overlay(a: Option<Value>, b: Option<Value>) -> Option<Value> {
221 match (a, b) {
222 (Some(Value::Object(mut am)), Some(Value::Object(bm))) => {
223 for (k, bv) in bm {
224 let merged = overlay(am.remove(&k), Some(bv));
225 if let Some(v) = merged {
226 am.insert(k, v);
227 }
228 }
229 Some(Value::Object(am))
230 }
231 (Some(Value::String(s)), b) if s.contains(PH_OPEN) => b.or(Some(Value::String(s))),
232 (Some(a), _) => Some(a),
233 (None, b) => b,
234 }
235}
236
237fn desired_config(vendored: &Path, format: &str, pointers: &[String], target: &Path, fallback: Option<&Path>, env: &BTreeMap<String, String>) -> Result<(Value, BTreeSet<String>)> {
239 let src_fmt = if pointers.is_empty() { if format == "jsonc" { "json" } else { format } } else { "json" };
240 let desired = read_config(vendored, src_fmt)?.ok_or_else(|| Error::Lockfile(format!("vendored file missing: {}", vendored.display())))?;
241 let actual = read_config(target, format).ok().flatten();
242 let live = fallback.and_then(|f| read_config(f, format).ok().flatten());
243 let secrets_from = overlay(actual.clone(), live);
244 let mut missing = BTreeSet::new();
245 if pointers.is_empty() {
246 return Ok((fill(&desired, secrets_from.as_ref(), env, &mut missing), missing));
247 }
248 let mut out = actual.clone().filter(Value::is_object).unwrap_or_else(|| json!({}));
249 for p in pointers {
250 if let Some(sub) = desired.get(p) {
251 let cur = secrets_from.as_ref().and_then(|a| a.pointer(p));
252 set_pointer(&mut out, p, fill(sub, cur, env, &mut missing));
253 }
254 }
255 Ok((out, missing))
256}
257
258pub(crate) fn target_ctx(profile: &HarnessProfile, ctx: &Ctx, opts: &SyncOptions) -> (Ctx, String, BTreeMap<String, String>) {
264 let mut t = ctx.clone();
265 let mut iso = BTreeMap::new();
266 if let Some(dir) = &opts.docker {
267 t.home = dir.join("home");
268 if let Some(var) = &profile.config_root.env {
269 t.env.remove(var);
270 }
271 return (t, "docker".into(), iso);
272 }
273 if let Some(prefix) = &opts.prefix {
274 let base = if prefix.is_absolute() { prefix.clone() } else { ctx.cwd.join(prefix) };
275 let base = base.join(&profile.id);
276 let root = match &profile.config_root.suffix {
277 Some(sfx) => base.join("xdg").join(sfx),
278 None => base.join("config"),
279 };
280 match profile.isolation_env(&root) {
281 Some((var, value)) => {
282 t.env.insert(var.clone(), value.to_string_lossy().into_owned());
283 iso.insert(var, value.to_string_lossy().into_owned());
284 }
285 None => {
286 t.home = base.join("home");
287 iso.insert("HOME".into(), t.home.to_string_lossy().into_owned());
288 }
289 }
290 return (t, "prefix".into(), iso);
291 }
292 (t, "home".into(), iso)
293}
294
295fn expand(p: &str, root: &Path, t: &Ctx) -> PathBuf {
297 t.expand(p, Some(root))
298}
299
300pub(crate) fn lock_dir_of(lock_path: &Path, ctx: &Ctx) -> PathBuf {
301 match lock_path.parent().filter(|p| !p.as_os_str().is_empty()) {
302 Some(p) if p.is_absolute() => p.to_path_buf(),
303 Some(p) => ctx.cwd.join(p),
304 None => ctx.cwd.clone(),
305 }
306}
307
308pub(crate) fn pretty(p: &Path, ctx: &Ctx) -> String {
310 if let Ok(rel) = p.strip_prefix(&ctx.cwd) {
311 return rel.to_string_lossy().into_owned();
312 }
313 if let Ok(rel) = p.strip_prefix(&ctx.home) {
314 return format!("~/{}", rel.to_string_lossy());
315 }
316 p.to_string_lossy().into_owned()
317}
318
319pub(crate) fn shell_join(argv: &[String]) -> String {
320 argv.iter()
321 .map(|a| if a.chars().all(|c| c.is_ascii_alphanumeric() || "-_./:@=+".contains(c)) { a.clone() } else { format!("'{}'", a.replace('\'', "'\\''")) })
322 .collect::<Vec<_>>()
323 .join(" ")
324}
325
326pub(crate) fn install_command(method: &str, package: Option<&str>, version: Option<&str>, script: Option<&str>) -> Option<Vec<String>> {
328 let v = |sep: &str| version.map(|v| format!("{sep}{v}")).unwrap_or_default();
329 match (method, package) {
330 ("npm", Some(p)) => Some(vec!["npm".into(), "install".into(), "-g".into(), format!("{p}{}", v("@"))]),
331 ("brew", Some(p)) => Some(vec!["brew".into(), "install".into(), p.into()]),
332 ("brew-cask", Some(p)) => Some(vec!["brew".into(), "install".into(), "--cask".into(), p.into()]),
333 ("uv-tool", Some(p)) => Some(vec!["uv".into(), "tool".into(), "install".into(), format!("{p}{}", v("=="))]),
334 ("pip", Some(p)) => Some(vec!["python3".into(), "-m".into(), "pip".into(), "install".into(), format!("{p}{}", v("=="))]),
335 ("script", _) => script.map(|s| vec!["sh".into(), "-c".into(), s.replace("{version}", version.unwrap_or(""))]),
336 _ => None,
337 }
338}
339
340fn parse_source(src: &str) -> (String, Option<String>) {
342 match src.split_once(':') {
343 Some((m, rest)) if !m.contains('@') => {
344 let pkg = if m == "pypi" {
345 rest.split("==").next().unwrap_or(rest).to_string()
346 } else {
347 let (scope, body) = rest.strip_prefix('@').map(|b| ("@", b)).unwrap_or(("", rest));
348 format!("{scope}{}", body.split('@').next().unwrap_or(body))
349 };
350 (if m == "pypi" { "pip".into() } else { m.to_string() }, Some(pkg))
351 }
352 _ => (src.split('@').next().unwrap_or(src).to_string(), None),
353 }
354}
355
356fn install_step(id: String, kind: &str, what: &str, version: Option<&str>, source: Option<&str>, profile_install: &[crate::profiles::InstallMethod], opts: &SyncOptions, verify: Option<String>, ctx: &Ctx) -> Step {
357 let (method, package) = source.map(parse_source).unwrap_or_default();
358 let script = profile_install.iter().find(|m| m.method == "script").and_then(|m| m.command.clone());
359 let manual_hint = profile_install.iter().find(|m| m.method == method || m.method == "app" || m.method == "manual").and_then(|m| m.command.clone());
360 let mut argv = install_command(&method, package.as_deref(), version, script.as_deref());
361 if argv.is_none() {
362 argv = profile_install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), version, m.command.as_deref()).filter(|a| ctx.which(&a[0]).is_some()));
364 }
365 let vtxt = version.map(|v| format!(" {v}")).unwrap_or_default();
366 let mut step = Step { id, kind: kind.into(), title: format!("Install {what}{vtxt}"), verify, ..Default::default() };
367 match argv {
368 Some(a) => {
369 step.command = Some(if a[0] == "sh" && a.len() == 3 { a[2].clone() } else { shell_join(&a) });
370 let tool_ok = ctx.which(&a[0]).is_some();
371 if opts.install && tool_ok {
372 step.status = "auto".into();
373 step.action = Some(Action::Run { argv: a, env: BTreeMap::new() });
374 } else {
375 step.status = "manual".into();
376 step.reason = if !tool_ok {
377 format!("needs `{}`, which isn't on PATH", a[0])
378 } else {
379 "package installs run only with --install".into()
380 };
381 if method == "brew" || method == "brew-cask" {
382 step.reason.push_str("; Homebrew installs the current version, which may differ from the pin");
383 }
384 }
385 }
386 None => {
387 step.status = "manual".into();
388 step.command = manual_hint;
389 step.reason = format!("{what} is installed by hand");
390 }
391 }
392 step
393}
394
395pub fn plan(lock: &Lockfile, lock_path: &Path, reg: &Registry, ctx: &Ctx, opts: &SyncOptions) -> Result<Plan> {
401 let profile = reg.require_harness(&lock.harness.name)?;
402 let (t, mode, isolation) = target_ctx(profile, ctx, opts);
403 let root = profile.config_root(&t);
404 let lock_dir = lock_dir_of(lock_path, ctx);
405 let docker = mode == "docker";
406 let mut steps: Vec<Step> = Vec::new();
407 let mut missing_secrets: BTreeSet<String> = BTreeSet::new();
408 let mut all_secrets: BTreeSet<String> = BTreeSet::new();
409
410 if profile.kind == "cli" && !docker {
412 let probe = probe_harness(profile, ctx);
413 let want = lock.harness.version.as_deref();
414 let bin = profile.binaries.first().cloned().unwrap_or_default();
415 let verify = Some(format!("{bin} {}", profile.version.args.join(" ")));
416 let matches = probe.installed && (want.is_none() || probe.version.as_deref() == want);
417 if matches {
418 steps.push(Step {
419 id: "harness".into(),
420 kind: "harness".into(),
421 title: format!("{} {}", profile.name, probe.version.clone().unwrap_or_default()),
422 reason: "installed".into(),
423 ..Default::default()
424 });
425 } else {
426 let mut s = install_step("harness".into(), "harness", &profile.name, want, lock.harness.source.as_deref(), &profile.install, opts, verify, ctx);
427 if probe.installed {
428 s.title = format!("Install {} {} (found {})", profile.name, want.unwrap_or("?"), probe.version.unwrap_or_else(|| "?".into()));
429 }
430 steps.push(s);
431 }
432 }
433
434 let harness_env = isolation.clone();
436 for n in &lock.native {
437 if let Some(s) = native_step(n, profile, &t, &root, harness_env.clone(), opts, ctx) {
438 steps.push(s);
439 }
440 }
441
442 for c in &lock.config {
444 let Some(target) = profile.file_path(&c.id, &t) else { continue };
445 let display = pretty(&target, ctx);
446 let Some(vend) = &c.vendored else {
447 if c.restore == "record" {
448 let ok = std::fs::read(&target).map(|b| Some(crate::hash::sha256_bytes(&b)) == c.digest).unwrap_or(false);
449 steps.push(Step {
450 id: format!("config:{}", c.id),
451 kind: "config".into(),
452 title: format!("Check {}", c.path),
453 status: if ok { "satisfied" } else { "manual" }.into(),
454 reason: if ok { "matches the lock".into() } else { "project file differs from the lock; it comes from the repo, so update it there".into() },
455 optional: !ok,
456 ..Default::default()
457 });
458 }
459 continue;
460 };
461 let vendored = lock_dir.join(vend);
462 let fallback = (mode == "prefix").then(|| profile.file_path(&c.id, ctx)).flatten();
463 let (desired, missing) = desired_config(&vendored, &c.format, &c.pointers, &target, fallback.as_deref(), &t.env)?;
464 collect_placeholders(&read_config(&vendored, if c.pointers.is_empty() && c.format != "jsonc" { &c.format } else { "json" })?.unwrap_or_default(), &mut all_secrets);
465 missing_secrets.extend(missing.iter().cloned());
466 let actual = read_config(&target, &c.format).ok().flatten();
467 let same = match (&actual, c.pointers.is_empty()) {
468 (Some(a), true) => a == &desired,
469 (Some(a), false) => c.pointers.iter().all(|p| a.pointer(p) == desired.pointer(p)),
470 (None, _) => false,
471 };
472 let what = if c.pointers.is_empty() { display.clone() } else { format!("{} {}", display, c.pointers.join(",")) };
473 let step_id = if c.pointers.is_empty() { format!("config:{}", c.id) } else { format!("config:{}#{}", c.id, c.pointers.join(",")) };
474 steps.push(Step {
475 id: step_id,
476 kind: "config".into(),
477 title: if same { what } else if actual.is_some() { format!("Update {what}") } else { format!("Write {what}") },
478 status: if same { "satisfied" } else { "auto" }.into(),
479 reason: if same { "matches the lock".into() } else if actual.is_some() { "the current file is kept as .hlock-bak".into() } else { String::new() },
480 action: (!same).then(|| Action::WriteConfig { path: target.clone(), vendored, format: c.format.clone(), pointers: c.pointers.clone(), fallback }),
481 ..Default::default()
482 });
483 }
484
485 let write_dir = profile.skills.write.as_ref().map(|w| expand(w, &root, &t));
487 let mut locked_skill_dirs = BTreeSet::new();
488 for s in &lock.skill {
489 if matches!(s.origin.as_str(), "harness-builtin" | "bundled") {
490 continue; }
492 let Some(path) = &s.path else { continue };
493 let mut dest = expand(path, &root, &t);
494 let outside_root = !path.starts_with("{root}") && (path.starts_with('~') || Path::new(path).is_absolute());
495 if mode != "home" && outside_root {
496 if let Some(w) = &write_dir {
497 dest = w.join(&s.name);
498 }
499 }
500 locked_skill_dirs.insert(dest.clone());
501 let ok = dest.join("SKILL.md").is_file() && Some(tree_digest(&dest)) == s.digest;
502 let shown = pretty(&dest, ctx);
503 let mut step = Step { id: format!("skill:{}", s.name), kind: "skill".into(), title: format!("{} → {shown}", s.name), ..Default::default() };
504 if ok {
505 step.reason = "matches the lock".into();
506 } else if let Some(v) = &s.vendored {
507 step.status = "auto".into();
508 step.title = format!("Copy skill {} → {shown}", s.name);
509 step.action = Some(Action::CopyDir { from: lock_dir.join(v), to: dest });
510 } else if let Some(src) = s.source.as_deref().and_then(|x| x.strip_prefix("git+")) {
511 let (url_commit, sub) = src.split_once("#path=").unwrap_or((src, ""));
512 let (url, commit) = url_commit.rsplit_once('@').unwrap_or((url_commit, "HEAD"));
513 let has_git = ctx.which("git").is_some();
514 step.status = if has_git { "auto" } else { "manual" }.into();
515 step.title = format!("Fetch skill {} from {url}@{}", s.name, &commit[..commit.len().min(10)]);
516 step.reason = if has_git { String::new() } else { "needs git".into() };
517 step.command = Some(format!("git clone {url} && git -C <clone> checkout {commit} # then copy {sub}"));
518 step.action = has_git.then(|| Action::GitSkill { url: url.into(), commit: commit.into(), subpath: sub.into(), to: dest });
519 } else {
520 step.status = "manual".into();
521 step.reason = if s.origin == "first-party" { "project skill missing or changed; it comes from the repo".into() } else { "no vendored copy or source to fetch it from".into() };
522 }
523 steps.push(step);
524 }
525 if opts.exact {
526 if let Some(w) = &write_dir {
527 for d in std::fs::read_dir(w).into_iter().flatten().flatten() {
528 let p = d.path();
529 if p.join("SKILL.md").is_file() && !locked_skill_dirs.contains(&p) {
530 steps.push(Step {
531 id: format!("skill-extra:{}", d.file_name().to_string_lossy()),
532 kind: "skill".into(),
533 title: format!("Remove skill {} (not in the lock)", d.file_name().to_string_lossy()),
534 status: "auto".into(),
535 reason: "--exact; moved aside to .hlock-bak".into(),
536 action: Some(Action::Remove { path: p }),
537 ..Default::default()
538 });
539 }
540 }
541 }
542 }
543
544 for i in &lock.instructions {
546 let dest = expand(&i.name, &root, &t);
547 let ok = std::fs::read(&dest).map(|b| Some(crate::hash::sha256_bytes(&b)) == i.digest).unwrap_or(false);
548 let mut step = Step { id: format!("instructions:{}", i.name), kind: "instructions".into(), title: pretty(&dest, ctx), ..Default::default() };
549 if ok {
550 step.reason = "matches the lock".into();
551 } else if let Some(v) = &i.vendored {
552 step.status = "auto".into();
553 step.title = format!("Write {}", pretty(&dest, ctx));
554 step.action = Some(Action::CopyFile { from: lock_dir.join(v), to: dest });
555 } else {
556 step.status = "manual".into();
557 step.optional = true;
558 step.reason = "project instruction file differs from the lock; it comes from the repo".into();
559 }
560 steps.push(step);
561 }
562
563 let mut launchers = BTreeSet::new();
565 for tool in lock.tool.iter().filter(|x| x.kind == "mcp" && x.enabled != Some(false)) {
566 if let Some(cmd) = &tool.command {
567 let bin = Path::new(cmd).file_name().map(|b| b.to_string_lossy().into_owned()).unwrap_or_default();
568 if launchers.insert(bin.clone()) && !docker {
569 let ok = ctx.which(cmd).is_some();
570 let users: Vec<&str> = lock.tool.iter().filter(|x| x.command.as_deref() == Some(cmd)).map(|x| x.name.as_str()).collect();
571 let hint = match bin.as_str() {
572 "npx" | "node" | "npm" => Some("install Node.js: https://nodejs.org (or: brew install node)"),
573 "uvx" | "uv" => Some("curl -LsSf https://astral.sh/uv/install.sh | sh"),
574 "docker" => Some("install Docker: https://docs.docker.com/get-docker/"),
575 _ => None,
576 };
577 steps.push(Step {
578 id: format!("mcp:{bin}"),
579 kind: "mcp".into(),
580 title: format!("`{bin}` for MCP servers: {}", users.join(", ")),
581 status: if ok { "satisfied" } else { "manual" }.into(),
582 reason: if ok { "on PATH".into() } else { format!("`{bin}` isn't on PATH") },
583 command: (!ok).then(|| hint.map(str::to_string)).flatten(),
584 verify: Some(format!("{bin} --version")),
585 ..Default::default()
586 });
587 }
588 }
589 for v in tool.env.values().chain(tool.headers.values()) {
590 all_secrets.extend(placeholders(v));
591 }
592 }
593
594 let used: BTreeSet<(String, String)> = lock.llm.iter().filter_map(|l| l.runtime.clone().map(|r| (r, l.model.clone()))).collect();
596 for rt in &lock.runtime {
597 steps.extend(runtime_steps(rt, &used, reg, ctx, opts, docker));
598 }
599
600 let env_has = |n: &str| ctx.var(n).is_some();
602 for r in &lock.secret {
603 if !all_secrets.contains(&r.name) {
604 continue;
605 }
606 let missing = missing_secrets.contains(&r.name);
607 steps.push(Step {
608 id: format!("secret:{}", r.name),
609 kind: "secret".into(),
610 title: format!("{} ({})", r.name, r.used_by),
611 status: if missing && !docker { "manual" } else { "satisfied" }.into(),
612 reason: if docker {
613 "supplied at `docker run -e` time".into()
614 } else if !missing && env_has(&r.name) {
615 "set in the environment".into()
616 } else if !missing {
617 "kept the value already configured here".into()
618 } else {
619 "not set".into()
620 },
621 command: (missing && !docker).then(|| format!("export {}=… # then re-run hlock sync", r.name)),
622 ..Default::default()
623 });
624 }
625
626 Ok(Plan {
627 harness: profile.id.clone(),
628 harness_name: profile.name.clone(),
629 binary: profile.binaries.first().cloned(),
630 version: lock.harness.version.clone(),
631 mode,
632 root,
633 isolation,
634 steps,
635 })
636}
637
638fn native_step(n: &Native, profile: &HarnessProfile, t: &Ctx, root: &Path, env: BTreeMap<String, String>, opts: &SyncOptions, ctx: &Ctx) -> Option<Step> {
639 let kind = n.kind.rsplit('.').next().unwrap_or(&n.kind);
640 let bin = profile.binaries.first().cloned().unwrap_or_default();
641 let have_bin = ctx.which(&bin).is_some();
642 let mut s = Step { id: format!("{}:{}", n.kind, n.name), kind: "plugin".into(), ..Default::default() };
643 let vtxt = n.version.as_deref().map(|v| format!(" {v}")).unwrap_or_default();
644 match (profile.id.as_str(), kind) {
645 ("claude-code", "marketplace") => {
646 let known = read_config(&root.join("plugins/known_marketplaces.json"), "json").ok().flatten().unwrap_or_default();
647 s.title = format!("Claude Code marketplace {}", n.name);
648 if known.get(&n.name).is_some() {
649 s.reason = "added".into();
650 return Some(s);
651 }
652 let src = n.source.as_deref().unwrap_or("");
653 let arg = src.strip_prefix("github:").or_else(|| src.strip_prefix("git+")).or_else(|| src.strip_prefix("path:")).unwrap_or(src).to_string();
654 let argv = vec![bin.clone(), "plugin".into(), "marketplace".into(), "add".into(), arg];
655 s.title = format!("Add Claude Code marketplace {}", n.name);
656 s.command = Some(shell_join(&argv));
657 s.status = if have_bin { "auto" } else { "manual" }.into();
658 s.reason = if have_bin { String::new() } else { "needs Claude Code installed".into() };
659 s.action = have_bin.then_some(Action::Run { argv, env });
660 }
661 ("claude-code", "plugin") => {
662 let installed = read_config(&root.join("plugins/installed_plugins.json"), "json").ok().flatten().unwrap_or_default();
663 let have = installed["plugins"][&n.name][0]["version"].as_str().map(str::to_string);
664 s.title = format!("Claude Code plugin {}{vtxt}", n.name);
665 if have.is_some() && (have == n.version || n.version.is_none()) {
666 s.reason = "installed".into();
667 return Some(s);
668 }
669 let mut argv = vec![bin.clone(), "plugin".into(), "install".into(), n.name.clone()];
670 if !n.enabled {
671 argv = vec!["sh".into(), "-c".into(), format!("{0} plugin install {1} && {0} plugin disable {1}", shell_join(&[bin.clone()]), shell_join(&[n.name.clone()]))];
672 }
673 s.title = format!("Install Claude Code plugin {}{vtxt}", n.name);
674 s.command = Some(if argv[0] == "sh" { argv[2].clone() } else { shell_join(&argv) });
675 s.status = if have_bin { "auto" } else { "manual" }.into();
676 s.reason = if have_bin { "installs the marketplace's current version".into() } else { "needs Claude Code installed".into() };
677 s.action = have_bin.then_some(Action::Run { argv, env });
678 }
679 ("pi", "package") => {
680 let lock = read_config(&root.join("npm/package-lock.json"), "json").ok().flatten().unwrap_or_default();
681 let have = lock["packages"][format!("node_modules/{}", n.name)]["version"].as_str().map(str::to_string);
682 s.title = format!("Pi package {}{vtxt}", n.name);
683 if have.is_some() && (have == n.version || n.version.is_none()) {
684 s.reason = "installed".into();
685 return Some(s);
686 }
687 let spec = n.source.clone().unwrap_or_else(|| format!("npm:{}", n.name));
688 let argv = vec![bin.clone(), "install".into(), spec];
689 s.title = format!("Install Pi package {}{vtxt}", n.name);
690 s.command = Some(shell_join(&argv));
691 s.status = if have_bin { "auto" } else { "manual" }.into();
692 s.reason = if have_bin { String::new() } else { "needs Pi installed".into() };
693 s.action = have_bin.then_some(Action::Run { argv, env });
694 }
695 ("opencode", "plugin") => {
696 s.title = format!("OpenCode plugin {}{vtxt}", n.name);
697 s.reason = "OpenCode installs plugins listed in its config on startup".into();
698 }
699 (_, "package") if n.source.as_deref().is_some_and(|x| x.starts_with("pypi:")) => {
700 let have = python_package_versions(&[n.name.clone()], profile.run.python.as_deref(), t).get(&n.name).cloned();
701 let pkg = n.name.trim_start_matches("pypi:");
702 s.kind = "package".into();
703 s.title = format!("Python package {pkg}{vtxt}");
704 if have.is_some() && have == n.version {
705 s.reason = "installed".into();
706 return Some(s);
707 }
708 let spec = format!("{pkg}{}", n.version.as_deref().map(|v| format!("=={v}")).unwrap_or_default());
709 let argv = if ctx.which("uv").is_some() { vec!["uv".into(), "pip".into(), "install".into(), spec] } else { vec!["python3".into(), "-m".into(), "pip".into(), "install".into(), spec] };
710 s.title = format!("Install Python package {pkg}{vtxt}");
711 s.command = Some(shell_join(&argv));
712 s.status = if opts.install { "auto" } else { "manual" }.into();
713 s.reason = if opts.install { String::new() } else { "package installs run only with --install".into() };
714 s.action = opts.install.then_some(Action::Run { argv, env: BTreeMap::new() });
715 }
716 _ => {
717 s.title = format!("{} {}{vtxt}", n.kind, n.name);
718 s.status = "manual".into();
719 s.reason = "hlock doesn't know how to install this kind yet".into();
720 s.command = n.source.clone();
721 }
722 }
723 Some(s)
724}
725
726fn runtime_steps(rt: &Runtime, used: &BTreeSet<(String, String)>, reg: &Registry, ctx: &Ctx, opts: &SyncOptions, docker: bool) -> Vec<Step> {
727 let mut out = Vec::new();
728 let base_id = rt.name.split('@').next().unwrap_or(&rt.name);
729 let Some(profile) = reg.runtime(base_id) else { return out };
730 let is_used = |m: &str| used.contains(&(rt.name.clone(), m.to_string()));
731 if rt.location != "local" || docker {
732 let models: Vec<&str> = rt.model.iter().map(|m| m.id.as_str()).collect();
733 let endpoint = rt.endpoint.clone().unwrap_or_default();
734 out.push(Step {
735 id: format!("runtime:{}", rt.name),
736 kind: "runtime".into(),
737 title: format!("{} at {endpoint}", profile.name),
738 status: "manual".into(),
739 reason: format!(
740 "{} server; make sure it's running and serving: {}",
741 if docker { "reached from the container via host.docker.internal; a host" } else { "a self-hosted" },
742 if models.is_empty() { "(no models listed)".to_string() } else { models.join(", ") }
743 ),
744 verify: (!endpoint.is_empty()).then(|| format!("curl -s {}/models", endpoint.trim_end_matches('/'))),
745 optional: !rt.model.iter().any(|m| is_used(&m.id)),
746 ..Default::default()
747 });
748 return out;
749 }
750 let probe = probe_runtime(profile, ctx);
751 let bin = profile.binaries.first().cloned().unwrap_or_default();
752 if probe.installed {
753 out.push(Step { id: format!("runtime:{}", rt.name), kind: "runtime".into(), title: format!("{} {}", profile.name, probe.version.unwrap_or_default()), reason: "installed".into(), ..Default::default() });
754 } else {
755 let mut s = install_step(format!("runtime:{}", rt.name), "runtime", &profile.name, rt.version.as_deref(), rt.source.as_deref(), &profile.install, opts, Some(format!("{bin} --version")), ctx);
756 if rt.source.is_none() {
757 s.command = profile.install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), None, m.command.as_deref()).map(|a| shell_join(&a)).or_else(|| m.command.clone()));
758 }
759 out.push(s);
760 }
761 for m in &rt.model {
762 let here = locate_model(profile, &m.id, ctx);
763 let optional = !is_used(&m.id) && !opts.all_models;
764 let size = m.size_bytes.map(|b| format!(" ({:.1} GB)", b as f64 / 1e9)).unwrap_or_default();
765 let mut s = Step { id: format!("model:{}/{}", rt.name, m.id), kind: "model".into(), title: format!("{} model {}{size}", profile.name, m.id), optional, size_bytes: m.size_bytes, ..Default::default() };
766 let matches = here.present && (m.digest.is_none() || here.digest == m.digest) && (m.revision.is_none() || here.revision == m.revision);
767 if matches {
768 s.reason = "on disk".into();
769 } else {
770 let repo = m.source.as_deref().and_then(|x| x.strip_prefix("hf://")).unwrap_or(&m.id);
771 let cmd = profile.pull.as_ref().map(|p| {
772 p.replace("{model}", &m.id).replace("{repo}", repo).replace("{revision}", m.revision.as_deref().unwrap_or("main")).replace(" {file}", "")
773 });
774 s.title = format!("Pull {} model {}{size}", profile.name, m.id);
775 s.command = cmd.clone();
776 s.verify = (bin == "ollama").then(|| "ollama list".to_string());
777 let can = cmd.is_some() && probe.installed;
778 if opts.pull && can && !optional {
779 s.status = "auto".into();
780 s.action = cmd.map(|c| Action::Run { argv: vec!["sh".into(), "-c".into(), c], env: BTreeMap::new() });
781 } else {
782 s.status = "manual".into();
783 s.reason = if !probe.installed {
784 format!("needs {} installed first", profile.name)
785 } else if optional {
786 "not used by a locked role; pull it with --all-models".into()
787 } else {
788 "model downloads run only with --pull".into()
789 };
790 if here.present && m.digest.is_some() && here.digest != m.digest {
791 s.reason = format!("a different build is on disk; {}", s.reason);
792 }
793 }
794 }
795 out.push(s);
796 }
797 out
798}
799
800fn backup(path: &Path) -> Result<Option<PathBuf>> {
805 if !path.exists() {
806 return Ok(None);
807 }
808 let bak = PathBuf::from(format!("{}.hlock-bak", path.display()));
809 if bak.is_dir() {
810 std::fs::remove_dir_all(&bak)?;
811 } else if bak.exists() {
812 std::fs::remove_file(&bak)?;
813 }
814 std::fs::rename(path, &bak)?;
815 Ok(Some(bak))
816}
817
818pub(crate) fn copy_tree(from: &Path, to: &Path) -> Result<()> {
819 std::fs::create_dir_all(to)?;
820 for e in std::fs::read_dir(from)?.flatten() {
821 let (src, dst) = (e.path(), to.join(e.file_name()));
822 if src.is_dir() {
823 copy_tree(&src, &dst)?;
824 } else {
825 std::fs::copy(&src, &dst)?;
826 }
827 }
828 Ok(())
829}
830
831pub fn run_step(step: &Step, ctx: &Ctx) -> Result<String> {
833 let Some(action) = &step.action else { return Ok("nothing to do".into()) };
834 match action {
835 Action::WriteConfig { path, vendored, format, pointers, fallback } => {
836 let (desired, missing) = desired_config(vendored, format, pointers, path, fallback.as_deref(), &ctx.env)?;
837 let fmt = if format == "jsonc" { "json" } else { format.as_str() };
838 let text = render_config(&desired, fmt).map_err(Error::Profile)?;
839 if let Some(parent) = path.parent() {
840 std::fs::create_dir_all(parent)?;
841 }
842 let bak = if path.exists() {
843 let b = PathBuf::from(format!("{}.hlock-bak", path.display()));
844 std::fs::copy(path, &b)?;
845 Some(b)
846 } else {
847 None
848 };
849 std::fs::write(path, text)?;
850 let mut msg = format!("wrote {}", path.display());
851 if let Some(b) = bak {
852 msg.push_str(&format!(" (previous: {})", b.display()));
853 }
854 if !missing.is_empty() {
855 msg.push_str(&format!("; placeholders left for {}", missing.into_iter().collect::<Vec<_>>().join(", ")));
856 }
857 Ok(msg)
858 }
859 Action::CopyDir { from, to } => {
860 if !from.is_dir() {
861 return Err(Error::Lockfile(format!("vendored copy missing: {}", from.display())));
862 }
863 let bak = backup(to)?;
864 copy_tree(from, to)?;
865 Ok(match bak {
866 Some(b) => format!("copied to {} (previous: {})", to.display(), b.display()),
867 None => format!("copied to {}", to.display()),
868 })
869 }
870 Action::CopyFile { from, to } => {
871 if let Some(parent) = to.parent() {
872 std::fs::create_dir_all(parent)?;
873 }
874 if to.exists() {
875 std::fs::copy(to, format!("{}.hlock-bak", to.display()))?;
876 }
877 std::fs::copy(from, to)?;
878 Ok(format!("wrote {}", to.display()))
879 }
880 Action::GitSkill { url, commit, subpath, to } => {
881 let git = ctx.which("git").ok_or_else(|| Error::Profile("git isn't on PATH".into()))?;
882 let tmp = std::env::temp_dir().join(format!("hlock-skill-{}-{}", std::process::id(), crate::hash::sha256_hex(url.as_bytes()).get(..8).unwrap_or("x")));
883 let _ = std::fs::remove_dir_all(&tmp);
884 let t = Duration::from_secs(600);
885 let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::<Vec<_>>();
886 run_capture(&git, &s(&["clone", "--quiet", url, &tmp.to_string_lossy()]), ctx, t).ok_or_else(|| Error::Profile(format!("git clone {url} failed")))?;
887 run_capture(&git, &s(&["-C", &tmp.to_string_lossy(), "checkout", "--quiet", commit]), ctx, t).ok_or_else(|| Error::Profile(format!("git checkout {commit} failed")))?;
888 let src = tmp.join(subpath);
889 if !src.join("SKILL.md").is_file() {
890 return Err(Error::Profile(format!("{url}@{commit} has no skill at {subpath}")));
891 }
892 backup(to)?;
893 copy_tree(&src, to)?;
894 let _ = std::fs::remove_dir_all(&tmp);
895 Ok(format!("fetched into {}", to.display()))
896 }
897 Action::Run { argv, env } => {
898 let mut cmd = std::process::Command::new(&argv[0]);
899 cmd.args(&argv[1..]).current_dir(&ctx.cwd).envs(env).stdin(std::process::Stdio::null());
900 let out = cmd.output().map_err(|e| Error::Profile(format!("cannot run {}: {e}", argv[0])))?;
901 let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
902 if out.status.success() {
903 Ok(format!("ran {}", shell_join(argv)))
904 } else {
905 let tail: Vec<&str> = text.lines().rev().take(8).collect::<Vec<_>>().into_iter().rev().collect();
906 Err(Error::Profile(format!("`{}` exited with {}:\n{}", shell_join(argv), out.status, tail.join("\n"))))
907 }
908 }
909 Action::Remove { path } => {
910 let bak = backup(path)?;
911 Ok(format!("moved {} aside to {}", path.display(), bak.map(|b| b.display().to_string()).unwrap_or_default()))
912 }
913 }
914}
915
916pub fn rehydrate_skill(plan: &Plan, lock_name: &str) -> String {
922 let mut s = String::new();
923 s.push_str(&format!(
924 "---\nname: hlock-rehydrate\ndescription: Rehydrate the {} setup pinned in {lock_name} on this machine. Use when the user asks to set up, restore, or sync their agent harness from {lock_name}.\n---\n\n",
925 plan.harness_name
926 ));
927 s.push_str(&format!("# Rehydrate {} from `{lock_name}`\n\n", plan.harness_name));
928 s.push_str(&format!(
929 "This skill was generated by `hlock sync --skill` from `{lock_name}`. It lists what is still missing to reproduce the pinned {} {} setup, in order.\n\n",
930 plan.harness_name,
931 plan.version.as_deref().unwrap_or("")
932 ));
933 s.push_str("## Rules\n\n- Ask the user before installing system software or downloading large model weights, and say how big the download is.\n- Never print, log, or commit secret values. Ask the user to set secrets in their shell; don't type them yourself.\n- Run each step's check before moving on. If a step fails, stop and report the error.\n- Finish by running `hlock sync`, which performs the remaining automatic steps, then `hlock check`.\n\n");
934 let pending: Vec<&Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && !st.optional).collect();
935 if pending.is_empty() {
936 s.push_str("## Steps\n\nNothing is missing. Run `hlock check` to confirm.\n");
937 return s;
938 }
939 s.push_str("## Steps\n\n");
940 for (i, st) in pending.iter().enumerate() {
941 s.push_str(&format!("{}. **{}**", i + 1, st.title));
942 if st.status == "auto" {
943 s.push_str(" (`hlock sync` does this)");
944 }
945 s.push('\n');
946 if !st.reason.is_empty() {
947 s.push_str(&format!(" - Why: {}\n", st.reason));
948 }
949 if let Some(c) = &st.command {
950 s.push_str(&format!(" - Run: `{c}`\n"));
951 }
952 if let Some(v) = &st.verify {
953 s.push_str(&format!(" - Check: `{v}`\n"));
954 }
955 }
956 let optional: Vec<&Step> = plan.steps.iter().filter(|st| st.status != "satisfied" && st.optional).collect();
957 if !optional.is_empty() {
958 s.push_str("\n## Optional\n\n");
959 for st in optional {
960 s.push_str(&format!("- {}{}\n", st.title, st.command.as_ref().map(|c| format!(": `{c}`")).unwrap_or_default()));
961 }
962 }
963 s.push_str("\n## Finish\n\n1. Run `hlock sync` and confirm it reports no remaining steps.\n2. Run `hlock check`.\n");
964 s
965}
966
967pub fn dockerfile(plan: &Plan, lock: &Lockfile, profile: &HarnessProfile) -> (String, String) {
971 let mut d = String::new();
972 d.push_str(&format!("# Generated by `hlock sync --docker` for {} {}. Rebuild after re-pinning.\n", plan.harness_name, plan.version.as_deref().unwrap_or("")));
973 d.push_str("FROM node:22-bookworm\n");
974 d.push_str("RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git python3 && rm -rf /var/lib/apt/lists/*\n");
975 d.push_str("RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh\n");
976 let version = lock.harness.version.as_deref();
977 let (method, package) = lock.harness.source.as_deref().map(parse_source).unwrap_or_default();
978 let script = profile.install.iter().find(|m| m.method == "script").and_then(|m| m.command.clone());
980 let install = install_command(&method, package.as_deref(), version, script.as_deref())
981 .filter(|a| a[0] != "brew")
982 .or_else(|| profile.install.iter().find_map(|m| install_command(&m.method, m.package.as_deref(), version, m.command.as_deref()).filter(|a| a[0] != "brew")));
983 match install {
984 Some(a) if a[0] == "sh" => d.push_str(&format!("RUN {}\n", a[2])),
985 Some(a) => d.push_str(&format!("RUN {}\n", a.iter().map(|x| if x == "python3" { "uv".to_string() } else { x.clone() }).collect::<Vec<_>>().join(" ").replace("uv -m pip install", "uv pip install --system"))),
986 None => d.push_str(&format!("# TODO: install {} here; hlock has no scriptable Linux install for it\n", plan.harness_name)),
987 }
988 for st in plan.steps.iter().filter(|s| s.kind == "plugin" && s.status == "auto") {
989 if let Some(Action::Run { argv, .. }) = &st.action {
990 let cmd = if argv[0] == "sh" { argv[2].clone() } else { shell_join(argv) };
991 d.push_str(&format!("RUN {cmd} || echo 'warning: {} failed'\n", st.id.replace('\'', "")));
992 }
993 }
994 for st in plan.steps.iter().filter(|s| s.kind == "package") {
995 if let Some(c) = &st.command {
996 d.push_str(&format!("RUN {}\n", c.replace("uv pip install", "uv pip install --system").replace("python3 -m pip install", "uv pip install --system")));
997 }
998 }
999 d.push_str("COPY home/ /root/\n");
1000 d.push_str("COPY hlock-entrypoint /usr/local/bin/hlock-entrypoint\n");
1001 for rt in lock.runtime.iter().filter(|r| r.location == "local") {
1002 if let Some((_, Some(port))) = rt.endpoint.as_deref().and_then(host_port) {
1003 d.push_str(&format!("# {} runs on the host: point the harness at http://host.docker.internal:{port}\n", rt.name));
1004 }
1005 }
1006 let secrets: Vec<&str> = plan.steps.iter().filter(|s| s.kind == "secret").map(|s| s.id.trim_start_matches("secret:")).collect();
1007 if !secrets.is_empty() {
1008 d.push_str(&format!("# Secrets (pass with -e): {}\n", secrets.join(" ")));
1009 }
1010 d.push_str("WORKDIR /workspace\nENTRYPOINT [\"/usr/local/bin/hlock-entrypoint\"]\n");
1011 let bin = profile.binaries.first().cloned().unwrap_or_else(|| "bash".into());
1012 d.push_str(&format!("CMD [\"{bin}\"]\n"));
1013
1014 let entry = r#"#!/bin/sh
1015# Fill ${HLOCK_SECRET_*} placeholders in the harness config from the environment, then run.
1016python3 - <<'PY'
1017import os, pathlib, re
1018pat = re.compile(r"\$\{(HLOCK_SECRET_[A-Z0-9_]+)\}")
1019for p in pathlib.Path.home().rglob("*"):
1020 if p.is_file() and p.suffix in {".json", ".jsonc", ".toml", ".yaml", ".yml"} and p.stat().st_size < 5_000_000:
1021 try:
1022 text = p.read_text()
1023 except Exception:
1024 continue
1025 new = pat.sub(lambda m: os.environ.get(m.group(1), m.group(0)), text)
1026 if new != text:
1027 p.write_text(new)
1028PY
1029exec "$@"
1030"#
1031 .to_string();
1032 (d, entry)
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use crate::capture::parse_config;
1039
1040 #[test]
1041 fn placeholder_fill_keeps_existing_secrets() {
1042 let desired = json!({"a": {"apiKey": "${HLOCK_SECRET_A}"}, "b": "Bearer ${HLOCK_SECRET_B}", "c": "${HLOCK_SECRET_C}"});
1043 let actual = json!({"a": {"apiKey": "real-key"}});
1044 let env: BTreeMap<String, String> = [("HLOCK_SECRET_B".to_string(), "tok".to_string())].into();
1045 let mut missing = BTreeSet::new();
1046 let v = fill(&desired, Some(&actual), &env, &mut missing);
1047 assert_eq!(v, json!({"a": {"apiKey": "real-key"}, "b": "Bearer tok", "c": "${HLOCK_SECRET_C}"}));
1048 assert_eq!(missing.into_iter().collect::<Vec<_>>(), ["HLOCK_SECRET_C"]);
1049 }
1050
1051 #[test]
1052 fn pointers_and_sources() {
1053 let mut v = json!({"keep": 1});
1054 set_pointer(&mut v, "/mcpServers", json!({"gh": {}}));
1055 set_pointer(&mut v, "/a/b~1c", json!(2));
1056 assert_eq!(v, json!({"keep": 1, "mcpServers": {"gh": {}}, "a": {"b/c": 2}}));
1057 assert_eq!(parse_source("npm:@scope/pkg@1.2.3"), ("npm".into(), Some("@scope/pkg".into())));
1058 assert_eq!(parse_source("brew-cask:claude-code@2.1.285"), ("brew-cask".into(), Some("claude-code".into())));
1059 assert_eq!(parse_source("pypi:vllm==0.11.2"), ("pip".into(), Some("vllm".into())));
1060 assert_eq!(parse_source("script@0.20.0"), ("script".into(), None));
1061 assert_eq!(
1062 install_command("npm", Some("@openai/codex"), Some("0.5.0"), None).unwrap(),
1063 ["npm", "install", "-g", "@openai/codex@0.5.0"]
1064 );
1065 assert_eq!(install_command("script", None, Some("2.1"), Some("curl x | bash -s {version}")).unwrap()[2], "curl x | bash -s 2.1");
1066 assert!(install_command("app", None, None, None).is_none());
1067 }
1068
1069 #[test]
1070 fn parse_jsonc_vendored() {
1071 assert!(parse_config("{\"a\": 1,}", "jsonc").is_ok());
1072 }
1073}