@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/base/ledger.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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 {
|
|
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.
|
|
70
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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(
|
|
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
|
|
204
|
-
async create(input:
|
|
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
|
|
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
|
|
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: '
|
|
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 (
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
&& typeof
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
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:
|
|
127
|
+
capabilities: string[]
|
|
118
128
|
}
|
package/src/base/plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
148
|
-
//
|
|
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
|
-
|
|
165
|
+
disposeRegistration()
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
|
package/src/base/registry.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
export type
|
|
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
|
|
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
|
-
|
|
45
|
-
return () => { /* already registered */ }
|
|
37
|
+
return () => { /* The original registration retains ownership. */ }
|
|
46
38
|
}
|
|
47
39
|
this.providers.set(id, provider)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
}
|
package/src/base/router.ts
CHANGED
|
@@ -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
|
}
|