@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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join, posix } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
//#region src/ledger.ts
|
|
4
|
+
/**
|
|
5
|
+
* SSH-bound workspace path helpers.
|
|
6
|
+
*
|
|
7
|
+
* This module is the last remaining piece of the retired SSH ledger module:
|
|
8
|
+
* the `SshWorkspaceLedger` class was removed with the generic cutover, so what
|
|
9
|
+
* survives here are the anchor/path helpers the generic runtime still shares —
|
|
10
|
+
* the managed SSH anchor layout (`~/.dsh/ssh-workspaces/<id>`), the legacy
|
|
11
|
+
* ledger file location (the one-time import source), the client DTO title
|
|
12
|
+
* default, and the lexical anchor-containment helpers used by fs/subprocess
|
|
13
|
+
* fail-closed gating. Record persistence itself is owned by the generic
|
|
14
|
+
* `WorkspaceLedger` (`./base/ledger.ts`).
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-hardssh/ledger
|
|
17
|
+
*/
|
|
18
|
+
/** The frozen legacy SSH ledger location: ~/.dsh/dsh-hardssh-workspaces.json.
|
|
19
|
+
* Only read once by the startup import when no cutover marker exists. */
|
|
20
|
+
function ledgerPath() {
|
|
21
|
+
return join(homedir(), ".dsh", "dsh-hardssh-workspaces.json");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Normalize a remote POSIX root: keep '/' as '/', collapse repeated
|
|
25
|
+
* separators and dot segments, reject relative paths and NUL.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeRemoteRoot(raw) {
|
|
28
|
+
if (raw.includes("\0")) throw new Error(`remoteRoot must not contain NUL (got '${raw}')`);
|
|
29
|
+
const normalized = posix.normalize(raw.trim());
|
|
30
|
+
if (!normalized.startsWith("/")) throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`);
|
|
31
|
+
return normalized === "/" ? "/" : normalized.replace(/\/+$/, "");
|
|
32
|
+
}
|
|
33
|
+
/** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
|
|
34
|
+
* ordinary host workspaces (and thus selectable for sessions). */
|
|
35
|
+
function anchorRoot() {
|
|
36
|
+
return join(homedir(), ".dsh", "ssh-workspaces");
|
|
37
|
+
}
|
|
38
|
+
/** Default record title when the user gives none. */
|
|
39
|
+
function defaultTitle(remoteRoot, alias) {
|
|
40
|
+
return `${alias}:${remoteRoot.split("/").filter(Boolean).pop() ?? remoteRoot}`;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { normalizeRemoteRoot as i, defaultTitle as n, ledgerPath as r, anchorRoot as t };
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
//#region src/base/ledger.ts
|
|
6
|
+
/**
|
|
7
|
+
* Provider-neutral workspace ledger with strict loading, atomic persistence,
|
|
8
|
+
* detached snapshots, and one serialized mutation queue.
|
|
9
|
+
*/
|
|
10
|
+
/** Normalize an anchor for comparison (Windows case-insensitive; POSIX not). */
|
|
11
|
+
function normalizeAnchorPath(path) {
|
|
12
|
+
if (/^[a-zA-Z]:[\\/]/.test(path) || path.includes("\\")) {
|
|
13
|
+
const normalized = path.replace(/\//g, "\\");
|
|
14
|
+
return trimTrailing(normalized, /^[a-zA-Z]:\\/.test(normalized) ? 3 : 0).toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
return trimTrailing(path, path.startsWith("/") ? 1 : 0);
|
|
17
|
+
}
|
|
18
|
+
/** True when `candidate` equals `anchor` or is one of its descendants (lexical). */
|
|
19
|
+
function isPathUnderAnchor(anchor, candidate) {
|
|
20
|
+
const normAnchor = normalizeAnchorPath(anchor);
|
|
21
|
+
const normCandidate = normalizeAnchorPath(candidate);
|
|
22
|
+
if (normCandidate === normAnchor) return true;
|
|
23
|
+
const sep = normAnchor.includes("\\") ? "\\" : "/";
|
|
24
|
+
const prefix = normAnchor.endsWith(sep) ? normAnchor : `${normAnchor}${sep}`;
|
|
25
|
+
return normCandidate.startsWith(prefix);
|
|
26
|
+
}
|
|
27
|
+
function trimTrailing(path, minimumLength) {
|
|
28
|
+
let end = path.length;
|
|
29
|
+
while (end > minimumLength && (path[end - 1] === "/" || path[end - 1] === "\\")) end -= 1;
|
|
30
|
+
return path.slice(0, end);
|
|
31
|
+
}
|
|
32
|
+
/** Generic ledger schema currently accepted by strict loading. */
|
|
33
|
+
const WORKSPACE_LEDGER_SCHEMA_VERSION = 1;
|
|
34
|
+
/**
|
|
35
|
+
* The generic ledger. The in-memory state changes only after an atomic save,
|
|
36
|
+
* and every public observation is detached from that state.
|
|
37
|
+
*/
|
|
38
|
+
var WorkspaceLedger = class {
|
|
39
|
+
fileOverride;
|
|
40
|
+
anchorOverride;
|
|
41
|
+
records;
|
|
42
|
+
loadPromise;
|
|
43
|
+
anchorIndex = [];
|
|
44
|
+
mutationTail = Promise.resolve();
|
|
45
|
+
currentRevision = 0;
|
|
46
|
+
listeners = /* @__PURE__ */ new Set();
|
|
47
|
+
constructor(fileOverride, anchorOverride) {
|
|
48
|
+
this.fileOverride = fileOverride;
|
|
49
|
+
this.anchorOverride = anchorOverride;
|
|
50
|
+
}
|
|
51
|
+
file() {
|
|
52
|
+
return this.fileOverride ?? "";
|
|
53
|
+
}
|
|
54
|
+
anchors() {
|
|
55
|
+
return this.anchorOverride ?? "";
|
|
56
|
+
}
|
|
57
|
+
async ensureLoaded() {
|
|
58
|
+
if (this.records !== void 0) return;
|
|
59
|
+
if (this.loadPromise === void 0) this.loadPromise = this.readRecords();
|
|
60
|
+
await this.loadPromise;
|
|
61
|
+
}
|
|
62
|
+
async readRecords() {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
const text = await readFile(this.file(), "utf8");
|
|
66
|
+
parsed = JSON.parse(text);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (isNodeErrorCode(error, "ENOENT")) {
|
|
69
|
+
this.records = [];
|
|
70
|
+
this.reindex();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(parsed)) throw new Error("WorkspaceLedger: expected a JSON array");
|
|
76
|
+
assertValidRecords(parsed);
|
|
77
|
+
this.records = cloneRecords(parsed);
|
|
78
|
+
this.reindex();
|
|
79
|
+
}
|
|
80
|
+
reindex() {
|
|
81
|
+
this.anchorIndex = (this.records ?? []).filter((record) => record.anchor !== void 0).map((record) => ({
|
|
82
|
+
anchor: normalizeAnchorPath(record.anchor.path),
|
|
83
|
+
record
|
|
84
|
+
})).sort((a, b) => b.anchor.length - a.anchor.length);
|
|
85
|
+
}
|
|
86
|
+
async save(nextRecords) {
|
|
87
|
+
assertValidRecords(nextRecords);
|
|
88
|
+
const target = this.file();
|
|
89
|
+
if (target === "") throw new Error("WorkspaceLedger: no persistence file configured");
|
|
90
|
+
await mkdir(dirname(target), { recursive: true });
|
|
91
|
+
const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
92
|
+
const lastGood = `${target}.last-good`;
|
|
93
|
+
try {
|
|
94
|
+
await writeFile(temporary, JSON.stringify(nextRecords, null, 2), "utf8");
|
|
95
|
+
const hadPrevious = await copyFile(target, lastGood).then(() => true, (error) => {
|
|
96
|
+
if (!isNodeErrorCode(error, "ENOENT")) console.warn(`[dsh-hardssh] could not refresh the ledger recovery copy: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
return false;
|
|
98
|
+
});
|
|
99
|
+
await rename(temporary, target);
|
|
100
|
+
if (!hadPrevious) await copyFile(target, lastGood).catch(() => void 0);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
enqueueMutation(operation) {
|
|
107
|
+
const result = this.mutationTail.then(operation);
|
|
108
|
+
this.mutationTail = result.then(() => void 0, () => void 0);
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
commit(nextRecords, change) {
|
|
112
|
+
this.records = cloneRecords(nextRecords);
|
|
113
|
+
this.reindex();
|
|
114
|
+
this.currentRevision += 1;
|
|
115
|
+
this.emit({
|
|
116
|
+
...change,
|
|
117
|
+
revision: this.currentRevision
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
emit(change) {
|
|
121
|
+
for (const listener of [...this.listeners]) try {
|
|
122
|
+
listener(cloneChange(change));
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn("[dsh-workspace] ledger subscriber failed:", error instanceof Error ? error.message : String(error));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async load() {
|
|
128
|
+
await this.ensureLoaded();
|
|
129
|
+
return cloneRecords(this.records ?? []);
|
|
130
|
+
}
|
|
131
|
+
async list() {
|
|
132
|
+
await this.ensureLoaded();
|
|
133
|
+
return cloneRecords(this.records ?? []);
|
|
134
|
+
}
|
|
135
|
+
async snapshot() {
|
|
136
|
+
await this.ensureLoaded();
|
|
137
|
+
return {
|
|
138
|
+
revision: this.currentRevision,
|
|
139
|
+
records: cloneRecords(this.records ?? [])
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
snapshotSync() {
|
|
143
|
+
return {
|
|
144
|
+
revision: this.currentRevision,
|
|
145
|
+
records: cloneRecords(this.records ?? [])
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
revision() {
|
|
149
|
+
return this.currentRevision;
|
|
150
|
+
}
|
|
151
|
+
subscribe(listener) {
|
|
152
|
+
this.listeners.add(listener);
|
|
153
|
+
let disposed = false;
|
|
154
|
+
return () => {
|
|
155
|
+
if (disposed) return;
|
|
156
|
+
disposed = true;
|
|
157
|
+
this.listeners.delete(listener);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async get(id) {
|
|
161
|
+
await this.ensureLoaded();
|
|
162
|
+
const record = this.records?.find((candidate) => candidate.id === id);
|
|
163
|
+
return record === void 0 ? void 0 : cloneRecord(record);
|
|
164
|
+
}
|
|
165
|
+
async findByAnchor(path) {
|
|
166
|
+
await this.ensureLoaded();
|
|
167
|
+
return this.findByAnchorSync(path);
|
|
168
|
+
}
|
|
169
|
+
findByAnchorSync(path) {
|
|
170
|
+
const canonical = safeRealpathSync(path);
|
|
171
|
+
if (canonical === void 0) return void 0;
|
|
172
|
+
for (const { anchor, record } of this.anchorIndex) if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record);
|
|
173
|
+
}
|
|
174
|
+
/** Create cannot preserve caller timestamps, so migration uses replaceAll instead. */
|
|
175
|
+
async create(input) {
|
|
176
|
+
return this.enqueueMutation(async () => {
|
|
177
|
+
await this.ensureLoaded();
|
|
178
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
179
|
+
const record = cloneRecord({
|
|
180
|
+
schemaVersion: input.schemaVersion,
|
|
181
|
+
id: input.id ?? randomUUID(),
|
|
182
|
+
title: input.title,
|
|
183
|
+
provider: input.provider,
|
|
184
|
+
location: input.location,
|
|
185
|
+
anchor: input.anchor,
|
|
186
|
+
createdAt: now,
|
|
187
|
+
updatedAt: now,
|
|
188
|
+
labels: input.labels,
|
|
189
|
+
extensions: input.extensions
|
|
190
|
+
});
|
|
191
|
+
const nextRecords = [...this.records ?? [], record];
|
|
192
|
+
assertValidRecords(nextRecords);
|
|
193
|
+
if (record.anchor !== void 0 && record.anchor.mode === "managed") await mkdir(record.anchor.path, { recursive: true });
|
|
194
|
+
await this.save(nextRecords);
|
|
195
|
+
this.commit(nextRecords, {
|
|
196
|
+
type: "created",
|
|
197
|
+
record
|
|
198
|
+
});
|
|
199
|
+
return cloneRecord(record);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async rename(id, title) {
|
|
203
|
+
const before = await this.get(id);
|
|
204
|
+
const record = await this.update(id, { title: title.trim() });
|
|
205
|
+
if (before === void 0 || record === void 0) return void 0;
|
|
206
|
+
return record;
|
|
207
|
+
}
|
|
208
|
+
/** WorkspaceLedger.rename cannot change provider bindings or extension data, so update applies a generic patch. */
|
|
209
|
+
async update(id, patch) {
|
|
210
|
+
return this.enqueueMutation(async () => {
|
|
211
|
+
await this.ensureLoaded();
|
|
212
|
+
const records = this.records ?? [];
|
|
213
|
+
const index = records.findIndex((record) => record.id === id);
|
|
214
|
+
if (index < 0) return void 0;
|
|
215
|
+
const before = cloneRecord(records[index]);
|
|
216
|
+
const record = cloneRecord({
|
|
217
|
+
...before,
|
|
218
|
+
...structuredClone(patch),
|
|
219
|
+
id: before.id,
|
|
220
|
+
schemaVersion: before.schemaVersion,
|
|
221
|
+
createdAt: before.createdAt,
|
|
222
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
223
|
+
});
|
|
224
|
+
const nextRecords = [...records];
|
|
225
|
+
nextRecords[index] = record;
|
|
226
|
+
assertValidRecords(nextRecords);
|
|
227
|
+
await this.save(nextRecords);
|
|
228
|
+
this.commit(nextRecords, {
|
|
229
|
+
type: "updated",
|
|
230
|
+
before,
|
|
231
|
+
record
|
|
232
|
+
});
|
|
233
|
+
return cloneRecord(record);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** WorkspaceLedger.create persists one generated record, so it cannot atomically import a complete authoritative snapshot. */
|
|
237
|
+
async replaceAll(records, options = {}) {
|
|
238
|
+
return this.enqueueMutation(async () => {
|
|
239
|
+
await this.ensureLoaded();
|
|
240
|
+
const nextRecords = cloneRecords(records);
|
|
241
|
+
assertValidRecords(nextRecords);
|
|
242
|
+
const before = cloneRecords(this.records ?? []);
|
|
243
|
+
if (options.backupPath !== void 0) {
|
|
244
|
+
await mkdir(dirname(options.backupPath), { recursive: true });
|
|
245
|
+
try {
|
|
246
|
+
await copyFile(this.file(), options.backupPath);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (!isNodeErrorCode(error, "ENOENT")) throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
await this.save(nextRecords);
|
|
252
|
+
this.commit(nextRecords, {
|
|
253
|
+
type: "replaced",
|
|
254
|
+
before,
|
|
255
|
+
records: nextRecords
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/** Remove a record. The anchor directory is left in place (caller decides). */
|
|
260
|
+
async remove(id) {
|
|
261
|
+
return this.enqueueMutation(async () => {
|
|
262
|
+
await this.ensureLoaded();
|
|
263
|
+
const records = this.records ?? [];
|
|
264
|
+
const index = records.findIndex((record) => record.id === id);
|
|
265
|
+
if (index < 0) return false;
|
|
266
|
+
const record = records[index];
|
|
267
|
+
const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)];
|
|
268
|
+
await this.save(nextRecords);
|
|
269
|
+
this.commit(nextRecords, {
|
|
270
|
+
type: "removed",
|
|
271
|
+
record
|
|
272
|
+
});
|
|
273
|
+
return true;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
anchorsRoot() {
|
|
277
|
+
return this.anchors();
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
/** isRecord validates one shape only, so this validator also rejects duplicate ids and ambiguous anchors across records. */
|
|
281
|
+
function assertValidRecords(values) {
|
|
282
|
+
const ids = /* @__PURE__ */ new Set();
|
|
283
|
+
const anchors = [];
|
|
284
|
+
for (const value of values) {
|
|
285
|
+
if (!isRecord(value)) throw new Error("WorkspaceLedger: invalid workspace record");
|
|
286
|
+
if (ids.has(value.id)) throw new Error(`WorkspaceLedger: duplicate workspace id '${value.id}'`);
|
|
287
|
+
ids.add(value.id);
|
|
288
|
+
if (value.anchor !== void 0) anchors.push({
|
|
289
|
+
id: value.id,
|
|
290
|
+
path: normalizeAnchorPath(value.anchor.path)
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
for (let left = 0; left < anchors.length; left += 1) for (let right = left + 1; right < anchors.length; right += 1) {
|
|
294
|
+
const a = anchors[left];
|
|
295
|
+
const b = anchors[right];
|
|
296
|
+
if (isPathUnderAnchor(a.path, b.path) || isPathUnderAnchor(b.path, a.path)) throw new Error(`WorkspaceLedger: duplicate or overlapping anchors for '${a.id}' and '${b.id}'`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function isRecord(value) {
|
|
300
|
+
if (!isPlainObject(value)) return false;
|
|
301
|
+
const provider = value.provider;
|
|
302
|
+
const location = value.location;
|
|
303
|
+
const anchor = value.anchor;
|
|
304
|
+
const labels = value.labels;
|
|
305
|
+
const extensions = value.extensions;
|
|
306
|
+
if (value.schemaVersion !== 1 || typeof value.id !== "string" || value.id === "" || typeof value.title !== "string" || !isPlainObject(provider) || typeof provider.id !== "string" || provider.id === "" || provider.instanceId !== void 0 && typeof provider.instanceId !== "string" || !isConnectionRef(provider.connectionRef) || !isPlainObject(location) || typeof location.kind !== "string" || location.kind === "" || typeof location.root !== "string" || location.root === "" || location.options !== void 0 && (!isPlainObject(location.options) || !isJsonValue(location.options)) || !isAnchor(anchor) || typeof value.createdAt !== "string" || typeof value.updatedAt !== "string" || labels !== void 0 && (!isPlainObject(labels) || Object.values(labels).some((label) => typeof label !== "string")) || extensions !== void 0 && (!isPlainObject(extensions) || !isJsonValue(extensions))) return false;
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
/** isRecord cannot distinguish an absent connectionRef from a malformed present one without this focused guard. */
|
|
310
|
+
function isConnectionRef(value) {
|
|
311
|
+
return value === void 0 || isPlainObject(value) && typeof value.id === "string" && value.id !== "" && (value.alias === void 0 || typeof value.alias === "string");
|
|
312
|
+
}
|
|
313
|
+
/** isRecord delegates optional anchor validation here because anchor has a discriminated mode shape. */
|
|
314
|
+
function isAnchor(value) {
|
|
315
|
+
return value === void 0 || isPlainObject(value) && typeof value.path === "string" && value.path !== "" && (value.mode === "managed" || value.mode === "existing");
|
|
316
|
+
}
|
|
317
|
+
/** typeof object also accepts arrays and prototypes, so isRecord needs this stricter object check. */
|
|
318
|
+
function isPlainObject(value) {
|
|
319
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
320
|
+
const prototype = Object.getPrototypeOf(value);
|
|
321
|
+
return prototype === Object.prototype || prototype === null;
|
|
322
|
+
}
|
|
323
|
+
/** isRecord cannot validate arbitrarily nested options/extensions inline, so this recursive JSON-value guard closes that gap. */
|
|
324
|
+
function isJsonValue(value) {
|
|
325
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
326
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
327
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
328
|
+
if (!isPlainObject(value)) return false;
|
|
329
|
+
return Object.values(value).every(isJsonValue);
|
|
330
|
+
}
|
|
331
|
+
function safeRealpathSync(path) {
|
|
332
|
+
try {
|
|
333
|
+
return realpathSync(path);
|
|
334
|
+
} catch {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function cloneRecord(record) {
|
|
339
|
+
return structuredClone(record);
|
|
340
|
+
}
|
|
341
|
+
function cloneRecords(records) {
|
|
342
|
+
return records.map(cloneRecord);
|
|
343
|
+
}
|
|
344
|
+
function cloneChange(change) {
|
|
345
|
+
switch (change.type) {
|
|
346
|
+
case "created": return {
|
|
347
|
+
...change,
|
|
348
|
+
record: cloneRecord(change.record)
|
|
349
|
+
};
|
|
350
|
+
case "renamed": return {
|
|
351
|
+
...change,
|
|
352
|
+
before: cloneRecord(change.before),
|
|
353
|
+
record: cloneRecord(change.record)
|
|
354
|
+
};
|
|
355
|
+
case "updated": return {
|
|
356
|
+
...change,
|
|
357
|
+
before: cloneRecord(change.before),
|
|
358
|
+
record: cloneRecord(change.record)
|
|
359
|
+
};
|
|
360
|
+
case "removed": return {
|
|
361
|
+
...change,
|
|
362
|
+
record: cloneRecord(change.record)
|
|
363
|
+
};
|
|
364
|
+
case "replaced": return {
|
|
365
|
+
...change,
|
|
366
|
+
before: cloneRecords(change.before),
|
|
367
|
+
records: cloneRecords(change.records)
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Error.message cannot reliably identify ENOENT, so strict loading checks Node's machine-readable code. */
|
|
372
|
+
function isNodeErrorCode(error, code) {
|
|
373
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
export { normalizeAnchorPath as a, isPathUnderAnchor as i, WorkspaceLedger as n, assertValidRecords as r, WORKSPACE_LEDGER_SCHEMA_VERSION as t };
|