feat(v0.3.2): 文件夹选择体验(等待提示/任务栏指引/手动输入/错误反馈/竞态修复) + 发布工具加固(release-prep 身份断言+工厂门禁+dist 终态/verify-remote 远端SHA核对)

This commit is contained in:
Aisland
2026-09-05 18:02:10 +08:00
parent 5e810e3a09
commit 709906ac00
13 changed files with 796 additions and 72 deletions
+143
View File
@@ -0,0 +1,143 @@
/**
* dsh-worktable 客户端工厂求值门禁(供 release-prep 调用;独立成模块便于失败测试)。
*
* 定位:这是「工厂求值门禁」,不是客户端端到端验收——只验证 bundle 在
* window.__ModuleLoader__.load 握手下能被真实求值一次,能抓住:
* - 顶层 TDZ / 求值异常(如 const Ge=Ge)
* - ModuleLoader ID 登记错误
* - 工厂引用了未授权的外部模块
* - 导出结构错误(apply 非函数 / inject 内容不对)
* 抓不到:React 真实渲染异常、apply(ctx) 与真实宿主服务的兼容、浏览器环境差异——
* 这些属于真实 DSH 冷启动验收(独立步骤)。
*/
import { createContext, runInNewContext } from 'node:vm'
/** 客户端 external 精确白名单——当前真实 bundle 只 require react / react/jsx-runtime。
* 新增任何宿主依赖必须显式审核后加入此清单(防引用已删除宿主模块导致白屏)。 */
export const CLIENT_EXTERNAL_ALLOW = [
'react',
'react/jsx-runtime',
]
/** 递归万能桩:任何属性访问/调用/构造都返回自身同型桩(覆盖 react 导出树与 jsx 调用)。 */
function makeStub() {
const stub = function () { return makeStub() }
return new Proxy(stub, {
get: (_t, k) => {
if (k === Symbol.toPrimitive) return () => 'stub'
return makeStub()
},
apply: () => makeStub(),
construct: () => makeStub(),
})
}
/**
* 求值一个 client bundle 源码:捕获 window.__ModuleLoader__.load 的注册结果并执行工厂。
* @param source - lib/client.js 全文
* @param expectedId - 期望的 ModuleLoader ID('dsh-worktable')
* @param expectedInject - 期望的客户端 inject 数组
* @returns {{ exports: any, id: string }} 工厂执行结果
* @throws {Error} 任何门禁失败(TDZ/ID 错误/未知依赖/导出结构错误)
*/
export function gateClientFactory(source, expectedId, expectedInject) {
let captured = null
let loadCalls = 0
const actualRequires = new Set()
const localStorageStub = { getItem: () => null, setItem: () => {}, removeItem: () => {} }
const docStub = new Proxy(function () { return makeStub() }, {
get: (_t, k) => {
if (k === 'body' || k === 'documentElement') return makeStub()
if (k === 'createElement') return () => makeStub()
if (k === 'querySelector' || k === 'querySelectorAll' || k === 'getElementById') return () => null
if (k === 'addEventListener' || k === 'removeEventListener') return () => {}
if (k === Symbol.toPrimitive) return () => 'stub'
return makeStub()
},
apply: () => makeStub(),
})
const sandbox = {
window: {},
console: { log() {}, warn() {}, error() {} },
setTimeout, clearTimeout,
localStorage: localStorageStub,
document: docStub,
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:stub', revokeObjectURL: () => {} },
MutationObserver: function MutationObserver() { return { observe: () => {}, disconnect: () => {} } },
ResizeObserver: function ResizeObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
IntersectionObserver: function IntersectionObserver() { return { observe: () => {}, unobserve: () => {}, disconnect: () => {} } },
requestAnimationFrame: (cb) => 0,
cancelAnimationFrame: () => {},
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0', platform: 'Win32', language: 'zh-CN' },
fetch: () => Promise.reject(new Error('fetch unavailable in gate sandbox')),
indexedDB: undefined,
}
sandbox.window.__ModuleLoader__ = {
load: (reg) => {
loadCalls++
if (loadCalls > 1) throw new Error('window.__ModuleLoader__.load called more than once')
captured = reg
},
}
sandbox.window.addEventListener = () => {}
sandbox.window.removeEventListener = () => {}
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} })
sandbox.window.localStorage = localStorageStub
sandbox.window.document = docStub
sandbox.self = sandbox.window // 浏览器全局 self === window
createContext(sandbox)
try {
// VM 超时:错误/恶意 bundle 的顶层无限循环不能卡死发布流程(10s)
runInNewContext(source, sandbox, { filename: 'client.js', timeout: 10000 })
} catch (e) {
throw new Error('client factory evaluation failed: ' + (e && e.message ? e.message : String(e)))
}
if (loadCalls === 0) {
throw new Error('window.__ModuleLoader__.load was never called')
}
if (!captured || typeof captured !== 'object') {
throw new Error('window.__ModuleLoader__.load was never called')
}
if (captured.id !== expectedId) {
throw new Error('ModuleLoader ID mismatch: got ' + JSON.stringify(captured.id) + ', expected ' + expectedId)
}
if (typeof captured.factory !== 'function') {
throw new Error('registered factory is not a function')
}
const requireStub = (id) => {
if (typeof id !== 'string') throw new Error('require called with non-string id')
actualRequires.add(id)
if (CLIENT_EXTERNAL_ALLOW.includes(id)) return makeStub()
throw new Error('unexpected external require: ' + id)
}
// 工厂调用放进受 timeout 控制的 vm 执行(防死循环卡死发布流程)
const runSandbox = { __factory: captured.factory, __requireStub: requireStub }
createContext(runSandbox)
let exportsObj
try {
exportsObj = runInNewContext('__factory(__requireStub)', runSandbox, { timeout: 10000 })
} catch (e) {
const msg = e && e.message ? e.message : String(e)
if (/timed out|script execution/i.test(msg)) throw new Error('factory evaluation timed out (>10s)')
throw new Error('factory threw during evaluation: ' + msg)
}
// 实际 require 集合必须严格等于白名单中已声明且被使用的项(按需子集);空集也放行
for (const id of actualRequires) {
if (!CLIENT_EXTERNAL_ALLOW.includes(id)) throw new Error('unexpected external require: ' + id)
}
if (!exportsObj || typeof exportsObj !== 'object') {
throw new Error('factory did not return module.exports object')
}
if (typeof exportsObj.apply !== 'function') {
throw new Error('client exports.apply is not a function (got ' + typeof exportsObj.apply + ')')
}
const inj = exportsObj.inject
if (!Array.isArray(inj)) throw new Error('client exports.inject is not an array')
const want = JSON.stringify(expectedInject)
const got = JSON.stringify(inj)
if (want !== got) throw new Error('client inject mismatch: got ' + got + ', expected ' + want)
return { id: captured.id, exports: exportsObj, actualRequires: [...actualRequires].sort() }
}
+72
View File
@@ -0,0 +1,72 @@
/**
* client-factory-gate 失败测试:证明 TDZ / 错误 ID / 未知依赖 / 错误 inject 能被拦截。
* 用法:node client-factory-gate.test.mjs(不依赖 npm,纯 node;失败用例抛错即测试失败)
*/
import { gateClientFactory } from './client-factory-gate.mjs'
const WRAP = (body) => "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports; " + body + " return module.exports; } });"
const OK_BODY = "exports.apply = function apply() {}; exports.inject = ['slots','locale','sessions','conversation','workspaces'];"
let pass = 0
function expectFail(name, source, expectId, expectInject, pattern) {
try {
gateClientFactory(source, expectId, expectInject)
console.error('FAIL(should have been blocked): ' + name)
process.exit(1)
} catch (e) {
const msg = String(e && e.message ? e.message : e)
if (pattern && !pattern.test(msg)) {
console.error('FAIL(wrong reason): ' + name + ' => ' + msg)
process.exit(1)
}
console.log('ok blocked: ' + name + ' => ' + msg)
pass++
}
}
// 1. TDZ 顶层自引用(#491 同款 const Ge=Ge)
expectFail('TDZ self-reference', WRAP("const Ge = Ge;"), 'dsh-worktable', ['slots'], /factory threw|evaluation failed/)
// 2. 错误 ModuleLoader ID
expectFail('wrong ModuleLoader ID', "window.__ModuleLoader__.load({ id: 'wrong-plugin', factory: (require) => { return { apply() {}, inject: [] } } });", 'dsh-worktable', [], /ID mismatch/)
// 3. 未知外部依赖 require
expectFail('unknown external require', WRAP("require('totally-unknown-pkg'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /unexpected external require/)
// 4. 导出结构错误:apply 非函数
expectFail('apply not a function', WRAP("exports.apply = null; exports.inject = [];"), 'dsh-worktable', [], /apply is not a function/)
// 5. inject 内容错误
expectFail('inject mismatch', WRAP("exports.apply = function(){}; exports.inject = ['slots'];"), 'dsh-worktable', ['slots','locale'], /inject mismatch/)
// 6. 白名单外 @deepseek-ai 包(曾是最常见的"引用已删除宿主模块白屏")
expectFail('disallowed @deepseek-ai require', WRAP("require('@deepseek-ai/dsh-client-runtime/client'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /unexpected external require/)
// 7. 重复注册 ModuleLoader(load 调用超过一次)
expectFail('double ModuleLoader registration', "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: () => ({ apply() {}, inject: [] }) }); window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: () => ({ apply() {}, inject: [] }) });", 'dsh-worktable', [], /more than once/)
// 8. 正向:合法最小 bundle 通过
try {
gateClientFactory(WRAP(OK_BODY), 'dsh-worktable', ['slots','locale','sessions','conversation','workspaces'])
console.log('ok passed: valid minimal bundle')
pass++
} catch (e) {
console.error('FAIL(should pass): valid minimal bundle => ' + (e && e.message ? e.message : e))
process.exit(1)
}
// 9. 死循环工厂(>10s 超时拦截;用 11s 忙等模拟)
expectFail('factory infinite loop timeout', WRAP("var t0 = Date.now(); while (Date.now() - t0 < 11000) {}; exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [], /timed out|>10s/)
// 10. 正向:白名单内 require 被记录(react/jsx-runtime 允许)
try {
const r = gateClientFactory(WRAP("require('react/jsx-runtime'); exports.apply = function(){}; exports.inject = [];"), 'dsh-worktable', [])
if (!r.actualRequires.includes('react/jsx-runtime')) throw new Error('actualRequires not recorded')
console.log('ok passed: allowed require recorded')
pass++
} catch (e) {
console.error('FAIL(should pass): allowed require => ' + (e && e.message ? e.message : e))
process.exit(1)
}
console.log('all gate tests passed: ' + pass + '/10')
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
"version": "0.3.1",
"version": "0.3.2",
"entry": {
"name": "dsh-worktable",
"inject": []
+212 -29
View File
@@ -8031,11 +8031,15 @@ var css = xterm_default + "\n" + [
".dsh-wt_addFolderPath{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".dsh-wt_addFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280);border-style:dashed}",
".dsh-wt_addFolderRow .dsh-wt_customLabel{flex:none;width:auto;text-align:left}",
".dsh-wt_manualPathInput{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb)}",
".dsh-wt_addFolderRow .dsh-wt_bindFolderChange:disabled{opacity:.5;cursor:default}",
".dsh-wt_folderPickHint{margin-top:6px;margin-bottom:0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-state-warning,#e0a33c)}",
".dsh-wt_bindFolderBox .dsh-wt_addError{margin-top:6px;margin-bottom:0}",
// 绑定弹窗内的项目文件夹区
".dsh-wt_bindFolderBox{margin-top:8px;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px}",
".dsh-wt_bindFolderRow{display:flex;align-items:center;justify-content:space-between}",
".dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}",
".dsh-wt_bindFolderChange{flex:none;padding:1px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}",
".dsh-wt_bindFolderRow{display:flex;align-items:center;gap:6px}",
".dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb);margin-right:auto}",
".dsh-wt_bindFolderChange{flex:none;padding:2px 10px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}",
".dsh-wt_bindFolderChange:hover{color:var(--dsw-alias-label-primary,#e6e8eb);border-color:var(--dsw-alias-border-l2,#3a4150)}",
".dsh-wt_bindFolderPath{margin-top:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".dsh-wt_bindFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280)}",
@@ -8437,6 +8441,16 @@ var zh = {
"add.folderPick": "\u9009\u62E9\u4F4D\u7F6E\u2026",
"add.folderNone": "\u672A\u9009\u62E9\uFF08\u70B9\u53F3\u4FA7\u300C\u9009\u62E9\u4F4D\u7F6E\u2026\u300D\u5728\u8D44\u6E90\u7BA1\u7406\u5668\u7A97\u53E3\u91CC\u6311\u4E00\u4E2A\u6587\u4EF6\u5939\uFF09",
"add.folderRequired": "\u8BF7\u5148\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u7684\u4F4D\u7F6E",
"add.folderPicking": "\u7B49\u5F85\u9009\u62E9\u2026",
"add.folderPickerHint": "\u6B63\u5728\u7B49\u5F85\u6587\u4EF6\u5939\u9009\u62E9\uFF1B\u82E5\u672A\u770B\u5230\u9009\u62E9\u7A97\u53E3\uFF0C\u53EF\u68C0\u67E5\u4EFB\u52A1\u680F\uFF0C\u6216\u4F7F\u7528\u300C\u624B\u52A8\u8F93\u5165\u300D\u3002",
"add.folderPickFail": "\u76EE\u5F55\u9009\u62E9\u5931\u8D25\uFF1A{msg}",
"add.folderPickerUnavailable": "\u7CFB\u7EDF\u76EE\u5F55\u9009\u62E9\u5668\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7528\u300C\u624B\u52A8\u8F93\u5165\u300D\u7C98\u8D34\u8DEF\u5F84",
"add.folderManual": "\u624B\u52A8\u8F93\u5165",
"add.folderManualPh": "\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF0C\u56DE\u8F66\u786E\u8BA4",
"add.folderManualOk": "\u4F7F\u7528\u6B64\u8DEF\u5F84",
"add.folderManualEmpty": "\u8BF7\u8F93\u5165\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84",
"add.folderManualAbs": "\u8BF7\u8F93\u5165\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 C:\\Users\\me\\Projects \u6216 /home/me/projects\uFF09",
"add.folderCreateFail": "\u9879\u76EE\u6587\u4EF6\u5939\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u540E\u91CD\u8BD5",
"add.layoutSave": "\u8FDB\u5165\u5DE5\u4F5C\u533A",
"add.layoutInvalid": "\u8BF7\u586B\u5199\u5E03\u5C40\u540D\u79F0",
"manage.deleteLayout": "\u5220\u9664\u5E03\u5C40",
@@ -8657,6 +8671,16 @@ var en = {
"add.folderPick": "Choose\u2026",
"add.folderNone": "Not chosen (click Choose\u2026 to pick a folder in the explorer window)",
"add.folderRequired": "Pick the project folder location first",
"add.folderPicking": "Waiting for picker\u2026",
"add.folderPickerHint": "Waiting for the folder picker. If no window appears, check the taskbar or use \u201CManual\u201D.",
"add.folderPickFail": "Folder pick failed: {msg}",
"add.folderPickerUnavailable": "System folder picker unavailable \u2014 paste the path via \u201CManual\u201D",
"add.folderManual": "Manual",
"add.folderManualPh": "Paste an absolute folder path, Enter to confirm",
"add.folderManualOk": "Use this path",
"add.folderManualEmpty": "Enter an absolute folder path first",
"add.folderManualAbs": "Absolute path required (e.g. C:\\Users\\me\\Projects or /home/me/projects)",
"add.folderCreateFail": "Failed to create the project folder \u2014 check the path and retry",
"add.layoutSave": "Enter workspace",
"add.layoutInvalid": "Enter a layout name",
"manage.deleteLayout": "Delete layout",
@@ -8872,9 +8896,19 @@ function basenameOf(p) {
var import_react = require("react");
// src/client/changelog.ts
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.1
var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.2
\u{1F527} \u672C\u7248\u672C\u4FEE\u590D\u8BF4\u660E\uFF1Av0.3.0 \u53D1\u5E03\u5305\u5B58\u5728\u6253\u5305\u7F3A\u9677\uFF08\u975E\u6807\u51C6 npm \u5305\u683C\u5F0F\uFF0C\u5B89\u88C5\u65F6\u4F1A\u62A5\u9519\u5BFC\u81F4\u63D2\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF09\uFF0Cv0.3.1 \u5DF2\u6309\u6807\u51C6\u683C\u5F0F\u91CD\u65B0\u6253\u5305\u3002\u6B64\u524D\u5B89\u88C5\u5931\u8D25\u7684\u7528\u6237\uFF0C\u91CD\u65B0\u6267\u884C\u4E00\u6B21\u5347\u7EA7\u5B89\u88C5\u547D\u4EE4\u5373\u53EF\u6B63\u5E38\u4F7F\u7528\u672C\u7248\u672C\u3002
\u{1F6E0}\uFE0F \u672C\u7248\u672C\u66F4\u65B0
\u3010\u6587\u4EF6\u5939\u9009\u62E9\u4F53\u9A8C\u3011\u9009\u62E9\u9879\u76EE\u6587\u4EF6\u5939\u65F6\uFF1A\u7B49\u5F85\u671F\u95F4\u6309\u94AE\u663E\u793A\u300C\u7B49\u5F85\u9009\u62E9\u2026\u300D\u5E76\u63D0\u793A\u300C\u82E5\u672A\u770B\u5230\u9009\u62E9\u7A97\u53E3\uFF0C\u53EF\u68C0\u67E5\u4EFB\u52A1\u680F\uFF0C\u6216\u4F7F\u7528\u624B\u52A8\u8F93\u5165\u300D\uFF1B\u65B0\u589E\u300C\u624B\u52A8\u8F93\u5165\u300D\u5165\u53E3\uFF0C\u53EF\u76F4\u63A5\u7C98\u8D34\u6587\u4EF6\u5939\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u76F8\u5BF9\u8DEF\u5F84\u4F1A\u88AB\u62D2\u7EDD\u5E76\u63D0\u793A\uFF09\uFF1B\u9009\u62E9\u5931\u8D25\u65F6\u663E\u793A\u5177\u4F53\u9519\u8BEF\uFF0C\u4E0D\u518D\u9759\u9ED8\u65E0\u53CD\u5E94\uFF1B\u4FEE\u590D\u4E86\u65E7\u9009\u62E9\u7A97\u53E3\u8FD4\u56DE\u540E\u8986\u76D6\u624B\u5DE5\u8F93\u5165\u3001\u5F39\u7A97\u5173\u95ED/\u5207\u6362\u540E\u72B6\u6001\u6B8B\u7559\u7B49\u95EE\u9898\u3002
\u3010\u53D1\u5E03\u6D41\u7A0B\u52A0\u56FA\u3011\u53D1\u5E03\u5305\u65B0\u589E\u7ED3\u6784\u4E0E\u8EAB\u4EFD\u6821\u9A8C\u3001\u5BA2\u6237\u7AEF\u5DE5\u5382\u6C42\u503C\u95E8\u7981\u3001\u53CC\u8D44\u4EA7\u540C\u6E90\u6838\u5BF9\u4E0E\u8FDC\u7AEF SHA \u6BD4\u5BF9\uFF08\u5F00\u53D1\u8005\u4FA7\u6539\u8FDB\uFF0C\u4E0D\u5F71\u54CD\u63D2\u4EF6\u529F\u80FD\uFF09\u3002
\u3010\u8FB9\u754C\u8BF4\u660E\u3011\u672C\u7248\u672C\u662F\u63D2\u4EF6\u4FA7\u4F53\u9A8C\u6539\u5584\u4E0E\u53D1\u5E03\u6D41\u7A0B\u52A0\u56FA\uFF1A\u4E0D\u6539\u53D8\u5BBF\u4E3B\u539F\u751F\u76EE\u5F55\u9009\u62E9\u5668\u7684\u884C\u4E3A\uFF08\u9009\u62E9\u7A97\u53E3\u4E0D\u7F6E\u524D\u5C5E DSH \u5BBF\u4E3B\u5DF2\u77E5\u95EE\u9898\uFF0C\u7B49\u5F85\u5B98\u65B9\u4FEE\u590D\uFF09\uFF0C\u4E5F\u4E0D\u4EE3\u8868\u6240\u6709\u542F\u52A8\u5931\u8D25\u5DF2\u89E3\u51B3\u3002\u5347\u7EA7\u5B89\u88C5\u540E\u8BF7\u5B8C\u6574\u9000\u51FA\u5E76\u91CD\u542F DSH \u518D\u5237\u65B0\u9875\u9762\uFF1B\u82E5\u91CD\u542F\u540E\u4ECD\u663E\u793A\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u4FDD\u7559\u5B8C\u6574\u9519\u8BEF\u4FE1\u606F\u53CD\u9988\u3002
\u2500\u2500\u2500\u2500\u2500\u2500\u2500
\u{1F527} \u6B64\u524D\u7248\u672C\u8BF4\u660E\uFF08v0.3.1\uFF09\uFF1Av0.3.0 \u53D1\u5E03\u5305\u5B58\u5728\u6253\u5305\u7F3A\u9677\uFF08\u975E\u6807\u51C6 npm \u5305\u683C\u5F0F\uFF0C\u5B89\u88C5\u65F6\u62A5\u9519\u5BFC\u81F4\u63D2\u4EF6\u65E0\u6CD5\u52A0\u8F7D\uFF09\uFF0Cv0.3.1 \u5DF2\u6309\u6807\u51C6\u683C\u5F0F\u91CD\u65B0\u6253\u5305\u3002
\u{1F6E0}\uFE0F \u4E00\u3001\u63A7\u5236\u5BA4 UI \u8C03\u6574
@@ -8902,7 +8936,7 @@ var CHANGELOG_V030 = `\u66F4\u65B0\u516C\u544A \xB7 v0.3.1
\u30103.1 \u6587\u5B57\u4E0E\u7EC6\u8282\u6253\u78E8\u3011\u5168\u5C40\u5B57\u4F53\u4E0E\u5B57\u53F7\u7EDF\u4E00\u4F18\u5316\u3001\u4E0B\u62C9\u9762\u677F\u73BB\u7483\u5316\u4E0E\u5BF9\u9F50\u3001\u83DC\u5355\u70B9\u9009\u540E\u4FDD\u6301\u6253\u5F00\u4FBF\u4E8E\u8FDE\u7EED\u9884\u89C8\u3001\u6309\u94AE\u63CF\u8FB9\u4E0E\u60AC\u505C\u53CD\u9988\u7B49\u4EA4\u4E92\u7EC6\u8282\uFF1B\u540C\u65F6\u4FEE\u590D\u4E86\u591A\u9879\u4F53\u9A8C\u95EE\u9898\uFF08\u7167\u7247\u4E0A\u4F20\u6E05\u6670\u5EA6\u3001\u80CC\u666F\u7F51\u683C\u7EBF\u5728\u7167\u7247\u6A21\u5F0F\u4E0B\u4E0D\u751F\u6548\u3001\u6D45\u8272\u4E3B\u9898\u4E0B\u5DE5\u4F5C\u72B6\u6001\u5149\u6548\u4E0D\u53EF\u89C1\u7B49\uFF09\u3002`;
// src/client/updateCheck.ts
var LOCAL_VERSION = false ? "dev" : "0.3.1";
var LOCAL_VERSION = false ? "dev" : "0.3.2";
var UPDATE_REPO = "Aisland-SJL/dsh-worktable";
var K_UPDATE_CHECK = "dsh.worktable.updateCheck.v1";
var K_LAST_CHECK = "dsh.worktable.lastUpdateCheck.v1";
@@ -20008,7 +20042,7 @@ var WAVE_BG_B64 = "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBQUFBAYFBQUHBgYHCQ8KCQgIC
// src/client/index.tsx
var import_jsx_runtime2 = require("react/jsx-runtime");
var LOCAL_VERSION2 = false ? "dev" : "0.3.1";
var LOCAL_VERSION2 = false ? "dev" : "0.3.2";
var UPDATE_REPO2 = "Aisland-SJL/dsh-worktable";
var UPGRADE_CMD2 = 'dsh plugin --profile web add "https://github.com/Aisland-SJL/dsh-worktable/releases/latest/download/dsh-worktable.tgz"';
var UPGRADE_AI2 = "\u5E2E\u6211\u5347\u7EA7 dsh-worktable\uFF1A\u6267\u884C " + UPGRADE_CMD2 + "\uFF0C\u5B8C\u6210\u540E\u63D0\u9192\u6211\u91CD\u542F dsh web \u5E76\u5237\u65B0\u9875\u9762";
@@ -21040,6 +21074,16 @@ function WorktableSection(props) {
const [wsError, setWsError] = (0, import_react2.useState)(false);
const [wsFolderParent, setWsFolderParent] = (0, import_react2.useState)("");
const [wsFolderError, setWsFolderError] = (0, import_react2.useState)(false);
const [pickBusy, setPickBusy] = (0, import_react2.useState)(false);
const [pickErr, setPickErr] = (0, import_react2.useState)({ add: "", bind: "" });
const [manualPathFor, setManualPathFor] = (0, import_react2.useState)(null);
const [manualPathText, setManualPathText] = (0, import_react2.useState)("");
const pickSeqRef = (0, import_react2.useRef)(0);
const pickBusyRef = (0, import_react2.useRef)(false);
const mountedRef = (0, import_react2.useRef)(true);
(0, import_react2.useEffect)(() => () => {
mountedRef.current = false;
}, []);
const [iconPick, setIconPick] = (0, import_react2.useState)(null);
const [bindPick, setBindPick] = (0, import_react2.useState)(null);
const [bindGroups, setBindGroups] = (0, import_react2.useState)([]);
@@ -21700,6 +21744,7 @@ function WorktableSection(props) {
};
actionsRef.current = { openSplit, openConsole };
const openBindPick = (0, import_react2.useCallback)((id, anchor) => {
invalidatePickState();
const r = anchor.getBoundingClientRect();
const x = clamp2(Math.round(r.right + 8), 8, window.innerWidth - 300);
const y = clamp2(Math.round(r.top), 8, window.innerHeight - 420);
@@ -21708,19 +21753,68 @@ function WorktableSection(props) {
setBindGroups([]);
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]));
}, []);
const pickFolder = async (apply2) => {
const pickFolder = async (target, apply2) => {
if (pickBusyRef.current) return;
const ws = sessionBridge?.workspaces;
if (!ws || typeof ws.pickDirectory !== "function") {
setPickErr((prev) => ({ ...prev, [target]: t("add.folderPickerUnavailable") }));
return;
}
pickBusyRef.current = true;
setPickBusy(true);
setPickErr((prev) => ({ ...prev, [target]: "" }));
const seq = ++pickSeqRef.current;
try {
const ws = sessionBridge?.workspaces;
if (ws && typeof ws.pickDirectory === "function") {
const p = await ws.pickDirectory();
if (p && typeof p === "string") apply2(p);
const p = await ws.pickDirectory();
if (seq !== pickSeqRef.current) return;
if (!mountedRef.current) return;
if (p && typeof p === "string") apply2(p);
} catch (e) {
if (seq !== pickSeqRef.current) return;
if (!mountedRef.current) return;
const msg = e && typeof e === "object" && typeof e.message === "string" ? e.message : String(e ?? "");
setPickErr((prev) => ({ ...prev, [target]: t("add.folderPickFail", { msg: msg || "unknown" }) }));
} finally {
if (seq === pickSeqRef.current && mountedRef.current) {
pickBusyRef.current = false;
setPickBusy(false);
}
} catch {
}
};
const invalidatePickState = () => {
pickSeqRef.current++;
pickBusyRef.current = false;
setPickBusy(false);
setManualPathFor(null);
setManualPathText("");
setPickErr({ add: "", bind: "" });
};
const applyManualPath = (target) => {
const p = manualPathText.trim();
if (!p) {
setPickErr((prev) => ({ ...prev, [target]: t("add.folderManualEmpty") }));
return;
}
if (!isAbs(p)) {
setPickErr((prev) => ({ ...prev, [target]: t("add.folderManualAbs") }));
return;
}
pickSeqRef.current++;
pickBusyRef.current = false;
setPickBusy(false);
if (target === "add") {
setWsFolderParent(p);
setWsFolderError(false);
} else if (bindPick) {
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
}
setManualPathFor(null);
setManualPathText("");
setPickErr((prev) => ({ ...prev, [target]: "" }));
};
const changeBindFolder = () => {
if (!bindPick) return;
pickFolder((p) => {
if (!bindPick || pickBusyRef.current) return;
pickFolder("bind", (p) => {
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }));
});
};
@@ -22001,6 +22095,7 @@ function WorktableSection(props) {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
}
invalidatePickState();
setViewOptionsOpen(false);
setAddOpen(false);
};
@@ -22081,12 +22176,25 @@ function WorktableSection(props) {
setWsFolderError(true);
return;
}
if (!isAbs(folderPath)) {
setWsFolderError(true);
return;
}
try {
await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: folderPath }) });
const r = await fetch("/api/worktable/mkdir", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: folderPath }) });
if (!r.ok) {
setWsFolderError(true);
setPickErr((prev) => ({ ...prev, add: t("add.folderCreateFail") }));
return;
}
} catch {
setWsFolderError(true);
setPickErr((prev) => ({ ...prev, add: t("add.folderCreateFail") }));
return;
}
const layout = buildLayout(wsPreset, name);
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } }));
invalidatePickState();
setWsName("");
setWsFolderParent("");
setWsError(false);
@@ -22481,6 +22589,7 @@ function WorktableSection(props) {
"aria-label": t("menu.viewOptions"),
title: t("menu.viewOptions"),
onClick: () => {
invalidatePickState();
setViewOptionsOpen((v) => !v);
setAddOpen(false);
},
@@ -22495,6 +22604,7 @@ function WorktableSection(props) {
"aria-label": t("menu.add"),
title: t("menu.add"),
onClick: () => {
invalidatePickState();
setAddOpen((v) => !v);
setViewOptionsOpen(false);
if (!wsFolderParent) {
@@ -22530,7 +22640,10 @@ function WorktableSection(props) {
}
)
] }),
addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", onClick: () => setAddOpen(false) }),
addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", onClick: () => {
invalidatePickState();
setAddOpen(false);
} }),
addOpen && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_menu dsh-wt_add dsh-wt_pop", style: { position: "fixed", left: popLeft, top: popTop, width: 360, zIndex: 80 }, children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_menuLabel", children: t("add.chooseLayout") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_presets", children: [
@@ -22555,6 +22668,7 @@ function WorktableSection(props) {
className: "dsh-wt_preset dsh-wt_presetAdd",
title: t("customLayout.addTitle"),
onClick: () => {
invalidatePickState();
setAddOpen(false);
setCustomOpen(true);
},
@@ -22581,14 +22695,54 @@ function WorktableSection(props) {
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_customLabel", children: t("add.folderParent") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-wt_addFolderPath" + (wsFolderParent ? "" : " dsh-wt_addFolderPathNone"), title: wsFolderParent || "", children: wsFolderParent || t("add.folderNone") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => {
pickFolder((p) => {
setWsFolderParent(p);
setWsFolderError(false);
});
setWsError(false);
}, children: t("add.folderPick") })
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"button",
{
type: "button",
className: "dsh-wt_bindFolderChange",
disabled: pickBusy,
onClick: () => {
pickFolder("add", (p) => {
setWsFolderParent(p);
setWsFolderError(false);
});
setWsError(false);
},
children: pickBusy ? t("add.folderPicking") : t("add.folderPick")
}
),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"button",
{
type: "button",
className: "dsh-wt_bindFolderChange",
title: t("add.folderManual"),
onClick: () => {
setManualPathFor(manualPathFor === "add" ? null : "add");
setPickErr((prev) => ({ ...prev, add: "" }));
},
children: t("add.folderManual")
}
)
] }),
pickBusy && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_folderPickHint", children: t("add.folderPickerHint") }),
manualPathFor === "add" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"input",
{
type: "text",
className: "dsh-wt_manualPathInput",
placeholder: t("add.folderManualPh"),
value: manualPathText,
onChange: (e) => setManualPathText(e.target.value),
onKeyDown: (e) => {
if (e.key === "Enter") applyManualPath("add");
}
}
),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => applyManualPath("add"), children: t("add.folderManualOk") })
] }),
pickErr.add && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: pickErr.add }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_addBtn", onClick: saveLayout, children: t("add.layoutSave") })
] }),
wsError && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: t("add.layoutInvalid") }),
@@ -22828,6 +22982,7 @@ function WorktableSection(props) {
] })
] }),
bindPick && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_popBackdrop", style: { zIndex: 83 }, onClick: () => {
invalidatePickState();
setBindPick(null);
setBindListOpen(false);
} }),
@@ -22838,12 +22993,40 @@ function WorktableSection(props) {
"\u{1F4C1} ",
t("bind.folder")
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: changeBindFolder, children: [
t("bind.folderChange"),
" \u21BB"
] })
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", disabled: pickBusy, onClick: changeBindFolder, children: pickBusy ? t("add.folderPicking") : t("bind.folderChange") + " \u21BB" }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"button",
{
type: "button",
className: "dsh-wt_bindFolderChange",
title: t("add.folderManual"),
onClick: () => {
setManualPathFor(manualPathFor === "bind" ? null : "bind");
setPickErr((prev) => ({ ...prev, bind: "" }));
},
children: t("add.folderManual")
}
)
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_bindFolderPath" + (projects.folders[bindPick.id] ? "" : " dsh-wt_bindFolderPathNone"), title: projects.folders[bindPick.id] ?? "", children: projects.folders[bindPick.id] ?? t("bind.folderNone") })
pickBusy && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_folderPickHint", children: t("add.folderPickerHint") }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "dsh-wt_bindFolderPath" + (projects.folders[bindPick.id] ? "" : " dsh-wt_bindFolderPathNone"), title: projects.folders[bindPick.id] ?? "", children: projects.folders[bindPick.id] ?? t("bind.folderNone") }),
manualPathFor === "bind" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_addFolderRow", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
"input",
{
type: "text",
className: "dsh-wt_manualPathInput",
placeholder: t("add.folderManualPh"),
value: manualPathText,
onChange: (e) => setManualPathText(e.target.value),
onKeyDown: (e) => {
if (e.key === "Enter") applyManualPath("bind");
}
}
),
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "dsh-wt_bindFolderChange", onClick: () => applyManualPath("bind"), children: t("add.folderManualOk") })
] }),
pickErr.bind && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "dsh-wt_addError", children: pickErr.bind })
] }),
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_bindFolderBox", children: [
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "dsh-wt_bindFolderRow", children: [
+1 -1
View File
@@ -88,7 +88,7 @@ body {
var dshell_default2 = '<!doctype html>\n<!-- dsh-worktable \u539F\u751F\u76AE\u80A4\u6A21\u677F\uFF1A\u65B0\u9875\u9762\u4EE5\u6B64\u4E3A\u57FA\u7840\uFF0C\u66FF\u6362\u4E0B\u9762\u793A\u4F8B\u5185\u5BB9\u5373\u53EF\u3002\n \u6837\u5F0F\u8868\u7531\u63D2\u4EF6\u63D0\u4F9B\uFF08\u968F\u4E3B\u9898\u81EA\u52A8\u9002\u914D\uFF09\uFF0C\u4E0D\u8981\u590D\u5236\u6216\u6539\u5199\u5B83\u3002 -->\n<html lang="zh-CN">\n<head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n <title>\u6211\u7684\u7A97\u53E3</title>\n <link rel="stylesheet" href="/api/worktable/template/dshell.css" />\n</head>\n<body>\n <div class="dshell">\n <!-- \u6807\u9898\u533A -->\n <h1 class="dshell-title">\u7A97\u53E3\u6807\u9898</h1>\n <p class="dshell-sub">\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002</p>\n\n <!-- \u72B6\u6001\u5FBD\u6807\uFF1A\u5DF2\u5B8C\u6210 dshell-badgeDone / \u8FDB\u884C\u4E2D dshell-badgeWait / \u9ED8\u8BA4 -->\n <div>\n <span class="dshell-badge dshell-badgeDone">\u5DF2\u5B8C\u6210</span>\n <span class="dshell-badge dshell-badgeWait">\u8FDB\u884C\u4E2D</span>\n <span class="dshell-badge">\u672A\u5F00\u59CB</span>\n </div>\n\n <!-- \u6807\u7B7E\u9875 -->\n <div class="dshell-tabs">\n <button class="dshell-tab dshell-tabOn">\u6982\u89C8</button>\n <button class="dshell-tab">\u8BE6\u60C5</button>\n <button class="dshell-tab">\u8BBE\u7F6E</button>\n </div>\n\n <!-- \u7EDF\u8BA1\u5361\u7247\u7F51\u683C -->\n <div class="dshell-grid">\n <div class="dshell-stat">\n <div class="dshell-statLabel">\u603B\u6570</div>\n <div class="dshell-statValue">128</div>\n <div class="dshell-statDelta">+12.4%</div>\n </div>\n <div class="dshell-stat">\n <div class="dshell-statLabel">\u8FDB\u884C\u4E2D</div>\n <div class="dshell-statValue">7</div>\n </div>\n <div class="dshell-stat">\n <div class="dshell-statLabel">\u5DF2\u5B8C\u6210</div>\n <div class="dshell-statValue">121</div>\n </div>\n </div>\n\n <!-- \u5217\u8868 -->\n <div class="dshell-list">\n <div class="dshell-listItem">\n <span class="dshell-listItemTitle">\u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898</span>\n <span class="dshell-listItemMeta">\u6628\u5929</span>\n </div>\n <div class="dshell-listItem">\n <span class="dshell-listItemTitle">\u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898</span>\n <span class="dshell-badge dshell-badgeDone">\u5DF2\u53D1\u5E03</span>\n </div>\n </div>\n\n <!-- \u5361\u7247 + \u952E\u503C\u5BF9 -->\n <div class="dshell-card">\n <h2 class="dshell-sub" style="margin:0 0 8px">\u8BE6\u60C5</h2>\n <div class="dshell-kv">\n <div class="dshell-kvRow"><span class="dshell-kvKey">\u5B57\u6BB5 A</span><span class="dshell-kvValue">\u503C A</span></div>\n <div class="dshell-kvRow"><span class="dshell-kvKey">\u5B57\u6BB5 B</span><span class="dshell-kvValue">\u503C B</span></div>\n </div>\n <div class="dshell-divider"></div>\n <div class="dshell-progress"><div class="dshell-progressBar" style="width:72%"></div></div>\n </div>\n\n <!-- \u64CD\u4F5C\u533A -->\n <div style="display:flex;gap:8px">\n <button class="dshell-btn">\u4E3B\u8981\u64CD\u4F5C</button>\n <button class="dshell-btn dshell-btnGhost">\u6B21\u8981\u64CD\u4F5C</button>\n </div>\n </div>\n</body>\n</html>\n';
// src/index.ts
var PLUGIN_VERSION = false ? "dev" : "0.3.1";
var PLUGIN_VERSION = false ? "dev" : "0.3.2";
var name = "dsh-worktable";
var inject = ["webServer", "sessions"];
var HEALTH_PATH = "/api/worktable/health";
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-worktable",
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
"version": "0.3.1",
"version": "0.3.2",
"license": "MIT",
"type": "module",
"main": "lib/index.js",
@@ -34,7 +34,9 @@
"scripts": {
"build": "node build.mjs",
"check": "node --check lib/index.js && node --check lib/client.js",
"pack": "node release-prep.mjs"
"pack": "node release-prep.mjs",
"test:gate": "node client-factory-gate.test.mjs",
"verify:remote": "node verify-remote.mjs"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
+77 -10
View File
@@ -13,10 +13,11 @@
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, rmSync, copyFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, copyFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { gateClientFactory } from './client-factory-gate.mjs'
const HERE = dirname(fileURLToPath(import.meta.url)) // 固定 = 01_content,绝不依赖执行 cwd
// Windows 下 spawnSync('npm') 有 ENOENT、spawnSync('npm.cmd') 有 EINVAL 问题。
@@ -47,6 +48,40 @@ if (manifest.version !== pkg.version) {
const VERSION = pkg.version
console.log('[release-prep] version = ' + VERSION)
// ---------- 1b. 身份断言(package/manifest/patch 统一为 dsh-worktable) ----------
const IDENTITY = 'dsh-worktable'
if (pkg.name !== IDENTITY) { console.error('[release-prep] FAIL: package.json name = ' + pkg.name); process.exit(1) }
if (manifest.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json name = ' + manifest.name); process.exit(1) }
if (!manifest.entry || manifest.entry.name !== IDENTITY) { console.error('[release-prep] FAIL: dsh.plugin.json entry.name != ' + IDENTITY); process.exit(1) }
// cordis.patch.yml 严格检查:按缩进层级解析,只接受「- insert: 下的子列表项(id/name=IDENTITY)」结构
const patchRaw = readFileSync(join(HERE, 'cordis.patch.yml'), 'utf8')
const patchLines = patchRaw.split(/\r?\n/)
let patchOk = true
const patchEntries = []
let cur = null
for (const rawLine of patchLines) {
const line = rawLine.trimEnd()
if (line.trim() === '' || line.trim().startsWith('#')) continue
const indent = line.length - line.trimStart().length
const text = line.trim()
if (indent === 0 && text === '- insert:') { cur = {}; patchEntries.push(cur); continue }
if (indent === 0) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml unexpected top-level line: ' + JSON.stringify(text)); break }
if (indent === 4 && text === '- id: ' + IDENTITY) { if (cur && cur.id !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate - id: item (only one plugin entry allowed)'); break }; if (cur) cur.id = IDENTITY; continue }
if (indent === 4 && /^- id:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item id != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
if (indent === 6 && text === 'name: ' + IDENTITY) { if (cur && cur.name !== undefined) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml duplicate name field'); break }; if (cur) cur.name = IDENTITY; continue }
if (indent === 6 && /^name:/.test(text)) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml insert item name != ' + IDENTITY + ': ' + JSON.stringify(text)); break }
patchOk = false
console.error('[release-prep] FAIL: cordis.patch.yml unexpected line (indent ' + indent + '): ' + JSON.stringify(text))
break
}
if (patchOk && patchEntries.length !== 1) { patchOk = false; console.error('[release-prep] FAIL: cordis.patch.yml must contain exactly one insert entry, got ' + patchEntries.length) }
if (patchOk && (patchEntries[0].id !== IDENTITY || patchEntries[0].name !== IDENTITY)) {
patchOk = false
console.error('[release-prep] FAIL: cordis.patch.yml insert entry incomplete: ' + JSON.stringify(patchEntries[0]))
}
if (!patchOk) process.exit(1)
console.log('[release-prep] identity check passed (' + IDENTITY + ', cordis.patch.yml strict)')
// ---------- 2. 构建 + 语法检查 ----------
// 构建必带 cwd: HERE(脚本自身位置=01_content),否则从仓库根调用会输出到根 lib/ 造成旧包
const build = spawnSync(process.execPath, [join(HERE, 'build.mjs')], { stdio: 'inherit', cwd: HERE })
@@ -108,6 +143,8 @@ writeFileSync(assertJs, [
"const fails = []",
"if (typeof m.apply !== 'function') fails.push('apply not function: ' + typeof m.apply)",
"if (!Array.isArray(m.inject)) fails.push('inject not array')",
"if (!m.inject.includes('webServer')) fails.push('server inject missing webServer')",
"if (!m.inject.includes('sessions')) fails.push('server inject missing sessions')",
"if (m.name !== 'dsh-worktable') fails.push('name mismatch: ' + m.name)",
"if (m.HEALTH_PATH !== '/api/worktable/health') fails.push('HEALTH_PATH mismatch: ' + m.HEALTH_PATH)",
"const pkg = JSON.parse(readFileSync('./node_modules/dsh-worktable/package.json', 'utf8'))",
@@ -124,16 +161,46 @@ const imp = spawnSync(process.execPath, ['--input-type=module', '--eval', 'await
if (imp.status !== 0) { console.error('[release-prep] FAIL: import() assertion'); console.error(imp.stderr); process.exit(1) }
console.log('[release-prep] install + import assertion passed')
// ---------- 5b. 客户端工厂求值门禁(ModuleLoader 握手真实执行一次;非端到端验收) ----------
// 读「安装目录中的最终产物」lib/client.js(node_modules/dsh-worktable/lib/client.js),而非工作目录——验收最终包
const clientSrc = readFileSync(join(inst, 'node_modules', 'dsh-worktable', 'lib', 'client.js'), 'utf8')
const CLIENT_EXPECTED_INJECT = ['slots', 'locale', 'sessions', 'conversation', 'workspaces']
try {
gateClientFactory(clientSrc, 'dsh-worktable', CLIENT_EXPECTED_INJECT)
} catch (e) {
console.error('[release-prep] FAIL: client factory gate: ' + (e && e.message ? e.message : String(e)))
process.exit(1)
}
console.log('[release-prep] client factory gate passed (ModuleLoader id + factory evaluation + inject)')
// ---------- 6. 双资产从同一已验证包复制 + SHA-256 ----------
const fixedDir = join(HERE, 'dist')
mkdirSync(fixedDir, { recursive: true })
const assetFixed = join(fixedDir, 'dsh-worktable.tgz')
const assetVer = join(fixedDir, 'dsh-worktable-' + VERSION + '.tgz')
copyFileSync(tgz, assetFixed)
const outDir = join(HERE, 'dist', 'v' + VERSION)
mkdirSync(outDir, { recursive: true })
// 输出目录终态约束:只允许固定名 + 版本化两个 tgz;出现未知文件 = 报错,绝不删除
const KNOWN = new Set(['dsh-worktable.tgz', 'dsh-worktable-' + VERSION + '.tgz'])
for (const ent of readdirSync(outDir)) {
if (!KNOWN.has(ent)) {
console.error('[release-prep] FAIL: dist/v' + VERSION + '/ contains unknown file: ' + ent + ' (not deleting; fix manually)')
process.exit(1)
}
}
const assetFixed = join(outDir, 'dsh-worktable.tgz')
const assetVer = join(outDir, 'dsh-worktable-' + VERSION + '.tgz')
copyFileSync(tgz, assetFixed) // 同版本重跑:覆盖这两个已知文件是允许的
copyFileSync(tgz, assetVer)
// 复制后终态断言:目录恰好两个已知文件(无遗漏/无多余),双 SHA 一致
const finalEntries = readdirSync(outDir).sort()
const finalSet = new Set(finalEntries)
if (finalEntries.length !== 2 || !finalSet.has('dsh-worktable.tgz') || !finalSet.has('dsh-worktable-' + VERSION + '.tgz')) {
console.error('[release-prep] FAIL: post-copy final listing mismatch: ' + finalEntries.join(', '))
process.exit(1)
}
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
console.log('[release-prep] DONE — 双资产同源(同 SHA-256):')
console.log(' dist/dsh-worktable.tgz sha256:' + sha(assetFixed))
console.log(' dist/dsh-worktable-' + VERSION + '.tgz sha256:' + sha(assetVer))
console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。请用 gh 手动上传双资产并下载远端比对。')
const shaFixed = sha(assetFixed)
const shaVer = sha(assetVer)
if (shaFixed !== shaVer) { console.error('[release-prep] FAIL: two assets differ after copy'); process.exit(1) }
console.log('[release-prep] DONE — 双资产同源(同 SHA-256),终态目录恰好 2 文件:')
console.log(' dist/v' + VERSION + '/dsh-worktable.tgz sha256:' + shaFixed)
console.log(' dist/v' + VERSION + '/dsh-worktable-' + VERSION + '.tgz sha256:' + shaVer)
console.log('[release-prep] 提示:本脚本未推送、未打 tag、未发布。上传后请运行 verify-remote.mjs --expect-sha ' + shaFixed + ' 核对远端。')
rmSync(work, { recursive: true, force: true })
+14 -4
View File
@@ -1,8 +1,18 @@
/** 更新公告正文(v0.3.1 = v0.3.0 功能 + 发布包修复;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.1'
export const CHANGELOG_V030 = `更新公告 · v0.3.1
/** 更新公告正文(v0.3.2:文件夹选择体验 + 发布流程加固;呈现时按纯文本保留换行,不做 md 渲染) */
export const CHANGELOG_VERSION = 'v0.3.2'
export const CHANGELOG_V030 = `更新公告 · v0.3.2
🔧 本版本修复说明:v0.3.0 发布包存在打包缺陷(非标准 npm 包格式,安装时会报错导致插件无法加载),v0.3.1 已按标准格式重新打包。此前安装失败的用户,重新执行一次升级安装命令即可正常使用本版本。
🛠️ 本版本更新
【文件夹选择体验】选择项目文件夹时:等待期间按钮显示「等待选择…」并提示「若未看到选择窗口,可检查任务栏,或使用手动输入」;新增「手动输入」入口,可直接粘贴文件夹绝对路径(相对路径会被拒绝并提示);选择失败时显示具体错误,不再静默无反应;修复了旧选择窗口返回后覆盖手工输入、弹窗关闭/切换后状态残留等问题。
【发布流程加固】发布包新增结构与身份校验、客户端工厂求值门禁、双资产同源核对与远端 SHA 比对(开发者侧改进,不影响插件功能)。
【边界说明】本版本是插件侧体验改善与发布流程加固:不改变宿主原生目录选择器的行为(选择窗口不置前属 DSH 宿主已知问题,等待官方修复),也不代表所有启动失败已解决。升级安装后请完整退出并重启 DSH 再刷新页面;若重启后仍显示加载失败,请保留完整错误信息反馈。
───────
🔧 此前版本说明(v0.3.1):v0.3.0 发布包存在打包缺陷(非标准 npm 包格式,安装时报错导致插件无法加载),v0.3.1 已按标准格式重新打包。
🛠️ 一、控制室 UI 调整
+113 -18
View File
@@ -1179,6 +1179,17 @@ function WorktableSection(props: any) {
// 新建项目强制工作文件夹:经「选择位置…」弹窗选定(系统资源管理器式选择窗)
const [wsFolderParent, setWsFolderParent] = useState('')
const [wsFolderError, setWsFolderError] = useState(false)
/** 文件夹选择器状态:busy=等待系统选择器;err=按入口分开的失败原因(用户取消不报错);manual=手工输入展开目标 */
const [pickBusy, setPickBusy] = useState(false)
const [pickErr, setPickErr] = useState<{ add: string; bind: string }>({ add: '', bind: '' })
const [manualPathFor, setManualPathFor] = useState<'add' | 'bind' | null>(null)
const [manualPathText, setManualPathText] = useState('')
/** 文件夹选择在途锁 + 请求序号(useRef:跨渲染稳定;弹窗关闭/切换目标/手工确认时自增使旧请求失效) */
const pickSeqRef = useRef(0)
const pickBusyRef = useRef(false)
/** 组件卸载后不再应用旧选择结果 */
const mountedRef = useRef(true)
useEffect(() => () => { mountedRef.current = false }, [])
/** 图标选择器:kind + 目标 id + 弹窗锚点坐标(fixed 定位) */
const [iconPick, setIconPick] = useState<{ kind: 'layout' | 'shortcut' | 'project'; id: string; x: number; y: number } | null>(null)
/** 对话绑定弹窗:项目 id + 锚点坐标;bindGroups = 打开时抓取的会话分组;
@@ -1820,6 +1831,7 @@ function buildCustomLayoutPrompt(req: string): string {
/** 打开对话绑定弹窗:抓取会话分组 + 锚点定位 */
const openBindPick = useCallback((id: string, anchor: HTMLElement) => {
invalidatePickState() // 切换目标:失效在途选择请求、清空旧错误与手工输入
const r = anchor.getBoundingClientRect()
const x = clamp(Math.round(r.right + 8), 8, window.innerWidth - 300)
const y = clamp(Math.round(r.top), 8, window.innerHeight - 420)
@@ -1829,21 +1841,70 @@ function buildCustomLayoutPrompt(req: string): string {
fetchSessionGroups().then((res) => setBindGroups(res.groups)).catch(() => setBindGroups([]))
}, [])
/** 弹出系统文件夹选择窗(宿主 pickDirectory);选中后回调 */
const pickFolder = async (apply: (p: string) => void) => {
/** 弹出系统文件夹选择窗(宿主 pickDirectory);选中后回调。
* - useRef 在途锁 + 请求序号:真实防重入;手工确认/新请求会使旧请求失效,旧弹窗返回后丢弃
* - 返回 null = 用户取消:不报错
* - 抛异常 = 显示真实错误信息,不再静默吞掉
* - 宿主能力不可用 = 提示改用「手动输入」 */
const pickFolder = async (target: 'add' | 'bind', apply: (p: string) => void) => {
if (pickBusyRef.current) return
const ws = sessionBridge?.workspaces as any
if (!ws || typeof ws.pickDirectory !== 'function') {
setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickerUnavailable') }))
return
}
pickBusyRef.current = true
setPickBusy(true)
setPickErr((prev) => ({ ...prev, [target]: '' }))
const seq = ++pickSeqRef.current
try {
const ws = sessionBridge?.workspaces as any
if (ws && typeof ws.pickDirectory === 'function') {
const p = await ws.pickDirectory()
if (p && typeof p === 'string') apply(p)
const p = await ws.pickDirectory()
if (seq !== pickSeqRef.current) return // 已被手工确认/新请求/弹窗关闭失效:丢弃旧弹窗结果
if (!mountedRef.current) return // 组件已卸载:不再应用
if (p && typeof p === 'string') apply(p)
// p 为 null:用户取消,静默
} catch (e: any) {
if (seq !== pickSeqRef.current) return
if (!mountedRef.current) return // 组件已卸载:不再更新状态
const msg = (e && typeof e === 'object' && typeof e.message === 'string') ? e.message : String(e ?? '')
setPickErr((prev) => ({ ...prev, [target]: t('add.folderPickFail', { msg: msg || 'unknown' }) }))
} finally {
if (seq === pickSeqRef.current && mountedRef.current) {
pickBusyRef.current = false
setPickBusy(false)
}
} catch { /* 取消或不可用 */ }
}
}
/** 使在途选择请求失效并清空选择器 UI 状态(弹窗关闭/切换目标时调用) */
const invalidatePickState = () => {
pickSeqRef.current++
pickBusyRef.current = false
setPickBusy(false)
setManualPathFor(null)
setManualPathText('')
setPickErr({ add: '', bind: '' })
}
/** 手工输入绝对路径(选择器不可用/失败的降级入口):必须通过 isAbs,并失效在途系统选择请求 */
const applyManualPath = (target: 'add' | 'bind') => {
const p = manualPathText.trim()
if (!p) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualEmpty') })); return }
if (!isAbs(p)) { setPickErr((prev) => ({ ...prev, [target]: t('add.folderManualAbs') })); return }
pickSeqRef.current++ // 使在途系统选择请求失效(旧弹窗返回后丢弃)
pickBusyRef.current = false
setPickBusy(false)
if (target === 'add') { setWsFolderParent(p); setWsFolderError(false) }
else if (bindPick) { persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } })) }
setManualPathFor(null)
setManualPathText('')
setPickErr((prev) => ({ ...prev, [target]: '' }))
}
/** 绑定弹窗「更改」:弹窗选择文件夹,直接作为项目文件夹 */
const changeBindFolder = () => {
if (!bindPick) return
pickFolder((p) => {
if (!bindPick || pickBusyRef.current) return
pickFolder('bind', (p) => {
persistProjects((prev) => ({ ...prev, folders: { ...prev.folders, [bindPick.id]: p } }))
})
}
@@ -2123,6 +2184,7 @@ function buildCustomLayoutPrompt(req: string): string {
const startRect = root.getBoundingClientRect()
dragRef.current = { startY: e.clientY, startX: e.clientX, startRect, dragging: false, prevFloat: float }
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {}
invalidatePickState()
setViewOptionsOpen(false); setAddOpen(false)
}
@@ -2219,12 +2281,15 @@ function buildCustomLayoutPrompt(req: string): string {
if (!name) { setWsError(true); return }
const folderPath = wsFolderParent.trim()
if (!folderPath) { setWsFolderError(true); return }
if (!isAbs(folderPath)) { setWsFolderError(true); return }
try {
// 轻量兜底:选中的路径理应存在,mkdir 幂等无害
await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
} catch { /* 非致命 */ }
// 兜底建目录;HTTP 非 2xx 视为失败,不能继续保存(路径可能无效)
const r = await fetch('/api/worktable/mkdir', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: folderPath }) })
if (!r.ok) { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return }
} catch { setWsFolderError(true); setPickErr((prev) => ({ ...prev, add: t('add.folderCreateFail') })); return }
const layout = buildLayout(wsPreset, name)
persistProjects((prev) => ({ ...prev, layouts: [...prev.layouts, layout], folders: { ...prev.folders, [layout.id]: folderPath } }))
invalidatePickState() // 保存成功:失效在途选择请求(防止旧选择器稍后返回写回已重置的表单)
setWsName(''); setWsFolderParent(''); setWsError(false); setWsFolderError(false)
setAddOpen(false)
openSplit(layout)
@@ -2649,7 +2714,7 @@ function buildCustomLayoutPrompt(req: string): string {
className="dsh-wt_iconBtn"
aria-label={t('menu.viewOptions')}
title={t('menu.viewOptions')}
onClick={() => { setViewOptionsOpen((v) => !v); setAddOpen(false) }}
onClick={() => { invalidatePickState(); setViewOptionsOpen((v) => !v); setAddOpen(false) }}
>{ICON_VIEW_OPTIONS}</button>
<button
type="button"
@@ -2657,6 +2722,7 @@ function buildCustomLayoutPrompt(req: string): string {
aria-label={t('menu.add')}
title={t('menu.add')}
onClick={() => {
invalidatePickState()
setAddOpen((v) => !v); setViewOptionsOpen(false)
// 父目录默认 = 当前会话工作目录(不落 C 盘默认位置)
if (!wsFolderParent) {
@@ -2683,7 +2749,7 @@ function buildCustomLayoutPrompt(req: string): string {
</div>
)}
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => setAddOpen(false)} />}
{addOpen && <div className="dsh-wt_popBackdrop" onClick={() => { invalidatePickState(); setAddOpen(false) }} />}
{addOpen && (
<div className="dsh-wt_menu dsh-wt_add dsh-wt_pop" style={{ position: 'fixed', left: popLeft, top: popTop, width: 360, zIndex: 80 }}>
<span className="dsh-wt_menuLabel">{t('add.chooseLayout')}</span>
@@ -2700,7 +2766,7 @@ function buildCustomLayoutPrompt(req: string): string {
</button>
))}
<button type="button" className="dsh-wt_preset dsh-wt_presetAdd" title={t('customLayout.addTitle')}
onClick={() => { setAddOpen(false); setCustomOpen(true) }}>
onClick={() => { invalidatePickState(); setAddOpen(false); setCustomOpen(true) }}>
<span className="dsh-wt_presetAddIcon" aria-hidden>+</span>
<span className="dsh-wt_presetAddText">{t('customLayout.add')}</span>
</button>
@@ -2713,8 +2779,23 @@ function buildCustomLayoutPrompt(req: string): string {
<span className={'dsh-wt_addFolderPath' + (wsFolderParent ? '' : ' dsh-wt_addFolderPathNone')} title={wsFolderParent || ''}>
{wsFolderParent || t('add.folderNone')}
</span>
<button type="button" className="dsh-wt_bindFolderChange" onClick={() => { pickFolder((p) => { setWsFolderParent(p); setWsFolderError(false) }); setWsError(false) }}>{t('add.folderPick')}</button>
<button type="button" className="dsh-wt_bindFolderChange" disabled={pickBusy}
onClick={() => { pickFolder('add', (p) => { setWsFolderParent(p); setWsFolderError(false) }); setWsError(false) }}>
{pickBusy ? t('add.folderPicking') : t('add.folderPick')}
</button>
<button type="button" className="dsh-wt_bindFolderChange" title={t('add.folderManual')}
onClick={() => { setManualPathFor(manualPathFor === 'add' ? null : 'add'); setPickErr((prev) => ({ ...prev, add: '' })) }}>{t('add.folderManual')}</button>
</div>
{pickBusy && <p className="dsh-wt_folderPickHint">{t('add.folderPickerHint')}</p>}
{manualPathFor === 'add' && (
<div className="dsh-wt_addFolderRow">
<input type="text" className="dsh-wt_manualPathInput" placeholder={t('add.folderManualPh')} value={manualPathText}
onChange={(e) => setManualPathText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('add') }} />
<button type="button" className="dsh-wt_bindFolderChange" onClick={() => applyManualPath('add')}>{t('add.folderManualOk')}</button>
</div>
)}
{pickErr.add && <p className="dsh-wt_addError">{pickErr.add}</p>}
<button type="button" className="dsh-wt_addBtn" onClick={saveLayout}>{t('add.layoutSave')}</button>
</div>
{wsError && <p className="dsh-wt_addError">{t('add.layoutInvalid')}</p>}
@@ -2902,18 +2983,32 @@ function buildCustomLayoutPrompt(req: string): string {
</div>
)}
{bindPick && <div className="dsh-wt_popBackdrop" style={{ zIndex: 83 }} onClick={() => { setBindPick(null); setBindListOpen(false) }} />}
{bindPick && <div className="dsh-wt_popBackdrop" style={{ zIndex: 83 }} onClick={() => { invalidatePickState(); setBindPick(null); setBindListOpen(false) }} />}
{bindPick && (
<div className="dsh-wt_menu dsh-wt_pop dsh-wt_bindPop" style={{ position: 'fixed', left: bindPick.x, top: bindPick.y, width: 280, zIndex: 84 }}>
{/* 项目文件夹框(格式基准):第一行 emoji+标题,第二行路径;可随时更改 */}
<div className="dsh-wt_bindFolderBox">
<div className="dsh-wt_bindFolderRow">
<span className="dsh-wt_bindFolderLabel">📁 {t('bind.folder')}</span>
<button type="button" className="dsh-wt_bindFolderChange" onClick={changeBindFolder}>{t('bind.folderChange')} ↻</button>
<button type="button" className="dsh-wt_bindFolderChange" disabled={pickBusy} onClick={changeBindFolder}>
{pickBusy ? t('add.folderPicking') : t('bind.folderChange') + ' ↻'}
</button>
<button type="button" className="dsh-wt_bindFolderChange" title={t('add.folderManual')}
onClick={() => { setManualPathFor(manualPathFor === 'bind' ? null : 'bind'); setPickErr((prev) => ({ ...prev, bind: '' })) }}>{t('add.folderManual')}</button>
</div>
{pickBusy && <p className="dsh-wt_folderPickHint">{t('add.folderPickerHint')}</p>}
<div className={'dsh-wt_bindFolderPath' + (projects.folders[bindPick.id] ? '' : ' dsh-wt_bindFolderPathNone')} title={projects.folders[bindPick.id] ?? ''}>
{projects.folders[bindPick.id] ?? t('bind.folderNone')}
</div>
{manualPathFor === 'bind' && (
<div className="dsh-wt_addFolderRow">
<input type="text" className="dsh-wt_manualPathInput" placeholder={t('add.folderManualPh')} value={manualPathText}
onChange={(e) => setManualPathText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('bind') }} />
<button type="button" className="dsh-wt_bindFolderChange" onClick={() => applyManualPath('bind')}>{t('add.folderManualOk')}</button>
</div>
)}
{pickErr.bind && <p className="dsh-wt_addError">{pickErr.bind}</p>}
</div>
{/* 绑定对话框(与项目文件夹同格式):第一行 💬+标题(最右解绑);第二行 分组 | 对话名(点击右侧弹列表,再点反选收起);框内下方:说明 */}
<div className="dsh-wt_bindFolderBox">
+20
View File
@@ -44,6 +44,16 @@ export const zh = {
'add.folderPick': '选择位置…',
'add.folderNone': '未选择(点右侧「选择位置…」在资源管理器窗口里挑一个文件夹)',
'add.folderRequired': '请先选择项目文件夹的位置',
'add.folderPicking': '等待选择…',
'add.folderPickerHint': '正在等待文件夹选择;若未看到选择窗口,可检查任务栏,或使用「手动输入」。',
'add.folderPickFail': '目录选择失败:{msg}',
'add.folderPickerUnavailable': '系统目录选择器不可用,请用「手动输入」粘贴路径',
'add.folderManual': '手动输入',
'add.folderManualPh': '粘贴文件夹绝对路径,回车确认',
'add.folderManualOk': '使用此路径',
'add.folderManualEmpty': '请输入文件夹绝对路径',
'add.folderManualAbs': '请输入绝对路径(如 C:\\Users\\me\\Projects 或 /home/me/projects)',
'add.folderCreateFail': '项目文件夹创建失败,请检查路径后重试',
'add.layoutSave': '进入工作区',
'add.layoutInvalid': '请填写布局名称',
'manage.deleteLayout': '删除布局',
@@ -269,6 +279,16 @@ export const en = {
'add.folderPick': 'Choose…',
'add.folderNone': 'Not chosen (click Choose… to pick a folder in the explorer window)',
'add.folderRequired': 'Pick the project folder location first',
'add.folderPicking': 'Waiting for picker…',
'add.folderPickerHint': 'Waiting for the folder picker. If no window appears, check the taskbar or use “Manual”.',
'add.folderPickFail': 'Folder pick failed: {msg}',
'add.folderPickerUnavailable': 'System folder picker unavailable — paste the path via “Manual”',
'add.folderManual': 'Manual',
'add.folderManualPh': 'Paste an absolute folder path, Enter to confirm',
'add.folderManualOk': 'Use this path',
'add.folderManualEmpty': 'Enter an absolute folder path first',
'add.folderManualAbs': 'Absolute path required (e.g. C:\\Users\\me\\Projects or /home/me/projects)',
'add.folderCreateFail': 'Failed to create the project folder — check the path and retry',
'add.layoutSave': 'Enter workspace',
'add.layoutInvalid': 'Enter a layout name',
'manage.deleteLayout': 'Delete layout',
+7 -3
View File
@@ -279,11 +279,15 @@ export const css = xtermCss + '\n' + [
'.dsh-wt_addFolderPath{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_addFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280);border-style:dashed}',
'.dsh-wt_addFolderRow .dsh-wt_customLabel{flex:none;width:auto;text-align:left}',
'.dsh-wt_manualPathInput{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_addFolderRow .dsh-wt_bindFolderChange:disabled{opacity:.5;cursor:default}',
'.dsh-wt_folderPickHint{margin-top:6px;margin-bottom:0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-state-warning,#e0a33c)}',
'.dsh-wt_bindFolderBox .dsh-wt_addError{margin-top:6px;margin-bottom:0}',
// 绑定弹窗内的项目文件夹区
'.dsh-wt_bindFolderBox{margin-top:8px;padding:6px 8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px}',
'.dsh-wt_bindFolderRow{display:flex;align-items:center;justify-content:space-between}',
'.dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_bindFolderChange{flex:none;padding:1px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_bindFolderRow{display:flex;align-items:center;gap:6px}',
'.dsh-wt_bindFolderLabel{font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e8eb);margin-right:auto}',
'.dsh-wt_bindFolderChange{flex:none;padding:2px 10px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_bindFolderChange:hover{color:var(--dsw-alias-label-primary,#e6e8eb);border-color:var(--dsw-alias-border-l2,#3a4150)}',
'.dsh-wt_bindFolderPath{margin-top:4px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_bindFolderPathNone{color:var(--dsw-alias-label-tertiary,#6b7280)}',
+121
View File
@@ -0,0 +1,121 @@
/**
* dsh-worktable 发布后验证(独立于 release-prep 的发布前验证)。
*
* 远端只读:本脚本只做 GET 下载与本地核对,绝不上传/删除/覆盖/发布任何 GitHub 内容。
* 本地副作用:在临时目录下载/解包/安装(--ignore-scripts)/执行服务端 import 断言,结束后删除临时目录。
* 核对项:latest 固定名资产 + 版本化资产(用包内版本拼出),对每个资产:
* 文件名、SHA-256(必须等于 --expect-sha 提供的发布前验收 SHA,防止"双资产同错")、
* 包结构(package/ 前缀 + 精确 7 文件)、包内版本、npm 安装 + import 断言。
*
* 用法:
* node verify-remote.mjs [--expect-sha <sha256>] [tag]
* # 不传 tag = releases/latest;--expect-sha 来自 release-prep 输出的 dist SHA
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { execPath } from 'node:process'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
function npmCliPath() {
const nodeDir = dirname(execPath)
const c = [join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')].filter((p) => existsSync(p))
return c[0] ?? 'npm'
}
const REPO = 'Aisland-SJL/dsh-worktable'
// 参数严格解析:--expect-sha <64位hex> [tag];缺值/非 hex/重复参数/多余 tag 一律拒绝
const args = process.argv.slice(2)
let EXPECT_SHA = null
let TAG = ''
let sawSha = false
let sawTag = false
for (let i = 0; i < args.length; i++) {
const a = args[i]
if (a === '--expect-sha') {
if (sawSha) { console.error('[verify-remote] FAIL: --expect-sha given twice'); process.exit(1) }
const v = args[++i]
if (!v || !/^[0-9a-fA-F]{64}$/.test(v)) { console.error('[verify-remote] FAIL: --expect-sha needs a 64-hex sha256 value, got ' + JSON.stringify(v ?? '')); process.exit(1) }
EXPECT_SHA = v.toLowerCase()
sawSha = true
continue
}
if (a.startsWith('-')) { console.error('[verify-remote] FAIL: unknown option ' + JSON.stringify(a)); process.exit(1) }
if (sawTag) { console.error('[verify-remote] FAIL: more than one tag given (' + JSON.stringify(TAG) + ' and ' + JSON.stringify(a) + ')'); process.exit(1) }
TAG = a
sawTag = true
}
// latest 用特殊路径 /releases/latest/download/<asset>;具体 tag 用 /releases/download/<tag>/<asset>(tag 保留 v 前缀)
const assetBase = TAG === '' ? 'https://github.com/' + REPO + '/releases/latest/download/' : 'https://github.com/' + REPO + '/releases/download/' + TAG + '/'
function httpGet(url, outFile) {
// PowerShell Invoke-WebRequest:走 WinINET 系统代理(本机网络环境需要);-L 行为由 IWR 自动跟随重定向
const ps = "try { Invoke-WebRequest -Uri '" + url.replace(/'/g, "''") + "' -OutFile '" + outFile.replace(/'/g, "''") + "' -UseBasicParsing -TimeoutSec 90 -ErrorAction Stop | Out-Null; exit 0 } catch { Write-Error ($_.Exception.Message); exit 1 }"
const r = spawnSync('powershell', ['-NoProfile', '-Command', ps], { stdio: 'pipe', encoding: 'utf8' })
if (r.status !== 0) { console.error((r.stderr || r.stdout || '').slice(0, 500)); return false }
return true
}
const work = join(tmpdir(), 'wt-verify-remote-' + Date.now())
mkdirSync(work, { recursive: true })
// 1. 下载 latest 固定名资产
const fixedUrl = assetBase + 'dsh-worktable.tgz'
const fixedFile = join(work, 'dsh-worktable.tgz')
console.log('[verify-remote] GET ' + fixedUrl)
if (!httpGet(fixedUrl, fixedFile)) { console.error('[verify-remote] FAIL: download fixed-name asset'); process.exit(1) }
// 2. 解包读版本,拼版本化 URL 并下载
const listTar = spawnSync('tar', ['-tzf', fixedFile], { encoding: 'utf8' })
if (listTar.status !== 0) { console.error('[verify-remote] FAIL: tar -tzf'); process.exit(1) }
const entries = listTar.stdout.split(/\r?\n/).filter(Boolean)
const EXPECTED = ['package/LICENSE','package/README.md','package/cordis.patch.yml','package/dsh.plugin.json','package/lib/client.js','package/lib/index.js','package/package.json']
const okStruct = entries.length === EXPECTED.length && EXPECTED.every((e) => entries.includes(e))
if (!okStruct) { console.error('[verify-remote] FAIL: structure mismatch: ' + entries.join(',')); process.exit(1) }
console.log('[verify-remote] structure ok (7 files)')
const ext = join(work, 'ext'); mkdirSync(ext, { recursive: true })
const x = spawnSync('tar', ['-xzf', fixedFile, '-C', ext])
if (x.status !== 0) { console.error('[verify-remote] FAIL: tar extract'); process.exit(1) }
const pkg = JSON.parse(readFileSync(join(ext, 'package', 'package.json'), 'utf8'))
const manifest = JSON.parse(readFileSync(join(ext, 'package', 'dsh.plugin.json'), 'utf8'))
if (pkg.version !== manifest.version) { console.error('[verify-remote] FAIL: version mismatch pkg=' + pkg.version + ' manifest=' + manifest.version); process.exit(1) }
if (TAG !== '' && TAG !== 'v' + pkg.version) { console.error('[verify-remote] FAIL: tag ' + TAG + ' != v' + pkg.version + ' (包内版本与请求 tag 不一致)'); process.exit(1) }
console.log('[verify-remote] version = ' + pkg.version)
const verUrl = assetBase + 'dsh-worktable-' + pkg.version + '.tgz'
const verFile = join(work, 'dsh-worktable-' + pkg.version + '.tgz')
console.log('[verify-remote] GET ' + verUrl)
if (!httpGet(verUrl, verFile)) { console.error('[verify-remote] FAIL: download versioned asset'); process.exit(1) }
// 3. SHA 比对(必须先于安装/执行代码):双资产彼此同源 且 都等于发布前验收 SHA
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex')
const shaFixed = sha(fixedFile)
const shaVer = sha(verFile)
console.log('[verify-remote] fixed-name sha256: ' + shaFixed)
console.log('[verify-remote] versioned sha256: ' + shaVer)
if (shaFixed !== shaVer) { console.error('[verify-remote] FAIL: two assets differ'); process.exit(1) }
if (EXPECT_SHA) {
if (shaFixed !== EXPECT_SHA) { console.error('[verify-remote] FAIL: sha mismatch — got ' + shaFixed + ', expected (release-prep) ' + EXPECT_SHA + ' — 上传的不是本地验收过的包'); process.exit(1) }
console.log('[verify-remote] sha matches release-prep expectation: ' + EXPECT_SHA)
} else {
console.log('[verify-remote] 提示: 未提供 --expect-sha,未做「远端=本地验收包」比对(建议从 release-prep 输出传入)')
}
// 4. 版本化资产安装 + import 断言(独立临时目录;--ignore-scripts 防包脚本副作用;import 会执行服务端模块顶层代码,属功能验证所需)
const inst = join(work, 'inst'); mkdirSync(inst, { recursive: true })
writeFileSync(join(inst, 'package.json'), JSON.stringify({ name: 'wt-verify-test', version: '0.0.0', private: true }))
const ins = spawnSync(process.execPath, [npmCliPath(), 'install', verFile, '--no-audit', '--no-fund', '--ignore-scripts', '--loglevel=error'], { cwd: inst, stdio: 'pipe', encoding: 'utf8' })
if (ins.status !== 0) { console.error('[verify-remote] FAIL: npm install'); console.error(ins.stderr); process.exit(1) }
const imp = spawnSync(process.execPath, ['--input-type=module', '-e', "const m = await import('dsh-worktable'); if (typeof m.apply !== 'function' || !Array.isArray(m.inject) || m.name !== 'dsh-worktable' || m.HEALTH_PATH !== '/api/worktable/health') process.exit(3); console.log('import ok')"], { cwd: inst, stdio: 'pipe', encoding: 'utf8' })
if (imp.status !== 0) { console.error('[verify-remote] FAIL: import assertion'); console.error(imp.stderr); process.exit(1) }
if (EXPECT_SHA) {
console.log('[verify-remote] PASS: ' + (TAG === '' ? 'latest' : TAG) + ' — 文件名/结构/版本(' + pkg.version + ')/SHA 同源+远端=验收SHA/安装/导入 全部通过(远端只读,本地仅临时目录)——构成发布验收')
} else {
console.log('[verify-remote] INSPECT-ONLY PASS: ' + (TAG === '' ? 'latest' : TAG) + ' — 文件名/结构/版本(' + pkg.version + ')/SHA 同源/安装/导入 通过,但未提供 --expect-sha,未做远端=本地验收包比对,**不构成发布验收**(远端只读,本地仅临时目录)')
}
rmSync(work, { recursive: true, force: true })
+11 -4
View File
@@ -50,11 +50,18 @@ node --check lib/index.js
- 变更视图状态结构时同步更新 PRD 的持久化说明。
- **构建必须 `cd 01_content` 后执行**:误在仓库根跑会把 lib 写到仓库根 `lib/`,宿主仍加载
`01_content/lib` 旧 bundle,出现「改完不生效」假象(已有教训,见工作日志)。
- **发布打包唯一入口 = `npm run pack`(01_content/release-prep.mjs)**:版本一致性 → 构建(cwd 固定 01_content)→
node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件,无 src/、无 .map)→
独立临时目录 npm install + import() 断言(apply 函数/inject 数组/name/HEALTH_PATH/包内双 bundle 版本)
→ 双资产同源复制 + SHA-256。脚本零 git/gh 动作,发布上传由 gh 手动完成并下载远端比对。
- **发布打包唯一入口 = `npm run pack`(01_content/release-prep.mjs)**:身份断言(package/manifest/cordis.patch.yml 严格结构)→
版本一致性 → 构建(cwd 固定 01_content)→ node --check → npm pack → 结构清单断言(package/ 前缀 + 精确 7 文件,
无 src/、无 .map)→ 独立临时目录 npm install + import() 断言(apply 函数/inject 含 webServer+sessions/name/
HEALTH_PATH/包内双 bundle 版本)→ **客户端工厂求值门禁**(ModuleLoader 恰好注册一次 + ID 校验 + 精确外部依赖
白名单 react/react/jsx-runtime + apply/inject 断言)→ dist/v版本号/ 双资产(终态恰好 2 文件 + 双 SHA 同源)。
脚本零 git/gh 动作,发布上传由 gh 手动完成。
**发布禁止裸 npm pack 或手工 tar 生成发布包**;脚本从仓库任意目录调用均安全(以自身位置解析)。
- **配套检查入口**:`npm run test:gate` = 工厂门禁 9 个失败/正向用例;
`npm run verify:remote -- --expect-sha <release-prep 输出的 SHA> [tag]` = 发布后只读核对
(远端固定名+版本化双资产文件名/结构/版本/双 SHA 同源且等于本地验收 SHA/安装/导入;
远端只读,本地仅临时目录)。上传后必须跑 verify:remote 并用 --expect-sha 比对,
防「双资产同错」;tag 模式断言 tag==='v'+包内版本。
## 领域约定(会话中必须遵守)