diff --git a/01_content/client-factory-gate.mjs b/01_content/client-factory-gate.mjs new file mode 100644 index 0000000..6817420 --- /dev/null +++ b/01_content/client-factory-gate.mjs @@ -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() } +} diff --git a/01_content/client-factory-gate.test.mjs b/01_content/client-factory-gate.test.mjs new file mode 100644 index 0000000..f8db722 --- /dev/null +++ b/01_content/client-factory-gate.test.mjs @@ -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') diff --git a/01_content/dsh.plugin.json b/01_content/dsh.plugin.json index b3c93f1..c30b85a 100644 --- a/01_content/dsh.plugin.json +++ b/01_content/dsh.plugin.json @@ -1,7 +1,7 @@ { "name": "dsh-worktable", "description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目", - "version": "0.3.1", + "version": "0.3.2", "entry": { "name": "dsh-worktable", "inject": [] diff --git a/01_content/lib/client.js b/01_content/lib/client.js index 2fecb6b..05afa2b 100644 --- a/01_content/lib/client.js +++ b/01_content/lib/client.js @@ -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: [ diff --git a/01_content/lib/index.js b/01_content/lib/index.js index 406e273..19bcfb9 100644 --- a/01_content/lib/index.js +++ b/01_content/lib/index.js @@ -88,7 +88,7 @@ body { var dshell_default2 = '\n\n\n\n \n \n \u6211\u7684\u7A97\u53E3\n \n\n\n
\n \n

\u7A97\u53E3\u6807\u9898

\n

\u4E00\u53E5\u8BDD\u8BF4\u660E\u8FD9\u4E2A\u7A97\u53E3\u505A\u4EC0\u4E48\u3002

\n\n \n
\n \u5DF2\u5B8C\u6210\n \u8FDB\u884C\u4E2D\n \u672A\u5F00\u59CB\n
\n\n \n
\n \n \n \n
\n\n \n
\n
\n
\u603B\u6570
\n
128
\n
+12.4%
\n
\n
\n
\u8FDB\u884C\u4E2D
\n
7
\n
\n
\n
\u5DF2\u5B8C\u6210
\n
121
\n
\n
\n\n \n
\n
\n \u6761\u76EE\u4E00\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u6628\u5929\n
\n
\n \u6761\u76EE\u4E8C\uFF1A\u793A\u4F8B\u5185\u5BB9\u6807\u9898\n \u5DF2\u53D1\u5E03\n
\n
\n\n \n
\n

\u8BE6\u60C5

\n
\n
\u5B57\u6BB5 A\u503C A
\n
\u5B57\u6BB5 B\u503C B
\n
\n
\n
\n
\n\n \n
\n \n \n
\n
\n\n\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"; diff --git a/01_content/package.json b/01_content/package.json index 86245e7..3426733 100644 --- a/01_content/package.json +++ b/01_content/package.json @@ -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": "*", diff --git a/01_content/release-prep.mjs b/01_content/release-prep.mjs index 72c7a48..cbf8503 100644 --- a/01_content/release-prep.mjs +++ b/01_content/release-prep.mjs @@ -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 }) diff --git a/01_content/src/client/changelog.ts b/01_content/src/client/changelog.ts index 6cf66f4..0748d76 100644 --- a/01_content/src/client/changelog.ts +++ b/01_content/src/client/changelog.ts @@ -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 调整 diff --git a/01_content/src/client/index.tsx b/01_content/src/client/index.tsx index 07bfcdc..fe506d2 100644 --- a/01_content/src/client/index.tsx +++ b/01_content/src/client/index.tsx @@ -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} ))} @@ -2713,8 +2779,23 @@ function buildCustomLayoutPrompt(req: string): string { {wsFolderParent || t('add.folderNone')} - + + + {pickBusy &&

{t('add.folderPickerHint')}

} + {manualPathFor === 'add' && ( +
+ setManualPathText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('add') }} /> + +
+ )} + {pickErr.add &&

{pickErr.add}

} {wsError &&

{t('add.layoutInvalid')}

} @@ -2902,18 +2983,32 @@ function buildCustomLayoutPrompt(req: string): string { )} - {bindPick &&
{ setBindPick(null); setBindListOpen(false) }} />} + {bindPick &&
{ invalidatePickState(); setBindPick(null); setBindListOpen(false) }} />} {bindPick && (
{/* 项目文件夹框(格式基准):第一行 emoji+标题,第二行路径;可随时更改 */}
📁 {t('bind.folder')} - + +
+ {pickBusy &&

{t('add.folderPickerHint')}

}
{projects.folders[bindPick.id] ?? t('bind.folderNone')}
+ {manualPathFor === 'bind' && ( +
+ setManualPathText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') applyManualPath('bind') }} /> + +
+ )} + {pickErr.bind &&

{pickErr.bind}

}
{/* 绑定对话框(与项目文件夹同格式):第一行 💬+标题(最右解绑);第二行 分组 | 对话名(点击右侧弹列表,再点反选收起);框内下方:说明 */}
diff --git a/01_content/src/client/locales.ts b/01_content/src/client/locales.ts index 2f8fcc9..b695081 100644 --- a/01_content/src/client/locales.ts +++ b/01_content/src/client/locales.ts @@ -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', diff --git a/01_content/src/client/styles.ts b/01_content/src/client/styles.ts index 2e204a4..6ac7d9a 100644 --- a/01_content/src/client/styles.ts +++ b/01_content/src/client/styles.ts @@ -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)}', diff --git a/01_content/verify-remote.mjs b/01_content/verify-remote.mjs new file mode 100644 index 0000000..67b8d38 --- /dev/null +++ b/01_content/verify-remote.mjs @@ -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 ] [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/;具体 tag 用 /releases/download//(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 }) + diff --git a/AGENTS.md b/AGENTS.md index d96e96c..d01b27e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 [tag]` = 发布后只读核对 + (远端固定名+版本化双资产文件名/结构/版本/双 SHA 同源且等于本地验收 SHA/安装/导入; + 远端只读,本地仅临时目录)。上传后必须跑 verify:remote 并用 --expect-sha 比对, + 防「双资产同错」;tag 模式断言 tag==='v'+包内版本。 ## 领域约定(会话中必须遵守)