@feng0531/dsh-vcs 0.4.9 → 0.4.10
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/dist/client.js +3 -3
- package/dist/frontend.json +1 -1
- package/dist/interface.js +3 -3
- package/dist/svn-host.mjs +1 -1
- package/git-history.mjs +6 -1
- package/index.mjs +285 -116
- package/package.json +1 -1
- package/vcs.mjs +103 -94
package/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ const runningFrontend=JSON.parse(readFileSync(new URL('./dist/frontend.json',imp
|
|
|
5
5
|
import * as adapter from './vcs.mjs';
|
|
6
6
|
import {runtimeInfo,checkUpdate} from './dist/updates.mjs';
|
|
7
7
|
import {createSvnHttpsHost} from './dist/svn-host.mjs';
|
|
8
|
+
import {historySearchArgs,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
|
|
8
9
|
|
|
9
10
|
export const name = 'dsh-vcs';
|
|
10
11
|
export const inject = ['connection', 'webServer', 'sessions', 'sessionPersistence'];
|
|
@@ -27,8 +28,6 @@ export async function resolveSessionCwd(ctx, sessionId, signal) {
|
|
|
27
28
|
|
|
28
29
|
const rediscover = () => Object.assign(new Error('Repository authorization is missing, expired, or changed. Rediscover repositories for this Session and retry.'), { code: 'vcs/rediscover-required' });
|
|
29
30
|
|
|
30
|
-
import {historySearchArgs,validateDateBound} from './git-history.mjs';
|
|
31
|
-
|
|
32
31
|
// Exact request fields are a protocol contract, independent of dispatch.
|
|
33
32
|
const REQUEST_FIELDS = {
|
|
34
33
|
'vcs/repositories': ['sessionId', 'subdirectory'],
|
|
@@ -54,51 +53,150 @@ const REQUEST_FIELDS = {
|
|
|
54
53
|
'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
|
|
55
54
|
'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
|
|
56
55
|
};
|
|
56
|
+
|
|
57
|
+
/** The supported endpoint list and each field whitelist derive from REQUEST_FIELDS. */
|
|
58
|
+
const ACTION_ENDPOINTS = new Set(Object.keys(REQUEST_FIELDS));
|
|
59
|
+
const REQUEST_FIELD_SETS = new Map(Object.entries(REQUEST_FIELDS).map(([endpoint, fields]) => [endpoint, new Set(fields)]));
|
|
60
|
+
const UPDATE_ENDPOINTS = new Set(['vcs/runtime-info', 'vcs/check-update']);
|
|
61
|
+
|
|
62
|
+
// Only repositories, SVN identity, workspace status and workspace comparison can
|
|
63
|
+
// address SVN working copies; every other action reads immutable Git history.
|
|
64
|
+
const SVN_CAPABLE_ENDPOINTS = new Set(['vcs/repositories', 'vcs/svn-identity', 'vcs/status', 'vcs/compare']);
|
|
65
|
+
const GIT_ONLY_ENDPOINTS = new Set([...ACTION_ENDPOINTS].filter(endpoint => !SVN_CAPABLE_ENDPOINTS.has(endpoint)));
|
|
66
|
+
|
|
67
|
+
// Named endpoint groups keep every request-shape and capacity decision in one place.
|
|
68
|
+
const HISTORICAL_PATH_ENDPOINTS = new Set(['vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history']);
|
|
69
|
+
const REVISION_PAIR_ENDPOINTS = new Set(['vcs/revision-changes', 'vcs/revision-compare', 'vcs/revision-image']);
|
|
70
|
+
const REVISION_CHANGE_ID_ENDPOINTS = new Set(['vcs/revision-compare', 'vcs/revision-image']);
|
|
71
|
+
const SELECTED_CHANGE_ENDPOINTS = new Set(['vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/commit-image']);
|
|
72
|
+
const COMMIT_ENDPOINTS = new Set(['vcs/commit', 'vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image']);
|
|
73
|
+
const LARGE_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/commit-compare', 'vcs/revision-compare']);
|
|
74
|
+
const HISTORY_PATH_ENDPOINTS = new Set(['vcs/file-history', 'vcs/tree-history']);
|
|
75
|
+
const PAGINATED_HISTORY_ENDPOINTS = new Set(['vcs/history', 'vcs/file-history', 'vcs/tree-history']);
|
|
76
|
+
const IMAGE_ENDPOINTS = new Set(['vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image']);
|
|
77
|
+
const WORKSPACE_CHANGE_ENDPOINTS = new Set(['vcs/workspace-image', 'vcs/conflict-stages']);
|
|
78
|
+
const CHANGE_ID_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/workspace-image']);
|
|
79
|
+
const IMAGE_SIDES = new Set(['left', 'right']);
|
|
80
|
+
const COMPARISON_MODES = new Set(['all', 'unstaged', 'staged']);
|
|
81
|
+
|
|
82
|
+
/** Repositories requests may only narrow discovery to a relative Session subdirectory. */
|
|
83
|
+
function validateRepositoriesPayload(payload) {
|
|
84
|
+
if (payload.subdirectory === undefined) return;
|
|
85
|
+
const dir = payload.subdirectory;
|
|
86
|
+
if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Historical paths are repository-relative and can never be absolute or escape. */
|
|
90
|
+
function validateHistoricalPath(endpoint, path) {
|
|
91
|
+
if (!HISTORICAL_PATH_ENDPOINTS.has(endpoint)) return;
|
|
92
|
+
if (typeof path !== 'string' || !path || path.length > 32768 || path.includes('\0') || path.startsWith('/') || path.split('/').some(part => !part || part === '.' || part === '..')) throw invalid('Invalid historical file path.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateImageSide(endpoint, side) {
|
|
96
|
+
if (IMAGE_ENDPOINTS.has(endpoint) && side !== undefined && !IMAGE_SIDES.has(side)) throw invalid('Invalid image side.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function validateRevisionSelection(endpoint, payload) {
|
|
100
|
+
if (!REVISION_PAIR_ENDPOINTS.has(endpoint)) return;
|
|
101
|
+
for (const oid of [payload.base, payload.target]) if (typeof oid !== 'string' || !isObjectId(oid)) throw invalid('Invalid revision commit id.');
|
|
102
|
+
if (REVISION_CHANGE_ID_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid revision change id.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateSelectedChangeId(endpoint, payload) {
|
|
106
|
+
if (SELECTED_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid historical change id.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateCommitSelection(endpoint, payload) {
|
|
110
|
+
if (!COMMIT_ENDPOINTS.has(endpoint)) return;
|
|
111
|
+
if (typeof payload.commit !== 'string' || !isObjectId(payload.commit)) throw invalid('Invalid commit id.');
|
|
112
|
+
if (payload.parentIndex !== undefined && (!Number.isInteger(payload.parentIndex) || payload.parentIndex < 0 || payload.parentIndex > 100)) throw invalid('Invalid parent index.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateLargeComparison(endpoint, payload) {
|
|
116
|
+
if (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large !== undefined && typeof payload.large !== 'boolean') throw invalid('Invalid large comparison mode.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateBlameWindow(endpoint, payload) {
|
|
120
|
+
if (endpoint !== 'vcs/blame') return;
|
|
121
|
+
const startInvalid = payload.startLine !== undefined && (!Number.isInteger(payload.startLine) || payload.startLine < 1 || payload.startLine > 100001);
|
|
122
|
+
const limitInvalid = payload.lineLimit !== undefined && (!Number.isInteger(payload.lineLimit) || payload.lineLimit < 1 || payload.lineLimit > 500);
|
|
123
|
+
if (startInvalid || limitInvalid) throw invalid('Invalid blame line window.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateContentSearch(endpoint, payload) {
|
|
127
|
+
if (endpoint !== 'vcs/grep') return;
|
|
128
|
+
if (typeof payload.query !== 'string' || !payload.query || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query)) throw invalid('Invalid content search query.');
|
|
129
|
+
if (payload.commit !== undefined && (typeof payload.commit !== 'string' || !isObjectId(payload.commit))) throw invalid('Invalid content search commit.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateRenameFollow(endpoint, payload) {
|
|
133
|
+
if (HISTORY_PATH_ENDPOINTS.has(endpoint) && payload.follow !== undefined && typeof payload.follow !== 'boolean') throw invalid('Invalid rename follow mode.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateHistoryDates(endpoint, payload) {
|
|
137
|
+
if (!HISTORY_PATH_ENDPOINTS.has(endpoint) || (payload.since === undefined && payload.until === undefined)) return;
|
|
138
|
+
try {
|
|
139
|
+
if (payload.since !== undefined) validateDateBound(payload.since);
|
|
140
|
+
if (payload.until !== undefined) validateDateBound(payload.until);
|
|
141
|
+
} catch { throw invalid('Invalid history date bound.'); }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateHistoryPagination(endpoint, payload) {
|
|
145
|
+
if (!PAGINATED_HISTORY_ENDPOINTS.has(endpoint)) return;
|
|
146
|
+
if (payload.offset === null || payload.limit === null) throw invalid('Invalid history pagination.');
|
|
147
|
+
if (payload.snapshot !== undefined && (typeof payload.snapshot !== 'string' || !isObjectId(payload.snapshot))) throw invalid('Invalid history snapshot.');
|
|
148
|
+
const offset = payload.offset ?? 0, limit = payload.limit ?? 50;
|
|
149
|
+
if (!Number.isInteger(offset) || offset < 0 || offset > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid history pagination.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateWorkspaceChangeId(endpoint, payload) {
|
|
153
|
+
if (WORKSPACE_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid workspace image change id.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateModeAndComparisonId(endpoint, payload) {
|
|
157
|
+
if (!COMPARISON_MODES.has(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
158
|
+
if (CHANGE_ID_COMPARISON_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The validation order below is part of the protocol: the first failing group wins. */
|
|
162
|
+
function validateRepositoryPayload(endpoint, payload) {
|
|
163
|
+
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
164
|
+
validateHistoricalPath(endpoint, payload.path);
|
|
165
|
+
validateImageSide(endpoint, payload.side);
|
|
166
|
+
validateRevisionSelection(endpoint, payload);
|
|
167
|
+
validateSelectedChangeId(endpoint, payload);
|
|
168
|
+
validateCommitSelection(endpoint, payload);
|
|
169
|
+
validateLargeComparison(endpoint, payload);
|
|
170
|
+
if (endpoint === 'vcs/tree-segment' && payload.offset !== undefined && (!Number.isInteger(payload.offset) || payload.offset < 0 || payload.offset > 16777216)) throw invalid('Invalid segment offset.');
|
|
171
|
+
validateBlameWindow(endpoint, payload);
|
|
172
|
+
validateContentSearch(endpoint, payload);
|
|
173
|
+
validateRenameFollow(endpoint, payload);
|
|
174
|
+
validateHistoryDates(endpoint, payload);
|
|
175
|
+
if (endpoint === 'vcs/history') { try { historySearchArgs(payload.search); } catch { throw invalid('Invalid history search.'); } }
|
|
176
|
+
validateHistoryPagination(endpoint, payload);
|
|
177
|
+
validateWorkspaceChangeId(endpoint, payload);
|
|
178
|
+
validateModeAndComparisonId(endpoint, payload);
|
|
179
|
+
}
|
|
180
|
+
|
|
57
181
|
function validatePayload(endpoint, payload) {
|
|
58
182
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
|
|
59
|
-
const
|
|
60
|
-
if (Object.keys(payload).some(key => !
|
|
61
|
-
if (endpoint === 'vcs/repositories')
|
|
62
|
-
|
|
63
|
-
const dir = payload.subdirectory;
|
|
64
|
-
if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
|
|
65
|
-
}
|
|
66
|
-
} else {
|
|
67
|
-
if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
|
|
68
|
-
if(['vcs/tree-file','vcs/tree-segment','vcs/tree-history'].includes(endpoint)&&(typeof payload.path!=='string'||!payload.path||payload.path.length>32768||payload.path.includes('\0')||payload.path.startsWith('/')||payload.path.split('/').some(part=>!part||part==='.'||part==='..')))throw invalid('Invalid historical file path.');
|
|
69
|
-
if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
|
|
70
|
-
if(['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint)){
|
|
71
|
-
for(const oid of [payload.base,payload.target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw invalid('Invalid revision commit id.');
|
|
72
|
-
if(['vcs/revision-compare','vcs/revision-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
|
|
73
|
-
}
|
|
74
|
-
if(['vcs/commit-compare','vcs/file-history','vcs/blame','vcs/commit-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid historical change id.');
|
|
75
|
-
if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/commit-image'].includes(endpoint)){
|
|
76
|
-
if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
|
|
77
|
-
if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
|
|
78
|
-
}
|
|
79
|
-
if(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
|
|
80
|
-
if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
|
|
81
|
-
if(endpoint==='vcs/blame'&&((payload.startLine!==undefined&&(!Number.isInteger(payload.startLine)||payload.startLine<1||payload.startLine>100001))||(payload.lineLimit!==undefined&&(!Number.isInteger(payload.lineLimit)||payload.lineLimit<1||payload.lineLimit>500))))throw invalid('Invalid blame line window.');
|
|
82
|
-
if(endpoint==='vcs/grep'){if(typeof payload.query!=='string'||!payload.query||payload.query.length>1024||/[\0\r\n]/.test(payload.query))throw invalid('Invalid content search query.');if(payload.commit!==undefined&&(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit)))throw invalid('Invalid content search commit.');}
|
|
83
|
-
if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
|
|
84
|
-
if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&(payload.since!==undefined||payload.until!==undefined)){try{if(payload.since!==undefined)validateDateBound(payload.since);if(payload.until!==undefined)validateDateBound(payload.until);}catch{throw invalid('Invalid history date bound.');}}
|
|
85
|
-
if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
|
|
86
|
-
if(['vcs/history','vcs/file-history','vcs/tree-history'].includes(endpoint)){
|
|
87
|
-
if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
|
|
88
|
-
if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
|
|
89
|
-
if(!Number.isInteger(payload.offset??0)||(payload.offset??0)<0||(payload.offset??0)>10000||!Number.isInteger(payload.limit??50)||(payload.limit??50)<1||(payload.limit??50)>100)throw invalid('Invalid history pagination.');
|
|
90
|
-
}
|
|
91
|
-
if(['vcs/workspace-image','vcs/conflict-stages'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid workspace image change id.');
|
|
92
|
-
if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
|
|
93
|
-
if (['vcs/compare','vcs/workspace-image'].includes(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
|
|
94
|
-
}
|
|
183
|
+
const allowedFields = REQUEST_FIELD_SETS.get(endpoint);
|
|
184
|
+
if (Object.keys(payload).some(key => !allowedFields.has(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
|
|
185
|
+
if (endpoint === 'vcs/repositories') validateRepositoriesPayload(payload);
|
|
186
|
+
else validateRepositoryPayload(endpoint, payload);
|
|
95
187
|
}
|
|
96
188
|
|
|
189
|
+
/** Forward an optional field only when the request actually supplied it. */
|
|
190
|
+
const optionalField = (key, value) => value === undefined ? {} : {[key]: value};
|
|
191
|
+
|
|
97
192
|
/** Dispatch only after grants and capacity have been checked by createHandler. */
|
|
98
193
|
async function readRepository(api, endpoint, repository, payload, {cwd, mode, signal}) {
|
|
99
194
|
switch (endpoint) {
|
|
100
195
|
case 'vcs/tree-history':
|
|
101
|
-
return api.listPathHistory({...repository},
|
|
196
|
+
return api.listPathHistory({...repository}, {
|
|
197
|
+
commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, limit: payload.limit ?? 50, follow: payload.follow ?? false,
|
|
198
|
+
...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
|
|
199
|
+
});
|
|
102
200
|
case 'vcs/tree-segment':
|
|
103
201
|
return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, signal});
|
|
104
202
|
case 'vcs/workspace-image':
|
|
@@ -118,19 +216,21 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
118
216
|
case 'vcs/tree':
|
|
119
217
|
return api.getHistoricalTree({...repository}, {commit: payload.commit, signal});
|
|
120
218
|
case 'vcs/blame':
|
|
121
|
-
return api.getFileBlame({...repository}, {
|
|
122
|
-
|
|
123
|
-
...(payload.
|
|
219
|
+
return api.getFileBlame({...repository}, {
|
|
220
|
+
commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
|
|
221
|
+
...optionalField('startLine', payload.startLine), ...optionalField('lineLimit', payload.lineLimit), signal,
|
|
222
|
+
});
|
|
124
223
|
case 'vcs/file-history':
|
|
125
|
-
return api.listFileHistory({...repository}, {
|
|
126
|
-
offset: payload.offset ?? 0, limit: payload.limit ?? 50,
|
|
127
|
-
...(
|
|
224
|
+
return api.listFileHistory({...repository}, {
|
|
225
|
+
commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, offset: payload.offset ?? 0, limit: payload.limit ?? 50,
|
|
226
|
+
...optionalField('follow', payload.follow), ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
|
|
227
|
+
});
|
|
128
228
|
case 'vcs/revision-changes':
|
|
129
229
|
return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
|
|
130
230
|
case 'vcs/revision-compare':
|
|
131
|
-
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(
|
|
231
|
+
return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...optionalField('large', payload.large), signal});
|
|
132
232
|
case 'vcs/commit-compare':
|
|
133
|
-
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(
|
|
233
|
+
return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...optionalField('large', payload.large), signal});
|
|
134
234
|
case 'vcs/commit':
|
|
135
235
|
return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
|
|
136
236
|
case 'vcs/history':
|
|
@@ -138,9 +238,9 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
138
238
|
case 'vcs/status':
|
|
139
239
|
return {cwd, repository: {...repository}, changes: await api.listChanges({...repository}, mode, {signal}), mode};
|
|
140
240
|
case 'vcs/conflict-stages':
|
|
141
|
-
return api.getConflictStages({...repository},{mode,id:payload.id,signal});
|
|
241
|
+
return api.getConflictStages({...repository}, {mode, id: payload.id, signal});
|
|
142
242
|
case 'vcs/compare':
|
|
143
|
-
return api.getComparison({...repository}, {mode, id: payload.id, ...(signal
|
|
243
|
+
return api.getComparison({...repository}, {mode, id: payload.id, ...optionalField('signal', signal), ...optionalField('large', payload.large)});
|
|
144
244
|
}
|
|
145
245
|
}
|
|
146
246
|
|
|
@@ -148,96 +248,170 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
|
|
|
148
248
|
export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, bindSvnIdentityResolver } = {}) {
|
|
149
249
|
let active = 0, activeImages = 0, activeSegments = 0;
|
|
150
250
|
const sessions = new Map();
|
|
251
|
+
|
|
252
|
+
/** Resolve a live Session grant, evicting expired entries and cwd-mismatched grants. */
|
|
151
253
|
function current(sessionId, cwd) {
|
|
152
254
|
for (const [id, entry] of sessions) if (entry.expiresAt <= now()) sessions.delete(id);
|
|
153
255
|
const entry = sessions.get(sessionId);
|
|
154
256
|
if (entry && entry.cwd !== cwd) { sessions.delete(sessionId); return undefined; }
|
|
155
257
|
return entry;
|
|
156
258
|
}
|
|
259
|
+
|
|
260
|
+
/** Re-read the Session cwd, then require the same grant object to still be current. */
|
|
157
261
|
async function assertCurrent(sessionId, cwd, entry, signal) {
|
|
158
262
|
const latest = await resolveSessionCwd(ctx, sessionId, signal);
|
|
159
263
|
signal?.throwIfAborted();
|
|
160
264
|
if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
|
|
161
265
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
266
|
+
|
|
267
|
+
/** Adapter work finished: both the cwd grant and the selected repository must still be current. */
|
|
268
|
+
async function assertCurrentGrant(sessionId, cwd, entry, repositoryId, repository, signal) {
|
|
269
|
+
await assertCurrent(sessionId, cwd, entry, signal);
|
|
270
|
+
if (entry.repositories.get(repositoryId) !== repository) throw rediscover();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (bindSvnIdentityResolver !== undefined) {
|
|
274
|
+
if (typeof bindSvnIdentityResolver !== 'function') throw new Error('Invalid internal SVN resolver binding');
|
|
275
|
+
bindSvnIdentityResolver(async (address, signal) => {
|
|
276
|
+
validatePayload('vcs/svn-identity', address);
|
|
277
|
+
signal?.throwIfAborted();
|
|
278
|
+
const cwd = await resolveSessionCwd(ctx, address.sessionId, signal);
|
|
279
|
+
const entry = current(address.sessionId, cwd);
|
|
280
|
+
const repository = entry?.repositories.get(address.repositoryId);
|
|
281
|
+
if (!repository) throw rediscover();
|
|
282
|
+
if (repository.type !== 'svn') throw invalid('SVN working copy required.');
|
|
283
|
+
const identity = await api.getSvnIdentity({...repository}, {signal});
|
|
284
|
+
await assertCurrentGrant(address.sessionId, cwd, entry, address.repositoryId, repository, signal);
|
|
285
|
+
return {...identity, cwd};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Update queries accept no fields and are admitted separately from repository reads. */
|
|
290
|
+
async function handleUpdateRequest(endpoint, payload, signal) {
|
|
291
|
+
try {
|
|
292
|
+
signal?.throwIfAborted();
|
|
293
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
|
|
294
|
+
if (endpoint === 'vcs/runtime-info') return {ok: true, value: runtimeInfo()};
|
|
295
|
+
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
296
|
+
active++;
|
|
297
|
+
try { return {ok: true, value: await checkUpdate({signal})}; } finally { active--; }
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return failure(signal?.aborted ? 'vcs/cancelled' : 'vcs/update-failed', error.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Discovery creates, replaces or merges grants; a stale scan can never resurrect one. */
|
|
304
|
+
async function discoverRepositories(payload, cwd, entry, signal) {
|
|
305
|
+
if (!entry) {
|
|
306
|
+
while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
|
|
307
|
+
entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
|
|
308
|
+
sessions.set(payload.sessionId, entry);
|
|
309
|
+
}
|
|
310
|
+
const targeted = payload.subdirectory !== undefined;
|
|
311
|
+
const sequence = ++entry.scanSequence;
|
|
312
|
+
if (!targeted) entry.fullScanSequence = sequence;
|
|
313
|
+
const fullSequence = entry.fullScanSequence;
|
|
314
|
+
const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
|
|
315
|
+
// A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
|
|
316
|
+
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
317
|
+
// Full scans replace grants. Never let an older full scan erase a newer scan,
|
|
318
|
+
// or an older targeted scan resurrect grants after a new full scan begins.
|
|
319
|
+
if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
|
|
320
|
+
const grants = targeted ? new Map(entry.repositories) : new Map();
|
|
321
|
+
let truncated = !!result.truncated;
|
|
322
|
+
const warnings = [...result.warnings];
|
|
323
|
+
const repositories = [];
|
|
324
|
+
for (const repository of result.repositories) {
|
|
325
|
+
if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
|
|
326
|
+
grants.set(repository.id, { ...repository });
|
|
327
|
+
repositories.push({ ...repository });
|
|
328
|
+
}
|
|
329
|
+
if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
|
|
330
|
+
entry.repositories = grants;
|
|
331
|
+
entry.expiresAt = now() + ttlMs;
|
|
332
|
+
return { cwd, repositories, truncated, warnings };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Read and revalidate SVN identity, returning only the fixed remote projection. */
|
|
336
|
+
async function readSvnIdentity(payload, cwd, entry, repository, signal) {
|
|
337
|
+
if (repository.type !== 'svn') throw invalid('SVN working copy required.');
|
|
338
|
+
const identity = await api.getSvnIdentity({...repository}, {signal});
|
|
339
|
+
await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
|
|
340
|
+
return {
|
|
341
|
+
root: identity.root, uuid: identity.uuid, scope: identity.scope,
|
|
342
|
+
origin: identity.origin, revision: identity.revision, remoteEnabled: false,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Reserve the independent image or text-segment slot; report a busy failure instead of queuing. */
|
|
347
|
+
function admitHeavyOperation(endpoint, payload) {
|
|
348
|
+
let imageSlot = false, segmentSlot = false;
|
|
349
|
+
if (endpoint === 'vcs/tree-segment' || (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large === true)) {
|
|
350
|
+
if (activeSegments >= 1) return {busy: failure('vcs/busy', 'A text segment is already loading. Please retry.'), imageSlot, segmentSlot};
|
|
351
|
+
activeSegments++; segmentSlot = true;
|
|
352
|
+
}
|
|
353
|
+
if (IMAGE_ENDPOINTS.has(endpoint)) {
|
|
354
|
+
if (activeImages >= 2) return {busy: failure('vcs/busy', 'Too many image requests. Please retry.'), imageSlot, segmentSlot};
|
|
355
|
+
activeImages++; imageSlot = true;
|
|
172
356
|
}
|
|
173
|
-
|
|
357
|
+
return {busy: null, imageSlot, segmentSlot};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Read through the adapter, then require both the cwd grant and the selected repository to be current. */
|
|
361
|
+
async function readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) {
|
|
362
|
+
const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
|
|
363
|
+
await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return async (endpoint, payload, signal) => {
|
|
368
|
+
if (UPDATE_ENDPOINTS.has(endpoint)) return handleUpdateRequest(endpoint, payload, signal);
|
|
369
|
+
if (!ACTION_ENDPOINTS.has(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
|
|
174
370
|
if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
|
|
175
371
|
active++;
|
|
176
|
-
let imageSlot=false,segmentSlot=false;
|
|
372
|
+
let imageSlot = false, segmentSlot = false;
|
|
177
373
|
try {
|
|
178
374
|
signal?.throwIfAborted();
|
|
179
375
|
validatePayload(endpoint, payload);
|
|
180
376
|
const cwd = await resolveSessionCwd(ctx, payload.sessionId, signal);
|
|
181
377
|
signal?.throwIfAborted();
|
|
182
|
-
|
|
183
|
-
if (endpoint === 'vcs/repositories') {
|
|
184
|
-
if (!entry) {
|
|
185
|
-
while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
|
|
186
|
-
entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
|
|
187
|
-
sessions.set(payload.sessionId, entry);
|
|
188
|
-
}
|
|
189
|
-
const targeted = payload.subdirectory !== undefined;
|
|
190
|
-
const sequence = ++entry.scanSequence;
|
|
191
|
-
if (!targeted) entry.fullScanSequence = sequence;
|
|
192
|
-
const fullSequence = entry.fullScanSequence;
|
|
193
|
-
const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
|
|
194
|
-
// A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
|
|
195
|
-
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
196
|
-
// Full scans replace grants. Never let an older full scan erase a newer scan,
|
|
197
|
-
// or an older targeted scan resurrect grants after a new full scan begins.
|
|
198
|
-
if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
|
|
199
|
-
const grants = targeted ? new Map(entry.repositories) : new Map();
|
|
200
|
-
let truncated = !!result.truncated;
|
|
201
|
-
const warnings = [...result.warnings];
|
|
202
|
-
const repositories = [];
|
|
203
|
-
for (const repository of result.repositories) {
|
|
204
|
-
if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
|
|
205
|
-
grants.set(repository.id, { ...repository });
|
|
206
|
-
repositories.push({ ...repository });
|
|
207
|
-
}
|
|
208
|
-
if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
|
|
209
|
-
entry.repositories = grants;
|
|
210
|
-
entry.expiresAt = now() + ttlMs;
|
|
211
|
-
return { ok: true, value: { cwd, repositories, truncated, warnings } };
|
|
212
|
-
}
|
|
378
|
+
const entry = current(payload.sessionId, cwd);
|
|
379
|
+
if (endpoint === 'vcs/repositories') return { ok: true, value: await discoverRepositories(payload, cwd, entry, signal) };
|
|
213
380
|
const repository = entry?.repositories.get(payload.repositoryId);
|
|
214
381
|
if (!repository) throw rediscover();
|
|
215
|
-
if(endpoint==='vcs/svn-identity'){
|
|
216
|
-
if(repository.type!=='svn')throw invalid('SVN working copy required.');
|
|
217
|
-
const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(payload.sessionId,cwd,entry,signal);if(entry.repositories.get(payload.repositoryId)!==repository)throw rediscover();
|
|
218
|
-
return {ok:true,value:{root:identity.root,uuid:identity.uuid,scope:identity.scope,origin:identity.origin,revision:identity.revision,remoteEnabled:false}};
|
|
219
|
-
}
|
|
382
|
+
if (endpoint === 'vcs/svn-identity') return { ok: true, value: await readSvnIdentity(payload, cwd, entry, repository, signal) };
|
|
220
383
|
const mode = payload.mode ?? 'all';
|
|
221
384
|
if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
|
|
222
385
|
// Both adapter operations revalidate the canonical root; comparison also validates
|
|
223
386
|
// the change ID against fresh status, so do not duplicate a full status scan here.
|
|
224
|
-
if(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
|
|
231
|
-
await assertCurrent(payload.sessionId, cwd, entry, signal);
|
|
232
|
-
if (entry.repositories.get(payload.repositoryId) !== repository) throw rediscover();
|
|
233
|
-
return { ok: true, value };
|
|
387
|
+
if (GIT_ONLY_ENDPOINTS.has(endpoint) && repository.type !== 'git') throw invalid('History currently supports Git only.');
|
|
388
|
+
const admission = admitHeavyOperation(endpoint, payload);
|
|
389
|
+
imageSlot = admission.imageSlot;
|
|
390
|
+
segmentSlot = admission.segmentSlot;
|
|
391
|
+
if (admission.busy) return admission.busy;
|
|
392
|
+
return { ok: true, value: await readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) };
|
|
234
393
|
} catch (error) {
|
|
235
394
|
if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
|
|
236
|
-
|
|
237
|
-
|
|
395
|
+
const code = typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed';
|
|
396
|
+
return failure(code, error instanceof Error ? error.message : String(error));
|
|
397
|
+
} finally {
|
|
398
|
+
active--;
|
|
399
|
+
if (imageSlot) activeImages--;
|
|
400
|
+
if (segmentSlot) activeSegments--;
|
|
401
|
+
}
|
|
238
402
|
};
|
|
239
403
|
}
|
|
240
404
|
|
|
405
|
+
/** Content type is fixed per build artifact; URL input never chooses it. */
|
|
406
|
+
const assetContentType = filename => filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8';
|
|
407
|
+
/** Shared bounded-response headers for both asset transports. */
|
|
408
|
+
const assetHeaders = (filename, bytes) => ({
|
|
409
|
+
'Content-Type': assetContentType(filename),
|
|
410
|
+
'Content-Length': String(bytes.byteLength),
|
|
411
|
+
'Cache-Control': 'private, no-cache',
|
|
412
|
+
'X-Content-Type-Options': 'nosniff',
|
|
413
|
+
});
|
|
414
|
+
|
|
241
415
|
/** Three fixed authenticated assets; URL input never enters a filesystem path. */
|
|
242
416
|
export function registerAssets(ctx, load = filename => readFile(new URL('./dist/' + filename, import.meta.url))) {
|
|
243
417
|
for (const filename of ASSETS) {
|
|
@@ -248,12 +422,7 @@ export function registerAssets(ctx, load = filename => readFile(new URL('./dist/
|
|
|
248
422
|
async fetch(request) {
|
|
249
423
|
try {
|
|
250
424
|
const bytes = await load(filename);
|
|
251
|
-
return new Response(request.method === 'HEAD' ? null : bytes, { headers:
|
|
252
|
-
'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
|
|
253
|
-
'Content-Length': String(bytes.byteLength),
|
|
254
|
-
'Cache-Control': 'private, no-cache',
|
|
255
|
-
'X-Content-Type-Options': 'nosniff',
|
|
256
|
-
} });
|
|
425
|
+
return new Response(request.method === 'HEAD' ? null : bytes, { headers: assetHeaders(filename, bytes) });
|
|
257
426
|
} catch { return new Response('VCS editor assets unavailable. Build the plugin first.', { status: 503 }); }
|
|
258
427
|
},
|
|
259
428
|
});
|
|
@@ -270,7 +439,7 @@ export function registerDirectAssets(ctx, load = filename => readFile(new URL('.
|
|
|
270
439
|
if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, {Allow:'GET, HEAD'}); res.end(); return; }
|
|
271
440
|
try {
|
|
272
441
|
const bytes = await load(filename);
|
|
273
|
-
res.writeHead(200,
|
|
442
|
+
res.writeHead(200, assetHeaders(filename, bytes));
|
|
274
443
|
res.end(req.method === 'HEAD' ? undefined : bytes);
|
|
275
444
|
} catch { res.writeHead(503, {'Content-Type':'text/plain; charset=utf-8'}); res.end('VCS editor assets unavailable.'); }
|
|
276
445
|
}
|