@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
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 +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -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 +18 -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 +36 -11
- 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 -99
- 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 +137 -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 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/backend.ts
CHANGED
|
@@ -1,51 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The generic record source the SSH workspace API projects.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* `GenericWorkspaceStore` is the single record source: it projects the generic
|
|
5
|
+
* `WorkspaceLedger` (via `WorkspaceCore`) into the client SSH DTO and performs
|
|
6
|
+
* workspace CRUD through the generic core. There is no implicit local fallback
|
|
7
|
+
* and no dependency on any retired global SSH mode state.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
readdir,
|
|
13
|
-
readFile,
|
|
14
|
-
realpath,
|
|
15
|
-
stat,
|
|
16
|
-
writeFile,
|
|
17
|
-
} from 'node:fs/promises'
|
|
18
|
-
import {
|
|
19
|
-
dirname,
|
|
20
|
-
isAbsolute,
|
|
21
|
-
join,
|
|
22
|
-
relative,
|
|
23
|
-
resolve,
|
|
24
|
-
sep,
|
|
25
|
-
} from 'node:path'
|
|
10
|
+
import { realpathSync } from 'node:fs'
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
import { join } from 'node:path'
|
|
26
13
|
import { posix } from 'node:path'
|
|
27
|
-
import
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import type {
|
|
14
|
+
import { defaultTitle, normalizeRemoteRoot as normalizeSshRemoteRoot } from './ledger.ts'
|
|
15
|
+
import { isPathUnderAnchor as baseIsPathUnderAnchor, normalizeAnchorPath as baseNormalizeAnchorPath, WORKSPACE_LEDGER_SCHEMA_VERSION } from './base/ledger.ts'
|
|
16
|
+
import type { WorkspaceRecord } from './base/model.ts'
|
|
17
|
+
import type { WorkspaceCore } from './runtime/workspace-core.ts'
|
|
31
18
|
import type {
|
|
32
|
-
DirListing,
|
|
33
|
-
FileRead,
|
|
34
|
-
FileWriteResult,
|
|
35
|
-
SearchHit,
|
|
36
|
-
SearchView,
|
|
37
19
|
SshWorkspaceRecord,
|
|
38
20
|
WorkspaceEntry,
|
|
39
21
|
} from './protocol.ts'
|
|
40
22
|
|
|
41
|
-
export const SEARCH_HIT_CAP = 200
|
|
42
|
-
export const SEARCH_SCAN_CAP = 20_000
|
|
43
|
-
export const SEARCH_MAX_DEPTH = 4
|
|
44
|
-
export const REMOTE_SEARCH_TIMEOUT_MS = 20_000
|
|
45
|
-
|
|
46
|
-
const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules'])
|
|
47
|
-
const TREE_SKIP_DIRS = new Set(['.git'])
|
|
48
|
-
|
|
49
23
|
export type BackendErrorCode =
|
|
50
24
|
| 'binary'
|
|
51
25
|
| 'too-large'
|
|
@@ -69,38 +43,51 @@ export class BackendError extends Error {
|
|
|
69
43
|
}
|
|
70
44
|
}
|
|
71
45
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
)
|
|
90
|
-
|
|
46
|
+
/**
|
|
47
|
+
* The SSH-workspace record source the workspace routes, agent tools, and the
|
|
48
|
+
* SSH host-delete reference guard satisfy. Every consumer depends on this
|
|
49
|
+
* narrow view, so none of them knows which ledger implementation is
|
|
50
|
+
* authoritative.
|
|
51
|
+
*/
|
|
52
|
+
export interface WorkspaceStoreView {
|
|
53
|
+
/** Every SSH-bound workspace as the client DTO. */
|
|
54
|
+
list(): Promise<SshWorkspaceRecord[]>
|
|
55
|
+
get(id: string): Promise<SshWorkspaceRecord | undefined>
|
|
56
|
+
findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined>
|
|
57
|
+
/** Synchronous anchor lookup used by the session announcement. */
|
|
58
|
+
findByAnchorSync(path: string): SshWorkspaceRecord | undefined
|
|
59
|
+
/** Create an SSH workspace (title/alias/remoteRoot); the anchor is managed by the store. */
|
|
60
|
+
create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord>
|
|
61
|
+
rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined>
|
|
62
|
+
remove(id: string): Promise<boolean>
|
|
63
|
+
/** The shared anchor root (fail-closed gating before the runtime is ready). */
|
|
64
|
+
anchorsRoot(): string
|
|
91
65
|
}
|
|
92
66
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Project one generic SSH `WorkspaceRecord` into the client `SshWorkspaceRecord`
|
|
69
|
+
* DTO (id/title/alias/remoteRoot/anchorPath/createdAt). The wire contract is
|
|
70
|
+
* preserved server-side; no client or protocol change accompanies the cutover.
|
|
71
|
+
* A generic ledger hands out provider-agnostic WorkspaceRecord values, so the
|
|
72
|
+
* projection cannot reuse any legacy accessor — it exists once for the generic
|
|
73
|
+
* record source.
|
|
74
|
+
*/
|
|
75
|
+
function toSshWorkspaceDto(record: WorkspaceRecord): SshWorkspaceRecord {
|
|
76
|
+
// A legacy alias maps 1:1 to provider.connectionRef.{id,alias} (migration
|
|
77
|
+
// keeps both equal); prefer the display alias, falling back to the ref id.
|
|
78
|
+
const alias = record.provider.connectionRef?.alias ?? record.provider.connectionRef?.id ?? ''
|
|
79
|
+
// Every SSH record created through this plugin carries a managed anchor; a
|
|
80
|
+
// hand-made record without one projects an empty anchor (never throws, so
|
|
81
|
+
// stale sidebar lists keep rendering) and simply never binds a session.
|
|
82
|
+
const anchorPath = record.anchor?.path ?? ''
|
|
83
|
+
return {
|
|
84
|
+
id: record.id,
|
|
85
|
+
title: record.title,
|
|
86
|
+
alias,
|
|
87
|
+
remoteRoot: record.location.root,
|
|
88
|
+
anchorPath,
|
|
89
|
+
createdAt: record.createdAt,
|
|
90
|
+
}
|
|
104
91
|
}
|
|
105
92
|
|
|
106
93
|
/**
|
|
@@ -151,9 +138,7 @@ export function relToAbs(root: string, rel: string): string {
|
|
|
151
138
|
return abs
|
|
152
139
|
}
|
|
153
140
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
/** Stable dir-first, case-insensitive ordering shared by every backend. */
|
|
141
|
+
/** Stable dir-first, case-insensitive ordering shared by every listing. */
|
|
157
142
|
export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[] {
|
|
158
143
|
return [...entries].sort((a, b) => {
|
|
159
144
|
const aDir = a.type === 'dir'
|
|
@@ -164,60 +149,6 @@ export function sortWorkspaceEntries(entries: WorkspaceEntry[]): WorkspaceEntry[
|
|
|
164
149
|
})
|
|
165
150
|
}
|
|
166
151
|
|
|
167
|
-
export type WorkspacePathFlavor = 'local' | 'remote'
|
|
168
|
-
|
|
169
|
-
/** Return abs relative to root, rejecting prefix-collision/parent traversal. */
|
|
170
|
-
export function relativeWorkspacePath(
|
|
171
|
-
root: string,
|
|
172
|
-
abs: string,
|
|
173
|
-
flavor: WorkspacePathFlavor,
|
|
174
|
-
): string {
|
|
175
|
-
if (flavor === 'remote') {
|
|
176
|
-
const normalizedRoot = normalizeRemoteRoot(root)
|
|
177
|
-
const normalizedAbs = posix.normalize(abs)
|
|
178
|
-
if (!isInside(normalizedRoot, normalizedAbs)) {
|
|
179
|
-
throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
|
|
180
|
-
}
|
|
181
|
-
const rel = posix.relative(normalizedRoot, normalizedAbs)
|
|
182
|
-
return rel === '' ? '' : normalizeRel(rel)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const normalizedRoot = resolve(root)
|
|
186
|
-
const normalizedAbs = resolve(abs)
|
|
187
|
-
const rel = relative(normalizedRoot, normalizedAbs)
|
|
188
|
-
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
189
|
-
throw new BackendError('outside-root', `path '${abs}' is outside root '${root}'`)
|
|
190
|
-
}
|
|
191
|
-
return rel === '' ? '' : rel.split(sep).join('/')
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Normalize filesystem/SFTP errors to stable route-visible codes. */
|
|
195
|
-
export function toBackendIoError(error: unknown, path: string): BackendError {
|
|
196
|
-
if (error instanceof BackendError) return error
|
|
197
|
-
const errno = error as NodeJS.ErrnoException
|
|
198
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
199
|
-
const lower = message.toLowerCase()
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
errno.code === 'ENOENT'
|
|
203
|
-
|| errno.code === 'ENOTDIR'
|
|
204
|
-
|| /\bno such file\b/.test(lower)
|
|
205
|
-
|| /\bnot found\b/.test(lower)
|
|
206
|
-
) {
|
|
207
|
-
return new BackendError('not-found', `'${path}' was not found`, { cause: error })
|
|
208
|
-
}
|
|
209
|
-
if (
|
|
210
|
-
errno.code === 'EACCES'
|
|
211
|
-
|| errno.code === 'EPERM'
|
|
212
|
-
|| /\bpermission denied\b/.test(lower)
|
|
213
|
-
) {
|
|
214
|
-
return new BackendError('forbidden', `permission denied for '${path}'`, { cause: error })
|
|
215
|
-
}
|
|
216
|
-
if (/\bmtime conflict\b/.test(lower) || /\bconflict\b/.test(lower)) {
|
|
217
|
-
return new BackendError('conflict', message, { cause: error })
|
|
218
|
-
}
|
|
219
|
-
return new BackendError('io', `'${path}': ${message}`, { cause: error })
|
|
220
|
-
}
|
|
221
152
|
|
|
222
153
|
/** Map a BackendError to its stable HTTP status (routes use this). */
|
|
223
154
|
export function backendErrorStatus(error: BackendError, ioStatus: 500 | 502): number {
|
|
@@ -253,372 +184,113 @@ function normalizeRemoteRoot(root: string): string {
|
|
|
253
184
|
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
|
|
254
185
|
}
|
|
255
186
|
|
|
256
|
-
function decodeText(buffer: Buffer, path: string): string {
|
|
257
|
-
const probe = buffer.subarray(0, Math.min(buffer.length, 8192))
|
|
258
|
-
if (probe.includes(0)) {
|
|
259
|
-
throw new BackendError('binary', `'${path}' is not a text file`)
|
|
260
|
-
}
|
|
261
|
-
return buffer.toString('utf8')
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function canonicalLocalPath(path: string): string {
|
|
265
|
-
const result = resolve(path).replace(/[\\/]+$/, '')
|
|
266
|
-
return process.platform === 'win32' ? result.toLowerCase() : result
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function sameLocalPath(a: string, b: string): boolean {
|
|
270
|
-
return canonicalLocalPath(a) === canonicalLocalPath(b)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function sanitizeSearchQuery(query: string): string {
|
|
274
|
-
return query.replace(/\0/g, '').replace(/[\r\n]/g, ' ').slice(0, 128)
|
|
275
|
-
}
|
|
276
187
|
|
|
277
188
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
189
|
+
* The single record source: projects the generic `WorkspaceLedger` (via
|
|
190
|
+
* `WorkspaceCore`) into the client SSH DTO and performs workspace CRUD through
|
|
191
|
+
* the generic core. Routes, agent tools, and the SSH host-delete guard stay
|
|
192
|
+
* store-agnostic behind `WorkspaceStoreView`. New SSH workspaces keep the
|
|
193
|
+
* established anchor layout (~/.dsh/ssh-workspaces/<id>) so existing
|
|
194
|
+
* sidebar/session bindings and the host workspace registry stay unchanged.
|
|
280
195
|
*/
|
|
281
|
-
export class
|
|
282
|
-
assertRoot(root: string): void {
|
|
283
|
-
if (!isAbsolute(root)) {
|
|
284
|
-
throw new BackendError('outside-root', `root must be an absolute local path (got '${root}')`)
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async list(root: string, rel: string): Promise<DirListing> {
|
|
289
|
-
const abs = await this.resolvePath(root, rel)
|
|
290
|
-
let dirents: Dirent[]
|
|
291
|
-
try {
|
|
292
|
-
dirents = await readdir(abs, { withFileTypes: true })
|
|
293
|
-
} catch (error) {
|
|
294
|
-
throw toBackendIoError(error, abs)
|
|
295
|
-
}
|
|
296
|
-
const entries = dirents
|
|
297
|
-
.filter(entry => !TREE_SKIP_DIRS.has(entry.name))
|
|
298
|
-
.map((entry): WorkspaceEntry => ({
|
|
299
|
-
name: entry.name,
|
|
300
|
-
type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other',
|
|
301
|
-
size: 0,
|
|
302
|
-
mtimeMs: 0,
|
|
303
|
-
}))
|
|
304
|
-
return { path: abs, entries: sortWorkspaceEntries(entries) }
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async read(root: string, rel: string): Promise<FileRead> {
|
|
308
|
-
const abs = await this.resolvePath(root, rel)
|
|
309
|
-
let buffer: Buffer
|
|
310
|
-
let stats: Stats
|
|
311
|
-
try {
|
|
312
|
-
;[buffer, stats] = await Promise.all([readFile(abs), stat(abs)])
|
|
313
|
-
} catch (error) {
|
|
314
|
-
throw toBackendIoError(error, abs)
|
|
315
|
-
}
|
|
316
|
-
if (!stats.isFile()) {
|
|
317
|
-
throw new BackendError('invalid', `'${abs}' is not a regular file`)
|
|
318
|
-
}
|
|
319
|
-
return { path: abs, content: decodeText(buffer, abs), size: stats.size, mtime: stats.mtimeMs }
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async write(
|
|
323
|
-
root: string,
|
|
324
|
-
rel: string,
|
|
325
|
-
content: string,
|
|
326
|
-
expectedMtime?: number,
|
|
327
|
-
): Promise<FileWriteResult> {
|
|
328
|
-
const abs = await this.resolvePath(root, rel)
|
|
329
|
-
if (expectedMtime !== undefined) {
|
|
330
|
-
let stats: Stats
|
|
331
|
-
try {
|
|
332
|
-
stats = await stat(abs)
|
|
333
|
-
} catch (error) {
|
|
334
|
-
throw toBackendIoError(error, abs)
|
|
335
|
-
}
|
|
336
|
-
if (Math.round(stats.mtimeMs) !== Math.round(expectedMtime)) {
|
|
337
|
-
throw new BackendError(
|
|
338
|
-
'conflict',
|
|
339
|
-
`mtime conflict: current ${Math.round(stats.mtimeMs)} != expected ${Math.round(expectedMtime)}`,
|
|
340
|
-
)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
try {
|
|
344
|
-
await mkdir(dirname(abs), { recursive: true })
|
|
345
|
-
// Re-run the realpath-walk after mkdir: a concurrent actor may have
|
|
346
|
-
// introduced a symlink while the parent chain was being created.
|
|
347
|
-
await this.assertRealpathWalk(root, abs, rel)
|
|
348
|
-
await writeFile(abs, content, 'utf8')
|
|
349
|
-
const stats = await stat(abs)
|
|
350
|
-
return { mtime: stats.mtimeMs }
|
|
351
|
-
} catch (error) {
|
|
352
|
-
throw toBackendIoError(error, abs)
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async search(root: string, query: string): Promise<SearchView> {
|
|
357
|
-
const rootAbs = await this.resolvePath(root, '')
|
|
358
|
-
const needle = query.toLocaleLowerCase()
|
|
359
|
-
const hits: SearchHit[] = []
|
|
360
|
-
let scanned = 0
|
|
361
|
-
let truncated = false
|
|
362
|
-
|
|
363
|
-
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
364
|
-
if (depth > SEARCH_MAX_DEPTH) return
|
|
365
|
-
if (hits.length >= SEARCH_HIT_CAP || scanned >= SEARCH_SCAN_CAP) {
|
|
366
|
-
truncated = true
|
|
367
|
-
return
|
|
368
|
-
}
|
|
369
|
-
let dirents: Dirent[]
|
|
370
|
-
try {
|
|
371
|
-
dirents = await readdir(dir, { withFileTypes: true })
|
|
372
|
-
} catch {
|
|
373
|
-
return
|
|
374
|
-
}
|
|
375
|
-
for (const entry of dirents) {
|
|
376
|
-
scanned += 1
|
|
377
|
-
if (scanned > SEARCH_SCAN_CAP) {
|
|
378
|
-
truncated = true
|
|
379
|
-
return
|
|
380
|
-
}
|
|
381
|
-
if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name)) continue
|
|
382
|
-
const abs = join(dir, entry.name)
|
|
383
|
-
// Directory symlinks are not followed (Dirent.isDirectory() is false
|
|
384
|
-
// for symlinks).
|
|
385
|
-
if (entry.isDirectory()) {
|
|
386
|
-
if (entry.name.toLocaleLowerCase().includes(needle)) {
|
|
387
|
-
hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: true })
|
|
388
|
-
}
|
|
389
|
-
await walk(abs, depth + 1)
|
|
390
|
-
} else if (entry.isFile() && entry.name.toLocaleLowerCase().includes(needle)) {
|
|
391
|
-
hits.push({ path: abs, rel: relativeWorkspacePath(rootAbs, abs, 'local'), isDir: false })
|
|
392
|
-
}
|
|
393
|
-
if (hits.length >= SEARCH_HIT_CAP) {
|
|
394
|
-
truncated = true
|
|
395
|
-
return
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
await walk(rootAbs, 0)
|
|
401
|
-
return { query, hits: hits.slice(0, SEARCH_HIT_CAP), truncated }
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
private async resolvePath(root: string, rel: string): Promise<string> {
|
|
405
|
-
this.assertRoot(root)
|
|
406
|
-
const normalized = normalizeRel(rel)
|
|
407
|
-
const normalizedRoot = resolve(root)
|
|
408
|
-
const abs = normalized === '' ? normalizedRoot : resolve(normalizedRoot, ...normalized.split('/'))
|
|
409
|
-
relativeWorkspacePath(normalizedRoot, abs, 'local')
|
|
410
|
-
await this.assertRealpathWalk(normalizedRoot, abs, rel)
|
|
411
|
-
return abs
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** Resolve the nearest existing ancestor; root and every existing target
|
|
415
|
-
* ancestor must resolve below the real workspace root. */
|
|
416
|
-
private async assertRealpathWalk(root: string, abs: string, rel: string): Promise<void> {
|
|
417
|
-
let realRoot: string
|
|
418
|
-
try {
|
|
419
|
-
realRoot = await realpath(root)
|
|
420
|
-
} catch (error) {
|
|
421
|
-
throw toBackendIoError(error, root)
|
|
422
|
-
}
|
|
423
|
-
let probe = abs
|
|
424
|
-
for (;;) {
|
|
425
|
-
try {
|
|
426
|
-
const realProbe = await realpath(probe)
|
|
427
|
-
relativeWorkspacePath(realRoot, realProbe, 'local')
|
|
428
|
-
return
|
|
429
|
-
} catch (error) {
|
|
430
|
-
if (error instanceof BackendError) throw error
|
|
431
|
-
const code = (error as NodeJS.ErrnoException).code
|
|
432
|
-
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
433
|
-
throw toBackendIoError(error, probe)
|
|
434
|
-
}
|
|
435
|
-
const parent = dirname(probe)
|
|
436
|
-
if (parent === probe) {
|
|
437
|
-
throw new BackendError('outside-root', `path cannot be resolved below workspace root: '${rel}'`)
|
|
438
|
-
}
|
|
439
|
-
probe = parent
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** A request-scoped remote backend bound to one immutable ledger record. */
|
|
446
|
-
export class RemoteBackend implements WorkspaceBackend {
|
|
447
|
-
private readonly remoteRoot: string
|
|
448
|
-
|
|
196
|
+
export class GenericWorkspaceStore implements WorkspaceStoreView {
|
|
449
197
|
constructor(
|
|
450
|
-
private readonly
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
assertRoot(root: string): void {
|
|
458
|
-
if (normalizeRemoteRoot(root) !== this.remoteRoot) {
|
|
459
|
-
throw new BackendError('root-mismatch', `root '${root}' does not match remote workspace root '${this.remoteRoot}'`)
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
async list(root: string, rel: string): Promise<DirListing> {
|
|
464
|
-
this.assertRoot(root)
|
|
465
|
-
const abs = relToAbs(this.remoteRoot, rel)
|
|
466
|
-
try {
|
|
467
|
-
const entries = await this.engine.ls(this.record.alias, abs)
|
|
468
|
-
return {
|
|
469
|
-
path: abs,
|
|
470
|
-
entries: sortWorkspaceEntries(
|
|
471
|
-
entries
|
|
472
|
-
.filter(entry => !TREE_SKIP_DIRS.has(entry.name))
|
|
473
|
-
.map((entry): WorkspaceEntry => ({
|
|
474
|
-
name: entry.name,
|
|
475
|
-
type: entry.type,
|
|
476
|
-
size: entry.size,
|
|
477
|
-
mtimeMs: entry.mtimeMs,
|
|
478
|
-
})),
|
|
479
|
-
),
|
|
480
|
-
}
|
|
481
|
-
} catch (error) {
|
|
482
|
-
throw toBackendIoError(error, abs)
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async read(root: string, rel: string): Promise<FileRead> {
|
|
487
|
-
this.assertRoot(root)
|
|
488
|
-
const abs = relToAbs(this.remoteRoot, rel)
|
|
489
|
-
try {
|
|
490
|
-
const result = await this.engine.readFile(this.record.alias, abs)
|
|
491
|
-
return {
|
|
492
|
-
path: abs,
|
|
493
|
-
content: decodeText(result.content, abs),
|
|
494
|
-
size: result.size,
|
|
495
|
-
mtime: result.mtime,
|
|
496
|
-
}
|
|
497
|
-
} catch (error) {
|
|
498
|
-
throw toBackendIoError(error, abs)
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
async write(
|
|
503
|
-
root: string,
|
|
504
|
-
rel: string,
|
|
505
|
-
content: string,
|
|
506
|
-
expectedMtime?: number,
|
|
507
|
-
): Promise<FileWriteResult> {
|
|
508
|
-
this.assertRoot(root)
|
|
509
|
-
const abs = relToAbs(this.remoteRoot, rel)
|
|
510
|
-
try {
|
|
511
|
-
const result = await this.engine.writeFile(
|
|
512
|
-
this.record.alias,
|
|
513
|
-
abs,
|
|
514
|
-
Buffer.from(content, 'utf8'),
|
|
515
|
-
expectedMtime,
|
|
516
|
-
)
|
|
517
|
-
return { mtime: result.mtime }
|
|
518
|
-
} catch (error) {
|
|
519
|
-
throw toBackendIoError(error, abs)
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
async search(root: string, query: string): Promise<SearchView> {
|
|
524
|
-
this.assertRoot(root)
|
|
525
|
-
let found: RemoteNameSearch
|
|
526
|
-
try {
|
|
527
|
-
found = await this.searchService.searchNames({ alias: this.record.alias, root: this.remoteRoot }, query)
|
|
528
|
-
} catch (error) {
|
|
529
|
-
throw toBackendIoError(error, this.remoteRoot)
|
|
530
|
-
}
|
|
531
|
-
const hits: SearchHit[] = []
|
|
532
|
-
for (const hit of found.hits) {
|
|
533
|
-
// Never trust command output as authorization evidence.
|
|
534
|
-
const rel = relativeWorkspacePath(this.remoteRoot, hit.path, 'remote')
|
|
535
|
-
hits.push({ path: posix.normalize(hit.path), rel, isDir: hit.isDir })
|
|
536
|
-
}
|
|
537
|
-
return { query: found.query, hits, truncated: found.truncated }
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Production UI service: exact ledger anchor -> request-scoped backend. */
|
|
542
|
-
export class LedgerWorkspaceFileService implements WorkspaceFileService {
|
|
543
|
-
constructor(
|
|
544
|
-
private readonly ledger: SshWorkspaceLedger,
|
|
545
|
-
private readonly engine: SshEngine,
|
|
546
|
-
private readonly searchService: RemoteSearchService,
|
|
198
|
+
private readonly core: WorkspaceCore,
|
|
199
|
+
/** Resolves when the generic runtime (migration + initialize) finished;
|
|
200
|
+
* rejects on any startup failure so every consumer fails closed instead
|
|
201
|
+
* of silently reading an un-migrated or corrupt ledger. */
|
|
202
|
+
private readonly ready: Promise<void>,
|
|
203
|
+
/** Anchor root for newly created SSH workspaces (~/.dsh/ssh-workspaces). */
|
|
204
|
+
private readonly anchorBase: string,
|
|
547
205
|
) {}
|
|
548
206
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const record = await this.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
207
|
+
/** WorkspaceCore.list() exposes every provider, but the SSH DTO surface is SSH-only, so this filters before projecting. */
|
|
208
|
+
async list(): Promise<SshWorkspaceRecord[]> {
|
|
209
|
+
await this.ready
|
|
210
|
+
const records = await this.core.list()
|
|
211
|
+
return records.filter(record => record.provider.id === 'ssh').map(toSshWorkspaceDto)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** WorkspaceCore.get() returns generic records, so this narrows to SSH and projects the DTO. */
|
|
215
|
+
async get(id: string): Promise<SshWorkspaceRecord | undefined> {
|
|
216
|
+
await this.ready
|
|
217
|
+
const record = await this.core.get(id)
|
|
218
|
+
return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** WorkspaceCore.findByAnchor() also resolves non-SSH workspaces, so this projects only SSH owners. */
|
|
222
|
+
async findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined> {
|
|
223
|
+
await this.ready
|
|
224
|
+
const record = await this.core.findByAnchor(path)
|
|
225
|
+
return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** WorkspaceCore has no synchronous anchor lookup on the public surface, so this resolves the detached ledger snapshot lexically. */
|
|
229
|
+
findByAnchorSync(path: string): SshWorkspaceRecord | undefined {
|
|
230
|
+
const canonical = safeRealpathSync(path)
|
|
231
|
+
if (canonical === undefined) return undefined
|
|
232
|
+
const anchors = this.core.ledger.snapshotSync().records
|
|
233
|
+
.filter(record => record.provider.id === 'ssh' && record.anchor !== undefined)
|
|
234
|
+
.map(record => ({ normalized: baseNormalizeAnchorPath(record.anchor!.path), record }))
|
|
235
|
+
.sort((a, b) => b.normalized.length - a.normalized.length) // longest prefix first
|
|
236
|
+
for (const { normalized, record } of anchors) {
|
|
237
|
+
if (baseIsPathUnderAnchor(normalized, canonical)) return toSshWorkspaceDto(record)
|
|
576
238
|
}
|
|
239
|
+
return undefined
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** WorkspaceLedger.create() cannot generate SSH defaults or the SSH anchor layout, so this maps the SSH create input into a generic record first. */
|
|
243
|
+
async create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord> {
|
|
244
|
+
await this.ready
|
|
245
|
+
const root = normalizeSshRemoteRoot(input.remoteRoot)
|
|
246
|
+
// Anchor under the SSH anchor base (not the generic anchor root): ids and
|
|
247
|
+
// anchors must stay stable for sidebar/session binding.
|
|
248
|
+
const id = randomUUID()
|
|
249
|
+
const record = await this.core.create({
|
|
250
|
+
schemaVersion: WORKSPACE_LEDGER_SCHEMA_VERSION,
|
|
251
|
+
id,
|
|
252
|
+
title: input.title.trim() === '' ? defaultTitle(root, input.alias) : input.title.trim(),
|
|
253
|
+
provider: { id: 'ssh', connectionRef: { id: input.alias, alias: input.alias } },
|
|
254
|
+
location: { kind: 'posix', root },
|
|
255
|
+
anchor: { path: join(this.anchorBase, id), mode: 'managed' },
|
|
256
|
+
})
|
|
257
|
+
return toSshWorkspaceDto(record)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Title edits route through the generic update (the generic ledger has no SSH-specific rename). */
|
|
261
|
+
async rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined> {
|
|
262
|
+
await this.ready
|
|
263
|
+
// This is an SSH-only projection over a provider-neutral core. Guard the
|
|
264
|
+
// provider before mutating so an arbitrary generic id cannot rename a local
|
|
265
|
+
// or third-party workspace through the SSH API.
|
|
266
|
+
const existing = await this.core.get(id)
|
|
267
|
+
if (existing === undefined || existing.provider.id !== 'ssh') return undefined
|
|
268
|
+
const record = await this.core.update(id, { title: title.trim() })
|
|
269
|
+
return record === undefined || record.provider.id !== 'ssh' ? undefined : toSshWorkspaceDto(record)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** WorkspaceCore.remove() is the generic delete; the anchor directory stays in place (the host workspace may still reference it). */
|
|
273
|
+
async remove(id: string): Promise<boolean> {
|
|
274
|
+
await this.ready
|
|
275
|
+
// Match every other projection method: only SSH-owned records may cross
|
|
276
|
+
// this mutation boundary. No new Core CAS surface is introduced here.
|
|
277
|
+
const existing = await this.core.get(id)
|
|
278
|
+
if (existing === undefined || existing.provider.id !== 'ssh') return false
|
|
279
|
+
return this.core.remove(id)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The SSH anchor base this store creates its managed anchors under
|
|
283
|
+
* (~/.dsh/ssh-workspaces). */
|
|
284
|
+
anchorsRoot(): string {
|
|
285
|
+
return this.anchorBase
|
|
577
286
|
}
|
|
287
|
+
}
|
|
578
288
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const { record, backend } = await this.resolveBackend(root)
|
|
586
|
-
return backend.read(record.remoteRoot, rel)
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
async write(
|
|
590
|
-
root: string,
|
|
591
|
-
rel: string,
|
|
592
|
-
content: string,
|
|
593
|
-
expectedMtime?: number,
|
|
594
|
-
): Promise<FileWriteResult> {
|
|
595
|
-
const { record, backend } = await this.resolveBackend(root)
|
|
596
|
-
return backend.write(record.remoteRoot, rel, content, expectedMtime)
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async search(root: string, query: string): Promise<SearchView> {
|
|
600
|
-
const { record, backend } = await this.resolveBackend(root)
|
|
601
|
-
return backend.search(record.remoteRoot, query)
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
private async resolveBackend(root: string): Promise<{
|
|
605
|
-
record: SshWorkspaceRecord
|
|
606
|
-
backend: RemoteBackend
|
|
607
|
-
}> {
|
|
608
|
-
const context = await this.resolveContext(root)
|
|
609
|
-
const record = await this.ledger.get(context.workspaceId)
|
|
610
|
-
|
|
611
|
-
// A concurrent deletion between resolveContext and dispatch must fail
|
|
612
|
-
// closed rather than silently selecting another backend.
|
|
613
|
-
if (
|
|
614
|
-
record === undefined
|
|
615
|
-
|| !sameLocalPath(record.anchorPath, context.anchorPath)
|
|
616
|
-
|| record.alias !== context.alias
|
|
617
|
-
|| normalizeRemoteRoot(record.remoteRoot) !== context.remoteRoot
|
|
618
|
-
) {
|
|
619
|
-
throw new BackendError('not-remote', `workspace '${context.workspaceId}' is no longer available`)
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
return { record, backend: new RemoteBackend(this.engine, record, this.searchService) }
|
|
289
|
+
/** resolve() can throw for a vanished cwd; the sync lookup must stay total. */
|
|
290
|
+
function safeRealpathSync(path: string): string | undefined {
|
|
291
|
+
try {
|
|
292
|
+
return realpathSync(path)
|
|
293
|
+
} catch {
|
|
294
|
+
return undefined
|
|
623
295
|
}
|
|
624
296
|
}
|