raycast-rsync-extension 1.0.1

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.
Files changed (49) hide show
  1. package/.eslintrc.js +18 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  3. package/.github/dependabot.yml +35 -0
  4. package/.github/workflows/ci.yml +105 -0
  5. package/.github/workflows/publish.yml +269 -0
  6. package/.github/workflows/update-copyright-year.yml +70 -0
  7. package/CHANGELOG.md +7 -0
  8. package/LICENSE +21 -0
  9. package/README.md +81 -0
  10. package/assets/icon.png +0 -0
  11. package/eslint.config.js +23 -0
  12. package/metadata/browse-remote-path.png +0 -0
  13. package/metadata/browse-remote.png +0 -0
  14. package/metadata/download-local-path.png +0 -0
  15. package/metadata/download-remote-path.png +0 -0
  16. package/metadata/extension.png +0 -0
  17. package/metadata/upload-local-path.png +0 -0
  18. package/metadata/upload-remote-path.png +0 -0
  19. package/metadata/upload-search-host.png +0 -0
  20. package/package.json +93 -0
  21. package/src/__mocks__/raycast-api.ts +84 -0
  22. package/src/browse.tsx +378 -0
  23. package/src/components/FileList.test.tsx +73 -0
  24. package/src/components/FileList.tsx +61 -0
  25. package/src/download.tsx +353 -0
  26. package/src/e2e/browse.e2e.test.ts +295 -0
  27. package/src/e2e/download.e2e.test.ts +193 -0
  28. package/src/e2e/error-handling.e2e.test.ts +292 -0
  29. package/src/e2e/rsync-options.e2e.test.ts +348 -0
  30. package/src/e2e/upload.e2e.test.ts +207 -0
  31. package/src/index.tsx +21 -0
  32. package/src/test-setup.ts +1 -0
  33. package/src/types/server.ts +60 -0
  34. package/src/upload.tsx +404 -0
  35. package/src/utils/__tests__/sshConfig.test.ts +352 -0
  36. package/src/utils/__tests__/validation.test.ts +139 -0
  37. package/src/utils/preferences.ts +24 -0
  38. package/src/utils/rsync.test.ts +490 -0
  39. package/src/utils/rsync.ts +517 -0
  40. package/src/utils/shellEscape.test.ts +98 -0
  41. package/src/utils/shellEscape.ts +36 -0
  42. package/src/utils/ssh.test.ts +209 -0
  43. package/src/utils/ssh.ts +187 -0
  44. package/src/utils/sshConfig.test.ts +191 -0
  45. package/src/utils/sshConfig.ts +212 -0
  46. package/src/utils/validation.test.ts +224 -0
  47. package/src/utils/validation.ts +115 -0
  48. package/tsconfig.json +27 -0
  49. package/vitest.config.ts +8 -0
@@ -0,0 +1,353 @@
1
+ import {
2
+ List,
3
+ ActionPanel,
4
+ Action,
5
+ Form,
6
+ showToast,
7
+ Toast,
8
+ popToRoot,
9
+ } from "@raycast/api";
10
+ import React, { useState, useEffect } from "react";
11
+ import { parseSSHConfig } from "./utils/sshConfig";
12
+ import { executeRsync } from "./utils/rsync";
13
+ import { validateRemotePath, validateHostConfig } from "./utils/validation";
14
+ import {
15
+ SSHHostConfig,
16
+ TransferDirection,
17
+ TransferOptions,
18
+ RsyncOptions,
19
+ } from "./types/server";
20
+ import { getRsyncPreferences } from "./utils/preferences";
21
+
22
+ /**
23
+ * Main download command component
24
+ * Displays list of SSH hosts from config file
25
+ */
26
+ export default function Command() {
27
+ const [hosts, setHosts] = useState<SSHHostConfig[]>([]);
28
+ const [isLoading, setIsLoading] = useState(true);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ useEffect(() => {
32
+ loadHosts();
33
+ }, []);
34
+
35
+ async function loadHosts() {
36
+ try {
37
+ const parsedHosts = parseSSHConfig();
38
+
39
+ if (parsedHosts.length === 0) {
40
+ const errorMsg = "No host entries found in SSH config file";
41
+ setError(errorMsg);
42
+ console.warn("SSH config parsed but no hosts found");
43
+ } else {
44
+ setHosts(parsedHosts);
45
+ console.log(`Loaded ${parsedHosts.length} SSH host(s)`);
46
+ }
47
+ } catch (err) {
48
+ const errorMessage =
49
+ err instanceof Error ? err.message : "Failed to parse SSH config";
50
+ console.error("Error loading SSH hosts:", err);
51
+ setError(errorMessage);
52
+ await showToast({
53
+ style: Toast.Style.Failure,
54
+ title: "Error Loading SSH Config",
55
+ message: errorMessage,
56
+ });
57
+ } finally {
58
+ setIsLoading(false);
59
+ }
60
+ }
61
+
62
+ if (error) {
63
+ return (
64
+ <List>
65
+ <List.EmptyView title="Error Loading SSH Config" description={error} />
66
+ </List>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <List isLoading={isLoading} searchBarPlaceholder="Search hosts...">
72
+ {hosts.map((host: SSHHostConfig) => (
73
+ <List.Item
74
+ key={host.host}
75
+ title={host.host}
76
+ subtitle={host.hostName}
77
+ accessories={[
78
+ { text: host.user ? `User: ${host.user}` : "" },
79
+ { text: host.port ? `Port: ${host.port}` : "" },
80
+ ]}
81
+ actions={
82
+ <ActionPanel>
83
+ <Action.Push
84
+ title="Enter Remote Path"
85
+ target={<RemotePathForm hostConfig={host} />}
86
+ />
87
+ </ActionPanel>
88
+ }
89
+ />
90
+ ))}
91
+ </List>
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Remote path input form
97
+ * Allows user to specify source path on remote server
98
+ */
99
+ function RemotePathForm({ hostConfig }: { hostConfig: SSHHostConfig }) {
100
+ const [remotePath, setRemotePath] = useState<string>("");
101
+ const [remotePathError, setRemotePathError] = useState<string | undefined>();
102
+
103
+ return (
104
+ <Form
105
+ actions={
106
+ <ActionPanel>
107
+ <Action.Push
108
+ title="Continue"
109
+ target={
110
+ <LocalPathForm hostConfig={hostConfig} remotePath={remotePath} />
111
+ }
112
+ />
113
+ </ActionPanel>
114
+ }
115
+ >
116
+ <Form.TextField
117
+ id="remotePath"
118
+ title="Remote Path"
119
+ placeholder="/path/to/remote/file"
120
+ value={remotePath}
121
+ onChange={(value: string) => {
122
+ setRemotePath(value);
123
+ setRemotePathError(undefined);
124
+ }}
125
+ error={remotePathError}
126
+ info="Enter the path to the file or directory on the remote server"
127
+ />
128
+ <Form.Description
129
+ title="Host Details"
130
+ text={`Downloading from: ${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
131
+ />
132
+ {hostConfig.user && (
133
+ <Form.Description title="User" text={hostConfig.user} />
134
+ )}
135
+ {hostConfig.port && (
136
+ <Form.Description title="Port" text={hostConfig.port.toString()} />
137
+ )}
138
+ {hostConfig.identityFile && (
139
+ <Form.Description
140
+ title="Identity File"
141
+ text={hostConfig.identityFile}
142
+ />
143
+ )}
144
+ </Form>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Local destination path form
150
+ * Allows user to specify destination directory on local system
151
+ */
152
+ function LocalPathForm({
153
+ hostConfig,
154
+ remotePath,
155
+ }: {
156
+ hostConfig: SSHHostConfig;
157
+ remotePath: string;
158
+ }) {
159
+ const [localPath, setLocalPath] = useState<string>("");
160
+ const [localPathError, setLocalPathError] = useState<string | undefined>();
161
+
162
+ // Initialize rsync options with global preferences
163
+ const defaultRsyncOptions = getRsyncPreferences();
164
+ const [humanReadable, setHumanReadable] = useState<boolean>(
165
+ defaultRsyncOptions.humanReadable ?? false,
166
+ );
167
+ const [progress, setProgress] = useState<boolean>(
168
+ defaultRsyncOptions.progress ?? false,
169
+ );
170
+ const [deleteExtra, setDeleteExtra] = useState<boolean>(
171
+ defaultRsyncOptions.delete ?? false,
172
+ );
173
+
174
+ async function handleSubmit(values: {
175
+ localPath: string;
176
+ humanReadable: boolean;
177
+ progress: boolean;
178
+ deleteExtra: boolean;
179
+ }) {
180
+ const localPathValue = values.localPath.trim();
181
+
182
+ if (!localPathValue) {
183
+ console.error("Local path is empty");
184
+ setLocalPathError("Local path is required");
185
+ await showToast({
186
+ style: Toast.Style.Failure,
187
+ title: "Invalid Local Path",
188
+ message: "Please enter a destination path for the downloaded files",
189
+ });
190
+ return;
191
+ }
192
+
193
+ // Validate remote path
194
+ const remoteValidation = validateRemotePath(remotePath);
195
+ if (!remoteValidation.valid) {
196
+ console.error("Remote path validation failed:", remoteValidation.error);
197
+ await showToast({
198
+ style: Toast.Style.Failure,
199
+ title: "Invalid Remote Path",
200
+ message: remoteValidation.error || "The remote path format is invalid",
201
+ });
202
+ return;
203
+ }
204
+
205
+ // Validate host config
206
+ const hostValidation = validateHostConfig(hostConfig);
207
+ if (!hostValidation.valid) {
208
+ console.error("Host config validation failed:", hostValidation.error);
209
+ await showToast({
210
+ style: Toast.Style.Failure,
211
+ title: "Invalid Host Configuration",
212
+ message:
213
+ hostValidation.error ||
214
+ "The host configuration is incomplete or invalid",
215
+ });
216
+ return;
217
+ }
218
+
219
+ // Execute transfer using form values
220
+ await executeTransfer(hostConfig, remotePath, localPathValue, {
221
+ humanReadable: values.humanReadable,
222
+ progress: values.progress,
223
+ delete: values.deleteExtra,
224
+ });
225
+ }
226
+
227
+ async function executeTransfer(
228
+ hostConfig: SSHHostConfig,
229
+ remotePath: string,
230
+ localPath: string,
231
+ rsyncOptions: RsyncOptions,
232
+ ) {
233
+ // Show initial progress toast
234
+ await showToast({
235
+ style: Toast.Style.Animated,
236
+ title: "Transferring files...",
237
+ message: `Downloading from ${hostConfig.host}`,
238
+ });
239
+
240
+ console.log("Starting download:", {
241
+ host: hostConfig.host,
242
+ remotePath,
243
+ localPath,
244
+ });
245
+
246
+ try {
247
+ const options: TransferOptions = {
248
+ hostConfig,
249
+ localPath,
250
+ remotePath,
251
+ direction: TransferDirection.DOWNLOAD,
252
+ rsyncOptions,
253
+ };
254
+
255
+ // Progress callback to update toast in real-time
256
+ const progressCallback = async (progressMessage: string) => {
257
+ await showToast({
258
+ style: Toast.Style.Animated,
259
+ title: "Transferring files...",
260
+ message: progressMessage,
261
+ });
262
+ };
263
+
264
+ const result = await executeRsync(options, progressCallback);
265
+
266
+ if (result.success) {
267
+ console.log("Download completed successfully");
268
+ // Show formatted rsync output message (includes file sizes and progress if flags enabled)
269
+ await showToast({
270
+ style: Toast.Style.Success,
271
+ title: "Download Successful",
272
+ message: result.message,
273
+ });
274
+ // Log full output for debugging
275
+ if (result.stdout) {
276
+ console.log("Rsync output:", result.stdout);
277
+ }
278
+ // Close the extension after successful download
279
+ await popToRoot();
280
+ } else {
281
+ console.error("Download failed:", result.message);
282
+ await showToast({
283
+ style: Toast.Style.Failure,
284
+ title: "Download Failed",
285
+ message: result.message,
286
+ });
287
+ }
288
+ } catch (err) {
289
+ const errorMessage =
290
+ err instanceof Error ? err.message : "Unknown error occurred";
291
+ console.error("Download error:", err);
292
+ await showToast({
293
+ style: Toast.Style.Failure,
294
+ title: "Download Failed",
295
+ message: errorMessage,
296
+ });
297
+ }
298
+ }
299
+
300
+ return (
301
+ <Form
302
+ actions={
303
+ <ActionPanel>
304
+ <Action.SubmitForm title="Download" onSubmit={handleSubmit} />
305
+ </ActionPanel>
306
+ }
307
+ >
308
+ <Form.TextField
309
+ id="localPath"
310
+ title="Local Destination Path"
311
+ placeholder="/path/to/local/destination"
312
+ value={localPath}
313
+ onChange={(value: string) => {
314
+ setLocalPath(value);
315
+ setLocalPathError(undefined);
316
+ }}
317
+ error={localPathError}
318
+ info="Enter the destination directory on your local system"
319
+ />
320
+ <Form.Description title="Remote Path" text={remotePath} />
321
+ <Form.Description
322
+ title="Host"
323
+ text={`${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
324
+ />
325
+ <Form.Separator />
326
+ <Form.Description
327
+ title="Rsync Options"
328
+ text="Configure options for this transfer"
329
+ />
330
+ <Form.Checkbox
331
+ id="humanReadable"
332
+ label="Human-readable file sizes (-h)"
333
+ value={humanReadable}
334
+ onChange={setHumanReadable}
335
+ info="Display file sizes in human-readable format (e.g., 1.5M, 500K)"
336
+ />
337
+ <Form.Checkbox
338
+ id="progress"
339
+ label="Show progress (-P)"
340
+ value={progress}
341
+ onChange={setProgress}
342
+ info="Display progress information and support partial transfers"
343
+ />
344
+ <Form.Checkbox
345
+ id="deleteExtra"
346
+ label="Delete extraneous files (--delete)"
347
+ value={deleteExtra}
348
+ onChange={setDeleteExtra}
349
+ info="Delete files in destination that don't exist in source (use with caution)"
350
+ />
351
+ </Form>
352
+ );
353
+ }
@@ -0,0 +1,295 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { validateRemotePath, validateHostConfig } from "../utils/validation";
3
+ import { SSHHostConfig } from "../types/server";
4
+
5
+ describe("Browse E2E Flow", () => {
6
+ const mockHostConfig: SSHHostConfig = {
7
+ host: "browseserver",
8
+ hostName: "browse.example.com",
9
+ user: "browseuser",
10
+ port: 22,
11
+ identityFile: "~/.ssh/browse_key",
12
+ };
13
+
14
+ it("should complete full browse workflow with valid inputs", () => {
15
+ // Step 1: Validate remote path (home directory)
16
+ const remotePath = "~";
17
+ const remoteValidation = validateRemotePath(remotePath);
18
+ expect(remoteValidation.valid).toBe(true);
19
+ expect(remoteValidation.error).toBeUndefined();
20
+
21
+ // Step 2: Validate host config
22
+ const hostValidation = validateHostConfig(mockHostConfig);
23
+ expect(hostValidation.valid).toBe(true);
24
+ expect(hostValidation.error).toBeUndefined();
25
+
26
+ // Step 3: Verify path construction for navigation
27
+ const subdirectory = "documents";
28
+ const constructedPath = remotePath.endsWith("/")
29
+ ? `${remotePath}${subdirectory}`
30
+ : `${remotePath}/${subdirectory}`;
31
+ expect(constructedPath).toBe("~/documents");
32
+ });
33
+
34
+ it("should handle absolute remote path", () => {
35
+ const remotePath = "/home/user/documents";
36
+ const remoteValidation = validateRemotePath(remotePath);
37
+ expect(remoteValidation.valid).toBe(true);
38
+ expect(remoteValidation.error).toBeUndefined();
39
+
40
+ const hostValidation = validateHostConfig(mockHostConfig);
41
+ expect(hostValidation.valid).toBe(true);
42
+ });
43
+
44
+ it("should handle relative remote path", () => {
45
+ const remotePath = "documents/files";
46
+ const remoteValidation = validateRemotePath(remotePath);
47
+ expect(remoteValidation.valid).toBe(true);
48
+ expect(remoteValidation.error).toBeUndefined();
49
+ });
50
+
51
+ it("should handle empty remote path (defaults to ~)", () => {
52
+ // In the browse form, empty path defaults to "~"
53
+ const remotePath = "";
54
+ const remotePathValue = remotePath.trim() || "~";
55
+ expect(remotePathValue).toBe("~");
56
+
57
+ const remoteValidation = validateRemotePath(remotePathValue);
58
+ expect(remoteValidation.valid).toBe(true);
59
+ });
60
+
61
+ it("should construct paths correctly for directory navigation", () => {
62
+ const testCases = [
63
+ { current: "~", subdir: "documents", expected: "~/documents" },
64
+ {
65
+ current: "/home/user",
66
+ subdir: "documents",
67
+ expected: "/home/user/documents",
68
+ },
69
+ {
70
+ current: "/home/user/",
71
+ subdir: "documents",
72
+ expected: "/home/user/documents",
73
+ },
74
+ {
75
+ current: "~/projects",
76
+ subdir: "my-project",
77
+ expected: "~/projects/my-project",
78
+ },
79
+ { current: "/var/www", subdir: "html", expected: "/var/www/html" },
80
+ ];
81
+
82
+ testCases.forEach(({ current, subdir, expected }) => {
83
+ const constructedPath = current.endsWith("/")
84
+ ? `${current}${subdir}`
85
+ : `${current}/${subdir}`;
86
+ expect(constructedPath).toBe(expected);
87
+ });
88
+ });
89
+
90
+ it("should handle invalid remote path in browse workflow", () => {
91
+ // Try to validate invalid remote path (empty without default)
92
+ const remoteValidation = validateRemotePath("");
93
+
94
+ // Should fail validation
95
+ expect(remoteValidation.valid).toBe(false);
96
+ expect(remoteValidation.error).toBeDefined();
97
+ expect(remoteValidation.error).toContain("cannot be empty");
98
+ });
99
+
100
+ it("should handle invalid remote path with control characters", () => {
101
+ const invalidPath = "/remote/path\x00/directory";
102
+ const remoteValidation = validateRemotePath(invalidPath);
103
+
104
+ // Should fail validation
105
+ expect(remoteValidation.valid).toBe(false);
106
+ expect(remoteValidation.error).toBeDefined();
107
+ expect(remoteValidation.error).toContain("control characters");
108
+ });
109
+
110
+ it("should validate all inputs before allowing browse", () => {
111
+ const browseHost: SSHHostConfig = {
112
+ host: "browseserver",
113
+ hostName: "browse.example.com",
114
+ user: "browseuser",
115
+ };
116
+
117
+ // All validations should pass
118
+ const remoteValidation = validateRemotePath("~");
119
+ const hostValidation = validateHostConfig(browseHost);
120
+
121
+ expect(remoteValidation.valid).toBe(true);
122
+ expect(hostValidation.valid).toBe(true);
123
+ });
124
+
125
+ it("should handle host without optional properties", () => {
126
+ const minimalHost: SSHHostConfig = {
127
+ host: "minimal-server",
128
+ hostName: "minimal.example.com",
129
+ };
130
+
131
+ // Should validate successfully even without user, port, and identityFile
132
+ const hostValidation = validateHostConfig(minimalHost);
133
+ expect(hostValidation.valid).toBe(true);
134
+ });
135
+
136
+ it("should handle various remote path formats for browsing", () => {
137
+ const validPaths = [
138
+ "/absolute/path/directory",
139
+ "relative/path/directory",
140
+ "/path/with spaces/directory",
141
+ "/path/with-dashes/directory",
142
+ "/path/with_underscores/directory",
143
+ "/path/with.dots/directory",
144
+ "~/home/path/directory",
145
+ "~",
146
+ ];
147
+
148
+ validPaths.forEach((remotePath) => {
149
+ const validation = validateRemotePath(remotePath);
150
+ expect(validation.valid).toBe(true);
151
+ });
152
+ });
153
+
154
+ it("should handle path construction for nested directories", () => {
155
+ // Simulate navigating through multiple directory levels
156
+ let currentPath = "~";
157
+ const directories = ["documents", "projects", "my-app"];
158
+
159
+ directories.forEach((dir) => {
160
+ currentPath = currentPath.endsWith("/")
161
+ ? `${currentPath}${dir}`
162
+ : `${currentPath}/${dir}`;
163
+ });
164
+
165
+ expect(currentPath).toBe("~/documents/projects/my-app");
166
+ });
167
+
168
+ it("should handle path construction with trailing slashes", () => {
169
+ const pathsWithTrailingSlash = [
170
+ {
171
+ path: "/home/user/",
172
+ subdir: "documents",
173
+ expected: "/home/user/documents",
174
+ },
175
+ { path: "~/", subdir: "projects", expected: "~/projects" },
176
+ { path: "/var/www/", subdir: "html", expected: "/var/www/html" },
177
+ ];
178
+
179
+ pathsWithTrailingSlash.forEach(({ path, subdir, expected }) => {
180
+ const constructedPath = path.endsWith("/")
181
+ ? `${path}${subdir}`
182
+ : `${path}/${subdir}`;
183
+ expect(constructedPath).toBe(expected);
184
+ });
185
+ });
186
+
187
+ it("should validate host config with invalid port", () => {
188
+ const invalidHost: SSHHostConfig = {
189
+ host: "browseserver",
190
+ hostName: "browse.example.com",
191
+ port: 99999, // Invalid port
192
+ };
193
+
194
+ const hostValidation = validateHostConfig(invalidHost);
195
+ expect(hostValidation.valid).toBe(false);
196
+ expect(hostValidation.error).toBeDefined();
197
+ expect(hostValidation.error).toContain("must be between 1 and 65535");
198
+ });
199
+
200
+ it("should handle missing hostname in host config", () => {
201
+ const hostWithoutHostname: SSHHostConfig = {
202
+ host: "browseserver",
203
+ // hostName is optional, so this should still be valid
204
+ };
205
+
206
+ const hostValidation = validateHostConfig(hostWithoutHostname);
207
+ // Hostname is optional, so validation should pass
208
+ expect(hostValidation.valid).toBe(true);
209
+ });
210
+
211
+ describe("Remote file listing workflow", () => {
212
+ it("should handle path validation before listing", () => {
213
+ const remotePath = "/home/user/documents";
214
+ const remoteValidation = validateRemotePath(remotePath);
215
+ expect(remoteValidation.valid).toBe(true);
216
+
217
+ const hostValidation = validateHostConfig(mockHostConfig);
218
+ expect(hostValidation.valid).toBe(true);
219
+
220
+ // Both validations must pass before attempting to list files
221
+ expect(remoteValidation.valid && hostValidation.valid).toBe(true);
222
+ });
223
+
224
+ it("should handle home directory path", () => {
225
+ const remotePath = "~";
226
+ const remoteValidation = validateRemotePath(remotePath);
227
+ expect(remoteValidation.valid).toBe(true);
228
+ });
229
+
230
+ it("should construct correct paths for file operations", () => {
231
+ const basePath = "/home/user";
232
+ const fileName = "document.txt";
233
+ const filePath = basePath.endsWith("/")
234
+ ? `${basePath}${fileName}`
235
+ : `${basePath}/${fileName}`;
236
+ expect(filePath).toBe("/home/user/document.txt");
237
+ });
238
+
239
+ it("should construct correct paths for directory operations", () => {
240
+ const basePath = "/home/user";
241
+ const dirName = "documents";
242
+ const dirPath = basePath.endsWith("/")
243
+ ? `${basePath}${dirName}`
244
+ : `${basePath}/${dirName}`;
245
+ expect(dirPath).toBe("/home/user/documents");
246
+ });
247
+ });
248
+
249
+ describe("Error handling in browse workflow", () => {
250
+ it("should reject empty remote path", () => {
251
+ const remoteValidation = validateRemotePath("");
252
+ expect(remoteValidation.valid).toBe(false);
253
+ expect(remoteValidation.error).toBeDefined();
254
+ });
255
+
256
+ it("should reject remote path with only whitespace", () => {
257
+ const remoteValidation = validateRemotePath(" ");
258
+ expect(remoteValidation.valid).toBe(false);
259
+ expect(remoteValidation.error).toBeDefined();
260
+ });
261
+
262
+ it("should handle invalid host configuration", () => {
263
+ const invalidHost: SSHHostConfig = {
264
+ host: "", // Empty host should fail
265
+ hostName: "example.com",
266
+ };
267
+
268
+ const hostValidation = validateHostConfig(invalidHost);
269
+ expect(hostValidation.valid).toBe(false);
270
+ });
271
+ });
272
+
273
+ describe("Path normalization", () => {
274
+ it("should handle paths with multiple slashes", () => {
275
+ // In real usage, paths might have multiple slashes that need normalization
276
+ const remotePath = "/home//user///documents";
277
+ const remoteValidation = validateRemotePath(remotePath);
278
+ // Validation should pass (normalization happens at SSH level)
279
+ expect(remoteValidation.valid).toBe(true);
280
+ });
281
+
282
+ it("should handle paths with dots", () => {
283
+ const pathsWithDots = [
284
+ "/home/user/./documents",
285
+ "/home/user/../documents",
286
+ "/home/user/././documents",
287
+ ];
288
+
289
+ pathsWithDots.forEach((path) => {
290
+ const validation = validateRemotePath(path);
291
+ expect(validation.valid).toBe(true);
292
+ });
293
+ });
294
+ });
295
+ });