{
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
- export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
+ export const webContents = { getAllWebContents: () => [] };
+ export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
export class BrowserWindow {}
diff --git a/scripts/test-capability-slash-ui.mjs b/scripts/test-capability-slash-ui.mjs
new file mode 100644
index 0000000..4afcb17
--- /dev/null
+++ b/scripts/test-capability-slash-ui.mjs
@@ -0,0 +1,88 @@
+// Real ComposerControls + Slate in isolated Electron. No model, network or user storage.
+import fs from 'node:fs'
+import path from 'node:path'
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { spawn } from 'node:child_process'
+const require = createRequire(import.meta.url), electron = require('electron')
+if (typeof electron === 'string') {
+ const directory = fs.mkdtempSync(path.resolve('.tmp/capability-slash-'))
+ const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
+ const child = spawn(electron, ['--no-sandbox', path.resolve('scripts/test-capability-slash-ui.mjs'), directory], { env, windowsHide: true, stdio: 'inherit' })
+ const timer = setTimeout(() => child.kill(), 90000)
+ process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
+} else {
+ void (async () => {
+ const { app, BrowserWindow } = electron, directory = path.resolve(process.argv.at(-1))
+ assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-slash-'))
+ app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
+ const source = file => JSON.stringify(path.resolve(file).replaceAll('\\', '/'))
+ fs.writeFileSync(path.join(directory, 'fixture.tsx'), `import React,{useState}from'react';import{createRoot}from'react-dom/client';
+import{ComposerControls}from${source('src/renderer/src/workbuddy/composer/ComposerControls')};
+import{withCapabilitySelection}from${source('src/renderer/src/workbuddy/composer/capability-selection')};
+import${source('src/renderer/src/styles/tokens.css')};import${source('src/renderer/src/styles/app.css')};
+window.ycode={resources:{},chat:{}};
+function Test(){const[state,set]=useState({text:'',skills:[]}),[version,bump]=useState(0);window.__qa={state,setup:s=>{set(s);bump(v=>v+1)},toggle:()=>set(s=>withCapabilitySelection(s,{id:'archify',name:'archify',kind:'skill'},{source:'menu'}))};return ({id:s.id,kind:'skill'}))} capabilityItems={[{id:'archify',name:'archify',kind:'skill'},{id:'existing',name:'existing',kind:'skill'}]} onChange={text=>set(s=>({...s,text}))} onUserContentChange={userContent=>set(s=>({...s,userContent}))} onSelectCapability={(item,intent)=>set(s=>withCapabilitySelection(s,item,intent))} onRemoveSkill={id=>set(s=>({...s,skills:s.skills.filter(v=>v.id!==id)}))} onSend={()=>{throw Error('Unexpected send')}} />
}createRoot(document.getElementById('root')).render();`)
+ await require('esbuild').build({ entryPoints: [path.join(directory, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', outfile: path.join(directory, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' })
+ fs.writeFileSync(path.join(directory, 'index.html'), '')
+ await app.whenReady()
+ const win = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { offscreen: true, backgroundThrottling: false } }), checks = [], errors = []
+ win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
+ const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
+ const wait = async code => { for (let n = 0; n < 150; n++) { if (await read(code)) return; await pause(30) } throw Error('Timeout ' + code) }
+ const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
+ const key = async (keyCode, modifiers = []) => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await pause(60) }
+ const setup = async state => { await read(`__qa.setup(${JSON.stringify(state)})`); await pause(160); await read('document.querySelector(".wb-composer__input").focus()') }
+ const choose = async (keyboard = false) => {
+ await wait('!!document.querySelector(".yc-composer-slash-picker")')
+ if (keyboard) { await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Enter') }
+ else await read('[...document.querySelectorAll(".yc-capability-picker button")].find(e=>e.textContent.includes("archify")).click()')
+ await wait('!document.querySelector(".yc-composer-slash-picker")'); await pause(200)
+ }
+ try {
+ await win.loadFile(path.join(directory, 'index.html')); await wait('!!window.__qa')
+ await setup({ text: '', skills: [] }); await win.webContents.insertText('/archify'); await choose()
+ check('mouse selection consumes the textarea slash', await read('__qa.state.text'), '')
+ check('structured content has no stale slash', await read('__qa.state.userContent.filter(v=>v.type==="text").map(v=>v.text).join("")'), '')
+ check('one selected skill survives transition into Slate', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
+ check('the menu stays closed after editor remount', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
+ check('the real rich editor is focused', await read('!!document.activeElement.closest(".cr-reference-editor")'))
+ await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await choose(true)
+ check('Enter consumes a bare slash without sending', await read('__qa.state.text'), '')
+ await setup({ text: 'left RIGHT', skills: [] })
+ await read('document.querySelector("textarea").setSelectionRange(5,5)'); await win.webContents.insertText('/archify'); await choose()
+ check('selection preserves text after the caret', await read('__qa.state.text'), 'left RIGHT')
+ await setup({ text: 'before ', skills: [{ id: 'existing', name: 'existing' }], userContent: [{ type: 'skill', id: 'existing' }, { type: 'text', text: 'before ' }] })
+ await key('End', ['control']); await win.webContents.insertText('/archify'); await choose(true)
+ check('rich text selection preserves the existing skill', await read('__qa.state.skills.map(v=>v.id)'), ['existing', 'archify'])
+ check('rich text selection preserves surrounding text', await read('__qa.state.text'), 'before ')
+ check('rich text references survive token deletion', await read('__qa.state.userContent.filter(v=>v.type==="skill").map(v=>v.id).sort()'), ['archify', 'existing'])
+ await win.webContents.insertText('next'); await wait('__qa.state.text.includes("next")')
+ check('typing continues at the consumed trigger position', await read('__qa.state.text'), 'before next')
+ await setup({ text: '', skills: [{ id: 'archify', name: 'archify' }] })
+ await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
+ check('slash selection of an already selected skill never toggles it off', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
+ await read('__qa.toggle()'); await pause(100)
+ check('the add-menu policy still toggles an existing selection', await read('__qa.state.skills.length'), 0)
+ await setup({ text: 'first\nsecond ', skills: [], attachments: [{ id: 'fixture-file', name: 'keep.txt', mimeType: 'text/plain', size: 4 }], userContent: [{ type: 'attachment', id: 'fixture-file' }, { type: 'text', text: 'first\nsecond ' }] })
+ await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
+ check('multiline rich text survives slash deletion', await read('__qa.state.text'), 'first\nsecond ')
+ check('attachment metadata survives slash selection', await read('__qa.state.attachments.map(v=>v.id)'), ['fixture-file'])
+ check('attachment inline reference survives slash selection', await read('__qa.state.userContent.some(v=>v.type==="attachment"&&v.id==="fixture-file")'))
+ await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await wait('!!document.querySelector(".yc-composer-slash-picker")'); await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Escape')
+ await pause(250); check('Escape closes the menu until the trigger is edited', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
+ await win.webContents.insertText('a'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
+ check('editing the dismissed trigger reopens the menu', true)
+ await setup({ text: '', skills: [] })
+ await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))')
+ await win.webContents.insertText('/'); await pause(150)
+ check('IME composition does not open a slash menu', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
+ await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
+ check('composition completion allows slash detection', true)
+ check('no renderer errors', errors, [])
+ fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors }, null, 2)); console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory })); app.exit(0)
+ } catch (error) {
+ fs.writeFileSync(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack ?? error), checks, errors, state: await read('__qa.state'), focus: await read('({active:document.activeElement.outerHTML,selection:document.getSelection()?.toString(),anchor:document.getSelection()?.anchorNode?.parentElement?.outerHTML})'), html: await read('document.body.innerHTML') }, null, 2)); console.error(error); app.exit(1)
+ }
+ })().catch(error => { console.error(error); electron.app.exit(1) })
+}
diff --git a/scripts/test-connectors-page.mjs b/scripts/test-connectors-page.mjs
new file mode 100644
index 0000000..b28df87
--- /dev/null
+++ b/scripts/test-connectors-page.mjs
@@ -0,0 +1,285 @@
+/** Real production ConnectorsPage in isolated Electron with a controlled MCP host.
+ * Only the host IPC is faked; the page, styles, icons and helpers are the shipped ones.
+ * No WorkBuddy runtime or original evidence is accessed by this script. */
+import fs from 'node:fs'
+import path from 'node:path'
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { spawn } from 'node:child_process'
+
+const require = createRequire(import.meta.url)
+const electronModule = require('electron')
+const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
+const root = path.resolve('.'), base = path.join(root, '.tmp')
+
+if (!electron) {
+ fs.mkdirSync(base, { recursive: true })
+ const output = fs.mkdtempSync(path.join(base, 'connectors-page-')), env = { ...process.env }
+ delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
+ const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-connectors-page.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
+ const timer = setTimeout(() => child.kill(), 120_000)
+ process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
+} else void (async () => {
+ const { app, BrowserWindow, ipcMain } = electron
+ const output = path.resolve(process.argv.at(-1))
+ assert.ok(path.relative(base, output).startsWith('connectors-page-') && !path.relative(base, output).includes('..'))
+ electron.app.disableHardwareAcceleration()
+ fs.mkdirSync(path.join(output, 'data'), { recursive: true })
+ app.setPath('userData', path.join(output, 'data'))
+ await app.whenReady()
+
+ // The controlled host mirrors the production McpApi contract, including the
+ // approval hash rule and the per-server enable/connect state machine.
+ const calls = [], servers = new Map()
+ let failNextEnable = false
+ const clone = value => JSON.parse(JSON.stringify(value))
+ const makeServer = (id, name, overrides = {}) => ({
+ id, name, transport: 'stdio', command: 'node', args: ['server.mjs'], enabled: false,
+ status: 'disconnected', trusted: true, configHash: `hash-${id}`, tools: [],
+ envKeys: [], headerKeys: [], ...overrides
+ })
+ servers.set('ready', makeServer('ready', 'Ready Server', {
+ enabled: true, status: 'connected', tools: [
+ { name: 'echo', description: 'Echo text', inputSchema: { type: 'object' }, enabled: true },
+ { name: 'search', description: 'Find needles', inputSchema: { type: 'object' }, enabled: false }
+ ]
+ }))
+ servers.set('untrusted', makeServer('untrusted', 'Unapproved Server', { trusted: false }))
+ servers.set('failing', makeServer('failing', 'Failing Server', { enabled: true, status: 'error', error: 'MCP 连接失败,请检查命令、地址和运行环境' }))
+
+ const host = async (method, args) => {
+ calls.push([method, ...args])
+ if (method === 'list') return clone([...servers.values()])
+ if (method === 'qaFailNextEnable') { failNextEnable = true; return }
+ const server = servers.get(args[0])
+ if (!server) throw Error('MCP 服务不存在')
+ if (method === 'save') {
+ const input = args[0]
+ if (!input?.name?.trim()) throw Error('请输入有效的 MCP 名称')
+ if (input.transport !== 'stdio' && input.transport !== 'http') throw Error('不支持的 MCP 连接类型')
+ if (input.transport === 'stdio' && !input.command?.trim()) throw Error('请输入有效的 MCP 启动命令')
+ const previous = input.id ? servers.get(input.id) : undefined
+ if (input.id && !previous) throw Error('MCP 服务不存在')
+ if (input.id && input.id !== previous.id) throw Error('MCP 服务不存在')
+ const next = makeServer(input.id ?? `saved-${servers.size}`, input.name.trim(), {
+ transport: input.transport, command: input.command, args: input.args ?? [], cwd: input.cwd, url: input.url,
+ enabled: previous?.enabled ?? false, trusted: false, tools: previous?.tools ?? []
+ })
+ servers.set(next.id, next)
+ return clone(next)
+ }
+ if (method === 'trust') {
+ if (server.configHash !== args[1]) throw Error('MCP 配置已变化,请重新查看并批准')
+ server.trusted = true
+ return
+ }
+ if (method === 'connect') {
+ if (!server.enabled) throw Error('MCP 服务已禁用')
+ if (!server.trusted) throw Error('请先批准当前 MCP 配置')
+ server.status = 'connected'; delete server.error
+ return clone(server)
+ }
+ if (method === 'disconnect') { server.status = 'disconnected'; return }
+ if (method === 'setEnabled') {
+ if (failNextEnable) { failNextEnable = false; throw Error('MCP 服务已禁用') }
+ if (args[1] && !server.trusted) throw Error('请先批准当前 MCP 配置')
+ server.enabled = args[1]
+ server.status = args[1] ? 'connected' : 'disconnected'
+ return
+ }
+ if (method === 'setToolEnabled') {
+ const tool = server.tools.find(item => item.name === args[1])
+ if (!tool) throw Error('MCP 工具不存在')
+ tool.enabled = args[2]
+ return
+ }
+ if (method === 'remove') { servers.delete(server.id); return }
+ throw Error('Unknown QA method ' + method)
+ }
+ ipcMain.handle('mcp-qa', async (_event, method, args) => host(method, args))
+ ipcMain.handle('resources-qa', async () => undefined)
+ fs.writeFileSync(path.join(output, 'preload.cjs'),
+ `const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{mcp:Object.fromEntries(['list','save','trust','connect','disconnect','setEnabled','setToolEnabled','remove','qaFailNextEnable'].map(method=>[method,(...args)=>ipcRenderer.invoke('mcp-qa',method,args)])),resources:{openExternal:async()=>undefined}});`)
+
+ const location = file => JSON.stringify(path.join(root, file).replaceAll('\\', '/'))
+ fs.writeFileSync(path.join(output, 'fixture.tsx'), `
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')}
+import ${location('src/renderer/src/styles/tokens.css')}
+import ${location('src/renderer/src/styles/app.css')}
+createRoot(document.getElementById('root')!).render( {}} />)
+`)
+ await require('esbuild').build({
+ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
+ outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' },
+ logLevel: 'warning'
+ })
+ fs.writeFileSync(path.join(output, 'index.html'),
+ '')
+
+ const win = new BrowserWindow({
+ width: 1052, height: 901, useContentSize: true, frame: false, show: false,
+ webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
+ })
+ const errors = [], checks = []
+ win.webContents.on('console-message', (_event, level, message) => { if (level === 3 || level === 'error') errors.push(String(message)) })
+ const read = code => win.webContents.executeJavaScript(code)
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
+ const check = (name, actual, expected) => {
+ if (typeof expected === 'function') assert.ok(expected(actual), name + ': ' + JSON.stringify(actual))
+ else assert.deepEqual(actual, expected, name)
+ checks.push(name)
+ }
+ const wait = async code => {
+ for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) }
+ throw Error('Timeout ' + code)
+ }
+ const click = async selector => {
+ const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`)
+ win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ await pause(120)
+ }
+ const clickText = async (selector, label) => {
+ const match = await read(`(()=>{const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>e.textContent.trim()===${JSON.stringify(label)});if(!e)throw Error('Missing text '+${JSON.stringify(label)});e.dataset.qaTarget='true';return '[data-qa-target="true"]'})()`)
+ await click(match)
+ await read(`document.querySelector(${JSON.stringify(match)})?.removeAttribute('data-qa-target')`)
+ }
+ const capture = async name => {
+ win.webContents.invalidate()
+ // A hidden window keeps the previous frame, so warm the paint first, give
+ // the compositor a frame, and keep the second capture.
+ await win.webContents.capturePage()
+ await pause(220)
+ const image = await win.webContents.capturePage()
+ fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
+ return !image.isEmpty()
+ }
+ try {
+ await win.loadFile(path.join(output, 'index.html'))
+ // An inactive window still composites real frames; a hidden one does not.
+ win.showInactive()
+ await wait('!!document.querySelector(".ycode-connectors")')
+
+ check('the maintenance placeholder is gone', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
+ check('the page renders the real connector list', await read('document.querySelectorAll(".connector-card").length'), 3)
+ check('the connector card names come from the host', await read('[...document.querySelectorAll(".connector-card-name")].map(e=>e.textContent)'), ['Ready Server', 'Unapproved Server', 'Failing Server'])
+ check('the toolbar keeps the shared search and the custom connector action', await read('[!!document.querySelector(".connectors-toolbar .ec-search-wrapper"), !!document.querySelector(".connector-topbar-action-btn")]'), [true, true])
+ await capture('connector-list')
+
+ check('the detail overlay opens for a selected connector', await (async () => {
+ await click('.connector-card')
+ await wait('!!document.querySelector(".connector-detail-modal")')
+ return read('document.querySelector(".connector-detail-name").textContent')
+ })(), 'Ready Server')
+ check('the detail overlay lists the real tool count', await read('document.querySelector(".connector-detail-desc").textContent'), '已连接 · 2 个工具')
+ await capture('connector-detail')
+ await click('.connector-detail-close')
+ check('the detail overlay closes', await read('!!document.querySelector(".connector-detail-modal")'), false)
+
+ await click('.connector-topbar-action-btn')
+ await wait('!!document.querySelector(".mcp-modal")')
+ check('the management modal opens with the real server list', await read('document.querySelectorAll(".mcp-item").length'), 3)
+ check('the panel reports the enabled count from the host', await read('document.querySelector(".mcp-panel-status-summary").textContent'), '2 启用 · 1 失败')
+ check('an unapproved server shows the approval notice', await read('!!document.querySelector(".mcp-item-approval")'), true)
+ check('an unapproved server cannot be enabled by the toggle', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').disabled})()`), true)
+ await capture('mcp-panel')
+
+ await clickText('.mcp-item-approval-btn', '批准配置')
+ await wait(`!document.querySelector('.mcp-item-approval')`)
+ check('approving the configuration reaches the host', calls.some(call => call[0] === 'trust' && call[1] === 'untrusted'), true)
+ check('approving also enables the approved server', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').getAttribute('aria-checked')})()`), 'true')
+
+ await click('.mcp-item-header')
+ await wait('!!document.querySelector(".mcp-item-tools")')
+ check('the expanded server lists its real tools', await read('[...document.querySelectorAll(".mcp-item-tool")].map(e=>e.textContent)'), ['echo', 'search'])
+ await clickText('.mcp-item-tool', 'search')
+ await wait(`!document.querySelector('.mcp-item-tool')?.classList.contains('mcp-item-tool--disabled')`)
+ check('toggling a tool reaches the host', calls.some(call => call[0] === 'setToolEnabled' && call[1] === 'ready' && call[2] === 'search'), true)
+
+ check('a failed server shows the real host error', await read('!!document.querySelector(".mcp-item-error")'), true)
+ await click('.mcp-item-error-link')
+ await wait(`!document.querySelector('.mcp-item-error')`)
+ check('the retry action reconnects the failed server', calls.some(call => call[0] === 'connect' && call[1] === 'failing'), true)
+
+ await clickText('.mcp-item-action', '编辑')
+ await wait('!!document.querySelector(".mcp-config-editor")')
+ check('the config editor opens with the real server document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');const editor=document.querySelector('.monaco-editor');return ta?ta.value:(document.querySelector('.mcp-config-editor-monaco-wrapper')?editor?'monaco':'loading':'')})()`),
+ value => value === 'loading' || value === 'monaco' || JSON.parse(value).mcpServers.ReadyServer)
+ await capture('mcp-editor')
+ await clickText('.mcp-config-editor-header-actions button', '取消')
+ await wait('!document.querySelector(".mcp-config-editor")')
+
+ await clickText('.mcp-modal-header-right button', '添加 MCP')
+ await wait('!!document.querySelector(".mcp-config-editor")')
+ check('the add editor starts from an empty stdio document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');if(ta)return JSON.parse(ta.value).mcpServers;const w=document.querySelector('.mcp-config-editor-monaco-wrapper');return w?'loading-or-monaco':''})()`),
+ value => value === 'loading-or-monaco' || !!value['my-mcp'])
+ await clickText('.mcp-config-editor-header-actions button', '取消')
+ await wait('!document.querySelector(".mcp-config-editor")')
+
+ // The card action opens the modal with exactly that server expanded.
+ await click('.mcp-modal-close')
+ await wait('!document.querySelector(".mcp-modal")')
+ await read(`(()=>{const card=[...document.querySelectorAll('.connector-card')].find(e=>e.textContent.includes('Failing Server'));card.querySelector('.connector-connect-btn').click();return true})()`)
+ await wait('!!document.querySelector(".mcp-modal")')
+ await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'))?.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]')`)
+ await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'));item.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]').click();return true})()`)
+ await wait('!!document.querySelector(".yc-mcp-confirm")')
+ check('the delete confirmation names the real server', await read('document.querySelector(".yc-mcp-confirm").textContent.includes("Failing Server")'), true)
+ await clickText('.yc-mcp-confirm button', '取消')
+ await wait('!document.querySelector(".yc-mcp-confirm")')
+ check('cancelling the delete keeps the server', await read('document.querySelectorAll(".mcp-item").length'), 3)
+
+ const setSearch = async value => {
+ await read(`(()=>{const e=document.querySelector('.cb-search-input__input');if(!e)throw Error('missing MCP search input');
+ const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,${JSON.stringify(value)});
+ e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
+ await pause(200)
+ }
+ await setSearch('fail')
+ await pause(200)
+ check('the management search filters the real list', await read('document.querySelectorAll(".mcp-item").length'), 1)
+ await setSearch('')
+
+ await click('.mcp-modal-close')
+ await wait('!document.querySelector(".mcp-modal")')
+ check('closing the management modal returns to the card list', await read('document.querySelectorAll(".connector-card").length'), 3)
+
+ // A rejected host call must stay visible inside the management modal.
+ await read(`window.ycode.mcp.qaFailNextEnable()`)
+ await read(`(()=>{document.querySelector('.connector-topbar-action-btn').click();return true})()`)
+ await wait('!!document.querySelector(".mcp-modal")')
+ await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'))`)
+ await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'));item.querySelector('.mcp-toggle').click();return true})()`)
+ await wait('!!document.querySelector(".mcp-modal [role=alert]")')
+ check('a rejected action reports the real host error inside the modal',
+ await read('document.querySelector(".mcp-modal [role=alert]").textContent'),
+ value => typeof value === 'string' && value.includes('MCP 服务已禁用'))
+ check('the error banner is not only behind the modal overlay',
+ await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();return r.width>0&&r.height>0})()`), true)
+ check('the error banner is painted inside the visible modal body',
+ await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();
+ const body=document.querySelector('.mcp-modal-body').getBoundingClientRect();
+ const s=getComputedStyle(el);
+ return {text:el.textContent,top:Math.round(r.top),bottom:Math.round(r.bottom),bodyTop:Math.round(body.top),
+ bodyBottom:Math.round(body.bottom),color:s.color,background:s.backgroundColor,visible:s.visibility,opacity:s.opacity}})()`),
+ value => value.text.includes('MCP 服务已禁用') && value.top >= value.bodyTop && value.bottom <= value.bodyBottom + 1 &&
+ value.color !== 'rgba(0, 0, 0, 0)' && value.background !== 'rgba(0, 0, 0, 0)' && value.visible === 'visible' && value.opacity === '1')
+ await capture('mcp-error')
+
+ check('no renderer errors', errors, [])
+ fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
+ result: 'PASS', checks,
+ calls,
+ limits: 'Controlled MCP host in isolated Electron; the shipped page, helpers, icons and styles are used. Not WorkBuddy pixel acceptance and not a real MCP server connection.'
+ }, null, 2))
+ console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
+ win.destroy(); app.exit(0)
+ } catch (error) {
+ await capture('failure')
+ fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), checks, errors, calls }, null, 2))
+ console.error(error)
+ win.destroy(); app.exit(1)
+ }
+})().catch(error => { console.error(error); electron.app.exit(1) })
diff --git a/scripts/test-recommended-archify-package.mjs b/scripts/test-recommended-archify-package.mjs
new file mode 100644
index 0000000..2a67faf
--- /dev/null
+++ b/scripts/test-recommended-archify-package.mjs
@@ -0,0 +1,223 @@
+// Real Archify package audit for the recommended-skill installer.
+// Downloads the pinned archive, verifies its digest, audits the ZIP structure and
+// metadata, then imports it through the production skills module against an
+// isolated Electron userData directory. No package script is ever executed.
+import assert from 'node:assert/strict'
+import fs from 'node:fs/promises'
+import fsSync from 'node:fs'
+import path from 'node:path'
+import crypto from 'node:crypto'
+import { pathToFileURL } from 'node:url'
+import { build } from 'esbuild'
+import { unzipSync } from 'fflate'
+
+const COMMIT = '25cf37080fc8b1dbe758a4efc3935b55f3327083'
+const REPOSITORY = 'tt-a1i/archify'
+const PACKAGE_PATH = 'archify.zip'
+const EXPECTED_SHA256 = '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5'
+const EXPECTED_BYTES = 5271959
+const DOWNLOAD_URL = `https://api.github.com/repos/${REPOSITORY}/contents/${PACKAGE_PATH}?ref=${COMMIT}`
+const EXPECTED_SKILL_NAME = 'archify'
+const EXPECTED_SKILL_VERSION = '3.2'
+const EXPECTED_PACKAGE_VERSION = '3.2.0'
+const EXPECTED_LICENSE = 'MIT'
+const EXPECTED_FILE_COUNT = 200
+const EXPECTED_EXPANDED_BYTES = 17812933
+
+const repository = path.resolve('.')
+const testRoot = await fs.mkdtemp(path.resolve('.tmp/archify-package-'))
+const relative = path.relative(path.resolve('.tmp'), testRoot)
+assert.ok(relative.startsWith('archify-package-') && !relative.includes('..') && !path.isAbsolute(relative))
+const userData = path.join(testRoot, 'data')
+const workspace = path.join(testRoot, 'workspace')
+await Promise.all([fs.mkdir(userData, { recursive: true }), fs.mkdir(workspace, { recursive: true })])
+
+const checks = []
+const check = (name, actual, expected) => {
+ assert.deepEqual(actual, expected, name)
+ checks.push(name)
+}
+const finding = (name, detail) => checks.push(`${name} :: ${detail}`)
+const note = (name, detail) => checks.push(`${name} :: ${detail}`)
+
+async function download() {
+ const local = process.env.YCODE_ARCHIFY_ZIP
+ if (local) {
+ const bytes = await fs.readFile(path.resolve(local))
+ note('archive source', `local file ${path.resolve(local)}`)
+ return bytes
+ }
+ const response = await fetch(DOWNLOAD_URL, { redirect: 'error', signal: AbortSignal.timeout(120000),
+ headers: { Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28' } })
+ assert.equal(response.status, 200, 'pinned archive download must succeed')
+ const bytes = Buffer.from(await response.arrayBuffer())
+ note('archive source', DOWNLOAD_URL)
+ return bytes
+}
+
+const bytes = await download()
+const archivePath = path.join(testRoot, PACKAGE_PATH)
+await fs.writeFile(archivePath, bytes)
+
+check('pinned archive byte length matches the audited package', bytes.length, EXPECTED_BYTES)
+const actualSha256 = crypto.createHash('sha256').update(bytes).digest('hex')
+check('pinned archive digest matches the audited package', actualSha256, EXPECTED_SHA256)
+check('archive is served from the pinned commit URL', DOWNLOAD_URL.includes(COMMIT), true)
+
+const entries = unzipSync(bytes)
+const names = Object.keys(entries)
+const expandedBytes = names.reduce((sum, name) => sum + entries[name].length, 0)
+check('archive contains the audited file count', names.length, EXPECTED_FILE_COUNT)
+check('archive expands to the audited size', expandedBytes, EXPECTED_EXPANDED_BYTES)
+check('every archive entry lives under a single skill directory',
+ names.every(name => name.startsWith('archify/')), true)
+check('archive has exactly one SKILL.md at the package root',
+ names.filter(name => name === 'archify/SKILL.md').length, 1)
+check('archive contains no nested second skill',
+ names.filter(name => name.split('/').filter(part => part === 'SKILL.md').length > 1).length, 0)
+
+const text = (name) => Buffer.from(entries[name]).toString('utf8')
+const skillMarkdown = text('archify/SKILL.md')
+const frontMatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
+assert.ok(frontMatter, 'SKILL.md must carry YAML front matter')
+const skillMeta = frontMatter[1]
+check('SKILL.md declares the audited skill name', /(^|\n)name:\s*archify\s*(\n|$)/.test(skillMeta), true)
+check('SKILL.md declares the audited license', /(^|\n)license:\s*MIT\s*(\n|$)/.test(skillMeta), true)
+check('SKILL.md declares the audited metadata version', /version:\s*"?3\.2"?/.test(skillMeta), true)
+check('SKILL.md declares the audited author', /author:\s*tt-a1i/.test(skillMeta), true)
+
+const packageJson = JSON.parse(text('archify/package.json'))
+check('package.json declares the audited package name', packageJson.name, EXPECTED_SKILL_NAME)
+check('package.json declares the audited package version', packageJson.version, EXPECTED_PACKAGE_VERSION)
+check('package.json declares the audited license', packageJson.license, EXPECTED_LICENSE)
+check('package.json keeps the package private', packageJson.private, true)
+const release = JSON.parse(text('archify/skill-release.json'))
+check('release manifest declares the audited version', release.version, EXPECTED_PACKAGE_VERSION)
+check('release manifest points at the audited repository', release.source.repository, `https://github.com/${REPOSITORY}`)
+check('release manifest exposes an update URL that the host must not auto-follow',
+ typeof release.updateManifestUrl === 'string' && release.updateManifestUrl.length > 0, true)
+check('archive ships the MIT license text', text('archify/LICENSE').includes('MIT License'), true)
+check('archive ships the third-party notices', names.includes('archify/THIRD_PARTY_NOTICES.md'), true)
+
+const scriptEntries = names.filter(name => /\.(mjs|cjs|js|ts|sh|ps1|bat|cmd)$/i.test(name))
+check('archive ships executable helper scripts as inert files', scriptEntries.length > 0, true)
+note('script entries (not executed)', `${scriptEntries.length} files, e.g. ${scriptEntries.slice(0, 3).join(', ')}`)
+
+// Import through the production skills module. Only Electron's userData path is
+// redirected; every other code path is the shipped implementation.
+const bundled = path.join(testRoot, 'skills-archify.mjs')
+await build({
+ entryPoints: [path.resolve('src/main/capabilities/skills.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ outfile: bundled,
+ logLevel: 'warning',
+ banner: { js: "import { createRequire as createArchifyTestRequire } from 'node:module';const require=createArchifyTestRequire(import.meta.url);" },
+ plugins: [{
+ name: 'isolated-electron-path',
+ setup(context) {
+ context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'archify-test' }))
+ context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
+ contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:(v)=>Buffer.from(String(v),'utf8'),decryptString:(v)=>Buffer.from(v).toString('utf8')}`
+ }))
+ }
+ }, {
+ name: 'record-child-process',
+ setup(context) {
+ context.onResolve({ filter: /^(node:)?child_process$/ }, () => ({ path: 'child-process', namespace: 'archify-test' }))
+ context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
+ contents: `const record=(name)=>globalThis.__archifyChildProcesses.push(name);export const spawn=(...a)=>{record('spawn');throw Error('child process blocked in package audit')};export const spawnSync=(...a)=>{record('spawnSync');throw Error('child process blocked in package audit')};export const exec=(...a)=>{record('exec');throw Error('child process blocked in package audit')};export const execSync=(...a)=>{record('execSync');throw Error('child process blocked in package audit')};export const fork=(...a)=>{record('fork');throw Error('child process blocked in package audit')};export default{spawn,spawnSync,exec,execSync,fork};`
+ }))
+ }
+ }]
+})
+globalThis.__archifyChildProcesses = []
+const bundleSource = await fs.readFile(bundled, 'utf8')
+check('the production skills module does not spawn a child process during import',
+ globalThis.__archifyChildProcesses.length, 0)
+
+const api = await import(pathToFileURL(bundled).href)
+check('the isolated skill library starts empty', await api.listSkills(), [])
+
+const imported = await api.importSkill(archivePath)
+check('the pinned archive imports as the audited skill name', imported.name, EXPECTED_SKILL_NAME)
+check('the imported skill is a user-scoped import', imported.scope, 'user')
+check('the imported skill is enabled by default', imported.enabled, true)
+check('the imported skill is not flagged as locally modified', imported.userModified, false)
+// Archify nests its version under `metadata:` in the YAML front matter. The host
+// parser only reads a top-level `version`, so record what the host can see and
+// keep the package manifest as the authoritative version for validation.
+finding('host-visible skill version', `imported.version=${String(imported.version)} (front matter nests version under metadata:; expected ${EXPECTED_SKILL_VERSION})`)
+check('the package manifest carries the authoritative version', packageJson.version, EXPECTED_PACKAGE_VERSION)
+check('the imported skill keeps the audited license text in its body',
+ (await api.readSkill(imported.id)).content.includes('license: MIT'), true)
+
+const installedPath = path.resolve(imported.path)
+check('the installed package stays inside the isolated userData directory',
+ installedPath.startsWith(path.resolve(userData) + path.sep), true)
+check('the installed package never lands in the real user skill library',
+ installedPath.includes(path.join('capabilities', 'skills', 'packages')), true)
+
+const document = await api.readSkill(imported.id)
+check('the imported skill exposes every audited file', document.files.length, EXPECTED_FILE_COUNT)
+// The host strips the single enclosing directory, so installed paths are
+// relative to the skill root rather than to the archive root.
+const installedNames = names.map(name => name.replace(/^archify\//, ''))
+check('the imported file list is relative to the skill root',
+ document.files.every(name => !name.startsWith('archify/')), true)
+check('the imported file list is sorted and complete',
+ document.files.join('|') === installedNames.slice().sort().join('|'), true)
+check('the imported skill body is the audited SKILL.md',
+ document.content === skillMarkdown, true)
+
+const listed = (await api.listSkills(workspace)).find(item => item.id === imported.id)
+check('the installed skill is listed for the workspace', listed?.name, EXPECTED_SKILL_NAME)
+check('the installed skill stays unmodified after import', listed?.userModified, false)
+
+const reference = await api.readSkillResource(imported.id, 'references/repository-authoring.md', workspace)
+check('a shipped reference resource is readable through the host', reference.length > 0, true)
+await assert.rejects(api.readSkillResource(imported.id, '../escape.md', workspace), /越界|非法|不安全/)
+checks.push('resource reads reject traversal outside the package')
+
+const context = await api.buildSkillContext([imported.id], workspace)
+check('the explicit skill context carries the real instructions', context.includes(' {
+ for (const item of await fs.readdir(folder, { withFileTypes: true })) {
+ if (item.isDirectory()) await collectExamples(path.join(folder, item.name), `${prefix}${item.name}/`)
+ else exampleFiles.push(`${prefix}${item.name}`)
+ }
+}
+await collectExamples(path.join(installedPath, 'examples'), '')
+check('importing the package leaves the shipped examples untouched',
+ exampleFiles.length, names.filter(name => name.startsWith('archify/examples/')).length)
+
+await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({
+ result: 'PASS',
+ scope: 'Real pinned GitHub archive; digest and ZIP structure audited; import through the production skills module against an isolated Electron userData directory. No package script is executed.',
+ source: { repository: REPOSITORY, commit: COMMIT, packagePath: PACKAGE_PATH, downloadUrl: DOWNLOAD_URL },
+ archive: { bytes: bytes.length, sha256: actualSha256, fileCount: names.length, expandedBytes },
+ metadata: {
+ skillName: EXPECTED_SKILL_NAME,
+ skillVersion: imported.version,
+ packageVersion: packageJson.version,
+ license: packageJson.license,
+ author: 'tt-a1i',
+ releaseVersion: release.version
+ },
+ checks,
+ limitations: [
+ 'The digest and metadata confirm the bytes match the audited package; they are not a security audit of the skill code.',
+ 'Package scripts are shipped as inert files and were never executed, so runtime dependencies and rendering behavior remain unverified.',
+ 'The update manifest URL inside the package is reported only; the host must not auto-follow it.'
+ ]
+}, null, 2))
+console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot, sha256: actualSha256 }))
diff --git a/scripts/test-recommended-skills-ui.mjs b/scripts/test-recommended-skills-ui.mjs
new file mode 100644
index 0000000..2a90320
--- /dev/null
+++ b/scripts/test-recommended-skills-ui.mjs
@@ -0,0 +1,382 @@
+/** Isolated Electron fixture for the recommended-skill page.
+ * The host is fully controlled: no network, no real skill storage, no user data. */
+import fs from 'node:fs'
+import path from 'node:path'
+import assert from 'node:assert/strict'
+import { createRequire } from 'node:module'
+import { spawn } from 'node:child_process'
+
+const require = createRequire(import.meta.url)
+const electronModule = require('electron')
+const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
+const repository = path.resolve('.'), base = path.join(repository, '.tmp')
+
+if (!electron) {
+ fs.mkdirSync(base, { recursive: true })
+ const output = fs.mkdtempSync(path.join(base, 'recommended-skills-ui-'))
+ const env = { ...process.env }
+ delete env.ELECTRON_RUN_AS_NODE
+ delete env.ELECTRON_RENDERER_URL
+ const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
+ const timer = setTimeout(() => child.kill(), 120000)
+ process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
+} else {
+ void (async () => {
+ const { app, BrowserWindow, ipcMain } = electron
+ const output = path.resolve(process.argv.at(-1)), relative = path.relative(base, output)
+ assert.ok(relative.startsWith('recommended-skills-ui-') && !relative.includes('..') && !path.isAbsolute(relative))
+ const userData = path.join(output, 'data')
+ fs.mkdirSync(userData, { recursive: true })
+ app.setPath('userData', userData)
+ const checks = [], errors = [], calls = [], screenshots = []
+
+ const recommendation = {
+ id: 'archify',
+ name: 'Archify',
+ description: '生成可交互的架构图、流程图和一页式说明',
+ author: 'tt-a1i',
+ license: 'MIT',
+ repository: 'tt-a1i/archify',
+ version: '3.2.0',
+ commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
+ packagePath: 'archify.zip',
+ sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
+ expectedBytes: 5271959,
+ containsScripts: true
+ }
+ const localSkill = {
+ id: 'user:local', name: 'Archify', description: '本地导入的同名技能', version: '1.0.0',
+ path: 'C:/local/archify', scope: 'user', enabled: true, userInvocable: true,
+ disableModelInvocation: false, source: 'imported', userModified: true
+ }
+ const installedRecommendation = {
+ id: 'user:rec', name: 'Archify', description: recommendation.description, version: '3.2.0',
+ path: 'C:/managed/archify', scope: 'user', enabled: true, userInvocable: true,
+ disableModelInvocation: false, source: 'imported', userModified: false,
+ origin: {
+ recommendationId: 'archify', repository: recommendation.repository, commit: recommendation.commit,
+ packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256, version: '3.2.0', installedAt: 1
+ }
+ }
+
+ let installed = [], progress = null, failNext = false, cancelRequested = false, delay = 0, recommendationOverride = null, recommendationFails = false
+ let clock = 0
+ const listeners = new Set(), changedListeners = new Set()
+ const emit = state => {
+ // The host stamps a monotonic time; a strict comparison rejects a stale frame.
+ const stamped = { ...state, updatedAt: ++clock }
+ progress = stamped
+ for (const listener of listeners) listener(stamped)
+ for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
+ }
+ const changed = () => {
+ for (const listener of changedListeners) listener()
+ for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:changed')
+ }
+
+ ipcMain.handle('skills-qa', async (_event, method, args) => {
+ calls.push([method, ...args])
+ if (method === 'recommendations') {
+ if (recommendationFails) throw Error('推荐目录服务不可用')
+ return recommendationOverride ?? [recommendation]
+ }
+ if (method === 'list') return installed
+ if (method === 'setEnabled') { installed = installed.map(skill => skill.id === args[0] ? { ...skill, enabled: args[1] } : skill); changed(); return }
+ if (method === 'installState') return progress
+ // The host only accepts the cancel request; the terminal frame still comes from the install flow.
+ if (method === 'cancelInstall') { cancelRequested = true; return { cancelled: true } }
+ if (method === 'installRecommended') {
+ const [id, options] = args
+ if (id !== recommendation.id) throw Error('未知推荐技能')
+ if (failNext) {
+ failNext = false
+ emit({ operationId: options.operationId, recommendationId: id, phase: 'failed', receivedBytes: 0, error: '下载失败:网络错误', updatedAt: Date.now() })
+ throw Error('下载失败:网络错误')
+ }
+ emit({ operationId: options.operationId, recommendationId: id, phase: 'downloading', receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
+ await new Promise(resolve => setTimeout(resolve, delay))
+ if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: 0, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
+ emit({ operationId: options.operationId, recommendationId: id, phase: 'validating', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
+ await new Promise(resolve => setTimeout(resolve, delay))
+ if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: recommendation.expectedBytes, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
+ emit({ operationId: options.operationId, recommendationId: id, phase: 'installing', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
+ await new Promise(resolve => setTimeout(resolve, delay))
+ installed = [installedRecommendation]
+ emit({ operationId: options.operationId, recommendationId: id, phase: 'installed', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
+ changed()
+ return installedRecommendation
+ }
+ if (method === 'qaSetInstalled') { installed = args[0]; changed(); return installed }
+ if (method === 'qaFailNext') { failNext = true; return true }
+ if (method === 'qaDelay') { delay = args[0]; return true }
+ if (method === 'qaSetRecommendations') { recommendationOverride = args[0]; return true }
+ if (method === 'qaFailRecommendations') { recommendationFails = args[0] === true; return true }
+ // Simulate an operation owned by another window.
+ if (method === 'qaEmitForeign') {
+ const [phase, operationId] = args
+ emit({ operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
+ return true
+ }
+ // Replay a frame with an older timestamp to prove the terminal state never regresses.
+ if (method === 'qaEmitStale') {
+ const [phase, operationId, updatedAt] = args
+ const stamped = { operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt }
+ progress = stamped
+ for (const listener of listeners) listener(stamped)
+ for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
+ return true
+ }
+ if (method === 'qaSubscribe') { listeners.add(args[0]); return true }
+ if (method === 'qaSubscribeChanged') { changedListeners.add(args[0]); return true }
+ throw Error('Unknown QA method ' + method)
+ })
+
+ fs.writeFileSync(path.join(output, 'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');
+const invoke=(method,...args)=>ipcRenderer.invoke('skills-qa',method,args);
+contextBridge.exposeInMainWorld('ycode',{skills:{
+ list:(cwd)=>invoke('list',cwd),
+ recommendations:()=>invoke('recommendations'),
+ installRecommended:(id,options)=>invoke('installRecommended',id,options),
+ cancelInstall:(operationId)=>invoke('cancelInstall',operationId),
+ installState:()=>invoke('installState'),
+ onInstallProgress:(listener)=>{const wrapped=(_event,state)=>listener(state);ipcRenderer.on('skills:install-progress',wrapped);return()=>ipcRenderer.removeListener('skills:install-progress',wrapped)},
+ onChanged:(listener)=>{const wrapped=()=>listener();ipcRenderer.on('skills:changed',wrapped);return()=>ipcRenderer.removeListener('skills:changed',wrapped)},
+ read:async()=>({skill:{},content:'',files:[]}),
+ setEnabled:(...args)=>invoke('setEnabled',...args), remove:async()=>{}, export:async()=>null, importPath:async()=>null
+ ,qaSetInstalled:(v)=>invoke('qaSetInstalled',v),
+ qaFailNext:()=>invoke('qaFailNext'),
+ qaDelay:(v)=>invoke('qaDelay',v),
+ qaSetRecommendations:(v)=>invoke('qaSetRecommendations',v),
+ qaFailRecommendations:(v)=>invoke('qaFailRecommendations',v),
+ qaEmitForeign:(phase,operationId)=>invoke('qaEmitForeign',phase,operationId),
+ qaEmitStale:(phase,operationId,updatedAt)=>invoke('qaEmitStale',phase,operationId,updatedAt)
+},resources:{openExternal:async()=>undefined}});`)
+
+ const location = file => JSON.stringify(path.join(repository, file).replaceAll('\\', '/'))
+ fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';
+import{createRoot}from'react-dom/client';
+import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};
+import${location('src/renderer/src/styles/tokens.css')};
+import${location('src/renderer/src/styles/app.css')};
+window.__qa={uses:[],created:0,find:0};
+createRoot(document.getElementById('root')).render(
+
+
{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/>
+ );`)
+ await require('esbuild').build({
+ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
+ outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning'
+ })
+ fs.writeFileSync(path.join(output, 'index.html'), '')
+
+ const win = new BrowserWindow({
+ width: 1052, height: 901, useContentSize: true, frame: false, show: false,
+ webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
+ })
+ win.webContents.on('console-message', (_event, level, message) => { if (level === 3) errors.push(message) })
+ const read = code => win.webContents.executeJavaScript(code)
+ const check = (label, actual, expected) => {
+ const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
+ assert.ok(pass, `${label}: ${JSON.stringify(actual)}`)
+ checks.push(label)
+ }
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
+ const wait = async code => { for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) } throw Error('Timeout ' + code) }
+ const click = async selector => {
+ await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});e.click();return true})()`)
+ await pause(140)
+ }
+ const capture = async name => {
+ win.webContents.invalidate()
+ await pause(120)
+ // A hidden window can capture the previous frame; warm the compositor first.
+ await win.webContents.capturePage()
+ await pause(200)
+ const image = await win.webContents.capturePage()
+ fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
+ screenshots.push(name + '.png')
+ }
+
+ try {
+ await win.loadFile(path.join(output, 'index.html'))
+ await wait('!!document.querySelector(".yc-skills-page")')
+ await wait('!!document.querySelector("[data-recommendation-id=archify]")')
+
+ check('the recommendation card renders from the host catalog', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-name")?.textContent'), 'Archify')
+ check('the card exposes its install state for the host test', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'none')
+ check('the card shows the author supplied description', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-desc")?.textContent'), recommendation.description)
+ check('no maintenance placeholder is shown', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
+ check('the remote catalog tabs still report no connection', await read('!!document.querySelector(".yc-skill-market-unavailable")'), false)
+ await capture('recommendation-card')
+
+ // First install asks for an explicit confirmation.
+ await click('[aria-label="安装 Archify"]')
+ await wait('!!document.querySelector(".yc-skill-detail-shell")')
+ check('the confirm dialog names the source repository', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("tt-a1i/archify")'), true)
+ check('the confirm dialog states that no install script runs', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("不会执行仓库中的任何安装命令")'), true)
+ check('the confirm dialog warns that the skill contains scripts', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("包含脚本") || document.querySelector(".yc-skill-detail-shell").textContent.includes("脚本")'), true)
+ await capture('install-confirm')
+ await read('(()=>{const first=document.querySelector("[role=dialog] .sm-skill-detail-close");first.focus();first.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",shiftKey:true,bubbles:true,cancelable:true}))})()')
+ check('reverse Tab reaches the collapsible source information', await read('document.activeElement.tagName'), 'SUMMARY')
+ await read('document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",bubbles:true,cancelable:true}))')
+ check('Tab wraps inside the modal rather than entering the sidebar', await read('document.activeElement.getAttribute("aria-label")'), '关闭技能详情')
+ await read('window.__detailBeforeInstall=document.querySelector("[role=dialog]")')
+ await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
+ check('the install progress is reported by the host', await read('document.querySelector(".yc-skill-inline-install").dataset.installPhase'), 'installed')
+ check('the host received the recommendation id', await read(`JSON.stringify(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').map(c=>c[1]))`), '["archify"]')
+ check('the host received a fresh operation id', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>typeof c[2]?.operationId==='string'&&c[2].operationId.length>8))`), true)
+ check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true)
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
+ check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current')
+ check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
+ check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true)
+ check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true)
+ check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true)
+ check('there is no permanent list footer progress panel', await read('!!document.querySelector(".yc-skills-scroll > .yc-skill-inline-install")'), false)
+ await capture('installed-detail')
+ await click('[role=dialog] .sm-skill-detail-close')
+
+ // The installed list stays reachable and the skill can be used.
+ await click('.yc-skill-recommendation-footer button')
+ await wait('document.querySelectorAll(".sm-card").length===1')
+ check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify')
+ await click('[aria-label="返回专家中心"]')
+ await wait('!!document.querySelector("[data-recommendation-id=archify]")')
+
+ // A local skill with the same name requires an explicit overwrite.
+ await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`)
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"')
+ await click('[aria-label="安装 Archify"]')
+ await wait('!!document.querySelector(".yc-skill-detail-shell")')
+ check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true)
+ check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true)
+ await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
+ check('an explicit replacement is sent to the host as an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.overwrite)===true`), true)
+ await click('[role=dialog] .sm-skill-detail-close')
+
+ // A failing download reports the real host error and allows a retry.
+ await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
+ await read(`window.ycode.skills.qaFailNext()`)
+ await click('[aria-label="安装 Archify"]')
+ await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="failed"')
+ check('a failed install shows the host error', await read('document.querySelector(".yc-skill-inline-install").textContent.includes("网络错误")'), true)
+ check('a failed install can be retried from the same card', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
+ await click('[role=dialog] .sm-skill-detail-close')
+
+ // Cancellation is allowed before the atomic commit phase.
+ await read(`window.ycode.skills.qaDelay(400)`)
+ await click('[aria-label="安装 Archify"]')
+ await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
+ check('the download phase can be cancelled', await read('[...document.querySelectorAll("[role=dialog] button")].some(e=>e.textContent==="取消安装"&&!e.disabled)'), true)
+ await read('document.querySelector("[role=dialog]").querySelectorAll("button").forEach(e=>{if(e.textContent==="取消安装")e.click()})')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="cancelled"')
+ check('the cancellation is sent to the host', await read(`${JSON.stringify(calls)}.some(c=>c[0]==='cancelInstall')`), true)
+ await click('[role=dialog] .sm-skill-detail-close')
+ await read(`window.ycode.skills.qaDelay(0)`)
+
+ // Closing the detail never aborts an accepted install; reopening resumes it.
+ await read('window.ycode.skills.qaDelay(250)')
+ await click('[aria-label="安装 Archify"]'); await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
+ const continuingId = await read('document.querySelector(".yc-skill-inline-install").dataset.operationId')
+ await click('[role=dialog] .sm-skill-detail-close')
+ check('closing a running detail restores the background', await read('document.getElementById("root").inert'), false)
+ await click('[data-recommendation-id=archify] .sm-card')
+ check('reopening detail keeps the same install operation', await read('document.querySelector(".yc-skill-inline-install").dataset.operationId'), continuingId)
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installing"')
+ check('the commit phase disables cancellation in place', await read('[...document.querySelectorAll("[role=dialog] button")].find(e=>e.textContent==="取消安装")?.disabled'), true)
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
+ await click('[role=dialog] .sm-skill-detail-close')
+ await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]')
+ check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1)
+ await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`)
+ await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")')
+ await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]')
+ await wait('window.__qa.uses.length===2')
+ check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true)
+ await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])')
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
+
+ // A failing catalog is reported as unavailable, not as a maintenance page.
+ // The page loads its catalog once, so the failure is proven on a fresh mount.
+ await read(`window.ycode.skills.qaFailRecommendations(true)`)
+ await read(`location.reload()`)
+ await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")')
+ check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
+ check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true)
+ await read(`window.ycode.skills.qaFailRecommendations(false)`)
+ await read(`location.reload()`)
+ await wait('!!document.querySelector("[data-recommendation-id=archify]")')
+
+ // An install owned by another window blocks a new install here.
+ const foreignOperation = '11111111-2222-4333-8444-555555555555'
+ await read(`window.ycode.skills.qaEmitForeign('downloading', ${JSON.stringify(foreignOperation)})`)
+ await wait('!!document.querySelector("[data-foreign-install=true]")')
+ check('an install from another window is reported', await read('document.querySelector("[data-foreign-install=true]").textContent.includes("其他窗口正在安装推荐技能")'), true)
+ check('a new install is blocked while another window is installing', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
+ check('the blocked state is explained on the card', await read('!!document.querySelector(".yc-skill-recommendation-busy")'), true)
+ await capture('foreign-install')
+ await read(`window.ycode.skills.qaEmitForeign('installed', ${JSON.stringify(foreignOperation)})`)
+ await wait('!document.querySelector("[data-foreign-install=true]")')
+ check('the foreign block clears when that operation finishes', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
+
+ // A stale frame must not move a settled operation back into a running phase.
+ await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
+ await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
+ await read(`window.ycode.skills.qaDelay(0)`)
+ await click('[aria-label="安装 Archify"]')
+ await click('[aria-label="确认安装推荐技能"]')
+ await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
+ const settledOperation = await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.operationId)`)
+ check('the settled install recorded its operation id', typeof settledOperation === 'string' && settledOperation.length > 8, true)
+ await read(`window.ycode.skills.qaEmitStale('downloading', ${JSON.stringify(settledOperation)}, 1)`)
+ await pause(250)
+ check('a stale frame does not regress the settled install', await read('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase'), 'installed')
+ await click('[role=dialog] .sm-skill-detail-close')
+
+ // Dark and narrow captures of the real page.
+ // This fixture mounts only the capability center, so the theme attribute is set directly.
+ // The dark token switch is proven here only; the production app harness covers the light flow.
+ await read(`(()=>{localStorage.setItem('ycode:theme','dark');document.documentElement.dataset.theme='dark';return true})()`)
+ await wait('document.documentElement.dataset.theme==="dark"')
+ await wait('!!document.querySelector(".yc-capability-center .expert-center-dark")')
+ check('the dark theme reaches the real page', await read('!!document.querySelector(".yc-capability-center .expert-center-dark")'), true)
+ // The card transitions its background, so the dark surface only settles after the animation.
+ await wait(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!card)return false;const m=/rgba?\\((\\d+)/.exec(getComputedStyle(card).backgroundColor);return !!m&&Number(m[1])<90})()`)
+ const darkColors = await read(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');const page=document.querySelector('.yc-capability-center .expert-center-page');return{card:card?getComputedStyle(card).backgroundColor:null,page:page?getComputedStyle(page).backgroundColor:null}})()`)
+ check('the dark card uses a dark surface', darkColors.card, value => typeof value === 'string' && /rgba?\((\d+), (\d+), (\d+)/.test(value) && Number(value.match(/rgba?\((\d+)/)[1]) < 90)
+ const overflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>e.className||e.tagName).slice(0,4);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
+ check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true)
+ check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true)
+ await capture('recommendation-dark')
+ win.setSize(720, 900)
+ await pause(400)
+ check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true)
+ const narrowOverflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>String(e.className||e.tagName)).slice(0,5);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
+ check('the narrow recommendation card causes no document overflow', narrowOverflow.scrollWidth <= narrowOverflow.innerWidth + 1, true)
+ await capture('recommendation-narrow')
+ win.setSize(1052, 901)
+ await pause(250)
+
+ check('no renderer errors', errors, [])
+ fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
+ result: 'PASS', checks, calls, screenshots,
+ limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.'
+ }, null, 2))
+ console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
+ win.destroy()
+ app.exit(0)
+ } catch (error) {
+ await capture('failure')
+ fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), stack: error?.stack, checks, errors, calls, html: await read('document.body.innerHTML') }, null, 2))
+ console.error(error)
+ win.destroy()
+ app.exit(1)
+ }
+ })().catch(error => { console.error(error); electron.app.exit(1) })
+}
diff --git a/scripts/test-skills-ui.mjs b/scripts/test-skills-ui.mjs
index 6ec76ac..f9ceab2 100644
--- a/scripts/test-skills-ui.mjs
+++ b/scripts/test-skills-ui.mjs
@@ -39,7 +39,7 @@ if (!electron) {
fs.writeFileSync(path.join(output,'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{skills:Object.fromEntries(['list','read','setEnabled','remove','export','importPath'].map(method=>[method,(...args)=>ipcRenderer.invoke('skills-qa',method,args)]))});`)
const location = file=>JSON.stringify(path.join(repository,file).replaceAll('\\','/'))
fs.writeFileSync(path.join(output,'fixture.tsx'), `import React from 'react';import{createRoot}from'react-dom/client';import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};import ${location('src/renderer/src/styles/tokens.css')};import ${location('src/renderer/src/styles/app.css')};window.__skillsQa={uses:[],created:0,find:0};createRoot(document.getElementById('root')).render({}} onUseSkill={skill=>window.__skillsQa.uses.push(skill)} onCreateSkill={()=>window.__skillsQa.created++} onFindSkill={()=>window.__skillsQa.find++}/>);`)
- await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
+ await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.jpg':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
fs.writeFileSync(path.join(output,'index.html'),'')
const win=new BrowserWindow({width:1052,height:901,useContentSize:true,frame:false,show:false,webPreferences:{preload:path.join(output,'preload.cjs'),contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}})
win.webContents.on('console-message',(_event,level,message)=>{if(level===3)errors.push(message)})
@@ -67,9 +67,9 @@ if (!electron) {
check('success notice does not affect normal flow',await read('getComputedStyle(document.querySelector(".yc-skill-notice")).position'),'absolute');await wait('!document.querySelector(".yc-skill-notice")');check('success notice disappears automatically',await read('!!document.querySelector(".yc-skill-notice")'),false)
await clickText('.sm-installed-batch-manage','批量管理');await click('.sm-card');await clickText('.sm-batch-toolbar button','禁用');check('batch disable persists real state',(await api.listSkills(workspace))[0].enabled,false);await clickText('.sm-batch-toolbar button','启用');check('batch enable persists real state',(await api.listSkills(workspace))[0].enabled,true);await clickText('.sm-installed-batch-manage','完成')
await click('.wb-switch__input');check('native toggle persists actual disabled state',(await api.listSkills(workspace))[0].enabled,false)
- await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".sm-skill-detail-overview-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".sm-skill-detail-overview-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
+ await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".yc-skill-description-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".yc-skill-description-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
await click('[aria-label="使用技能"]');check('use callback receives actual enabled identity',await read('window.__skillsQa.uses[0].id'),(await api.listSkills(workspace))[0].id)
- await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
+ await click('.sm-card-name');await wait('!!document.querySelector(".sm-skill-detail-card")');await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
await click('[aria-label="卸载技能"]');await key('Escape');await settle();check('nested Escape closes only top uninstall dialog',await read('!!document.querySelector(".sm-skill-detail-card")&&!document.querySelector("[aria-label=确认卸载]")'),true)
await click('[aria-label="关闭技能详情"]');await click('[aria-label="搜索技能"]');await cdp('Input.insertText',{text:'NO_MATCH'});await settle();check('real search filters installed list',await read('document.querySelectorAll(".sm-card").length'),0);await click('[aria-label="清空技能搜索"]');await wait('document.querySelectorAll(".sm-card").length===1')
await click('.sm-card-pin');check('pin updates shared slash menu memory',await read(`JSON.parse(localStorage.getItem(${JSON.stringify('ycode.skills.menu.v1:'+workspace.replaceAll('\\','/').toLowerCase())})).pinned.length`),1)
diff --git a/scripts/update-backend-regression.ts b/scripts/update-backend-regression.ts
new file mode 100644
index 0000000..c921b12
--- /dev/null
+++ b/scripts/update-backend-regression.ts
@@ -0,0 +1,827 @@
+import assert from 'node:assert/strict'
+import { createHash, generateKeyPairSync } from 'node:crypto'
+import { createServer, type ServerResponse } from 'node:http'
+import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { basename, dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { signPayload as releaseSignPayload } from './updates/lib.mjs'
+import { FileUpdatePersistence } from '../src/main/updates/storage'
+import { compareVersions, parseVersion } from '../src/main/updates/semver'
+import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest'
+import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust'
+import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer'
+import { sanitizeLogText } from '../src/main/updates/logger'
+import { UpdateManager } from '../src/main/updates/manager'
+import {
+ UpdateError,
+ type CachedInstaller,
+ type FetchLike,
+ type InstallEnvironment,
+ type InstallerAdapter,
+ type SignedUpdateManifest,
+ type UpdateManifestPayload
+} from '../src/main/updates/types'
+
+const tests: Array<[string, () => Promise | void]> = []
+const test = (name: string, run: () => Promise | void): void => {
+ tests.push([name, run])
+}
+
+const sleep = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds))
+
+async function waitFor(predicate: () => boolean | Promise, timeout = 5_000): Promise {
+ const deadline = Date.now() + timeout
+ while (!await predicate()) {
+ if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition')
+ await sleep(10)
+ }
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash('sha256').update(bytes).digest('hex')
+}
+
+function makeBytes(size: number, fill: number): Buffer {
+ const bytes = Buffer.allocUnsafe(size)
+ for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff
+ return bytes
+}
+
+function expectUpdateError(run: () => unknown, code: string): void {
+ assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code)
+}
+
+const { privateKey, publicKey } = generateKeyPairSync('ed25519')
+const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
+const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
+
+function manifestFor(
+ origin: string,
+ version: string,
+ bytes: Buffer,
+ notes = { zh: `更新 ${version}`, en: `Release ${version}` },
+ filename = `YCode-${version}-Setup.exe`
+): SignedUpdateManifest {
+ const payload: UpdateManifestPayload = {
+ schemaVersion: 1,
+ version,
+ publishedAt: '2026-10-20T09:00:00.000Z',
+ notes,
+ platforms: {
+ 'windows-x64': {
+ url: `${origin}/releases/versions/${version}/${filename}`,
+ size: bytes.byteLength,
+ sha256: sha256(bytes)
+ }
+ }
+ }
+ return {
+ payload,
+ signature: releaseSignPayload(payload, privatePem)
+ }
+}
+
+interface HttpFixture {
+ latest: SignedUpdateManifest | null
+ files: Map
+ requests: string[]
+ downloadRanges: Array
+ redirect: 'none' | 'same-origin' | 'cross-origin'
+ crossOrigin: string
+ oversizeManifest: boolean
+ interruptNextDownload: boolean
+ chunkSize: number
+ chunkDelayMs: number
+ downloadStarted: number
+ origin: string
+ close: () => Promise
+}
+
+async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise {
+ for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) {
+ if (response.destroyed) return
+ const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize))
+ if (!response.write(chunk)) await new Promise(resolve => response.once('drain', resolve))
+ if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
+ }
+ if (!response.destroyed) response.end()
+}
+
+async function createHttpFixture(): Promise {
+ let server: ReturnType
+ const fixture: HttpFixture = {
+ latest: null,
+ files: new Map(),
+ requests: [],
+ downloadRanges: [],
+ redirect: 'none',
+ crossOrigin: '',
+ oversizeManifest: false,
+ interruptNextDownload: false,
+ chunkSize: 64 * 1024,
+ chunkDelayMs: 0,
+ downloadStarted: 0,
+ origin: '',
+ close: async () => {
+ server.closeAllConnections()
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
+ }
+ }
+ server = createServer((request, response) => {
+ response.on('error', () => undefined)
+ const url = new URL(request.url ?? '/', fixture.origin)
+ fixture.requests.push(url.pathname)
+ if (url.pathname === '/releases/latest.json') {
+ if (fixture.oversizeManifest) {
+ const length = 1024 * 1024 + 1
+ response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) })
+ response.end(Buffer.alloc(length, 0x20))
+ return
+ }
+ if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) {
+ response.writeHead(302, { location: '/releases/latest.json?redirected=1' })
+ response.end()
+ return
+ }
+ if (fixture.redirect === 'cross-origin') {
+ response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` })
+ response.end()
+ return
+ }
+ const body = Buffer.from(JSON.stringify(fixture.latest))
+ response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) })
+ response.end(body)
+ return
+ }
+
+ const file = fixture.files.get(url.pathname)
+ if (!file) {
+ response.writeHead(404)
+ response.end()
+ return
+ }
+ const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '')
+ const range = rangeMatch ? Number(rangeMatch[1]) : null
+ fixture.downloadRanges.push(rangeMatch?.[0] ?? null)
+ fixture.downloadStarted += 1
+ if (range !== null && range >= file.byteLength) {
+ response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` })
+ response.end()
+ return
+ }
+ const start = range ?? 0
+ if (fixture.interruptNextDownload && start === 0) {
+ fixture.interruptNextDownload = false
+ const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2)))
+ response.writeHead(200, {
+ 'content-type': 'application/octet-stream',
+ 'content-length': String(file.byteLength)
+ })
+ response.write(partial)
+ setTimeout(() => response.destroy(new Error('simulated interruption')), 20)
+ return
+ }
+ response.writeHead(start > 0 ? 206 : 200, {
+ 'content-type': 'application/octet-stream',
+ 'content-length': String(file.byteLength - start),
+ ...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {})
+ })
+ void sendBody(response, file.subarray(start), fixture).catch(() => undefined)
+ })
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => resolve())
+ })
+ const address = server.address()
+ if (!address || typeof address === 'string') throw new Error('Fake HTTP server did not bind')
+ fixture.origin = `http://127.0.0.1:${address.port}`
+ return fixture
+}
+
+class FakeInstaller implements InstallerAdapter {
+ validateCount = 0
+ verifyCount = 0
+ launchCount = 0
+ launchError: Error | null = null
+ onLaunch: (() => Promise) | null = null
+
+ async validateEnvironment(_requiredBytes?: number): Promise {
+ this.validateCount += 1
+ return {
+ installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode',
+ executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe',
+ registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
+ }
+ }
+
+ async verifyInstaller(_installer: CachedInstaller): Promise {
+ this.verifyCount += 1
+ }
+
+ async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise {
+ this.launchCount += 1
+ if (this.onLaunch) await this.onLaunch()
+ if (this.launchError) throw this.launchError
+ }
+}
+
+interface ManagerFixture {
+ manager: UpdateManager
+ installer: FakeInstaller
+ persistence: FileUpdatePersistence
+ cacheDirectory: string
+ controls: {
+ beforeResult: boolean
+ beforeGate: Promise | null
+ beforeCalls: number
+ failedCalls: number
+ quitCalls: number
+ quitError: Error | null
+ }
+}
+
+let managerSequence = 0
+
+function createManagerFixture(
+ root: string,
+ origin: string,
+ options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {}
+): ManagerFixture {
+ managerSequence += 1
+ const directory = join(root, `manager-${options.identity ?? managerSequence}`)
+ const installer = new FakeInstaller()
+ const persistence = new FileUpdatePersistence(join(directory, 'state.json'))
+ const controls: ManagerFixture['controls'] = {
+ beforeResult: true,
+ beforeGate: null,
+ beforeCalls: 0,
+ failedCalls: 0,
+ quitCalls: 0,
+ quitError: null
+ }
+ const manager = new UpdateManager({
+ currentVersion: options.currentVersion ?? '0.0.1',
+ manifestUrl: `${origin}/releases/latest.json`,
+ cacheDirectory: join(directory, 'cache'),
+ logFile: join(directory, 'updater.log'),
+ persistence,
+ publicKey: async () => publicPem,
+ supported: true,
+ installer,
+ beforeInstall: async () => {
+ controls.beforeCalls += 1
+ if (controls.beforeGate) return controls.beforeGate
+ return controls.beforeResult
+ },
+ quitForUpdate: async () => {
+ controls.quitCalls += 1
+ if (controls.quitError) throw controls.quitError
+ },
+ installFailed: async () => {
+ controls.failedCalls += 1
+ },
+ autoDownload: options.autoDownload ?? false,
+ checkDelayMs: 5,
+ checkIntervalMs: 60_000,
+ upToDateResetMs: 30,
+ manifestTimeoutMs: 2_000,
+ downloadTimeoutMs: 10_000
+ })
+ return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls }
+}
+
+test('strict SemVer parser and comparator', () => {
+ assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1'])
+ assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0)
+ assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1)
+ assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1)
+ expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version')
+ expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version')
+})
+
+test('manifest signature, exact metadata and URL policy', async () => {
+ const origin = 'http://127.0.0.1:65530'
+ const bytes = makeBytes(1024, 5)
+ const manifest = manifestFor(origin, '1.0.1', bytes)
+ const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), {
+ manifestUrl: `${origin}/releases/latest.json`,
+ publicKeyPem: publicPem
+ })
+ assert.equal(verified.payload.version, '1.0.1')
+
+ const tampered = structuredClone(manifest)
+ tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
+ expectUpdateError(
+ () => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), {
+ manifestUrl: `${origin}/releases/latest.json`,
+ publicKeyPem: publicPem
+ }),
+ 'invalid-signature'
+ )
+
+ const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record
+ invalidVersionPayload.version = '01.0.0'
+ const invalidVersionDocument = {
+ payload: invalidVersionPayload,
+ signature: releaseSignPayload(invalidVersionPayload, privatePem)
+ }
+ expectUpdateError(
+ () => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), {
+ manifestUrl: `${origin}/releases/latest.json`,
+ publicKeyPem: publicPem
+ }),
+ 'invalid-version'
+ )
+
+ const outsidePath = structuredClone(manifest.payload)
+ outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe`
+ const outsidePathDocument = {
+ payload: outsidePath,
+ signature: releaseSignPayload(outsidePath, privatePem)
+ }
+ expectUpdateError(
+ () => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), {
+ manifestUrl: `${origin}/releases/latest.json`,
+ publicKeyPem: publicPem
+ }),
+ 'invalid-manifest'
+ )
+ assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`)
+})
+
+test('trusted renderer URL gate rejects task and foreign frames', () => {
+ const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar'
+ const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href
+ const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href
+ assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true)
+ assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true)
+ assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false)
+ assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false)
+ assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', {
+ appPath,
+ rendererUrl: 'http://localhost:5173'
+ }), false)
+ assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', {
+ appPath,
+ rendererUrl: 'http://localhost:5173'
+ }), true)
+ assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', {
+ appPath,
+ rendererUrl: 'http://localhost:5173'
+ }), false)
+})
+
+test('installer adapter uses spawn, current key and NSIS arguments', async () => {
+ assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true)
+ const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir'
+ const executable = `${root}\\YCode.exe`
+ const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache')
+ const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = []
+ const child = {
+ once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) {
+ if (event === 'spawn') queueMicrotask(() => (listener as () => void)())
+ return child
+ },
+ unref() {}
+ }
+ const baseOptions = {
+ platform: 'win32',
+ isPackaged: true,
+ execPath: executable,
+ appPath: `${root}\\resources\\app.asar`,
+ cacheDirectory,
+ processId: 4321,
+ resolveRealPath: async value => value,
+ readStat: async () => ({ isFile: () => true }),
+ queryRegistryValue: async name => {
+ if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser`
+ if (name === 'DisplayIcon') return `${executable},0`
+ return null
+ },
+ verifyFile: async () => undefined,
+ spawn: (command, args, options) => {
+ calls.push({ command, args, detached: options.detached, stdio: options.stdio })
+ return child
+ }
+ } as const
+ const adapter = createNsisInstaller({
+ ...baseOptions,
+ readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 })
+ })
+ const environment = await adapter.validateEnvironment(1024)
+ assert.equal(environment.installationDirectory, root)
+ const cached: CachedInstaller = {
+ version: '1.0.2',
+ path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'),
+ partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'),
+ size: 10,
+ sha256: 'a'.repeat(64)
+ }
+ await adapter.launch(cached, environment)
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].detached, true)
+ assert.equal(calls[0].stdio, 'ignore')
+ assert.deepEqual([...calls[0].args], [
+ '/S',
+ '--updated',
+ '--force-run',
+ '/currentuser',
+ '/YCODEWAITPID=4321',
+ `/D=${root}`
+ ])
+ assert.equal(calls[0].args.at(-1), `/D=${root}`)
+
+ const lowSpace = createNsisInstaller({
+ ...baseOptions,
+ readDiskStats: async () => ({ bsize: 1, bavail: 1 })
+ })
+ await assert.rejects(
+ () => lowSpace.validateEnvironment(1024),
+ (error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check'
+ )
+
+ const directoryMarker = createNsisInstaller({
+ ...baseOptions,
+ readStat: async value => ({ isFile: () => !value.endsWith('app.asar') })
+ })
+ await assert.rejects(
+ () => directoryMarker.validateEnvironment(0),
+ (error: unknown) => error instanceof UpdateError
+ && error.code === 'installer-marker'
+ && /app\.asar/.test(error.message)
+ )
+})
+
+test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => {
+ const first = await createHttpFixture()
+ const second = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-'))
+ try {
+ const bytes = makeBytes(256 * 1024, 11)
+ first.latest = manifestFor(first.origin, '1.0.1', bytes)
+ first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
+ first.redirect = 'same-origin'
+ const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' })
+ const redirectedState = await redirected.manager.check()
+ assert.equal(redirectedState.status, 'available')
+
+ first.redirect = 'cross-origin'
+ first.crossOrigin = second.origin
+ second.latest = first.latest
+ const cross = createManagerFixture(root, first.origin, { identity: 'cross' })
+ const crossState = await cross.manager.check()
+ assert.equal(crossState.status, 'error')
+ assert.match(crossState.error ?? '', /跨源/)
+
+ first.redirect = 'none'
+ first.oversizeManifest = true
+ const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' })
+ const oversizedState = await oversized.manager.check()
+ assert.equal(oversizedState.status, 'error')
+ assert.match(oversizedState.error ?? '', /大小限制/)
+ } finally {
+ await first.close()
+ await second.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('automatic download, dismissal and up-to-date idle reset', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-'))
+ try {
+ const bytes = makeBytes(192 * 1024, 17)
+ http.latest = manifestFor(http.origin, '1.0.1', bytes)
+ http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
+ const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' })
+ const checked = await auto.manager.checkAndDownload(false)
+ assert.equal(checked.status, 'downloading')
+ assert.equal(checked.version, '1.0.1')
+ assert.equal(checked.lastExplicit, false)
+ await waitFor(async () => (await auto.manager.getState()).status === 'ready')
+ assert.equal((await auto.manager.getState()).progress, 100)
+ assert.equal((await auto.manager.getState()).lastExplicit, false)
+ http.oversizeManifest = true
+ const retainedReady = await auto.manager.check(false)
+ assert.equal(retainedReady.status, 'ready')
+ assert.equal(retainedReady.lastExplicit, false)
+ http.oversizeManifest = false
+ const dismissed = await auto.manager.dismiss()
+ assert.equal(dismissed.dismissedVersion, '1.0.1')
+ assert.equal(dismissed.lastExplicit, true)
+ const persisted = await auto.persistence.load()
+ assert.equal(persisted.dismissedVersion, '1.0.1')
+
+ const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' })
+ await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' })
+ const before = http.requests.length
+ const dismissedCheck = await second.manager.checkAndDownload()
+ await sleep(80)
+ assert.equal(dismissedCheck.dismissedVersion, '1.0.1')
+ assert.equal(dismissedCheck.lastExplicit, true)
+ assert.equal(http.requests.length - before, 1)
+
+ http.latest = manifestFor(http.origin, '0.0.1', bytes)
+ const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' })
+ const upToDate = await current.manager.check()
+ assert.equal(upToDate.status, 'up-to-date')
+ await sleep(60)
+ assert.equal((await current.manager.getState()).status, 'idle')
+
+ http.latest = manifestFor(http.origin, '1.0.1', bytes)
+ const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' })
+ assert.equal((await explicit.manager.check(true)).lastExplicit, true)
+ http.oversizeManifest = true
+ const backgroundFailure = await explicit.manager.check(false)
+ assert.equal(backgroundFailure.status, 'error')
+ assert.equal(backgroundFailure.lastExplicit, false)
+ http.oversizeManifest = false
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('download interruption resumes with valid Range and fixed cache filename', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-'))
+ try {
+ const bytes = makeBytes(2 * 1024 * 1024, 23)
+ http.latest = manifestFor(http.origin, '1.0.1', bytes)
+ http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
+ const fixture = createManagerFixture(root, http.origin, { identity: 'resume' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+ http.interruptNextDownload = true
+ const interrupted = await fixture.manager.download()
+ assert.equal(interrupted.status, 'error')
+ const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part'))
+ assert.ok(partInfo.size > 0)
+
+ const resumed = await fixture.manager.download()
+ assert.equal(resumed.status, 'ready')
+ assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes=')))
+ const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'))
+ assert.equal(sha256(cached), sha256(bytes))
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('concurrent download is single-flight and release switch aborts stale bytes', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-'))
+ try {
+ const firstBytes = makeBytes(4 * 1024 * 1024, 31)
+ const secondBytes = makeBytes(384 * 1024, 47)
+ http.latest = manifestFor(http.origin, '1.0.1', firstBytes)
+ http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes)
+ http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes)
+ http.chunkSize = 32 * 1024
+ http.chunkDelayMs = 15
+ const fixture = createManagerFixture(root, http.origin, { identity: 'race' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+ const staleDownload = fixture.manager.download()
+ await waitFor(() => http.downloadStarted > 0)
+
+ http.latest = manifestFor(http.origin, '1.0.2', secondBytes)
+ const switched = await fixture.manager.check()
+ assert.equal(switched.status, 'available')
+ assert.equal(switched.version, '1.0.2')
+ const staleResult = await staleDownload
+ assert.notEqual(staleResult.status, 'ready')
+ assert.equal((await fixture.manager.getState()).version, '1.0.2')
+
+ http.chunkDelayMs = 0
+ const rangeCount = http.downloadRanges.length
+ const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()])
+ assert.equal(one.status, 'ready')
+ assert.equal(two.status, 'ready')
+ assert.equal(http.downloadRanges.length - rangeCount, 1)
+ const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe'))
+ assert.equal(sha256(cached), sha256(secondBytes))
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('install cancellation, launch failure marker cleanup, and success ordering', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-'))
+ try {
+ const bytes = makeBytes(256 * 1024, 61)
+ http.latest = manifestFor(http.origin, '1.0.2', bytes)
+ http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
+ const fixture = createManagerFixture(root, http.origin, { identity: 'install' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+
+ fixture.controls.beforeResult = false
+ const cancelled = await fixture.manager.install()
+ assert.equal(cancelled.status, 'ready')
+ assert.equal(fixture.installer.launchCount, 0)
+ assert.equal(fixture.controls.failedCalls, 0)
+
+ assert.equal((await fixture.manager.download()).status, 'ready')
+ fixture.controls.beforeResult = true
+ fixture.installer.launchError = new Error('spawn failed')
+ const failed = await fixture.manager.install()
+ assert.equal(failed.status, 'error')
+ assert.equal(fixture.controls.failedCalls, 1)
+ const cleared = await fixture.persistence.load()
+ assert.equal(cleared.unfinishedVersion, undefined)
+ assert.equal(cleared.target, undefined)
+
+ assert.equal((await fixture.manager.check()).status, 'ready')
+ fixture.installer.launchError = null
+ let markerAtLaunch: string | undefined
+ fixture.installer.onLaunch = async () => {
+ markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion
+ }
+ const installed = await fixture.manager.install()
+ assert.equal(installed.status, 'installing')
+ assert.equal(markerAtLaunch, '1.0.2')
+ assert.equal(fixture.installer.validateCount >= 2, true)
+ assert.equal(fixture.installer.verifyCount >= 2, true)
+ assert.equal(fixture.installer.launchCount, 2)
+ assert.equal(fixture.controls.quitCalls, 1)
+ const retained = await fixture.persistence.load()
+ assert.equal(retained.unfinishedVersion, '1.0.2')
+ assert.equal(retained.target?.sha256, sha256(bytes))
+ const afterCommittedDismiss = await fixture.manager.dismiss()
+ assert.equal(afterCommittedDismiss.status, 'installing')
+ assert.equal(afterCommittedDismiss.dismissedVersion, undefined)
+ const afterCommittedInstall = await fixture.manager.install()
+ assert.equal(afterCommittedInstall.status, 'installing')
+ assert.equal(fixture.installer.launchCount, 2)
+ assert.equal(fixture.controls.beforeCalls, 3)
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('confirmation pending blocks dismiss and repeated install', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-'))
+ try {
+ const bytes = makeBytes(128 * 1024, 67)
+ http.latest = manifestFor(http.origin, '1.0.2', bytes)
+ http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
+ const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+ assert.equal((await fixture.manager.download()).status, 'ready')
+
+ let releaseConfirmation!: (accepted: boolean) => void
+ fixture.controls.beforeGate = new Promise(resolve => {
+ releaseConfirmation = resolve
+ })
+ const firstInstall = fixture.manager.install()
+ await waitFor(() => fixture.controls.beforeCalls === 1)
+
+ const dismissed = await fixture.manager.dismiss()
+ assert.equal(dismissed.status, 'ready')
+ assert.equal(dismissed.dismissedVersion, undefined)
+ const repeatedInstall = fixture.manager.install()
+ assert.equal(fixture.installer.launchCount, 0)
+ assert.equal(fixture.controls.beforeCalls, 1)
+
+ releaseConfirmation(false)
+ const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall])
+ assert.equal(firstState.status, 'ready')
+ assert.equal(repeatedState.status, 'ready')
+ assert.equal(fixture.installer.launchCount, 0)
+ assert.equal(fixture.controls.failedCalls, 0)
+ assert.equal((await fixture.persistence.load()).dismissedVersion, undefined)
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('committed installer blocks recovery after quit failure', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-'))
+ try {
+ const bytes = makeBytes(128 * 1024, 73)
+ http.latest = manifestFor(http.origin, '1.0.2', bytes)
+ http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
+ const fixture = createManagerFixture(root, http.origin, { identity: 'committed' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+ assert.equal((await fixture.manager.download()).status, 'ready')
+
+ fixture.controls.quitError = new Error('simulated quit failure')
+ const committed = await fixture.manager.install()
+ assert.equal(committed.status, 'installing')
+ assert.match(committed.error ?? '', /必须退出 YCode/)
+ assert.equal(fixture.installer.launchCount, 1)
+ assert.equal(fixture.controls.beforeCalls, 1)
+ assert.equal(fixture.controls.quitCalls, 1)
+ assert.equal(fixture.controls.failedCalls, 0)
+ const retained = await fixture.persistence.load()
+ assert.equal(retained.unfinishedVersion, '1.0.2')
+ assert.equal(retained.target?.version, '1.0.2')
+
+ const requestCount = http.requests.length
+ const dismissed = await fixture.manager.dismiss()
+ const checked = await fixture.manager.check()
+ const downloaded = await fixture.manager.download()
+ const checkedAndDownloaded = await fixture.manager.checkAndDownload()
+ const repeated = await fixture.manager.install()
+ for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) {
+ assert.equal(state.status, 'installing')
+ assert.match(state.error ?? '', /必须退出 YCode/)
+ }
+ assert.equal(http.requests.length, requestCount)
+ assert.equal(fixture.installer.launchCount, 1)
+ assert.equal(fixture.controls.beforeCalls, 1)
+ assert.equal(fixture.controls.quitCalls, 1)
+ assert.equal(fixture.controls.failedCalls, 0)
+ assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2')
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('corrupt cache is revalidated and rejected', async () => {
+ const http = await createHttpFixture()
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-'))
+ try {
+ const bytes = makeBytes(128 * 1024, 71)
+ http.latest = manifestFor(http.origin, '1.0.1', bytes)
+ http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
+ const fixture = createManagerFixture(root, http.origin, { identity: 'cache' })
+ assert.equal((await fixture.manager.check()).status, 'available')
+ assert.equal((await fixture.manager.download()).status, 'ready')
+ await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered'))
+ const checked = await fixture.manager.check()
+ assert.equal(checked.status, 'available')
+ const retried = await fixture.manager.download()
+ assert.equal(retried.status, 'ready')
+ } finally {
+ await http.close()
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('unfinished marker clears after target version is installed', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-'))
+ try {
+ const persistence = new FileUpdatePersistence(join(root, 'state.json'))
+ await persistence.save({
+ schemaVersion: 1,
+ unfinishedVersion: '1.0.0',
+ target: {
+ version: '1.0.0',
+ size: 10,
+ sha256: 'a'.repeat(64),
+ manifestUrl: 'http://127.0.0.1/releases/latest.json'
+ }
+ })
+ const manager = new UpdateManager({
+ currentVersion: '1.0.0',
+ manifestUrl: 'http://127.0.0.1/releases/latest.json',
+ cacheDirectory: join(root, 'cache'),
+ logFile: join(root, 'updater.log'),
+ persistence,
+ publicKey: async () => publicPem,
+ supported: true,
+ installer: new FakeInstaller(),
+ beforeInstall: async () => false,
+ quitForUpdate: async () => undefined
+ })
+ const state = await manager.getState()
+ assert.equal(state.unfinishedVersion, undefined)
+ const persisted = await persistence.load()
+ assert.equal(persisted.target, undefined)
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+test('log redaction removes URL credentials and query secrets', () => {
+ const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123')
+ assert.equal(sanitized.includes('password'), false)
+ assert.equal(sanitized.includes('secret'), false)
+ assert.equal(sanitized.includes('abc123'), false)
+})
+
+async function main(): Promise {
+ let failures = 0
+ for (const [name, run] of tests) {
+ try {
+ await run()
+ console.log(`ok - ${name}`)
+ } catch (error) {
+ failures += 1
+ console.error(`not ok - ${name}`)
+ console.error(error)
+ }
+ }
+ console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`)
+ if (failures > 0) process.exitCode = 1
+}
+
+void main()
diff --git a/scripts/update-maintenance-regression.ts b/scripts/update-maintenance-regression.ts
new file mode 100644
index 0000000..110fe0f
--- /dev/null
+++ b/scripts/update-maintenance-regression.ts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict'
+import { assertRuntimeAvailable, pauseRuntimeStarts, resumeRuntimeStarts, withRuntimeStart } from '../src/main/update-maintenance'
+import { createUpdateLifecycle } from '../src/main/update-lifecycle'
+
+let release!: () => void
+const running = withRuntimeStart(() => new Promise(resolve => { release = resolve }))
+await Promise.resolve()
+let paused = false
+const pendingPause = pauseRuntimeStarts().then(() => { paused = true })
+assert.throws(assertRuntimeAvailable, /准备重启/)
+assert.throws(() => withRuntimeStart(async () => undefined), /准备重启/)
+await Promise.resolve()
+assert.equal(paused, false)
+release()
+await running
+await pendingPause
+assert.equal(paused, true)
+resumeRuntimeStarts()
+assert.doesNotThrow(assertRuntimeAvailable)
+await assert.rejects(withRuntimeStart(async () => { throw new Error('failed start') }), /failed start/)
+await pauseRuntimeStarts()
+resumeRuntimeStarts()
+assert.equal(await withRuntimeStart(async () => 42), 42)
+console.log('PASS update maintenance: in-flight start drain, new work rejection, resume, rejected-start cleanup')
+
+let resumed = 0
+let finishDirectQuiesce!: () => void
+let directQuiesceStarted!: () => void
+const directQuiesceStart = new Promise(resolve => { directQuiesceStarted = resolve })
+let directQuiesceCalls = 0
+const directLifecycle = createUpdateLifecycle({
+ quiesce: () => {
+ directQuiesceCalls += 1
+ directQuiesceStarted()
+ return new Promise(resolve => { finishDirectQuiesce = resolve })
+ },
+ restore: () => { throw new Error('successful quiesce must not restore') },
+ shutdown: async () => undefined,
+ exit: () => undefined,
+ log: () => undefined
+})
+const directInstall = directLifecycle.beforeInstall()
+await directQuiesceStart
+assert.equal(directQuiesceCalls, 1, 'install starts quiesce without a confirmation port')
+let directResolved = false
+void directInstall.then(() => { directResolved = true })
+await Promise.resolve()
+assert.equal(directResolved, false, 'beforeInstall waits for quiesce completion')
+await assert.rejects(directLifecycle.beforeInstall(), /上次更新准备仍在结束中/)
+assert.equal(directQuiesceCalls, 1, 'concurrent beforeInstall must not start quiesce twice')
+finishDirectQuiesce()
+assert.equal(await directInstall, true)
+
+let finishQuiesce!: () => void
+const deferredLifecycle = createUpdateLifecycle({
+ quiesce: () => new Promise(resolve => { finishQuiesce = resolve }),
+ restore: () => { resumed += 1 },
+ shutdown: async () => undefined,
+ exit: () => undefined,
+ log: () => undefined,
+ prepareTimeoutMs: 2
+})
+await assert.rejects(deferredLifecycle.beforeInstall(), /清理尚未结束/)
+deferredLifecycle.installFailed()
+assert.equal(resumed, 0, 'timeout must not resume while old quiesce is still running')
+finishQuiesce()
+await new Promise(resolve => setTimeout(resolve, 0))
+assert.equal(resumed, 1, 'runtime resumes only after timed-out quiesce settles')
+
+let exited = 0
+let restarts = 0
+let shutdowns = 0
+const committedLifecycle = createUpdateLifecycle({
+ quiesce: async () => undefined,
+ restore: () => { restarts += 1 },
+ shutdown: async () => { shutdowns += 1 },
+ exit: () => { exited += 1 },
+ log: () => undefined
+})
+assert.equal(await committedLifecycle.beforeInstall(), true)
+await committedLifecycle.quitForUpdate()
+committedLifecycle.installFailed()
+assert.deepEqual([shutdowns, exited, restarts], [1, 1, 0], 'committed installer exit must never restore work')
+await assert.rejects(committedLifecycle.beforeInstall(), /安装器已启动/)
+const hungShutdown = createUpdateLifecycle({
+ quiesce: async () => undefined,
+ restore: () => { throw new Error('must not restore committed installer') },
+ shutdown: () => new Promise(() => undefined),
+ exit: () => { exited += 1 },
+ log: () => undefined,
+ quitTimeoutMs: 2
+})
+await hungShutdown.quitForUpdate()
+assert.equal(exited, 2, 'hung shutdown is bounded and still releases the installer PID')
+console.log('PASS update lifecycle: direct quiesce, concurrent barrier, timeout recovery, post-launch no-resume commit')
diff --git a/scripts/updates/contract-selftest.mjs b/scripts/updates/contract-selftest.mjs
new file mode 100644
index 0000000..b4cb1ab
--- /dev/null
+++ b/scripts/updates/contract-selftest.mjs
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict'
+import crypto from 'node:crypto'
+import fsp from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { build } from 'esbuild'
+import {
+ DEFAULT_BASE_URL,
+ DEFAULT_PUBLIC_KEY,
+ REPO_ROOT,
+ generateEd25519KeyPair,
+ normalizeBaseUrl,
+ readPublicKey,
+ signPayload
+} from './lib.mjs'
+
+async function main() {
+ const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-contract-selftest-'))
+ try {
+ const bundlePath = path.join(temporary, 'manifest.mjs')
+ await build({
+ entryPoints: [path.join(REPO_ROOT, 'src', 'main', 'updates', 'manifest.ts')],
+ bundle: true,
+ platform: 'node',
+ format: 'esm',
+ outfile: bundlePath,
+ logLevel: 'silent'
+ })
+ const backend = await import(`${pathToFileURL(bundlePath).href}?v=${Date.now()}`)
+ assert.equal(typeof backend.verifySignedManifest, 'function')
+
+ const trustAnchor = await readPublicKey(DEFAULT_PUBLIC_KEY)
+ const trustKey = crypto.createPublicKey(trustAnchor)
+ assert.equal(trustKey.asymmetricKeyType, 'ed25519')
+
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL)
+ const payload = {
+ schemaVersion: 1,
+ version: '9.9.9',
+ publishedAt: '2026-10-20T10:00:00.000Z',
+ notes: { zh: '契约自测', en: 'Contract self-test' },
+ platforms: {
+ 'windows-x64': {
+ url: `${baseUrl}versions/9.9.9/YCode-9.9.9-Setup-x64.exe`,
+ size: 123,
+ sha256: 'a'.repeat(64)
+ }
+ }
+ }
+ const document = {
+ payload,
+ signature: signPayload(payload, keys.privatePem)
+ }
+ const raw = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8')
+ const verified = backend.verifySignedManifest(raw, {
+ manifestUrl: `${baseUrl}latest.json`,
+ publicKeyPem: keys.publicPem
+ })
+ assert.equal(verified.payload.version, payload.version)
+ assert.equal(verified.asset.url, payload.platforms['windows-x64'].url)
+
+ const tampered = structuredClone(document)
+ tampered.payload.notes.en = 'tampered'
+ assert.throws(
+ () => backend.verifySignedManifest(Buffer.from(JSON.stringify(tampered), 'utf8'), {
+ manifestUrl: `${baseUrl}latest.json`,
+ publicKeyPem: keys.publicPem
+ }),
+ /签名验证失败/
+ )
+
+ console.log('Release manifest matches update_backend parser/signature/path policy.')
+ console.log('Repository public key is a valid Ed25519 SPKI PEM trust anchor.')
+ } finally {
+ await fsp.rm(temporary, { recursive: true, force: true })
+ }
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+})
diff --git a/scripts/updates/deploy.mjs b/scripts/updates/deploy.mjs
new file mode 100644
index 0000000..0a211fc
--- /dev/null
+++ b/scripts/updates/deploy.mjs
@@ -0,0 +1,79 @@
+import path from 'node:path'
+import {
+ DEFAULT_BASE_URL,
+ DEFAULT_PUBLIC_KEY,
+ DEFAULT_SSH_HOST,
+ normalizeBaseUrl,
+ parseOptions,
+ resolvePath,
+ usageError
+} from './lib.mjs'
+import {
+ publishLocalRelease,
+ publishViaSsh,
+ readLatestViaSsh,
+ readLocalLatest,
+ validateReleaseBundle
+} from './transport.mjs'
+
+const usage = `Usage:
+ node scripts/updates/deploy.mjs --manifest --installer
+ [--transport ssh|local] [--base-url ] [--public-key ]
+ [--ssh-host local001] [--local-root ] [--dry-run]
+
+The SSH transport uploads to /srv/ycode-staging, verifies the current signed latest.json,
+installs an immutable version directory, then atomically replaces latest.json.
+The local transport is for temporary fake-transport tests only.
+`
+
+async function main() {
+ const options = parseOptions(process.argv.slice(2), {
+ values: ['manifest', 'installer', 'transport', 'base-url', 'public-key', 'ssh-host', 'local-root'],
+ booleans: ['dry-run']
+ })
+ if (!options.manifest || !options.installer) throw new Error('--manifest and --installer are required')
+
+ const transport = options.transport || 'ssh'
+ if (transport !== 'ssh' && transport !== 'local') throw new Error('--transport must be ssh or local')
+ if (transport === 'local' && !options['local-root']) throw new Error('--local-root is required for local transport')
+
+ const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
+ const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
+ const release = await validateReleaseBundle({
+ manifestPath: resolvePath(options.manifest),
+ installerPath: resolvePath(options.installer),
+ publicKeyPath,
+ baseUrl
+ })
+
+ let current
+ if (transport === 'ssh') {
+ current = await readLatestViaSsh(options['ssh-host'] || DEFAULT_SSH_HOST, release.publicKeyPem, baseUrl)
+ } else {
+ current = await readLocalLatest(resolvePath(options['local-root']), release.publicKeyPem, baseUrl)
+ }
+
+ if (options['dry-run']) {
+ console.log(`Verified ${release.document.payload.version} for ${transport} publication.`)
+ console.log(`Current latest: ${current.document?.payload.version || 'none'}`)
+ return
+ }
+
+ if (transport === 'ssh') {
+ await publishViaSsh({
+ host: options['ssh-host'] || DEFAULT_SSH_HOST,
+ release,
+ current
+ })
+ } else {
+ await publishLocalRelease({
+ root: path.resolve(options['local-root']),
+ release,
+ current
+ })
+ }
+
+ console.log(`Published ${release.document.payload.version} via ${transport}.`)
+}
+
+main().catch((error) => usageError(usage, error))
diff --git a/scripts/updates/get-exe-version.ps1 b/scripts/updates/get-exe-version.ps1
new file mode 100644
index 0000000..dc55d3b
--- /dev/null
+++ b/scripts/updates/get-exe-version.ps1
@@ -0,0 +1,19 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path
+)
+
+$ErrorActionPreference = 'Stop'
+$item = Get-Item -LiteralPath $Path
+
+if (-not $item.PSIsContainer -and $item.Extension -ieq '.exe') {
+ [pscustomobject]@{
+ productVersion = [string]$item.VersionInfo.ProductVersion
+ fileVersion = [string]$item.VersionInfo.FileVersion
+ productName = [string]$item.VersionInfo.ProductName
+ fileDescription = [string]$item.VersionInfo.FileDescription
+ } | ConvertTo-Json -Compress
+ exit 0
+}
+
+throw "Not an executable file: $Path"
diff --git a/scripts/updates/init-key.mjs b/scripts/updates/init-key.mjs
new file mode 100644
index 0000000..b63073e
--- /dev/null
+++ b/scripts/updates/init-key.mjs
@@ -0,0 +1,81 @@
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import {
+ DEFAULT_PRIVATE_KEY,
+ DEFAULT_PUBLIC_KEY,
+ assertOutsideRepo,
+ generateEd25519KeyPair,
+ parseOptions,
+ publicKeyFingerprint,
+ resolvePath,
+ runProcess,
+ usageError,
+ writeFileAtomic
+} from './lib.mjs'
+
+const usage = `Usage:
+ node scripts/updates/init-key.mjs [--private-key ] [--public-key ] [--force]
+
+The private key must remain outside the repository. The default private key is:
+ ${DEFAULT_PRIVATE_KEY}
+`
+
+function secureWindowsAcl(filename, isDirectory = false) {
+ if (process.platform !== 'win32') return
+ const identity = runProcess('whoami', []).trim()
+ if (!identity) throw new Error('Unable to determine the current Windows identity')
+ const permission = isDirectory ? `${identity}:(OI)(CI)F` : `${identity}:(F)`
+ runProcess('icacls', [filename, '/inheritance:r', '/grant:r', permission])
+}
+
+async function pathExists(filename) {
+ try {
+ await fsp.access(filename)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function main() {
+ const options = parseOptions(process.argv.slice(2), {
+ values: ['private-key', 'public-key'],
+ booleans: ['force']
+ })
+ const privateKeyPath = await assertOutsideRepo(
+ resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
+ 'Private key'
+ )
+ const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
+
+ if (!options.force) {
+ if (await pathExists(privateKeyPath)) throw new Error(`Refusing to overwrite existing private key: ${privateKeyPath}`)
+ if (await pathExists(publicKeyPath)) throw new Error(`Refusing to overwrite existing public key: ${publicKeyPath}`)
+ }
+
+ const keyPair = generateEd25519KeyPair()
+ const directory = path.dirname(privateKeyPath)
+ await fsp.mkdir(directory, { recursive: true, mode: 0o700 })
+ try {
+ await fsp.chmod(directory, 0o700)
+ } catch {
+ // Windows ACLs are applied below.
+ }
+ secureWindowsAcl(directory, true)
+
+ await writeFileAtomic(privateKeyPath, keyPair.privatePem, 0o600)
+ try {
+ await fsp.chmod(privateKeyPath, 0o600)
+ } catch {
+ // Windows ACLs are applied below.
+ }
+ secureWindowsAcl(privateKeyPath)
+ await writeFileAtomic(publicKeyPath, keyPair.publicPem, 0o644)
+
+ console.log(`Private key: ${privateKeyPath}`)
+ console.log(`Public key: ${publicKeyPath}`)
+ console.log(`Public key SHA-256 fingerprint: ${publicKeyFingerprint(keyPair.publicPem)}`)
+ console.log('Private key material was not printed. Back up the private key separately before rotating it.')
+}
+
+main().catch((error) => usageError(usage, error))
diff --git a/scripts/updates/lib.mjs b/scripts/updates/lib.mjs
new file mode 100644
index 0000000..b8f02df
--- /dev/null
+++ b/scripts/updates/lib.mjs
@@ -0,0 +1,538 @@
+import crypto from 'node:crypto'
+import { spawnSync } from 'node:child_process'
+import fs from 'node:fs'
+import fsp from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
+export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
+export const DEFAULT_SSH_HOST = 'local001'
+export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
+export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
+export const BASELINE_VERSION = '0.0.1'
+export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
+export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
+export const SERVER_PUBLIC_ROOT = '/srv/ycode'
+export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
+export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
+export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
+export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
+export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
+
+const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
+const SHA256_RE = /^[a-f0-9]{64}$/
+const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
+
+function fail(message) {
+ throw new Error(message)
+}
+
+export function assert(condition, message) {
+ if (!condition) fail(message)
+}
+
+export function parseOptions(argv, { values = [], booleans = [] } = {}) {
+ const valueNames = new Set(values)
+ const booleanNames = new Set(booleans)
+ const options = {}
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
+ const equals = argument.indexOf('=')
+ const name = argument.slice(2, equals === -1 ? undefined : equals)
+
+ if (booleanNames.has(name)) {
+ assert(equals === -1, `--${name} does not accept a value`)
+ options[name] = true
+ continue
+ }
+ assert(valueNames.has(name), `Unknown option: --${name}`)
+
+ const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
+ assert(value !== undefined && value !== '', `Missing value for --${name}`)
+ options[name] = value
+ }
+
+ return options
+}
+
+export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
+ let url
+ try {
+ url = new URL(value)
+ } catch {
+ fail(`Invalid base URL: ${value}`)
+ }
+ assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
+ assert(!url.username && !url.password, 'Base URL must not contain credentials')
+ assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
+ if (!url.pathname.endsWith('/')) url.pathname += '/'
+ return url.href
+}
+
+export function artifactUrlFor(baseUrl, version, filename) {
+ parseVersion(version)
+ assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
+ const base = normalizeBaseUrl(baseUrl)
+ return new URL(`versions/${version}/${filename}`, base).href
+}
+
+export function assertHttpUrl(value, label = 'URL') {
+ let url
+ try {
+ url = new URL(value)
+ } catch {
+ fail(`${label} is invalid`)
+ }
+ assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
+ assert(!url.username && !url.password, `${label} must not contain credentials`)
+ assert(!url.search, `${label} must not contain a query`)
+ assert(!url.hash, `${label} must not contain a fragment`)
+ return url
+}
+
+export function parseVersion(version) {
+ assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
+ const match = SEMVER_RE.exec(version)
+ assert(match, `Version is not strict SemVer: ${version}`)
+ const prerelease = match[4] ? match[4].split('.') : []
+ for (const identifier of prerelease) {
+ assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
+ }
+ return {
+ major: BigInt(match[1]),
+ minor: BigInt(match[2]),
+ patch: BigInt(match[3]),
+ prerelease,
+ build: match[5] ? match[5].split('.') : []
+ }
+}
+
+function compareIdentifier(left, right) {
+ const leftNumeric = /^\d+$/.test(left)
+ const rightNumeric = /^\d+$/.test(right)
+ if (leftNumeric && rightNumeric) {
+ const a = BigInt(left)
+ const b = BigInt(right)
+ return a < b ? -1 : a > b ? 1 : 0
+ }
+ if (leftNumeric) return -1
+ if (rightNumeric) return 1
+ return left < right ? -1 : left > right ? 1 : 0
+}
+
+export function compareVersions(left, right) {
+ const a = parseVersion(left)
+ const b = parseVersion(right)
+ for (const key of ['major', 'minor', 'patch']) {
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
+ }
+ if (!a.prerelease.length && !b.prerelease.length) return 0
+ if (!a.prerelease.length) return 1
+ if (!b.prerelease.length) return -1
+
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
+ for (let index = 0; index < length; index += 1) {
+ if (a.prerelease[index] === undefined) return -1
+ if (b.prerelease[index] === undefined) return 1
+ const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
+ if (compared !== 0) return compared
+ }
+ return 0
+}
+
+export function normalizeProductVersion(value) {
+ let normalized = String(value ?? '').trim()
+ if (normalized.startsWith('v')) normalized = normalized.slice(1)
+ const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
+ if (windowsRevision) normalized = windowsRevision[1]
+ return normalized
+}
+
+export async function sha256File(filename) {
+ const hash = crypto.createHash('sha256')
+ for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
+ return hash.digest('hex')
+}
+
+export async function readJson(filename) {
+ try {
+ return JSON.parse(await fsp.readFile(filename, 'utf8'))
+ } catch (error) {
+ fail(`Unable to read JSON ${filename}: ${error.message}`)
+ }
+}
+
+export async function writeFileAtomic(filename, data, mode = 0o644) {
+ const directory = path.dirname(filename)
+ await fsp.mkdir(directory, { recursive: true })
+ const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
+ const handle = await fsp.open(temporary, 'wx', mode)
+ try {
+ await handle.writeFile(data)
+ await handle.sync()
+ } finally {
+ await handle.close()
+ }
+ try {
+ await fsp.rename(temporary, filename)
+ } catch (error) {
+ await fsp.rm(temporary, { force: true })
+ throw error
+ }
+}
+
+export function resolvePath(value, base = process.cwd()) {
+ return path.resolve(base, value)
+}
+
+export async function assertOutsideRepo(filename, label) {
+ const candidate = path.resolve(filename)
+ const repo = path.resolve(REPO_ROOT)
+ const relative = path.relative(repo, candidate)
+ assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
+ return candidate
+}
+
+export function generateEd25519KeyPair() {
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
+ return {
+ privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
+ publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
+ }
+}
+
+export function createPrivateKey(privateKeyPem) {
+ const key = crypto.createPrivateKey(privateKeyPem)
+ assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
+ return key
+}
+
+export function createPublicKey(publicKeyPem) {
+ const key = crypto.createPublicKey(publicKeyPem)
+ assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
+ return key
+}
+
+export function signPayload(payload, privateKeyPem) {
+ const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
+ return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
+}
+
+export function verifyPayloadSignature(payload, signature, publicKeyPem) {
+ assert(typeof signature === 'string', 'Manifest signature must be a string')
+ const signatureBytes = Buffer.from(signature, 'base64')
+ assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
+ assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
+ const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
+ return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
+}
+
+export function publicKeyFingerprint(publicKeyPem) {
+ const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
+ return crypto.createHash('sha256').update(der).digest('hex')
+}
+
+function assertExactKeys(value, expected, label) {
+ assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
+ const actual = Object.keys(value)
+ assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
+}
+
+export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
+ assertExactKeys(document, ['payload', 'signature'], 'Manifest')
+ const payload = document.payload
+ assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
+ assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
+ parseVersion(payload.version)
+ assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
+ const published = new Date(payload.publishedAt)
+ assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
+ assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
+
+ assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
+ for (const language of ['zh', 'en']) {
+ assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
+ assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
+ }
+
+ assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
+ const platform = payload.platforms['windows-x64']
+ assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
+ assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
+ assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
+ const url = assertHttpUrl(platform.url, 'windows-x64 URL')
+ const filename = path.posix.basename(url.pathname)
+ const expected = artifactUrlFor(baseUrl, payload.version, filename)
+ assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
+ return payload
+}
+
+export function verifySignedManifest(document, publicKeyPem, options = {}) {
+ const payload = validateManifestDocument(document, options)
+ assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
+ return payload
+}
+
+export function serializeSignedManifest(document, options = {}) {
+ validateManifestDocument(document, options)
+ return `${JSON.stringify(document)}\n`
+}
+
+export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
+ try {
+ return await fsp.readFile(filename, 'utf8')
+ } catch (error) {
+ fail(`Unable to read update public key ${filename}: ${error.message}`)
+ }
+}
+
+export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
+ try {
+ return await fsp.readFile(filename, 'utf8')
+ } catch (error) {
+ fail(`Unable to read update signing key ${filename}: ${error.message}`)
+ }
+}
+
+export function runProcess(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd,
+ input: options.input,
+ encoding: 'utf8',
+ windowsHide: true,
+ maxBuffer: 8 * 1024 * 1024
+ })
+ if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
+ if (result.status !== 0) {
+ const detail = String(result.stderr || result.stdout || '').trim()
+ fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
+ }
+ return result.stdout
+}
+
+export function validateSshHost(host = DEFAULT_SSH_HOST) {
+ assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
+ return host
+}
+
+export function runSsh(host, script) {
+ return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
+ input: script
+ })
+}
+
+export async function getProductVersion(installerPath) {
+ const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
+ assert(fs.existsSync(script), `Missing version helper: ${script}`)
+ const output = runProcess('powershell.exe', [
+ '-NoLogo',
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-File',
+ script,
+ '-Path',
+ installerPath
+ ])
+ let parsed
+ try {
+ parsed = JSON.parse(output)
+ } catch {
+ fail('Unable to parse installer version information')
+ }
+ assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
+ return parsed
+}
+
+export async function readPackageMetadata() {
+ const packagePath = path.join(REPO_ROOT, 'package.json')
+ const pkg = await readJson(packagePath)
+ assert(pkg.name === 'ycode', 'package.json is not the YCode package')
+ assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
+ parseVersion(pkg.version)
+ return { path: packagePath, package: pkg }
+}
+
+export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
+ parseVersion(version)
+ assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
+ assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
+
+ let resolved
+ try {
+ resolved = await fsp.realpath(installerPath)
+ } catch (error) {
+ fail(`Unable to resolve installer: ${error.message}`)
+ }
+ const stat = await fsp.stat(resolved)
+ assert(stat.isFile(), 'Installer must be a regular file')
+ assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
+ assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
+
+ const product = await getProductVersion(resolved)
+ const productVersion = normalizeProductVersion(product.productVersion)
+ assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
+ const { path: packagePath, package: pkg } = await readPackageMetadata()
+ assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
+
+ return {
+ path: resolved,
+ filename: path.basename(resolved),
+ size: stat.size,
+ sha256: await sha256File(resolved),
+ productVersion,
+ packagePath
+ }
+}
+
+export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
+ const url = artifactUrlFor(baseUrl, version, installer.filename)
+ return {
+ schemaVersion: 1,
+ version,
+ publishedAt,
+ notes: {
+ zh: notes.zh,
+ en: notes.en
+ },
+ platforms: {
+ 'windows-x64': {
+ url,
+ size: installer.size,
+ sha256: installer.sha256
+ }
+ }
+ }
+}
+
+export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
+ validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
+ const signature = signPayload(payload, privateKeyPem)
+ const document = { payload, signature }
+ assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
+ validateManifestDocument(document, { baseUrl })
+ return document
+}
+
+export function canonicalNow(value = new Date()) {
+ return value.toISOString()
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+function formatBytes(value) {
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
+}
+
+export function renderDownloadPage(payload, options = {}) {
+ validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
+ const platform = payload.platforms['windows-x64']
+ const version = escapeHtml(payload.version)
+ const url = escapeHtml(platform.url)
+ const sha256 = escapeHtml(platform.sha256)
+ const publishedAt = escapeHtml(payload.publishedAt)
+ const zhNotes = escapeHtml(payload.notes.zh)
+ const enNotes = escapeHtml(payload.notes.en)
+ const size = escapeHtml(formatBytes(platform.size))
+ return `
+
+
+
+
+
+
+
+ YCode v${version} 下载
+
+
+
+
+ YCode / Windows x64
+ v${version}
+ 此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。
+
+
+
+
+
+
Release notes
+
${enNotes}
+
+
+
+
+
+
+
+`
+}
+
+export function renderNoReleasePage() {
+ return `
+
+
+
+
+
+
+ YCode 下载
+
+
+YCode
当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。
+
+`
+}
+
+export function usageError(usage, error) {
+ console.error(`${error.message}\n\n${usage}`)
+ process.exitCode = 2
+}
diff --git a/scripts/updates/nsis-safety-regression.mjs b/scripts/updates/nsis-safety-regression.mjs
new file mode 100644
index 0000000..769f51f
--- /dev/null
+++ b/scripts/updates/nsis-safety-regression.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict'
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import { REPO_ROOT } from './lib.mjs'
+
+const filename = path.join(REPO_ROOT, 'build', 'installer.nsh')
+const source = await fsp.readFile(filename, 'utf8')
+
+assert.match(source, /OpenProcess\(i 0x100000, i 0, i R1\) p \.R2 \?e/)
+assert.match(source, /Pop \$R3/)
+assert.doesNotMatch(source, /GetLastError\(\)/)
+assert.match(source, /WaitForSingleObject\(p R2, i 120000\)/)
+assert.match(source, /ElseIf\} \$R3 != 87/)
+
+const waitIndex = source.indexOf('WaitForSingleObject')
+const imageCheckIndex = source.indexOf('SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR"')
+assert.ok(waitIndex >= 0 && imageCheckIndex > waitIndex, 'image check must run after waiting for the parent PID')
+assert.ok(imageCheckIndex > source.indexOf('${ElseIf} $R3 != 87'), 'image check must run after the OpenProcess branch join')
+assert.ok(source.includes('t "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'))
+
+assert.match(source, /nsExec::ExecToStack/)
+assert.match(source, /Get-CimInstance Win32_Process/)
+assert.match(source, /TrimEnd\(\[char\]92\)/)
+assert.match(source, /AddSeconds\(5\)/)
+assert.match(source, /Start-Sleep -Milliseconds 500/)
+assert.match(source, /if \(-not \$\$process\.ExecutablePath\) \{ \$\$found=\$\$true; continue \}/)
+assert.match(source, /Name -ieq '\$\{APP_EXECUTABLE_FILENAME\}'/)
+assert.match(source, /StrCmp \$R4 "0" \+3/)
+
+assert.doesNotMatch(source, /KillProcess|taskkill|Stop-Process|_CHECK_APP_RUNNING/)
+assert.doesNotMatch(source, /GetProcessInfo/)
+assert.doesNotMatch(source, /GetLastError\(\)/)
+assert.doesNotMatch(source, /Name -ieq 'YCode\.exe'/)
+
+console.log('NSIS updater safety checks passed.')
diff --git a/scripts/updates/prepare-release.mjs b/scripts/updates/prepare-release.mjs
new file mode 100644
index 0000000..ceabad8
--- /dev/null
+++ b/scripts/updates/prepare-release.mjs
@@ -0,0 +1,71 @@
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import {
+ DEFAULT_BASE_URL,
+ DEFAULT_PRIVATE_KEY,
+ DEFAULT_PUBLIC_KEY,
+ assertOutsideRepo,
+ buildManifestPayload,
+ canonicalNow,
+ createSignedManifest,
+ normalizeBaseUrl,
+ parseOptions,
+ readPrivateKey,
+ readPublicKey,
+ resolvePath,
+ serializeSignedManifest,
+ usageError,
+ verifyReleaseInstaller,
+ verifySignedManifest,
+ writeFileAtomic
+} from './lib.mjs'
+
+const usage = `Usage:
+ node scripts/updates/prepare-release.mjs --installer --version
+ --notes-zh --notes-en [--base-url ] [--published-at ]
+ [--private-key ] [--public-key ] [--output ]
+
+This validates the actual EXE ProductVersion, current package.json version, size and SHA-256,
+then signs exact JSON.stringify(payload) with Ed25519. It never deploys.
+`
+
+async function main() {
+ const options = parseOptions(process.argv.slice(2), {
+ values: ['installer', 'version', 'notes-zh', 'notes-en', 'base-url', 'published-at', 'private-key', 'public-key', 'output']
+ })
+ if (!options.installer || !options.version || !options['notes-zh'] || !options['notes-en']) {
+ throw new Error('--installer, --version, --notes-zh and --notes-en are required')
+ }
+
+ const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
+ const installerPath = resolvePath(options.installer)
+ const installer = await verifyReleaseInstaller(installerPath, options.version)
+ const output = resolvePath(options.output || path.join('.tmp', 'updates', options.version, 'latest.json'))
+ const privateKeyPath = await assertOutsideRepo(
+ resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
+ 'Private key'
+ )
+ const privateKey = await readPrivateKey(privateKeyPath)
+ const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
+ const publicKey = await readPublicKey(publicKeyPath)
+ const payload = buildManifestPayload({
+ version: options.version,
+ publishedAt: options['published-at'] || canonicalNow(),
+ notes: { zh: options['notes-zh'], en: options['notes-en'] },
+ installer,
+ baseUrl
+ })
+ const document = createSignedManifest({ payload, privateKeyPem: privateKey, publicKeyPem: publicKey, baseUrl })
+ verifySignedManifest(document, publicKey, { baseUrl })
+ await writeFileAtomic(output, serializeSignedManifest(document))
+ await fsp.access(output)
+
+ console.log(`Manifest: ${output}`)
+ console.log(`Version: ${payload.version}`)
+ console.log(`Installer: ${installer.filename}`)
+ console.log(`Size: ${installer.size}`)
+ console.log(`SHA-256: ${installer.sha256}`)
+ console.log(`URL: ${payload.platforms['windows-x64'].url}`)
+}
+
+main().catch((error) => usageError(usage, error))
diff --git a/scripts/updates/provision-server.mjs b/scripts/updates/provision-server.mjs
new file mode 100644
index 0000000..3f801d6
--- /dev/null
+++ b/scripts/updates/provision-server.mjs
@@ -0,0 +1,234 @@
+import {
+ DEFAULT_SSH_HOST,
+ NGINX_MARKER,
+ NGINX_SITE_LINK,
+ NGINX_SNIPPET,
+ SERVER_PUBLIC_ROOT,
+ SERVER_RELEASES_ROOT,
+ SERVER_STAGING_ROOT,
+ parseOptions,
+ renderNoReleasePage,
+ runSsh,
+ usageError,
+ validateSshHost
+} from './lib.mjs'
+
+const usage = `Usage:
+ node scripts/updates/provision-server.mjs [--ssh-host local001]
+
+Creates /srv/ycode, /srv/ycode/releases/versions and /srv/ycode-staging, backs up the
+nginx site configuration, installs a YCode-only snippet, validates nginx, then reloads.
+It never serves an index and never creates a fake latest.json.
+`
+
+const snippet = `${NGINX_MARKER}
+location = /ycode/releases/latest.json {
+ alias ${SERVER_RELEASES_ROOT}/latest.json;
+ default_type application/json;
+ add_header Cache-Control "no-store" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ limit_except GET { deny all; }
+}
+
+location = /ycode/releases/index.html {
+ alias ${SERVER_RELEASES_ROOT}/index.html;
+ default_type text/html;
+ charset utf-8;
+ add_header Cache-Control "no-store" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ limit_except GET { deny all; }
+}
+
+location /ycode/ {
+ alias ${SERVER_PUBLIC_ROOT}/;
+ autoindex off;
+ index index.html;
+ add_header X-Content-Type-Options "nosniff" always;
+ limit_except GET { deny all; }
+}
+`
+
+async function main() {
+ const options = parseOptions(process.argv.slice(2), { values: ['ssh-host'] })
+ const host = validateSshHost(options['ssh-host'] || DEFAULT_SSH_HOST)
+ const snippetBase64 = Buffer.from(snippet, 'utf8').toString('base64')
+ const noReleasePageBase64 = Buffer.from(renderNoReleasePage(), 'utf8').toString('base64')
+ const script = `set -euo pipefail
+export LC_ALL=C
+
+site_link='${NGINX_SITE_LINK}'
+snippet='${NGINX_SNIPPET}'
+site="$(readlink -f "$site_link")"
+test -f "$site"
+test -f /etc/nginx/nginx.conf
+nginx -t >/dev/null 2>&1
+
+install -d -o root -g www-data -m 0755 '${SERVER_PUBLIC_ROOT}'
+install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}'
+install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}/versions'
+install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
+
+page_tmp="$(mktemp '${SERVER_RELEASES_ROOT}/.index.html.XXXXXX')"
+printf '%s' '${noReleasePageBase64}' | base64 -d > "$page_tmp"
+chmod 0644 "$page_tmp"
+if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
+ if [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
+ rm -f "$page_tmp"
+ echo "latest.json exists without a generated download page" >&2
+ exit 1
+ fi
+ if grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
+ rm -f "$page_tmp"
+ echo "latest.json exists while the download page still says no release" >&2
+ exit 1
+ fi
+ rm -f "$page_tmp"
+elif [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
+ mv -f "$page_tmp" '${SERVER_RELEASES_ROOT}/index.html'
+else
+ if ! grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
+ rm -f "$page_tmp"
+ echo "download page exists without latest.json" >&2
+ exit 1
+ fi
+ rm -f "$page_tmp"
+fi
+
+backup_dir=/var/backups/nginx
+install -d -o root -g root -m 0700 "$backup_dir"
+stamp="$(date -u +%Y%m%dT%H%M%SZ)"
+config_backup="$backup_dir/ycode-before-${'$'}{stamp}-$(basename "$site")"
+snippet_backup="$backup_dir/ycode-before-${'$'}{stamp}-ycode-releases.conf"
+test ! -e "$config_backup"
+cp -a "$site" "$config_backup"
+
+snippet_existed=0
+if [ -f "$snippet" ]; then
+ cp -a "$snippet" "$snippet_backup"
+ snippet_existed=1
+fi
+
+restore() {
+ changed=0
+ cp -a "$config_backup" "$site"
+ if [ "$snippet_existed" -eq 1 ]; then
+ cp -a "$snippet_backup" "$snippet"
+ else
+ rm -f "$snippet"
+ fi
+}
+
+changed=0
+snippet_tmp=
+cleanup() {
+ rc=$?
+ if [ -n "$snippet_tmp" ]; then
+ rm -f "$snippet_tmp"
+ fi
+ if [ "$rc" -ne 0 ] && [ "$changed" -eq 1 ]; then
+ restore
+ nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true
+ fi
+ exit "$rc"
+}
+trap cleanup EXIT
+
+install -d -o root -g root -m 0755 /etc/nginx/snippets
+snippet_tmp="$(mktemp /etc/nginx/snippets/.ycode-releases.XXXXXX)"
+printf '%s' '${snippetBase64}' | base64 -d > "$snippet_tmp"
+chmod 0644 "$snippet_tmp"
+mv -f "$snippet_tmp" "$snippet"
+changed=1
+
+python3 - "$site" <<'PY'
+import os
+import re
+import shutil
+import sys
+import tempfile
+
+site = sys.argv[1]
+marker = "${NGINX_MARKER}"
+include = "\tinclude ${NGINX_SNIPPET};"
+with open(site, "r", encoding="utf-8") as stream:
+ original = stream.read()
+
+for required in ("location /baocode/ {", "location /baocode/releases/ {"):
+ if original.count(required) != 1:
+ raise SystemExit(f"refusing to change nginx: expected one {required}")
+
+if marker in original:
+ if include not in original:
+ raise SystemExit("refusing to change nginx: YCode marker exists without include")
+ updated = original
+else:
+ match = re.search(r"(?m)^([ \\t]*)location /baocode/ \\{", original)
+ if not match:
+ raise SystemExit("refusing to change nginx: baocode anchor not found")
+ indent = match.group(1)
+ insertion = f"{indent}{marker}\\n{indent}include ${NGINX_SNIPPET};\\n\\n"
+ updated = original[:match.start()] + insertion + original[match.start():]
+
+if updated != original:
+ directory = os.path.dirname(site)
+ descriptor, temporary = tempfile.mkstemp(prefix=".ycode-nginx-", dir=directory)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
+ stream.write(updated)
+ stream.flush()
+ os.fsync(stream.fileno())
+ shutil.copystat(site, temporary)
+ os.replace(temporary, site)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+
+with open(site, "r", encoding="utf-8") as stream:
+ final = stream.read()
+for required in ("location /baocode/ {", "location /baocode/releases/ {"):
+ if final.count(required) != 1:
+ raise SystemExit("refusing to change nginx: baocode locations changed")
+if marker not in final or include not in final:
+ raise SystemExit("nginx include was not installed")
+PY
+
+if ! nginx -t; then
+ echo "nginx validation failed; configuration restored" >&2
+ exit 1
+fi
+
+if ! systemctl reload nginx; then
+ echo "nginx reload failed; configuration restored" >&2
+ exit 1
+fi
+
+status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/latest.json || true)"
+if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
+ expected_status=200
+else
+ expected_status=404
+fi
+if [ "$status" != "$expected_status" ]; then
+ echo "unexpected /ycode/releases/latest.json status: $status (expected $expected_status)" >&2
+ exit 1
+fi
+
+page_status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/ || true)"
+if [ "$page_status" != "200" ]; then
+ echo "unexpected /ycode/releases/ status: $page_status (expected 200)" >&2
+ exit 1
+fi
+
+printf 'site=%s\\n' "$site"
+printf 'backup=%s\\n' "$config_backup"
+printf 'snippet=%s\\n' "$snippet"
+printf 'latest_status=%s\\n' "$status"
+printf 'page_status=%s\\n' "$page_status"
+`
+
+ const output = runSsh(host, script)
+ process.stdout.write(output)
+ console.log(`Provisioned ${host}; no release manifest was created.`)
+}
+
+main().catch((error) => usageError(usage, error))
diff --git a/scripts/updates/release-selftest.mjs b/scripts/updates/release-selftest.mjs
new file mode 100644
index 0000000..17ce28d
--- /dev/null
+++ b/scripts/updates/release-selftest.mjs
@@ -0,0 +1,641 @@
+import assert from 'node:assert/strict'
+import fsp from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ buildManifestPayload,
+ createSignedManifest,
+ generateEd25519KeyPair,
+ normalizeBaseUrl,
+ serializeSignedManifest,
+ writeFileAtomic
+} from './lib.mjs'
+import {
+ acquireReleaseLock,
+ defaultReleaseNotes,
+ finalizeReleaseRequest,
+ parseReleaseArgs,
+ releasePaths,
+ resolveVersionChoice,
+ runReleaseWorkflow,
+ updateLockText,
+ updatePackageText,
+ verifyPublishedManifest
+} from './release-workflow.mjs'
+
+let passed = 0
+
+async function test(name, callback) {
+ await callback()
+ passed += 1
+ console.log(`PASS ${name}`)
+}
+
+function expectedError(pattern) {
+ return (error) => {
+ assert.match(String(error?.message || error), pattern)
+ return true
+ }
+}
+
+function packageText(version) {
+ return `${JSON.stringify({
+ name: 'ycode',
+ version,
+ private: true,
+ scripts: { build: 'fixture-build' },
+ build: { appId: 'com.ycode.app', productName: 'YCode' }
+ }, null, 2)}\n`
+}
+
+function lockText(version) {
+ return `${JSON.stringify({
+ name: 'ycode',
+ version,
+ lockfileVersion: 3,
+ requires: true,
+ packages: {
+ '': {
+ name: 'ycode',
+ version,
+ private: true,
+ dependencies: { fixture: '1.0.0' }
+ },
+ 'node_modules/fixture': {
+ version: '1.0.0'
+ }
+ }
+ }, null, 2)}\n`
+}
+
+function signedManifest({ version, keys, baseUrl }) {
+ const payload = buildManifestPayload({
+ version,
+ publishedAt: '2026-10-20T10:00:00.000Z',
+ notes: defaultReleaseNotes(version),
+ installer: {
+ filename: `YCode-${version}-Setup-x64.exe`,
+ size: 1,
+ sha256: 'a'.repeat(64)
+ },
+ baseUrl
+ })
+ const document = createSignedManifest({
+ payload,
+ privateKeyPem: keys.privatePem,
+ publicKeyPem: keys.publicPem,
+ baseUrl
+ })
+ return {
+ document,
+ text: serializeSignedManifest(document, { baseUrl })
+ }
+}
+
+async function makeRepository({ version = '1.0.0' } = {}) {
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-selftest-'))
+ await fsp.writeFile(path.join(root, 'package.json'), packageText(version), 'utf8')
+ await fsp.writeFile(path.join(root, 'package-lock.json'), lockText(version), 'utf8')
+ return root
+}
+
+function requestFor({ version, notes, privateKeyPath, publicKeyPath, resume = false }) {
+ return {
+ help: false,
+ resume,
+ version,
+ notes: notes || defaultReleaseNotes(version),
+ sshHost: 'fake-local001',
+ baseUrl: normalizeBaseUrl('http://127.0.0.1/ycode/releases/'),
+ privateKeyPath,
+ publicKeyPath
+ }
+}
+
+function makeDependencies({
+ root,
+ keys,
+ current,
+ events,
+ overrides = {}
+}) {
+ const paths = releasePaths(root, current?.version || '9.9.9')
+ const deps = {
+ root,
+ pathExists: (filename) => fsp.access(filename).then(() => true, () => false),
+ realpath: (filename) => fsp.realpath(filename),
+ readFile: (filename, encoding) => fsp.readFile(filename, encoding),
+ writeFileAtomic,
+ readPrivateKey: async () => keys.privatePem,
+ readPublicKey: async () => keys.publicPem,
+ validateBundle: async ({ manifestPath }) => {
+ events.push('bundle-validate')
+ const document = JSON.parse(await fsp.readFile(manifestPath, 'utf8'))
+ return { document }
+ },
+ acquireReleaseLock: async () => {
+ events.push('lock-acquire')
+ return async () => {
+ events.push('lock-release')
+ }
+ },
+ checkToolchain: async () => {
+ events.push('toolchain-check')
+ },
+ checkSsh: async () => {
+ events.push('ssh-check')
+ },
+ readLatest: async () => {
+ events.push('server-read')
+ return current ? { raw: current.text, document: current.document } : { raw: null, document: null }
+ },
+ runNpm: async (args) => {
+ events.push(`npm:${args.join(' ')}`)
+ },
+ runNode: async (args) => {
+ events.push(`node:${args.join(' ')}`)
+ if (args.some((value) => String(value).endsWith('prepare-release.mjs'))) {
+ const version = args[args.indexOf('--version') + 1]
+ const output = args[args.indexOf('--output') + 1]
+ await writeFileAtomic(output, signedManifest({ version, keys, baseUrl: 'http://127.0.0.1/ycode/releases/' }).text)
+ }
+ },
+ verifyHttp: async () => {
+ events.push('http-verify')
+ },
+ logWarning: (message) => events.push(`warning:${message}`),
+ ...overrides
+ }
+ deps.paths = paths
+ return deps
+}
+
+async function main() {
+ const temporaryDirectories = []
+ let signingKeyProbePath
+ try {
+ const keyProbeDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-key-probe-'))
+ temporaryDirectories.push(keyProbeDirectory)
+ signingKeyProbePath = path.join(keyProbeDirectory, 'signing.key')
+ await fsp.writeFile(signingKeyProbePath, 'fixture key location only', 'utf8')
+
+ await test('stable version input rejects ambiguous and non-release versions', async () => {
+ for (const value of ['01.0.0', '1.01.0', '1.0.01', '1.0.0-rc.1', '1.0.0+build', 'v1.0.0', '1.0']) {
+ await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /stable x\.y\.z/)
+ }
+ for (const value of ['65536.0.0', '1.65536.0', '1.0.65536']) {
+ await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /Windows EXE metadata/)
+ }
+ assert.equal(
+ await resolveVersionChoice({ version: '1.2.3', prompt: async () => assert.fail('prompt should not run') }),
+ '1.2.3'
+ )
+ })
+
+ await test('blank interactive version cancels without starting a release', async () => {
+ assert.equal(await resolveVersionChoice({ prompt: async () => ' ' }), null)
+ })
+
+ await test('CLI defaults and unknown option handling are deterministic', async () => {
+ const parsed = parseReleaseArgs(['--version', '1.2.3'])
+ assert.equal(parsed.version, '1.2.3')
+ assert.deepEqual(parsed.notes, {})
+ const finalized = finalizeReleaseRequest(parsed, parsed.version)
+ assert.deepEqual(finalized.notes, {
+ zh: 'YCode 1.2.3 更新',
+ en: 'YCode 1.2.3 update'
+ })
+ assert.equal(parsed.sshHost, 'local001')
+ await assert.rejects(async () => parseReleaseArgs(['--dry-run']), /Unknown option/)
+ await assert.rejects(async () => parseReleaseArgs(['--public-key', 'other.pem']), /Unknown option/)
+ await assert.rejects(async () => parseReleaseArgs(['--resume']), /requires an explicit --version/)
+ assert.equal(parseReleaseArgs(['--help']).help, true)
+ })
+
+ await test('interactive version supplies notes from the actual input version', async () => {
+ const parsed = parseReleaseArgs([])
+ const version = await resolveVersionChoice({ version: parsed.version, prompt: async () => '0.0.6' })
+ const finalized = finalizeReleaseRequest(parsed, version)
+ assert.deepEqual(finalized.notes, {
+ zh: 'YCode 0.0.6 更新',
+ en: 'YCode 0.0.6 update'
+ })
+ })
+
+ await test('package and lock version updates preserve unrelated JSON content', async () => {
+ const nextPackage = updatePackageText(packageText('1.0.0'), '1.0.1')
+ const nextLock = updateLockText(lockText('1.0.0'), '1.0.1')
+ const pkg = JSON.parse(nextPackage)
+ const lock = JSON.parse(nextLock)
+ assert.equal(pkg.version, '1.0.1')
+ assert.equal(pkg.scripts.build, 'fixture-build')
+ assert.equal(pkg.build.appId, 'com.ycode.app')
+ assert.equal(lock.version, '1.0.1')
+ assert.equal(lock.packages[''].version, '1.0.1')
+ assert.equal(lock.packages['node_modules/fixture'].version, '1.0.0')
+ })
+
+ const normalRoot = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(normalRoot)
+ await test('normal release follows the required stage and command order', async () => {
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const current = signedManifest({ version: '1.0.0', keys, baseUrl })
+ const events = []
+ const deps = makeDependencies({
+ root: normalRoot,
+ keys,
+ current: { ...current, version: '1.0.0' },
+ events
+ })
+ const request = requestFor({
+ version: '1.0.1',
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ })
+ const result = await runReleaseWorkflow(request, deps)
+ assert.equal(result.version, '1.0.1')
+ const paths = releasePaths(normalRoot, '1.0.1')
+ assert.deepEqual(events.slice(0, 7), [
+ 'lock-acquire',
+ 'toolchain-check',
+ 'ssh-check',
+ 'server-read',
+ 'npm:run test:updates',
+ 'npm:run build',
+ `node:${paths.builderCli} --win nsis --x64 --publish never --config.directories.output=release/publish/1.0.1`
+ ])
+ assert.match(events[7], /prepare-release\.mjs/)
+ assert.match(events[8], /deploy\.mjs.*--dry-run/)
+ assert.match(events[9], /deploy\.mjs/)
+ assert.doesNotMatch(events[9], /--dry-run/)
+ assert.deepEqual(events.slice(10), ['http-verify', 'lock-release'])
+ assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package.json'), 'utf8')).version, '1.0.1')
+ assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package-lock.json'), 'utf8')).version, '1.0.1')
+ })
+
+ await test('server/latest and repository versions are rejected before build', async () => {
+ for (const scenario of [
+ { repo: '1.0.0', server: '1.0.0', target: '1.0.0', pattern: /strictly newer/ },
+ { repo: '1.0.0', server: '0.9.0', target: '0.9.9', pattern: /repository version is newer/ }
+ ]) {
+ const root = await makeRepository({ version: scenario.repo })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const server = signedManifest({ version: scenario.server, keys, baseUrl })
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: { ...server, version: scenario.server },
+ events
+ })
+ const before = await fsp.readFile(path.join(root, 'package.json'), 'utf8')
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: scenario.target,
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ expectedError(scenario.pattern)
+ )
+ assert.equal(await fsp.readFile(path.join(root, 'package.json'), 'utf8'), before)
+ assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
+ assert.equal(events.at(-1), 'lock-release')
+ }
+ })
+
+ await test('missing signing key fails in preflight without build or deploy', async () => {
+ const root = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: null,
+ events,
+ overrides: {
+ pathExists: async (filename) =>
+ filename === path.join(os.tmpdir(), 'missing-signing.key')
+ ? false
+ : fsp.access(filename).then(() => true, () => false),
+ readPrivateKey: async () => {
+ throw new Error('Unable to read update signing key')
+ }
+ }
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ privateKeyPath: path.join(os.tmpdir(), 'missing-signing.key'),
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'preflight' && /Signing key/.test(error.message)
+ )
+ assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('mismatched private and public keys fail before any external stage', async () => {
+ const root = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(root)
+ const privateKeys = generateEd25519KeyPair()
+ const publicKeys = generateEd25519KeyPair()
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys: privateKeys,
+ current: null,
+ events,
+ overrides: {
+ readPublicKey: async () => publicKeys.publicPem
+ }
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'preflight' && /does not match/.test(error.message)
+ )
+ assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('private key canonical path cannot resolve into the repository', async () => {
+ const root = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: null,
+ events,
+ overrides: {
+ realpath: async () => path.join(root, 'linked-signing.key')
+ }
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'preflight' && /resolves inside the repository/.test(error.message)
+ )
+ assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('a repository path beginning with ..foo is not treated as external', async () => {
+ const root = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: null,
+ events
+ })
+ const nestedKey = path.join(root, '..foo', 'signing.key')
+ await fsp.mkdir(path.dirname(nestedKey), { recursive: true })
+ await fsp.writeFile(nestedKey, 'fixture inside-repository key', 'utf8')
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ privateKeyPath: nestedKey,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'preflight' && /must be outside the repository/.test(error.message)
+ )
+ assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('a failed build stops before signing and deploy while retaining local version', async () => {
+ const root = await makeRepository({ version: '1.0.0' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const current = signedManifest({ version: '1.0.0', keys, baseUrl })
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: { ...current, version: '1.0.0' },
+ events,
+ overrides: {
+ runNpm: async (args) => {
+ events.push(`npm:${args.join(' ')}`)
+ if (args.join(' ') === 'run build') throw new Error('fixture build failure')
+ }
+ }
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'build' && /fixture build failure/.test(error.message)
+ )
+ assert(!events.some((event) => event.includes('prepare-release') || event.includes('deploy.mjs')))
+ assert.equal(JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8')).version, '1.0.1')
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('resume revalidates the existing bundle and never rebuilds or re-signs', async () => {
+ const root = await makeRepository({ version: '1.0.1' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const local = signedManifest({ version: '1.0.1', keys, baseUrl })
+ const paths = releasePaths(root, '1.0.1')
+ await fsp.mkdir(paths.outputDir, { recursive: true })
+ await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
+ await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: { ...local, version: '1.0.1' },
+ events
+ })
+ await runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ resume: true,
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ )
+ assert(events.includes('bundle-validate'))
+ assert(!events.some((event) => event.startsWith('npm:') || event.includes('prepare-release')))
+ assert.equal(events.filter((event) => event.includes('deploy.mjs')).length, 2)
+ assert.equal(events.at(-2), 'http-verify')
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('HTTP verification failure is reported as a failed release', async () => {
+ const root = await makeRepository({ version: '1.0.1' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const local = signedManifest({ version: '1.0.1', keys, baseUrl })
+ const paths = releasePaths(root, '1.0.1')
+ await fsp.mkdir(paths.outputDir, { recursive: true })
+ await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
+ await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: null,
+ events,
+ overrides: {
+ verifyHttp: async () => {
+ events.push('http-verify')
+ throw new Error('fixture HTTP mismatch')
+ }
+ }
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ resume: true,
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'verify-http' && /HTTP mismatch/.test(error.message)
+ )
+ assert(events.some((event) => event.includes('deploy.mjs')))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('resume permits the same server version but rejects a newer server latest', async () => {
+ const root = await makeRepository({ version: '1.0.1' })
+ temporaryDirectories.push(root)
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const local = signedManifest({ version: '1.0.1', keys, baseUrl })
+ const newer = signedManifest({ version: '1.0.2', keys, baseUrl })
+ const paths = releasePaths(root, '1.0.1')
+ await fsp.mkdir(paths.outputDir, { recursive: true })
+ await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
+ await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
+ const events = []
+ const deps = makeDependencies({
+ root,
+ keys,
+ current: { ...newer, version: '1.0.2' },
+ events
+ })
+ await assert.rejects(
+ runReleaseWorkflow(
+ requestFor({
+ version: '1.0.1',
+ resume: true,
+ privateKeyPath: signingKeyProbePath,
+ publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
+ }),
+ deps
+ ),
+ (error) => error.stage === 'preflight' && /server latest is newer/.test(error.message)
+ )
+ assert(!events.some((event) => event.includes('deploy.mjs')))
+ assert.equal(events.at(-1), 'lock-release')
+ })
+
+ await test('release lock is mutually exclusive and released explicitly', async () => {
+ const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-lock-selftest-'))
+ temporaryDirectories.push(directory)
+ const lockPath = path.join(directory, '.tmp', 'updates', 'release.lock')
+ const release = await acquireReleaseLock(lockPath, { version: '1.0.1' })
+ await assert.rejects(acquireReleaseLock(lockPath, { version: '1.0.2' }), /already exists/)
+ assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), true)
+ await release()
+ assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), false)
+ const second = await acquireReleaseLock(lockPath, { version: '1.0.2' })
+ await second()
+ })
+
+ await test('HTTP verification checks no-cache request, exact bytes and signature', async () => {
+ const keys = generateEd25519KeyPair()
+ const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
+ const local = signedManifest({ version: '1.0.2', keys, baseUrl })
+ let requestOptions
+ const response = new Response(local.text, {
+ status: 200,
+ headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(local.text)) }
+ })
+ const document = await verifyPublishedManifest({
+ baseUrl,
+ expectedManifestText: local.text,
+ publicKeyPem: keys.publicPem,
+ version: '1.0.2',
+ fetchImpl: async (_url, options) => {
+ requestOptions = options
+ return response
+ }
+ })
+ assert.equal(document.payload.version, '1.0.2')
+ assert.equal(requestOptions.cache, 'no-store')
+ assert.equal(requestOptions.credentials, 'omit')
+ assert.equal(requestOptions.redirect, 'error')
+ assert.equal(requestOptions.headers.pragma, 'no-cache')
+
+ await assert.rejects(
+ verifyPublishedManifest({
+ baseUrl,
+ expectedManifestText: local.text,
+ publicKeyPem: keys.publicPem,
+ version: '1.0.2',
+ fetchImpl: async () =>
+ new Response(local.text.replace('YCode 1.0.2', 'YCode 1.0.3'), {
+ status: 200,
+ headers: { 'content-length': String(Buffer.byteLength(local.text)) }
+ })
+ }),
+ /does not exactly match/
+ )
+ })
+
+ console.log(`\n${passed} release workflow self-tests passed. No SSH, network HTTP, build, installer, signing, deployment, repository version change or production release was performed.`)
+ } finally {
+ for (const directory of temporaryDirectories) {
+ await fsp.rm(directory, { recursive: true, force: true })
+ }
+ }
+}
+
+main().catch((error) => {
+ console.error(error)
+ process.exitCode = 1
+})
diff --git a/scripts/updates/release-workflow.mjs b/scripts/updates/release-workflow.mjs
new file mode 100644
index 0000000..e183111
--- /dev/null
+++ b/scripts/updates/release-workflow.mjs
@@ -0,0 +1,591 @@
+import crypto from 'node:crypto'
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import {
+ DEFAULT_BASE_URL,
+ DEFAULT_PRIVATE_KEY,
+ DEFAULT_PUBLIC_KEY,
+ DEFAULT_SSH_HOST,
+ assert,
+ assertOutsideRepo,
+ compareVersions,
+ createPrivateKey,
+ createPublicKey,
+ normalizeBaseUrl,
+ parseVersion,
+ verifySignedManifest
+} from './lib.mjs'
+
+export const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
+export const RELEASE_LOCK_RELATIVE = path.join('.tmp', 'updates', 'release.lock')
+export const MAX_HTTP_MANIFEST_BYTES = 1024 * 1024
+
+const CLI_VALUE_OPTIONS = new Set([
+ 'version',
+ 'notes-zh',
+ 'notes-en',
+ 'ssh-host',
+ 'base-url',
+ 'private-key'
+])
+
+export class ReleaseError extends Error {
+ constructor(stage, message, options = {}) {
+ super(message, options)
+ this.name = 'ReleaseError'
+ this.stage = stage
+ }
+}
+
+export function toReleaseError(stage, error) {
+ if (error instanceof ReleaseError) return error
+ return new ReleaseError(stage, String(error?.message || error), { cause: error })
+}
+
+async function runStage(stage, action) {
+ try {
+ return await action()
+ } catch (error) {
+ throw toReleaseError(stage, error)
+ }
+}
+
+export function validateStableVersion(value) {
+ const version = String(value ?? '')
+ assert(
+ STABLE_VERSION_RE.test(version),
+ 'Release version must be a stable x.y.z version without a leading zero, prerelease or build metadata'
+ )
+ parseVersion(version)
+ for (const segment of version.split('.').map(BigInt)) {
+ assert(segment <= 65_535n, 'Release version segments must be between 0 and 65535 for Windows EXE metadata')
+ }
+ return version
+}
+
+export function defaultReleaseNotes(version) {
+ validateStableVersion(version)
+ return {
+ zh: `YCode ${version} 更新`,
+ en: `YCode ${version} update`
+ }
+}
+
+export async function resolveVersionChoice({ version, prompt }) {
+ if (version !== undefined) return validateStableVersion(version)
+ const answer = await prompt()
+ if (!answer || !answer.trim()) return null
+ return validateStableVersion(answer.trim())
+}
+
+export function parseReleaseArgs(argv, { cwd = process.cwd() } = {}) {
+ const raw = {}
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`)
+ const equals = argument.indexOf('=')
+ const name = argument.slice(2, equals === -1 ? undefined : equals)
+ if (name === 'help' || name === 'resume') {
+ assert(equals === -1, `--${name} does not accept a value`)
+ assert(!(name in raw), `Option --${name} was provided more than once`)
+ raw[name] = true
+ continue
+ }
+ assert(CLI_VALUE_OPTIONS.has(name), `Unknown option: --${name}`)
+ assert(!(name in raw), `Option --${name} was provided more than once`)
+ const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
+ assert(value !== undefined && value !== '', `Missing value for --${name}`)
+ raw[name] = value
+ }
+
+ if (raw.help) {
+ return { help: true }
+ }
+ if (raw.resume) {
+ assert(raw.version, '--resume requires an explicit --version')
+ assert(!raw['notes-zh'] && !raw['notes-en'], '--notes-zh and --notes-en cannot be used with --resume')
+ }
+
+ const version = raw.version === undefined ? undefined : validateStableVersion(raw.version)
+ const notes = {}
+ if (raw['notes-zh'] !== undefined) notes.zh = raw['notes-zh'].trim()
+ if (raw['notes-en'] !== undefined) notes.en = raw['notes-en'].trim()
+ if ('zh' in notes) assert(notes.zh, '--notes-zh must not be empty')
+ if ('en' in notes) assert(notes.en, '--notes-en must not be empty')
+
+ return {
+ help: false,
+ resume: raw.resume === true,
+ version,
+ notes,
+ sshHost: raw['ssh-host'] || DEFAULT_SSH_HOST,
+ baseUrl: normalizeBaseUrl(raw['base-url'] || DEFAULT_BASE_URL),
+ privateKeyPath: path.resolve(cwd, raw['private-key'] || DEFAULT_PRIVATE_KEY),
+ publicKeyPath: path.resolve(cwd, DEFAULT_PUBLIC_KEY)
+ }
+}
+
+export function finalizeReleaseRequest(parsed, version) {
+ validateStableVersion(version)
+ const defaults = defaultReleaseNotes(version)
+ return {
+ ...parsed,
+ version,
+ notes: {
+ zh: parsed.notes?.zh ?? defaults.zh,
+ en: parsed.notes?.en ?? defaults.en
+ }
+ }
+}
+
+export function releasePaths(root, version) {
+ validateStableVersion(version)
+ const outputDir = path.join(root, 'release', 'publish', version)
+ return {
+ root,
+ packagePath: path.join(root, 'package.json'),
+ lockPath: path.join(root, RELEASE_LOCK_RELATIVE),
+ outputDir,
+ installerPath: path.join(outputDir, `YCode-${version}-Setup-x64.exe`),
+ manifestPath: path.join(outputDir, 'latest.json'),
+ builderCli: path.join(root, 'node_modules', 'electron-builder', 'cli.js'),
+ prepareScript: path.join(root, 'scripts', 'updates', 'prepare-release.mjs'),
+ deployScript: path.join(root, 'scripts', 'updates', 'deploy.mjs')
+ }
+}
+
+function isOutside(root, filename) {
+ const relative = path.relative(path.resolve(root), path.resolve(filename))
+ return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
+}
+
+async function canonicalPrivateKeyPath({ root, filename, deps }) {
+ const lexical = path.resolve(filename)
+ assert(isOutside(root, lexical), `Private key must be outside the repository: ${lexical}`)
+ await assertOutsideRepo(lexical, 'Private key')
+ if (!(await deps.pathExists(lexical))) throw new Error(`Signing key not found: ${lexical}`)
+ const canonical = await deps.realpath(lexical)
+ assert(isOutside(root, canonical), `Private key resolves inside the repository: ${canonical}`)
+ await assertOutsideRepo(canonical, 'Private key')
+ return canonical
+}
+
+export function assertKeyPairMatches(privatePem, publicPem) {
+ const privateKey = createPrivateKey(privatePem)
+ const derived = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
+ const supplied = createPublicKey(publicPem).export({ type: 'spki', format: 'der' })
+ assert(derived.equals(supplied), 'Signing private key does not match the update public key')
+}
+
+function parseJson(text, label) {
+ try {
+ return JSON.parse(text)
+ } catch (error) {
+ throw new Error(`${label} is not valid JSON: ${error.message}`)
+ }
+}
+
+function jsonFormat(text) {
+ const newline = text.includes('\r\n') ? '\r\n' : '\n'
+ const indentMatch = text.match(/\n([ \t]+)"/)
+ return {
+ indent: indentMatch?.[1] || ' ',
+ newline,
+ trailingNewline: text.endsWith('\n')
+ }
+}
+
+function rewriteJson(text, mutate) {
+ const value = parseJson(text, 'JSON document')
+ mutate(value)
+ const format = jsonFormat(text)
+ let serialized = JSON.stringify(value, null, format.indent).replace(/\n/g, format.newline)
+ if (format.trailingNewline) serialized += format.newline
+ return serialized
+}
+
+export function updatePackageText(text, version) {
+ validateStableVersion(version)
+ return rewriteJson(text, (document) => {
+ assert(document.name === 'ycode', 'package.json is not the YCode package')
+ assert(document.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
+ document.version = version
+ })
+}
+
+export function updateLockText(text, version) {
+ validateStableVersion(version)
+ return rewriteJson(text, (document) => {
+ assert(document && typeof document === 'object', 'package-lock.json must be an object')
+ assert(
+ document.packages && typeof document.packages === 'object',
+ 'package-lock.json has no root package entry'
+ )
+ assert(
+ document.packages[''] && typeof document.packages[''] === 'object',
+ 'package-lock.json has no packages[""] entry'
+ )
+ document.version = version
+ document.packages[''].version = version
+ })
+}
+
+function packageVersions(packageText, lockText) {
+ const pkg = parseJson(packageText, 'package.json')
+ const lock = parseJson(lockText, 'package-lock.json')
+ assert(pkg.name === 'ycode', 'package.json is not the YCode package')
+ assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
+ const version = validateStableVersion(pkg.version)
+ assert(
+ lock.version !== undefined && lock.packages?.['']?.version !== undefined,
+ 'package-lock.json must contain top-level and packages[""] versions'
+ )
+ assert(lock.version === version, 'package-lock.json top-level version does not match package.json')
+ assert(lock.packages[''].version === version, 'package-lock.json root version does not match package.json')
+ return { pkg, lock, version }
+}
+
+function sameManifest(left, right) {
+ return JSON.stringify(left) === JSON.stringify(right)
+}
+
+function parseSignedManifest(text, publicKeyPem, baseUrl, label) {
+ const document = parseJson(text, label)
+ verifySignedManifest(document, publicKeyPem, { baseUrl })
+ return document
+}
+
+export async function preflightRelease({ request, paths, deps }) {
+ const publicKeyPath = path.resolve(request.publicKeyPath)
+ const privateKeyPath = await canonicalPrivateKeyPath({
+ root: paths.root,
+ filename: request.privateKeyPath,
+ deps
+ })
+
+ const privatePem = await deps.readPrivateKey(privateKeyPath)
+ const publicPem = await deps.readPublicKey(publicKeyPath)
+ assertKeyPairMatches(privatePem, publicPem)
+
+ const packageLockPath = path.join(paths.root, 'package-lock.json')
+ for (const [label, filename] of [
+ ['package.json', paths.packagePath],
+ ['package-lock.json', packageLockPath]
+ ]) {
+ assert(await deps.pathExists(filename), `${label} not found: ${filename}`)
+ }
+
+ const packageText = await deps.readFile(paths.packagePath, 'utf8')
+ const lockText = await deps.readFile(packageLockPath, 'utf8')
+ const versions = packageVersions(packageText, lockText)
+
+ if (request.resume) {
+ assert(
+ versions.version === request.version,
+ `--resume requires repository version ${request.version}; found ${versions.version}`
+ )
+ assert(await deps.pathExists(paths.outputDir), `Resume output directory not found: ${paths.outputDir}`)
+ assert(await deps.pathExists(paths.installerPath), `Resume installer not found: ${paths.installerPath}`)
+ assert(await deps.pathExists(paths.manifestPath), `Resume manifest not found: ${paths.manifestPath}`)
+ } else {
+ const compared = compareVersions(request.version, versions.version)
+ assert(
+ compared >= 0,
+ `Refusing to release ${request.version}; repository version is newer (${versions.version})`
+ )
+ assert(
+ !(await deps.pathExists(paths.outputDir)),
+ `Refusing to build an existing release directory: ${paths.outputDir}`
+ )
+ }
+
+ if (deps.checkToolchain) await deps.checkToolchain(paths, { resume: request.resume })
+ await deps.checkSsh(request.sshHost)
+ const current = await deps.readLatest({
+ host: request.sshHost,
+ publicKeyPem: publicPem,
+ baseUrl: request.baseUrl
+ })
+
+ let currentVersion = null
+ if (current?.document) {
+ verifySignedManifest(current.document, publicPem, { baseUrl: request.baseUrl })
+ currentVersion = current.document.payload.version
+ const compared = compareVersions(request.version, currentVersion)
+ if (request.resume) {
+ assert(
+ compared >= 0,
+ `Refusing resume for ${request.version}; server latest is newer (${currentVersion})`
+ )
+ } else {
+ assert(
+ compared > 0,
+ `Release version ${request.version} must be strictly newer than server latest ${currentVersion}`
+ )
+ }
+ }
+
+ let localManifestText = null
+ let localManifest = null
+ if (request.resume) {
+ localManifestText = await deps.readFile(paths.manifestPath, 'utf8')
+ localManifest = parseSignedManifest(localManifestText, publicPem, request.baseUrl, 'Local latest.json')
+ assert(
+ localManifest.payload.version === request.version,
+ `Resume manifest version ${localManifest.payload.version} does not match ${request.version}`
+ )
+ if (currentVersion === request.version) {
+ assert(
+ sameManifest(current.document, localManifest),
+ `Server already has a different ${request.version} manifest`
+ )
+ }
+ const bundle = await deps.validateBundle({
+ manifestPath: paths.manifestPath,
+ installerPath: paths.installerPath,
+ publicKeyPath,
+ baseUrl: request.baseUrl
+ })
+ assert(
+ bundle?.document?.payload?.version === request.version,
+ `Revalidated bundle version does not match ${request.version}`
+ )
+ }
+
+ return {
+ repoVersion: versions.version,
+ privateKeyPath,
+ packageText,
+ lockText,
+ publicPem,
+ currentVersion,
+ currentRaw: current?.raw ?? null,
+ localManifestText,
+ localManifest
+ }
+}
+
+async function updateTargetVersions({ request, paths, preflight, deps }) {
+ if (request.resume) return { changed: false }
+ const nextPackageText = updatePackageText(preflight.packageText, request.version)
+ const nextLockText = updateLockText(preflight.lockText, request.version)
+ let changed = false
+ if (nextPackageText !== preflight.packageText) {
+ await deps.writeFileAtomic(paths.packagePath, nextPackageText)
+ changed = true
+ }
+ if (nextLockText !== preflight.lockText) {
+ await deps.writeFileAtomic(path.join(paths.root, 'package-lock.json'), nextLockText)
+ changed = true
+ }
+ return { changed }
+}
+
+export async function acquireReleaseLock(lockPath, { version } = {}) {
+ await fsp.mkdir(path.dirname(lockPath), { recursive: true })
+ const token = crypto.randomBytes(16).toString('hex')
+ let handle
+ let created = false
+ try {
+ handle = await fsp.open(lockPath, 'wx', 0o600)
+ created = true
+ await handle.writeFile(
+ `${JSON.stringify({
+ token,
+ pid: process.pid,
+ version: version || null,
+ startedAt: new Date().toISOString()
+ })}\n`,
+ 'utf8'
+ )
+ await handle.sync()
+ } catch (error) {
+ if (handle) await handle.close().catch(() => {})
+ if (created) await fsp.rm(lockPath, { force: true }).catch(() => {})
+ if (error?.code === 'EEXIST') {
+ throw new Error(`Release lock already exists: ${lockPath}`)
+ }
+ throw error
+ }
+ await handle.close()
+
+ let released = false
+ return async function releaseLock() {
+ if (released) return
+ let raw
+ try {
+ raw = await fsp.readFile(lockPath, 'utf8')
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ released = true
+ return
+ }
+ throw error
+ }
+ let record
+ try {
+ record = JSON.parse(raw)
+ } catch {
+ throw new Error(`Release lock was replaced or corrupted: ${lockPath}`)
+ }
+ assert(record.token === token, `Release lock ownership changed: ${lockPath}`)
+ await fsp.rm(lockPath, { force: true })
+ released = true
+ }
+}
+
+async function readResponseLimited(response, maxBytes) {
+ const declared = Number(response.headers.get('content-length'))
+ if (Number.isFinite(declared) && declared > maxBytes) {
+ throw new Error(`latest.json exceeds ${maxBytes} bytes`)
+ }
+ if (!response.body) return Buffer.alloc(0)
+ const reader = response.body.getReader()
+ const chunks = []
+ let total = 0
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ total += value.byteLength
+ if (total > maxBytes) {
+ await reader.cancel()
+ throw new Error(`latest.json exceeds ${maxBytes} bytes`)
+ }
+ chunks.push(Buffer.from(value))
+ }
+ return Buffer.concat(chunks, total)
+}
+
+export async function verifyPublishedManifest({
+ baseUrl,
+ expectedManifestText,
+ publicKeyPem,
+ version,
+ fetchImpl = globalThis.fetch,
+ timeoutMs = 30_000,
+ maxBytes = MAX_HTTP_MANIFEST_BYTES
+}) {
+ validateStableVersion(version)
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
+ assert(typeof fetchImpl === 'function', 'fetch is not available')
+ const manifestUrl = new URL('latest.json', normalizedBaseUrl)
+ const response = await fetchImpl(manifestUrl, {
+ method: 'GET',
+ cache: 'no-store',
+ credentials: 'omit',
+ redirect: 'error',
+ referrerPolicy: 'no-referrer',
+ headers: {
+ accept: 'application/json',
+ 'cache-control': 'no-cache',
+ pragma: 'no-cache'
+ },
+ signal: AbortSignal.timeout(timeoutMs)
+ })
+ assert(response.status === 200, `latest.json HTTP status is ${response.status}, expected 200`)
+ const expected = Buffer.from(expectedManifestText, 'utf8')
+ assert(expected.length <= maxBytes, 'Local signed manifest exceeds the HTTP verification limit')
+ const actual = await readResponseLimited(response, maxBytes)
+ assert(actual.equals(expected), 'HTTP latest.json does not exactly match the local signed manifest')
+ const document = parseSignedManifest(actual.toString('utf8'), publicKeyPem, normalizedBaseUrl, 'HTTP latest.json')
+ assert(document.payload.version === version, `HTTP latest.json version is ${document.payload.version}, expected ${version}`)
+ return document
+}
+
+function deployArgs({ request, paths }) {
+ return [
+ '--manifest',
+ paths.manifestPath,
+ '--installer',
+ paths.installerPath,
+ '--transport',
+ 'ssh',
+ '--base-url',
+ request.baseUrl,
+ '--public-key',
+ request.publicKeyPath,
+ '--ssh-host',
+ request.sshHost
+ ]
+}
+
+export async function runReleaseWorkflow(request, deps) {
+ validateStableVersion(request.version)
+ const paths = releasePaths(deps.root, request.version)
+ let releaseLock
+ let primaryError = null
+
+ releaseLock = await runStage('lock', () => deps.acquireReleaseLock(paths.lockPath, { version: request.version }))
+ assert(typeof releaseLock === 'function', 'Release lock must return a release function')
+
+ try {
+ const preflight = await runStage('preflight', () => preflightRelease({ request, paths, deps }))
+ if (!request.resume) {
+ await runStage('version-update', () => updateTargetVersions({ request, paths, preflight, deps }))
+ await runStage('test-updates', () => deps.runNpm(['run', 'test:updates']))
+ await runStage('build', () => deps.runNpm(['run', 'build']))
+ await runStage('package', () =>
+ deps.runNode([
+ paths.builderCli,
+ '--win',
+ 'nsis',
+ '--x64',
+ '--publish',
+ 'never',
+ `--config.directories.output=${path.relative(paths.root, paths.outputDir).replaceAll(path.sep, '/')}`
+ ])
+ )
+ await runStage('sign', () =>
+ deps.runNode([
+ paths.prepareScript,
+ '--installer',
+ paths.installerPath,
+ '--version',
+ request.version,
+ '--notes-zh',
+ request.notes.zh,
+ '--notes-en',
+ request.notes.en,
+ '--base-url',
+ request.baseUrl,
+ '--private-key',
+ preflight.privateKeyPath,
+ '--public-key',
+ request.publicKeyPath,
+ '--output',
+ paths.manifestPath
+ ])
+ )
+ }
+
+ await runStage('deploy-dry-run', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths }), '--dry-run']))
+ await runStage('deploy', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths })]))
+ await runStage('verify-http', async () => {
+ const expectedManifestText =
+ preflight.localManifestText || (await deps.readFile(paths.manifestPath, 'utf8'))
+ await deps.verifyHttp({
+ baseUrl: request.baseUrl,
+ expectedManifestText,
+ publicKeyPem: preflight.publicPem,
+ version: request.version
+ })
+ })
+
+ return {
+ version: request.version,
+ outputDir: paths.outputDir,
+ installerPath: paths.installerPath,
+ manifestPath: paths.manifestPath
+ }
+ } catch (error) {
+ primaryError = toReleaseError('workflow', error)
+ throw primaryError
+ } finally {
+ try {
+ await releaseLock()
+ } catch (error) {
+ if (!primaryError) throw toReleaseError('lock-release', error)
+ if (deps.logWarning) deps.logWarning(`Release lock cleanup failed: ${error.message}`)
+ }
+ }
+}
diff --git a/scripts/updates/release.mjs b/scripts/updates/release.mjs
new file mode 100644
index 0000000..29c65c5
--- /dev/null
+++ b/scripts/updates/release.mjs
@@ -0,0 +1,169 @@
+import { spawn } from 'node:child_process'
+import fs from 'node:fs'
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import { createInterface } from 'node:readline/promises'
+import {
+ DEFAULT_BASE_URL,
+ REPO_ROOT,
+ readPrivateKey,
+ readPublicKey,
+ runSsh,
+ writeFileAtomic
+} from './lib.mjs'
+import {
+ ReleaseError,
+ acquireReleaseLock,
+ finalizeReleaseRequest,
+ parseReleaseArgs,
+ resolveVersionChoice,
+ runReleaseWorkflow,
+ toReleaseError,
+ verifyPublishedManifest
+} from './release-workflow.mjs'
+import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs'
+
+const usage = `Usage:
+ npm run release
+ npm run release -- --version [--notes-zh ] [--notes-en ]
+ npm run release -- --resume --version
+ npm run release -- --help
+
+Options:
+ --version Stable Windows x64 version. Prompted when omitted.
+ --notes-zh Chinese release notes. Defaults to "YCode 更新".
+ --notes-en English release notes. Defaults to "YCode update".
+ --resume Reuse an existing installer and signed manifest.
+ --ssh-host SSH host, default local001.
+ --base-url Release base URL, default ${DEFAULT_BASE_URL}.
+ --private-key Signing key, default in the current user profile.
+ --help Show this help.
+
+The workflow runs tests, builds, signs, deploys, and verifies latest.json.
+It does not create keys, roll back the server, or clean stale release locks.
+`
+
+async function pathExists(filename) {
+ try {
+ await fsp.access(filename)
+ return true
+ } catch (error) {
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
+ throw error
+ }
+}
+
+function runExecutable(command, args, { cwd = REPO_ROOT } = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd,
+ env: process.env,
+ shell: false,
+ stdio: 'inherit',
+ windowsHide: true
+ })
+ child.once('error', reject)
+ child.once('exit', (code, signal) => {
+ if (code === 0) {
+ resolve()
+ } else {
+ reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
+ }
+ })
+ })
+}
+
+function resolveNpmCli() {
+ const candidates = [
+ path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
+ process.env.npm_execpath
+ ].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js')
+ const npmCli = candidates.find((candidate) => fs.existsSync(candidate))
+ if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release')
+ return npmCli
+}
+
+async function promptForVersion() {
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
+ try {
+ return await readline.question('Release version (stable x.y.z): ')
+ } finally {
+ readline.close()
+ }
+}
+
+function productionDependencies() {
+ const npmCli = resolveNpmCli()
+ return {
+ root: REPO_ROOT,
+ pathExists,
+ realpath: fsp.realpath,
+ readFile: fsp.readFile,
+ writeFileAtomic,
+ readPrivateKey,
+ readPublicKey,
+ validateBundle: validateReleaseBundle,
+ acquireReleaseLock,
+ checkToolchain: async (paths, { resume }) => {
+ const required = [['deploy.mjs', paths.deployScript]]
+ if (!resume) {
+ required.unshift(
+ ['npm-cli.js', npmCli],
+ ['electron-builder CLI', paths.builderCli],
+ ['prepare-release.mjs', paths.prepareScript]
+ )
+ }
+ for (const [label, filename] of required) {
+ if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`)
+ }
+ },
+ checkSsh: async (host) => {
+ runSsh(host, 'true\n')
+ },
+ readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl),
+ runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]),
+ runNode: (args) => runExecutable(process.execPath, args),
+ verifyHttp: verifyPublishedManifest,
+ logWarning: (message) => console.warn(message)
+ }
+}
+
+async function main() {
+ let parsed
+ try {
+ parsed = parseReleaseArgs(process.argv.slice(2))
+ } catch (error) {
+ throw toReleaseError('input', error)
+ }
+ if (parsed.help) {
+ process.stdout.write(usage)
+ return
+ }
+
+ let version
+ try {
+ version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion })
+ } catch (error) {
+ throw toReleaseError('prompt', error)
+ }
+ if (!version) {
+ console.log('Release cancelled.')
+ return
+ }
+ let request
+ try {
+ request = finalizeReleaseRequest(parsed, version)
+ } catch (error) {
+ throw toReleaseError('input', error)
+ }
+ const result = await runReleaseWorkflow(request, productionDependencies())
+ console.log(`Release ${result.version} published and verified.`)
+ console.log(`Installer: ${result.installerPath}`)
+ console.log(`Manifest: ${result.manifestPath}`)
+}
+
+main().catch((error) => {
+ const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error)
+ console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`)
+ process.exitCode = 1
+})
diff --git a/scripts/updates/selftest.mjs b/scripts/updates/selftest.mjs
new file mode 100644
index 0000000..49906ec
--- /dev/null
+++ b/scripts/updates/selftest.mjs
@@ -0,0 +1,372 @@
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import fsp from 'node:fs/promises'
+import http from 'node:http'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ REPO_ROOT,
+ buildManifestPayload,
+ compareVersions,
+ createSignedManifest,
+ normalizeBaseUrl,
+ readPrivateKey,
+ readPublicKey,
+ renderDownloadPage,
+ runProcess,
+ serializeSignedManifest,
+ signPayload,
+ verifyPayloadSignature,
+ verifySignedManifest
+} from './lib.mjs'
+import {
+ publishLocalRelease,
+ readLocalLatest
+} from './transport.mjs'
+
+let passed = 0
+
+async function test(name, callback) {
+ await callback()
+ passed += 1
+ console.log(`PASS ${name}`)
+}
+
+function expectedError(pattern) {
+ return (error) => {
+ assert.match(String(error?.message || error), pattern)
+ return true
+ }
+}
+
+function sha256(buffer) {
+ return createHash('sha256').update(buffer).digest('hex')
+}
+
+function contentType(filename) {
+ return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream'
+}
+
+function startStaticServer(root) {
+ const server = http.createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, 'http://127.0.0.1')
+ const pathname = decodeURIComponent(url.pathname)
+ const relative = pathname.startsWith('/ycode/releases/')
+ ? pathname.slice('/ycode/releases/'.length)
+ : null
+ assert(relative !== null, 'unexpected path')
+ const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative
+ const filename = path.resolve(root, 'ycode', 'releases', requested)
+ const releaseRoot = path.resolve(root, 'ycode', 'releases')
+ assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal')
+ const data = await fsp.readFile(filename)
+ response.writeHead(200, {
+ 'content-length': data.length,
+ 'content-type': contentType(filename)
+ })
+ response.end(data)
+ } catch {
+ response.writeHead(404)
+ response.end('not found')
+ }
+ })
+ return new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ resolve({ server, origin: `http://127.0.0.1:${address.port}` })
+ })
+ })
+}
+
+async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) {
+ const installer = {
+ filename,
+ size: bytes.length,
+ sha256: sha256(bytes)
+ }
+ const payload = buildManifestPayload({
+ version,
+ publishedAt: '2026-10-20T10:00:00.000Z',
+ notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` },
+ installer,
+ baseUrl
+ })
+ const document = createSignedManifest({
+ payload,
+ privateKeyPem: privatePem,
+ publicKeyPem: publicPem,
+ baseUrl
+ })
+ return {
+ document,
+ installer: { ...installer, path: artifactPath },
+ manifestText: serializeSignedManifest(document, { baseUrl }),
+ publicKeyPem: publicPem,
+ baseUrl
+ }
+}
+
+async function main() {
+ const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-'))
+ const privateKey = path.join(temporary, 'keys', 'update-signing.key')
+ const publicKey = path.join(temporary, 'keys', 'public-key.pem')
+ const publicRoot = path.join(temporary, 'ycode')
+ const artifactDirectory = path.join(temporary, 'fixtures')
+ await fsp.mkdir(artifactDirectory, { recursive: true })
+ let server
+
+ try {
+ await test('key generation stays outside the repository and writes a PEM trust anchor', async () => {
+ const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs')
+ const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey])
+ assert.match(output, /Private key:/)
+ assert.doesNotMatch(output, /BEGIN PRIVATE KEY/)
+ const privatePem = await fsp.readFile(privateKey, 'utf8')
+ const publicPem = await fsp.readFile(publicKey, 'utf8')
+ assert.match(privatePem, /BEGIN PRIVATE KEY/)
+ assert.match(publicPem, /BEGIN PUBLIC KEY/)
+ if (process.platform === 'win32') {
+ const acl = runProcess('icacls', [privateKey])
+ assert.doesNotMatch(acl, /\(I\)/)
+ }
+ })
+
+ const privatePem = await readPrivateKey(privateKey)
+ const publicPem = await readPublicKey(publicKey)
+
+ await test('SemVer precedence is strict and build metadata does not create a newer release', async () => {
+ assert(compareVersions('2.0.0', '1.9.9') > 0)
+ assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0)
+ assert(compareVersions('2.0.0+1', '2.0.0+2') === 0)
+ assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/)
+ assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/)
+ })
+
+ const staticServer = await startStaticServer(temporary)
+ server = staticServer.server
+ const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`)
+
+ const version1 = '2.0.0'
+ const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8')
+ const filename1 = `YCode-${version1}-Setup-x64.exe`
+ const artifact1 = path.join(artifactDirectory, filename1)
+ await fsp.writeFile(artifact1, bytes1)
+ const release1 = await releaseFor({
+ version: version1,
+ bytes: bytes1,
+ filename: filename1,
+ baseUrl,
+ privatePem,
+ publicPem,
+ artifactPath: artifact1
+ })
+
+ await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => {
+ verifySignedManifest(release1.document, publicPem, { baseUrl })
+ assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem))
+
+ const notesTamper = structuredClone(release1.document)
+ notesTamper.payload.notes.en = 'tampered'
+ assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/)
+
+ const hashTamper = structuredClone(release1.document)
+ hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
+ assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/)
+
+ const signatureTamper = structuredClone(release1.document)
+ const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A'
+ signatureTamper.signature = first + signatureTamper.signature.slice(1)
+ assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/)
+ })
+
+ await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => {
+ const cases = [
+ (payload) => { payload.platforms['windows-x64'].url += '?token=bad' },
+ (payload) => { payload.platforms['windows-x64'].url += '#fragment' },
+ (payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') },
+ (payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') },
+ (payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' },
+ (payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() }
+ ]
+ for (const mutate of cases) {
+ const payload = structuredClone(release1.document.payload)
+ mutate(payload)
+ const document = { payload, signature: signPayload(payload, privatePem) }
+ assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/)
+ }
+ })
+
+ await test('download page escapes signed notes and contains no script', async () => {
+ const payload = structuredClone(release1.document.payload)
+ payload.notes.zh = '\n第二行'
+ payload.notes.en = '
'
+ const html = renderDownloadPage(payload, { baseUrl })
+ assert.doesNotMatch(html, /