@tiphareth/dsh-hardssh 0.1.2 → 0.2.3
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/LICENSE +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/ledger.ts
CHANGED
|
@@ -1,416 +1,62 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSH-bound workspace
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
*/
|
|
64
|
-
export function normalizeRemoteRoot(raw: string): string {
|
|
65
|
-
if (raw.includes('\0')) {
|
|
66
|
-
throw new Error(`remoteRoot must not contain NUL (got '${raw}')`)
|
|
67
|
-
}
|
|
68
|
-
const normalized = posix.normalize(raw.trim())
|
|
69
|
-
if (!normalized.startsWith('/')) {
|
|
70
|
-
throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`)
|
|
71
|
-
}
|
|
72
|
-
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
|
|
76
|
-
* ordinary host workspaces (and thus selectable for sessions). */
|
|
77
|
-
export function anchorRoot(): string {
|
|
78
|
-
return join(homedir(), '.dsh', 'ssh-workspaces')
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** The anchor directory for one id. */
|
|
82
|
-
export function anchorPathFor(id: string): string {
|
|
83
|
-
return join(anchorRoot(), id)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Default record title when the user gives none. */
|
|
87
|
-
export function defaultTitle(remoteRoot: string, alias: string): string {
|
|
88
|
-
return `${alias}:${remoteRoot.split('/').filter(Boolean).pop() ?? remoteRoot}`
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Normalize a local anchor path for comparison: Windows anchors are
|
|
93
|
-
* case-insensitive with mixed separators; POSIX anchors stay case-sensitive.
|
|
94
|
-
*/
|
|
95
|
-
export function normalizeAnchorPath(path: string): string {
|
|
96
|
-
const windowsStyle = isWindowsAnchor(path)
|
|
97
|
-
if (windowsStyle) {
|
|
98
|
-
const normalized = path.replace(/\//g, '\\')
|
|
99
|
-
const rootLength = windowsRootLength(normalized)
|
|
100
|
-
return trimTrailingSeparators(normalized, rootLength).toLowerCase()
|
|
101
|
-
}
|
|
102
|
-
const rootLength = path.startsWith('/') ? 1 : 0
|
|
103
|
-
return trimTrailingSeparators(path, rootLength)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** True when candidate equals anchor or is one of its descendants. Lexical
|
|
107
|
-
* comparison over already-resolved paths; deliberately no fs access. */
|
|
108
|
-
export function isPathUnderAnchor(anchor: string, candidate: string): boolean {
|
|
109
|
-
const windowsStyle = isWindowsAnchor(anchor)
|
|
110
|
-
const normalizedAnchor = normalizeAnchorPath(anchor)
|
|
111
|
-
|
|
112
|
-
let normalizedCandidate: string
|
|
113
|
-
if (windowsStyle) {
|
|
114
|
-
const withWindowsSeparators = candidate.replace(/\//g, '\\')
|
|
115
|
-
const rootLength = windowsRootLength(withWindowsSeparators)
|
|
116
|
-
normalizedCandidate = trimTrailingSeparators(withWindowsSeparators, rootLength).toLowerCase()
|
|
117
|
-
} else {
|
|
118
|
-
const rootLength = candidate.startsWith('/') ? 1 : 0
|
|
119
|
-
normalizedCandidate = trimTrailingSeparators(candidate, rootLength)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (normalizedCandidate === normalizedAnchor) return true
|
|
123
|
-
|
|
124
|
-
const separator = windowsStyle ? '\\' : '/'
|
|
125
|
-
const descendantPrefix = normalizedAnchor.endsWith(separator)
|
|
126
|
-
? normalizedAnchor
|
|
127
|
-
: `${normalizedAnchor}${separator}`
|
|
128
|
-
return normalizedCandidate.startsWith(descendantPrefix)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The in-memory ledger. Loaded lazily on first access; every mutation writes
|
|
133
|
-
* through durably (atomic rename) and is serialized through a per-instance
|
|
134
|
-
* queue. A synchronous anchor index is maintained from the latest committed
|
|
135
|
-
* records so host seams can resolve a cwd/root without awaiting I/O.
|
|
136
|
-
*/
|
|
137
|
-
export class SshWorkspaceLedger {
|
|
138
|
-
private records: SshWorkspaceRecord[] | undefined
|
|
139
|
-
private loadPromise: Promise<void> | undefined
|
|
140
|
-
private anchorIndex: Array<{ anchor: string; record: SshWorkspaceRecord }> = []
|
|
141
|
-
private mutationTail: Promise<void> = Promise.resolve()
|
|
142
|
-
private currentRevision = 0
|
|
143
|
-
private readonly listeners = new Set<LedgerListener>()
|
|
144
|
-
|
|
145
|
-
/** Overridable file location (tests isolate per instance). */
|
|
146
|
-
constructor(private readonly fileOverride?: string, private readonly anchorOverride?: string) {}
|
|
147
|
-
|
|
148
|
-
private file(): string {
|
|
149
|
-
return this.fileOverride ?? ledgerPath()
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
private anchors(): string {
|
|
153
|
-
return this.anchorOverride ?? anchorRoot()
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private anchorFor(id: string): string {
|
|
157
|
-
return join(this.anchors(), id)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Load the ledger once (missing/unreadable/malformed file -> empty ledger). */
|
|
161
|
-
private async ensureLoaded(): Promise<void> {
|
|
162
|
-
if (this.records !== undefined) return
|
|
163
|
-
if (this.loadPromise === undefined) {
|
|
164
|
-
this.loadPromise = this.readRecords()
|
|
165
|
-
}
|
|
166
|
-
await this.loadPromise
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async readRecords(): Promise<void> {
|
|
170
|
-
try {
|
|
171
|
-
const text = await readFile(this.file(), 'utf8')
|
|
172
|
-
const parsed = JSON.parse(text) as unknown
|
|
173
|
-
this.records = Array.isArray(parsed) ? parsed.filter(isRecord).map(cloneRecord) : []
|
|
174
|
-
} catch {
|
|
175
|
-
this.records = []
|
|
176
|
-
}
|
|
177
|
-
this.reindex()
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Rebuild the synchronous anchor index from the current records. */
|
|
181
|
-
private reindex(): void {
|
|
182
|
-
this.anchorIndex = (this.records ?? [])
|
|
183
|
-
.map((record) => ({ anchor: normalizeAnchorPath(record.anchorPath), record }))
|
|
184
|
-
.sort((a, b) => b.anchor.length - a.anchor.length) // longest prefix first
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Persist a proposed record array without touching observable state. */
|
|
188
|
-
private async save(nextRecords: readonly SshWorkspaceRecord[]): Promise<void> {
|
|
189
|
-
const target = this.file()
|
|
190
|
-
await mkdir(join(target, '..'), { recursive: true })
|
|
191
|
-
const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
|
192
|
-
try {
|
|
193
|
-
await writeFile(temporary, JSON.stringify(nextRecords, null, 2), 'utf8')
|
|
194
|
-
await renameFile(temporary, target)
|
|
195
|
-
} catch (error) {
|
|
196
|
-
await rm(temporary, { force: true }).catch(() => undefined)
|
|
197
|
-
throw error
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Serialize one mutation; a rejected earlier mutation never poisons later ones. */
|
|
202
|
-
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
203
|
-
const result = this.mutationTail.then(operation)
|
|
204
|
-
this.mutationTail = result.then(() => undefined, () => undefined)
|
|
205
|
-
return result
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Commit a computed next state: swap memory, bump revision, notify. */
|
|
209
|
-
private commit(nextRecords: SshWorkspaceRecord[], change: LedgerChangeWithoutRevision): void {
|
|
210
|
-
this.records = nextRecords
|
|
211
|
-
this.reindex()
|
|
212
|
-
this.currentRevision += 1
|
|
213
|
-
this.emit({ ...change, revision: this.currentRevision } as LedgerChange)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private emit(change: LedgerChange): void {
|
|
217
|
-
for (const listener of [...this.listeners]) {
|
|
218
|
-
try {
|
|
219
|
-
listener(cloneChange(change))
|
|
220
|
-
} catch (error) {
|
|
221
|
-
// Listener failures must never reject an already-committed mutation.
|
|
222
|
-
console.warn('[dsh-hardssh] ledger subscriber failed:', error instanceof Error ? error.message : String(error))
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Load the ledger (compat; returns detached copies). */
|
|
228
|
-
async load(): Promise<SshWorkspaceRecord[]> {
|
|
229
|
-
await this.ensureLoaded()
|
|
230
|
-
return cloneRecords(this.records ?? [])
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** All records, in creation order (detached copies). */
|
|
234
|
-
async list(): Promise<SshWorkspaceRecord[]> {
|
|
235
|
-
await this.ensureLoaded()
|
|
236
|
-
return cloneRecords(this.records ?? [])
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Current detached snapshot. */
|
|
240
|
-
async snapshot(): Promise<LedgerSnapshot> {
|
|
241
|
-
await this.ensureLoaded()
|
|
242
|
-
return { revision: this.currentRevision, records: cloneRecords(this.records ?? []) }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Current in-process snapshot, synchronously (empty before first load).
|
|
246
|
-
* The returned records are the live immutable objects — callers must not
|
|
247
|
-
* mutate them (commits always replace the array wholesale). */
|
|
248
|
-
snapshotSync(): LedgerSnapshot {
|
|
249
|
-
return { revision: this.currentRevision, records: this.records ?? [] }
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Current in-process revision (restarts at 0 on process restart). */
|
|
253
|
-
revision(): number {
|
|
254
|
-
return this.currentRevision
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Subscribe to committed mutations (no initial event). Returns a disposer. */
|
|
258
|
-
subscribe(listener: LedgerListener): () => void {
|
|
259
|
-
this.listeners.add(listener)
|
|
260
|
-
let disposed = false
|
|
261
|
-
return () => {
|
|
262
|
-
if (disposed) return
|
|
263
|
-
disposed = true
|
|
264
|
-
this.listeners.delete(listener)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Look up by id. */
|
|
269
|
-
async get(id: string): Promise<SshWorkspaceRecord | undefined> {
|
|
270
|
-
await this.ensureLoaded()
|
|
271
|
-
const record = this.records?.find((candidate) => candidate.id === id)
|
|
272
|
-
return record === undefined ? undefined : cloneRecord(record)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Look up by LOCAL anchor path (resolved). Returns the record whose
|
|
276
|
-
* anchor owns the path (the anchor itself or any descendant). */
|
|
277
|
-
async findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined> {
|
|
278
|
-
await this.ensureLoaded()
|
|
279
|
-
return this.findByAnchorSync(path)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Synchronous anchor lookup (uses the in-memory index). */
|
|
283
|
-
findByAnchorSync(path: string): SshWorkspaceRecord | undefined {
|
|
284
|
-
const canonical = safeRealpathSync(path)
|
|
285
|
-
if (canonical === undefined) return undefined
|
|
286
|
-
for (const { anchor, record } of this.anchorIndex) {
|
|
287
|
-
if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record)
|
|
288
|
-
}
|
|
289
|
-
return undefined
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Create a record: materialize the anchor, then persist+commit. */
|
|
293
|
-
async create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord> {
|
|
294
|
-
return this.enqueueMutation(async () => {
|
|
295
|
-
await this.ensureLoaded()
|
|
296
|
-
const id = randomUUID()
|
|
297
|
-
const remoteRoot = normalizeRemoteRoot(input.remoteRoot)
|
|
298
|
-
const record: SshWorkspaceRecord = {
|
|
299
|
-
id,
|
|
300
|
-
title: input.title.trim() === '' ? defaultTitle(remoteRoot, input.alias) : input.title.trim(),
|
|
301
|
-
alias: input.alias,
|
|
302
|
-
remoteRoot,
|
|
303
|
-
anchorPath: this.anchorFor(id),
|
|
304
|
-
createdAt: new Date().toISOString(),
|
|
305
|
-
}
|
|
306
|
-
// Materialize before publishing a binding that points at the anchor;
|
|
307
|
-
// a persistence failure leaves an unreferenced dir but no snapshot change.
|
|
308
|
-
await mkdir(record.anchorPath, { recursive: true })
|
|
309
|
-
const nextRecords = [...(this.records ?? []), record]
|
|
310
|
-
await this.save(nextRecords)
|
|
311
|
-
this.commit(nextRecords, { type: 'created', record })
|
|
312
|
-
return cloneRecord(record)
|
|
313
|
-
})
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** Rename a record (display title only). */
|
|
317
|
-
async rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined> {
|
|
318
|
-
return this.enqueueMutation(async () => {
|
|
319
|
-
await this.ensureLoaded()
|
|
320
|
-
const records = this.records ?? []
|
|
321
|
-
const index = records.findIndex((record) => record.id === id)
|
|
322
|
-
if (index < 0) return undefined
|
|
323
|
-
const before = records[index]
|
|
324
|
-
const record: SshWorkspaceRecord = { ...before, title: title.trim() }
|
|
325
|
-
const nextRecords = [...records]
|
|
326
|
-
nextRecords[index] = record
|
|
327
|
-
await this.save(nextRecords)
|
|
328
|
-
this.commit(nextRecords, { type: 'renamed', before, record })
|
|
329
|
-
return cloneRecord(record)
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Remove a record. The anchor directory is left in place (a host
|
|
334
|
-
* workspace may still reference it; the caller decides on deletion). */
|
|
335
|
-
async remove(id: string): Promise<boolean> {
|
|
336
|
-
return this.enqueueMutation(async () => {
|
|
337
|
-
await this.ensureLoaded()
|
|
338
|
-
const records = this.records ?? []
|
|
339
|
-
const index = records.findIndex((record) => record.id === id)
|
|
340
|
-
if (index < 0) return false
|
|
341
|
-
const record = records[index]
|
|
342
|
-
const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)]
|
|
343
|
-
await this.save(nextRecords)
|
|
344
|
-
this.commit(nextRecords, { type: 'removed', record })
|
|
345
|
-
return true
|
|
346
|
-
})
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** The anchor root shared by every record (for UI hints). */
|
|
350
|
-
anchorsRoot(): string {
|
|
351
|
-
return this.anchors()
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/** Guard: a parsed JSON value is a valid record. */
|
|
356
|
-
function isRecord(value: unknown): value is SshWorkspaceRecord {
|
|
357
|
-
if (typeof value !== 'object' || value === null) return false
|
|
358
|
-
const record = value as Record<string, unknown>
|
|
359
|
-
return typeof record.id === 'string'
|
|
360
|
-
&& typeof record.title === 'string'
|
|
361
|
-
&& typeof record.alias === 'string'
|
|
362
|
-
&& typeof record.remoteRoot === 'string'
|
|
363
|
-
&& typeof record.anchorPath === 'string'
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Synchronous realpath, swallowing errors (a session cwd can vanish mid-flight). */
|
|
367
|
-
function safeRealpathSync(path: string): string | undefined {
|
|
368
|
-
try {
|
|
369
|
-
return realpathSync(path)
|
|
370
|
-
} catch {
|
|
371
|
-
return undefined
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function isWindowsAnchor(path: string): boolean {
|
|
376
|
-
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\') || path.includes('\\')
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function windowsRootLength(path: string): number {
|
|
380
|
-
if (/^[a-zA-Z]:\\/.test(path)) return 3
|
|
381
|
-
if (path.startsWith('\\\\')) {
|
|
382
|
-
const segments = path.slice(2).split('\\')
|
|
383
|
-
if (segments.length >= 2 && segments[0] !== '' && segments[1] !== '') {
|
|
384
|
-
return 2 + segments[0].length + 1 + segments[1].length
|
|
385
|
-
}
|
|
386
|
-
return 2
|
|
387
|
-
}
|
|
388
|
-
return 0
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function trimTrailingSeparators(path: string, minimumLength: number): string {
|
|
392
|
-
let end = path.length
|
|
393
|
-
while (end > minimumLength && (path[end - 1] === '/' || path[end - 1] === '\\')) {
|
|
394
|
-
end -= 1
|
|
395
|
-
}
|
|
396
|
-
return path.slice(0, end)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function cloneRecord(record: SshWorkspaceRecord): SshWorkspaceRecord {
|
|
400
|
-
return { ...record }
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function cloneRecords(records: readonly SshWorkspaceRecord[]): SshWorkspaceRecord[] {
|
|
404
|
-
return records.map(cloneRecord)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function cloneChange(change: LedgerChange): LedgerChange {
|
|
408
|
-
switch (change.type) {
|
|
409
|
-
case 'created':
|
|
410
|
-
return { ...change, record: cloneRecord(change.record) }
|
|
411
|
-
case 'renamed':
|
|
412
|
-
return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
|
|
413
|
-
case 'removed':
|
|
414
|
-
return { ...change, record: cloneRecord(change.record) }
|
|
415
|
-
}
|
|
416
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* SSH-bound workspace path helpers.
|
|
3
|
+
*
|
|
4
|
+
* This module is the last remaining piece of the retired SSH ledger module:
|
|
5
|
+
* the `SshWorkspaceLedger` class was removed with the generic cutover, so what
|
|
6
|
+
* survives here are the anchor/path helpers the generic runtime still shares —
|
|
7
|
+
* the managed SSH anchor layout (`~/.dsh/ssh-workspaces/<id>`), the legacy
|
|
8
|
+
* ledger file location (the one-time import source), the client DTO title
|
|
9
|
+
* default, and the lexical anchor-containment helpers used by fs/subprocess
|
|
10
|
+
* fail-closed gating. Record persistence itself is owned by the generic
|
|
11
|
+
* `WorkspaceLedger` (`./base/ledger.ts`).
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-hardssh/ledger
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join, posix } from 'node:path'
|
|
18
|
+
|
|
19
|
+
/** The frozen legacy SSH ledger location: ~/.dsh/dsh-hardssh-workspaces.json.
|
|
20
|
+
* Only read once by the startup import when no cutover marker exists. */
|
|
21
|
+
export function ledgerPath(): string {
|
|
22
|
+
return join(homedir(), '.dsh', 'dsh-hardssh-workspaces.json')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a remote POSIX root: keep '/' as '/', collapse repeated
|
|
27
|
+
* separators and dot segments, reject relative paths and NUL.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeRemoteRoot(raw: string): string {
|
|
30
|
+
if (raw.includes('\0')) {
|
|
31
|
+
throw new Error(`remoteRoot must not contain NUL (got '${raw}')`)
|
|
32
|
+
}
|
|
33
|
+
const normalized = posix.normalize(raw.trim())
|
|
34
|
+
if (!normalized.startsWith('/')) {
|
|
35
|
+
throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`)
|
|
36
|
+
}
|
|
37
|
+
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
|
|
41
|
+
* ordinary host workspaces (and thus selectable for sessions). */
|
|
42
|
+
export function anchorRoot(): string {
|
|
43
|
+
return join(homedir(), '.dsh', 'ssh-workspaces')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The anchor directory for one id. */
|
|
47
|
+
export function anchorPathFor(id: string): string {
|
|
48
|
+
return join(anchorRoot(), id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Default record title when the user gives none. */
|
|
52
|
+
export function defaultTitle(remoteRoot: string, alias: string): string {
|
|
53
|
+
return `${alias}:${remoteRoot.split('/').filter(Boolean).pop() ?? remoteRoot}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Anchor-path comparison is owned by ONE implementation (`base/ledger.ts`):
|
|
58
|
+
* two copies had already drifted in their Windows/UNC root handling, and the
|
|
59
|
+
* seams, the store, and the client all compare anchors. Re-exported here so
|
|
60
|
+
* existing `from './ledger.ts'` imports keep working.
|
|
61
|
+
*/
|
|
62
|
+
export { isPathUnderAnchor, normalizeAnchorPath } from './base/ledger.ts'
|
package/src/protocol.ts
CHANGED
|
@@ -73,9 +73,6 @@ export const WORKSPACE_API_BASE = '/api/dsh-hardssh' as const
|
|
|
73
73
|
|
|
74
74
|
export const WORKSPACE_API = {
|
|
75
75
|
state: WORKSPACE_API_BASE + '/state',
|
|
76
|
-
tree: WORKSPACE_API_BASE + '/tree',
|
|
77
|
-
file: WORKSPACE_API_BASE + '/file',
|
|
78
|
-
search: WORKSPACE_API_BASE + '/search',
|
|
79
76
|
sshWorkspaces: WORKSPACE_API_BASE + '/ws',
|
|
80
77
|
sshWorkspaceDir: WORKSPACE_API_BASE + '/ws/dir',
|
|
81
78
|
} as const
|
|
@@ -102,9 +99,6 @@ export interface SshWorkspaceRecord {
|
|
|
102
99
|
createdAt: string
|
|
103
100
|
}
|
|
104
101
|
|
|
105
|
-
/** The ledger of SSH-bound workspaces (persisted by the host). */
|
|
106
|
-
export type SshWorkspaceLedger = SshWorkspaceRecord[]
|
|
107
|
-
|
|
108
102
|
/** One remote directory entry for the picker tree. */
|
|
109
103
|
export interface RemoteDirEntry {
|
|
110
104
|
name: string
|
package/src/providers/index.ts
CHANGED
|
@@ -10,21 +10,25 @@ import type { WorkspaceProvider } from '../base/model.ts'
|
|
|
10
10
|
import type { WorkspaceProviderRegistry } from '../base/registry.ts'
|
|
11
11
|
import type { SshEngine } from '../ssh/engine.ts'
|
|
12
12
|
import type { HostStoreView } from '../core.ts'
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
14
|
import { createSshWorkspaceProvider } from './ssh/provider.ts'
|
|
14
15
|
import { createLocalWorkspaceProvider } from './local/provider.ts'
|
|
15
16
|
|
|
16
|
-
/** Register both built-in providers. Returns per-provider disposers.
|
|
17
|
+
/** Register both built-in providers. Returns per-provider disposers. The
|
|
18
|
+
* Cordis context is mandatory: both providers serve only the real DSH
|
|
19
|
+
* fs/process capabilities, which need a per-workspace Cordis scope. */
|
|
17
20
|
export function registerBuiltinProviders(
|
|
18
21
|
registry: WorkspaceProviderRegistry,
|
|
19
|
-
deps: { engine?: SshEngine; hosts?: HostStoreView }
|
|
22
|
+
deps: { engine?: SshEngine; hosts?: HostStoreView },
|
|
23
|
+
context: Context,
|
|
20
24
|
): Array<() => void> {
|
|
21
25
|
const disposers: Array<() => void> = []
|
|
22
26
|
// Local first: always available, no external state.
|
|
23
|
-
disposers.push(registry.register(createLocalWorkspaceProvider()))
|
|
27
|
+
disposers.push(registry.register(createLocalWorkspaceProvider(context)))
|
|
24
28
|
// SSH requires the engine; when absent (headless / partial load) it is
|
|
25
29
|
// simply not registered — the base keeps working for local workspaces.
|
|
26
30
|
if (deps.engine !== undefined) {
|
|
27
|
-
disposers.push(registry.register(createSshWorkspaceProvider(deps.engine)))
|
|
31
|
+
disposers.push(registry.register(createSshWorkspaceProvider(deps.engine, context)))
|
|
28
32
|
}
|
|
29
33
|
return disposers
|
|
30
34
|
}
|