@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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -1,22 +1,13 @@
1
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
2
+ * Provider-neutral workspace ledger with strict loading, atomic persistence,
3
+ * detached snapshots, and one serialized mutation queue.
13
4
  */
14
5
 
15
6
  import { randomUUID } from 'node:crypto'
16
7
  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'
8
+ import { copyFile, mkdir, readFile, rename as renameFile, rm, writeFile } from 'node:fs/promises'
9
+ import { dirname } from 'node:path'
10
+ import type { WorkspaceCreateInput, WorkspaceRecord, WorkspaceUpdate } from './model.ts'
20
11
 
21
12
  /** One committed ledger snapshot. */
22
13
  export interface LedgerSnapshot {
@@ -28,14 +19,18 @@ export interface LedgerSnapshot {
28
19
  export type LedgerChange =
29
20
  | { type: 'created'; revision: number; record: WorkspaceRecord }
30
21
  | { type: 'renamed'; revision: number; before: WorkspaceRecord; record: WorkspaceRecord }
22
+ | { type: 'updated'; revision: number; before: WorkspaceRecord; record: WorkspaceRecord }
31
23
  | { type: 'removed'; revision: number; record: WorkspaceRecord }
24
+ | { type: 'replaced'; revision: number; before: WorkspaceRecord[]; records: WorkspaceRecord[] }
32
25
 
33
26
  export type LedgerListener = (change: LedgerChange) => void
34
27
 
35
28
  type LedgerChangeWithoutRevision =
36
29
  | { type: 'created'; record: WorkspaceRecord }
37
30
  | { type: 'renamed'; before: WorkspaceRecord; record: WorkspaceRecord }
31
+ | { type: 'updated'; before: WorkspaceRecord; record: WorkspaceRecord }
38
32
  | { type: 'removed'; record: WorkspaceRecord }
33
+ | { type: 'replaced'; before: WorkspaceRecord[]; records: WorkspaceRecord[] }
39
34
 
40
35
  /** Normalize an anchor for comparison (Windows case-insensitive; POSIX not). */
41
36
  export function normalizeAnchorPath(path: string): string {
@@ -65,10 +60,18 @@ function trimTrailing(path: string, minimumLength: number): string {
65
60
  return path.slice(0, end)
66
61
  }
67
62
 
63
+ /** Options for one atomic whole-ledger replacement. */
64
+ export interface LedgerReplaceOptions {
65
+ /** Copy the current persisted ledger here before overwriting it. */
66
+ backupPath?: string
67
+ }
68
+
69
+ /** Generic ledger schema currently accepted by strict loading. */
70
+ export const WORKSPACE_LEDGER_SCHEMA_VERSION = 1 as const
71
+
68
72
  /**
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.
73
+ * The generic ledger. The in-memory state changes only after an atomic save,
74
+ * and every public observation is detached from that state.
72
75
  */
73
76
  export class WorkspaceLedger {
74
77
  private records: WorkspaceRecord[] | undefined
@@ -93,13 +96,21 @@ export class WorkspaceLedger {
93
96
  }
94
97
 
95
98
  private async readRecords(): Promise<void> {
99
+ let parsed: unknown
96
100
  try {
97
101
  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
+ parsed = JSON.parse(text) as unknown
103
+ } catch (error) {
104
+ if (isNodeErrorCode(error, 'ENOENT')) {
105
+ this.records = []
106
+ this.reindex()
107
+ return
108
+ }
109
+ throw error
102
110
  }
111
+ if (!Array.isArray(parsed)) throw new Error('WorkspaceLedger: expected a JSON array')
112
+ assertValidRecords(parsed)
113
+ this.records = cloneRecords(parsed)
103
114
  this.reindex()
104
115
  }
105
116
 
@@ -111,13 +122,34 @@ export class WorkspaceLedger {
111
122
  }
112
123
 
113
124
  private async save(nextRecords: readonly WorkspaceRecord[]): Promise<void> {
125
+ assertValidRecords(nextRecords)
114
126
  const target = this.file()
115
127
  if (target === '') throw new Error('WorkspaceLedger: no persistence file configured')
116
- await mkdir(join(target, '..'), { recursive: true })
128
+ await mkdir(dirname(target), { recursive: true })
117
129
  const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
130
+ const lastGood = `${target}.last-good`
118
131
  try {
119
132
  await writeFile(temporary, JSON.stringify(nextRecords, null, 2), 'utf8')
133
+ // Recovery net: keep a known-good copy beside the ledger so a MISSING or
134
+ // UNPARSEABLE ledger can be rebuilt at startup (recoverGenericLedger), so
135
+ // removing the file can no longer silently drop every workspace.
136
+ //
137
+ // Trade-off, stated exactly: the copy is taken BEFORE the rename, so it
138
+ // holds the PREVIOUS committed state — recovering from it loses the LAST
139
+ // committed mutation. That is deliberate: a copy mirroring the new state
140
+ // would be worthless precisely when the new state is what got corrupted.
141
+ // (On the very first commit there is nothing to copy, so the new state is
142
+ // seeded.) Best-effort: it must never block the real write.
143
+ const hadPrevious = await copyFile(target, lastGood).then(() => true, (error: unknown) => {
144
+ if (!isNodeErrorCode(error, 'ENOENT')) {
145
+ console.warn(`[dsh-hardssh] could not refresh the ledger recovery copy: ${error instanceof Error ? error.message : String(error)}`)
146
+ }
147
+ return false
148
+ })
120
149
  await renameFile(temporary, target)
150
+ if (!hadPrevious) {
151
+ await copyFile(target, lastGood).catch(() => undefined)
152
+ }
121
153
  } catch (error) {
122
154
  await rm(temporary, { force: true }).catch(() => undefined)
123
155
  throw error
@@ -131,7 +163,7 @@ export class WorkspaceLedger {
131
163
  }
132
164
 
133
165
  private commit(nextRecords: WorkspaceRecord[], change: LedgerChangeWithoutRevision): void {
134
- this.records = nextRecords
166
+ this.records = cloneRecords(nextRecords)
135
167
  this.reindex()
136
168
  this.currentRevision += 1
137
169
  this.emit({ ...change, revision: this.currentRevision } as LedgerChange)
@@ -163,7 +195,7 @@ export class WorkspaceLedger {
163
195
  }
164
196
 
165
197
  snapshotSync(): LedgerSnapshot {
166
- return { revision: this.currentRevision, records: this.records ?? [] }
198
+ return { revision: this.currentRevision, records: cloneRecords(this.records ?? []) }
167
199
  }
168
200
 
169
201
  revision(): number {
@@ -200,15 +232,14 @@ export class WorkspaceLedger {
200
232
  return undefined
201
233
  }
202
234
 
203
- /** Create a record: materialize the anchor (managed mode), persist, commit. */
204
- async create(input: Omit<WorkspaceRecord, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }): Promise<WorkspaceRecord> {
235
+ /** Create cannot preserve caller timestamps, so migration uses replaceAll instead. */
236
+ async create(input: WorkspaceCreateInput): Promise<WorkspaceRecord> {
205
237
  return this.enqueueMutation(async () => {
206
238
  await this.ensureLoaded()
207
239
  const now = new Date().toISOString()
208
- const id = input.id ?? randomUUID()
209
- const record: WorkspaceRecord = {
240
+ const record: WorkspaceRecord = cloneRecord({
210
241
  schemaVersion: input.schemaVersion,
211
- id,
242
+ id: input.id ?? randomUUID(),
212
243
  title: input.title,
213
244
  provider: input.provider,
214
245
  location: input.location,
@@ -217,11 +248,12 @@ export class WorkspaceLedger {
217
248
  updatedAt: now,
218
249
  labels: input.labels,
219
250
  extensions: input.extensions,
220
- }
251
+ })
252
+ const nextRecords = [...(this.records ?? []), record]
253
+ assertValidRecords(nextRecords)
221
254
  if (record.anchor !== undefined && record.anchor.mode === 'managed') {
222
255
  await mkdir(record.anchor.path, { recursive: true })
223
256
  }
224
- const nextRecords = [...(this.records ?? []), record]
225
257
  await this.save(nextRecords)
226
258
  this.commit(nextRecords, { type: 'created', record })
227
259
  return cloneRecord(record)
@@ -229,21 +261,50 @@ export class WorkspaceLedger {
229
261
  }
230
262
 
231
263
  async rename(id: string, title: string): Promise<WorkspaceRecord | undefined> {
264
+ const before = await this.get(id)
265
+ const record = await this.update(id, { title: title.trim() })
266
+ if (before === undefined || record === undefined) return undefined
267
+ return record
268
+ }
269
+
270
+ /** WorkspaceLedger.rename cannot change provider bindings or extension data, so update applies a generic patch. */
271
+ async update(id: string, patch: WorkspaceUpdate): Promise<WorkspaceRecord | undefined> {
232
272
  return this.enqueueMutation(async () => {
233
273
  await this.ensureLoaded()
234
274
  const records = this.records ?? []
235
275
  const index = records.findIndex(record => record.id === id)
236
276
  if (index < 0) return undefined
237
- const before = records[index]
238
- const record: WorkspaceRecord = { ...before, title: title.trim(), updatedAt: new Date().toISOString() }
277
+ const before = cloneRecord(records[index]!)
278
+ const record = cloneRecord({ ...before, ...structuredClone(patch), id: before.id, schemaVersion: before.schemaVersion, createdAt: before.createdAt, updatedAt: new Date().toISOString() })
239
279
  const nextRecords = [...records]
240
280
  nextRecords[index] = record
281
+ assertValidRecords(nextRecords)
241
282
  await this.save(nextRecords)
242
- this.commit(nextRecords, { type: 'renamed', before, record })
283
+ this.commit(nextRecords, { type: 'updated', before, record })
243
284
  return cloneRecord(record)
244
285
  })
245
286
  }
246
287
 
288
+ /** WorkspaceLedger.create persists one generated record, so it cannot atomically import a complete authoritative snapshot. */
289
+ async replaceAll(records: readonly WorkspaceRecord[], options: LedgerReplaceOptions = {}): Promise<void> {
290
+ return this.enqueueMutation(async () => {
291
+ await this.ensureLoaded()
292
+ const nextRecords = cloneRecords(records)
293
+ assertValidRecords(nextRecords)
294
+ const before = cloneRecords(this.records ?? [])
295
+ if (options.backupPath !== undefined) {
296
+ await mkdir(dirname(options.backupPath), { recursive: true })
297
+ try {
298
+ await copyFile(this.file(), options.backupPath)
299
+ } catch (error) {
300
+ if (!isNodeErrorCode(error, 'ENOENT')) throw error
301
+ }
302
+ }
303
+ await this.save(nextRecords)
304
+ this.commit(nextRecords, { type: 'replaced', before, records: nextRecords })
305
+ })
306
+ }
307
+
247
308
  /** Remove a record. The anchor directory is left in place (caller decides). */
248
309
  async remove(id: string): Promise<boolean> {
249
310
  return this.enqueueMutation(async () => {
@@ -251,7 +312,7 @@ export class WorkspaceLedger {
251
312
  const records = this.records ?? []
252
313
  const index = records.findIndex(record => record.id === id)
253
314
  if (index < 0) return false
254
- const record = records[index]
315
+ const record = records[index]!
255
316
  const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)]
256
317
  await this.save(nextRecords)
257
318
  this.commit(nextRecords, { type: 'removed', record })
@@ -264,18 +325,77 @@ export class WorkspaceLedger {
264
325
  }
265
326
  }
266
327
 
328
+ /** isRecord validates one shape only, so this validator also rejects duplicate ids and ambiguous anchors across records. */
329
+ export function assertValidRecords(values: readonly unknown[]): asserts values is readonly WorkspaceRecord[] {
330
+ const ids = new Set<string>()
331
+ const anchors: Array<{ id: string; path: string }> = []
332
+ for (const value of values) {
333
+ if (!isRecord(value)) throw new Error('WorkspaceLedger: invalid workspace record')
334
+ if (ids.has(value.id)) throw new Error(`WorkspaceLedger: duplicate workspace id '${value.id}'`)
335
+ ids.add(value.id)
336
+ if (value.anchor !== undefined) anchors.push({ id: value.id, path: normalizeAnchorPath(value.anchor.path) })
337
+ }
338
+ for (let left = 0; left < anchors.length; left += 1) {
339
+ for (let right = left + 1; right < anchors.length; right += 1) {
340
+ const a = anchors[left]!
341
+ const b = anchors[right]!
342
+ if (isPathUnderAnchor(a.path, b.path) || isPathUnderAnchor(b.path, a.path)) {
343
+ throw new Error(`WorkspaceLedger: duplicate or overlapping anchors for '${a.id}' and '${b.id}'`)
344
+ }
345
+ }
346
+ }
347
+ }
348
+
267
349
  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'
350
+ if (!isPlainObject(value)) return false
351
+ const provider = value.provider
352
+ const location = value.location
353
+ const anchor = value.anchor
354
+ const labels = value.labels
355
+ const extensions = value.extensions
356
+ if (value.schemaVersion !== WORKSPACE_LEDGER_SCHEMA_VERSION
357
+ || typeof value.id !== 'string' || value.id === ''
358
+ || typeof value.title !== 'string'
359
+ || !isPlainObject(provider) || typeof provider.id !== 'string' || provider.id === ''
360
+ || (provider.instanceId !== undefined && typeof provider.instanceId !== 'string')
361
+ || !isConnectionRef(provider.connectionRef)
362
+ || !isPlainObject(location) || typeof location.kind !== 'string' || location.kind === '' || typeof location.root !== 'string' || location.root === ''
363
+ || (location.options !== undefined && (!isPlainObject(location.options) || !isJsonValue(location.options)))
364
+ || !isAnchor(anchor)
365
+ || typeof value.createdAt !== 'string' || typeof value.updatedAt !== 'string'
366
+ || (labels !== undefined && (!isPlainObject(labels) || Object.values(labels).some(label => typeof label !== 'string')))
367
+ || (extensions !== undefined && (!isPlainObject(extensions) || !isJsonValue(extensions)))) return false
368
+ return true
369
+ }
370
+
371
+ /** isRecord cannot distinguish an absent connectionRef from a malformed present one without this focused guard. */
372
+ function isConnectionRef(value: unknown): boolean {
373
+ return value === undefined || (isPlainObject(value)
374
+ && typeof value.id === 'string' && value.id !== ''
375
+ && (value.alias === undefined || typeof value.alias === 'string'))
376
+ }
377
+
378
+ /** isRecord delegates optional anchor validation here because anchor has a discriminated mode shape. */
379
+ function isAnchor(value: unknown): boolean {
380
+ return value === undefined || (isPlainObject(value)
381
+ && typeof value.path === 'string' && value.path !== ''
382
+ && (value.mode === 'managed' || value.mode === 'existing'))
383
+ }
384
+
385
+ /** typeof object also accepts arrays and prototypes, so isRecord needs this stricter object check. */
386
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
387
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
388
+ const prototype = Object.getPrototypeOf(value) as unknown
389
+ return prototype === Object.prototype || prototype === null
390
+ }
391
+
392
+ /** isRecord cannot validate arbitrarily nested options/extensions inline, so this recursive JSON-value guard closes that gap. */
393
+ function isJsonValue(value: unknown): boolean {
394
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
395
+ if (typeof value === 'number') return Number.isFinite(value)
396
+ if (Array.isArray(value)) return value.every(isJsonValue)
397
+ if (!isPlainObject(value)) return false
398
+ return Object.values(value).every(isJsonValue)
279
399
  }
280
400
 
281
401
  function safeRealpathSync(path: string): string | undefined {
@@ -283,7 +403,7 @@ function safeRealpathSync(path: string): string | undefined {
283
403
  }
284
404
 
285
405
  function cloneRecord(record: WorkspaceRecord): WorkspaceRecord {
286
- return { ...record, provider: { ...record.provider }, location: { ...record.location } }
406
+ return structuredClone(record)
287
407
  }
288
408
 
289
409
  function cloneRecords(records: readonly WorkspaceRecord[]): WorkspaceRecord[] {
@@ -294,6 +414,13 @@ function cloneChange(change: LedgerChange): LedgerChange {
294
414
  switch (change.type) {
295
415
  case 'created': return { ...change, record: cloneRecord(change.record) }
296
416
  case 'renamed': return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
417
+ case 'updated': return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
297
418
  case 'removed': return { ...change, record: cloneRecord(change.record) }
419
+ case 'replaced': return { ...change, before: cloneRecords(change.before), records: cloneRecords(change.records) }
298
420
  }
299
- }
421
+ }
422
+
423
+ /** Error.message cannot reliably identify ENOENT, so strict loading checks Node's machine-readable code. */
424
+ function isNodeErrorCode(error: unknown, code: string): boolean {
425
+ return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === code
426
+ }
package/src/base/model.ts CHANGED
@@ -69,14 +69,24 @@ export interface WorkspaceRecord {
69
69
  extensions?: Record<string, unknown>
70
70
  }
71
71
 
72
- /** One provider capability key (extensible via module augmentation). */
72
+ /** Provider API major implemented by the generic workspace foundations. */
73
+ export const WORKSPACE_PROVIDER_API_VERSION = 2 as const
74
+
75
+ /**
76
+ * Provider capability augmentation point. Runtime packages add their official
77
+ * capability types here; the platform-neutral base deliberately defines none.
78
+ */
73
79
  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
80
+ /** Unaugmented providers may carry private keys; runtime-known keys gain precise types through augmentation. */
81
+ [capability: string]: unknown
78
82
  }
79
83
 
84
+ /** Input accepted when creating a record; identity and timestamps are ledger-owned. */
85
+ export type WorkspaceCreateInput = Omit<WorkspaceRecord, 'id' | 'createdAt' | 'updatedAt'> & { id?: WorkspaceId }
86
+
87
+ /** Mutable record fields; identity, schema, and creation time remain immutable. */
88
+ export type WorkspaceUpdate = Partial<Pick<WorkspaceRecord, 'title' | 'provider' | 'location' | 'anchor' | 'labels' | 'extensions'>>
89
+
80
90
  /** A resolved, open connection to one workspace's provider. */
81
91
  export interface WorkspaceConnection {
82
92
  readonly workspaceId: WorkspaceId
@@ -101,8 +111,8 @@ export interface WorkspaceProvider {
101
111
  readonly manifest: WorkspaceProviderManifest
102
112
  /** Validate a record before open (e.g. root shape, ref resolvable). */
103
113
  validate(record: WorkspaceRecord): void | Promise<void>
104
- /** Open a connection for one record. */
105
- open(record: WorkspaceRecord, context?: WorkspaceOpenContext): Promise<WorkspaceConnection>
114
+ /** Open a connection for one record under the router-owned lifecycle context. */
115
+ open(record: WorkspaceRecord, context: WorkspaceOpenContext): Promise<WorkspaceConnection>
106
116
  }
107
117
 
108
118
  /** Static provider identity, checked before any plugin code runs. */
@@ -114,5 +124,5 @@ export interface WorkspaceProviderManifest {
114
124
  apiVersion: number
115
125
  displayName: string
116
126
  /** Capabilities this provider implements. */
117
- capabilities: Array<keyof WorkspaceCapabilityMap | string>
127
+ capabilities: string[]
118
128
  }
@@ -84,6 +84,17 @@ export interface WorkspacePlugin {
84
84
  /**
85
85
  * Registry of loaded plugins (provider + feature plugins together). Keeps
86
86
  * the workspace registry and plugin lifecycle in one place.
87
+ *
88
+ * NOT WIRED INTO PRODUCTION YET: the DSH runtime registers the builtin
89
+ * providers directly (`runtime/workspace-core.ts`) and no third-party plugin
90
+ * loads through this host — only tests do. It is kept because it is the
91
+ * documented distribution contract for out-of-tree providers (see SKILLS.md);
92
+ * provider teardown stays ownership-correct for when it is wired up: each
93
+ * registration releases the disposer that `register()` returned for THAT call,
94
+ * so a duplicate (same id+version) registration never unregisters a provider it
95
+ * does not own.
96
+ *
97
+ * @internal Not reachable from any production assembly today.
87
98
  */
88
99
  export class WorkspacePluginHost {
89
100
  private readonly plugins = new Map<string, WorkspacePlugin>()
@@ -144,14 +155,14 @@ export class WorkspacePluginHost {
144
155
  }
145
156
 
146
157
  private registerProvider(provider: WorkspaceProvider): () => void {
147
- // The registry returns a disposer on register; a same-version duplicate
148
- // no-ops, so the disposer may be a no-op too — unload still runs it.
158
+ // Preserve registry ownership: a duplicate registration's no-op disposer
159
+ // must never unregister the provider owned by another plugin.
160
+ const disposeRegistration = this.registries.workspaces.register(provider)
149
161
  let disposed = false
150
- this.registries.workspaces.register(provider)
151
162
  return () => {
152
163
  if (disposed) return
153
164
  disposed = true
154
- this.registries.workspaces.unregister?.(provider.manifest.id)
165
+ disposeRegistration()
155
166
  }
156
167
  }
157
168
 
@@ -1,20 +1,13 @@
1
- /**
2
- * Workspace provider registry — the single place providers register and are
3
- * looked up by id. The registry is provider-agnostic: SSH, local, and any
4
- * future provider all register here, and the switch layer resolves
5
- * workspaces through the registry without knowing which provider backs them.
6
- *
7
- * Registration is idempotent per (id, version, apiVersion): re-registering
8
- * the same provider is a no-op / reuse; registering a different or
9
- * incompatible implementation with the same id is an error.
10
- *
11
- * @module @tiphareth/dsh-hardssh/base/registry
12
- */
1
+ /** Provider-neutral registry with observable, ownership-safe registration lifecycle. */
13
2
 
14
3
  import type { WorkspaceProvider, WorkspaceProviderManifest } from './model.ts'
15
4
 
16
- /** Fired when a provider registers (for surface mounts / cache invalidation). */
17
- export type ProviderListener = (provider: WorkspaceProvider) => void
5
+ /** Provider registration lifecycle event consumed by routers. */
6
+ export type ProviderChange =
7
+ | { type: 'registered'; provider: WorkspaceProvider }
8
+ | { type: 'unregistered'; provider: WorkspaceProvider }
9
+
10
+ export type ProviderListener = (change: ProviderChange) => void
18
11
 
19
12
  /** Duplicate / incompatible registration error. */
20
13
  export class ProviderRegistrationError extends Error {
@@ -29,7 +22,7 @@ export class WorkspaceProviderRegistry {
29
22
  private readonly providers = new Map<string, WorkspaceProvider>()
30
23
  private readonly listeners = new Set<ProviderListener>()
31
24
 
32
- /** Register a provider. Throws on id collision with an incompatible impl. */
25
+ /** Register a provider and return a disposer that only removes that exact registration. */
33
26
  register(provider: WorkspaceProvider): () => void {
34
27
  const id = provider.manifest.id
35
28
  const existing = this.providers.get(id)
@@ -41,15 +34,18 @@ export class WorkspaceProviderRegistry {
41
34
  `provider '${id}' already registered (${existing.manifest.version}, api ${existing.manifest.apiVersion}); refusing incompatible duplicate`,
42
35
  )
43
36
  }
44
- // Same version: reuse. No-op.
45
- return () => { /* already registered */ }
37
+ return () => { /* The original registration retains ownership. */ }
46
38
  }
47
39
  this.providers.set(id, provider)
48
- const disposer = () => { this.providers.delete(id) }
49
- for (const listener of [...this.listeners]) {
50
- try { listener(provider) } catch { /* listener isolation */ }
40
+ this.emit({ type: 'registered', provider })
41
+ let disposed = false
42
+ return () => {
43
+ if (disposed) return
44
+ disposed = true
45
+ if (this.providers.get(id) !== provider) return
46
+ this.providers.delete(id)
47
+ this.emit({ type: 'unregistered', provider })
51
48
  }
52
- return disposer
53
49
  }
54
50
 
55
51
  /** Look up a provider by id. */
@@ -57,10 +53,19 @@ export class WorkspaceProviderRegistry {
57
53
  return this.providers.get(id)
58
54
  }
59
55
 
60
- /** Unregister a provider by id (no-op when absent or different version). */
56
+ /**
57
+ * Remove a provider by id, whoever registered it.
58
+ *
59
+ * Ownership-unsafe by construction: callers that registered a provider must
60
+ * use the disposer returned by register() instead, otherwise teardown can
61
+ * delete a provider owned by another plugin. Kept for whole-registry
62
+ * operations (tests, process-level resets).
63
+ */
61
64
  unregister(id: string): void {
62
- if (!this.providers.has(id)) return
65
+ const provider = this.providers.get(id)
66
+ if (provider === undefined) return
63
67
  this.providers.delete(id)
68
+ this.emit({ type: 'unregistered', provider })
64
69
  }
65
70
 
66
71
  /** All registered providers. */
@@ -68,10 +73,15 @@ export class WorkspaceProviderRegistry {
68
73
  return [...this.providers.values()]
69
74
  }
70
75
 
71
- /** Subscribe to registrations. Returns a disposer. */
76
+ /** Subscribe must expose both registration and removal, which the old registration-only listener could not report. */
72
77
  subscribe(listener: ProviderListener): () => void {
73
78
  this.listeners.add(listener)
74
- return () => { this.listeners.delete(listener) }
79
+ let disposed = false
80
+ return () => {
81
+ if (disposed) return
82
+ disposed = true
83
+ this.listeners.delete(listener)
84
+ }
75
85
  }
76
86
 
77
87
  /** True when a provider id is registered. */
@@ -83,27 +93,25 @@ export class WorkspaceProviderRegistry {
83
93
  manifestOf(id: string): WorkspaceProviderManifest | undefined {
84
94
  return this.providers.get(id)?.manifest
85
95
  }
86
- }
87
96
 
88
- /** The process-wide shared registry. */
89
- export const globalWorkspaceRegistry = new WorkspaceProviderRegistry()
97
+ /** register() cannot safely repeat listener isolation for both lifecycle directions, so emit centralizes it. */
98
+ private emit(change: ProviderChange): void {
99
+ for (const listener of [...this.listeners]) {
100
+ try { listener(change) } catch { /* listener isolation */ }
101
+ }
102
+ }
103
+ }
90
104
 
91
- /**
92
- * Workspace registry surface used by plugins: list / watch workspace records
93
- * and register providers. Implemented by the runtime over the ledger + the
94
- * provider registry.
95
- */
105
+ /** Plugin-facing workspace registry surface. */
96
106
  export interface WorkspaceRegistry {
97
- /** All known workspace records (provider-agnostic). */
98
107
  listWorkspaces(): Promise<import('./model.ts').WorkspaceRecord[]>
99
- /** Subscribe to workspace record changes. Returns a disposer. */
100
108
  subscribe(listener: (workspace: import('./model.ts').WorkspaceRecord) => void): () => void
101
- /** Register a provider (idempotent same-version; throws on conflict). */
102
- register(provider: WorkspaceProvider): void
103
- /** Unregister a provider by id (no-op when absent). */
109
+ register(provider: WorkspaceProvider): () => void
110
+ /**
111
+ * Ownership-unsafe id-based removal; plugin teardown must release the
112
+ * disposer returned by `register()` instead of calling this.
113
+ */
104
114
  unregister?(providerId: string): void
105
- /** Look up a provider by id. */
106
115
  provider(id: string): WorkspaceProvider | undefined
107
- /** All registered providers. */
108
116
  providers(): WorkspaceProvider[]
109
- }
117
+ }
@@ -32,12 +32,4 @@ export interface WorkspaceRouter {
32
32
  codec: WorkspaceNamespaceCodec
33
33
  /** Map a route back to a resolution (used by the switch to re-encode child keys). */
34
34
  resolveRoute(route: WorkspaceRoute): WorkspaceResolution | undefined
35
- }
36
-
37
- /** Convenience base: default codec wiring plus helper for resolution lookup. */
38
- export abstract class BaseWorkspaceRouter implements WorkspaceRouter {
39
- abstract readonly codec: WorkspaceNamespaceCodec
40
- abstract fromNamespace(key: string): WorkspaceResolution | undefined
41
- abstract fromAnchor(cwd: string | undefined): WorkspaceConnection | undefined
42
- abstract resolveRoute(route: WorkspaceRoute): WorkspaceResolution | undefined
43
35
  }