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,352 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { join } from "path";
3
+ import {
4
+ getSSHConfigPath,
5
+ parseSSHConfig,
6
+ getHostConfig,
7
+ clearCache,
8
+ parseConfigContent,
9
+ } from "../sshConfig";
10
+
11
+ // Mock os module
12
+ vi.mock("node:os", async () => {
13
+ const actual = await vi.importActual<typeof import("node:os")>("node:os");
14
+ return {
15
+ ...actual,
16
+ homedir: vi.fn(() => "/home/user"),
17
+ };
18
+ });
19
+
20
+ // Mock fs module
21
+ // Define state variables that will be accessed by mocks
22
+ let mockFileExists = false;
23
+ let mockFileContent = "";
24
+ let mockFileStats: { mtimeMs: number } | null = null;
25
+
26
+ // Create mock functions inside the factory to avoid hoisting issues
27
+ vi.mock("node:fs", async () => {
28
+ const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
29
+ const mockExistsSync = vi.fn(() => mockFileExists);
30
+ const mockReadFileSync = vi.fn(() => mockFileContent);
31
+ const mockStatSync = vi.fn(() => {
32
+ if (!mockFileStats) {
33
+ throw new Error("File not found");
34
+ }
35
+ return mockFileStats as any;
36
+ });
37
+
38
+ return {
39
+ ...actual,
40
+ existsSync: mockExistsSync,
41
+ readFileSync: mockReadFileSync,
42
+ statSync: mockStatSync,
43
+ };
44
+ });
45
+
46
+ // Import fs after mocking to get the mocked functions
47
+ import * as fs from "node:fs";
48
+
49
+ describe("sshConfig", () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ clearCache();
53
+ mockFileExists = false;
54
+ mockFileContent = "";
55
+ mockFileStats = null;
56
+ vi.mocked(fs.existsSync).mockClear();
57
+ vi.mocked(fs.readFileSync).mockClear();
58
+ vi.mocked(fs.statSync).mockClear();
59
+ });
60
+
61
+ afterEach(() => {
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ describe("getSSHConfigPath", () => {
66
+ it("should return the correct SSH config path", () => {
67
+ const path = getSSHConfigPath();
68
+ expect(path).toBe(join("/home/user", ".ssh", "config"));
69
+ });
70
+ });
71
+
72
+ describe("parseConfigContent", () => {
73
+ it("should parse simple host configuration", () => {
74
+ const content = `
75
+ Host myserver
76
+ HostName example.com
77
+ User myuser
78
+ Port 22
79
+ `;
80
+ const hosts = parseConfigContent(content);
81
+ expect(hosts).toHaveLength(1);
82
+ expect(hosts[0]).toMatchObject({
83
+ host: "myserver",
84
+ hostName: "example.com",
85
+ user: "myuser",
86
+ port: 22,
87
+ });
88
+ });
89
+
90
+ it("should parse multiple hosts", () => {
91
+ const content = `
92
+ Host server1
93
+ HostName server1.example.com
94
+ User user1
95
+
96
+ Host server2
97
+ HostName server2.example.com
98
+ User user2
99
+ Port 2222
100
+ `;
101
+ const hosts = parseConfigContent(content);
102
+ expect(hosts).toHaveLength(2);
103
+ expect(hosts[0].host).toBe("server1");
104
+ expect(hosts[1].host).toBe("server2");
105
+ expect(hosts[1].port).toBe(2222);
106
+ });
107
+
108
+ it("should skip wildcard hosts", () => {
109
+ const content = `
110
+ Host *
111
+ User default
112
+
113
+ Host myserver
114
+ HostName example.com
115
+ `;
116
+ const hosts = parseConfigContent(content);
117
+ expect(hosts).toHaveLength(1);
118
+ expect(hosts[0].host).toBe("myserver");
119
+ });
120
+
121
+ it("should handle multiple host aliases", () => {
122
+ const content = `
123
+ Host server1 server2 server3
124
+ HostName example.com
125
+ User myuser
126
+ `;
127
+ const hosts = parseConfigContent(content);
128
+ expect(hosts).toHaveLength(3);
129
+ expect(hosts.map((h) => h.host)).toEqual([
130
+ "server1",
131
+ "server2",
132
+ "server3",
133
+ ]);
134
+ });
135
+
136
+ it("should parse identity file and expand ~", () => {
137
+ const content = `
138
+ Host myserver
139
+ HostName example.com
140
+ IdentityFile ~/.ssh/id_rsa
141
+ `;
142
+ const hosts = parseConfigContent(content);
143
+ expect(hosts[0].identityFile).toBe("/home/user/.ssh/id_rsa");
144
+ });
145
+
146
+ it("should parse proxy jump", () => {
147
+ const content = `
148
+ Host myserver
149
+ HostName example.com
150
+ ProxyJump jumpbox
151
+ `;
152
+ const hosts = parseConfigContent(content);
153
+ expect(hosts[0].proxyJump).toBe("jumpbox");
154
+ });
155
+
156
+ it("should skip comments and empty lines", () => {
157
+ const content = `
158
+ # This is a comment
159
+ Host myserver
160
+ # Another comment
161
+ HostName example.com
162
+ User myuser
163
+
164
+ Host server2
165
+ HostName server2.com
166
+ `;
167
+ const hosts = parseConfigContent(content);
168
+ expect(hosts).toHaveLength(2);
169
+ });
170
+
171
+ it("should handle global configuration", () => {
172
+ // Note: The current implementation doesn't support global configuration
173
+ // This test documents the expected behavior if global config support is added
174
+ const content = `
175
+ User globaluser
176
+ Port 2222
177
+
178
+ Host myserver
179
+ HostName example.com
180
+ `;
181
+ const hosts = parseConfigContent(content);
182
+ // Global config is not currently supported, so user and port will be undefined
183
+ // This test verifies the current behavior
184
+ expect(hosts[0].hostName).toBe("example.com");
185
+ // If global config support is added, uncomment these:
186
+ // expect(hosts[0].user).toBe("globaluser");
187
+ // expect(hosts[0].port).toBe(2222);
188
+ });
189
+
190
+ it("should handle case-insensitive properties", () => {
191
+ const content = `
192
+ Host myserver
193
+ HOSTNAME example.com
194
+ USER myuser
195
+ PORT 2222
196
+ `;
197
+ const hosts = parseConfigContent(content);
198
+ expect(hosts[0].hostName).toBe("example.com");
199
+ expect(hosts[0].user).toBe("myuser");
200
+ expect(hosts[0].port).toBe(2222);
201
+ });
202
+
203
+ it("should handle invalid port gracefully", () => {
204
+ const content = `
205
+ Host myserver
206
+ HostName example.com
207
+ Port invalid
208
+ `;
209
+ const hosts = parseConfigContent(content);
210
+ expect(hosts[0].port).toBeUndefined();
211
+ });
212
+
213
+ it("should return empty array for empty config", () => {
214
+ const hosts = parseConfigContent("");
215
+ expect(hosts).toHaveLength(0);
216
+ });
217
+
218
+ it("should handle tabs and spaces for indentation", () => {
219
+ const content = `
220
+ Host myserver
221
+ HostName example.com
222
+ User myuser
223
+ `;
224
+ const hosts = parseConfigContent(content);
225
+ expect(hosts).toHaveLength(1);
226
+ expect(hosts[0].hostName).toBe("example.com");
227
+ expect(hosts[0].user).toBe("myuser");
228
+ });
229
+ });
230
+
231
+ describe("parseSSHConfig", () => {
232
+ it("should return empty array if config file does not exist", () => {
233
+ mockFileExists = false;
234
+ clearCache();
235
+ const hosts = parseSSHConfig();
236
+ expect(hosts).toHaveLength(0);
237
+ });
238
+
239
+ it("should parse existing config file", () => {
240
+ const configContent = `
241
+ Host myserver
242
+ HostName example.com
243
+ User myuser
244
+ `;
245
+ clearCache();
246
+ mockFileExists = true;
247
+ mockFileContent = configContent;
248
+ mockFileStats = { mtimeMs: 1234567890 };
249
+ // Reset the mock implementation to return the content
250
+ vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
251
+
252
+ const hosts = parseSSHConfig();
253
+ expect(hosts).toHaveLength(1);
254
+ });
255
+
256
+ it("should cache parsed config", () => {
257
+ const configContent = `
258
+ Host myserver
259
+ HostName example.com
260
+ `;
261
+ clearCache();
262
+ mockFileExists = true;
263
+ mockFileContent = configContent;
264
+ mockFileStats = { mtimeMs: 1234567890 };
265
+ vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
266
+ vi.mocked(fs.readFileSync).mockClear();
267
+
268
+ parseSSHConfig();
269
+ parseSSHConfig();
270
+
271
+ // Should only read file once due to caching
272
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(1);
273
+ });
274
+
275
+ it("should handle read errors gracefully", () => {
276
+ // Suppress console.error for this test
277
+ const consoleSpy = vi
278
+ .spyOn(console, "error")
279
+ .mockImplementation(() => {});
280
+ clearCache();
281
+
282
+ mockFileExists = true;
283
+ mockFileStats = { mtimeMs: 1234567890 };
284
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
285
+ throw new Error("Permission denied");
286
+ });
287
+
288
+ const hosts = parseSSHConfig();
289
+ expect(hosts).toHaveLength(0);
290
+
291
+ consoleSpy.mockRestore();
292
+ });
293
+ });
294
+
295
+ describe("getHostConfig", () => {
296
+ it("should return host config by alias", () => {
297
+ const configContent = `
298
+ Host myserver
299
+ HostName example.com
300
+ User myuser
301
+ `;
302
+ clearCache();
303
+ mockFileExists = true;
304
+ mockFileContent = configContent;
305
+ mockFileStats = { mtimeMs: 1234567890 };
306
+ // Reset the mock implementation to return the content
307
+ vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
308
+
309
+ const host = getHostConfig("myserver");
310
+ expect(host).toBeDefined();
311
+ expect(host?.host).toBe("myserver");
312
+ expect(host?.hostName).toBe("example.com");
313
+ });
314
+
315
+ it("should return null for non-existent host", () => {
316
+ const configContent = `
317
+ Host myserver
318
+ HostName example.com
319
+ `;
320
+ clearCache();
321
+ mockFileExists = true;
322
+ mockFileContent = configContent;
323
+ mockFileStats = { mtimeMs: 1234567890 };
324
+ vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
325
+
326
+ const host = getHostConfig("nonexistent");
327
+ expect(host).toBeNull();
328
+ });
329
+ });
330
+
331
+ describe("clearCache", () => {
332
+ it("should clear the cache", () => {
333
+ const configContent = `
334
+ Host myserver
335
+ HostName example.com
336
+ `;
337
+ clearCache();
338
+ mockFileExists = true;
339
+ mockFileContent = configContent;
340
+ mockFileStats = { mtimeMs: 1234567890 };
341
+ vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
342
+ vi.mocked(fs.readFileSync).mockClear();
343
+
344
+ parseSSHConfig();
345
+ clearCache();
346
+ parseSSHConfig();
347
+
348
+ // After clearing cache, should read file again
349
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(2);
350
+ });
351
+ });
352
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import {
4
+ validatePort,
5
+ validateLocalPath,
6
+ validateRemotePath,
7
+ validateHostConfig,
8
+ } from "../validation";
9
+ import { SSHHostConfig } from "../../types/server";
10
+
11
+ // Type definitions for mocked fs module
12
+ type MockedFS = typeof fs & {
13
+ __setMockFileExists: (exists: boolean) => void;
14
+ __setMockStats: (stats: fs.Stats | null) => void;
15
+ };
16
+
17
+ // Mock fs module
18
+ vi.mock("node:fs", async () => {
19
+ const actual = await vi.importActual<typeof fs>("node:fs");
20
+ let mockFileExists = false;
21
+ let mockStats: fs.Stats | null = null;
22
+
23
+ return {
24
+ ...actual,
25
+ existsSync: vi.fn(() => mockFileExists),
26
+ statSync: vi.fn(() => {
27
+ if (!mockStats) {
28
+ throw new Error("File not found");
29
+ }
30
+ return mockStats;
31
+ }),
32
+ __setMockFileExists: (exists: boolean) => {
33
+ mockFileExists = exists;
34
+ },
35
+ __setMockStats: (stats: fs.Stats | null) => {
36
+ mockStats = stats;
37
+ },
38
+ };
39
+ });
40
+
41
+ describe("validation", () => {
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ (fs as unknown as MockedFS).__setMockFileExists(false);
45
+ (fs as unknown as MockedFS).__setMockStats(null);
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ describe("validatePort", () => {
53
+ it("should return valid for valid ports", () => {
54
+ expect(validatePort(22).valid).toBe(true);
55
+ expect(validatePort(80).valid).toBe(true);
56
+ expect(validatePort(443).valid).toBe(true);
57
+ expect(validatePort(8080).valid).toBe(true);
58
+ expect(validatePort(65535).valid).toBe(true);
59
+ expect(validatePort(1).valid).toBe(true);
60
+ });
61
+
62
+ it("should return invalid for invalid ports", () => {
63
+ expect(validatePort(0).valid).toBe(false);
64
+ expect(validatePort(-1).valid).toBe(false);
65
+ expect(validatePort(65536).valid).toBe(false);
66
+ expect(validatePort(100000).valid).toBe(false);
67
+ expect(validatePort(22.5).valid).toBe(false);
68
+ expect(validatePort(NaN).valid).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe("validateLocalPath", () => {
73
+ it("should return valid if path exists", () => {
74
+ (fs as unknown as MockedFS).__setMockFileExists(true);
75
+ const result = validateLocalPath("/valid/path");
76
+ expect(result.valid).toBe(true);
77
+ expect(result.error).toBeUndefined();
78
+ });
79
+
80
+ it("should return invalid if path does not exist", () => {
81
+ (fs as unknown as MockedFS).__setMockFileExists(false);
82
+ const result = validateLocalPath("/invalid/path");
83
+ expect(result.valid).toBe(false);
84
+ expect(result.error).toBe("File not found");
85
+ });
86
+
87
+ it("should return invalid for empty paths", () => {
88
+ const result = validateLocalPath("");
89
+ expect(result.valid).toBe(false);
90
+ expect(result.error).toBe("Local path cannot be empty");
91
+ });
92
+
93
+ it("should handle whitespace", () => {
94
+ (fs as unknown as MockedFS).__setMockFileExists(true);
95
+ const result = validateLocalPath(" /path ");
96
+ expect(result.valid).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("validateRemotePath", () => {
101
+ it("should return valid for non-empty paths", () => {
102
+ expect(validateRemotePath("/home/user").valid).toBe(true);
103
+ expect(validateRemotePath("file.txt").valid).toBe(true);
104
+ expect(validateRemotePath("/path/to/file").valid).toBe(true);
105
+ });
106
+
107
+ it("should return invalid for empty paths", () => {
108
+ expect(validateRemotePath("").valid).toBe(false);
109
+ expect(validateRemotePath(" ").valid).toBe(false);
110
+ });
111
+
112
+ it("should handle whitespace", () => {
113
+ expect(validateRemotePath(" /path ").valid).toBe(true);
114
+ });
115
+ });
116
+
117
+ describe("validateHostConfig", () => {
118
+ it("should return valid for complete config", () => {
119
+ const config: SSHHostConfig = {
120
+ host: "server1",
121
+ hostName: "example.com",
122
+ user: "admin",
123
+ port: 22,
124
+ };
125
+ const result = validateHostConfig(config);
126
+ expect(result.valid).toBe(true);
127
+ });
128
+
129
+ it("should return invalid for missing host alias", () => {
130
+ const config: SSHHostConfig = {
131
+ host: "",
132
+ hostName: "example.com",
133
+ };
134
+ const result = validateHostConfig(config);
135
+ expect(result.valid).toBe(false);
136
+ expect(result.error).toBe("Host alias is required");
137
+ });
138
+ });
139
+ });
@@ -0,0 +1,24 @@
1
+ import { getPreferenceValues } from "@raycast/api";
2
+ import { RsyncOptions } from "../types/server";
3
+
4
+ /**
5
+ * Preferences interface matching package.json preferences
6
+ */
7
+ export interface RsyncPreferences {
8
+ rsyncHumanReadable: boolean;
9
+ rsyncProgress: boolean;
10
+ rsyncDelete: boolean;
11
+ }
12
+
13
+ /**
14
+ * Get rsync preferences from Raycast preferences
15
+ * @returns RsyncOptions object derived from preferences
16
+ */
17
+ export function getRsyncPreferences(): RsyncOptions {
18
+ const preferences = getPreferenceValues<RsyncPreferences>();
19
+ return {
20
+ humanReadable: preferences.rsyncHumanReadable,
21
+ progress: preferences.rsyncProgress,
22
+ delete: preferences.rsyncDelete,
23
+ };
24
+ }