@ebowwa/pkg-ops 0.1.18 → 0.1.20

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.
package/rust/src/lib.rs CHANGED
@@ -94,6 +94,52 @@ pub struct InstalledPackageInfo {
94
94
  pub version: String,
95
95
  pub dist_size_bytes: Option<u64>,
96
96
  pub installed_at: Option<String>, // ISO timestamp
97
+ pub total_versions: Option<u32>,
98
+ pub versions: Option<Vec<VersionInfo>>,
99
+ }
100
+
101
+ /// Version information for multi-version support
102
+ #[derive(Debug, Clone, Serialize, Deserialize)]
103
+ #[serde(rename_all = "camelCase")]
104
+ pub struct VersionInfo {
105
+ pub version: String,
106
+ pub installed_at: String,
107
+ pub dist_size_bytes: Option<u64>,
108
+ pub file_count: Option<u32>,
109
+ pub active: bool,
110
+ }
111
+
112
+ /// Result of switching versions
113
+ #[derive(Debug, Clone, Serialize, Deserialize)]
114
+ #[serde(rename_all = "camelCase")]
115
+ pub struct SwitchResult {
116
+ pub success: bool,
117
+ pub package_name: String,
118
+ pub from_version: String,
119
+ pub to_version: String,
120
+ pub message: String,
121
+ }
122
+
123
+ /// Result of pruning old versions
124
+ #[derive(Debug, Clone, Serialize, Deserialize)]
125
+ #[serde(rename_all = "camelCase")]
126
+ pub struct PruneResult {
127
+ pub success: bool,
128
+ pub package_name: String,
129
+ pub removed_versions: Vec<String>,
130
+ pub kept_versions: Vec<String>,
131
+ pub freed_bytes: u64,
132
+ pub message: String,
133
+ }
134
+
135
+ /// Multi-version package summary
136
+ #[derive(Debug, Clone, Serialize, Deserialize)]
137
+ #[serde(rename_all = "camelCase")]
138
+ pub struct MultiVersionPackage {
139
+ pub package_name: String,
140
+ pub active_version: String,
141
+ pub total_versions: u32,
142
+ pub versions: Vec<String>,
97
143
  }
98
144
 
99
145
  // ---------------------------------------------------------------------------
@@ -797,3 +843,395 @@ pub fn get_installed_info() -> Vec<InstalledPackageInfo> {
797
843
 
798
844
  info_list
799
845
  }
846
+
847
+ // ---------------------------------------------------------------------------
848
+ // Multi-Version Operations
849
+ // ---------------------------------------------------------------------------
850
+
851
+ /// Directory for storing multiple package versions
852
+ const VERSIONS_DIR: &str = "/opt/pkg-ops/versions";
853
+
854
+ /// List all installed versions of a package
855
+ pub fn list_versions(package_name: &str) -> Vec<VersionInfo> {
856
+ info!("Listing versions for {}", package_name);
857
+
858
+ let mut versions = Vec::new();
859
+
860
+ // Check the multi-version directory
861
+ let package_versions_dir = Path::new(VERSIONS_DIR)
862
+ .join(package_name.replace("@", "").replace("/", "-"));
863
+
864
+ if package_versions_dir.exists() {
865
+ if let Ok(entries) = fs::read_dir(&package_versions_dir) {
866
+ for entry in entries.flatten() {
867
+ let version_dir = entry.path();
868
+ if !version_dir.is_dir() {
869
+ continue;
870
+ }
871
+
872
+ let version = entry.file_name().to_string_lossy().to_string();
873
+ let dist_path = version_dir.join("dist");
874
+
875
+ // Get installed timestamp from directory mtime
876
+ let installed_at = fs::metadata(&version_dir)
877
+ .ok()
878
+ .and_then(|m| m.modified().ok())
879
+ .map(|t| {
880
+ let datetime: chrono::DateTime<chrono::Utc> = t.into();
881
+ datetime.to_rfc3339()
882
+ })
883
+ .unwrap_or_else(|| "unknown".to_string());
884
+
885
+ let (dist_size, file_count) = if dist_path.exists() {
886
+ let (size, count) = calculate_dir_size(&dist_path);
887
+ (Some(size), Some(count))
888
+ } else {
889
+ (None, None)
890
+ };
891
+
892
+ versions.push(VersionInfo {
893
+ version,
894
+ installed_at,
895
+ dist_size_bytes: dist_size,
896
+ file_count,
897
+ active: false, // Will be set below
898
+ });
899
+ }
900
+ }
901
+ }
902
+
903
+ // Also check the active version in node_modules
904
+ let active_version = get_installed_version(package_name);
905
+ if let Some(ref active) = active_version {
906
+ // Mark active version
907
+ for v in &mut versions {
908
+ if &v.version == active {
909
+ v.active = true;
910
+ }
911
+ }
912
+
913
+ // If active version not in versions list, add it
914
+ if !versions.iter().any(|v| &v.version == active) {
915
+ let package_path = Path::new(WORK_DIR)
916
+ .join("node_modules")
917
+ .join(package_name);
918
+
919
+ let dist_path = package_path.join("dist");
920
+ let (dist_size, file_count) = if dist_path.exists() {
921
+ let (size, count) = calculate_dir_size(&dist_path);
922
+ (Some(size), Some(count))
923
+ } else {
924
+ (None, None)
925
+ };
926
+
927
+ let installed_at = package_path.join("package.json")
928
+ .exists()
929
+ .then(|| {
930
+ fs::metadata(package_path.join("package.json"))
931
+ .ok()
932
+ .and_then(|m| m.modified().ok())
933
+ .map(|t| {
934
+ let datetime: chrono::DateTime<chrono::Utc> = t.into();
935
+ datetime.to_rfc3339()
936
+ })
937
+ })
938
+ .flatten()
939
+ .unwrap_or_else(|| "unknown".to_string());
940
+
941
+ versions.push(VersionInfo {
942
+ version: active.clone(),
943
+ installed_at,
944
+ dist_size_bytes: dist_size,
945
+ file_count,
946
+ active: true,
947
+ });
948
+ }
949
+ }
950
+
951
+ // Sort by version descending (newest first)
952
+ versions.sort_by(|a, b| {
953
+ // Simple semver comparison
954
+ let a_parts: Vec<u32> = a.version
955
+ .split('.')
956
+ .filter_map(|s| s.parse().ok())
957
+ .collect();
958
+ let b_parts: Vec<u32> = b.version
959
+ .split('.')
960
+ .filter_map(|s| s.parse().ok())
961
+ .collect();
962
+
963
+ for i in 0..std::cmp::min(a_parts.len(), b_parts.len()) {
964
+ match b_parts.get(i).cmp(&a_parts.get(i)) {
965
+ std::cmp::Ordering::Equal => continue,
966
+ other => return other,
967
+ }
968
+ }
969
+ b_parts.len().cmp(&a_parts.len())
970
+ });
971
+
972
+ versions
973
+ }
974
+
975
+ /// Switch to a specific installed version
976
+ pub fn switch_version(package_name: &str, target_version: &str) -> SwitchResult {
977
+ info!("Switching {} to version {}", package_name, target_version);
978
+
979
+ let current_version = match get_installed_version(package_name) {
980
+ Some(v) => v,
981
+ None => {
982
+ return SwitchResult {
983
+ success: false,
984
+ package_name: package_name.to_string(),
985
+ from_version: "none".to_string(),
986
+ to_version: target_version.to_string(),
987
+ message: "Package not installed".to_string(),
988
+ };
989
+ }
990
+ };
991
+
992
+ if current_version == target_version {
993
+ return SwitchResult {
994
+ success: true,
995
+ package_name: package_name.to_string(),
996
+ from_version: current_version.clone(),
997
+ to_version: target_version.to_string(),
998
+ message: "Already on target version".to_string(),
999
+ };
1000
+ }
1001
+
1002
+ // Check if target version exists in versions directory
1003
+ let version_dir = Path::new(VERSIONS_DIR)
1004
+ .join(package_name.replace("@", "").replace("/", "-"))
1005
+ .join(target_version);
1006
+
1007
+ if !version_dir.exists() {
1008
+ // Try installing from npm
1009
+ let package_spec = format!("{}@{}", package_name, target_version);
1010
+ let output = match Command::new("bun")
1011
+ .args(["add", &package_spec])
1012
+ .current_dir(WORK_DIR)
1013
+ .output()
1014
+ {
1015
+ Ok(o) => o,
1016
+ Err(e) => {
1017
+ return SwitchResult {
1018
+ success: false,
1019
+ package_name: package_name.to_string(),
1020
+ from_version: current_version,
1021
+ to_version: target_version.to_string(),
1022
+ message: format!("Failed to run bun add: {}", e),
1023
+ };
1024
+ }
1025
+ };
1026
+
1027
+ if !output.status.success() {
1028
+ let stderr = String::from_utf8_lossy(&output.stderr);
1029
+ return SwitchResult {
1030
+ success: false,
1031
+ package_name: package_name.to_string(),
1032
+ from_version: current_version,
1033
+ to_version: target_version.to_string(),
1034
+ message: format!("Version {} not found locally or on npm: {}", target_version, stderr),
1035
+ };
1036
+ }
1037
+
1038
+ return SwitchResult {
1039
+ success: true,
1040
+ package_name: package_name.to_string(),
1041
+ from_version: current_version,
1042
+ to_version: target_version.to_string(),
1043
+ message: format!("Installed and switched to {}", target_version),
1044
+ };
1045
+ }
1046
+
1047
+ // Backup current version to versions directory
1048
+ let current_version_dir = Path::new(VERSIONS_DIR)
1049
+ .join(package_name.replace("@", "").replace("/", "-"))
1050
+ .join(&current_version);
1051
+
1052
+ let current_package_path = Path::new(WORK_DIR)
1053
+ .join("node_modules")
1054
+ .join(package_name);
1055
+
1056
+ if current_package_path.exists() {
1057
+ // Create versions directory if needed
1058
+ let _ = fs::create_dir_all(current_version_dir.parent().unwrap());
1059
+
1060
+ // Copy current version to versions dir (if not already there)
1061
+ if !current_version_dir.exists() {
1062
+ if let Err(e) = copy_dir_all(&current_package_path, &current_version_dir) {
1063
+ error!("Failed to backup current version: {}", e);
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // Remove current version from node_modules
1069
+ if let Err(e) = fs::remove_dir_all(&current_package_path) {
1070
+ error!("Failed to remove current version: {}", e);
1071
+ }
1072
+
1073
+ // Copy target version to node_modules
1074
+ if let Err(e) = copy_dir_all(&version_dir, &current_package_path) {
1075
+ return SwitchResult {
1076
+ success: false,
1077
+ package_name: package_name.to_string(),
1078
+ from_version: current_version,
1079
+ to_version: target_version.to_string(),
1080
+ message: format!("Failed to copy target version: {}", e),
1081
+ };
1082
+ }
1083
+
1084
+ info!("Switched {} from {} to {}", package_name, current_version, target_version);
1085
+
1086
+ SwitchResult {
1087
+ success: true,
1088
+ package_name: package_name.to_string(),
1089
+ from_version: current_version,
1090
+ to_version: target_version.to_string(),
1091
+ message: format!("Switched from {} to {}", current_version, target_version),
1092
+ }
1093
+ }
1094
+
1095
+ /// Prune old versions, keeping only N most recent
1096
+ pub fn prune_versions(package_name: &str, keep_count: u32) -> PruneResult {
1097
+ info!("Pruning {} versions, keeping {}", package_name, keep_count);
1098
+
1099
+ let versions = list_versions(package_name);
1100
+
1101
+ if versions.len() <= keep_count as usize {
1102
+ return PruneResult {
1103
+ success: true,
1104
+ package_name: package_name.to_string(),
1105
+ removed_versions: vec![],
1106
+ kept_versions: versions.iter().map(|v| v.version.clone()).collect(),
1107
+ freed_bytes: 0,
1108
+ message: format!("Only {} versions installed, nothing to prune", versions.len()),
1109
+ };
1110
+ }
1111
+
1112
+ let package_versions_dir = Path::new(VERSIONS_DIR)
1113
+ .join(package_name.replace("@", "").replace("/", "-"));
1114
+
1115
+ // Sort by installed date (newest first) and keep top N
1116
+ let mut sorted_versions: Vec<_> = versions.into_iter().collect();
1117
+ sorted_versions.sort_by(|a, b| b.installed_at.cmp(&a.installed_at));
1118
+
1119
+ let kept: Vec<_> = sorted_versions.iter()
1120
+ .take(keep_count as usize)
1121
+ .map(|v| v.version.clone())
1122
+ .collect();
1123
+
1124
+ let mut removed = Vec::new();
1125
+ let mut freed_bytes = 0u64;
1126
+
1127
+ for version_info in sorted_versions.iter().skip(keep_count as usize) {
1128
+ // Don't remove active version
1129
+ if version_info.active {
1130
+ continue;
1131
+ }
1132
+
1133
+ let version_dir = package_versions_dir.join(&version_info.version);
1134
+ if version_dir.exists() {
1135
+ // Calculate size before removing
1136
+ let (size, _) = calculate_dir_size(&version_dir);
1137
+ freed_bytes += size;
1138
+
1139
+ if let Err(e) = fs::remove_dir_all(&version_dir) {
1140
+ error!("Failed to remove version {}: {}", version_info.version, e);
1141
+ } else {
1142
+ removed.push(version_info.version.clone());
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ PruneResult {
1148
+ success: true,
1149
+ package_name: package_name.to_string(),
1150
+ removed_versions: removed.clone(),
1151
+ kept_versions: kept,
1152
+ freed_bytes,
1153
+ message: if removed.is_empty() {
1154
+ "No versions to remove".to_string()
1155
+ } else {
1156
+ format!("Removed {} version(s), freed {} bytes", removed.len(), freed_bytes)
1157
+ },
1158
+ }
1159
+ }
1160
+
1161
+ /// Remove a specific version
1162
+ pub fn remove_version(package_name: &str, version: &str) -> Result<String, String> {
1163
+ info!("Removing {}@{}", package_name, version);
1164
+
1165
+ // Check if this is the active version
1166
+ let active_version = get_installed_version(package_name);
1167
+ if active_version.as_deref() == Some(version) {
1168
+ return Err("Cannot remove active version. Switch to another version first.".to_string());
1169
+ }
1170
+
1171
+ let version_dir = Path::new(VERSIONS_DIR)
1172
+ .join(package_name.replace("@", "").replace("/", "-"))
1173
+ .join(version);
1174
+
1175
+ if !version_dir.exists() {
1176
+ return Err(format!("Version {} not found", version));
1177
+ }
1178
+
1179
+ let (size, _) = calculate_dir_size(&version_dir);
1180
+
1181
+ if let Err(e) = fs::remove_dir_all(&version_dir) {
1182
+ return Err(format!("Failed to remove version: {}", e));
1183
+ }
1184
+
1185
+ Ok(format!("Removed {}@{} (freed {} bytes)", package_name, version, size))
1186
+ }
1187
+
1188
+ /// Get packages with multiple versions installed
1189
+ pub fn get_multi_version_packages() -> Vec<MultiVersionPackage> {
1190
+ info!("Getting packages with multiple versions");
1191
+
1192
+ let packages = list_packages();
1193
+ let mut result = Vec::new();
1194
+
1195
+ for pkg in packages {
1196
+ if !pkg.installed {
1197
+ continue;
1198
+ }
1199
+
1200
+ let versions = list_versions(&pkg.name);
1201
+ if versions.len() > 1 {
1202
+ let active_version = versions.iter()
1203
+ .find(|v| v.active)
1204
+ .map(|v| v.version.clone())
1205
+ .unwrap_or_else(|| pkg.version.clone());
1206
+
1207
+ result.push(MultiVersionPackage {
1208
+ package_name: pkg.name.clone(),
1209
+ active_version,
1210
+ total_versions: versions.len() as u32,
1211
+ versions: versions.iter().map(|v| v.version.clone()).collect(),
1212
+ });
1213
+ }
1214
+ }
1215
+
1216
+ result
1217
+ }
1218
+
1219
+ /// Helper to recursively copy a directory
1220
+ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
1221
+ fs::create_dir_all(dst)?;
1222
+
1223
+ for entry in fs::read_dir(src)? {
1224
+ let entry = entry?;
1225
+ let ty = entry.file_type()?;
1226
+ let src_path = entry.path();
1227
+ let dst_path = dst.join(entry.file_name());
1228
+
1229
+ if ty.is_dir() {
1230
+ copy_dir_all(&src_path, &dst_path)?;
1231
+ } else {
1232
+ fs::copy(&src_path, &dst_path)?;
1233
+ }
1234
+ }
1235
+
1236
+ Ok(())
1237
+ }
package/rust/src/main.rs CHANGED
@@ -227,6 +227,183 @@ fn handle_installed_info(req: Request) -> ResponseEnvelope {
227
227
  }
228
228
  }
229
229
 
230
+ // ---------------------------------------------------------------------------
231
+ // Multi-Version Handlers
232
+ // ---------------------------------------------------------------------------
233
+
234
+ fn handle_list_versions(req: Request) -> ResponseEnvelope {
235
+ let params = match req.params.as_ref() {
236
+ Some(p) => p,
237
+ None => {
238
+ return ResponseEnvelope::Error(ErrorResponse::new(
239
+ req.id,
240
+ ErrorObject::invalid_params("Missing params"),
241
+ ));
242
+ }
243
+ };
244
+
245
+ let package_name = match params["packageName"].as_str() {
246
+ Some(n) => n,
247
+ None => {
248
+ return ResponseEnvelope::Error(ErrorResponse::new(
249
+ req.id,
250
+ ErrorObject::invalid_params("Missing packageName"),
251
+ ));
252
+ }
253
+ };
254
+
255
+ let versions = list_versions(package_name);
256
+
257
+ match Response::new(req.id.clone(), &versions) {
258
+ Ok(r) => ResponseEnvelope::Success(r),
259
+ Err(e) => ResponseEnvelope::Error(ErrorResponse::new(
260
+ req.id,
261
+ ErrorObject::internal_error(e.to_string()),
262
+ )),
263
+ }
264
+ }
265
+
266
+ fn handle_switch_version(req: Request) -> ResponseEnvelope {
267
+ let params = match req.params.as_ref() {
268
+ Some(p) => p,
269
+ None => {
270
+ return ResponseEnvelope::Error(ErrorResponse::new(
271
+ req.id,
272
+ ErrorObject::invalid_params("Missing params"),
273
+ ));
274
+ }
275
+ };
276
+
277
+ let package_name = match params["packageName"].as_str() {
278
+ Some(n) => n,
279
+ None => {
280
+ return ResponseEnvelope::Error(ErrorResponse::new(
281
+ req.id,
282
+ ErrorObject::invalid_params("Missing packageName"),
283
+ ));
284
+ }
285
+ };
286
+
287
+ let version = match params["version"].as_str() {
288
+ Some(v) => v,
289
+ None => {
290
+ return ResponseEnvelope::Error(ErrorResponse::new(
291
+ req.id,
292
+ ErrorObject::invalid_params("Missing version"),
293
+ ));
294
+ }
295
+ };
296
+
297
+ let result = switch_version(package_name, version);
298
+
299
+ match Response::new(req.id.clone(), &result) {
300
+ Ok(r) => ResponseEnvelope::Success(r),
301
+ Err(e) => ResponseEnvelope::Error(ErrorResponse::new(
302
+ req.id,
303
+ ErrorObject::internal_error(e.to_string()),
304
+ )),
305
+ }
306
+ }
307
+
308
+ fn handle_prune_versions(req: Request) -> ResponseEnvelope {
309
+ let params = match req.params.as_ref() {
310
+ Some(p) => p,
311
+ None => {
312
+ return ResponseEnvelope::Error(ErrorResponse::new(
313
+ req.id,
314
+ ErrorObject::invalid_params("Missing params"),
315
+ ));
316
+ }
317
+ };
318
+
319
+ let package_name = match params["packageName"].as_str() {
320
+ Some(n) => n,
321
+ None => {
322
+ return ResponseEnvelope::Error(ErrorResponse::new(
323
+ req.id,
324
+ ErrorObject::invalid_params("Missing packageName"),
325
+ ));
326
+ }
327
+ };
328
+
329
+ let keep_count = params["keepCount"].as_u64().unwrap_or(2) as u32;
330
+
331
+ let result = prune_versions(package_name, keep_count);
332
+
333
+ match Response::new(req.id.clone(), &result) {
334
+ Ok(r) => ResponseEnvelope::Success(r),
335
+ Err(e) => ResponseEnvelope::Error(ErrorResponse::new(
336
+ req.id,
337
+ ErrorObject::internal_error(e.to_string()),
338
+ )),
339
+ }
340
+ }
341
+
342
+ fn handle_remove_version(req: Request) -> ResponseEnvelope {
343
+ let params = match req.params.as_ref() {
344
+ Some(p) => p,
345
+ None => {
346
+ return ResponseEnvelope::Error(ErrorResponse::new(
347
+ req.id,
348
+ ErrorObject::invalid_params("Missing params"),
349
+ ));
350
+ }
351
+ };
352
+
353
+ let package_name = match params["packageName"].as_str() {
354
+ Some(n) => n,
355
+ None => {
356
+ return ResponseEnvelope::Error(ErrorResponse::new(
357
+ req.id,
358
+ ErrorObject::invalid_params("Missing packageName"),
359
+ ));
360
+ }
361
+ };
362
+
363
+ let version = match params["version"].as_str() {
364
+ Some(v) => v,
365
+ None => {
366
+ return ResponseEnvelope::Error(ErrorResponse::new(
367
+ req.id,
368
+ ErrorObject::invalid_params("Missing version"),
369
+ ));
370
+ }
371
+ };
372
+
373
+ let result = remove_version(package_name, version);
374
+
375
+ let response = match result {
376
+ Ok(msg) => serde_json::json!({
377
+ "success": true,
378
+ "message": msg
379
+ }),
380
+ Err(msg) => serde_json::json!({
381
+ "success": false,
382
+ "message": msg
383
+ }),
384
+ };
385
+
386
+ match Response::new(req.id.clone(), &response) {
387
+ Ok(r) => ResponseEnvelope::Success(r),
388
+ Err(e) => ResponseEnvelope::Error(ErrorResponse::new(
389
+ req.id,
390
+ ErrorObject::internal_error(e.to_string()),
391
+ )),
392
+ }
393
+ }
394
+
395
+ fn handle_get_multi_version_packages(req: Request) -> ResponseEnvelope {
396
+ let packages = get_multi_version_packages();
397
+
398
+ match Response::new(req.id.clone(), &packages) {
399
+ Ok(r) => ResponseEnvelope::Success(r),
400
+ Err(e) => ResponseEnvelope::Error(ErrorResponse::new(
401
+ req.id,
402
+ ErrorObject::internal_error(e.to_string()),
403
+ )),
404
+ }
405
+ }
406
+
230
407
  // ---------------------------------------------------------------------------
231
408
  // Router
232
409
  // ---------------------------------------------------------------------------
@@ -247,6 +424,12 @@ fn route_request(request: Request) -> (ResponseEnvelope, bool) {
247
424
  "scan" => handle_audit(request), // alias for audit
248
425
  "sizes" => handle_sizes(request),
249
426
  "installedInfo" => handle_installed_info(request),
427
+ // Multi-version methods
428
+ "listVersions" => handle_list_versions(request),
429
+ "switchVersion" => handle_switch_version(request),
430
+ "pruneVersions" => handle_prune_versions(request),
431
+ "removeVersion" => handle_remove_version(request),
432
+ "getMultiVersionPackages" => handle_get_multi_version_packages(request),
250
433
  method => ResponseEnvelope::Error(ErrorResponse::new(
251
434
  request.id,
252
435
  ErrorObject::method_not_found(method),
package/src/index.ts CHANGED
@@ -1109,8 +1109,10 @@ async function main(): Promise<void> {
1109
1109
  await command.handler(commandArgs);
1110
1110
  }
1111
1111
 
1112
- // Run CLI only when executed directly (not when imported)
1113
- if (import.meta.main) {
1112
+ // Run CLI only when executed directly via bin (not when imported as library)
1113
+ // This check works after bundling where import.meta.main gets transformed
1114
+ const isCliInvocation = process.argv[1]?.includes("pkg-ops");
1115
+ if (isCliInvocation) {
1114
1116
  main().catch((error) => {
1115
1117
  console.error("Fatal error:", error);
1116
1118
  process.exit(1);