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.
- package/.eslintrc.js +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +105 -0
- package/.github/workflows/publish.yml +269 -0
- package/.github/workflows/update-copyright-year.yml +70 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/assets/icon.png +0 -0
- package/eslint.config.js +23 -0
- package/metadata/browse-remote-path.png +0 -0
- package/metadata/browse-remote.png +0 -0
- package/metadata/download-local-path.png +0 -0
- package/metadata/download-remote-path.png +0 -0
- package/metadata/extension.png +0 -0
- package/metadata/upload-local-path.png +0 -0
- package/metadata/upload-remote-path.png +0 -0
- package/metadata/upload-search-host.png +0 -0
- package/package.json +93 -0
- package/src/__mocks__/raycast-api.ts +84 -0
- package/src/browse.tsx +378 -0
- package/src/components/FileList.test.tsx +73 -0
- package/src/components/FileList.tsx +61 -0
- package/src/download.tsx +353 -0
- package/src/e2e/browse.e2e.test.ts +295 -0
- package/src/e2e/download.e2e.test.ts +193 -0
- package/src/e2e/error-handling.e2e.test.ts +292 -0
- package/src/e2e/rsync-options.e2e.test.ts +348 -0
- package/src/e2e/upload.e2e.test.ts +207 -0
- package/src/index.tsx +21 -0
- package/src/test-setup.ts +1 -0
- package/src/types/server.ts +60 -0
- package/src/upload.tsx +404 -0
- package/src/utils/__tests__/sshConfig.test.ts +352 -0
- package/src/utils/__tests__/validation.test.ts +139 -0
- package/src/utils/preferences.ts +24 -0
- package/src/utils/rsync.test.ts +490 -0
- package/src/utils/rsync.ts +517 -0
- package/src/utils/shellEscape.test.ts +98 -0
- package/src/utils/shellEscape.ts +36 -0
- package/src/utils/ssh.test.ts +209 -0
- package/src/utils/ssh.ts +187 -0
- package/src/utils/sshConfig.test.ts +191 -0
- package/src/utils/sshConfig.ts +212 -0
- package/src/utils/validation.test.ts +224 -0
- package/src/utils/validation.ts +115 -0
- package/tsconfig.json +27 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildRsyncCommand } from "../utils/rsync";
|
|
3
|
+
import {
|
|
4
|
+
TransferOptions,
|
|
5
|
+
TransferDirection,
|
|
6
|
+
SSHHostConfig,
|
|
7
|
+
} from "../types/server";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
|
|
11
|
+
describe("Rsync Options E2E", () => {
|
|
12
|
+
const mockHostConfig: SSHHostConfig = {
|
|
13
|
+
host: "testserver",
|
|
14
|
+
hostName: "test.example.com",
|
|
15
|
+
user: "testuser",
|
|
16
|
+
port: 22,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const configPath = join(homedir(), ".ssh", "config");
|
|
20
|
+
|
|
21
|
+
describe("Rsync flags and options", () => {
|
|
22
|
+
it("should include base flags (-avz) by default", () => {
|
|
23
|
+
const options: TransferOptions = {
|
|
24
|
+
hostConfig: mockHostConfig,
|
|
25
|
+
localPath: "/local/path",
|
|
26
|
+
remotePath: "/remote/path",
|
|
27
|
+
direction: TransferDirection.UPLOAD,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const command = buildRsyncCommand(options);
|
|
31
|
+
|
|
32
|
+
// Base flags are combined into -avz
|
|
33
|
+
expect(command).toContain("-avz");
|
|
34
|
+
// Verify individual flags are present in the combined string
|
|
35
|
+
expect(command).toMatch(/-[avz]+/);
|
|
36
|
+
// Command now uses single quotes for escaping
|
|
37
|
+
const flagsMatch = command.match(/rsync -e '[^']+' (-[^ ]+)/);
|
|
38
|
+
expect(flagsMatch).not.toBeNull();
|
|
39
|
+
const flags = flagsMatch![1];
|
|
40
|
+
expect(flags).toContain("a"); // archive
|
|
41
|
+
expect(flags).toContain("v"); // verbose
|
|
42
|
+
expect(flags).toContain("z"); // compress
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should include human-readable flag when enabled", () => {
|
|
46
|
+
const options: TransferOptions = {
|
|
47
|
+
hostConfig: mockHostConfig,
|
|
48
|
+
localPath: "/local/path",
|
|
49
|
+
remotePath: "/remote/path",
|
|
50
|
+
direction: TransferDirection.UPLOAD,
|
|
51
|
+
rsyncOptions: {
|
|
52
|
+
humanReadable: true,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const command = buildRsyncCommand(options);
|
|
57
|
+
|
|
58
|
+
// Flag should be included in the combined flags string (e.g., -avzh)
|
|
59
|
+
expect(command).toMatch(/-[avz]+h/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should include progress flag when enabled", () => {
|
|
63
|
+
const options: TransferOptions = {
|
|
64
|
+
hostConfig: mockHostConfig,
|
|
65
|
+
localPath: "/local/path",
|
|
66
|
+
remotePath: "/remote/path",
|
|
67
|
+
direction: TransferDirection.UPLOAD,
|
|
68
|
+
rsyncOptions: {
|
|
69
|
+
progress: true,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const command = buildRsyncCommand(options);
|
|
74
|
+
|
|
75
|
+
// Flag should be included in the combined flags string (e.g., -avzP)
|
|
76
|
+
expect(command).toMatch(/-[avz]+P/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should include delete flag when enabled", () => {
|
|
80
|
+
const options: TransferOptions = {
|
|
81
|
+
hostConfig: mockHostConfig,
|
|
82
|
+
localPath: "/local/path",
|
|
83
|
+
remotePath: "/remote/path",
|
|
84
|
+
direction: TransferDirection.UPLOAD,
|
|
85
|
+
rsyncOptions: {
|
|
86
|
+
delete: true,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const command = buildRsyncCommand(options);
|
|
91
|
+
|
|
92
|
+
expect(command).toContain("--delete");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should include all optional flags when all enabled", () => {
|
|
96
|
+
const options: TransferOptions = {
|
|
97
|
+
hostConfig: mockHostConfig,
|
|
98
|
+
localPath: "/local/path",
|
|
99
|
+
remotePath: "/remote/path",
|
|
100
|
+
direction: TransferDirection.DOWNLOAD,
|
|
101
|
+
rsyncOptions: {
|
|
102
|
+
humanReadable: true,
|
|
103
|
+
progress: true,
|
|
104
|
+
delete: true,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const command = buildRsyncCommand(options);
|
|
109
|
+
|
|
110
|
+
// Combined short flags should include h and P (e.g., -avzhP)
|
|
111
|
+
expect(command).toMatch(/-[avz]+hP/);
|
|
112
|
+
expect(command).toContain("--delete");
|
|
113
|
+
// Base flags should be present
|
|
114
|
+
expect(command).toMatch(/-[avz]+/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should not include optional flags when disabled", () => {
|
|
118
|
+
const options: TransferOptions = {
|
|
119
|
+
hostConfig: mockHostConfig,
|
|
120
|
+
localPath: "/local/path",
|
|
121
|
+
remotePath: "/remote/path",
|
|
122
|
+
direction: TransferDirection.UPLOAD,
|
|
123
|
+
rsyncOptions: {
|
|
124
|
+
humanReadable: false,
|
|
125
|
+
progress: false,
|
|
126
|
+
delete: false,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const command = buildRsyncCommand(options);
|
|
131
|
+
|
|
132
|
+
// Extract the flags part (between -e and paths)
|
|
133
|
+
// Command now uses single quotes for escaping
|
|
134
|
+
const flagsMatch = command.match(/rsync -e '[^']+' (-[^ ]+)/);
|
|
135
|
+
expect(flagsMatch).not.toBeNull();
|
|
136
|
+
const flags = flagsMatch![1];
|
|
137
|
+
|
|
138
|
+
// Optional flags should not be present
|
|
139
|
+
expect(flags).not.toContain("h");
|
|
140
|
+
expect(flags).not.toContain("P");
|
|
141
|
+
expect(command).not.toContain("--delete");
|
|
142
|
+
// Base flags should still be present
|
|
143
|
+
expect(flags).toContain("a");
|
|
144
|
+
expect(flags).toContain("v");
|
|
145
|
+
expect(flags).toContain("z");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("Progress and output message handling", () => {
|
|
150
|
+
it("should build command with progress flag for real-time updates", () => {
|
|
151
|
+
const options: TransferOptions = {
|
|
152
|
+
hostConfig: mockHostConfig,
|
|
153
|
+
localPath: "/local/file.txt",
|
|
154
|
+
remotePath: "/remote/file.txt",
|
|
155
|
+
direction: TransferDirection.UPLOAD,
|
|
156
|
+
rsyncOptions: {
|
|
157
|
+
progress: true,
|
|
158
|
+
humanReadable: true,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const command = buildRsyncCommand(options);
|
|
163
|
+
|
|
164
|
+
// Should include both progress and human-readable flags
|
|
165
|
+
expect(command).toMatch(/-[avz]+hP/);
|
|
166
|
+
// Paths are now escaped with single quotes
|
|
167
|
+
expect(command).toContain("'/local/file.txt'");
|
|
168
|
+
expect(command).toContain("'testserver':");
|
|
169
|
+
expect(command).toContain("'/remote/file.txt'");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should handle upload with all options enabled", () => {
|
|
173
|
+
const options: TransferOptions = {
|
|
174
|
+
hostConfig: mockHostConfig,
|
|
175
|
+
localPath: "/local/directory",
|
|
176
|
+
remotePath: "/remote/directory",
|
|
177
|
+
direction: TransferDirection.UPLOAD,
|
|
178
|
+
rsyncOptions: {
|
|
179
|
+
humanReadable: true,
|
|
180
|
+
progress: true,
|
|
181
|
+
delete: true,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const command = buildRsyncCommand(options);
|
|
186
|
+
|
|
187
|
+
// Command now uses single quotes for escaping
|
|
188
|
+
expect(command).toMatch(
|
|
189
|
+
/^rsync -e 'ssh -F .+' -[avz]+hP --delete .+ 'testserver':.+$/,
|
|
190
|
+
);
|
|
191
|
+
expect(command).toContain("'/local/directory'");
|
|
192
|
+
expect(command).toContain("'/remote/directory'");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should handle download with all options enabled", () => {
|
|
196
|
+
const options: TransferOptions = {
|
|
197
|
+
hostConfig: mockHostConfig,
|
|
198
|
+
localPath: "/local/destination",
|
|
199
|
+
remotePath: "/remote/source",
|
|
200
|
+
direction: TransferDirection.DOWNLOAD,
|
|
201
|
+
rsyncOptions: {
|
|
202
|
+
humanReadable: true,
|
|
203
|
+
progress: true,
|
|
204
|
+
delete: true,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const command = buildRsyncCommand(options);
|
|
209
|
+
|
|
210
|
+
// Command now uses single quotes for escaping
|
|
211
|
+
expect(command).toMatch(
|
|
212
|
+
/^rsync -e 'ssh -F .+' -[avz]+hP --delete 'testserver':.+ .+$/,
|
|
213
|
+
);
|
|
214
|
+
expect(command).toContain("'testserver':");
|
|
215
|
+
expect(command).toContain("'/remote/source'");
|
|
216
|
+
// For downloads, local destination should have trailing slash to ensure directory is created
|
|
217
|
+
expect(command).toContain("'/local/destination/'");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("Rsync options workflow", () => {
|
|
222
|
+
it("should validate workflow with human-readable option", () => {
|
|
223
|
+
const options: TransferOptions = {
|
|
224
|
+
hostConfig: mockHostConfig,
|
|
225
|
+
localPath: "/local/path",
|
|
226
|
+
remotePath: "/remote/path",
|
|
227
|
+
direction: TransferDirection.UPLOAD,
|
|
228
|
+
rsyncOptions: {
|
|
229
|
+
humanReadable: true,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const command = buildRsyncCommand(options);
|
|
234
|
+
expect(command).toMatch(/-[avz]+h/);
|
|
235
|
+
expect(command).toContain(configPath);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should validate workflow with progress option", () => {
|
|
239
|
+
const options: TransferOptions = {
|
|
240
|
+
hostConfig: mockHostConfig,
|
|
241
|
+
localPath: "/local/path",
|
|
242
|
+
remotePath: "/remote/path",
|
|
243
|
+
direction: TransferDirection.DOWNLOAD,
|
|
244
|
+
rsyncOptions: {
|
|
245
|
+
progress: true,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const command = buildRsyncCommand(options);
|
|
250
|
+
expect(command).toMatch(/-[avz]+P/);
|
|
251
|
+
expect(command).toContain(configPath);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("should validate workflow with delete option", () => {
|
|
255
|
+
const options: TransferOptions = {
|
|
256
|
+
hostConfig: mockHostConfig,
|
|
257
|
+
localPath: "/local/path",
|
|
258
|
+
remotePath: "/remote/path",
|
|
259
|
+
direction: TransferDirection.UPLOAD,
|
|
260
|
+
rsyncOptions: {
|
|
261
|
+
delete: true,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const command = buildRsyncCommand(options);
|
|
266
|
+
expect(command).toContain("--delete");
|
|
267
|
+
expect(command).toContain(configPath);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("should handle partial options (only some enabled)", () => {
|
|
271
|
+
const options: TransferOptions = {
|
|
272
|
+
hostConfig: mockHostConfig,
|
|
273
|
+
localPath: "/local/path",
|
|
274
|
+
remotePath: "/remote/path",
|
|
275
|
+
direction: TransferDirection.UPLOAD,
|
|
276
|
+
rsyncOptions: {
|
|
277
|
+
humanReadable: true,
|
|
278
|
+
progress: false,
|
|
279
|
+
delete: false,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const command = buildRsyncCommand(options);
|
|
284
|
+
expect(command).toMatch(/-[avz]+h/);
|
|
285
|
+
expect(command).not.toContain("P");
|
|
286
|
+
expect(command).not.toContain("--delete");
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("Command structure with options", () => {
|
|
291
|
+
it("should maintain correct command structure with options", () => {
|
|
292
|
+
const options: TransferOptions = {
|
|
293
|
+
hostConfig: mockHostConfig,
|
|
294
|
+
localPath: "/local/file.txt",
|
|
295
|
+
remotePath: "/remote/file.txt",
|
|
296
|
+
direction: TransferDirection.UPLOAD,
|
|
297
|
+
rsyncOptions: {
|
|
298
|
+
humanReadable: true,
|
|
299
|
+
progress: true,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const command = buildRsyncCommand(options);
|
|
304
|
+
|
|
305
|
+
// Verify command structure: rsync -e 'ssh -F config' flags source dest
|
|
306
|
+
// Command now uses single quotes for escaping
|
|
307
|
+
expect(command).toMatch(
|
|
308
|
+
/^rsync -e 'ssh -F .+' -[avz]+hP .+ 'testserver':.+$/,
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("should handle long-form flags correctly", () => {
|
|
313
|
+
const options: TransferOptions = {
|
|
314
|
+
hostConfig: mockHostConfig,
|
|
315
|
+
localPath: "/local/path",
|
|
316
|
+
remotePath: "/remote/path",
|
|
317
|
+
direction: TransferDirection.UPLOAD,
|
|
318
|
+
rsyncOptions: {
|
|
319
|
+
delete: true,
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const command = buildRsyncCommand(options);
|
|
324
|
+
|
|
325
|
+
// --delete should appear after short flags
|
|
326
|
+
expect(command).toMatch(/-avz --delete/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should combine short and long flags correctly", () => {
|
|
330
|
+
const options: TransferOptions = {
|
|
331
|
+
hostConfig: mockHostConfig,
|
|
332
|
+
localPath: "/local/path",
|
|
333
|
+
remotePath: "/remote/path",
|
|
334
|
+
direction: TransferDirection.DOWNLOAD,
|
|
335
|
+
rsyncOptions: {
|
|
336
|
+
humanReadable: true,
|
|
337
|
+
progress: true,
|
|
338
|
+
delete: true,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const command = buildRsyncCommand(options);
|
|
343
|
+
|
|
344
|
+
// Should have short flags combined, then long flags
|
|
345
|
+
expect(command).toMatch(/-[avz]+hP --delete/);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
validateLocalPath,
|
|
4
|
+
validateRemotePath,
|
|
5
|
+
validateHostConfig,
|
|
6
|
+
} from "../utils/validation";
|
|
7
|
+
import { buildRsyncCommand } from "../utils/rsync";
|
|
8
|
+
import {
|
|
9
|
+
TransferDirection,
|
|
10
|
+
TransferOptions,
|
|
11
|
+
SSHHostConfig,
|
|
12
|
+
} from "../types/server";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
|
|
17
|
+
describe("Upload E2E Flow", () => {
|
|
18
|
+
let testLocalFile: string;
|
|
19
|
+
let testDir: string;
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
// Create test directory
|
|
23
|
+
testDir = path.join(os.tmpdir(), "rsync-e2e-test-" + Date.now());
|
|
24
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
// Create test local file
|
|
27
|
+
testLocalFile = path.join(testDir, "test-file.txt");
|
|
28
|
+
fs.writeFileSync(testLocalFile, "Test content for upload");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
// Clean up test directory
|
|
33
|
+
if (fs.existsSync(testDir)) {
|
|
34
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should complete full upload workflow with valid inputs", () => {
|
|
39
|
+
// This test verifies the complete upload workflow
|
|
40
|
+
// After successful execution, the UI will call popToRoot() to close the extension
|
|
41
|
+
|
|
42
|
+
// Step 1: Create mock host config
|
|
43
|
+
const testHost: SSHHostConfig = {
|
|
44
|
+
host: "testserver",
|
|
45
|
+
hostName: "test.example.com",
|
|
46
|
+
user: "testuser",
|
|
47
|
+
port: 2222,
|
|
48
|
+
identityFile: "~/.ssh/test_key",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Step 2: Validate local path
|
|
52
|
+
const localValidation = validateLocalPath(testLocalFile);
|
|
53
|
+
expect(localValidation.valid).toBe(true);
|
|
54
|
+
expect(localValidation.error).toBeUndefined();
|
|
55
|
+
|
|
56
|
+
// Step 3: Validate remote path
|
|
57
|
+
const remotePath = "/remote/destination/file.txt";
|
|
58
|
+
const remoteValidation = validateRemotePath(remotePath);
|
|
59
|
+
expect(remoteValidation.valid).toBe(true);
|
|
60
|
+
expect(remoteValidation.error).toBeUndefined();
|
|
61
|
+
|
|
62
|
+
// Step 4: Validate host config
|
|
63
|
+
const hostValidation = validateHostConfig(testHost);
|
|
64
|
+
expect(hostValidation.valid).toBe(true);
|
|
65
|
+
expect(hostValidation.error).toBeUndefined();
|
|
66
|
+
|
|
67
|
+
// Step 5: Build rsync command
|
|
68
|
+
const options: TransferOptions = {
|
|
69
|
+
hostConfig: testHost,
|
|
70
|
+
localPath: testLocalFile,
|
|
71
|
+
remotePath: remotePath,
|
|
72
|
+
direction: TransferDirection.UPLOAD,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const command = buildRsyncCommand(options);
|
|
76
|
+
expect(command).toContain("rsync");
|
|
77
|
+
expect(command).toContain("-a");
|
|
78
|
+
// Host alias is now escaped with single quotes
|
|
79
|
+
expect(command).toContain("'testserver':");
|
|
80
|
+
// Paths are now escaped with single quotes
|
|
81
|
+
expect(command).toContain(`'${testLocalFile}'`);
|
|
82
|
+
expect(command).toContain(`'${remotePath}'`);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should handle missing local file error in upload workflow", () => {
|
|
86
|
+
// Try to validate non-existent local path
|
|
87
|
+
const nonExistentPath = "/path/that/does/not/exist.txt";
|
|
88
|
+
const localValidation = validateLocalPath(nonExistentPath);
|
|
89
|
+
|
|
90
|
+
// Should fail validation
|
|
91
|
+
expect(localValidation.valid).toBe(false);
|
|
92
|
+
expect(localValidation.error).toBeDefined();
|
|
93
|
+
expect(localValidation.error).toContain("File not found");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should handle invalid remote path in upload workflow", () => {
|
|
97
|
+
// Validate local path (should pass)
|
|
98
|
+
const localValidation = validateLocalPath(testLocalFile);
|
|
99
|
+
expect(localValidation.valid).toBe(true);
|
|
100
|
+
|
|
101
|
+
// Try to validate invalid remote path (empty)
|
|
102
|
+
const remoteValidation = validateRemotePath("");
|
|
103
|
+
|
|
104
|
+
// Should fail validation
|
|
105
|
+
expect(remoteValidation.valid).toBe(false);
|
|
106
|
+
expect(remoteValidation.error).toBeDefined();
|
|
107
|
+
expect(remoteValidation.error).toContain("cannot be empty");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should handle invalid port in host config", () => {
|
|
111
|
+
// Create host with invalid port
|
|
112
|
+
const invalidHost: SSHHostConfig = {
|
|
113
|
+
host: "testserver",
|
|
114
|
+
hostName: "test.example.com",
|
|
115
|
+
port: 99999,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Validate host config
|
|
119
|
+
const hostValidation = validateHostConfig(invalidHost);
|
|
120
|
+
|
|
121
|
+
// Should fail validation
|
|
122
|
+
expect(hostValidation.valid).toBe(false);
|
|
123
|
+
expect(hostValidation.error).toBeDefined();
|
|
124
|
+
expect(hostValidation.error).toContain("must be between 1 and 65535");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should validate all inputs before allowing transfer", () => {
|
|
128
|
+
const testHost: SSHHostConfig = {
|
|
129
|
+
host: "testserver",
|
|
130
|
+
hostName: "test.example.com",
|
|
131
|
+
user: "testuser",
|
|
132
|
+
port: 22,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// All validations should pass
|
|
136
|
+
const localValidation = validateLocalPath(testLocalFile);
|
|
137
|
+
const remoteValidation = validateRemotePath("/remote/path");
|
|
138
|
+
const hostValidation = validateHostConfig(testHost);
|
|
139
|
+
|
|
140
|
+
expect(localValidation.valid).toBe(true);
|
|
141
|
+
expect(remoteValidation.valid).toBe(true);
|
|
142
|
+
expect(hostValidation.valid).toBe(true);
|
|
143
|
+
|
|
144
|
+
// Should be able to create transfer options
|
|
145
|
+
const options: TransferOptions = {
|
|
146
|
+
hostConfig: testHost,
|
|
147
|
+
localPath: testLocalFile,
|
|
148
|
+
remotePath: "/remote/path",
|
|
149
|
+
direction: TransferDirection.UPLOAD,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
expect(options).toBeDefined();
|
|
153
|
+
expect(options.direction).toBe(TransferDirection.UPLOAD);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("should handle directory upload", () => {
|
|
157
|
+
// Create test directory with files
|
|
158
|
+
const testSubDir = path.join(testDir, "test-dir");
|
|
159
|
+
fs.mkdirSync(testSubDir, { recursive: true });
|
|
160
|
+
fs.writeFileSync(path.join(testSubDir, "file1.txt"), "Content 1");
|
|
161
|
+
fs.writeFileSync(path.join(testSubDir, "file2.txt"), "Content 2");
|
|
162
|
+
|
|
163
|
+
// Validate directory path
|
|
164
|
+
const localValidation = validateLocalPath(testSubDir);
|
|
165
|
+
expect(localValidation.valid).toBe(true);
|
|
166
|
+
|
|
167
|
+
// Create options for directory upload
|
|
168
|
+
const testHost: SSHHostConfig = {
|
|
169
|
+
host: "testserver",
|
|
170
|
+
hostName: "test.example.com",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const options: TransferOptions = {
|
|
174
|
+
hostConfig: testHost,
|
|
175
|
+
localPath: testSubDir,
|
|
176
|
+
remotePath: "/remote/dir",
|
|
177
|
+
direction: TransferDirection.UPLOAD,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const command = buildRsyncCommand(options);
|
|
181
|
+
expect(command).toContain("-a");
|
|
182
|
+
expect(command).toContain(testSubDir);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("should build correct rsync command for upload", () => {
|
|
186
|
+
const testHost: SSHHostConfig = {
|
|
187
|
+
host: "production",
|
|
188
|
+
hostName: "prod.example.com",
|
|
189
|
+
user: "produser",
|
|
190
|
+
port: 2222,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const options: TransferOptions = {
|
|
194
|
+
hostConfig: testHost,
|
|
195
|
+
localPath: testLocalFile,
|
|
196
|
+
remotePath: "/var/www/file.txt",
|
|
197
|
+
direction: TransferDirection.UPLOAD,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const command = buildRsyncCommand(options);
|
|
201
|
+
|
|
202
|
+
// Verify command structure (now uses single quotes for escaping)
|
|
203
|
+
expect(command).toMatch(/^rsync -e 'ssh -F .+' -avz .+ 'production':.+$/);
|
|
204
|
+
expect(command).toContain(`'${testLocalFile}'`);
|
|
205
|
+
expect(command).toContain("'/var/www/file.txt'");
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main entry point for the Raycast Rsync Extension
|
|
3
|
+
*
|
|
4
|
+
* This file serves as the central export point for all commands in the extension.
|
|
5
|
+
* The extension provides three main commands:
|
|
6
|
+
* 1. Upload Files via Rsync - Transfer files from local system to remote servers
|
|
7
|
+
* 2. Download Files via Rsync - Transfer files from remote servers to local system
|
|
8
|
+
* 3. Browse Remote Files - Browse and list files on remote servers
|
|
9
|
+
*
|
|
10
|
+
* All commands integrate with the user's SSH config file (~/.ssh/config) to
|
|
11
|
+
* provide a seamless experience for selecting and connecting to remote servers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Export upload command
|
|
15
|
+
export { default as upload } from "./upload";
|
|
16
|
+
|
|
17
|
+
// Export download command
|
|
18
|
+
export { default as download } from "./download";
|
|
19
|
+
|
|
20
|
+
// Export browse command
|
|
21
|
+
export { default as browse } from "./browse";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH host configuration parsed from ~/.ssh/config
|
|
3
|
+
*/
|
|
4
|
+
export interface SSHHostConfig {
|
|
5
|
+
host: string; // Host alias from config
|
|
6
|
+
hostName?: string; // Actual hostname or IP
|
|
7
|
+
user?: string; // SSH username
|
|
8
|
+
port?: number; // SSH port (default 22)
|
|
9
|
+
identityFile?: string; // Path to SSH key
|
|
10
|
+
proxyJump?: string; // Jump host configuration
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Direction of file transfer
|
|
15
|
+
*/
|
|
16
|
+
export enum TransferDirection {
|
|
17
|
+
UPLOAD = "upload",
|
|
18
|
+
DOWNLOAD = "download",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rsync-specific options
|
|
23
|
+
*/
|
|
24
|
+
export interface RsyncOptions {
|
|
25
|
+
humanReadable?: boolean; // -h: human-readable file sizes
|
|
26
|
+
delete?: boolean; // --delete: delete extraneous files from destination
|
|
27
|
+
progress?: boolean; // -P: show progress and support partial transfers
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options for rsync transfer operation
|
|
32
|
+
*/
|
|
33
|
+
export interface TransferOptions {
|
|
34
|
+
hostConfig: SSHHostConfig;
|
|
35
|
+
localPath: string;
|
|
36
|
+
remotePath: string;
|
|
37
|
+
direction: TransferDirection;
|
|
38
|
+
rsyncOptions?: RsyncOptions;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Result of rsync command execution
|
|
43
|
+
*/
|
|
44
|
+
export interface RsyncResult {
|
|
45
|
+
success: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
stdout?: string; // rsync output messages
|
|
48
|
+
stderr?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remote file information from ls command
|
|
53
|
+
*/
|
|
54
|
+
export interface RemoteFile {
|
|
55
|
+
name: string;
|
|
56
|
+
isDirectory: boolean;
|
|
57
|
+
size?: string;
|
|
58
|
+
permissions?: string;
|
|
59
|
+
modifiedDate?: string;
|
|
60
|
+
}
|