1use serde::Deserialize;
46
47use crate::error::{Result, TixError};
48use crate::sync::{CommandOutcome, CommandRunner, RemoteIssue, Tool, TrackerAdapter, scrub_secret};
49
50pub struct LinearAdapter {
54 runner: Box<dyn CommandRunner>,
55 token: Option<String>,
56 team: String,
57}
58
59impl LinearAdapter {
60 pub fn new(runner: impl CommandRunner + 'static, token: Option<String>, team: String) -> Self {
61 LinearAdapter {
62 runner: Box::new(runner),
63 token,
64 team,
65 }
66 }
67
68 fn child_env(&self) -> Vec<(String, String)> {
69 match &self.token {
70 Some(token) => vec![("LINEAR_API_KEY".to_string(), token.clone())],
71 None => Vec::new(),
72 }
73 }
74
75 fn run(&self, args: &[String]) -> Result<CommandOutcome> {
76 self.runner
77 .run(Tool::Linear.binary(), args, &self.child_env())
78 }
79
80 fn external(&self, context: &str, outcome: &CommandOutcome) -> TixError {
81 let mut message = format!("{context}: {}", outcome.text().trim());
82 if let Some(secret) = &self.token {
83 message = scrub_secret(&message, secret);
84 }
85 TixError::External(message)
86 }
87
88 fn parse_identifier(&self, outcome: &CommandOutcome) -> Result<String> {
90 serde_json::from_str::<serde_json::Value>(&outcome.stdout)
91 .ok()
92 .and_then(|value| {
93 value
94 .get("identifier")
95 .and_then(serde_json::Value::as_str)
96 .map(str::to_string)
97 })
98 .ok_or_else(|| {
99 TixError::External(format!(
100 "`linear-cli issues create` did not return JSON with an `identifier` field (expected {{\"identifier\":…}}): {}",
101 scrub_secret(outcome.stdout.trim(), self.token.as_deref().unwrap_or(""))
102 ))
103 })
104 }
105
106 fn fetch_issue(&self, identifier: &str) -> Result<RemoteIssue> {
108 let args: Vec<String> = vec![
109 "issues".into(),
110 "get".into(),
111 "-o".into(),
112 "json".into(),
113 identifier.to_string(),
114 ];
115 let outcome = self.run(&args)?;
116 if !outcome.success {
117 return Err(self.external("`linear-cli issues get` failed", &outcome));
118 }
119 let detail: LinearDetailIssue = serde_json::from_str(&outcome.stdout).map_err(|e| {
120 TixError::External(format!(
121 "`linear-cli issues get` did not return a JSON object: {e}\n{}",
122 scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
123 ))
124 })?;
125 Ok(RemoteIssue::from(detail))
126 }
127}
128
129impl TrackerAdapter for LinearAdapter {
130 fn tool(&self) -> Tool {
131 Tool::Linear
132 }
133
134 fn create_issue(
135 &mut self,
136 title: &str,
137 body: &str,
138 labels: &[String],
139 _assignee: Option<&str>,
140 priority: Option<&str>,
141 status: &str,
142 ) -> Result<String> {
143 let mut args: Vec<String> = vec![
144 "issues".into(),
145 "create".into(),
146 format!("-t={}", self.team),
147 format!("-d={body}"),
148 ];
149 for label in labels {
150 args.push(format!("-l={label}"));
151 }
152 if let Some(p) = priority.and_then(linear_priority) {
153 args.push(format!("-p={p}"));
154 }
155 args.push(format!("-s={status}"));
156 args.push("-o".into());
157 args.push("json".into());
158 args.push("--".into());
160 args.push(title.to_string());
161 let outcome = self.run(&args)?;
162 if !outcome.success {
163 return Err(self.external("`linear-cli issues create` failed", &outcome));
164 }
165 self.parse_identifier(&outcome)
166 }
167
168 fn update_issue(
169 &mut self,
170 remote_ref: &str,
171 title: &str,
172 body: &str,
173 labels: &[String],
174 _assignee: Option<&str>,
175 priority: Option<&str>,
176 status: &str,
177 ) -> Result<()> {
178 let mut args: Vec<String> = vec![
179 "issues".into(),
180 "update".into(),
181 format!("-T={title}"),
182 format!("-d={body}"),
183 ];
184 for label in labels {
185 args.push(format!("-l={label}"));
186 }
187 if let Some(p) = priority.and_then(linear_priority) {
188 args.push(format!("-p={p}"));
189 }
190 args.push(format!("-s={status}"));
191 args.push("--".into());
193 args.push(remote_ref.to_string());
194 let outcome = self.run(&args)?;
195 if !outcome.success {
196 return Err(self.external("`linear-cli issues update` failed", &outcome));
197 }
198 Ok(())
199 }
200
201 fn mark_done(&mut self, _remote_ref: &str) -> Result<()> {
203 Ok(())
204 }
205
206 fn list_open(&mut self) -> Result<Vec<RemoteIssue>> {
213 let args: Vec<String> = vec![
214 "issues".into(),
215 "list".into(),
216 format!("-t={}", self.team),
217 "-o".into(),
218 "json".into(),
219 ];
220 let outcome = self.run(&args)?;
221 if !outcome.success {
222 return Err(self.external("`linear-cli issues list` failed", &outcome));
223 }
224 let listed: Vec<LinearListIssue> = serde_json::from_str(&outcome.stdout).map_err(|e| {
225 TixError::External(format!(
226 "`linear-cli issues list` did not return a JSON array: {e}\n{}",
227 scrub_secret(&outcome.stdout, self.token.as_deref().unwrap_or(""))
228 ))
229 })?;
230 let mut issues = Vec::new();
231 for item in listed {
232 if item.state_name() == "Done" {
233 continue;
234 }
235 issues.push(self.fetch_issue(&item.identifier)?);
236 }
237 Ok(issues)
238 }
239
240 fn done_via_status(&self) -> bool {
241 true
242 }
243
244 fn set_relations(
255 &mut self,
256 remote_ref: &str,
257 relations: &[super::MappedRelation],
258 ) -> Result<()> {
259 for relation in relations {
260 let super::MappedRelation::Remote { field, value } = relation else {
261 continue;
262 };
263 let args: Vec<String> = vec![
264 "relations".into(),
265 "add".into(),
266 "-r".into(),
267 field.clone(),
268 remote_ref.to_string(),
269 value.clone(),
270 ];
271 let outcome = self.run(&args)?;
272 if !outcome.success {
273 return Err(self.external("`linear-cli relations add` failed", &outcome));
274 }
275 }
276 Ok(())
277 }
278
279 fn set_projects(&mut self, _remote_ref: &str, projects: &[super::MappedProject]) -> Result<()> {
293 debug_assert!(
294 projects.is_empty(),
295 "Linear's issue carries one `project`, so no membership list can be \
296 sent: flip `PROJECT_WIRE_VERIFIED` only together with an \
297 implementation here"
298 );
299 Ok(())
300 }
301
302 fn set_epics(&mut self, _remote_ref: &str, epics: &[super::MappedEpic]) -> Result<()> {
315 debug_assert!(
316 epics.is_empty(),
317 "Linear has no epic level, so no epic reference list can be sent: flip \
318 `EPIC_WIRE_VERIFIED` only together with an implementation here"
319 );
320 Ok(())
321 }
322}
323
324#[must_use]
335fn linear_priority(priority: &str) -> Option<&'static str> {
336 match priority {
337 "low" => Some("4"),
338 "medium" => Some("3"),
339 "high" => Some("2"),
340 "" => Some("0"),
341 _ => None,
342 }
343}
344
345#[derive(Debug, Deserialize)]
348struct LinearListIssue {
349 identifier: String,
350 #[serde(default, alias = "state")]
351 state: Option<LinearStatus>,
352}
353
354impl LinearListIssue {
355 fn state_name(&self) -> &str {
357 self.state.as_ref().map(LinearStatus::name).unwrap_or("")
358 }
359}
360
361#[derive(Debug, Deserialize)]
366struct LinearDetailIssue {
367 identifier: String,
368 #[serde(default)]
369 title: String,
370 #[serde(default)]
371 description: Option<String>,
372 #[serde(default, alias = "state")]
373 state: Option<LinearStatus>,
374 #[serde(default, alias = "updatedAt")]
375 updated_at: Option<String>,
376 #[serde(default)]
377 assignee: Option<LinearAssignee>,
378 #[serde(default)]
379 labels: Option<LinearLabels>,
380 #[serde(default)]
382 parent: Option<serde_json::Value>,
383 #[serde(default, alias = "projects")]
385 project: Option<serde_json::Value>,
386}
387
388#[derive(Debug, Deserialize)]
391struct LinearAssignee {
392 #[serde(default)]
393 name: Option<String>,
394}
395
396#[derive(Debug, Deserialize)]
398struct LinearLabels {
399 #[serde(default)]
400 nodes: Vec<LinearLabelNode>,
401}
402
403#[derive(Debug, Deserialize)]
404struct LinearLabelNode {
405 #[serde(default)]
406 name: String,
407}
408
409#[derive(Debug, Deserialize)]
411#[serde(untagged)]
412enum LinearStatus {
413 Name(String),
414 Named { name: String },
415}
416
417impl LinearStatus {
418 fn name(&self) -> &str {
419 match self {
420 LinearStatus::Name(name) | LinearStatus::Named { name } => name,
421 }
422 }
423}
424
425impl From<LinearDetailIssue> for RemoteIssue {
426 fn from(issue: LinearDetailIssue) -> Self {
427 RemoteIssue {
428 id: issue.identifier,
429 title: issue.title,
430 description: issue.description.unwrap_or_default(),
431 labels: issue
432 .labels
433 .map(|labels| labels.nodes.into_iter().map(|n| n.name).collect())
434 .unwrap_or_default(),
435 state: issue
436 .state
437 .as_ref()
438 .map(LinearStatus::name)
439 .unwrap_or("")
440 .to_string(),
441 updated_at: issue.updated_at,
442 assignee: issue.assignee.and_then(|a| a.name),
443 parent: linear_parent_id(issue.parent.as_ref()),
449 projects: crate::sync::remote_projects(issue.project.as_ref()),
450 epics: Vec::new(),
453 }
454 }
455}
456
457fn linear_parent_id(value: Option<&serde_json::Value>) -> Option<String> {
464 fn one(value: &serde_json::Value) -> Option<String> {
465 match value {
466 serde_json::Value::String(s) => Some(s.clone()),
467 serde_json::Value::Object(map) => map
468 .get("id")
469 .or_else(|| map.get("identifier"))
470 .and_then(|v| v.as_str())
471 .map(str::to_string),
472 _ => None,
473 }
474 }
475 match value? {
476 serde_json::Value::Array(items) => items.first().and_then(one),
477 other => one(other),
478 }
479}
480
481impl CommandOutcome {
483 #[must_use]
484 pub fn linear_created(identifier: &str) -> CommandOutcome {
485 CommandOutcome {
486 success: true,
487 status: Some(0),
488 stdout: format!(
489 "{{\"identifier\": \"{identifier}\", \"url\": \"https://linear.app/acme/issue/{identifier}\"}}"
490 ),
491 stderr: String::new(),
492 }
493 }
494
495 #[must_use]
496 pub fn linear_list(payload: &str) -> CommandOutcome {
497 CommandOutcome {
498 success: true,
499 status: Some(0),
500 stdout: payload.to_string(),
501 stderr: String::new(),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::sync::MappedRelation;
510 use crate::sync::glab::{FakeRunner, RecordedCall};
511
512 fn adapter_with(runner: FakeRunner, token: Option<&str>) -> LinearAdapter {
513 LinearAdapter::new(runner, token.map(String::from), "Eng".into())
514 }
515
516 fn recorded(adapter: &LinearAdapter) -> Vec<RecordedCall> {
517 adapter
518 .runner
519 .as_any()
520 .downcast_ref::<FakeRunner>()
521 .expect("adapter must hold a FakeRunner")
522 .take_calls()
523 }
524
525 #[test]
526 fn create_attaches_values_and_puts_the_positional_title_behind_double_dash() {
527 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
528 let mut adapter = adapter_with(runner, Some("lin-token"));
529 let id = adapter
530 .create_issue("Fix", "body", &["bug".into()], None, Some("high"), "Done")
531 .unwrap();
532 assert_eq!(id, "TIX-7");
533 let (argv, env) = &recorded(&adapter)[0];
534 assert_eq!(
535 argv.join(" "),
536 "linear-cli issues create -t=Eng -d=body -l=bug -p=2 -s=Done -o json -- Fix"
537 );
538 assert_eq!(
539 env,
540 &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
541 );
542 }
543
544 #[test]
548 fn create_survives_a_hyphen_leading_body_and_title() {
549 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-7")]);
550 let mut adapter = adapter_with(runner, None);
551 adapter
552 .create_issue(
553 "-leading title",
554 "- bullet one\n- bullet two",
555 &["bug".into()],
556 None,
557 None,
558 "Todo",
559 )
560 .unwrap();
561 let argv = &recorded(&adapter)[0].0;
562 assert_eq!(
563 argv.join(" "),
564 "linear-cli issues create -t=Eng -d=- bullet one\n- bullet two -l=bug -s=Todo -o json -- -leading title"
565 );
566 assert_eq!(
567 argv.last().unwrap(),
568 "-leading title",
569 "the title is the positional"
570 );
571 }
572
573 #[test]
574 fn update_survives_a_hyphen_leading_title_and_body() {
575 let runner = FakeRunner::new(vec![]);
576 let mut adapter = adapter_with(runner, None);
577 adapter
578 .update_issue("TIX-7", "-tricky title", "- body", &[], None, None, "Done")
579 .unwrap();
580 let argv = &recorded(&adapter)[0].0;
581 assert_eq!(
582 argv.join(" "),
583 "linear-cli issues update -T=-tricky title -d=- body -s=Done -- TIX-7"
584 );
585 }
586
587 #[test]
590 fn the_token_is_injected_under_the_name_the_tool_reads() {
591 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-1")]);
592 let mut adapter = adapter_with(runner, Some("lin-token"));
593 adapter
594 .create_issue("T", "b", &[], None, None, "Todo")
595 .unwrap();
596 assert_eq!(
597 &recorded(&adapter)[0].1,
598 &[("LINEAR_API_KEY".to_string(), "lin-token".to_string())]
599 );
600 }
601
602 #[test]
603 fn create_without_labels_omits_label_flag() {
604 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-8")]);
605 let mut adapter = adapter_with(runner, None);
606 adapter
607 .create_issue("T", "b", &[], None, None, "todo")
608 .unwrap();
609 let joined = recorded(&adapter)[0].0.join(" ");
610 assert!(!joined.contains("-l="), "{joined}");
611 }
612
613 #[test]
614 fn priority_maps_to_linear_numbers_and_unknown_is_omitted() {
615 assert_eq!(linear_priority("low"), Some("4"));
616 assert_eq!(linear_priority("medium"), Some("3"));
617 assert_eq!(linear_priority("high"), Some("2"));
618 assert_eq!(linear_priority(""), Some("0"), "the empty string is `none`");
619 for unknown in ["urgent", "none", "MEDIUM", "custom-word"] {
620 assert_eq!(
621 linear_priority(unknown),
622 None,
623 "{unknown} must not be coerced to a number"
624 );
625 }
626 }
627
628 #[test]
629 fn an_unmappable_priority_omits_the_priority_flag() {
630 let runner = FakeRunner::new(vec![CommandOutcome::linear_created("TIX-9")]);
631 let mut adapter = adapter_with(runner, None);
632 adapter
633 .create_issue("T", "b", &[], None, Some("urgent"), "Todo")
634 .unwrap();
635 let joined = recorded(&adapter)[0].0.join(" ");
636 assert!(
637 !joined.contains("-p="),
638 "unknown priority must be omitted: {joined}"
639 );
640 }
641
642 #[test]
643 fn update_passes_identifier_title_body_labels_priority_and_state() {
644 let runner = FakeRunner::new(vec![]);
645 let mut adapter = adapter_with(runner, None);
646 adapter
647 .update_issue(
648 "TIX-7",
649 "New title",
650 "new body",
651 &["bug".into()],
652 None,
653 Some("low"),
654 "Done",
655 )
656 .unwrap();
657 let joined = recorded(&adapter)[0].0.join(" ");
658 assert_eq!(
659 joined,
660 "linear-cli issues update -T=New title -d=new body -l=bug -p=4 -s=Done -- TIX-7"
661 );
662 }
663
664 #[test]
665 fn done_via_status_is_true_and_mark_done_is_a_noop() {
666 let runner = FakeRunner::new(vec![]);
667 let mut adapter = adapter_with(runner, None);
668 assert!(adapter.done_via_status());
669 adapter.mark_done("TIX-7").unwrap();
670 assert!(recorded(&adapter).is_empty(), "no close call for linear");
671 }
672
673 #[test]
674 fn set_relations_adds_one_edge_per_relation_with_remote_identifiers() {
675 let runner = FakeRunner::new(vec![
676 CommandOutcome::linear_list(""),
677 CommandOutcome::linear_list(""),
678 ]);
679 let mut adapter = adapter_with(runner, None);
680 adapter
681 .set_relations(
682 "TIX-1",
683 &[
684 MappedRelation::Remote {
685 field: "blocks".into(),
686 value: "TIX-2".into(),
687 },
688 MappedRelation::Remote {
689 field: "blocks".into(),
690 value: "TIX-3".into(),
691 },
692 ],
693 )
694 .unwrap();
695 let calls = recorded(&adapter);
696 assert_eq!(
697 calls[0].0.join(" "),
698 "linear-cli relations add -r blocks TIX-1 TIX-2"
699 );
700 assert_eq!(
701 calls[1].0.join(" "),
702 "linear-cli relations add -r blocks TIX-1 TIX-3"
703 );
704 }
705
706 #[test]
710 fn list_open_enumerates_then_fetches_each_open_issues_detail() {
711 let list = r#"[
712 {"id":"u1","identifier":"TIX-1","title":"T1","priority":3,"state":{"name":"In Progress"}},
713 {"id":"u2","identifier":"TIX-2","title":"T2","state":{"name":"Done"}}
714 ]"#;
715 let detail = r##"{"id":"u1","identifier":"TIX-1","title":"T1","description":"full body",
716 "updatedAt":"2026-05-11T10:00:00Z","state":{"name":"In Progress"},
717 "assignee":{"name":"Al","email":"al@example.com"},
718 "labels":{"nodes":[{"name":"bug","color":"#f00"},{"name":"api","color":"#0f0"}]}}"##;
719 let runner = FakeRunner::new(vec![
722 CommandOutcome::linear_list(detail),
723 CommandOutcome::linear_list(list),
724 ]);
725 let mut adapter = adapter_with(runner, None);
726 let issues = adapter.list_open().unwrap();
727 assert_eq!(issues.len(), 1, "the Done issue is filtered before the get");
728 assert_eq!(issues[0].id, "TIX-1");
729 assert_eq!(issues[0].title, "T1");
730 assert_eq!(issues[0].description, "full body");
731 assert_eq!(issues[0].labels, ["bug", "api"]);
732 assert_eq!(issues[0].state, "In Progress");
733 assert_eq!(issues[0].assignee.as_deref(), Some("Al"));
734 assert_eq!(
735 issues[0].updated_at.as_deref(),
736 Some("2026-05-11T10:00:00Z")
737 );
738 let calls = recorded(&adapter);
739 assert_eq!(
740 calls.len(),
741 2,
742 "one list call and one get — never a get for the Done issue"
743 );
744 assert_eq!(
745 calls[0].0.join(" "),
746 "linear-cli issues list -t=Eng -o json"
747 );
748 assert_eq!(calls[1].0.join(" "), "linear-cli issues get -o json TIX-1");
749 }
750
751 #[test]
754 fn a_list_payload_without_detail_fields_does_not_fail_the_import() {
755 let list = r#"[{"id":"u1","identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
756 let detail = r#"{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}"#;
757 let runner = FakeRunner::new(vec![
758 CommandOutcome::linear_list(detail),
759 CommandOutcome::linear_list(list),
760 ]);
761 let mut adapter = adapter_with(runner, None);
762 let issues = adapter.list_open().unwrap();
763 assert_eq!(issues.len(), 1);
764 assert_eq!(issues[0].description, "");
765 assert_eq!(issues[0].labels, [] as [std::string::String; 0]);
766 assert_eq!(issues[0].updated_at, None);
767 }
768
769 #[test]
770 fn create_json_without_identifier_is_a_parse_error() {
771 let runner = FakeRunner::new(vec![CommandOutcome::linear_list("{}")]);
772 let mut adapter = adapter_with(runner, None);
773 let err = adapter
774 .create_issue("T", "b", &[], None, None, "todo")
775 .unwrap_err();
776 assert!(matches!(err, TixError::External(_)));
777 assert!(err.to_string().contains("identifier"));
778 assert!(
779 err.to_string().contains("linear-cli issues create"),
780 "the error must name the real subcommand: {err}"
781 );
782 }
783
784 #[test]
785 fn a_detail_without_the_identifier_is_a_parse_error() {
786 let list = r#"[{"identifier":"TIX-1","title":"T1","state":{"name":"Todo"}}]"#;
787 let runner = FakeRunner::new(vec![
788 CommandOutcome::linear_list("{}"),
789 CommandOutcome::linear_list(list),
790 ]);
791 let mut adapter = adapter_with(runner, None);
792 let err = adapter.list_open().unwrap_err();
793 assert!(matches!(err, TixError::External(_)));
794 assert!(
795 err.to_string().contains("linear-cli issues get"),
796 "the error names the failing verb: {err}"
797 );
798 }
799
800 #[test]
801 fn failure_message_scrubs_token_from_tool_output() {
802 let runner = FakeRunner::new(vec![CommandOutcome {
803 success: false,
804 status: Some(1),
805 stdout: String::new(),
806 stderr: "LINEAR_API_KEY=lin-token leaked".into(),
807 }]);
808 let mut adapter = adapter_with(runner, Some("lin-token"));
809 let err = adapter
810 .create_issue("T", "b", &[], None, None, "todo")
811 .unwrap_err();
812 let text = err.to_string();
813 assert!(text.contains("<token>"), "expected redaction, got: {text}");
814 assert!(!text.contains("lin-token"), "secret leaked: {text}");
815 }
816
817 #[test]
821 fn t036_project_reference_is_read_from_detail_and_never_sent() {
822 let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
823 let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
824 "project":{"name":"One"}}"#;
825 let runner = FakeRunner::new(vec![
826 CommandOutcome::linear_list(detail),
827 CommandOutcome::linear_list(list),
828 ]);
829 let mut adapter = adapter_with(runner, None);
830 let issues = adapter.list_open().unwrap();
831 assert_eq!(
832 issues[0].projects,
833 vec![crate::sync::RemoteProject {
834 id: String::new(),
835 name: Some("One".into()),
836 }],
837 "the real query selects `project{{name}}`, so the id is empty and the name kept"
838 );
839
840 let calls_before = recorded(&adapter).len();
841 adapter.set_projects("TIX-1", &[]).unwrap();
842 assert_eq!(
843 recorded(&adapter).len(),
844 calls_before,
845 "an empty membership set must not spawn any command"
846 );
847 }
848
849 #[test]
853 fn t038_no_epic_reference_is_read_or_sent() {
854 let list = r#"[{"identifier":"TIX-1","title":"T","state":{"name":"Todo"}}]"#;
855 let detail = r#"{"identifier":"TIX-1","title":"T","state":{"name":"Todo"},
857 "epics":["e-1"]}"#;
858 let runner = FakeRunner::new(vec![
859 CommandOutcome::linear_list(detail),
860 CommandOutcome::linear_list(list),
861 ]);
862 let mut adapter = adapter_with(runner, None);
863 let issues = adapter.list_open().unwrap();
864 assert_eq!(issues[0].epics, [] as [crate::sync::RemoteEpic; 0]);
865
866 let calls_before = recorded(&adapter).len();
867 adapter.set_epics("TIX-1", &[]).unwrap();
868 assert_eq!(
869 recorded(&adapter).len(),
870 calls_before,
871 "an empty reference set must not spawn any command"
872 );
873 }
874}