@tiphareth/dsh-hardssh 0.1.2-alpha
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 +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/lib/fs.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { i as shellQuote, o as isPathUnderAnchor } from "./environment-BL1jddfB.js";
|
|
2
|
+
import { n as SwitchFileSystem, r as WFS_NAMESPACE_MARKER } from "./switch-fs-RrZtG2gv.js";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { posix } from "node:path";
|
|
5
|
+
import { FileSystem, FsError, FsTargetKey, FsVersion } from "@deepseek-ai/dsh-fs";
|
|
6
|
+
import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
|
|
7
|
+
//#region src/remote/remote-fs.ts
|
|
8
|
+
/**
|
|
9
|
+
* Remote filesystem provider for the `ctx.fs` capability seam: paths,
|
|
10
|
+
* contents, and atomic staging files live on the remote host, reached through
|
|
11
|
+
* the dsh-ssh engine's SFTP/exec primitives. Ported and adapted from
|
|
12
|
+
* UynajGI/dsh-ssh (MIT, https://github.com/UynajGI/dsh-ssh) — the seam
|
|
13
|
+
* contract (targets, versions, atomic writes, CRLF handling, canonical path
|
|
14
|
+
* transport) is preserved; the connection owner is replaced by the shared
|
|
15
|
+
* SshEngine and the working directory follows the mode store's remote root.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-hardssh/remote-fs
|
|
18
|
+
*/
|
|
19
|
+
const BINARY_SAMPLE_BYTES = 8192;
|
|
20
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
21
|
+
function assertNotAborted(signal, operation) {
|
|
22
|
+
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, "FS_ABORTED");
|
|
23
|
+
}
|
|
24
|
+
function normalizeLineEndings(value) {
|
|
25
|
+
return value.replaceAll("\r\n", "\n");
|
|
26
|
+
}
|
|
27
|
+
function detectsCrlf(value) {
|
|
28
|
+
const sample = value.slice(0, 4096);
|
|
29
|
+
const crlf = sample.split("\r\n").length - 1;
|
|
30
|
+
return crlf > sample.split("\n").length - 1 - crlf;
|
|
31
|
+
}
|
|
32
|
+
function restoreLineEndings(value, crlf) {
|
|
33
|
+
return crlf ? normalizeLineEndings(value).replaceAll("\n", "\r\n") : value;
|
|
34
|
+
}
|
|
35
|
+
function decodeText(bytes, displayPath) {
|
|
36
|
+
if (bytes.subarray(0, BINARY_SAMPLE_BYTES).includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT");
|
|
37
|
+
try {
|
|
38
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Decode a base64-wrapped NUL-terminated canonical path from `realpath -mz`. */
|
|
44
|
+
function decodeCanonicalPath(encoded) {
|
|
45
|
+
if (encoded.length === 0 || !BASE64.test(encoded)) throw new Error("fs-ssh: canonical path transport returned invalid base64");
|
|
46
|
+
const framed = Buffer.from(encoded, "base64");
|
|
47
|
+
if (framed.toString("base64") !== encoded || framed.length < 2 || framed.at(-1) !== 0 || framed.subarray(0, -1).includes(0)) throw new Error("fs-ssh: canonical path transport returned invalid NUL framing");
|
|
48
|
+
let path;
|
|
49
|
+
try {
|
|
50
|
+
path = new TextDecoder("utf-8", { fatal: true }).decode(framed.subarray(0, -1));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new Error("fs-ssh: canonical path is not valid UTF-8", { cause: error });
|
|
53
|
+
}
|
|
54
|
+
if (!posix.isAbsolute(path)) throw new Error("fs-ssh: canonical path is not absolute");
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
function entryType(stats) {
|
|
58
|
+
if (stats.isFile()) return "file";
|
|
59
|
+
if (stats.isDirectory()) return "directory";
|
|
60
|
+
return "other";
|
|
61
|
+
}
|
|
62
|
+
function entryVersion(stats, path) {
|
|
63
|
+
return FsVersion(`ssh:${createHash("sha256").update(JSON.stringify([
|
|
64
|
+
path,
|
|
65
|
+
stats.size,
|
|
66
|
+
stats.mtime,
|
|
67
|
+
stats.mode
|
|
68
|
+
])).digest("hex")}`);
|
|
69
|
+
}
|
|
70
|
+
function mapError(error, operation, displayPath, signal) {
|
|
71
|
+
if (error instanceof FsError) return error;
|
|
72
|
+
if (signal?.aborted === true) return new FsError(`${operation} aborted`, "FS_ABORTED", { cause: error });
|
|
73
|
+
const code = String(error.code ?? "");
|
|
74
|
+
const message = String(error);
|
|
75
|
+
if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`)) return new FsError(`cannot ${operation} "${displayPath}": not found`, "FS_NOT_FOUND", { cause: error });
|
|
76
|
+
if (/PERMISSION_DENIED|EACCES|permission denied/i.test(`${code} ${message}`)) return new FsError(`cannot ${operation} "${displayPath}": permission denied`, "FS_PERMISSION_DENIED", { cause: error });
|
|
77
|
+
return new FsError(`cannot ${operation} "${displayPath}": ${message}`, "FS_IO_ERROR", { cause: error });
|
|
78
|
+
}
|
|
79
|
+
function literalEdit(content, request, displayPath) {
|
|
80
|
+
const oldString = normalizeLineEndings(request.oldString);
|
|
81
|
+
const newString = normalizeLineEndings(request.newString);
|
|
82
|
+
if (oldString.length === 0) throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, "FS_EDIT_NOT_FOUND");
|
|
83
|
+
let matches = 0;
|
|
84
|
+
let offset = 0;
|
|
85
|
+
while (true) {
|
|
86
|
+
const found = content.indexOf(oldString, offset);
|
|
87
|
+
if (found < 0) break;
|
|
88
|
+
matches += 1;
|
|
89
|
+
offset = found + oldString.length;
|
|
90
|
+
}
|
|
91
|
+
if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, "FS_EDIT_NOT_FOUND");
|
|
92
|
+
if (!request.replaceAll && matches !== 1) throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, "FS_AMBIGUOUS_EDIT");
|
|
93
|
+
return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);
|
|
94
|
+
}
|
|
95
|
+
/** Whether one SFTP/exec error means "path absent". */
|
|
96
|
+
function isNotFound(error) {
|
|
97
|
+
const code = String(error.code ?? "");
|
|
98
|
+
const message = String(error);
|
|
99
|
+
return /NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${message}`);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Remote filesystem backend over the dsh-ssh engine. The working directory
|
|
103
|
+
* follows the mode store: relative paths resolve against the resolved remote
|
|
104
|
+
* root; a POSIX-absolute cwd override is honored; local (Windows) cwds are
|
|
105
|
+
* ignored so the model's relative-path habit keeps working remotely.
|
|
106
|
+
*/
|
|
107
|
+
var SshFileSystem = class extends FileSystem {
|
|
108
|
+
engine;
|
|
109
|
+
getState;
|
|
110
|
+
locks = /* @__PURE__ */ new Map();
|
|
111
|
+
constructor(ctx, engine, getState) {
|
|
112
|
+
super(ctx);
|
|
113
|
+
this.engine = engine;
|
|
114
|
+
this.getState = getState;
|
|
115
|
+
}
|
|
116
|
+
/** The active remote execution world (throws when not in remote mode). */
|
|
117
|
+
current() {
|
|
118
|
+
const state = this.getState();
|
|
119
|
+
if (state.mode !== "remote" || state.alias === void 0) throw new FsError("not in remote mode — switch the GUI to SSH mode first", "FS_IO_ERROR");
|
|
120
|
+
if (state.remoteRoot === void 0) throw new FsError("remote workspace root is not set", "FS_IO_ERROR");
|
|
121
|
+
return {
|
|
122
|
+
alias: state.alias,
|
|
123
|
+
remoteRoot: state.remoteRoot
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the working directory for a path: a POSIX-absolute cwd wins;
|
|
128
|
+
* anything else (relative or a local Windows path) falls back to the
|
|
129
|
+
* remote root.
|
|
130
|
+
*/
|
|
131
|
+
resolveRemoteCwd(cwd) {
|
|
132
|
+
if (cwd !== void 0 && posix.isAbsolute(cwd)) return cwd;
|
|
133
|
+
return this.current().remoteRoot;
|
|
134
|
+
}
|
|
135
|
+
async resolve(path, opts) {
|
|
136
|
+
assertNotAborted(opts?.signal, "resolve");
|
|
137
|
+
if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
138
|
+
const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path);
|
|
139
|
+
try {
|
|
140
|
+
const targetKey = await this.canonicalPath(displayPath, opts?.signal);
|
|
141
|
+
assertNotAborted(opts?.signal, "resolve");
|
|
142
|
+
return {
|
|
143
|
+
targetKey: FsTargetKey(targetKey),
|
|
144
|
+
displayPath
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
throw mapError(error, "resolve", displayPath, opts?.signal);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
processPath(target) {
|
|
151
|
+
return String(target.targetKey);
|
|
152
|
+
}
|
|
153
|
+
fileUrl(target) {
|
|
154
|
+
const path = this.processPath(target);
|
|
155
|
+
if (!posix.isAbsolute(path)) throw new Error(`fs-ssh: expected an absolute process path: ${JSON.stringify(path)}`);
|
|
156
|
+
return `file://${path.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
|
|
157
|
+
}
|
|
158
|
+
contains(parent, child) {
|
|
159
|
+
const relative = posix.relative(this.processPath(parent), this.processPath(child));
|
|
160
|
+
return relative === "" || relative !== ".." && !relative.startsWith("../") && !posix.isAbsolute(relative);
|
|
161
|
+
}
|
|
162
|
+
async stat(target, signal) {
|
|
163
|
+
assertNotAborted(signal, "stat");
|
|
164
|
+
const stats = await this.probe(String(target.targetKey), target.displayPath, signal);
|
|
165
|
+
if (stats === void 0) return void 0;
|
|
166
|
+
return {
|
|
167
|
+
version: entryVersion(stats, String(target.targetKey)),
|
|
168
|
+
type: entryType(stats),
|
|
169
|
+
...stats.isFile() ? { size: stats.size } : {}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async lstat(path, opts, signal) {
|
|
173
|
+
assertNotAborted(signal, "lstat");
|
|
174
|
+
if (path.trim().length === 0) throw new FsError("file_path must be a non-empty string", "FS_NOT_FOUND");
|
|
175
|
+
const displayPath = posix.resolve(this.resolveRemoteCwd(opts?.cwd), path);
|
|
176
|
+
const { alias } = this.current();
|
|
177
|
+
try {
|
|
178
|
+
const info = await this.engine.lstat(alias, displayPath);
|
|
179
|
+
assertNotAborted(signal, "lstat");
|
|
180
|
+
if (info === void 0) return void 0;
|
|
181
|
+
const type = info.type === "symlink" ? "symlink" : info.type === "directory" ? "directory" : info.type === "file" ? "file" : "other";
|
|
182
|
+
return {
|
|
183
|
+
version: entryVersion(this.asStats({
|
|
184
|
+
type: info.type,
|
|
185
|
+
size: info.size,
|
|
186
|
+
mtimeMs: info.mtimeMs,
|
|
187
|
+
mode: info.mode
|
|
188
|
+
}), displayPath),
|
|
189
|
+
type,
|
|
190
|
+
...type === "file" ? { size: info.size } : {}
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (isNotFound(error)) return void 0;
|
|
194
|
+
throw mapError(error, "lstat", displayPath, signal);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async readText(target, signal) {
|
|
198
|
+
await this.requireRegular(target, signal);
|
|
199
|
+
const bytes = await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY);
|
|
200
|
+
assertNotAborted(signal, "read");
|
|
201
|
+
return decodeText(bytes, target.displayPath);
|
|
202
|
+
}
|
|
203
|
+
async readBytes(target, signal, maxBytes) {
|
|
204
|
+
const info = await this.requireRegular(target, signal);
|
|
205
|
+
if (info.size !== void 0 && info.size > maxBytes) throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
|
|
206
|
+
const bytes = await this.readBytesRaw(target, signal, maxBytes);
|
|
207
|
+
assertNotAborted(signal, "read");
|
|
208
|
+
return bytes;
|
|
209
|
+
}
|
|
210
|
+
async streamText(target, signal) {
|
|
211
|
+
await this.requireRegular(target, signal);
|
|
212
|
+
const { alias } = this.current();
|
|
213
|
+
const displayPath = target.displayPath;
|
|
214
|
+
const stream = await this.engine.readStream(alias, String(target.targetKey));
|
|
215
|
+
return { async *[Symbol.asyncIterator]() {
|
|
216
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
217
|
+
let sampledBytes = 0;
|
|
218
|
+
try {
|
|
219
|
+
for await (const chunk of stream) {
|
|
220
|
+
assertNotAborted(signal, "read");
|
|
221
|
+
const bytes = Buffer.from(chunk);
|
|
222
|
+
if (sampledBytes < BINARY_SAMPLE_BYTES) {
|
|
223
|
+
const sample = bytes.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes);
|
|
224
|
+
if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, "FS_NOT_TEXT");
|
|
225
|
+
sampledBytes += sample.length;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const text = decoder.decode(bytes, { stream: true });
|
|
229
|
+
if (text.length > 0) yield text;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
decoder.decode();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, "FS_NOT_TEXT", { cause: error });
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw mapError(error, "read", displayPath, signal);
|
|
241
|
+
}
|
|
242
|
+
} };
|
|
243
|
+
}
|
|
244
|
+
async listDir(target, signal) {
|
|
245
|
+
const info = await this.stat(target, signal);
|
|
246
|
+
if (info === void 0) throw new FsError(`cannot list "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
247
|
+
if (info.type !== "directory") throw new FsError(`cannot list "${target.displayPath}": not a directory`, "FS_NOT_DIRECTORY");
|
|
248
|
+
const { alias } = this.current();
|
|
249
|
+
try {
|
|
250
|
+
const listed = await this.engine.ls(alias, String(target.targetKey));
|
|
251
|
+
assertNotAborted(signal, "list");
|
|
252
|
+
const displayPaths = listed.map((entry) => posix.join(target.displayPath, entry.name));
|
|
253
|
+
const canonicalPaths = await this.engine.realpaths(alias, displayPaths);
|
|
254
|
+
const entries = [];
|
|
255
|
+
for (let i = 0; i < listed.length; i += 1) {
|
|
256
|
+
const entry = listed[i];
|
|
257
|
+
const displayPath = displayPaths[i];
|
|
258
|
+
const canonical = canonicalPaths[i];
|
|
259
|
+
const stats = this.asStats({
|
|
260
|
+
type: entry.type === "dir" ? "directory" : entry.type === "file" ? "file" : "other",
|
|
261
|
+
size: entry.size,
|
|
262
|
+
mtimeMs: entry.mtimeMs,
|
|
263
|
+
mode: entry.mode ?? 384
|
|
264
|
+
});
|
|
265
|
+
entries.push({
|
|
266
|
+
name: entry.name,
|
|
267
|
+
type: entryType(stats),
|
|
268
|
+
target: {
|
|
269
|
+
targetKey: FsTargetKey(canonical),
|
|
270
|
+
displayPath
|
|
271
|
+
},
|
|
272
|
+
version: entryVersion(stats, canonical),
|
|
273
|
+
...entry.type === "file" ? { size: entry.size } : {}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw mapError(error, "list", target.displayPath, signal);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async writeText(target, content, expected, signal) {
|
|
282
|
+
return this.withLock(String(target.targetKey), async () => {
|
|
283
|
+
const existing = await this.probe(String(target.targetKey), target.displayPath, signal);
|
|
284
|
+
if (existing !== void 0 && !existing.isFile()) throw new FsError(`cannot write "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
285
|
+
this.checkWriteIntent(existing, expected, target);
|
|
286
|
+
const before = existing === void 0 ? null : await this.readForDiff(target, signal);
|
|
287
|
+
const version = await this.writeAtomic(target, content, existing, expected?.kind === "createIfAbsent", signal);
|
|
288
|
+
return {
|
|
289
|
+
operation: existing === void 0 ? "create" : "update",
|
|
290
|
+
version,
|
|
291
|
+
before,
|
|
292
|
+
after: normalizeLineEndings(content)
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async editText(target, edit, expected, signal) {
|
|
297
|
+
return this.withLock(String(target.targetKey), async () => {
|
|
298
|
+
const existing = await this.probe(String(target.targetKey), target.displayPath, signal);
|
|
299
|
+
if (existing === void 0) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
300
|
+
if (!existing.isFile()) throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
301
|
+
if (expected !== void 0 && entryVersion(existing, String(target.targetKey)) !== expected.version) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
302
|
+
const raw = await this.readForEdit(target, signal);
|
|
303
|
+
const before = normalizeLineEndings(raw);
|
|
304
|
+
const after = literalEdit(before, edit, target.displayPath);
|
|
305
|
+
const storage = restoreLineEndings(after, detectsCrlf(raw));
|
|
306
|
+
return {
|
|
307
|
+
version: await this.writeAtomic(target, storage, existing, false, signal),
|
|
308
|
+
before,
|
|
309
|
+
after
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
async withLock(targetKey, operation) {
|
|
314
|
+
const run = (this.locks.get(targetKey) ?? Promise.resolve()).then(operation, operation);
|
|
315
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
316
|
+
this.locks.set(targetKey, tail);
|
|
317
|
+
try {
|
|
318
|
+
return await run;
|
|
319
|
+
} finally {
|
|
320
|
+
if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async canonicalPath(path, signal) {
|
|
324
|
+
const { alias } = this.current();
|
|
325
|
+
const result = await this.engine.exec(alias, `set -o pipefail; realpath -mz -- ${shellQuote(path)} | base64 -w0`, 1e4);
|
|
326
|
+
signal?.throwIfAborted();
|
|
327
|
+
if (!result.success || result.exitCode !== 0) throw new Error(result.stderr || `realpath failed for ${path}`);
|
|
328
|
+
return decodeCanonicalPath(result.stdout.trim());
|
|
329
|
+
}
|
|
330
|
+
async probe(path, displayPath, signal) {
|
|
331
|
+
assertNotAborted(signal, "stat");
|
|
332
|
+
const { alias } = this.current();
|
|
333
|
+
try {
|
|
334
|
+
const info = await this.engine.stat(alias, path);
|
|
335
|
+
assertNotAborted(signal, "stat");
|
|
336
|
+
return this.asStats({
|
|
337
|
+
type: info.type,
|
|
338
|
+
size: info.size,
|
|
339
|
+
mtimeMs: info.mtimeMs,
|
|
340
|
+
mode: info.mode
|
|
341
|
+
});
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (isNotFound(error)) return void 0;
|
|
344
|
+
throw mapError(error, "stat", displayPath, signal);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async requireRegular(target, signal) {
|
|
348
|
+
const info = await this.stat(target, signal);
|
|
349
|
+
if (info === void 0) throw new FsError(`cannot read "${target.displayPath}": not found`, "FS_NOT_FOUND");
|
|
350
|
+
if (info.type !== "file") throw new FsError(`cannot read "${target.displayPath}": not a regular file`, "FS_NOT_REGULAR_FILE");
|
|
351
|
+
return info;
|
|
352
|
+
}
|
|
353
|
+
async readBytesRaw(target, signal, maxBytes) {
|
|
354
|
+
const { alias } = this.current();
|
|
355
|
+
try {
|
|
356
|
+
const data = await this.engine.readFile(alias, String(target.targetKey));
|
|
357
|
+
assertNotAborted(signal, "read");
|
|
358
|
+
if (data.content.length > maxBytes) throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, "FS_TOO_LARGE");
|
|
359
|
+
return data.content;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw mapError(error, "read", target.displayPath, signal);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
checkWriteIntent(existing, expected, target) {
|
|
365
|
+
if (expected?.kind === "createIfAbsent" && existing !== void 0) throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
366
|
+
if (expected?.kind === "replaceIfVersion") {
|
|
367
|
+
if (existing === void 0 || entryVersion(existing, String(target.targetKey)) !== expected.version) throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, "FS_STALE_VERSION");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async readForDiff(target, signal) {
|
|
371
|
+
try {
|
|
372
|
+
return normalizeLineEndings(decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath));
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof FsError && error.code === "FS_NOT_TEXT") return null;
|
|
375
|
+
throw mapError(error, "read", target.displayPath, signal);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async readForEdit(target, signal) {
|
|
379
|
+
return decodeText(await this.readBytesRaw(target, signal, Number.POSITIVE_INFINITY), target.displayPath);
|
|
380
|
+
}
|
|
381
|
+
async writeAtomic(target, content, existing, createIfAbsent, signal) {
|
|
382
|
+
assertNotAborted(signal, "write");
|
|
383
|
+
const { alias } = this.current();
|
|
384
|
+
const targetPath = String(target.targetKey);
|
|
385
|
+
const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`);
|
|
386
|
+
const temporary = posix.join(stagingDirectory, "content");
|
|
387
|
+
let stagingCreated = false;
|
|
388
|
+
try {
|
|
389
|
+
await this.engine.mkdir(alias, stagingDirectory);
|
|
390
|
+
stagingCreated = true;
|
|
391
|
+
await this.engine.writeFile(alias, temporary, Buffer.from(content, "utf8"));
|
|
392
|
+
assertNotAborted(signal, "write");
|
|
393
|
+
const mode = existing === void 0 ? 384 : existing.mode & 511;
|
|
394
|
+
await this.engine.exec(alias, `chmod ${mode.toString(8)} -- ${shellQuote(temporary)}`, 1e4);
|
|
395
|
+
assertNotAborted(signal, "write");
|
|
396
|
+
if (createIfAbsent) {
|
|
397
|
+
const publication = await this.engine.exec(alias, `if ln -T -- ${shellQuote(temporary)} ${shellQuote(targetPath)}; then printf created; elif test -e ${shellQuote(targetPath)} || test -L ${shellQuote(targetPath)}; then printf exists; else exit 1; fi`, 1e4);
|
|
398
|
+
if (publication.stdout.trim() === "exists") throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, "FS_NOT_OBSERVED");
|
|
399
|
+
if (publication.stdout.trim() !== "created") throw new Error("guarded create returned an invalid publication result");
|
|
400
|
+
} else await this.engine.rename(alias, temporary, targetPath);
|
|
401
|
+
assertNotAborted(signal, "write");
|
|
402
|
+
await this.removeStaging(stagingDirectory);
|
|
403
|
+
const committed = await this.probe(targetPath, target.displayPath, signal);
|
|
404
|
+
if (committed === void 0) throw new FsError(`cannot write "${target.displayPath}": commit produced no file`, "FS_IO_ERROR");
|
|
405
|
+
return entryVersion(committed, targetPath);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (stagingCreated) await this.removeStaging(stagingDirectory);
|
|
408
|
+
throw mapError(error, "write", target.displayPath, signal);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async removeStaging(directory) {
|
|
412
|
+
const { alias } = this.current();
|
|
413
|
+
try {
|
|
414
|
+
await this.engine.rm(alias, directory, true);
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
/** Normalize an engine stat/ls shape into the RemoteStats the helpers expect. */
|
|
418
|
+
asStats(info) {
|
|
419
|
+
const isDir = info.type === "dir" || info.type === "directory";
|
|
420
|
+
const isFile = info.type === "file";
|
|
421
|
+
return {
|
|
422
|
+
isFile: () => isFile,
|
|
423
|
+
isDirectory: () => isDir,
|
|
424
|
+
isSymbolicLink: () => info.type === "symlink",
|
|
425
|
+
size: info.size,
|
|
426
|
+
mtime: Math.round(info.mtimeMs / 1e3),
|
|
427
|
+
mode: info.mode
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/fs.ts
|
|
433
|
+
/** Stable cordis plugin name. */
|
|
434
|
+
const name = "hardssh-fs";
|
|
435
|
+
/** Services required: the shared workspace core (mode store + engine) and the
|
|
436
|
+
* sandbox policy the local backend (`SandboxedFileSystem`) consumes. */
|
|
437
|
+
const inject = ["hardsshCore", "sandboxPolicy"];
|
|
438
|
+
/** A remote backend fixed to one SSH workspace's alias + remote root. */
|
|
439
|
+
function fixedRemoteState(alias, remoteRoot) {
|
|
440
|
+
return () => ({
|
|
441
|
+
mode: "remote",
|
|
442
|
+
alias,
|
|
443
|
+
remoteRoot
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/** Mount the switching filesystem facade. */
|
|
447
|
+
function apply(ctx) {
|
|
448
|
+
const core = ctx.hardsshCore;
|
|
449
|
+
const localCtx = ctx.isolate("fs");
|
|
450
|
+
const localFs = new SandboxedFileSystem(localCtx, {
|
|
451
|
+
cwd: process.env.DSH_CWD ?? process.cwd(),
|
|
452
|
+
diffBasisMaxBytes: 10485760
|
|
453
|
+
});
|
|
454
|
+
core.seams.bindFs((record) => ({
|
|
455
|
+
backend: new SshFileSystem(ctx.isolate("fs"), core.engine, fixedRemoteState(record.alias, record.remoteRoot)),
|
|
456
|
+
namespace: `${WFS_NAMESPACE_MARKER}${record.id.toLowerCase()}/`,
|
|
457
|
+
anchorPath: record.anchorPath,
|
|
458
|
+
remoteRoot: record.remoteRoot
|
|
459
|
+
}));
|
|
460
|
+
const anchorRoot = core.ledger.anchorsRoot();
|
|
461
|
+
let warnedUnready = false;
|
|
462
|
+
new SwitchFileSystem(ctx, {
|
|
463
|
+
local: localFs,
|
|
464
|
+
worldFor: (cwd) => {
|
|
465
|
+
if (!core.seams.isReady() && !warnedUnready && cwd !== void 0 && isPathUnderAnchor(anchorRoot, cwd)) {
|
|
466
|
+
warnedUnready = true;
|
|
467
|
+
console.warn("[dsh-hardssh] fs routing is not ready yet (workspace ledger still loading) — operating on the local anchor until the snapshot is applied");
|
|
468
|
+
}
|
|
469
|
+
return core.seams.worldForFs(cwd) ?? {
|
|
470
|
+
backend: localFs,
|
|
471
|
+
namespace: ""
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
worldForNamespace: (namespace) => core.seams.worldForFsNamespace(namespace)
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
export { apply, inject, name };
|