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,193 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
+ import { validateRemotePath, validateHostConfig } from "../utils/validation";
3
+ import { buildRsyncCommand } from "../utils/rsync";
4
+ import {
5
+ TransferDirection,
6
+ TransferOptions,
7
+ SSHHostConfig,
8
+ } from "../types/server";
9
+ import * as fs from "fs";
10
+ import * as os from "os";
11
+ import * as path from "path";
12
+
13
+ describe("Download E2E Flow", () => {
14
+ let testLocalDir: string;
15
+
16
+ beforeAll(() => {
17
+ // Create test directory for downloads
18
+ testLocalDir = path.join(os.tmpdir(), "rsync-e2e-download-" + Date.now());
19
+ fs.mkdirSync(testLocalDir, { recursive: true });
20
+ });
21
+
22
+ afterAll(() => {
23
+ // Clean up test directory
24
+ if (fs.existsSync(testLocalDir)) {
25
+ fs.rmSync(testLocalDir, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ it("should complete full download workflow with valid inputs", () => {
30
+ // This test verifies the complete download workflow
31
+ // After successful execution, the UI will call popToRoot() to close the extension
32
+
33
+ // Step 1: Create mock host config
34
+ const downloadHost: SSHHostConfig = {
35
+ host: "downloadserver",
36
+ hostName: "download.example.com",
37
+ user: "downloaduser",
38
+ port: 22,
39
+ identityFile: "~/.ssh/download_key",
40
+ };
41
+
42
+ // Step 2: Validate remote path
43
+ const remotePath = "/remote/source/file.txt";
44
+ const remoteValidation = validateRemotePath(remotePath);
45
+ expect(remoteValidation.valid).toBe(true);
46
+ expect(remoteValidation.error).toBeUndefined();
47
+
48
+ // Step 3: Validate host config
49
+ const hostValidation = validateHostConfig(downloadHost);
50
+ expect(hostValidation.valid).toBe(true);
51
+ expect(hostValidation.error).toBeUndefined();
52
+
53
+ // Step 4: Build rsync command
54
+ const options: TransferOptions = {
55
+ hostConfig: downloadHost,
56
+ localPath: testLocalDir,
57
+ remotePath: remotePath,
58
+ direction: TransferDirection.DOWNLOAD,
59
+ };
60
+
61
+ const command = buildRsyncCommand(options);
62
+ expect(command).toContain("rsync");
63
+ expect(command).toContain("-a");
64
+ // Host alias is now escaped with single quotes
65
+ expect(command).toContain("'downloadserver':");
66
+ // Paths are now escaped with single quotes
67
+ expect(command).toContain(`'${remotePath}'`);
68
+ // For downloads, local destination should have trailing slash to ensure directory is created
69
+ expect(command).toContain(`'${testLocalDir}/'`);
70
+ });
71
+
72
+ it("should handle empty remote path in download workflow", () => {
73
+ // Try to validate empty remote path
74
+ const remoteValidation = validateRemotePath("");
75
+
76
+ // Should fail validation
77
+ expect(remoteValidation.valid).toBe(false);
78
+ expect(remoteValidation.error).toBeDefined();
79
+ expect(remoteValidation.error).toContain("cannot be empty");
80
+ });
81
+
82
+ it("should handle invalid remote path with control characters", () => {
83
+ // Try to validate remote path with control characters
84
+ const invalidPath = "/remote/path\x00/file.txt";
85
+ const remoteValidation = validateRemotePath(invalidPath);
86
+
87
+ // Should fail validation
88
+ expect(remoteValidation.valid).toBe(false);
89
+ expect(remoteValidation.error).toBeDefined();
90
+ expect(remoteValidation.error).toContain("control characters");
91
+ });
92
+
93
+ it("should validate all inputs before allowing download", () => {
94
+ const downloadHost: SSHHostConfig = {
95
+ host: "downloadserver",
96
+ hostName: "download.example.com",
97
+ user: "downloaduser",
98
+ };
99
+
100
+ // All validations should pass
101
+ const remoteValidation = validateRemotePath("/remote/file.txt");
102
+ const hostValidation = validateHostConfig(downloadHost);
103
+
104
+ expect(remoteValidation.valid).toBe(true);
105
+ expect(hostValidation.valid).toBe(true);
106
+
107
+ // Should be able to create transfer options
108
+ const options: TransferOptions = {
109
+ hostConfig: downloadHost,
110
+ localPath: testLocalDir,
111
+ remotePath: "/remote/file.txt",
112
+ direction: TransferDirection.DOWNLOAD,
113
+ };
114
+
115
+ expect(options).toBeDefined();
116
+ expect(options.direction).toBe(TransferDirection.DOWNLOAD);
117
+ });
118
+
119
+ it("should handle directory download", () => {
120
+ const downloadHost: SSHHostConfig = {
121
+ host: "downloadserver",
122
+ hostName: "download.example.com",
123
+ };
124
+
125
+ const remoteDir = "/remote/directory";
126
+ const remoteValidation = validateRemotePath(remoteDir);
127
+ expect(remoteValidation.valid).toBe(true);
128
+
129
+ const options: TransferOptions = {
130
+ hostConfig: downloadHost,
131
+ localPath: testLocalDir,
132
+ remotePath: remoteDir,
133
+ direction: TransferDirection.DOWNLOAD,
134
+ };
135
+
136
+ const command = buildRsyncCommand(options);
137
+ expect(command).toContain("-a");
138
+ expect(command).toContain(remoteDir);
139
+ });
140
+
141
+ it("should handle host without optional properties", () => {
142
+ const backupServer: SSHHostConfig = {
143
+ host: "backup-server",
144
+ hostName: "backup.example.com",
145
+ user: "backupuser",
146
+ };
147
+
148
+ // Should validate successfully even without port and identityFile
149
+ const hostValidation = validateHostConfig(backupServer);
150
+ expect(hostValidation.valid).toBe(true);
151
+ });
152
+
153
+ it("should handle various remote path formats", () => {
154
+ const validPaths = [
155
+ "/absolute/path/file.txt",
156
+ "relative/path/file.txt",
157
+ "/path/with spaces/file.txt",
158
+ "/path/with-dashes/file.txt",
159
+ "/path/with_underscores/file.txt",
160
+ "/path/with.dots/file.txt",
161
+ "~/home/path/file.txt",
162
+ ];
163
+
164
+ validPaths.forEach((remotePath) => {
165
+ const validation = validateRemotePath(remotePath);
166
+ expect(validation.valid).toBe(true);
167
+ });
168
+ });
169
+
170
+ it("should build correct rsync command for download", () => {
171
+ const downloadHost: SSHHostConfig = {
172
+ host: "backup",
173
+ hostName: "backup.example.com",
174
+ user: "backupuser",
175
+ port: 2222,
176
+ };
177
+
178
+ const options: TransferOptions = {
179
+ hostConfig: downloadHost,
180
+ localPath: testLocalDir,
181
+ remotePath: "/backup/data.tar.gz",
182
+ direction: TransferDirection.DOWNLOAD,
183
+ };
184
+
185
+ const command = buildRsyncCommand(options);
186
+
187
+ // Verify command structure (now uses single quotes for escaping)
188
+ expect(command).toMatch(/^rsync -e 'ssh -F .+' -avz 'backup':.+ .+$/);
189
+ expect(command).toContain("'/backup/data.tar.gz'");
190
+ // For downloads, local destination should have trailing slash to ensure directory is created
191
+ expect(command).toContain(`'${testLocalDir}/'`);
192
+ });
193
+ });
@@ -0,0 +1,292 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { executeRsync } from "../utils/rsync";
3
+ import {
4
+ validateLocalPath,
5
+ validateRemotePath,
6
+ validateHostConfig,
7
+ validatePort,
8
+ } from "../utils/validation";
9
+ import {
10
+ TransferDirection,
11
+ TransferOptions,
12
+ SSHHostConfig,
13
+ } from "../types/server";
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+ import * as path from "path";
17
+
18
+ describe("Error Handling E2E", () => {
19
+ describe("Validation Errors", () => {
20
+ it("should handle missing local file", () => {
21
+ const nonExistentFile = "/path/that/does/not/exist/file.txt";
22
+ const validation = validateLocalPath(nonExistentFile);
23
+
24
+ expect(validation.valid).toBe(false);
25
+ expect(validation.error).toContain("File not found");
26
+ });
27
+
28
+ it("should handle empty local path", () => {
29
+ const validation = validateLocalPath("");
30
+
31
+ expect(validation.valid).toBe(false);
32
+ expect(validation.error).toContain("cannot be empty");
33
+ });
34
+
35
+ it("should handle empty remote path", () => {
36
+ const validation = validateRemotePath("");
37
+
38
+ expect(validation.valid).toBe(false);
39
+ expect(validation.error).toContain("cannot be empty");
40
+ });
41
+
42
+ it("should handle remote path with control characters", () => {
43
+ const invalidPath = "/path\x00/file.txt";
44
+ const validation = validateRemotePath(invalidPath);
45
+
46
+ expect(validation.valid).toBe(false);
47
+ expect(validation.error).toContain("control characters");
48
+ });
49
+
50
+ it("should handle invalid port numbers", () => {
51
+ const testCases = [
52
+ { port: 0, shouldFail: true },
53
+ { port: -1, shouldFail: true },
54
+ { port: 65536, shouldFail: true },
55
+ { port: 99999, shouldFail: true },
56
+ { port: 1, shouldFail: false },
57
+ { port: 22, shouldFail: false },
58
+ { port: 65535, shouldFail: false },
59
+ ];
60
+
61
+ testCases.forEach(({ port, shouldFail }) => {
62
+ const validation = validatePort(port);
63
+ if (shouldFail) {
64
+ expect(validation.valid).toBe(false);
65
+ expect(validation.error).toContain("must be between 1 and 65535");
66
+ } else {
67
+ expect(validation.valid).toBe(true);
68
+ }
69
+ });
70
+ });
71
+
72
+ it("should handle missing host alias", () => {
73
+ const invalidHost: SSHHostConfig = {
74
+ host: "",
75
+ hostName: "example.com",
76
+ };
77
+
78
+ const validation = validateHostConfig(invalidHost);
79
+ expect(validation.valid).toBe(false);
80
+ expect(validation.error).toContain("Host alias is required");
81
+ });
82
+
83
+ it("should handle host with invalid port", () => {
84
+ const invalidHost: SSHHostConfig = {
85
+ host: "testhost",
86
+ hostName: "example.com",
87
+ port: 70000,
88
+ };
89
+
90
+ const validation = validateHostConfig(invalidHost);
91
+ expect(validation.valid).toBe(false);
92
+ expect(validation.error).toContain("must be between 1 and 65535");
93
+ });
94
+ });
95
+
96
+ describe("Rsync Execution Errors", () => {
97
+ it("should handle connection to unreachable host", async () => {
98
+ const mockHost: SSHHostConfig = {
99
+ host: "unreachable-host",
100
+ hostName: "192.0.2.1", // TEST-NET-1 (non-routable)
101
+ user: "testuser",
102
+ };
103
+
104
+ const testDir = path.join(os.tmpdir(), "rsync-test-" + Date.now());
105
+ fs.mkdirSync(testDir, { recursive: true });
106
+ const testFile = path.join(testDir, "test.txt");
107
+ fs.writeFileSync(testFile, "test content");
108
+
109
+ const options: TransferOptions = {
110
+ hostConfig: mockHost,
111
+ localPath: testFile,
112
+ remotePath: "/remote/test.txt",
113
+ direction: TransferDirection.UPLOAD,
114
+ };
115
+
116
+ const result = await executeRsync(options);
117
+
118
+ expect(result.success).toBe(false);
119
+ expect(result.message).toBeDefined();
120
+ expect(result.message.length).toBeGreaterThan(0);
121
+
122
+ // Cleanup
123
+ fs.rmSync(testDir, { recursive: true, force: true });
124
+ });
125
+
126
+ it("should handle invalid hostname", async () => {
127
+ const mockHost: SSHHostConfig = {
128
+ host: "invalid-host",
129
+ hostName: "this-host-does-not-exist-12345.invalid",
130
+ user: "testuser",
131
+ };
132
+
133
+ const testDir = path.join(os.tmpdir(), "rsync-test-" + Date.now());
134
+ fs.mkdirSync(testDir, { recursive: true });
135
+ const testFile = path.join(testDir, "test.txt");
136
+ fs.writeFileSync(testFile, "test content");
137
+
138
+ const options: TransferOptions = {
139
+ hostConfig: mockHost,
140
+ localPath: testFile,
141
+ remotePath: "/remote/test.txt",
142
+ direction: TransferDirection.UPLOAD,
143
+ };
144
+
145
+ const result = await executeRsync(options);
146
+
147
+ expect(result.success).toBe(false);
148
+ expect(result.message).toBeDefined();
149
+
150
+ // Cleanup
151
+ fs.rmSync(testDir, { recursive: true, force: true });
152
+ });
153
+
154
+ it("should handle missing local file in rsync execution", async () => {
155
+ const mockHost: SSHHostConfig = {
156
+ host: "test",
157
+ hostName: "test.example.com",
158
+ };
159
+
160
+ const nonExistentFile =
161
+ "/tmp/file-that-does-not-exist-" + Date.now() + ".txt";
162
+
163
+ const options: TransferOptions = {
164
+ hostConfig: mockHost,
165
+ localPath: nonExistentFile,
166
+ remotePath: "/remote/test.txt",
167
+ direction: TransferDirection.UPLOAD,
168
+ };
169
+
170
+ const result = await executeRsync(options);
171
+
172
+ expect(result.success).toBe(false);
173
+ expect(result.message).toBeDefined();
174
+ });
175
+ });
176
+
177
+ describe("Complete Error Workflow", () => {
178
+ it("should validate inputs before attempting transfer", () => {
179
+ const mockHost: SSHHostConfig = {
180
+ host: "testhost",
181
+ hostName: "test.example.com",
182
+ port: 99999, // Invalid port
183
+ };
184
+
185
+ const nonExistentFile = "/path/does/not/exist.txt";
186
+ const emptyRemotePath = "";
187
+
188
+ // All validations should fail
189
+ const localValidation = validateLocalPath(nonExistentFile);
190
+ const remoteValidation = validateRemotePath(emptyRemotePath);
191
+ const hostValidation = validateHostConfig(mockHost);
192
+
193
+ expect(localValidation.valid).toBe(false);
194
+ expect(remoteValidation.valid).toBe(false);
195
+ expect(hostValidation.valid).toBe(false);
196
+
197
+ // Should not proceed to transfer
198
+ expect(localValidation.error).toBeDefined();
199
+ expect(remoteValidation.error).toBeDefined();
200
+ expect(hostValidation.error).toBeDefined();
201
+ });
202
+
203
+ it("should pass all validations with correct inputs", () => {
204
+ const testDir = path.join(os.tmpdir(), "rsync-test-" + Date.now());
205
+ fs.mkdirSync(testDir, { recursive: true });
206
+ const testFile = path.join(testDir, "test.txt");
207
+ fs.writeFileSync(testFile, "test content");
208
+
209
+ const mockHost: SSHHostConfig = {
210
+ host: "validhost",
211
+ hostName: "valid.example.com",
212
+ user: "validuser",
213
+ port: 22,
214
+ };
215
+
216
+ const remotePath = "/remote/valid/path.txt";
217
+
218
+ // All validations should pass
219
+ const localValidation = validateLocalPath(testFile);
220
+ const remoteValidation = validateRemotePath(remotePath);
221
+ const hostValidation = validateHostConfig(mockHost);
222
+
223
+ expect(localValidation.valid).toBe(true);
224
+ expect(remoteValidation.valid).toBe(true);
225
+ expect(hostValidation.valid).toBe(true);
226
+
227
+ // Cleanup
228
+ fs.rmSync(testDir, { recursive: true, force: true });
229
+ });
230
+ });
231
+
232
+ describe("Edge Cases", () => {
233
+ it("should handle paths with special characters", () => {
234
+ const specialPaths = [
235
+ "/path/with spaces/file.txt",
236
+ "/path/with-dashes/file.txt",
237
+ "/path/with_underscores/file.txt",
238
+ "/path/with.dots/file.txt",
239
+ "/path/with(parentheses)/file.txt",
240
+ "/path/with[brackets]/file.txt",
241
+ ];
242
+
243
+ specialPaths.forEach((remotePath) => {
244
+ const validation = validateRemotePath(remotePath);
245
+ expect(validation.valid).toBe(true);
246
+ });
247
+ });
248
+
249
+ it("should handle various port edge cases", () => {
250
+ const portTests = [
251
+ { port: 1, valid: true },
252
+ { port: 22, valid: true },
253
+ { port: 80, valid: true },
254
+ { port: 443, valid: true },
255
+ { port: 8080, valid: true },
256
+ { port: 65535, valid: true },
257
+ { port: 0, valid: false },
258
+ { port: -1, valid: false },
259
+ { port: 65536, valid: false },
260
+ { port: 1.5, valid: false }, // Non-integer
261
+ ];
262
+
263
+ portTests.forEach(({ port, valid }) => {
264
+ const validation = validatePort(port);
265
+ expect(validation.valid).toBe(valid);
266
+ });
267
+ });
268
+
269
+ it("should handle host config with minimal properties", () => {
270
+ const minimalHost: SSHHostConfig = {
271
+ host: "minimal",
272
+ };
273
+
274
+ const validation = validateHostConfig(minimalHost);
275
+ expect(validation.valid).toBe(true);
276
+ });
277
+
278
+ it("should handle host config with all properties", () => {
279
+ const fullHost: SSHHostConfig = {
280
+ host: "fullhost",
281
+ hostName: "full.example.com",
282
+ user: "fulluser",
283
+ port: 2222,
284
+ identityFile: "~/.ssh/id_rsa",
285
+ proxyJump: "jumphost",
286
+ };
287
+
288
+ const validation = validateHostConfig(fullHost);
289
+ expect(validation.valid).toBe(true);
290
+ });
291
+ });
292
+ });