@johnhenry/wsh 0.16.1 → 0.17.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/README.md +349 -28
- package/package.json +16 -3
- package/src/auth.mjs +63 -1
- package/src/client.mjs +379 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +47 -192
- package/src/index.d.ts +261 -8
- package/src/index.mjs +10 -2
- package/src/known-hosts.mjs +139 -0
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +30 -16
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +73 -21
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-message subscription for the helper classes that compose their
|
|
3
|
+
* own wire messages — `WshMcpBridge` and `WshFileTransfer`.
|
|
4
|
+
*
|
|
5
|
+
* Both accept "a WshClient, or any object exposing sendControl() plus a
|
|
6
|
+
* way to observe inbound control messages", and both used to inline the
|
|
7
|
+
* same three-way registration. The third branch of it — the fallback for
|
|
8
|
+
* an object whose only hook is a settable `onControl` property, which is
|
|
9
|
+
* what a bare `WshTransport` is — wrapped that property and **never
|
|
10
|
+
* unwrapped it**:
|
|
11
|
+
*
|
|
12
|
+
* ```js
|
|
13
|
+
* const prev = client.onControl;
|
|
14
|
+
* client.onControl = (msg) => { prev?.(msg); listener(msg); };
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The matching `cleanup()` handled `removeControlListener` and
|
|
18
|
+
* `_controlListeners` and did nothing at all for this branch. So every
|
|
19
|
+
* `discover()` / `call()` / `list()` left another wrapper permanently in
|
|
20
|
+
* the chain. Measured against a real `WshTransport`, the number of frames
|
|
21
|
+
* an inbound control message traverses to reach the connection's own
|
|
22
|
+
* handler grew one-for-one with the number of operations — 1, 5, 20 and
|
|
23
|
+
* 50 operations gave 5, 9, 24 and 54 frames — with no ceiling.
|
|
24
|
+
*
|
|
25
|
+
* Attaching once per subscriber and detaching on cleanup keeps that flat.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe `listener` to a client's inbound control messages.
|
|
30
|
+
*
|
|
31
|
+
* Supports, in order of preference:
|
|
32
|
+
* 1. `addControlListener()` / `removeControlListener()` — what `WshClient`
|
|
33
|
+
* exposes, and the only shape that can deregister cleanly.
|
|
34
|
+
* 2. A `_controlListeners` array.
|
|
35
|
+
* 3. A settable `onControl` property (a bare `WshTransport`), wrapped
|
|
36
|
+
* once and restored on detach.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} client
|
|
39
|
+
* @param {function(object): void} listener
|
|
40
|
+
* @returns {function(): void} detach — idempotent; safe to call twice.
|
|
41
|
+
*/
|
|
42
|
+
export function attachControlListener(client, listener) {
|
|
43
|
+
if (typeof client.addControlListener === 'function') {
|
|
44
|
+
client.addControlListener(listener);
|
|
45
|
+
let detached = false;
|
|
46
|
+
return () => {
|
|
47
|
+
if (detached) return;
|
|
48
|
+
detached = true;
|
|
49
|
+
client.removeControlListener?.(listener);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(client._controlListeners)) {
|
|
54
|
+
client._controlListeners.push(listener);
|
|
55
|
+
let detached = false;
|
|
56
|
+
return () => {
|
|
57
|
+
if (detached) return;
|
|
58
|
+
detached = true;
|
|
59
|
+
const idx = client._controlListeners.indexOf(listener);
|
|
60
|
+
if (idx !== -1) client._controlListeners.splice(idx, 1);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: wrap `onControl`. Restoring `prev` is only correct while our
|
|
65
|
+
// wrapper is still the installed handler — if something else has wrapped
|
|
66
|
+
// it since, putting `prev` back would drop that handler. In that case the
|
|
67
|
+
// wrapper stays but is made inert, so the chain stops growing per
|
|
68
|
+
// operation even though this one link cannot be spliced out.
|
|
69
|
+
const prev = client.onControl;
|
|
70
|
+
let live = true;
|
|
71
|
+
const wrapper = (msg) => {
|
|
72
|
+
prev?.(msg);
|
|
73
|
+
if (live) listener(msg);
|
|
74
|
+
};
|
|
75
|
+
client.onControl = wrapper;
|
|
76
|
+
|
|
77
|
+
let detached = false;
|
|
78
|
+
return () => {
|
|
79
|
+
if (detached) return;
|
|
80
|
+
detached = true;
|
|
81
|
+
live = false;
|
|
82
|
+
if (client.onControl === wrapper) client.onControl = prev;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wait for the first inbound control message matching `predicate`.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} client
|
|
90
|
+
* @param {function(object): boolean} predicate
|
|
91
|
+
* @param {number} timeoutMs
|
|
92
|
+
* @param {string} label - Used in the timeout message.
|
|
93
|
+
* @returns {Promise<object>}
|
|
94
|
+
*/
|
|
95
|
+
export function waitForControlMessage(client, predicate, timeoutMs, label) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let settled = false;
|
|
98
|
+
let detach = () => {};
|
|
99
|
+
let timer = null;
|
|
100
|
+
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
settled = true;
|
|
103
|
+
if (timer !== null) clearTimeout(timer);
|
|
104
|
+
detach();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// `detach` is assigned after attach() returns, but a client that
|
|
108
|
+
// dispatches synchronously from inside attach() would fire the
|
|
109
|
+
// listener first — hence the `settled` guard rather than relying on
|
|
110
|
+
// detach() having been wired up.
|
|
111
|
+
detach = attachControlListener(client, (msg) => {
|
|
112
|
+
if (settled) return;
|
|
113
|
+
let matches = false;
|
|
114
|
+
try {
|
|
115
|
+
matches = predicate(msg);
|
|
116
|
+
} catch {
|
|
117
|
+
matches = false;
|
|
118
|
+
}
|
|
119
|
+
if (!matches) return;
|
|
120
|
+
cleanup();
|
|
121
|
+
resolve(msg);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
timer = setTimeout(() => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
cleanup();
|
|
127
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
});
|
|
130
|
+
}
|
package/src/e2e-frame.mjs
CHANGED
|
@@ -138,13 +138,44 @@ export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
|
|
|
138
138
|
* @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
|
|
139
139
|
* @returns {Promise<Uint8Array>} plaintext
|
|
140
140
|
*/
|
|
141
|
-
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
|
|
141
|
+
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext, expectedRoleTag }) {
|
|
142
142
|
if (!key || typeof key !== 'object') {
|
|
143
143
|
throw new Error('e2e-frame: openFrame requires a CryptoKey');
|
|
144
144
|
}
|
|
145
145
|
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
|
|
146
146
|
throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
|
|
147
147
|
}
|
|
148
|
+
/*
|
|
149
|
+
* The role tag is REQUIRED, and a caller that omits it gets an error rather
|
|
150
|
+
* than a skipped check.
|
|
151
|
+
*
|
|
152
|
+
* The tag exists so the two directions of one session are structurally
|
|
153
|
+
* distinguishable -- it is the reason a colliding nonce is impossible when
|
|
154
|
+
* both directions share one AES-GCM key. WshSession computed the peer's
|
|
155
|
+
* expected tag and stored it, and nothing ever read it: `grep -rn
|
|
156
|
+
* e2eRecvRoleTag` returned the declaration and the assignment and no third
|
|
157
|
+
* line. So the receiver accepted a frame sealed with its OWN tag, and the
|
|
158
|
+
* relay this layer exists to distrust could echo a peer's sealed frame
|
|
159
|
+
* straight back at it. Both counters start at zero, so the first frame of a
|
|
160
|
+
* session reflects cleanly.
|
|
161
|
+
*
|
|
162
|
+
* Optional would have reproduced the defect: a security check nobody is
|
|
163
|
+
* forced to pass is the same as no check, and this file already had one --
|
|
164
|
+
* see TimestampProof.verify's ignored verifyFn for the same shape.
|
|
165
|
+
*/
|
|
166
|
+
if (!(expectedRoleTag instanceof Uint8Array) || expectedRoleTag.length !== ROLE_TAG_LENGTH) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`e2e-frame: openFrame requires expectedRoleTag, a ${ROLE_TAG_LENGTH}-byte Uint8Array`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const actualRoleTag = nonce.subarray(COUNTER_LENGTH);
|
|
172
|
+
for (let i = 0; i < ROLE_TAG_LENGTH; i += 1) {
|
|
173
|
+
if (actualRoleTag[i] !== expectedRoleTag[i]) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'e2e-frame: role tag mismatch -- this frame was sealed by the wrong side of the session'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
148
179
|
const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
|
|
149
180
|
if (actualCounter !== expectedCounter) {
|
|
150
181
|
throw new Error(
|
package/src/file-transfer.mjs
CHANGED
|
@@ -3,33 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uploads and downloads delegate to the underlying client's upload()/
|
|
5
5
|
* download() (FileChunk control messages over a 'file'-kind channel —
|
|
6
|
-
* see client.mjs). list()
|
|
7
|
-
* a
|
|
6
|
+
* see client.mjs). list() (wsh #59) delegates to the client's fileList(),
|
|
7
|
+
* which sends a structured FileOp{op:"list"} and reads the typed
|
|
8
|
+
* FileResult.entries -- the same wire path upload/download already use
|
|
9
|
+
* for moving bytes, now extended to directory listings.
|
|
10
|
+
*
|
|
11
|
+
* Before wsh #59, list() ran `ls -la` over a plain exec channel and
|
|
12
|
+
* parsed the text output client-side -- a private, JS-only convention
|
|
13
|
+
* the Rust client had no equivalent of at all (see wsh #58/#59, and the
|
|
14
|
+
* FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
11
|
-
|
|
12
17
|
/** Default timeout for waiting on control messages (30 seconds). */
|
|
13
18
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
14
19
|
|
|
15
20
|
export class WshFileTransfer {
|
|
16
21
|
/**
|
|
17
22
|
* @type {{
|
|
18
|
-
* upload: function, download: function,
|
|
19
|
-
* sendControl: function, openStream: function, onControl: function,
|
|
23
|
+
* upload: function, download: function, fileList: function,
|
|
20
24
|
* }}
|
|
21
25
|
*/
|
|
22
26
|
#client;
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
* @param {object} client - A WshClient (or any object exposing
|
|
26
|
-
* download()
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* (or a method to add a listener — see _waitForMessage)
|
|
29
|
+
* @param {object} client - A WshClient (or any object exposing the same
|
|
30
|
+
* methods used below). `upload()`/`download()` are used directly for
|
|
31
|
+
* file transfer; `list()` uses `fileList()` (the structured
|
|
32
|
+
* FileOp/FileResult request-response helper already on WshClient).
|
|
33
|
+
* Each method checks for the members it specifically needs, so a
|
|
34
|
+
* client exposing only `upload()`/`download()` (and nothing else)
|
|
35
|
+
* stays valid for those two calls.
|
|
33
36
|
*/
|
|
34
37
|
constructor(client) {
|
|
35
38
|
if (!client) throw new Error('WshFileTransfer requires a client');
|
|
@@ -92,195 +95,47 @@ export class WshFileTransfer {
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/**
|
|
95
|
-
* List files at a remote path
|
|
98
|
+
* List files at a remote path over the structured file channel (wsh #59):
|
|
99
|
+
* sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
|
|
100
|
+
* typed `FileResult.entries` -- the same `FileEntry` shape
|
|
101
|
+
* `crates/wsh-core`'s Rust client is generated from, so a symlink can't
|
|
102
|
+
* be reported as a plain file here and correctly here-and-there.
|
|
96
103
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
104
|
+
* A refusal (unimplemented op, unauthorized fs capability, no such
|
|
105
|
+
* directory) throws -- it is never coerced into an empty array, so a
|
|
106
|
+
* caller can't mistake "refused" for "empty directory" (wsh #58).
|
|
99
107
|
*
|
|
100
108
|
* @param {string} remotePath - Directory path on the remote host
|
|
101
|
-
* @
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {number} [opts.timeout] - Timeout in ms for the server response
|
|
111
|
+
* @returns {Promise<Array<{
|
|
112
|
+
* name: string, size: number, modified: Date,
|
|
113
|
+
* type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
|
|
114
|
+
* symlinkTarget?: string,
|
|
115
|
+
* }>>}
|
|
102
116
|
*/
|
|
103
|
-
async list(remotePath) {
|
|
117
|
+
async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
104
118
|
if (!remotePath || typeof remotePath !== 'string') {
|
|
105
119
|
throw new Error('remotePath is required');
|
|
106
120
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
command: `ls -la ${this._shellEscape(remotePath)}`,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
await this.#client.sendControl(openMsg);
|
|
115
|
-
|
|
116
|
-
const response = await this._waitForMessage(
|
|
117
|
-
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
118
|
-
RESPONSE_TIMEOUT_MS
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
if (response.type === MSG.OPEN_FAIL) {
|
|
122
|
-
throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Read the output stream
|
|
126
|
-
const stream = await this.#client.openStream();
|
|
127
|
-
const reader = stream.readable.getReader();
|
|
128
|
-
const chunks = [];
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
while (true) {
|
|
132
|
-
const { done, value } = await reader.read();
|
|
133
|
-
if (done) break;
|
|
134
|
-
chunks.push(value);
|
|
135
|
-
}
|
|
136
|
-
} finally {
|
|
137
|
-
reader.releaseLock();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const output = new TextDecoder().decode(this._concatChunks(chunks));
|
|
141
|
-
return this._parseLsOutput(output);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── Internal helpers ──────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Wait for a control message matching a predicate.
|
|
148
|
-
*
|
|
149
|
-
* Temporarily hooks into the client's control message flow and resolves
|
|
150
|
-
* when a matching message arrives (or rejects on timeout).
|
|
151
|
-
*
|
|
152
|
-
* @param {function(object): boolean} predicate
|
|
153
|
-
* @param {number} timeoutMs
|
|
154
|
-
* @returns {Promise<object>}
|
|
155
|
-
*/
|
|
156
|
-
_waitForMessage(predicate, timeoutMs) {
|
|
157
|
-
return new Promise((resolve, reject) => {
|
|
158
|
-
let timer = null;
|
|
159
|
-
let settled = false;
|
|
160
|
-
|
|
161
|
-
const cleanup = () => {
|
|
162
|
-
settled = true;
|
|
163
|
-
if (timer !== null) clearTimeout(timer);
|
|
164
|
-
// Remove listener
|
|
165
|
-
if (this.#client.removeControlListener) {
|
|
166
|
-
this.#client.removeControlListener(listener);
|
|
167
|
-
} else if (this.#client._controlListeners) {
|
|
168
|
-
const idx = this.#client._controlListeners.indexOf(listener);
|
|
169
|
-
if (idx !== -1) this.#client._controlListeners.splice(idx, 1);
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const listener = (msg) => {
|
|
174
|
-
if (settled) return;
|
|
175
|
-
if (predicate(msg)) {
|
|
176
|
-
cleanup();
|
|
177
|
-
resolve(msg);
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// Register listener on the client
|
|
182
|
-
if (this.#client.addControlListener) {
|
|
183
|
-
this.#client.addControlListener(listener);
|
|
184
|
-
} else if (this.#client._controlListeners) {
|
|
185
|
-
this.#client._controlListeners.push(listener);
|
|
186
|
-
} else {
|
|
187
|
-
// Fallback: wrap existing onControl
|
|
188
|
-
const prev = this.#client.onControl;
|
|
189
|
-
this.#client.onControl = (msg) => {
|
|
190
|
-
prev?.(msg);
|
|
191
|
-
listener(msg);
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
timer = setTimeout(() => {
|
|
196
|
-
if (!settled) {
|
|
197
|
-
cleanup();
|
|
198
|
-
reject(new Error(`Timeout waiting for response (${timeoutMs}ms)`));
|
|
199
|
-
}
|
|
200
|
-
}, timeoutMs);
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Concatenate an array of Uint8Arrays into one.
|
|
206
|
-
*
|
|
207
|
-
* @param {Uint8Array[]} chunks
|
|
208
|
-
* @param {number} [totalLength] - Pre-computed total, avoids re-summing
|
|
209
|
-
* @returns {Uint8Array}
|
|
210
|
-
*/
|
|
211
|
-
_concatChunks(chunks, totalLength) {
|
|
212
|
-
if (chunks.length === 0) return new Uint8Array(0);
|
|
213
|
-
if (chunks.length === 1) return chunks[0];
|
|
214
|
-
|
|
215
|
-
const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
|
|
216
|
-
const result = new Uint8Array(total);
|
|
217
|
-
let offset = 0;
|
|
218
|
-
for (const chunk of chunks) {
|
|
219
|
-
result.set(chunk, offset);
|
|
220
|
-
offset += chunk.length;
|
|
121
|
+
if (typeof this.#client.fileList !== 'function') {
|
|
122
|
+
throw new TypeError(
|
|
123
|
+
'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
|
|
124
|
+
);
|
|
221
125
|
}
|
|
222
|
-
return result;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Parse the output of `ls -la` into structured entries.
|
|
227
|
-
*
|
|
228
|
-
* Expected format per line:
|
|
229
|
-
* drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
|
|
230
|
-
* -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
|
|
231
|
-
*
|
|
232
|
-
* @param {string} output
|
|
233
|
-
* @returns {Array<{ name: string, size: number, modified: string, type: string }>}
|
|
234
|
-
*/
|
|
235
|
-
_parseLsOutput(output) {
|
|
236
|
-
const lines = output.split('\n').filter((l) => l.trim());
|
|
237
|
-
const entries = [];
|
|
238
|
-
|
|
239
|
-
for (const line of lines) {
|
|
240
|
-
// Skip the "total" header line
|
|
241
|
-
if (line.startsWith('total ')) continue;
|
|
242
|
-
|
|
243
|
-
// ls -la columns: perms links owner group size month day time name
|
|
244
|
-
const parts = line.split(/\s+/);
|
|
245
|
-
if (parts.length < 9) continue;
|
|
246
126
|
|
|
247
|
-
|
|
248
|
-
const size = parseInt(parts[4], 10) || 0;
|
|
249
|
-
const month = parts[5];
|
|
250
|
-
const day = parts[6];
|
|
251
|
-
const time = parts[7];
|
|
252
|
-
// Name may contain spaces — rejoin everything from column 8 onward
|
|
253
|
-
const name = parts.slice(8).join(' ');
|
|
127
|
+
const result = await this.#client.fileList(remotePath, timeout);
|
|
254
128
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// Determine type from permission string first character
|
|
259
|
-
let type = 'file';
|
|
260
|
-
if (perms.startsWith('d')) type = 'directory';
|
|
261
|
-
else if (perms.startsWith('l')) type = 'symlink';
|
|
262
|
-
else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
|
|
263
|
-
else if (perms.startsWith('p')) type = 'pipe';
|
|
264
|
-
else if (perms.startsWith('s')) type = 'socket';
|
|
265
|
-
|
|
266
|
-
entries.push({
|
|
267
|
-
name,
|
|
268
|
-
size,
|
|
269
|
-
modified: `${month} ${day} ${time}`,
|
|
270
|
-
type,
|
|
271
|
-
});
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
|
|
272
131
|
}
|
|
273
132
|
|
|
274
|
-
return entries
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
*/
|
|
282
|
-
_shellEscape(str) {
|
|
283
|
-
// Wrap in single quotes, escaping any embedded single quotes
|
|
284
|
-
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
133
|
+
return (result.entries || []).map((entry) => ({
|
|
134
|
+
name: entry.name,
|
|
135
|
+
size: entry.size,
|
|
136
|
+
modified: new Date(entry.modified * 1000),
|
|
137
|
+
type: entry.type,
|
|
138
|
+
...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
|
|
139
|
+
}));
|
|
285
140
|
}
|
|
286
141
|
}
|