@tiphareth/dsh-hardssh 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +77 -50
  3. package/SKILLS.md +140 -0
  4. package/lib/base/index.js +95 -0
  5. package/lib/client.js +1171 -1321
  6. package/lib/fs.js +333 -416
  7. package/lib/index.js +5428 -2991
  8. package/lib/ledger-B-LXlftp.js +43 -0
  9. package/lib/ledger-D2ezq1iW.js +376 -0
  10. package/lib/model-Cp7f70Mb.js +5 -0
  11. package/lib/registry-CViuzKYI.js +421 -0
  12. package/lib/subprocess.js +92 -549
  13. package/lib/types/backend.d.ts +73 -78
  14. package/lib/types/base/capability.d.ts +4 -0
  15. package/lib/types/base/ledger-router.d.ts +42 -26
  16. package/lib/types/base/ledger.d.ts +30 -19
  17. package/lib/types/base/model.d.ts +17 -8
  18. package/lib/types/base/plugin.d.ts +11 -0
  19. package/lib/types/base/registry.d.ts +28 -31
  20. package/lib/types/base/router.d.ts +0 -7
  21. package/lib/types/client/api.d.ts +8 -8
  22. package/lib/types/client/connect-host.d.ts +13 -0
  23. package/lib/types/client/index.d.ts +6 -4
  24. package/lib/types/client/locales.d.ts +15 -1
  25. package/lib/types/client/session-connect-gate.d.ts +71 -0
  26. package/lib/types/client/ssh/api.d.ts +11 -9
  27. package/lib/types/client/ssh/apply.d.ts +14 -8
  28. package/lib/types/client/ssh/locales.d.ts +8 -1
  29. package/lib/types/client/ssh/ops-tab.d.ts +34 -0
  30. package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
  31. package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
  33. package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
  34. package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
  35. package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
  36. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
  37. package/lib/types/client/ssh/session-target.d.ts +17 -0
  38. package/lib/types/client/state.d.ts +4 -1
  39. package/lib/types/client/workspace-panel-entry.d.ts +30 -0
  40. package/lib/types/client/workspace-panel.d.ts +24 -0
  41. package/lib/types/client-http.d.ts +77 -6
  42. package/lib/types/core.d.ts +5 -16
  43. package/lib/types/fs.d.ts +42 -25
  44. package/lib/types/index.d.ts +138 -9
  45. package/lib/types/ledger.d.ts +16 -111
  46. package/lib/types/protocol.d.ts +0 -5
  47. package/lib/types/providers/index.d.ts +6 -3
  48. package/lib/types/providers/local/provider.d.ts +83 -23
  49. package/lib/types/providers/ssh/provider.d.ts +24 -57
  50. package/lib/types/remote/environment.d.ts +2 -0
  51. package/lib/types/remote/remote-fs.d.ts +12 -1
  52. package/lib/types/remote/remote-process.d.ts +22 -1
  53. package/lib/types/remote/remote-subprocess.d.ts +13 -1
  54. package/lib/types/remote/remote-terminal.d.ts +4 -0
  55. package/lib/types/remote-search.d.ts +3 -3
  56. package/lib/types/routes.d.ts +36 -8
  57. package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
  58. package/lib/types/runtime/workspace-core.d.ts +73 -38
  59. package/lib/types/runtime/workspace-migration.d.ts +113 -0
  60. package/lib/types/ssh/connection/lease.d.ts +23 -0
  61. package/lib/types/ssh/connection/manager.d.ts +359 -0
  62. package/lib/types/ssh/connection/pool.d.ts +8 -1
  63. package/lib/types/ssh/engine.d.ts +97 -267
  64. package/lib/types/ssh/known-hosts.d.ts +22 -5
  65. package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
  66. package/lib/types/ssh/plugin.d.ts +10 -15
  67. package/lib/types/ssh/protocol.d.ts +18 -0
  68. package/lib/types/ssh/routes.d.ts +15 -7
  69. package/lib/types/ssh/sftp/service.d.ts +216 -0
  70. package/lib/types/ssh/store.d.ts +20 -8
  71. package/lib/types/ssh/terminal/service.d.ts +49 -0
  72. package/lib/types/ssh/tunnel/service.d.ts +26 -0
  73. package/lib/types/ssh/vault.d.ts +51 -4
  74. package/lib/types/subprocess.d.ts +13 -21
  75. package/lib/types/switch/switch-fs.d.ts +52 -2
  76. package/lib/types/switch/switch-subprocess.d.ts +32 -3
  77. package/lib/types/tools.d.ts +7 -6
  78. package/lib/types/workspace-tool-ops.d.ts +33 -0
  79. package/lib/types/workspace.d.ts +18 -0
  80. package/lib/vault-3gpWct2Q.js +559 -0
  81. package/lib/workspace.js +2 -0
  82. package/package.json +59 -26
  83. package/scripts/export-legacy-workspaces.mjs +136 -0
  84. package/src/backend.ts +155 -483
  85. package/src/base/capability.ts +4 -0
  86. package/src/base/ledger-router.ts +214 -65
  87. package/src/base/ledger.ts +175 -48
  88. package/src/base/model.ts +18 -8
  89. package/src/base/plugin.ts +15 -4
  90. package/src/base/registry.ts +49 -41
  91. package/src/base/router.ts +0 -8
  92. package/src/client/api.ts +8 -36
  93. package/src/client/connect-host.ts +199 -0
  94. package/src/client/directory-flow.tsx +91 -36
  95. package/src/client/index.ts +56 -53
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/session-connect-gate.ts +163 -0
  98. package/src/client/ssh/api.ts +245 -108
  99. package/src/client/ssh/apply.ts +23 -24
  100. package/src/client/ssh/locales.ts +16 -2
  101. package/src/client/ssh/ops-tab.tsx +79 -0
  102. package/src/client/ssh/panel/ClusterTab.tsx +9 -20
  103. package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
  104. package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
  105. package/src/client/ssh/panel/SshPanel.tsx +81 -77
  106. package/src/client/ssh/panel/TerminalTab.tsx +18 -37
  107. package/src/client/ssh/panel/TransferTab.tsx +43 -33
  108. package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
  109. package/src/client/ssh/panel/panel.module.css +90 -127
  110. package/src/client/ssh/session-target.ts +69 -0
  111. package/src/client/state.ts +9 -1
  112. package/src/client/workspace-badges.ts +99 -96
  113. package/src/client/workspace-panel-entry.tsx +91 -0
  114. package/src/client/workspace-panel.tsx +227 -0
  115. package/src/client/workspace.module.css +76 -2
  116. package/src/client-http.ts +118 -18
  117. package/src/core.ts +37 -47
  118. package/src/fs.ts +171 -85
  119. package/src/index.ts +535 -244
  120. package/src/ledger.ts +62 -416
  121. package/src/protocol.ts +0 -6
  122. package/src/providers/index.ts +8 -4
  123. package/src/providers/local/provider.ts +259 -87
  124. package/src/providers/ssh/provider.ts +158 -155
  125. package/src/remote/environment.ts +22 -1
  126. package/src/remote/remote-fs.ts +123 -34
  127. package/src/remote/remote-process.ts +265 -203
  128. package/src/remote/remote-subprocess.ts +61 -18
  129. package/src/remote/remote-terminal.ts +106 -21
  130. package/src/remote-search.ts +122 -26
  131. package/src/routes.ts +416 -395
  132. package/src/runtime/dsh-capabilities.ts +19 -0
  133. package/src/runtime/workspace-core.ts +171 -88
  134. package/src/runtime/workspace-migration.ts +472 -0
  135. package/src/ssh/connection/lease.ts +35 -0
  136. package/src/ssh/connection/manager.ts +1083 -0
  137. package/src/ssh/connection/pool.ts +341 -275
  138. package/src/ssh/engine.ts +269 -1477
  139. package/src/ssh/known-hosts.ts +42 -18
  140. package/src/ssh/local-transfer-policy.ts +83 -0
  141. package/src/ssh/plugin.ts +20 -19
  142. package/src/ssh/protocol.ts +22 -1
  143. package/src/ssh/routes.ts +419 -187
  144. package/src/ssh/sftp/service.ts +967 -0
  145. package/src/ssh/store.ts +169 -72
  146. package/src/ssh/terminal/service.ts +177 -0
  147. package/src/ssh/tools.ts +42 -20
  148. package/src/ssh/tunnel/service.ts +217 -0
  149. package/src/ssh/vault.ts +245 -91
  150. package/src/subprocess.ts +88 -71
  151. package/src/switch/switch-fs.ts +141 -10
  152. package/src/switch/switch-subprocess.ts +80 -6
  153. package/src/tools.ts +217 -221
  154. package/src/workspace-tool-ops.ts +101 -0
  155. package/src/workspace.ts +52 -0
  156. package/lib/environment-BL1jddfB.js +0 -449
  157. package/lib/switch-fs-CAJpFY9C.js +0 -193
  158. package/lib/switch-fs-RrZtG2gv.js +0 -210
  159. package/lib/types/client/manager-button.d.ts +0 -32
  160. package/lib/types/client/ssh/mount.d.ts +0 -13
  161. package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
  162. package/lib/types/client/ssh/panel/controller.d.ts +0 -23
  163. package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
  164. package/lib/types/client/workspace-gate.d.ts +0 -15
  165. package/lib/types/remote-runner.d.ts +0 -83
  166. package/lib/types/seam-state.d.ts +0 -69
  167. package/src/client/manager-button.tsx +0 -269
  168. package/src/client/ssh/mount.tsx +0 -83
  169. package/src/client/ssh/panel/HostsTab.tsx +0 -236
  170. package/src/client/ssh/panel/controller.ts +0 -48
  171. package/src/client/ssh/sidebar-entry.ts +0 -123
  172. package/src/client/workspace-gate.ts +0 -225
  173. package/src/remote-runner.ts +0 -201
  174. package/src/seam-state.ts +0 -185
@@ -0,0 +1,421 @@
1
+ import { a as normalizeAnchorPath, i as isPathUnderAnchor } from "./ledger-D2ezq1iW.js";
2
+ //#region src/base/namespace.ts
3
+ /**
4
+ * Workspace namespace codec — how a routable target key is encoded.
5
+ *
6
+ * New format is a URI style that carries ONLY the workspace id and the
7
+ * provider-agnostic path:
8
+ *
9
+ * wfs://<encoded-workspace-id>/<path>
10
+ *
11
+ * - The workspace id survives provider migration (ssh → container → …), so
12
+ * a key never needs re-routing when a workspace changes providers.
13
+ * - It cannot collide with Windows drive paths (`C:\…`) or bare POSIX paths.
14
+ * - It contains no provider name, so the switch layer never branches on
15
+ * "which provider" — only on "which workspace".
16
+ *
17
+ * The legacy SSH format (`ssh:<recordId>:`) is still accepted (decode only)
18
+ * so existing persisted targets keep resolving during the migration window.
19
+ *
20
+ * @module @tiphareth/dsh-hardssh/base/namespace
21
+ */
22
+ /** The URI scheme used for explicit workspace-namespaced target keys. */
23
+ const WFS_SCHEME = "wfs:";
24
+ /** ASCII-encode a workspace id for safe embedding in a URL-ish key. */
25
+ function encodeWorkspaceId(id) {
26
+ return encodeURIComponent(id);
27
+ }
28
+ /** Decode a workspace id back from its encoded form. */
29
+ function decodeWorkspaceId(encoded) {
30
+ return decodeURIComponent(encoded);
31
+ }
32
+ /**
33
+ * Encode a route as `wfs://<id>/<path>`. A leading slash on the path is
34
+ * preserved after the namespace marker.
35
+ */
36
+ function encodeRoute(route) {
37
+ const path = route.path.startsWith("/") ? route.path : `/${route.path}`;
38
+ return `${WFS_SCHEME}//${encodeWorkspaceId(route.workspaceId)}${path}`;
39
+ }
40
+ /** Decode a `wfs://<id>/<path>` key (strict). */
41
+ function decodeWfsKey(input) {
42
+ if (!input.startsWith(`wfs://`)) return void 0;
43
+ const rest = input.slice(`${WFS_SCHEME}//`.length);
44
+ const separator = rest.indexOf("/");
45
+ if (separator < 0) return void 0;
46
+ const encodedId = rest.slice(0, separator);
47
+ if (encodedId === "") return void 0;
48
+ let workspaceId;
49
+ try {
50
+ workspaceId = decodeWorkspaceId(encodedId);
51
+ } catch {
52
+ return;
53
+ }
54
+ const path = rest.slice(separator);
55
+ return {
56
+ workspaceId,
57
+ path
58
+ };
59
+ }
60
+ /**
61
+ * Decode a legacy `ssh:<recordId>:<key>` key. Kept during the migration
62
+ * window so persisted remote targets keep resolving; the record id maps to
63
+ * the workspace id 1:1 (old record ids ARE the workspace ids).
64
+ */
65
+ function decodeLegacySshKey(input) {
66
+ if (!input.startsWith("ssh:")) return void 0;
67
+ const rest = input.slice(4);
68
+ const end = rest.indexOf(":");
69
+ if (end <= 0) return void 0;
70
+ return {
71
+ workspaceId: rest.slice(0, end),
72
+ path: rest.slice(end + 1)
73
+ };
74
+ }
75
+ /**
76
+ * The default codec: emits `wfs://…` and accepts both `wfs://…` and legacy
77
+ * `ssh:<id>:…`.
78
+ */
79
+ const defaultNamespaceCodec = {
80
+ encode: encodeRoute,
81
+ decode(input) {
82
+ return decodeWfsKey(input) ?? decodeLegacySshKey(input);
83
+ },
84
+ isExplicitNamespace(input) {
85
+ return input.startsWith("wfs:") || input.startsWith("ssh:");
86
+ }
87
+ };
88
+ //#endregion
89
+ //#region src/base/ledger-router.ts
90
+ /** Ledger + provider-registry based router. */
91
+ var LedgerWorkspaceRouter = class {
92
+ ledger;
93
+ providers;
94
+ options;
95
+ codec;
96
+ connections = /* @__PURE__ */ new Map();
97
+ connectionBindings = /* @__PURE__ */ new Map();
98
+ opening = /* @__PURE__ */ new Map();
99
+ closing = /* @__PURE__ */ new Set();
100
+ anchors = [];
101
+ lifecycleAbort = new AbortController();
102
+ initializePromise;
103
+ ready = false;
104
+ closed = false;
105
+ ledgerDisposer;
106
+ providerDisposer;
107
+ constructor(ledger, providers, options = {}) {
108
+ this.ledger = ledger;
109
+ this.providers = providers;
110
+ this.options = options;
111
+ this.codec = options.codec ?? defaultNamespaceCodec;
112
+ }
113
+ /** ensureOpen() warms one supplied record only, so initialize loads, subscribes, and preopens the complete persisted ledger before readiness. */
114
+ initialize() {
115
+ if (this.initializePromise !== void 0) return this.initializePromise;
116
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("LedgerWorkspaceRouter is closed"));
117
+ this.initializePromise = this.initializeInternal();
118
+ return this.initializePromise;
119
+ }
120
+ /** initialize() is asynchronous, so isReady provides the synchronous seam gate it cannot provide. */
121
+ isReady() {
122
+ return this.ready && !this.closed;
123
+ }
124
+ /** isReady() only reports a snapshot, so whenReady lets asynchronous consumers await the initialization outcome. */
125
+ async whenReady() {
126
+ await this.initialize();
127
+ }
128
+ /** ensureOpen() requires a record, so openById performs fail-closed ledger identity lookup before single-flight opening. */
129
+ async openById(id, signal) {
130
+ if (this.closed) return void 0;
131
+ if (signal?.aborted === true) throw abortError();
132
+ const record = await this.ledger.get(id);
133
+ if (record === void 0) return void 0;
134
+ return awaitWithSignal(this.openFor(record), signal);
135
+ }
136
+ /** openById() cannot translate a host anchor, so openByAnchor resolves ledger ownership before opening. */
137
+ async openByAnchor(path, signal) {
138
+ if (this.closed) return void 0;
139
+ const record = await this.ledger.findByAnchor(path);
140
+ if (record === void 0) return void 0;
141
+ return this.openById(record.id, signal);
142
+ }
143
+ /** initialize() owns promise reuse, so initializeInternal performs the one actual load/subscribe/preopen sequence. */
144
+ async initializeInternal() {
145
+ const records = await this.ledger.load();
146
+ if (this.closed) return;
147
+ this.reindex();
148
+ this.ledgerDisposer = this.ledger.subscribe((change) => this.handleLedgerChange(change));
149
+ this.providerDisposer = this.providers.subscribe((change) => this.handleProviderChange(change));
150
+ await Promise.all(records.map(async (record) => {
151
+ if (!this.providers.has(record.provider.id)) return;
152
+ await this.openFor(record);
153
+ }));
154
+ if (!this.closed) this.ready = true;
155
+ }
156
+ reindex() {
157
+ const snapshot = this.ledger.snapshotSync();
158
+ const recordsById = new Map(snapshot.records.map((record) => [record.id, record]));
159
+ const anchors = [];
160
+ for (const record of snapshot.records) if (record.anchor !== void 0) anchors.push({
161
+ anchor: normalizeAnchorPath(record.anchor.path),
162
+ record
163
+ });
164
+ anchors.sort((a, b) => b.anchor.length - a.anchor.length);
165
+ this.anchors.length = 0;
166
+ this.anchors.push(...anchors);
167
+ for (const [id] of this.connections) {
168
+ const record = recordsById.get(id);
169
+ if (record === void 0 || this.connectionBindings.get(id) !== bindingKey(record)) this.detach(id);
170
+ }
171
+ }
172
+ async openFor(record) {
173
+ const existing = this.connections.get(record.id);
174
+ if (existing !== void 0 && this.connectionBindings.get(record.id) === bindingKey(record)) return existing;
175
+ const inFlight = this.opening.get(record.id);
176
+ if (inFlight !== void 0) return inFlight;
177
+ const expectedBinding = bindingKey(record);
178
+ const provider = this.providers.get(record.provider.id);
179
+ if (provider === void 0) return void 0;
180
+ const opening = (async () => {
181
+ await provider.validate(record);
182
+ const opened = await provider.open(record, {
183
+ signal: this.lifecycleAbort.signal,
184
+ logger: this.options.logger
185
+ });
186
+ const current = await this.ledger.get(record.id);
187
+ if (this.closed || current === void 0 || bindingKey(current) !== expectedBinding || this.providers.get(record.provider.id) !== provider) {
188
+ await this.closeConnection(opened);
189
+ return;
190
+ }
191
+ const replaced = this.connections.get(record.id);
192
+ if (replaced !== void 0 && replaced !== opened) await this.closeConnection(replaced);
193
+ this.connections.set(record.id, opened);
194
+ this.connectionBindings.set(record.id, expectedBinding);
195
+ return opened;
196
+ })();
197
+ this.opening.set(record.id, opening);
198
+ opening.finally(() => {
199
+ if (this.opening.get(record.id) === opening) this.opening.delete(record.id);
200
+ }).catch(() => void 0);
201
+ return opening;
202
+ }
203
+ fromNamespace(key) {
204
+ if (!this.isReady()) return void 0;
205
+ const route = this.codec.decode(key);
206
+ if (route === void 0) return void 0;
207
+ const resolution = this.resolveRoute(route);
208
+ return resolution === void 0 ? void 0 : {
209
+ ...resolution,
210
+ rawKey: route.path
211
+ };
212
+ }
213
+ fromAnchor(cwd) {
214
+ if (!this.isReady() || cwd === void 0 || cwd === "") return void 0;
215
+ for (const { anchor, record } of this.anchors) if (isPathUnderAnchor(anchor, cwd)) return this.connections.get(record.id);
216
+ }
217
+ resolveRoute(route) {
218
+ if (!this.isReady()) return void 0;
219
+ const record = this.ledger.snapshotSync().records.find((candidate) => candidate.id === route.workspaceId);
220
+ if (record === void 0) return void 0;
221
+ const connection = this.connections.get(record.id);
222
+ if (connection === void 0) return void 0;
223
+ return {
224
+ connection,
225
+ rawKey: route.path
226
+ };
227
+ }
228
+ /** Async compatibility entry used by older adapters that already hold a detached record. */
229
+ async ensureOpen(record, signal) {
230
+ if (this.closed) return void 0;
231
+ return awaitWithSignal(this.openFor(record), signal);
232
+ }
233
+ /** All currently cached connections (for lifecycle/teardown). */
234
+ connectionsSnapshot() {
235
+ return [...this.connections.values()];
236
+ }
237
+ /** Close every cached and in-flight connection and release both subscriptions. */
238
+ async closeAll() {
239
+ if (this.closed && this.opening.size === 0 && this.closing.size === 0 && this.connections.size === 0) return;
240
+ this.closed = true;
241
+ this.ready = false;
242
+ this.lifecycleAbort.abort();
243
+ this.ledgerDisposer?.();
244
+ this.providerDisposer?.();
245
+ this.ledgerDisposer = void 0;
246
+ this.providerDisposer = void 0;
247
+ const openings = [...this.opening.values()];
248
+ const connections = [...this.connections.values()];
249
+ this.connections.clear();
250
+ this.connectionBindings.clear();
251
+ await Promise.allSettled(openings);
252
+ await Promise.allSettled(connections.map((connection) => this.closeConnection(connection)));
253
+ await Promise.allSettled([...this.closing]);
254
+ this.opening.clear();
255
+ }
256
+ /** reindex() only updates synchronous ownership, so this handler also closes and reopens resources after ledger commits. */
257
+ handleLedgerChange(change) {
258
+ const previousBindings = new Map(this.connectionBindings);
259
+ this.reindex();
260
+ if (change.type === "removed") {
261
+ this.detach(change.record.id);
262
+ return;
263
+ }
264
+ if (change.type === "updated" || change.type === "renamed") {
265
+ if (previousBindings.get(change.record.id) !== bindingKey(change.record)) this.reopen(change.record.id);
266
+ return;
267
+ }
268
+ if (change.type === "created") {
269
+ if (this.ready) this.reopen(change.record.id);
270
+ return;
271
+ }
272
+ for (const record of change.records) if (this.ready && this.providers.has(record.provider.id) && !this.connections.has(record.id)) this.reopen(record.id);
273
+ }
274
+ /** openFor() cannot observe provider lifecycle events itself, so this handler invalidates unregisters and preopens later registrations. */
275
+ handleProviderChange(change) {
276
+ if (change.type === "unregistered") {
277
+ for (const record of this.ledger.snapshotSync().records) if (record.provider.id === change.provider.manifest.id) this.detach(record.id);
278
+ return;
279
+ }
280
+ if (!this.ready) return;
281
+ for (const record of this.ledger.snapshotSync().records) if (record.provider.id === change.provider.manifest.id) this.reopen(record.id);
282
+ }
283
+ /** openFor() reuses an in-flight promise, so reopen waits for stale work before retrying the current record. */
284
+ reopen(id) {
285
+ const pending = this.opening.get(id);
286
+ this.detach(id);
287
+ (async () => {
288
+ if (pending !== void 0) await pending.catch(() => void 0);
289
+ if (!this.closed) await this.openById(id).catch((error) => this.options.logger?.warn?.("workspace reopen failed", error));
290
+ })();
291
+ }
292
+ /** reindex() cannot await resource release, so detach removes routing synchronously and tracks close completion separately. */
293
+ detach(id) {
294
+ const connection = this.connections.get(id);
295
+ this.connections.delete(id);
296
+ this.connectionBindings.delete(id);
297
+ if (connection !== void 0) this.closeConnection(connection);
298
+ }
299
+ /** WorkspaceConnection.close() alone is not tracked, so closeConnection lets closeAll await invalidation work already in progress. */
300
+ closeConnection(connection) {
301
+ const closing = connection.close().catch((error) => {
302
+ this.options.logger?.warn?.("workspace connection close failed", error);
303
+ });
304
+ this.closing.add(closing);
305
+ closing.finally(() => this.closing.delete(closing));
306
+ return closing;
307
+ }
308
+ };
309
+ /** WorkspaceRecord.updatedAt changes on every edit, so router invalidation needs a stable binding-only key instead. */
310
+ function bindingKey(record) {
311
+ return JSON.stringify({
312
+ provider: record.provider,
313
+ location: record.location
314
+ });
315
+ }
316
+ /** openFor() is shared by callers, so a caller signal must cancel only its wait rather than the shared provider open. */
317
+ function awaitWithSignal(promise, signal) {
318
+ if (signal === void 0) return promise;
319
+ if (signal.aborted) return Promise.reject(abortError());
320
+ return new Promise((resolve, reject) => {
321
+ const onAbort = () => reject(abortError());
322
+ signal.addEventListener("abort", onAbort, { once: true });
323
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
324
+ });
325
+ }
326
+ /** awaitWithSignal() needs a consistent AbortError, which Error() alone cannot identify by name. */
327
+ function abortError() {
328
+ const error = /* @__PURE__ */ new Error("Workspace open aborted");
329
+ error.name = "AbortError";
330
+ return error;
331
+ }
332
+ //#endregion
333
+ //#region src/base/registry.ts
334
+ /** Duplicate / incompatible registration error. */
335
+ var ProviderRegistrationError = class extends Error {
336
+ constructor(message) {
337
+ super(message);
338
+ this.name = "ProviderRegistrationError";
339
+ }
340
+ };
341
+ /** In-memory provider registry (process-wide singleton by default). */
342
+ var WorkspaceProviderRegistry = class {
343
+ providers = /* @__PURE__ */ new Map();
344
+ listeners = /* @__PURE__ */ new Set();
345
+ /** Register a provider and return a disposer that only removes that exact registration. */
346
+ register(provider) {
347
+ const id = provider.manifest.id;
348
+ const existing = this.providers.get(id);
349
+ if (existing !== void 0) {
350
+ if (!(existing.manifest.version === provider.manifest.version && existing.manifest.apiVersion === provider.manifest.apiVersion)) throw new ProviderRegistrationError(`provider '${id}' already registered (${existing.manifest.version}, api ${existing.manifest.apiVersion}); refusing incompatible duplicate`);
351
+ return () => {};
352
+ }
353
+ this.providers.set(id, provider);
354
+ this.emit({
355
+ type: "registered",
356
+ provider
357
+ });
358
+ let disposed = false;
359
+ return () => {
360
+ if (disposed) return;
361
+ disposed = true;
362
+ if (this.providers.get(id) !== provider) return;
363
+ this.providers.delete(id);
364
+ this.emit({
365
+ type: "unregistered",
366
+ provider
367
+ });
368
+ };
369
+ }
370
+ /** Look up a provider by id. */
371
+ get(id) {
372
+ return this.providers.get(id);
373
+ }
374
+ /**
375
+ * Remove a provider by id, whoever registered it.
376
+ *
377
+ * Ownership-unsafe by construction: callers that registered a provider must
378
+ * use the disposer returned by register() instead, otherwise teardown can
379
+ * delete a provider owned by another plugin. Kept for whole-registry
380
+ * operations (tests, process-level resets).
381
+ */
382
+ unregister(id) {
383
+ const provider = this.providers.get(id);
384
+ if (provider === void 0) return;
385
+ this.providers.delete(id);
386
+ this.emit({
387
+ type: "unregistered",
388
+ provider
389
+ });
390
+ }
391
+ /** All registered providers. */
392
+ list() {
393
+ return [...this.providers.values()];
394
+ }
395
+ /** Subscribe must expose both registration and removal, which the old registration-only listener could not report. */
396
+ subscribe(listener) {
397
+ this.listeners.add(listener);
398
+ let disposed = false;
399
+ return () => {
400
+ if (disposed) return;
401
+ disposed = true;
402
+ this.listeners.delete(listener);
403
+ };
404
+ }
405
+ /** True when a provider id is registered. */
406
+ has(id) {
407
+ return this.providers.has(id);
408
+ }
409
+ /** Static identity of a registered provider (for manifests / UI). */
410
+ manifestOf(id) {
411
+ return this.providers.get(id)?.manifest;
412
+ }
413
+ /** register() cannot safely repeat listener isolation for both lifecycle directions, so emit centralizes it. */
414
+ emit(change) {
415
+ for (const listener of [...this.listeners]) try {
416
+ listener(change);
417
+ } catch {}
418
+ }
419
+ };
420
+ //#endregion
421
+ export { decodeLegacySshKey as a, defaultNamespaceCodec as c, WFS_SCHEME as i, encodeRoute as l, WorkspaceProviderRegistry as n, decodeWfsKey as o, LedgerWorkspaceRouter as r, decodeWorkspaceId as s, ProviderRegistrationError as t, encodeWorkspaceId as u };