feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -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 `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>YCode v${version} 下载</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
|
||||
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
|
||||
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
|
||||
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
|
||||
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
|
||||
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
|
||||
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
|
||||
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
|
||||
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
|
||||
.download:hover { background:var(--accent-dark); }
|
||||
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
|
||||
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
|
||||
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
|
||||
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
|
||||
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p class="eyebrow">YCode / Windows x64</p>
|
||||
<h1>v${version}</h1>
|
||||
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
|
||||
<section class="card">
|
||||
<div class="release">
|
||||
<div class="meta">
|
||||
<span>发布时间 ${publishedAt}</span>
|
||||
<span>${size}</span>
|
||||
</div>
|
||||
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
|
||||
<code class="hash">SHA-256 ${sha256}</code>
|
||||
</div>
|
||||
<div class="notes">
|
||||
<div>
|
||||
<h2>更新说明</h2>
|
||||
<p lang="zh-CN">${zhNotes}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Release notes</h2>
|
||||
<p lang="en">${enNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
|
||||
<a href="latest.json">查看签名清单</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function renderNoReleasePage() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>YCode 下载</title>
|
||||
<style>
|
||||
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
|
||||
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
|
||||
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
|
||||
p { margin:0; color:var(--muted); line-height:1.75; }
|
||||
</style>
|
||||
</head>
|
||||
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function usageError(usage, error) {
|
||||
console.error(`${error.message}\n\n${usage}`)
|
||||
process.exitCode = 2
|
||||
}
|
||||
Reference in new issue
Block a user