@tiphareth/dsh-hardssh 0.1.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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @tiphareth/dsh-hardssh/base — the generic workspace base.
3
+ *
4
+ * Provider-agnostic core: workspace model, provider/connection/capability
5
+ * contracts, registry, ledger, namespace codec, router, and the plugin
6
+ * registration API. No SSH, no Cordis, no React.
7
+ *
8
+ * @module @tiphareth/dsh-hardssh/base
9
+ */
10
+
11
+ export * from './model.ts'
12
+ export * from './capability.ts'
13
+ export * from './registry.ts'
14
+ export * from './ledger.ts'
15
+ export * from './namespace.ts'
16
+ export * from './router.ts'
17
+ export * from './ledger-router.ts'
18
+ export * from './plugin.ts'
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Ledger-backed workspace router — the concrete `WorkspaceRouter` that ties
3
+ * the ledger's anchor index, the namespace codec, and the provider registry
4
+ * together. Any provider can be routed through this router with zero SSH
5
+ * knowledge: a session cwd inside a workspace's anchor resolves to that
6
+ * workspace's provider connection; an explicit `wfs://…` (or legacy `ssh:…`)
7
+ * target key resolves to the owning connection.
8
+ *
9
+ * The router opens connections lazily and caches them per workspace id;
10
+ * removed workspaces drop their connections so stale keys fail closed.
11
+ *
12
+ * @module @tiphareth/dsh-hardssh/base/ledger-router
13
+ */
14
+
15
+ import type { WorkspaceConnection, WorkspaceRecord } from './model.ts'
16
+ import { defaultNamespaceCodec, type WorkspaceNamespaceCodec, type WorkspaceRoute } from './namespace.ts'
17
+ import type { WorkspaceRegistry } from './registry.ts'
18
+ import { isPathUnderAnchor, normalizeAnchorPath, type WorkspaceLedger } from './ledger.ts'
19
+ import type { WorkspaceResolution, WorkspaceRouter } from './router.ts'
20
+
21
+ /** Open-or-reuse strategy for workspace connections. */
22
+ export interface LedgerRouterOptions {
23
+ codec?: WorkspaceNamespaceCodec
24
+ /** Called with the record; resolves to a connection (undefined = cannot open). */
25
+ open(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined>
26
+ /** Called when a workspace is removed (drop cache / pool leases). */
27
+ onRemoved?(workspaceId: string): void
28
+ }
29
+
30
+ /** Ledger + registry based router. */
31
+ export class LedgerWorkspaceRouter implements WorkspaceRouter {
32
+ readonly codec: WorkspaceNamespaceCodec
33
+ private readonly connections = new Map<string, WorkspaceConnection>()
34
+ private readonly anchors: Array<{ anchor: string; record: WorkspaceRecord }> = []
35
+
36
+ constructor(
37
+ private readonly ledger: WorkspaceLedger,
38
+ private readonly registry: WorkspaceRegistry,
39
+ private readonly options: LedgerRouterOptions,
40
+ ) {
41
+ this.codec = options.codec ?? defaultNamespaceCodec
42
+ this.reindex()
43
+ // Keep the anchor index fresh on every ledger commit.
44
+ this.ledger.subscribe(() => this.reindex())
45
+ }
46
+
47
+ private reindex(): void {
48
+ const snapshot = this.ledger.snapshotSync()
49
+ const seen = new Set<string>()
50
+ const anchors: Array<{ anchor: string; record: WorkspaceRecord }> = []
51
+ for (const record of snapshot.records) {
52
+ seen.add(record.id.toLowerCase())
53
+ if (record.anchor !== undefined) {
54
+ anchors.push({ anchor: normalizeAnchorPath(record.anchor.path), record })
55
+ }
56
+ }
57
+ anchors.sort((a, b) => b.anchor.length - a.anchor.length)
58
+ this.anchors.length = 0
59
+ this.anchors.push(...anchors)
60
+ // Drop connections of removed workspaces (fail-closed on stale keys).
61
+ for (const id of [...this.connections.keys()]) {
62
+ if (!seen.has(id.toLowerCase())) {
63
+ const connection = this.connections.get(id)
64
+ this.connections.delete(id)
65
+ this.options.onRemoved?.(id)
66
+ void connection?.close().catch(() => undefined)
67
+ }
68
+ }
69
+ }
70
+
71
+ private async openFor(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
72
+ const id = record.id
73
+ const existing = this.connections.get(id)
74
+ if (existing !== undefined) return existing
75
+ const opened = await this.options.open(record, signal)
76
+ if (opened !== undefined) this.connections.set(id, opened)
77
+ return opened
78
+ }
79
+
80
+ fromNamespace(key: string): WorkspaceResolution | undefined {
81
+ const route = this.codec.decode(key)
82
+ if (route === undefined) return undefined
83
+ const resolution = this.resolveRoute(route)
84
+ if (resolution === undefined) return undefined
85
+ // Preserve the raw path as the raw key.
86
+ return { ...resolution, rawKey: route.path }
87
+ }
88
+
89
+ fromAnchor(cwd: string | undefined): WorkspaceConnection | undefined {
90
+ if (cwd === undefined || cwd === '') return undefined
91
+ for (const { anchor, record } of this.anchors) {
92
+ if (isPathUnderAnchor(anchor, cwd)) {
93
+ // Synchronous best-effort: return a cached connection; opening is async.
94
+ const existing = this.connections.get(record.id)
95
+ if (existing !== undefined) return existing
96
+ // Keep routing crisp: the runtime pre-opens active records; a miss
97
+ // here simply means the DSH adapter awaits openFor at mount time.
98
+ return undefined
99
+ }
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ resolveRoute(route: WorkspaceRoute): WorkspaceResolution | undefined {
105
+ const record = this.ledger.snapshotSync().records.find(candidate => candidate.id === route.workspaceId)
106
+ if (record === undefined) return undefined
107
+ const connection = this.connections.get(record.id)
108
+ if (connection === undefined) return undefined
109
+ return { connection, rawKey: route.path }
110
+ }
111
+
112
+ /** Async open used by the adapter at mount time (pre-warm active records). */
113
+ async ensureOpen(record: WorkspaceRecord, signal?: AbortSignal): Promise<WorkspaceConnection | undefined> {
114
+ return this.openFor(record, signal)
115
+ }
116
+
117
+ /** All currently cached connections (for lifecycle/teardown). */
118
+ connectionsSnapshot(): WorkspaceConnection[] {
119
+ return [...this.connections.values()]
120
+ }
121
+
122
+ /** Close every cached connection (plugin teardown). */
123
+ async closeAll(): Promise<void> {
124
+ const connections = [...this.connections.values()]
125
+ this.connections.clear()
126
+ await Promise.all(connections.map(connection => connection.close().catch(() => undefined)))
127
+ }
128
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Generic workspace ledger — the provider-agnostic persistence and routing
3
+ * index for workspace records. It generalizes the SSH-bound ledger's proven
4
+ * mechanics (atomic write, sync anchor index, subscription) without any
5
+ * SSH-typed fields: a record is a `WorkspaceRecord` whose `provider` ref
6
+ * names the owning provider, and anchors are plain local directories.
7
+ *
8
+ * Concrete providers keep their own specialized stores (e.g. the SSH host
9
+ * store) and reference them through `provider.connectionRef`; the ledger
10
+ * itself never touches provider configs.
11
+ *
12
+ * @module @tiphareth/dsh-hardssh/base/ledger
13
+ */
14
+
15
+ import { randomUUID } from 'node:crypto'
16
+ import { realpathSync } from 'node:fs'
17
+ import { mkdir, readFile, rename as renameFile, rm, writeFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+ import type { WorkspaceRecord } from './model.ts'
20
+
21
+ /** One committed ledger snapshot. */
22
+ export interface LedgerSnapshot {
23
+ revision: number
24
+ records: readonly WorkspaceRecord[]
25
+ }
26
+
27
+ /** One committed mutation delivered to subscribers. */
28
+ export type LedgerChange =
29
+ | { type: 'created'; revision: number; record: WorkspaceRecord }
30
+ | { type: 'renamed'; revision: number; before: WorkspaceRecord; record: WorkspaceRecord }
31
+ | { type: 'removed'; revision: number; record: WorkspaceRecord }
32
+
33
+ export type LedgerListener = (change: LedgerChange) => void
34
+
35
+ type LedgerChangeWithoutRevision =
36
+ | { type: 'created'; record: WorkspaceRecord }
37
+ | { type: 'renamed'; before: WorkspaceRecord; record: WorkspaceRecord }
38
+ | { type: 'removed'; record: WorkspaceRecord }
39
+
40
+ /** Normalize an anchor for comparison (Windows case-insensitive; POSIX not). */
41
+ export function normalizeAnchorPath(path: string): string {
42
+ const windowsStyle = /^[a-zA-Z]:[\\/]/.test(path) || path.includes('\\')
43
+ if (windowsStyle) {
44
+ const normalized = path.replace(/\//g, '\\')
45
+ const rootLength = /^[a-zA-Z]:\\/.test(normalized) ? 3 : 0
46
+ return trimTrailing(normalized, rootLength).toLowerCase()
47
+ }
48
+ const rootLength = path.startsWith('/') ? 1 : 0
49
+ return trimTrailing(path, rootLength)
50
+ }
51
+
52
+ /** True when `candidate` equals `anchor` or is one of its descendants (lexical). */
53
+ export function isPathUnderAnchor(anchor: string, candidate: string): boolean {
54
+ const normAnchor = normalizeAnchorPath(anchor)
55
+ const normCandidate = normalizeAnchorPath(candidate)
56
+ if (normCandidate === normAnchor) return true
57
+ const sep = normAnchor.includes('\\') ? '\\' : '/'
58
+ const prefix = normAnchor.endsWith(sep) ? normAnchor : `${normAnchor}${sep}`
59
+ return normCandidate.startsWith(prefix)
60
+ }
61
+
62
+ function trimTrailing(path: string, minimumLength: number): string {
63
+ let end = path.length
64
+ while (end > minimumLength && (path[end - 1] === '/' || path[end - 1] === '\\')) end -= 1
65
+ return path.slice(0, end)
66
+ }
67
+
68
+ /**
69
+ * The generic ledger. Persisted as a JSON file; mutations serialized through
70
+ * a per-instance queue with atomic rename; a synchronous anchor index is
71
+ * maintained so the switch layer can resolve a cwd without awaiting I/O.
72
+ */
73
+ export class WorkspaceLedger {
74
+ private records: WorkspaceRecord[] | undefined
75
+ private loadPromise: Promise<void> | undefined
76
+ private anchorIndex: Array<{ anchor: string; record: WorkspaceRecord }> = []
77
+ private mutationTail: Promise<void> = Promise.resolve()
78
+ private currentRevision = 0
79
+ private readonly listeners = new Set<LedgerListener>()
80
+
81
+ constructor(
82
+ private readonly fileOverride?: string,
83
+ private readonly anchorOverride?: string,
84
+ ) {}
85
+
86
+ private file(): string { return this.fileOverride ?? '' }
87
+ private anchors(): string { return this.anchorOverride ?? '' }
88
+
89
+ private async ensureLoaded(): Promise<void> {
90
+ if (this.records !== undefined) return
91
+ if (this.loadPromise === undefined) this.loadPromise = this.readRecords()
92
+ await this.loadPromise
93
+ }
94
+
95
+ private async readRecords(): Promise<void> {
96
+ try {
97
+ const text = await readFile(this.file(), 'utf8')
98
+ const parsed = JSON.parse(text) as unknown
99
+ this.records = Array.isArray(parsed) ? parsed.filter(isRecord).map(cloneRecord) : []
100
+ } catch {
101
+ this.records = []
102
+ }
103
+ this.reindex()
104
+ }
105
+
106
+ private reindex(): void {
107
+ this.anchorIndex = (this.records ?? [])
108
+ .filter(record => record.anchor !== undefined)
109
+ .map(record => ({ anchor: normalizeAnchorPath(record.anchor!.path), record }))
110
+ .sort((a, b) => b.anchor.length - a.anchor.length)
111
+ }
112
+
113
+ private async save(nextRecords: readonly WorkspaceRecord[]): Promise<void> {
114
+ const target = this.file()
115
+ if (target === '') throw new Error('WorkspaceLedger: no persistence file configured')
116
+ await mkdir(join(target, '..'), { recursive: true })
117
+ const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
118
+ try {
119
+ await writeFile(temporary, JSON.stringify(nextRecords, null, 2), 'utf8')
120
+ await renameFile(temporary, target)
121
+ } catch (error) {
122
+ await rm(temporary, { force: true }).catch(() => undefined)
123
+ throw error
124
+ }
125
+ }
126
+
127
+ private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
128
+ const result = this.mutationTail.then(operation)
129
+ this.mutationTail = result.then(() => undefined, () => undefined)
130
+ return result
131
+ }
132
+
133
+ private commit(nextRecords: WorkspaceRecord[], change: LedgerChangeWithoutRevision): void {
134
+ this.records = nextRecords
135
+ this.reindex()
136
+ this.currentRevision += 1
137
+ this.emit({ ...change, revision: this.currentRevision } as LedgerChange)
138
+ }
139
+
140
+ private emit(change: LedgerChange): void {
141
+ for (const listener of [...this.listeners]) {
142
+ try {
143
+ listener(cloneChange(change))
144
+ } catch (error) {
145
+ console.warn('[dsh-workspace] ledger subscriber failed:', error instanceof Error ? error.message : String(error))
146
+ }
147
+ }
148
+ }
149
+
150
+ async load(): Promise<WorkspaceRecord[]> {
151
+ await this.ensureLoaded()
152
+ return cloneRecords(this.records ?? [])
153
+ }
154
+
155
+ async list(): Promise<WorkspaceRecord[]> {
156
+ await this.ensureLoaded()
157
+ return cloneRecords(this.records ?? [])
158
+ }
159
+
160
+ async snapshot(): Promise<LedgerSnapshot> {
161
+ await this.ensureLoaded()
162
+ return { revision: this.currentRevision, records: cloneRecords(this.records ?? []) }
163
+ }
164
+
165
+ snapshotSync(): LedgerSnapshot {
166
+ return { revision: this.currentRevision, records: this.records ?? [] }
167
+ }
168
+
169
+ revision(): number {
170
+ return this.currentRevision
171
+ }
172
+
173
+ subscribe(listener: LedgerListener): () => void {
174
+ this.listeners.add(listener)
175
+ let disposed = false
176
+ return () => {
177
+ if (disposed) return
178
+ disposed = true
179
+ this.listeners.delete(listener)
180
+ }
181
+ }
182
+
183
+ async get(id: string): Promise<WorkspaceRecord | undefined> {
184
+ await this.ensureLoaded()
185
+ const record = this.records?.find(candidate => candidate.id === id)
186
+ return record === undefined ? undefined : cloneRecord(record)
187
+ }
188
+
189
+ async findByAnchor(path: string): Promise<WorkspaceRecord | undefined> {
190
+ await this.ensureLoaded()
191
+ return this.findByAnchorSync(path)
192
+ }
193
+
194
+ findByAnchorSync(path: string): WorkspaceRecord | undefined {
195
+ const canonical = safeRealpathSync(path)
196
+ if (canonical === undefined) return undefined
197
+ for (const { anchor, record } of this.anchorIndex) {
198
+ if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record)
199
+ }
200
+ return undefined
201
+ }
202
+
203
+ /** Create a record: materialize the anchor (managed mode), persist, commit. */
204
+ async create(input: Omit<WorkspaceRecord, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }): Promise<WorkspaceRecord> {
205
+ return this.enqueueMutation(async () => {
206
+ await this.ensureLoaded()
207
+ const now = new Date().toISOString()
208
+ const id = input.id ?? randomUUID()
209
+ const record: WorkspaceRecord = {
210
+ schemaVersion: input.schemaVersion,
211
+ id,
212
+ title: input.title,
213
+ provider: input.provider,
214
+ location: input.location,
215
+ anchor: input.anchor,
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ labels: input.labels,
219
+ extensions: input.extensions,
220
+ }
221
+ if (record.anchor !== undefined && record.anchor.mode === 'managed') {
222
+ await mkdir(record.anchor.path, { recursive: true })
223
+ }
224
+ const nextRecords = [...(this.records ?? []), record]
225
+ await this.save(nextRecords)
226
+ this.commit(nextRecords, { type: 'created', record })
227
+ return cloneRecord(record)
228
+ })
229
+ }
230
+
231
+ async rename(id: string, title: string): Promise<WorkspaceRecord | undefined> {
232
+ return this.enqueueMutation(async () => {
233
+ await this.ensureLoaded()
234
+ const records = this.records ?? []
235
+ const index = records.findIndex(record => record.id === id)
236
+ if (index < 0) return undefined
237
+ const before = records[index]
238
+ const record: WorkspaceRecord = { ...before, title: title.trim(), updatedAt: new Date().toISOString() }
239
+ const nextRecords = [...records]
240
+ nextRecords[index] = record
241
+ await this.save(nextRecords)
242
+ this.commit(nextRecords, { type: 'renamed', before, record })
243
+ return cloneRecord(record)
244
+ })
245
+ }
246
+
247
+ /** Remove a record. The anchor directory is left in place (caller decides). */
248
+ async remove(id: string): Promise<boolean> {
249
+ return this.enqueueMutation(async () => {
250
+ await this.ensureLoaded()
251
+ const records = this.records ?? []
252
+ const index = records.findIndex(record => record.id === id)
253
+ if (index < 0) return false
254
+ const record = records[index]
255
+ const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)]
256
+ await this.save(nextRecords)
257
+ this.commit(nextRecords, { type: 'removed', record })
258
+ return true
259
+ })
260
+ }
261
+
262
+ anchorsRoot(): string {
263
+ return this.anchors()
264
+ }
265
+ }
266
+
267
+ function isRecord(value: unknown): value is WorkspaceRecord {
268
+ if (typeof value !== 'object' || value === null) return false
269
+ const record = value as Record<string, unknown>
270
+ return typeof record.id === 'string'
271
+ && typeof record.title === 'string'
272
+ && typeof record.schemaVersion === 'number'
273
+ && typeof record.provider === 'object' && record.provider !== null
274
+ && typeof (record.provider as Record<string, unknown>).id === 'string'
275
+ && typeof record.location === 'object' && record.location !== null
276
+ && typeof (record.location as Record<string, unknown>).root === 'string'
277
+ && typeof record.createdAt === 'string'
278
+ && typeof record.updatedAt === 'string'
279
+ }
280
+
281
+ function safeRealpathSync(path: string): string | undefined {
282
+ try { return realpathSync(path) } catch { return undefined }
283
+ }
284
+
285
+ function cloneRecord(record: WorkspaceRecord): WorkspaceRecord {
286
+ return { ...record, provider: { ...record.provider }, location: { ...record.location } }
287
+ }
288
+
289
+ function cloneRecords(records: readonly WorkspaceRecord[]): WorkspaceRecord[] {
290
+ return records.map(cloneRecord)
291
+ }
292
+
293
+ function cloneChange(change: LedgerChange): LedgerChange {
294
+ switch (change.type) {
295
+ case 'created': return { ...change, record: cloneRecord(change.record) }
296
+ case 'renamed': return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
297
+ case 'removed': return { ...change, record: cloneRecord(change.record) }
298
+ }
299
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The generic workspace model — the platform-neutral core of the workspace
3
+ * base. A `WorkspaceRecord` describes ONE bound workspace: a local anchor
4
+ * directory (what sessions see as their cwd) mapped to a location on some
5
+ * provider (local disk, SSH host, container, object store, …). The record
6
+ * never holds secrets — only references into a provider-managed credential
7
+ * store.
8
+ *
9
+ * Everything in this module is provider-agnostic: it imports no SSH, no
10
+ * Cordis, no React, and no `@deepseek-ai/*` runtime package.
11
+ *
12
+ * @module @tiphareth/dsh-hardssh/base/model
13
+ */
14
+
15
+ /** Opaque workspace id (uuid or provider-assigned). */
16
+ export type WorkspaceId = string
17
+
18
+ /** A connection reference into a provider's own credential/config store. */
19
+ export interface ConnectionRef {
20
+ /** Provider-local stable id (e.g. the SSH host record id). */
21
+ id: string
22
+ /** Optional display alias kept for humans; never used as identity. */
23
+ alias?: string
24
+ }
25
+
26
+ /** Identifies which provider owns a workspace and how to reach its config. */
27
+ export interface WorkspaceProviderRef {
28
+ /** Provider type id: 'local' | 'ssh' | 'docker' | 'wsl' | … (registry key). */
29
+ id: string
30
+ /** Optional multiple-config instance within one provider. */
31
+ instanceId?: string
32
+ /** Reference into the provider's credential/config store (no secrets). */
33
+ connectionRef?: ConnectionRef
34
+ }
35
+
36
+ /** Where a workspace lives, interpreted by its provider. */
37
+ export interface WorkspaceLocation {
38
+ /** Path kind: 'native' | 'posix' | 'uri' | 'object-prefix' | … */
39
+ kind: string
40
+ /** Provider-specific root (e.g. /srv/repo, C:\repo, bucket/prefix). */
41
+ root: string
42
+ /** Provider-defined options; must be JSON-serializable. No secrets. */
43
+ options?: Record<string, unknown>
44
+ }
45
+
46
+ /** How the workspace is anchored on the local host (the session-visible dir). */
47
+ export interface WorkspaceAnchor {
48
+ /** Absolute local anchor directory path. */
49
+ path: string
50
+ /** 'managed' = base owns a dir under its anchor root; 'existing' = user dir. */
51
+ mode: 'managed' | 'existing'
52
+ }
53
+
54
+ /** One persisted workspace record. */
55
+ export interface WorkspaceRecord {
56
+ /** Schema identifier so future migrations can be detected. */
57
+ schemaVersion: number
58
+ id: WorkspaceId
59
+ /** Human-readable sidebar title. */
60
+ title: string
61
+ provider: WorkspaceProviderRef
62
+ location: WorkspaceLocation
63
+ anchor?: WorkspaceAnchor
64
+ createdAt: string
65
+ updatedAt: string
66
+ /** Free-form labels (env, team, …). */
67
+ labels?: Record<string, string>
68
+ /** Provider/feature extension bag. Never holds secrets. */
69
+ extensions?: Record<string, unknown>
70
+ }
71
+
72
+ /** One provider capability key (extensible via module augmentation). */
73
+ export interface WorkspaceCapabilityMap {
74
+ 'workspace.fs': import('./capability.ts').WorkspaceFileSystem
75
+ 'workspace.process': import('./capability.ts').WorkspaceProcessRuntime
76
+ 'workspace.terminal': import('./capability.ts').WorkspaceTerminalService
77
+ 'workspace.search': import('./capability.ts').WorkspaceSearchService
78
+ }
79
+
80
+ /** A resolved, open connection to one workspace's provider. */
81
+ export interface WorkspaceConnection {
82
+ readonly workspaceId: WorkspaceId
83
+ readonly providerId: string
84
+ /** Fetch a capability; undefined when this provider does not offer it. */
85
+ get<K extends keyof WorkspaceCapabilityMap>(capability: K): WorkspaceCapabilityMap[K] | undefined
86
+ /** Connection lifecycle status. */
87
+ status(): 'connecting' | 'ready' | 'degraded' | 'closed'
88
+ /** Release every resource (connections, pool leases, PTYs). Idempotent. */
89
+ close(): Promise<void>
90
+ }
91
+
92
+ /** Context handed to a provider when opening a workspace. */
93
+ export interface WorkspaceOpenContext {
94
+ signal?: AbortSignal
95
+ logger?: Pick<Console, 'warn' | 'error' | 'info' | 'debug'>
96
+ }
97
+
98
+ /** The interface every workspace provider implements. */
99
+ export interface WorkspaceProvider {
100
+ /** Static identity (registry key, capabilities, display name). */
101
+ readonly manifest: WorkspaceProviderManifest
102
+ /** Validate a record before open (e.g. root shape, ref resolvable). */
103
+ validate(record: WorkspaceRecord): void | Promise<void>
104
+ /** Open a connection for one record. */
105
+ open(record: WorkspaceRecord, context?: WorkspaceOpenContext): Promise<WorkspaceConnection>
106
+ }
107
+
108
+ /** Static provider identity, checked before any plugin code runs. */
109
+ export interface WorkspaceProviderManifest {
110
+ /** Provider type id (registry key; unique across the deployment). */
111
+ id: string
112
+ version: string
113
+ /** Compatibility gate: big-version mismatches refuse to load. */
114
+ apiVersion: number
115
+ displayName: string
116
+ /** Capabilities this provider implements. */
117
+ capabilities: Array<keyof WorkspaceCapabilityMap | string>
118
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Workspace namespace codec — how a routable target key is encoded.
3
+ *
4
+ * New format is a URI style that carries ONLY the workspace id and the
5
+ * provider-agnostic path:
6
+ *
7
+ * wfs://<encoded-workspace-id>/<path>
8
+ *
9
+ * - The workspace id survives provider migration (ssh → container → …), so
10
+ * a key never needs re-routing when a workspace changes providers.
11
+ * - It cannot collide with Windows drive paths (`C:\…`) or bare POSIX paths.
12
+ * - It contains no provider name, so the switch layer never branches on
13
+ * "which provider" — only on "which workspace".
14
+ *
15
+ * The legacy SSH format (`ssh:<recordId>:`) is still accepted (decode only)
16
+ * so existing persisted targets keep resolving during the migration window.
17
+ *
18
+ * @module @tiphareth/dsh-hardssh/base/namespace
19
+ */
20
+
21
+ /** The URI scheme used for explicit workspace-namespaced target keys. */
22
+ export const WFS_SCHEME = 'wfs:' as const
23
+
24
+ /** ASCII-encode a workspace id for safe embedding in a URL-ish key. */
25
+ export function encodeWorkspaceId(id: string): string {
26
+ return encodeURIComponent(id)
27
+ }
28
+
29
+ /** Decode a workspace id back from its encoded form. */
30
+ export function decodeWorkspaceId(encoded: string): string {
31
+ return decodeURIComponent(encoded)
32
+ }
33
+
34
+ /** One parsed route: which workspace, and the raw path inside it. */
35
+ export interface WorkspaceRoute {
36
+ workspaceId: string
37
+ /** The path portion as it appeared (provider-relative). */
38
+ path: string
39
+ }
40
+
41
+ /** Namespace codec contract (make the switch layer provider-agnostic). */
42
+ export interface WorkspaceNamespaceCodec {
43
+ encode(route: WorkspaceRoute): string
44
+ decode(input: string): WorkspaceRoute | undefined
45
+ /** True when `input` is an explicit workspace namespace (not a plain path). */
46
+ isExplicitNamespace(input: string): boolean
47
+ }
48
+
49
+ /**
50
+ * Encode a route as `wfs://<id>/<path>`. A leading slash on the path is
51
+ * preserved after the namespace marker.
52
+ */
53
+ export function encodeRoute(route: WorkspaceRoute): string {
54
+ const path = route.path.startsWith('/') ? route.path : `/${route.path}`
55
+ return `${WFS_SCHEME}//${encodeWorkspaceId(route.workspaceId)}${path}`
56
+ }
57
+
58
+ /** Decode a `wfs://<id>/<path>` key (strict). */
59
+ export function decodeWfsKey(input: string): WorkspaceRoute | undefined {
60
+ if (!input.startsWith(`${WFS_SCHEME}//`)) return undefined
61
+ const rest = input.slice(`${WFS_SCHEME}//`.length)
62
+ const separator = rest.indexOf('/')
63
+ if (separator < 0) return undefined
64
+ const encodedId = rest.slice(0, separator)
65
+ if (encodedId === '') return undefined
66
+ let workspaceId: string
67
+ try {
68
+ workspaceId = decodeWorkspaceId(encodedId)
69
+ } catch {
70
+ return undefined
71
+ }
72
+ const path = rest.slice(separator)
73
+ return { workspaceId, path }
74
+ }
75
+
76
+ /**
77
+ * Decode a legacy `ssh:<recordId>:<key>` key. Kept during the migration
78
+ * window so persisted remote targets keep resolving; the record id maps to
79
+ * the workspace id 1:1 (old record ids ARE the workspace ids).
80
+ */
81
+ export function decodeLegacySshKey(input: string): WorkspaceRoute | undefined {
82
+ if (!input.startsWith('ssh:')) return undefined
83
+ const rest = input.slice('ssh:'.length)
84
+ const end = rest.indexOf(':')
85
+ if (end <= 0) return undefined
86
+ const recordId = rest.slice(0, end)
87
+ return { workspaceId: recordId, path: rest.slice(end + 1) }
88
+ }
89
+
90
+ /**
91
+ * The default codec: emits `wfs://…` and accepts both `wfs://…` and legacy
92
+ * `ssh:<id>:…`.
93
+ */
94
+ export const defaultNamespaceCodec: WorkspaceNamespaceCodec = {
95
+ encode: encodeRoute,
96
+ decode(input: string): WorkspaceRoute | undefined {
97
+ return decodeWfsKey(input) ?? decodeLegacySshKey(input)
98
+ },
99
+ isExplicitNamespace(input: string): boolean {
100
+ return input.startsWith(WFS_SCHEME) || input.startsWith('ssh:')
101
+ },
102
+ }