@tiphareth/dsh-hardssh 0.1.2 → 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.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +49 -25
  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 +307 -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 +18 -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 +58 -25
  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 +137 -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
@@ -0,0 +1,472 @@
1
+ /** Phase 3 legacy-to-generic conversion and side-effect-free shadow comparison. */
2
+
3
+ import { createHash, randomUUID } from 'node:crypto'
4
+ import { readFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { dirname, join } from 'node:path'
6
+ import { assertValidRecords, isPathUnderAnchor, normalizeAnchorPath, WorkspaceLedger } from '../base/ledger.ts'
7
+ import { defaultNamespaceCodec, type WorkspaceNamespaceCodec } from '../base/namespace.ts'
8
+ import type { WorkspaceRecord } from '../base/model.ts'
9
+ import type { SshWorkspaceRecord } from '../protocol.ts'
10
+
11
+ /**
12
+ * Which runtime the migration is preparing. The legacy runtime is gone — the
13
+ * generic core is the ONLY production runtime — so this has a single value; it
14
+ * stays a named field so the cutover marker and the migration report record
15
+ * which runtime wrote them.
16
+ */
17
+ export type WorkspaceRuntimeMode = 'generic'
18
+
19
+ export interface WorkspaceMigrationOptions {
20
+ mode: WorkspaceRuntimeMode
21
+ legacyPath: string
22
+ genericPath: string
23
+ reportPath?: string
24
+ now?: () => Date
25
+ }
26
+
27
+ export interface WorkspaceMigrationReport {
28
+ schemaVersion: 1
29
+ mode: WorkspaceRuntimeMode
30
+ createdAt: string
31
+ status: 'migrated' | 'unchanged'
32
+ sourceDigest: string
33
+ targetDigest: string
34
+ recordCount: number
35
+ ids: string[]
36
+ backupPath?: string
37
+ differences: string[]
38
+ }
39
+
40
+ export interface ShadowCapabilityObservation {
41
+ providerId: string
42
+ manifestCapabilities: readonly string[]
43
+ availableCapabilities: readonly string[]
44
+ }
45
+
46
+ export interface WorkspaceShadowComparisonInput {
47
+ legacy: readonly SshWorkspaceRecord[]
48
+ generic: readonly WorkspaceRecord[]
49
+ anchorProbes?: readonly string[]
50
+ namespaceProbes?: readonly string[]
51
+ codec?: WorkspaceNamespaceCodec
52
+ capabilities?: readonly ShadowCapabilityObservation[]
53
+ }
54
+
55
+ export interface WorkspaceShadowComparisonReport {
56
+ matches: boolean
57
+ differences: string[]
58
+ }
59
+
60
+ /** The generic ledger's create() generates identity and time fields, so it cannot preserve a legacy record exactly; this mapper exists to retain the source id/anchor/timestamp verbatim. */
61
+ export function legacySshRecordToWorkspaceRecord(record: SshWorkspaceRecord): WorkspaceRecord {
62
+ assertLegacyRecord(record)
63
+ return {
64
+ schemaVersion: 1,
65
+ id: record.id,
66
+ title: record.title,
67
+ provider: {
68
+ id: 'ssh',
69
+ connectionRef: { id: record.alias, alias: record.alias },
70
+ },
71
+ location: { kind: 'posix', root: record.remoteRoot },
72
+ anchor: { path: record.anchorPath, mode: 'managed' },
73
+ createdAt: record.createdAt,
74
+ updatedAt: record.createdAt,
75
+ }
76
+ }
77
+
78
+ /** Repeated WorkspaceLedger.create() calls cannot provide an all-or-nothing full-snapshot conversion with one backup. */
79
+ export async function migrateLegacySshLedger(options: WorkspaceMigrationOptions): Promise<WorkspaceMigrationReport> {
80
+ const now = options.now?.() ?? new Date()
81
+ const legacyBytes = await readLegacyBytes(options.legacyPath)
82
+ const legacy = parseLegacyRecords(legacyBytes.toString('utf8'))
83
+ const mapped = legacy.map(legacySshRecordToWorkspaceRecord)
84
+ assertValidRecords(mapped)
85
+
86
+ const ledger = new WorkspaceLedger(options.genericPath)
87
+ const existing = await ledger.list()
88
+ const existingById = new Map(existing.map(record => [record.id, record]))
89
+ const mappedIds = new Set(mapped.map(record => record.id))
90
+ for (const record of mapped) {
91
+ const current = existingById.get(record.id)
92
+ if (current !== undefined && stableJson(current) !== stableJson(record)) {
93
+ throw new Error(`Workspace migration conflict for id '${record.id}'`)
94
+ }
95
+ }
96
+ for (const record of existing) {
97
+ if (record.provider.id === 'ssh' && !mappedIds.has(record.id)) {
98
+ throw new Error(`Workspace migration refuses to remove existing SSH id '${record.id}'`)
99
+ }
100
+ if (record.provider.id !== 'ssh' && mappedIds.has(record.id)) {
101
+ throw new Error(`Workspace migration id '${record.id}' is owned by provider '${record.provider.id}'`)
102
+ }
103
+ }
104
+
105
+ const retained = existing.filter(record => record.provider.id !== 'ssh')
106
+ const next = [...retained, ...mapped]
107
+ assertValidRecords(next)
108
+ const unchanged = stableRecordSet(existing) === stableRecordSet(next)
109
+ let backupPath: string | undefined
110
+ if (!unchanged) {
111
+ if (await fileExists(options.genericPath)) backupPath = `${options.genericPath}.backup-${safeTimestamp(now)}`
112
+ await ledger.replaceAll(next, { backupPath })
113
+ }
114
+
115
+ const committed = await ledger.list()
116
+ const differences = verifyLegacyProjection(legacy, committed)
117
+ if (differences.length > 0) throw new Error(`Workspace migration verification failed: ${differences.join('; ')}`)
118
+ const report: WorkspaceMigrationReport = {
119
+ schemaVersion: 1,
120
+ mode: options.mode,
121
+ createdAt: now.toISOString(),
122
+ status: unchanged ? 'unchanged' : 'migrated',
123
+ sourceDigest: sha256(legacyBytes),
124
+ targetDigest: sha256(Buffer.from(stableJson(committed))),
125
+ recordCount: mapped.length,
126
+ ids: mapped.map(record => record.id).sort(),
127
+ backupPath,
128
+ differences,
129
+ }
130
+ if (options.reportPath !== undefined) await writeReportAtomically(options.reportPath, report)
131
+ return structuredClone(report)
132
+ }
133
+
134
+ /** LedgerWorkspaceRouter.fromAnchor() cannot compare detached legacy/generic decisions, and capability get() must not run in shadow. */
135
+ export function compareWorkspaceShadow(input: WorkspaceShadowComparisonInput): WorkspaceShadowComparisonReport {
136
+ const differences = verifyLegacyProjection(input.legacy, input.generic)
137
+ const mapped = input.legacy.map(legacySshRecordToWorkspaceRecord)
138
+ for (const path of input.anchorProbes ?? []) {
139
+ const legacyId = resolveDetachedAnchor(input.legacy.map(record => ({ id: record.id, path: record.anchorPath })), path)
140
+ const genericId = resolveDetachedAnchor(input.generic.flatMap(record => record.anchor === undefined ? [] : [{ id: record.id, path: record.anchor.path }]), path)
141
+ if (legacyId !== genericId) differences.push(`anchor '${path}' resolved legacy=${legacyId ?? 'none'} generic=${genericId ?? 'none'}`)
142
+ }
143
+ const codec = input.codec ?? defaultNamespaceCodec
144
+ const genericIds = new Set(input.generic.map(record => record.id))
145
+ const legacyIds = new Set(mapped.map(record => record.id))
146
+ for (const key of input.namespaceProbes ?? []) {
147
+ const route = codec.decode(key)
148
+ const legacyId = route !== undefined && legacyIds.has(route.workspaceId) ? route.workspaceId : undefined
149
+ const genericId = route !== undefined && genericIds.has(route.workspaceId) ? route.workspaceId : undefined
150
+ if (legacyId !== genericId) differences.push(`namespace '${key}' resolved legacy=${legacyId ?? 'none'} generic=${genericId ?? 'none'}`)
151
+ }
152
+ for (const observation of input.capabilities ?? []) {
153
+ const declared = new Set(observation.manifestCapabilities)
154
+ const available = new Set(observation.availableCapabilities)
155
+ for (const key of new Set([...declared, ...available])) {
156
+ if (declared.has(key) !== available.has(key)) differences.push(`provider '${observation.providerId}' capability '${key}' declaration mismatch`)
157
+ }
158
+ }
159
+ return { matches: differences.length === 0, differences }
160
+ }
161
+
162
+ /** The generic ledger's load cannot distinguish malformed or unreadable input from an empty source, so conversion reads the frozen source bytes strictly. */
163
+ async function readLegacyBytes(path: string): Promise<Buffer> {
164
+ try {
165
+ return await readFile(path)
166
+ } catch (error) {
167
+ if (isNodeErrorCode(error, 'ENOENT')) return Buffer.from('[]')
168
+ throw error
169
+ }
170
+ }
171
+
172
+ /** JSON.parse() cannot validate the legacy record schema or cross-record collisions, so this parser performs both checks. */
173
+ function parseLegacyRecords(text: string): SshWorkspaceRecord[] {
174
+ const parsed = JSON.parse(text) as unknown
175
+ if (!Array.isArray(parsed)) throw new Error('Legacy workspace ledger must be a JSON array')
176
+ const records = parsed.map(value => {
177
+ assertLegacyRecord(value)
178
+ return structuredClone(value)
179
+ })
180
+ const ids = new Set<string>()
181
+ const anchors: string[] = []
182
+ for (const record of records) {
183
+ if (ids.has(record.id)) throw new Error(`Legacy workspace ledger has duplicate id '${record.id}'`)
184
+ ids.add(record.id)
185
+ const anchor = normalizeAnchorPath(record.anchorPath)
186
+ if (anchors.some(candidate => isPathUnderAnchor(candidate, anchor) || isPathUnderAnchor(anchor, candidate))) {
187
+ throw new Error(`Legacy workspace ledger has duplicate or overlapping anchor '${record.anchorPath}'`)
188
+ }
189
+ anchors.push(anchor)
190
+ }
191
+ return records
192
+ }
193
+
194
+ /** The generic ledger's isRecord() targets the generic schema only and does not validate absolute safe roots, so migration uses this strict legacy guard. */
195
+ function assertLegacyRecord(value: unknown): asserts value is SshWorkspaceRecord {
196
+ if (!isPlainObject(value)
197
+ || typeof value.id !== 'string' || value.id === ''
198
+ || typeof value.title !== 'string'
199
+ || typeof value.alias !== 'string' || value.alias === ''
200
+ || typeof value.remoteRoot !== 'string' || !value.remoteRoot.startsWith('/') || value.remoteRoot.includes('\0')
201
+ || typeof value.anchorPath !== 'string' || value.anchorPath === ''
202
+ || typeof value.createdAt !== 'string' || Number.isNaN(Date.parse(value.createdAt))) {
203
+ throw new Error('Legacy workspace ledger contains an invalid record')
204
+ }
205
+ }
206
+
207
+ /** WorkspaceLedger.get() cannot prove exact SSH count, id set, and every mapped field in one detached verification pass. */
208
+ function verifyLegacyProjection(legacy: readonly SshWorkspaceRecord[], generic: readonly WorkspaceRecord[]): string[] {
209
+ const differences: string[] = []
210
+ const ssh = generic.filter(record => record.provider.id === 'ssh')
211
+ if (ssh.length !== legacy.length) differences.push(`SSH record count differs: legacy=${legacy.length} generic=${ssh.length}`)
212
+ const genericById = new Map(ssh.map(record => [record.id, record]))
213
+ for (const source of legacy) {
214
+ const record = genericById.get(source.id)
215
+ if (record === undefined) {
216
+ differences.push(`missing generic id '${source.id}'`)
217
+ continue
218
+ }
219
+ const expected = legacySshRecordToWorkspaceRecord(source)
220
+ for (const field of ['title', 'provider', 'location', 'anchor', 'createdAt', 'updatedAt'] as const) {
221
+ if (stableJson(record[field]) !== stableJson(expected[field])) differences.push(`id '${source.id}' differs at ${field}`)
222
+ }
223
+ }
224
+ for (const record of ssh) {
225
+ if (!legacy.some(source => source.id === record.id)) differences.push(`unknown generic SSH id '${record.id}'`)
226
+ }
227
+ return differences
228
+ }
229
+
230
+ /** WorkspaceLedger.findByAnchorSync() requires filesystem state, so shadow comparison resolves detached snapshots lexically. */
231
+ function resolveDetachedAnchor(records: readonly { id: string; path: string }[], candidate: string): string | undefined {
232
+ return [...records]
233
+ .map(record => ({ ...record, normalized: normalizeAnchorPath(record.path) }))
234
+ .sort((left, right) => right.normalized.length - left.normalized.length)
235
+ .find(record => isPathUnderAnchor(record.normalized, candidate))?.id
236
+ }
237
+
238
+ /** WorkspaceLedger.snapshot() has no canonical digest, so stableRecordSet sorts records before canonical serialization. */
239
+ function stableRecordSet(records: readonly WorkspaceRecord[]): string {
240
+ return stableJson([...records].sort((left, right) => left.id.localeCompare(right.id)))
241
+ }
242
+
243
+ /** JSON.stringify() depends on insertion order, so stableJson recursively sorts object keys before conflict and digest checks. */
244
+ function stableJson(value: unknown): string {
245
+ return JSON.stringify(canonicalize(value))
246
+ }
247
+
248
+ /** stableJson() needs key-order-independent values, which WorkspaceLedger's deep clone does not canonicalize. */
249
+ function canonicalize(value: unknown): unknown {
250
+ if (Array.isArray(value)) return value.map(canonicalize)
251
+ if (!isPlainObject(value)) return value
252
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalize(value[key])]))
253
+ }
254
+
255
+ /** assertLegacyRecord() needs to reject arrays and exotic prototypes, which typeof object cannot distinguish. */
256
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
257
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
258
+ const prototype = Object.getPrototypeOf(value) as unknown
259
+ return prototype === Object.prototype || prototype === null
260
+ }
261
+
262
+ /** WorkspaceLedger.snapshot() does not expose source bytes, so sha256 records an independent migration source/target digest. */
263
+ function sha256(value: Uint8Array): string {
264
+ return createHash('sha256').update(value).digest('hex')
265
+ }
266
+
267
+ /** WorkspaceLedger.replaceAll() cannot persist the separate migration report schema, so this helper publishes it atomically. */
268
+ async function writeReportAtomically(path: string, report: WorkspaceMigrationReport): Promise<void> {
269
+ await mkdir(dirname(path), { recursive: true })
270
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`
271
+ try {
272
+ await writeFile(temporary, JSON.stringify(report, null, 2), 'utf8')
273
+ await rename(temporary, path)
274
+ } catch (error) {
275
+ await rm(temporary, { force: true }).catch(() => undefined)
276
+ throw error
277
+ }
278
+ }
279
+
280
+ /** WorkspaceLedger.replaceAll() treats a missing target specially only during backup, so migration checks whether to report a backup path. */
281
+ async function fileExists(path: string): Promise<boolean> {
282
+ try {
283
+ await stat(path)
284
+ return true
285
+ } catch (error) {
286
+ if (isNodeErrorCode(error, 'ENOENT')) return false
287
+ throw error
288
+ }
289
+ }
290
+
291
+ /** Date.toISOString() contains filename-hostile colons on Windows, so migration backup names need a portable timestamp. */
292
+ function safeTimestamp(value: Date): string {
293
+ return value.toISOString().replace(/[:.]/g, '-')
294
+ }
295
+
296
+ /** Error.message cannot reliably identify ENOENT, so strict migration reads inspect Node's machine-readable code. */
297
+ function isNodeErrorCode(error: unknown, code: string): boolean {
298
+ return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === code
299
+ }
300
+
301
+ /** Where a recovered generic ledger came from. */
302
+ export type GenericLedgerRecoverySource = 'last-good' | 'backup' | 'legacy'
303
+
304
+ /** Outcome of one missing-ledger recovery attempt. */
305
+ export interface GenericLedgerRecoveryResult {
306
+ recovered: boolean
307
+ source?: GenericLedgerRecoverySource
308
+ /** The file the records were restored from (absent for a legacy re-import). */
309
+ path?: string
310
+ /** How many records the restored ledger holds. */
311
+ recordCount?: number
312
+ /** Why recovery failed, when it did. */
313
+ reason?: string
314
+ }
315
+
316
+ /** Options for {@link recoverGenericLedger}. */
317
+ export interface GenericLedgerRecoveryOptions {
318
+ /** The missing/unreadable generic ledger target. */
319
+ genericPath: string
320
+ /** The frozen legacy SSH ledger, the last-resort source. */
321
+ legacyPath: string
322
+ /**
323
+ * Record ids the cutover marker proves existed. A candidate that cannot
324
+ * produce ALL of them is rejected: recovery must never silently restore a
325
+ * SMALLER workspace set than the marker recorded — that would look like a
326
+ * successful recovery while quietly losing workspaces. An empty/absent list
327
+ * disables the id check (count is still required to be non-zero).
328
+ */
329
+ expectedIds?: readonly string[]
330
+ /** Sink for recovery diagnostics (defaults to console.warn). */
331
+ log?: (message: string) => void
332
+ }
333
+
334
+ /** Whether a candidate record set satisfies the marker's expectations. */
335
+ function candidateProblem(records: readonly WorkspaceRecord[], expectedIds?: readonly string[]): string | undefined {
336
+ if (records.length === 0) return 'it holds no records'
337
+ if (expectedIds === undefined || expectedIds.length === 0) return undefined
338
+ const present = new Set(records.map(record => record.id))
339
+ const missing = expectedIds.filter(id => !present.has(id))
340
+ if (missing.length > 0) {
341
+ return `it is missing ${missing.length} of the ${expectedIds.length} workspace id(s) recorded by the cutover marker (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''})`
342
+ }
343
+ return undefined
344
+ }
345
+
346
+ /** Read and validate a ledger file; returns undefined when absent/unreadable. */
347
+ async function readLedgerRecords(path: string): Promise<WorkspaceRecord[] | undefined> {
348
+ try {
349
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as unknown
350
+ if (!Array.isArray(parsed)) return undefined
351
+ assertValidRecords(parsed)
352
+ return parsed.map(record => structuredClone(record))
353
+ } catch {
354
+ return undefined
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Classify the generic ledger file so the boot can tell "legitimately empty"
360
+ * (the user deleted every workspace, the file exists with `[]`) from "lost or
361
+ * damaged" (missing, truncated, or unparseable).
362
+ *
363
+ * @param path - the generic ledger path.
364
+ * @returns the ledger state, never throwing.
365
+ */
366
+ export async function inspectGenericLedger(path: string): Promise<'absent' | 'readable' | 'corrupt'> {
367
+ if (!(await fileExists(path))) return 'absent'
368
+ return (await readLedgerRecords(path)) === undefined ? 'corrupt' : 'readable'
369
+ }
370
+
371
+ /**
372
+ * Restore a generic ledger that the cutover marker proves should exist.
373
+ *
374
+ * The marker records that a migration committed N workspaces, so a missing or
375
+ * unreadable ledger is data loss — never a fresh deployment. Recovery is
376
+ * attempted newest-first: the rolling `.last-good` snapshot, then the newest
377
+ * `.backup-<timestamp>` left by a ledger replacement, then a re-import from the
378
+ * frozen legacy source. EVERY candidate must satisfy `expectedIds`; a source
379
+ * that would restore fewer workspaces than the marker recorded is rejected
380
+ * rather than accepted as success.
381
+ *
382
+ * The legacy tier deliberately migrates WITHOUT a report path: rewriting the
383
+ * marker there could downgrade it (an empty legacy source produces
384
+ * `recordCount: 0`) and permanently disarm the gate that asked for this
385
+ * recovery in the first place.
386
+ *
387
+ * @param options - paths, the expected ids, and an optional logger.
388
+ * @returns which source was used, or `recovered: false` plus the reason.
389
+ */
390
+ export async function recoverGenericLedger(options: GenericLedgerRecoveryOptions): Promise<GenericLedgerRecoveryResult> {
391
+ const log = options.log ?? ((message: string) => { console.warn(message) })
392
+ const candidates: Array<{ source: GenericLedgerRecoverySource; path: string }> = []
393
+ const lastGood = `${options.genericPath}.last-good`
394
+ if (await fileExists(lastGood)) candidates.push({ source: 'last-good', path: lastGood })
395
+ for (const backup of await listLedgerBackups(options.genericPath)) {
396
+ candidates.push({ source: 'backup', path: backup })
397
+ }
398
+
399
+ for (const candidate of candidates) {
400
+ const parsed = await readLedgerRecords(candidate.path)
401
+ if (parsed === undefined) {
402
+ log(`[dsh-hardssh] ledger recovery candidate '${candidate.path}' is unusable: not a valid workspace ledger`)
403
+ continue
404
+ }
405
+ const problem = candidateProblem(parsed, options.expectedIds)
406
+ if (problem !== undefined) {
407
+ log(`[dsh-hardssh] ledger recovery candidate '${candidate.path}' is insufficient: ${problem}`)
408
+ continue
409
+ }
410
+ try {
411
+ await mkdir(dirname(options.genericPath), { recursive: true })
412
+ const temporary = `${options.genericPath}.tmp-${process.pid}-${randomUUID()}`
413
+ await writeFile(temporary, JSON.stringify(parsed, null, 2), 'utf8')
414
+ await rename(temporary, options.genericPath)
415
+ } catch (error) {
416
+ const message = error instanceof Error ? error.message : String(error)
417
+ log(`[dsh-hardssh] ledger recovery from '${candidate.path}' failed while writing: ${message}`)
418
+ continue
419
+ }
420
+ log(`[dsh-hardssh] restored ${parsed.length} workspace(s) into '${options.genericPath}' from ${candidate.source} '${candidate.path}'`)
421
+ return { recovered: true, source: candidate.source, path: candidate.path, recordCount: parsed.length }
422
+ }
423
+
424
+ if (await fileExists(options.legacyPath)) {
425
+ try {
426
+ // reportPath omitted on purpose (see the doc comment above).
427
+ await migrateLegacySshLedger({
428
+ mode: 'generic',
429
+ legacyPath: options.legacyPath,
430
+ genericPath: options.genericPath,
431
+ })
432
+ } catch (error) {
433
+ const message = error instanceof Error ? error.message : String(error)
434
+ log(`[dsh-hardssh] ledger recovery could not re-import the legacy source '${options.legacyPath}': ${message}`)
435
+ return { recovered: false, reason: `legacy re-import failed: ${message}` }
436
+ }
437
+ const restored = await readLedgerRecords(options.genericPath)
438
+ if (restored === undefined) {
439
+ const reason = 'legacy source is insufficient: the re-import produced no readable ledger'
440
+ log(`[dsh-hardssh] ${reason} ('${options.legacyPath}')`)
441
+ return { recovered: false, reason }
442
+ }
443
+ const problem = candidateProblem(restored, options.expectedIds)
444
+ if (problem !== undefined) {
445
+ const reason = `legacy source is insufficient: ${problem}`
446
+ log(`[dsh-hardssh] ${reason} ('${options.legacyPath}')`)
447
+ return { recovered: false, reason }
448
+ }
449
+ log(`[dsh-hardssh] rebuilt '${options.genericPath}' by re-importing the legacy source '${options.legacyPath}' (${restored.length} workspace(s))`)
450
+ return { recovered: true, source: 'legacy', path: options.legacyPath, recordCount: restored.length }
451
+ }
452
+
453
+ return { recovered: false, reason: 'no .last-good copy, no backup, and no legacy source' }
454
+ }
455
+
456
+ /** Newest-first `.backup-<timestamp>` files beside one ledger target. */
457
+ async function listLedgerBackups(genericPath: string): Promise<string[]> {
458
+ const directory = dirname(genericPath)
459
+ const base = genericPath.slice(directory.length + 1)
460
+ let entries: string[]
461
+ try {
462
+ entries = await readdir(directory)
463
+ } catch (error) {
464
+ if (isNodeErrorCode(error, 'ENOENT')) return []
465
+ throw error
466
+ }
467
+ return entries
468
+ .filter(entry => entry.startsWith(`${base}.backup-`))
469
+ .sort()
470
+ .reverse()
471
+ .map(entry => join(directory, entry))
472
+ }
@@ -10,8 +10,43 @@ export interface ClientLease {
10
10
  readonly generation: number
11
11
  readonly kind: LeaseKind
12
12
  readonly released: boolean
13
+ /**
14
+ * True when this is the only lease on its pooled connection.
15
+ *
16
+ * Cancellation uses it to decide the blast radius: retiring the transport is
17
+ * safe (nobody else is using it) while other holders exist it would destroy
18
+ * unrelated work on the same alias.
19
+ */
20
+ holdsOnlyLease(): boolean
13
21
  /** Mark the underlying connection broken (surfaces to every holder). */
14
22
  markBroken(error?: unknown): void
15
23
  /** Idempotent: release this holder's ownership of the connection. */
16
24
  release(): void
17
25
  }
26
+
27
+ /**
28
+ * Tie a lease's release to the OPERATION's settlement, not to the caller's
29
+ * promise.
30
+ *
31
+ * Why this exists: `withClient` rejects its caller as soon as an abort arrives,
32
+ * but the underlying request may keep running (SFTP has no cancel API). If the
33
+ * lease were released at rejection time, `holdsOnlyLease()` would report "no
34
+ * other user" while that request was still in flight — and a later abort in a
35
+ * different session would then close the shared transport underneath it.
36
+ *
37
+ * @param lease - the lease owned by this operation.
38
+ * @param operation - the operation whose settlement frees the lease.
39
+ * @returns the operation, unchanged, with the release chained to it.
40
+ */
41
+ export function holdLeaseUntilSettled<T>(lease: ClientLease, operation: Promise<T>): Promise<T> {
42
+ let released = false
43
+ const release = (): void => {
44
+ if (released) return
45
+ released = true
46
+ lease.release()
47
+ }
48
+ return operation.then(
49
+ (value) => { release(); return value },
50
+ (error: unknown) => { release(); throw error },
51
+ )
52
+ }