clawdex-mobile 5.0.8 → 5.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,9 @@ use std::{
4
4
  };
5
5
 
6
6
  use crate::{
7
- normalize_path, BridgeError, GitCommitResponse, GitDiffResponse, GitPushResponse,
8
- GitStageAllResponse, GitStageResponse, GitStatusEntry, GitStatusResponse,
9
- GitUnstageAllResponse, GitUnstageResponse,
7
+ normalize_path, BridgeError, GitCloneResponse, GitCommitResponse, GitDiffResponse,
8
+ GitHistoryCommit, GitHistoryResponse, GitPushResponse, GitStageAllResponse, GitStageResponse,
9
+ GitStatusEntry, GitStatusResponse, GitUnstageAllResponse, GitUnstageResponse,
10
10
  };
11
11
 
12
12
  use super::TerminalService;
@@ -196,6 +196,96 @@ impl GitService {
196
196
  })
197
197
  }
198
198
 
199
+ pub(crate) async fn get_history(
200
+ &self,
201
+ raw_cwd: Option<&str>,
202
+ limit: Option<usize>,
203
+ ) -> Result<GitHistoryResponse, BridgeError> {
204
+ let repo_path = self.resolve_repo_path(raw_cwd)?;
205
+ let history_limit = limit.unwrap_or(12).clamp(1, 30);
206
+ let args = vec![
207
+ "-C".to_string(),
208
+ repo_path.to_string_lossy().to_string(),
209
+ "log".to_string(),
210
+ "--first-parent".to_string(),
211
+ "--decorate=short".to_string(),
212
+ "--date=iso-strict".to_string(),
213
+ format!("--max-count={history_limit}"),
214
+ "--pretty=format:%H\x1f%h\x1f%an\x1f%aI\x1f%D\x1f%s\x1e".to_string(),
215
+ "HEAD".to_string(),
216
+ ];
217
+
218
+ let result = self
219
+ .terminal
220
+ .execute_binary("git", &args, repo_path.clone(), None)
221
+ .await?;
222
+
223
+ if result.code != Some(0) {
224
+ return Err(BridgeError::server(
225
+ &(if !result.stderr.is_empty() {
226
+ result.stderr
227
+ } else if !result.stdout.is_empty() {
228
+ result.stdout
229
+ } else {
230
+ "git log failed".to_string()
231
+ }),
232
+ ));
233
+ }
234
+
235
+ Ok(GitHistoryResponse {
236
+ commits: parse_git_history(&result.stdout),
237
+ cwd: repo_path.to_string_lossy().to_string(),
238
+ })
239
+ }
240
+
241
+ pub(crate) async fn clone_repo(
242
+ &self,
243
+ repository_url: &str,
244
+ raw_parent_path: Option<&str>,
245
+ directory_name: &str,
246
+ ) -> Result<GitCloneResponse, BridgeError> {
247
+ let parent_path = self.resolve_repo_path(raw_parent_path)?;
248
+ if !parent_path.exists() {
249
+ return Err(BridgeError::invalid_params(
250
+ "destination parent path must exist",
251
+ ));
252
+ }
253
+ if !parent_path.is_dir() {
254
+ return Err(BridgeError::invalid_params(
255
+ "destination parent path must be a directory",
256
+ ));
257
+ }
258
+
259
+ let normalized_directory_name = resolve_clone_directory_name(directory_name)?;
260
+ let destination_path = normalize_path(&parent_path.join(&normalized_directory_name));
261
+ if destination_path.exists() {
262
+ return Err(BridgeError::invalid_params(
263
+ "destination path already exists",
264
+ ));
265
+ }
266
+
267
+ let args = vec![
268
+ "clone".to_string(),
269
+ "--".to_string(),
270
+ repository_url.trim().to_string(),
271
+ normalized_directory_name,
272
+ ];
273
+
274
+ let result = self
275
+ .terminal
276
+ .execute_binary("git", &args, parent_path.clone(), None)
277
+ .await?;
278
+
279
+ Ok(GitCloneResponse {
280
+ code: result.code,
281
+ stdout: result.stdout,
282
+ stderr: result.stderr,
283
+ cloned: result.code == Some(0),
284
+ cwd: destination_path.to_string_lossy().to_string(),
285
+ url: repository_url.trim().to_string(),
286
+ })
287
+ }
288
+
199
289
  pub(crate) async fn stage_file(
200
290
  &self,
201
291
  path: &str,
@@ -549,6 +639,49 @@ fn parse_status_has_upstream(raw: &str) -> bool {
549
639
  .unwrap_or(false)
550
640
  }
551
641
 
642
+ fn parse_git_history(raw: &str) -> Vec<GitHistoryCommit> {
643
+ raw.split('\x1e')
644
+ .filter_map(|record| {
645
+ let trimmed = record.trim();
646
+ if trimmed.is_empty() {
647
+ return None;
648
+ }
649
+
650
+ let mut parts = trimmed.split('\x1f');
651
+ let hash = parts.next()?.trim().to_string();
652
+ let short_hash = parts.next().unwrap_or_default().trim().to_string();
653
+ let author_name = parts.next().unwrap_or_default().trim().to_string();
654
+ let authored_at = parts.next().unwrap_or_default().trim().to_string();
655
+ let refs_raw = parts.next().unwrap_or_default().trim().to_string();
656
+ let subject = parts.next().unwrap_or_default().trim().to_string();
657
+
658
+ if hash.is_empty() || short_hash.is_empty() || subject.is_empty() {
659
+ return None;
660
+ }
661
+
662
+ let ref_names = refs_raw
663
+ .split(',')
664
+ .map(str::trim)
665
+ .filter(|entry| !entry.is_empty())
666
+ .map(str::to_string)
667
+ .collect::<Vec<_>>();
668
+ let is_head = ref_names
669
+ .iter()
670
+ .any(|entry| entry == "HEAD" || entry.starts_with("HEAD ->"));
671
+
672
+ Some(GitHistoryCommit {
673
+ hash,
674
+ short_hash,
675
+ subject,
676
+ author_name,
677
+ authored_at,
678
+ ref_names,
679
+ is_head,
680
+ })
681
+ })
682
+ .collect()
683
+ }
684
+
552
685
  fn select_default_remote_name(raw: &str) -> Option<String> {
553
686
  let remotes = raw
554
687
  .lines()
@@ -626,11 +759,47 @@ fn resolve_repo_relative_path(raw_path: &str, repo_path: &Path) -> Result<String
626
759
  Ok(relative.to_string_lossy().to_string())
627
760
  }
628
761
 
762
+ fn resolve_clone_directory_name(raw_name: &str) -> Result<String, BridgeError> {
763
+ let trimmed = raw_name.trim();
764
+ if trimmed.is_empty() {
765
+ return Err(BridgeError::invalid_params(
766
+ "directoryName must not be empty",
767
+ ));
768
+ }
769
+
770
+ let requested = PathBuf::from(trimmed);
771
+ if requested.is_absolute() {
772
+ return Err(BridgeError::invalid_params(
773
+ "directoryName must be a folder name, not a path",
774
+ ));
775
+ }
776
+
777
+ let mut components = requested.components();
778
+ let Some(component) = components.next() else {
779
+ return Err(BridgeError::invalid_params(
780
+ "directoryName must not be empty",
781
+ ));
782
+ };
783
+ if components.next().is_some() {
784
+ return Err(BridgeError::invalid_params(
785
+ "directoryName must be a single folder name",
786
+ ));
787
+ }
788
+ if !matches!(component, std::path::Component::Normal(_)) {
789
+ return Err(BridgeError::invalid_params(
790
+ "directoryName must be a valid folder name",
791
+ ));
792
+ }
793
+
794
+ Ok(trimmed.to_string())
795
+ }
796
+
629
797
  #[cfg(test)]
630
798
  mod tests {
631
799
  use super::{
632
- parse_porcelain_status_entries, parse_status_has_upstream, resolve_git_cwd,
633
- resolve_repo_relative_path, select_default_remote_name,
800
+ parse_git_history, parse_porcelain_status_entries, parse_status_has_upstream,
801
+ resolve_clone_directory_name, resolve_git_cwd, resolve_repo_relative_path,
802
+ select_default_remote_name,
634
803
  };
635
804
  use std::path::{Path, PathBuf};
636
805
 
@@ -685,6 +854,20 @@ mod tests {
685
854
  assert_eq!(error.code, -32602);
686
855
  }
687
856
 
857
+ #[test]
858
+ fn resolves_clone_directory_name_from_single_segment() {
859
+ let resolved =
860
+ resolve_clone_directory_name("my-repo").expect("resolve single directory name");
861
+ assert_eq!(resolved, "my-repo");
862
+ }
863
+
864
+ #[test]
865
+ fn rejects_nested_clone_directory_name() {
866
+ let error = resolve_clone_directory_name("nested/repo")
867
+ .expect_err("reject nested clone directory name");
868
+ assert_eq!(error.code, -32602);
869
+ }
870
+
688
871
  #[test]
689
872
  fn parses_porcelain_entries_for_rename_and_untracked() {
690
873
  let raw = "## main...origin/main\0R new/path.ts\0old/path.ts\0?? fresh/file.ts\0";
@@ -729,4 +912,27 @@ mod tests {
729
912
  );
730
913
  assert_eq!(select_default_remote_name(""), None);
731
914
  }
915
+
916
+ #[test]
917
+ fn parses_git_history_records() {
918
+ let raw = concat!(
919
+ "abc123\x1fabc123\x1fMohit\x1f2026-04-05T10:00:00+05:30\x1fHEAD -> feat/test, origin/feat/test\x1fAdd history card\x1e",
920
+ "def456\x1fdef456\x1fMohit\x1f2026-04-04T09:00:00+05:30\x1forigin/main\x1fPrevious commit\x1e"
921
+ );
922
+
923
+ let commits = parse_git_history(raw);
924
+ assert_eq!(commits.len(), 2);
925
+ assert_eq!(commits[0].hash, "abc123");
926
+ assert_eq!(commits[0].subject, "Add history card");
927
+ assert!(commits[0].is_head);
928
+ assert_eq!(
929
+ commits[0].ref_names,
930
+ vec![
931
+ "HEAD -> feat/test".to_string(),
932
+ "origin/feat/test".to_string()
933
+ ]
934
+ );
935
+ assert_eq!(commits[1].subject, "Previous commit");
936
+ assert!(!commits[1].is_head);
937
+ }
732
938
  }
@@ -36,6 +36,7 @@ pub(crate) struct BridgeRuntimeInfo {
36
36
  pub(crate) version: String,
37
37
  pub(crate) install_kind: BridgeInstallKind,
38
38
  pub(crate) self_update_supported: bool,
39
+ pub(crate) safe_restart_supported: bool,
39
40
  pub(crate) latest_version: Option<String>,
40
41
  pub(crate) updater_status: Option<BridgeUpdaterStatus>,
41
42
  }
@@ -50,6 +51,44 @@ pub(crate) struct BridgeUpdateStartResponse {
50
51
  pub(crate) log_path: Option<String>,
51
52
  }
52
53
 
54
+ #[derive(Debug, Clone, Serialize)]
55
+ #[serde(rename_all = "camelCase")]
56
+ pub(crate) struct BridgeRestartStartResponse {
57
+ pub(crate) ok: bool,
58
+ pub(crate) job_id: String,
59
+ pub(crate) message: String,
60
+ pub(crate) log_path: Option<String>,
61
+ }
62
+
63
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
64
+ enum BridgeMaintenanceAction {
65
+ Update,
66
+ Restart,
67
+ }
68
+
69
+ impl BridgeMaintenanceAction {
70
+ fn as_arg(self) -> &'static str {
71
+ match self {
72
+ Self::Update => "update",
73
+ Self::Restart => "restart",
74
+ }
75
+ }
76
+
77
+ fn job_prefix(self) -> &'static str {
78
+ match self {
79
+ Self::Update => "bridge-update",
80
+ Self::Restart => "bridge-restart",
81
+ }
82
+ }
83
+ }
84
+
85
+ #[derive(Debug, Clone)]
86
+ struct BridgeMaintenanceJobStart {
87
+ job_id: String,
88
+ target_version: String,
89
+ log_path: Option<String>,
90
+ }
91
+
53
92
  #[derive(Clone)]
54
93
  pub(crate) struct UpdateService {
55
94
  package_root: Option<PathBuf>,
@@ -57,6 +96,8 @@ pub(crate) struct UpdateService {
57
96
  status_path: Option<PathBuf>,
58
97
  log_path: Option<PathBuf>,
59
98
  script_path: Option<PathBuf>,
99
+ launcher_path: Option<PathBuf>,
100
+ secure_env_path: Option<PathBuf>,
60
101
  }
61
102
 
62
103
  impl UpdateService {
@@ -75,6 +116,10 @@ impl UpdateService {
75
116
  let script_path = package_root
76
117
  .as_ref()
77
118
  .map(|root| root.join("scripts").join("bridge-self-update.js"));
119
+ let launcher_path = package_root
120
+ .as_ref()
121
+ .map(|root| root.join("scripts").join("start-bridge-secure.js"));
122
+ let secure_env_path = package_root.as_ref().map(|root| root.join(".env.secure"));
78
123
 
79
124
  Self {
80
125
  package_root,
@@ -82,13 +127,26 @@ impl UpdateService {
82
127
  status_path,
83
128
  log_path,
84
129
  script_path,
130
+ launcher_path,
131
+ secure_env_path,
85
132
  }
86
133
  }
87
134
 
135
+ pub(crate) fn is_safe_restart_supported(&self) -> bool {
136
+ self.package_root.is_some()
137
+ && self.script_path.as_ref().is_some_and(|path| path.is_file())
138
+ && self
139
+ .launcher_path
140
+ .as_ref()
141
+ .is_some_and(|path| path.is_file())
142
+ && self
143
+ .secure_env_path
144
+ .as_ref()
145
+ .is_some_and(|path| path.is_file())
146
+ }
147
+
88
148
  pub(crate) fn is_self_update_supported(&self) -> bool {
89
- self.install_kind == BridgeInstallKind::PublishedCli
90
- && self.package_root.is_some()
91
- && self.script_path.as_ref().is_some_and(|path| path.exists())
149
+ self.install_kind == BridgeInstallKind::PublishedCli && self.is_safe_restart_supported()
92
150
  }
93
151
 
94
152
  pub(crate) async fn runtime_info(&self) -> BridgeRuntimeInfo {
@@ -96,6 +154,7 @@ impl UpdateService {
96
154
  version: env!("CARGO_PKG_VERSION").to_string(),
97
155
  install_kind: self.install_kind,
98
156
  self_update_supported: self.is_self_update_supported(),
157
+ safe_restart_supported: self.is_safe_restart_supported(),
99
158
  latest_version: fetch_latest_npm_version().await,
100
159
  updater_status: self.read_status(),
101
160
  }
@@ -107,11 +166,68 @@ impl UpdateService {
107
166
  bridge_pid: u32,
108
167
  now_iso: &str,
109
168
  ) -> Result<BridgeUpdateStartResponse, String> {
110
- if !self.is_self_update_supported() {
111
- return Err(
112
- "Bridge self-update is only supported for published clawdex-mobile CLI installs."
169
+ let job = self.start_job(
170
+ BridgeMaintenanceAction::Update,
171
+ version,
172
+ bridge_pid,
173
+ now_iso,
174
+ )?;
175
+
176
+ Ok(BridgeUpdateStartResponse {
177
+ ok: true,
178
+ job_id: job.job_id,
179
+ target_version: job.target_version.clone(),
180
+ message: format!(
181
+ "Bridge update scheduled for {}. The bridge will disconnect briefly and should restart automatically.",
182
+ job.target_version
183
+ ),
184
+ log_path: job.log_path,
185
+ })
186
+ }
187
+
188
+ pub(crate) fn start_restart(
189
+ &self,
190
+ bridge_pid: u32,
191
+ now_iso: &str,
192
+ ) -> Result<BridgeRestartStartResponse, String> {
193
+ let job = self.start_job(
194
+ BridgeMaintenanceAction::Restart,
195
+ env!("CARGO_PKG_VERSION"),
196
+ bridge_pid,
197
+ now_iso,
198
+ )?;
199
+
200
+ Ok(BridgeRestartStartResponse {
201
+ ok: true,
202
+ job_id: job.job_id,
203
+ message:
204
+ "Bridge restart scheduled. The bridge will disconnect briefly and should restart automatically."
113
205
  .to_string(),
114
- );
206
+ log_path: job.log_path,
207
+ })
208
+ }
209
+
210
+ fn start_job(
211
+ &self,
212
+ action: BridgeMaintenanceAction,
213
+ version: &str,
214
+ bridge_pid: u32,
215
+ now_iso: &str,
216
+ ) -> Result<BridgeMaintenanceJobStart, String> {
217
+ match action {
218
+ BridgeMaintenanceAction::Update if !self.is_self_update_supported() => {
219
+ return Err(
220
+ "Bridge self-update is only supported for published clawdex-mobile CLI installs."
221
+ .to_string(),
222
+ );
223
+ }
224
+ BridgeMaintenanceAction::Restart if !self.is_safe_restart_supported() => {
225
+ return Err(
226
+ "Bridge safe restart requires a detected clawdex-mobile install with .env.secure and launcher scripts available."
227
+ .to_string(),
228
+ );
229
+ }
230
+ _ => {}
115
231
  }
116
232
 
117
233
  let package_root = self
@@ -132,7 +248,7 @@ impl UpdateService {
132
248
  .ok_or_else(|| "bridge updater log path is missing".to_string())?;
133
249
 
134
250
  let target_version = normalize_target_version(version)?;
135
- let job_id = create_job_id();
251
+ let job_id = create_job_id(action.job_prefix());
136
252
 
137
253
  let log_file = OpenOptions::new()
138
254
  .create(true)
@@ -146,6 +262,8 @@ impl UpdateService {
146
262
  let mut command = std::process::Command::new(node_command());
147
263
  command
148
264
  .arg(script_path)
265
+ .arg("--action")
266
+ .arg(action.as_arg())
149
267
  .arg("--job-id")
150
268
  .arg(&job_id)
151
269
  .arg("--bridge-pid")
@@ -170,13 +288,9 @@ impl UpdateService {
170
288
  .map_err(|error| format!("failed to spawn updater: {error}"))?;
171
289
  let _ = child.id();
172
290
 
173
- Ok(BridgeUpdateStartResponse {
174
- ok: true,
291
+ Ok(BridgeMaintenanceJobStart {
175
292
  job_id,
176
- target_version: target_version.clone(),
177
- message: format!(
178
- "Bridge update scheduled for {target_version}. The bridge will disconnect briefly and should restart automatically."
179
- ),
293
+ target_version,
180
294
  log_path: Some(log_path.to_string_lossy().to_string()),
181
295
  })
182
296
  }
@@ -273,9 +387,9 @@ fn normalize_target_version(value: &str) -> Result<String, String> {
273
387
  Err("version must be 'latest' or a simple npm package version".to_string())
274
388
  }
275
389
 
276
- fn create_job_id() -> String {
390
+ fn create_job_id(prefix: &str) -> String {
277
391
  format!(
278
- "bridge-update-{}-{}",
392
+ "{prefix}-{}-{}",
279
393
  chrono::Utc::now().timestamp_millis(),
280
394
  std::process::id()
281
395
  )