@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.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- 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
|
+
}
|