mirror of
https://git.openapi.site/https://github.com/desirecore/registry.git
synced 2026-10-22 01:33:18 +08:00
feat: 增加固定工作台应用目录兼容格式
This commit is contained in:
68
scripts/application-authoring.test.mjs
Normal file
68
scripts/application-authoring.test.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, cpSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { test, afterEach } from 'node:test'
|
||||
import Ajv from 'ajv'
|
||||
import { validateRegistry } from './catalog/validator.mjs'
|
||||
import { validateApplicationReleaseContract } from './catalog/application-release-contract.mjs'
|
||||
|
||||
const source = join(dirname(fileURLToPath(import.meta.url)),'..')
|
||||
const temporary = []
|
||||
afterEach(() => { for (const root of temporary.splice(0)) rmSync(root,{recursive:true,force:true}) })
|
||||
const schema = JSON.parse(readFileSync(join(source,'schemas/registry-entry.schema.json'),'utf8'))
|
||||
const validate = new Ajv({allErrors:true,strict:true,strictTypes:false,strictRequired:false,validateFormats:false}).compile(schema)
|
||||
const load = id => JSON.parse(readFileSync(join(source,'fixtures/authoring/entries',id,'manifest.json'),'utf8'))
|
||||
|
||||
test('4.3只新增兼容与合成fixtures,正式目录仍无web-bundle或appRelease条目',() => {
|
||||
const root = JSON.parse(readFileSync(join(source,'manifest.json'),'utf8'))
|
||||
assert.equal(root.version,'4.3.0')
|
||||
assert.equal(root.stats.totalEntries,24)
|
||||
assert.equal(root.stats.webBundles,undefined)
|
||||
})
|
||||
test('静态与带服务合成目录三文件保持App语义,两个validator同样通过',() => {
|
||||
const root = mkdtempSync(join(tmpdir(),'registry-authoring-'));temporary.push(root)
|
||||
cpSync(join(source,'schemas'),join(root,'schemas'),{recursive:true})
|
||||
cpSync(join(source,'fixtures/authoring/entries'),join(root,'entries'),{recursive:true})
|
||||
const manifest = JSON.parse(readFileSync(join(source,'manifest.json'),'utf8'))
|
||||
manifest.stats={totalEntries:3,dockerApps:1,nativeApps:1,webBundles:1,mcpServices:0,httpApis:0,externalIntegrations:0}
|
||||
writeFileSync(join(root,'manifest.json'),JSON.stringify(manifest));writeFileSync(join(root,'SCHEMA_VERSION'),'4.3.0\n')
|
||||
const report = validateRegistry(root)
|
||||
assert.equal(report.ok,true,JSON.stringify(report.errors))
|
||||
assert.equal(report.counts.webBundles,1)
|
||||
for (const id of ['static-workbench-example','service-workbench-example','docker-workbench-example']) {
|
||||
const item=load(id);assert.equal(validate(item),true,JSON.stringify(validate.errors))
|
||||
assert.deepEqual(validateApplicationReleaseContract(item),[])
|
||||
assert.equal(item.sourceId,undefined)
|
||||
assert.notEqual(item.appRelease.releaseDigest,item.source.sha256)
|
||||
assert.equal(item.availability,'listing-only')
|
||||
}
|
||||
})
|
||||
test('不能把完整HTML伪装成artifact、native-node或内部MCP服务',() => {
|
||||
const item=load('static-workbench-example')
|
||||
for (const change of [copy=>{copy.type='artifact'},copy=>{copy.install.method='native-node'},copy=>{copy.connection={transport:'stdio',command:'node'}},copy=>{copy.appRelease.materialRoot='/private'}]) {
|
||||
const copy=structuredClone(item);change(copy);assert.equal(validate(copy),false)
|
||||
}
|
||||
})
|
||||
test('未知能力、可变ref和私有凭据不成为正式分发事实',() => {
|
||||
const item=load('static-workbench-example')
|
||||
for (const change of [copy=>{copy.source.ref='latest'},copy=>{copy.source.url='https://credential@git.example.test/private.zip'},copy=>{copy.appRelease.requiredCapabilities=['app-authoring-v1']}]) {
|
||||
const copy=structuredClone(item);change(copy);assert.ok(validateApplicationReleaseContract(copy).length>0)
|
||||
}
|
||||
})
|
||||
test('保留既有native-node条目,新native release字段不污染旧分支',() => {
|
||||
const old=JSON.parse(readFileSync(join(source,'entries/desirecore-control/manifest.json'),'utf8'))
|
||||
assert.equal(validate(old),true,JSON.stringify(validate.errors))
|
||||
old.appRelease=load('service-workbench-example').appRelease
|
||||
assert.equal(validate(old),false)
|
||||
})
|
||||
test('Docker发布固定真实镜像内容引用,不把浮动tag或错误端口当成固定材料',() => {
|
||||
const item=load('docker-workbench-example')
|
||||
assert.equal(validate(item),true,JSON.stringify(validate.errors))
|
||||
assert.equal(item.install.method,'release');assert.equal(item.install.requirements.docker,true)
|
||||
for (const image of ['node:latest','registry.example.test/example/app:1.0.0','localhost:70000/app@sha256:'+'d'.repeat(64)]) {
|
||||
const copy=structuredClone(item);copy.appRelease.imageRefs=[image]
|
||||
assert.ok(!validate(copy) || validateApplicationReleaseContract(copy).length>0)
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 发布者关系声明的跨字段检查;不推导安装、权限或运行时健康。 */
|
||||
export function validateApplicationContract(manifest) {
|
||||
if (!['docker-app', 'native-app', 'artifact'].includes(manifest.type)) return []
|
||||
if (!['docker-app', 'native-app', 'artifact', 'web-bundle'].includes(manifest.type)) return []
|
||||
const errors = []
|
||||
const ids = new Set()
|
||||
for (const entrypoint of Array.isArray(manifest.entrypoints) ? manifest.entrypoints : []) {
|
||||
|
||||
32
scripts/catalog/application-release-contract.mjs
Normal file
32
scripts/catalog/application-release-contract.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
/** 作者材料的跨字段检查;身份与运行事实由客户端owner核验,不执行安装说明。 */
|
||||
export function validateApplicationReleaseContract(manifest) {
|
||||
if (manifest.type !== 'web-bundle' && manifest.appRelease === undefined) return []
|
||||
const errors = []
|
||||
if (!['web-bundle', 'native-app', 'docker-app'].includes(manifest.type)) errors.push('appRelease: unsupported delivery type')
|
||||
if (!manifest.appRelease?.requiredCapabilities?.includes('app-authoring-v1')) errors.push('appRelease: app-authoring-v1 capability handshake required')
|
||||
if (!manifest.appRelease?.requiredCapabilities?.includes('release') ||
|
||||
(manifest.type === 'web-bundle' && !manifest.appRelease.requiredCapabilities.includes('web-bundle'))) errors.push('appRelease: delivery capability handshake required')
|
||||
const images = manifest.appRelease?.imageRefs
|
||||
if (manifest.type === 'docker-app' && (!Array.isArray(images) || !images.length || images.some(image =>
|
||||
typeof image !== 'string' || !/^[a-z0-9][a-z0-9._:/-]*@sha256:[a-f0-9]{64}$/.test(image) ||
|
||||
Number(image.split('/')[0].split(':')[1] ?? 0) > 65535))) errors.push('appRelease: Docker images must be pinned by digest')
|
||||
if (manifest.type !== 'docker-app' && images !== undefined) errors.push('appRelease: imageRefs belongs only to Docker release')
|
||||
const source = manifest.source
|
||||
if (!source || !['git', 'release'].includes(source.kind) ||
|
||||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.ref ?? '') || !/^[a-f0-9]{64}$/.test(source.sha256 ?? '')) {
|
||||
errors.push('source: authored release requires immutable commit and archive sha256')
|
||||
}
|
||||
try {
|
||||
const url = new URL(source?.url)
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.search) errors.push('source: credential-free fixed HTTPS URL required')
|
||||
} catch { errors.push('source: invalid URL') }
|
||||
if (source?.kind === 'git' && !isPortablePath(source.path)) errors.push('source: git archive path must be canonical and relative')
|
||||
if (!isPortablePath(manifest.appRelease?.definitionPath)) errors.push('appRelease: definitionPath must be canonical and relative')
|
||||
return errors
|
||||
}
|
||||
|
||||
function isPortablePath(value) {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 512 &&
|
||||
!/[\\\x00-\x1f?#:]/.test(value) && !value.startsWith('/') &&
|
||||
value.split('/').every(part => part.length > 0 && part !== '.' && part !== '..')
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { basename, join, relative, resolve, sep } from 'node:path'
|
||||
import { validateJsonSchema } from './json-schema.mjs'
|
||||
import { findStaticMetrics } from './static-metadata.mjs'
|
||||
import { validateApplicationContract } from './application-contract.mjs'
|
||||
import { validateApplicationReleaseContract } from './application-release-contract.mjs'
|
||||
|
||||
export const CATALOG_SIDECAR_FILENAME = 'catalog-metadata.v1.json'
|
||||
|
||||
@@ -77,7 +78,7 @@ function timestampShapeIsConsistent(timestamp) {
|
||||
function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
const errors = []
|
||||
const add = (code, path, message) => errors.push(diagnostic('error', code, file, path, message))
|
||||
const expectedKind = ['docker-app', 'native-app', 'artifact'].includes(manifest.type) ? 'app' : 'service'
|
||||
const expectedKind = ['docker-app', 'native-app', 'artifact', 'web-bundle'].includes(manifest.type) ? 'app' : 'service'
|
||||
|
||||
if (sidecar.identity?.id !== manifest.id) add('identity-mismatch', '$.identity.id', 'sidecar identity.id 必须与 legacy manifest.id 一致')
|
||||
if (sidecar.identity?.kind !== expectedKind) add('kind-mismatch', '$.identity.kind', `legacy ${manifest.type} 必须映射为 ${expectedKind}`)
|
||||
@@ -140,7 +141,7 @@ function validateSidecarSemantics(manifest, sidecar, file) {
|
||||
if (manifest.toolCount !== undefined && sidecar.spec?.toolCount !== manifest.toolCount) add('spec-mismatch', '$.spec.toolCount', 'toolCount 必须与 legacy 一致')
|
||||
}
|
||||
|
||||
if (['native-app', 'artifact'].includes(manifest.type)) {
|
||||
if (['native-app', 'artifact', 'web-bundle'].includes(manifest.type)) {
|
||||
for (const field of ['stewardship', 'availability', 'redistribution', 'listingMaintainer', 'upstreamMaintainer', 'branding', 'compliance']) {
|
||||
if (!isDeepStrictEqual(manifest[field], sidecar.governance?.[field])) add('native-governance-mismatch', '$.governance.' + field, '原生应用 manifest 与 sidecar 的治理事实必须一致')
|
||||
}
|
||||
@@ -237,12 +238,14 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
if (entry.type === 'docker-app') counts.dockerApps += 1
|
||||
if (entry.type === 'native-app') counts.nativeApps = (counts.nativeApps ?? 0) + 1
|
||||
if (entry.type === 'artifact') counts.artifactApps = (counts.artifactApps ?? 0) + 1
|
||||
if (entry.type === 'web-bundle') counts.webBundles = (counts.webBundles ?? 0) + 1
|
||||
if (entry.type === 'mcp') counts.mcpServices += 1
|
||||
if (entry.type === 'http-api') counts.httpApis += 1
|
||||
if (entry.type === 'external-integration') counts.externalIntegrations += 1
|
||||
const effectiveEntrySchema = ['external-integration', 'native-app', 'artifact'].includes(entry.type) ? externalEntrySchema : currentEntrySchema
|
||||
const effectiveEntrySchema = ['external-integration', 'native-app', 'artifact', 'web-bundle'].includes(entry.type) ? externalEntrySchema : currentEntrySchema
|
||||
if (effectiveEntrySchema) diagnostics.push(...schemaDiagnostics(entry, effectiveEntrySchema, manifestRelative))
|
||||
for (const message of validateApplicationContract(entry)) diagnostics.push(diagnostic('error', 'application-contract', manifestRelative, '$', message))
|
||||
for (const message of validateApplicationReleaseContract(entry)) diagnostics.push(diagnostic('error', 'application-release-contract', manifestRelative, '$', message))
|
||||
if (entry.id !== basename(entryDir)) diagnostics.push(diagnostic('error', 'directory-id-mismatch', manifestRelative, '$.id', 'manifest.id 必须与 entries/<id> 目录名一致'))
|
||||
if (seenIds.has(entry.id)) diagnostics.push(diagnostic('error', 'duplicate-id', manifestRelative, '$.id', 'Registry 条目 ID 重复'))
|
||||
seenIds.add(entry.id)
|
||||
@@ -264,8 +267,8 @@ export function validateRegistry(repoRoot, options = {}) {
|
||||
}
|
||||
|
||||
if (rootManifest?.stats) {
|
||||
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'artifactApps', 'mcpServices', 'httpApis', 'externalIntegrations']) {
|
||||
if (['nativeApps', 'artifactApps'].includes(key) && rootManifest.stats[key] === undefined && !counts[key]) continue
|
||||
for (const key of ['totalEntries', 'dockerApps', 'nativeApps', 'artifactApps', 'webBundles', 'mcpServices', 'httpApis', 'externalIntegrations']) {
|
||||
if (['nativeApps', 'artifactApps', 'webBundles'].includes(key) && rootManifest.stats[key] === undefined && !counts[key]) continue
|
||||
if (rootManifest.stats[key] === undefined && key === 'externalIntegrations' && counts[key] === 0) continue
|
||||
if (rootManifest.stats[key] !== (counts[key] ?? 0)) diagnostics.push(diagnostic('error', 'stats-mismatch', 'manifest.json', `$.stats.${key}`, `声明 ${rootManifest.stats[key]},实际 ${counts[key] ?? 0}`))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import Ajv from 'ajv'
|
||||
import { findStaticMetrics } from './catalog/static-metadata.mjs'
|
||||
import { validateApplicationContract } from './catalog/application-contract.mjs'
|
||||
import { validateApplicationReleaseContract } from './catalog/application-release-contract.mjs'
|
||||
|
||||
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const rootArgumentIndex = process.argv.indexOf('--root')
|
||||
@@ -348,6 +349,7 @@ for (const directoryEntry of await readdir(entriesRoot, { withFileTypes: true })
|
||||
addError(`entries/${directoryEntry.name}/manifest.json: ${formatAjvErrors(validateEntry.errors)}`)
|
||||
}
|
||||
for (const message of validateApplicationContract(manifest)) addError(`entries/${directoryEntry.name}/manifest.json: ${message}`)
|
||||
for (const message of validateApplicationReleaseContract(manifest)) addError(`entries/${directoryEntry.name}/manifest.json: ${message}`)
|
||||
if (manifest.type === 'external-integration') {
|
||||
await validateExternalIntegration(entryDirectory, manifest)
|
||||
}
|
||||
@@ -359,6 +361,7 @@ if (rootManifest) {
|
||||
dockerApps: manifests.filter((entry) => entry.type === 'docker-app').length,
|
||||
...(manifests.some((entry) => entry.type === 'native-app') || rootManifest.stats?.nativeApps !== undefined ? { nativeApps: manifests.filter((entry) => entry.type === 'native-app').length } : {}),
|
||||
...(manifests.some((entry) => entry.type === 'artifact') || rootManifest.stats?.artifactApps !== undefined ? { artifactApps: manifests.filter((entry) => entry.type === 'artifact').length } : {}),
|
||||
...(manifests.some((entry) => entry.type === 'web-bundle') || rootManifest.stats?.webBundles !== undefined ? { webBundles: manifests.filter((entry) => entry.type === 'web-bundle').length } : {}),
|
||||
mcpServices: manifests.filter((entry) => entry.type === 'mcp').length,
|
||||
httpApis: manifests.filter((entry) => entry.type === 'http-api').length,
|
||||
externalIntegrations: manifests.filter((entry) => entry.type === 'external-integration').length,
|
||||
|
||||
Reference in New Issue
Block a user