@feng0531/dsh-vcs 0.3.67 → 0.3.69

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/docs/RELEASING.md CHANGED
@@ -1,65 +1,52 @@
1
- # 发布清单
1
+ # 维护者发布流程
2
2
 
3
- 当前包名 @feng0531/dsh-vcs;正式发布统一走 GitHub Actions,不在本地 npm publish。公开下载不改变非商业许可证。
3
+ 本文用于维护项目。使用者请阅读[README](<../README.md>)和[安装指南](https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/docs/INSTALL.md)。
4
4
 
5
- ## GitHub + npm 联合发布
5
+ ## 发布内容
6
6
 
7
- 在 GitHub 仓库 Settings → Secrets and variables → Actions 添加 NPM_AUTOMATION_TOKEN。该令牌必须有 @feng0531/dsh-vcs 发布权限,并满足 npm 的 2FA 策略(需要时启用 bypass 2FA);名字叫 AUTOMATION_TOKEN 不会自动赋予权限。不要在聊天、源码或日志中公开值。
7
+ 每个新版本必须有独立的中文发布说明,放在 `docs/releases/版本号.md`。按“本版变化、安装、限制、验证”组织,不把自动提交列表、开发轮次或内部恢复记录当作用户说明。没有新增功能时明确写出,不夸大改动。
8
8
 
9
- 对应标签 CI 全绿及人工验收后,从 main 手动运行 Publish GitHub and npm 工作流,填写已有版本标签。构建、验证和打包步骤不注入 npm 凭据;仅 npm 发布步骤使用 NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }},且禁用生命周期脚本。同一 tgz 先发布 npm,再作为 GitHub 正式 Release 附件上传并附 SHA-256。
9
+ 更新包版本和锁文件,在变更记录中填写同一版本。核查许可证、第三方声明及公开文件,排除凭据和私人数据。
10
10
 
11
- 两平台不支持原子事务:npm 失败则不发布 GitHub;npm 成功而 GitHub 失败时,重跑仅在已发布 npm integrity 与当前包完全一致时继续,否则拒绝覆盖。GitHub 已存在 Release 时也拒绝覆盖,需先检查现场。新版本不移动旧标签。
11
+ ## 1. 本地检查
12
12
 
13
- ## 1. 公开发布前的决定
13
+ 需要 Node.js 22+、Git、SVN、svnadmin。在干净检出中执行:
14
14
 
15
- - [x] 仓库地址已确认:https://github.com/f-e-n-g-0531/dsh-vcs 。
16
- - [x] 正式采用 [非商业源码公开许可证 1.0](<../LICENSE>),禁止商用,允许非商业修改和分发。
17
- - [ ] 每次发布检查源码权属及第三方声明;商业另行授权须覆盖全部有关权利人。
18
- - [ ] 确认安全报告渠道、维护责任与支持范围。
19
- - [ ] 审查待公开文件和 Git 历史,排除个人路径、令牌、用户数据、私人记录和敏感图片。
15
+ npm ci --include=dev
16
+ npm run check
17
+ npm run verify:package
18
+ npm run test:install
20
19
 
21
- ## 2. 干净环境验证
20
+ 测试失败或跳过须调查;隔离安装只检查包和宿主注册,不是用户实际 DSH 验收。
22
21
 
23
- 使用 Node.js 22 或更新版本,在干净检出目录中执行:
22
+ ## 2. 固定版本并验证
24
23
 
25
- npm ci && npm run check
26
- npm run verify:package
27
- npm run test:install
28
- npm pack --dry-run
29
- npm pack
24
+ 1. 提交并推送版本修改,创建新标签 `vX.Y.Z`;不移动已有标签。
25
+ 2. 等待标签 CI 的 Windows/Ubuntu × Node 22/24 全部成功。
26
+ 3. 手动在同一标签运行 **Browser layout checks**,等待成功。
27
+ 4. 下载四个 CI 候选 tgz,确认字节大小和 SHA-256 全部一致。
30
28
 
31
- 确认锁文件安装、构建和测试成功,记录失败与跳过项。完整 Git / SVN 测试需要 git、svn、svnadmin。npm pack 的 prepack 会执行 check;审查包内容是否包含客户端、编辑器、CSS、worker、宿主入口、适配器、语言文件与插件 patch。
29
+ 分支检查不能代替同标签检查。失败后先定位;需要修改源码或测试时使用新的版本和标签。
32
30
 
33
- 构建会复制 Monaco 的许可证与第三方声明进入分发资源;仍应核查最终包中的声明及其他依赖义务。排除测试数据、依赖目录与私人材料。
31
+ ## 3. 联合发布
34
32
 
35
- Git 来源安装依赖 prepare 构建,需要另行验证生命周期和构建依赖。pnpm 可能需要 allowBuilds 审批;拒绝脚本的环境应优先使用已构建 tgz,不应直接关闭安全策略。
33
+ 在 Actions 从 main 运行 **Publish GitHub and npm**,输入已验证的标签。
36
34
 
37
- ## 3. 安装与真实页面验收
35
+ 工作流检出该标签,重新检查并打包。它先上传 npm,再验证公开 npm 元数据与实际下载包,最后创建 GitHub 正式 Release,上传同一个 tgz 和 SHA-256 校验文件。
38
36
 
39
- dsh plugin --profile web add <absolute-tgz>
37
+ 仓库需配置 `NPM_AUTOMATION_TOKEN`,具有该包发布权限并符合 npm 2FA 策略。凭据只用于上传步骤;不要写进源码、发布说明或聊天。
40
38
 
41
- 替换为包的绝对路径,必要时加引号。先确认目标 profile 和宿主兼容性;安装改变运行配置,不是无副作用检查。
39
+ ## 4. 发布后核验
42
40
 
43
- **宿主重启只能由用户执行。** 用户重启后刷新现有 DSH Web 地址,在真实 Session 中验证:
41
+ - npm 版本与 latest 正确,实际 tarball 可下载。
42
+ - GitHub Release 非草稿、非预发布,附件可下载。
43
+ - 两端实际包与四候选的字节大小和 SHA-256 一致。
44
+ - 补记最终结论,但不修改已发布标签或替换工件。
44
45
 
45
- - [ ] 插件入口可见;顶部单仓库下拉,无额外仓库左栏。
46
- - [ ] 当前 / 上级仓库、一层子目录发现,以及明确相对子目录的定向发现。
47
- - [ ] Git 三模式、SVN BASE 与属性;新增、删除、重命名、未跟踪和冲突提示。
48
- - [ ] UTF-8、GBK、带 BOM 的 UTF-16;二进制与超大文件提示。
49
- - [ ] 并排 / 行内、空白选项、换行、导航、主题和编辑器加载失败降级。
50
- - [ ] Session / 仓库切换与重新发现不展示过期内容;预览不修改工作区或暂存区。
51
- - [ ] 发现授权过期后的手动重新发现,以及取消请求的实际反馈。
46
+ npm 与 GitHub 不是原子发布。若 npm 已接受上传但公开下载暂时 404,**不要再次上传**;先只读确认公开包。确认相同后恢复原失败任务,工作流会核对已有 npm integrity 并跳过上传。不匹配则停止;已有 GitHub Release 不覆盖。
52
47
 
53
- 记录宿主和浏览器版本、步骤及已知限制。自动化测试通过不等于这些项目已完成;未执行时明确写“未验收”,不补写成功结论。
48
+ ## 实机验证与安装边界
54
49
 
55
- ## 4. 标签 CI 与发布工作流
50
+ 自动浏览器测试不等于当前 DSH 实机通过。发布说明须如实写出未确认项;用户明确授权在此边界下发布时,不伪造实机结论。
56
51
 
57
- 普通分支 push、PR 和日常脚本修改不自动触发 CI;可手动对分支运行四组合CI作冻结前验证。维护者主动推送版本标签(`vX.Y.Z`)会触发 Windows/Linux × Node22/24矩阵;手动分支结果不能替代同标签CI。完成本地检查后,确认包版本并执行 `git tag vX.Y.Z`、`git push origin vX.Y.Z`(替换成真实版本,标签须指向包含新 CI 规则的提交)。不要移动已经发布的标签。等待该标签的 CI 全部通过并完成真实页面验收后再进行发布。在 Actions 手动运行 Release workflow 并填写已有 tag:工作流验证版本、重新构建与测试、检查包并生成 SHA256SUMS.txt,随后先发布 npm 再公开 GitHub Release。手动触发即授权两平台正式发布。未通过的真实页面验收不得勾选完成。
58
-
59
- CI 会严格校验标签为 `vX.Y.Z` 且与包版本相同。联合发布检查同标签同提交的最新四组合CI和Chrome工作流成功,并下载四候选包比较字节与SHA-256;未运行、失败、尚在运行或包不一致时拒绝发布。公开npm元数据、实际tarball及latest也须验证,再上传同包至GitHub。这不替代真实 DSH 页面人工验收;用户授权发布未实机验收功能时,Release必须明确披露,不自动安装或重启。
60
-
61
- ## 5. 分发
62
-
63
- 确认完整附带许可证及第三方声明,不把本项目标记为标准开源软件。在已确认的仓库中按实际验证结果编写发布说明,附审核过的 tgz 和校验值,注明兼容环境与未验证事项。是否打 tag / 创建 GitHub Release 由维护者决定;不为本次整理虚构日期、历史版本或功能变更。
64
-
65
- 相关约定见 [README](<../README.md>)、[贡献指南](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/CONTRIBUTING.md>)、[变更记录](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/CHANGELOG.md>) 和 [安全说明](<https://github.com/f-e-n-g-0531/dsh-vcs/blob/main/SECURITY.md>)。
52
+ 发布不安装、不升级、不重启运行中的 DSH。用户选择安装后,在原 Web 页面检查入口、仓库发现、差异、历史以及 Session 切换;由用户自行重启。维护者不能启动替代服务器冒充当前页面。
package/index.mjs CHANGED
@@ -34,11 +34,11 @@ const REQUEST_FIELDS = {
34
34
  'vcs/compare': ['sessionId', 'repositoryId', 'mode', 'id'],
35
35
  'vcs/history': ['sessionId', 'repositoryId', 'snapshot', 'offset', 'limit', 'search'],
36
36
  'vcs/commit': ['sessionId', 'repositoryId', 'commit', 'parentIndex'],
37
- 'vcs/commit-compare': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id'],
37
+ 'vcs/commit-compare': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'large'],
38
38
  'vcs/file-history': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'offset', 'limit', 'follow'],
39
39
  'vcs/blame': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'startLine', 'lineLimit'],
40
40
  'vcs/revision-changes': ['sessionId', 'repositoryId', 'base', 'target'],
41
- 'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id'],
41
+ 'vcs/revision-compare': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'large'],
42
42
  'vcs/revision-image': ['sessionId', 'repositoryId', 'base', 'target', 'id', 'side'],
43
43
  'vcs/tree': ['sessionId', 'repositoryId', 'commit'],
44
44
  'vcs/tree-file': ['sessionId', 'repositoryId', 'commit', 'path'],
@@ -68,6 +68,7 @@ function validatePayload(endpoint, payload) {
68
68
  if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
69
69
  if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
70
70
  }
71
+ if(['vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
71
72
  if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
72
73
  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.');
73
74
  if(endpoint==='vcs/file-history'&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
@@ -111,9 +112,9 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
111
112
  case 'vcs/revision-changes':
112
113
  return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
113
114
  case 'vcs/revision-compare':
114
- return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, signal});
115
+ return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
115
116
  case 'vcs/commit-compare':
116
- return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, signal});
117
+ return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
117
118
  case 'vcs/commit':
118
119
  return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
119
120
  case 'vcs/history':
@@ -194,7 +195,7 @@ export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.no
194
195
  // Both adapter operations revalidate the canonical root; comparison also validates
195
196
  // the change ID against fresh status, so do not duplicate a full status scan here.
196
197
  if(['vcs/history','vcs/commit','vcs/commit-compare','vcs/revision-changes','vcs/revision-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/commit-image','vcs/revision-image','vcs/workspace-image','vcs/references'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
197
- if(endpoint==='vcs/tree-segment'){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
198
+ if(endpoint==='vcs/tree-segment'||(['vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large===true)){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
198
199
  if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)){
199
200
  if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
200
201
  activeImages++;imageSlot=true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
package/text-content.mjs CHANGED
@@ -1,5 +1,29 @@
1
1
  /** Pure bounded byte decoding shared by working and committed previews. */
2
2
  export const MAX_TEXT = 2 * 1024 * 1024;
3
+ export const MAX_LARGE_TEXT = 8 * 1024 * 1024;
4
+
5
+ /** Explicit historical mode only: complete UTF-8, never a truncated Diff input. */
6
+ export function decodeLargeText(buffer, signal) {
7
+ signal?.throwIfAborted();
8
+ if (buffer.length > MAX_LARGE_TEXT) throw new Error('Large comparison exceeds 8 MiB per side.');
9
+ if (buffer.some(byte => byte < 32 && ![9, 10, 12, 13].includes(byte))) {
10
+ throw new Error('Large comparison requires UTF-8 text without binary control bytes.');
11
+ }
12
+ let text;
13
+ try { text = new TextDecoder('utf-8', {fatal: true}).decode(buffer); }
14
+ catch { throw new Error('Large comparison requires valid UTF-8 text.'); }
15
+ let lines = 1, length = 0;
16
+ for (let index = 0; index < text.length; index++) {
17
+ if (index % 65536 === 0) signal?.throwIfAborted();
18
+ if (text[index] === '\n' || text[index] === '\r') {
19
+ if (text[index] === '\r' && text[index + 1] === '\n') index++;
20
+ if (++lines > 100000) throw new Error('Large comparison exceeds 100000 lines per side.');
21
+ length = 0;
22
+ } else if (++length > 65536) throw new Error('Large comparison exceeds 65536 characters per line.');
23
+ }
24
+ signal?.throwIfAborted();
25
+ return {text, encoding: 'UTF-8', totalBytes: buffer.length, lineCount: lines};
26
+ }
3
27
 
4
28
  export function decode(buffer) {
5
29
  if (buffer.length > MAX_TEXT) {
package/vcs.mjs CHANGED
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { XMLParser, XMLValidator } from 'fast-xml-parser';
10
10
 
11
- import {decode, MAX_TEXT} from './text-content.mjs';
11
+ import {decode, MAX_TEXT, MAX_LARGE_TEXT, decodeLargeText} from './text-content.mjs';
12
12
  import {inside, confined} from './repository-path.mjs';
13
13
  const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
14
14
 
@@ -455,35 +455,48 @@ async function working(root, relative, svnLink = false) {
455
455
  try { const info = await handle.stat(); if (!info.isFile()) throw new Error('Not a regular file'); const buffer = Buffer.alloc(MAX_TEXT + 1); const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); await confined(root, relative); return decode(buffer.subarray(0, bytesRead)); } finally { await handle.close(); }
456
456
  }
457
457
  async function gitBlob(root, revision, file) { return content(() => git(root, ['show', '--no-ext-diff', '--no-textconv', revision + ':' + file], MAX_TEXT)); }
458
- async function historicalBlob(root,revision,file,signal){
458
+ async function historicalBlob(root,revision,file,signal,large=false){
459
459
  const listing=(await git(root,['ls-tree','-z',revision,'--',file],MAX_TEXT,{signal})).toString('utf8');
460
460
  const records=listing.split(String.fromCharCode(0)).filter(Boolean);
461
461
  const record=records.find(row=>row.slice(row.indexOf(' ')+1)===file);
462
462
  if(!record)throw new Error('Historical path missing');
463
463
  const [mode,type,oid]=record.slice(0,record.indexOf(' ')).split(' ');
464
+ if(large&&(!['100644','100755'].includes(mode)||type!=='blob'))throw new Error('Large comparison requires a committed regular file.');
464
465
  if(mode==='160000')return {text:oid,notice:'Submodule commit reference; repository contents are not loaded.'};
465
466
  if(type!=='blob'||!['100644','100755','120000'].includes(mode))return {text:'',notice:'Unsupported historical object type.'};
467
+ if (large) {
468
+ const rawSize = (await git(root, ['cat-file', '-s', oid], MAX_TEXT, {signal})).toString('utf8').trim();
469
+ if (!/^\d+$/.test(rawSize)) throw new Error('Invalid historical object size.');
470
+ const size = Number(rawSize);
471
+ if (size > MAX_LARGE_TEXT) throw new Error('Large comparison exceeds 8 MiB per side.');
472
+ const buffer = await git(root, ['cat-file', 'blob', oid], MAX_LARGE_TEXT, {signal});
473
+ if (buffer.length !== size) throw new Error('Historical object size changed.');
474
+ return decodeLargeText(buffer, signal);
475
+ }
476
+
466
477
  const result=await content(()=>git(root,['cat-file','blob',oid],MAX_TEXT,{signal}));
467
478
  if(mode==='120000')result.notice=[result.notice,'Symbolic link target text; not followed.'].filter(Boolean).join(' ');
468
479
  return result;
469
480
  }
470
- export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal}={}){
481
+ export async function getCommitComparison(repo,{commit,parentIndex=0,id,signal,large=false}={}){
482
+ if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
471
483
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid historical change id');
472
484
  const details=await commitDetails(repo,{commit,parentIndex,signal});
473
485
  const entry=details.changes.find(row=>row.id===id);
474
486
  if(!entry)throw new Error('Historical change is not part of selected commit and parent');
475
- const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal):{text:''};
476
- const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal):{text:''};
477
- return {...entry,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
487
+ const left=details.parent&&entry.status!=='added'?await historicalBlob(repo.root,details.parent,entry.oldPath||entry.path,signal,large):{text:''};
488
+ const right=entry.status!=='deleted'?await historicalBlob(repo.root,commit,entry.path,signal,large):{text:''};
489
+ return {...entry,large,left:{...left,label:details.parent||'Empty tree'},right:{...right,label:commit},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
478
490
  }
479
- export async function getRevisionComparison(repo,{base,target,id,signal}={}){
491
+ export async function getRevisionComparison(repo,{base,target,id,signal,large=false}={}){
492
+ if(typeof large!=='boolean')throw new Error('Invalid large comparison mode');
480
493
  if(typeof id!=='string'||!/^[a-f0-9]{64}$/.test(id))throw new Error('Invalid revision change id');
481
494
  const details=await getRevisionChanges(repo,{base,target,signal});
482
495
  const entry=details.changes.find(row=>row.id===id);
483
496
  if(!entry)throw new Error('Change is not part of selected revision pair');
484
- const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal):{text:''};
485
- const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal):{text:''};
486
- return {...entry,base,target,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
497
+ const left=entry.status!=='added'?await historicalBlob(repo.root,base,entry.oldPath||entry.path,signal,large):{text:''};
498
+ const right=entry.status!=='deleted'?await historicalBlob(repo.root,target,entry.path,signal,large):{text:''};
499
+ return {...entry,base,target,large,left:{...left,label:base},right:{...right,label:target},binary:!!(left.binary||right.binary),notice:[left.notice,right.notice].filter(Boolean).join(' ')};
487
500
  }
488
501
  async function properties(root, file, base) {
489
502
  const result = parseXML(await svn(root, ['proplist', '--xml', '--verbose', ...(base ? ['--revision', 'BASE'] : []), '--', file + '@']));