mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
chore: migrate js-yaml to v5 (#38639)
This commit is contained in:
parent
c3fb5344cb
commit
b7255e6f27
@ -98,7 +98,6 @@
|
||||
"devDependencies": {
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@types/js-yaml": "catalog:",
|
||||
"@types/lockfile": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
|
||||
@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import yaml from 'js-yaml'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
@ -109,11 +109,11 @@ describe('AppMetaClient', () => {
|
||||
await new AppMetaClient({ apps, host: mock.url, cache: seed }).get('app-1', [FieldInfo])
|
||||
|
||||
// Reuse that serialized entry as a valid sibling; corrupt the app-1 slot.
|
||||
const file = yaml.load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
|
||||
const file = load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
|
||||
const validEntry = file.entries[`${mock.url}::app-1`]
|
||||
await writeFile(
|
||||
path,
|
||||
yaml.dump({ entries: {
|
||||
dump({ entries: {
|
||||
[`${mock.url}::app-1`]: 'corrupted-string',
|
||||
[`${mock.url}::sibling`]: validEntry,
|
||||
} }),
|
||||
|
||||
10
cli/src/cache/app-info.test.ts
vendored
10
cli/src/cache/app-info.test.ts
vendored
@ -2,7 +2,7 @@ import type { AppMeta } from '@/types/app-meta'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
|
||||
@ -112,9 +112,9 @@ describe('app-info disk cache', () => {
|
||||
await seed.set('h', 'app-2', metaInfoOnly('app-2'))
|
||||
|
||||
// Inject a corrupt sibling alongside the real one.
|
||||
const file = yaml.load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record<string, unknown> }
|
||||
const file = load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record<string, unknown> }
|
||||
file.entries['h::app-1'] = 'corrupted-string-not-object'
|
||||
await writeFile(appInfoPath(dir), yaml.dump(file), 'utf8')
|
||||
await writeFile(appInfoPath(dir), dump(file), 'utf8')
|
||||
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
@ -122,7 +122,7 @@ describe('app-info disk cache', () => {
|
||||
})
|
||||
|
||||
it('treats a non-object entries map as empty', async () => {
|
||||
await writeFile(appInfoPath(dir), yaml.dump({ entries: 'not-an-object' }), 'utf8')
|
||||
await writeFile(appInfoPath(dir), dump({ entries: 'not-an-object' }), 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
@ -137,7 +137,7 @@ describe('app-info disk cache', () => {
|
||||
}
|
||||
await c.set('h', 'app-1', slim)
|
||||
const raw = await readFile(appInfoPath(dir), 'utf8')
|
||||
const parsed = yaml.load(raw) as { entries: Record<string, unknown> }
|
||||
const parsed = load(raw) as { entries: Record<string, unknown> }
|
||||
expect(Object.keys(parsed.entries)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
4
cli/src/cache/nudge-store.test.ts
vendored
4
cli/src/cache/nudge-store.test.ts
vendored
@ -1,7 +1,7 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_NUDGE, cachePath, getCache } from '@/store/manager'
|
||||
@ -70,7 +70,7 @@ describe('NudgeStore', () => {
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
await store.markWarned(HOST)
|
||||
const raw = await readFile(nudgeStorePath(dir), 'utf8')
|
||||
const parsed = yaml.load(raw) as Record<string, unknown>
|
||||
const parsed = load(raw) as Record<string, unknown>
|
||||
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
|
||||
})
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import type { CommandConstructor, CommandEffect } from './command'
|
||||
import type { CommandTree } from './registry'
|
||||
import type { ArgValueType, FlagDefinition } from './types'
|
||||
import type { HelpTopic } from '@/help/topics'
|
||||
import yaml from 'js-yaml'
|
||||
import { dump } from 'js-yaml'
|
||||
import { CONTRACT, GLOBAL_FLAG_HELP } from '@/help/contract'
|
||||
import { TOPICS } from '@/help/topics'
|
||||
import { collectCommands } from './registry'
|
||||
@ -41,7 +41,7 @@ function isStructured(format: string): boolean {
|
||||
|
||||
function serialize(value: unknown, format: string): string {
|
||||
if (format === 'yaml')
|
||||
return yaml.dump(value, { indent: 2, lineWidth: -1 })
|
||||
return dump(value, { indent: 2, lineWidth: -1 })
|
||||
return `${JSON.stringify(value, null, 2)}\n`
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import yaml from 'js-yaml'
|
||||
import { dump } from 'js-yaml'
|
||||
import { OutputFormatNotSupportedError } from './errors'
|
||||
|
||||
export type RawOutput = {
|
||||
@ -91,7 +91,7 @@ function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>):
|
||||
case OutputFormat.JSON:
|
||||
return `${JSON.stringify(output.data.json(), null, 2)}\n`
|
||||
case OutputFormat.YAML:
|
||||
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
|
||||
return dump(output.data.json(), { indent: 2, lineWidth: -1 })
|
||||
case OutputFormat.NAME:
|
||||
return `${toName(output.data)}\n`
|
||||
default:
|
||||
@ -107,7 +107,7 @@ function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
|
||||
case OutputFormat.JSON:
|
||||
return `${JSON.stringify(output.data.json(), null, 2)}\n`
|
||||
case OutputFormat.YAML:
|
||||
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
|
||||
return dump(output.data.json(), { indent: 2, lineWidth: -1 })
|
||||
case OutputFormat.NAME:
|
||||
return `${toName(output.data)}\n`
|
||||
default:
|
||||
|
||||
@ -16,7 +16,7 @@ export class ConcurrentAccessError extends BaseError {
|
||||
type YamlMark = {
|
||||
line: number
|
||||
column: number
|
||||
snippet?: string
|
||||
snippet?: string | null
|
||||
}
|
||||
|
||||
type YamlParseError = {
|
||||
|
||||
@ -12,6 +12,18 @@ describe('YamlStore.doGet', () => {
|
||||
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
|
||||
})
|
||||
|
||||
it('returns default when content is empty', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('')
|
||||
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
|
||||
})
|
||||
|
||||
it('returns default when content contains only comments', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('# empty configuration\n')
|
||||
expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback')
|
||||
})
|
||||
|
||||
it('reads a flat key', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\n')
|
||||
@ -24,6 +36,51 @@ describe('YamlStore.doGet', () => {
|
||||
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
|
||||
})
|
||||
|
||||
it('resolves YAML merge keys', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('defaults: &defaults\n format: json\nselected:\n <<: *defaults\n')
|
||||
expect(store.doGet({ key: 'selected.format', default: '' })).toBe('json')
|
||||
})
|
||||
|
||||
it('parses timestamp scalars as dates', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
const timestamp = '2026-07-10T03:04:05.000Z'
|
||||
store.setRawContent(`created_at: ${timestamp}\n`)
|
||||
expect(store.doGet<Date | null>({ key: 'created_at', default: null })).toEqual(new Date(timestamp))
|
||||
})
|
||||
|
||||
it('rejects multiple YAML documents', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\n---\nname: bob\n')
|
||||
|
||||
expect(() => store.doGet({ key: 'name', default: '' }))
|
||||
.toThrowError(/single document/)
|
||||
})
|
||||
|
||||
it('rejects duplicate mapping keys', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\nname: bob\n')
|
||||
|
||||
expect(() => store.doGet({ key: 'name', default: '' }))
|
||||
.toThrowError(/duplicated mapping key/)
|
||||
})
|
||||
|
||||
it('rejects complex mapping keys', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('? [name, region]\n: deployment\n')
|
||||
|
||||
expect(() => store.doGet({ key: 'name', default: '' }))
|
||||
.toThrowError(/does not support complex keys/)
|
||||
})
|
||||
|
||||
it('parses explicit YAML sets as JavaScript sets', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('features: !!set\n workflow:\n chat:\n')
|
||||
|
||||
expect(store.doGet<Set<string>>({ key: 'features', default: new Set() }))
|
||||
.toEqual(new Set(['workflow', 'chat']))
|
||||
})
|
||||
|
||||
it('returns default for a missing flat key', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\n')
|
||||
|
||||
@ -3,7 +3,18 @@ import type { Platform } from '@/sys'
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { AsyncEntry } from '@napi-rs/keyring'
|
||||
import yaml from 'js-yaml'
|
||||
import {
|
||||
binaryTag,
|
||||
CORE_SCHEMA,
|
||||
dump,
|
||||
loadAll,
|
||||
mergeTag,
|
||||
omapTag,
|
||||
pairsTag,
|
||||
setTag,
|
||||
timestampTag,
|
||||
YAMLException,
|
||||
} from 'js-yaml'
|
||||
import lockfile from 'lockfile'
|
||||
import { pid, resolvePlatform } from '@/sys'
|
||||
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
|
||||
@ -11,6 +22,7 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors'
|
||||
const FILE_PERM = 0o600
|
||||
const DIR_PERM = 0o700
|
||||
const LOCK_STALE_MS = 30_000
|
||||
const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag)
|
||||
|
||||
function lockAsync(path: string, opts: LockOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -202,7 +214,7 @@ export class YamlStore extends FileBasedStore {
|
||||
async setTyped<T>(data: T): Promise<void> {
|
||||
await this.withLock(async () => {
|
||||
await this.load()
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
|
||||
await this.flush()
|
||||
})
|
||||
}
|
||||
@ -220,7 +232,7 @@ export class YamlStore extends FileBasedStore {
|
||||
current = current[part] as Record<string, unknown>
|
||||
}
|
||||
current[lastKey] = value
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
|
||||
}
|
||||
|
||||
doUnset<T>(key: Key<T>): void {
|
||||
@ -239,7 +251,7 @@ export class YamlStore extends FileBasedStore {
|
||||
if (!(lastKey in current))
|
||||
return
|
||||
delete current[lastKey]
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
this.setRawContent(dump(data, { lineWidth: -1, noRefs: true }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -247,10 +259,13 @@ function loadYaml(raw: string | undefined, file_path: string): Record<string, un
|
||||
if (raw === undefined)
|
||||
return null
|
||||
try {
|
||||
return (yaml.load(raw) ?? {}) as Record<string, unknown>
|
||||
const documents = loadAll(raw, { schema: YAML_LOAD_SCHEMA })
|
||||
if (documents.length > 1)
|
||||
throw new YAMLException('expected a single document in the stream, but found more')
|
||||
return (documents[0] ?? {}) as Record<string, unknown>
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof yaml.YAMLException)
|
||||
if (err instanceof YAMLException)
|
||||
throw new BadYamlFormatError(file_path, raw, err)
|
||||
throw err
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import { execSync, spawn } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { dump } from 'js-yaml'
|
||||
|
||||
/** Path to the dev entry point — no build required. */
|
||||
export const BIN = resolve(__dirname, '../../../bin/dev.js')
|
||||
@ -214,7 +214,7 @@ async function writeFileToken(configDir: string, host: string, email: string, be
|
||||
version: 1,
|
||||
tokens: { [host]: { [email]: bearer } },
|
||||
}
|
||||
await writeFile(join(configDir, 'tokens.yml'), yaml.dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 })
|
||||
await writeFile(join(configDir, 'tokens.yml'), dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from '@hey-api/openapi-ts'
|
||||
import yaml from 'js-yaml'
|
||||
import { loadOpenApiYaml } from './openapi-yaml'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
@ -267,7 +267,7 @@ const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => {
|
||||
}
|
||||
|
||||
const normalizeEnterpriseOpenApi = () => {
|
||||
const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
|
||||
const openApi = loadOpenApiYaml(fs.readFileSync(enterpriseOpenApiPath, 'utf8'))
|
||||
|
||||
if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi))
|
||||
throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`)
|
||||
|
||||
35
packages/contracts/openapi-yaml.test.ts
Normal file
35
packages/contracts/openapi-yaml.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadOpenApiYaml } from './openapi-yaml'
|
||||
|
||||
describe('loadOpenApiYaml', () => {
|
||||
it('preserves merge keys and timestamps used by v4 documents', () => {
|
||||
const document = loadOpenApiYaml(`
|
||||
defaults: &defaults
|
||||
version: 2026-07-10T03:04:05Z
|
||||
info:
|
||||
<<: *defaults
|
||||
`)
|
||||
|
||||
expect(document).toEqual({
|
||||
defaults: { version: new Date('2026-07-10T03:04:05Z') },
|
||||
info: { version: new Date('2026-07-10T03:04:05Z') },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate mapping keys', () => {
|
||||
expect(() => loadOpenApiYaml('openapi: 3.0.0\nopenapi: 3.1.0\n'))
|
||||
.toThrow(/duplicated mapping key/)
|
||||
})
|
||||
|
||||
it('rejects multiple YAML documents', () => {
|
||||
expect(() => loadOpenApiYaml('openapi: 3.0.0\n---\nopenapi: 3.1.0\n'))
|
||||
.toThrow(/single document/)
|
||||
})
|
||||
|
||||
it('uses v5 mapping and set semantics', () => {
|
||||
expect(() => loadOpenApiYaml('? [name, region]\n: deployment\n'))
|
||||
.toThrow(/does not support complex keys/)
|
||||
expect(loadOpenApiYaml('features: !!set\n workflow:\n chat:\n'))
|
||||
.toEqual({ features: new Set(['workflow', 'chat']) })
|
||||
})
|
||||
})
|
||||
23
packages/contracts/openapi-yaml.ts
Normal file
23
packages/contracts/openapi-yaml.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {
|
||||
binaryTag,
|
||||
CORE_SCHEMA,
|
||||
load,
|
||||
mergeTag,
|
||||
omapTag,
|
||||
pairsTag,
|
||||
setTag,
|
||||
timestampTag,
|
||||
} from 'js-yaml'
|
||||
|
||||
const OPENAPI_YAML_SCHEMA = CORE_SCHEMA.withTags(
|
||||
binaryTag,
|
||||
mergeTag,
|
||||
omapTag,
|
||||
pairsTag,
|
||||
setTag,
|
||||
timestampTag,
|
||||
)
|
||||
|
||||
export function loadOpenApiYaml(input: string) {
|
||||
return load(input, { schema: OPENAPI_YAML_SCHEMA })
|
||||
}
|
||||
@ -20,6 +20,7 @@
|
||||
"scripts": {
|
||||
"gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
|
||||
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
|
||||
"test": "vp test openapi-yaml.test.ts",
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -29,12 +30,12 @@
|
||||
"devDependencies": {
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@hey-api/openapi-ts": "catalog:",
|
||||
"@types/js-yaml": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@ -213,9 +213,6 @@ catalogs:
|
||||
'@types/js-cookie':
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6
|
||||
'@types/js-yaml':
|
||||
specifier: 4.0.9
|
||||
version: 4.0.9
|
||||
'@types/lockfile':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4
|
||||
@ -418,8 +415,8 @@ catalogs:
|
||||
specifier: 3.0.8
|
||||
version: 3.0.8
|
||||
js-yaml:
|
||||
specifier: 4.3.0
|
||||
version: 4.3.0
|
||||
specifier: 5.2.1
|
||||
version: 5.2.1
|
||||
jsonschema:
|
||||
specifier: 1.5.0
|
||||
version: 1.5.0
|
||||
@ -706,7 +703,7 @@ importers:
|
||||
version: 3.1.0
|
||||
js-yaml:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.0
|
||||
version: 5.2.1
|
||||
lockfile:
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.4
|
||||
@ -735,9 +732,6 @@ importers:
|
||||
'@hono/node-server':
|
||||
specifier: 'catalog:'
|
||||
version: 2.0.8(hono@4.12.28)
|
||||
'@types/js-yaml':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.9
|
||||
'@types/lockfile':
|
||||
specifier: 'catalog:'
|
||||
version: 1.0.4
|
||||
@ -823,9 +817,6 @@ importers:
|
||||
'@hey-api/openapi-ts':
|
||||
specifier: 'catalog:'
|
||||
version: 0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3)
|
||||
'@types/js-yaml':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 25.9.5
|
||||
@ -837,13 +828,16 @@ importers:
|
||||
version: 10.6.0(jiti@2.7.0)
|
||||
js-yaml:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.0
|
||||
version: 5.2.1
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: '@typescript/typescript6@6.0.2'
|
||||
vite-plus:
|
||||
specifier: 'catalog:'
|
||||
version: 0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(happy-dom@20.10.6)
|
||||
|
||||
packages/dev-proxy:
|
||||
dependencies:
|
||||
@ -1280,7 +1274,7 @@ importers:
|
||||
version: 3.0.8
|
||||
js-yaml:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.0
|
||||
version: 5.2.1
|
||||
jsonschema:
|
||||
specifier: 'catalog:'
|
||||
version: 1.5.0
|
||||
@ -1519,9 +1513,6 @@ importers:
|
||||
'@types/js-cookie':
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.6
|
||||
'@types/js-yaml':
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.9
|
||||
'@types/negotiator':
|
||||
specifier: 'catalog:'
|
||||
version: 0.6.4
|
||||
@ -4928,9 +4919,6 @@ packages:
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@ -7503,6 +7491,10 @@ packages:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@5.2.1:
|
||||
resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==}
|
||||
hasBin: true
|
||||
|
||||
jsdoc-type-pratt-parser@7.1.1:
|
||||
resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -13323,8 +13315,6 @@ snapshots:
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/katex@0.16.8': {}
|
||||
@ -16289,6 +16279,10 @@ snapshots:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
js-yaml@5.2.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
jsdoc-type-pratt-parser@7.1.1: {}
|
||||
|
||||
jsdoc-type-pratt-parser@7.2.0: {}
|
||||
@ -19703,7 +19697,6 @@ time:
|
||||
'@tsslint/compat-eslint@3.1.4': '2026-06-16T18:21:23.786Z'
|
||||
'@tsslint/config@3.1.4': '2026-06-16T18:21:26.545Z'
|
||||
'@types/js-cookie@3.0.6': '2023-11-07T08:41:16.889Z'
|
||||
'@types/js-yaml@4.0.9': '2023-11-07T20:20:13.264Z'
|
||||
'@types/lockfile@1.0.4': '2023-11-07T20:23:21.070Z'
|
||||
'@types/negotiator@0.6.4': '2025-06-07T02:18:17.532Z'
|
||||
'@types/node@25.9.5': '2026-07-08T06:47:58.834Z'
|
||||
@ -19772,7 +19765,7 @@ time:
|
||||
jotai@2.20.1: '2026-06-11T06:30:45.782Z'
|
||||
js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z'
|
||||
js-cookie@3.0.8: '2026-05-29T10:51:39.065Z'
|
||||
js-yaml@4.3.0: '2026-06-26T22:29:00.874Z'
|
||||
js-yaml@5.2.1: '2026-07-02T00:55:21.714Z'
|
||||
jsonschema@1.5.0: '2025-01-07T15:09:11.287Z'
|
||||
katex@0.17.0: '2026-05-22T08:06:26.967Z'
|
||||
knip@6.25.0: '2026-07-07T08:47:13.416Z'
|
||||
|
||||
@ -120,7 +120,6 @@ catalog:
|
||||
'@tsslint/compat-eslint': 3.1.4
|
||||
'@tsslint/config': 3.1.4
|
||||
'@types/js-cookie': 3.0.6
|
||||
'@types/js-yaml': 4.0.9
|
||||
'@types/lockfile': 1.0.4
|
||||
'@types/negotiator': 0.6.4
|
||||
'@types/node': 25.9.5
|
||||
@ -188,7 +187,7 @@ catalog:
|
||||
jotai-tanstack-query: 0.11.0
|
||||
js-audio-recorder: 1.0.7
|
||||
js-cookie: 3.0.8
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 5.2.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.25.0
|
||||
|
||||
@ -22,6 +22,20 @@ workflow:
|
||||
expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject disallowed node types inherited through YAML merge keys', () => {
|
||||
const content = `
|
||||
trigger: &trigger
|
||||
type: trigger-webhook
|
||||
workflow:
|
||||
graph:
|
||||
nodes:
|
||||
- data:
|
||||
<<: *trigger
|
||||
`
|
||||
|
||||
expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject malformed yaml and answer nodes in non-advanced mode', () => {
|
||||
expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false)
|
||||
expect(validateDSLContent(`
|
||||
@ -43,6 +57,18 @@ workflow:
|
||||
`, AppModeEnum.ADVANCED_CHAT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept empty yaml content', () => {
|
||||
expect(validateDSLContent('', AppModeEnum.CHAT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept comment-only yaml content', () => {
|
||||
expect(validateDSLContent('# No document', AppModeEnum.CHAT)).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject yaml streams with multiple documents', () => {
|
||||
expect(validateDSLContent('---\n{}\n---\n{}', AppModeEnum.CHAT)).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose the invalid node sets per mode', () => {
|
||||
expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual(
|
||||
expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]),
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CommonNodeType, Node } from './types'
|
||||
import { load as yamlLoad } from 'js-yaml'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { loadYaml } from '@/utils/yaml'
|
||||
import { BlockEnum, SupportUploadFileTypes } from './types'
|
||||
|
||||
type ParsedDSL = {
|
||||
@ -58,7 +58,7 @@ export const getInvalidNodeTypes = (mode?: AppModeEnum) => {
|
||||
|
||||
export const validateDSLContent = (content: string, mode?: AppModeEnum) => {
|
||||
try {
|
||||
const data = yamlLoad(content) as ParsedDSL
|
||||
const data = loadYaml(content) as ParsedDSL | undefined
|
||||
const nodes = data?.workflow?.graph?.nodes ?? []
|
||||
const invalidNodes = getInvalidNodeTypes(mode)
|
||||
return !nodes.some((node: Node<CommonNodeType>) => invalidNodes.includes(node?.data?.type))
|
||||
|
||||
105
web/features/deployments/shared/domain/__tests__/dsl.spec.ts
Normal file
105
web/features/deployments/shared/domain/__tests__/dsl.spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { dslAppName, dslEnvVarSlots, encodeDslContent, isWorkflowDsl } from '../dsl'
|
||||
|
||||
describe('deployment DSL domain', () => {
|
||||
describe('content encoding', () => {
|
||||
it('should preserve Unicode content through base64 encoding', () => {
|
||||
const content = 'app:\n name: 部署 🚀'
|
||||
|
||||
const decoded = new TextDecoder().decode(Uint8Array.from(atob(encodeDslContent(content)), character => character.charCodeAt(0)))
|
||||
|
||||
expect(decoded).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe('app metadata', () => {
|
||||
it('should trim the app name', () => {
|
||||
expect(dslAppName('app:\n name: " Deployment app "')).toBe('Deployment app')
|
||||
})
|
||||
|
||||
it('should return an empty app name for malformed content', () => {
|
||||
expect(dslAppName('app: [')).toBe('')
|
||||
})
|
||||
|
||||
it('should identify workflow apps', () => {
|
||||
expect(isWorkflowDsl('app:\n mode: workflow')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject non-workflow apps', () => {
|
||||
expect(isWorkflowDsl('app:\n mode: advanced-chat')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('environment variable slots', () => {
|
||||
it('should return no slots for malformed content', () => {
|
||||
expect(dslEnvVarSlots('workflow: [')).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep the first slot when names are duplicated', () => {
|
||||
const content = `
|
||||
workflow:
|
||||
environment_variables:
|
||||
- name: REGION
|
||||
value: first
|
||||
- name: " REGION "
|
||||
value: second
|
||||
`
|
||||
|
||||
expect(dslEnvVarSlots(content)).toEqual([{
|
||||
key: 'REGION',
|
||||
defaultValue: 'first',
|
||||
hasDefaultValue: true,
|
||||
}])
|
||||
})
|
||||
|
||||
it('should omit masked secret defaults', () => {
|
||||
const content = `
|
||||
workflow:
|
||||
environment_variables:
|
||||
- name: API_KEY
|
||||
value: '[__HIDDEN__]'
|
||||
value_type: secret
|
||||
`
|
||||
|
||||
expect(dslEnvVarSlots(content)).toEqual([{
|
||||
key: 'API_KEY',
|
||||
valueType: 'secret',
|
||||
}])
|
||||
})
|
||||
|
||||
it('should normalize unquoted timestamp defaults', () => {
|
||||
const content = `
|
||||
workflow:
|
||||
environment_variables:
|
||||
- name: START_AT
|
||||
value: 2026-07-10T12:34:56Z
|
||||
`
|
||||
|
||||
expect(dslEnvVarSlots(content)).toEqual([{
|
||||
key: 'START_AT',
|
||||
defaultValue: '"2026-07-10T12:34:56.000Z"',
|
||||
hasDefaultValue: true,
|
||||
}])
|
||||
})
|
||||
|
||||
it('should apply values inherited through YAML merge keys', () => {
|
||||
const content = `
|
||||
defaults: &defaults
|
||||
description: Deployment region
|
||||
value: ap-southeast-1
|
||||
value_type: string
|
||||
workflow:
|
||||
environment_variables:
|
||||
- <<: *defaults
|
||||
name: REGION
|
||||
`
|
||||
|
||||
expect(dslEnvVarSlots(content)).toEqual([{
|
||||
key: 'REGION',
|
||||
description: 'Deployment region',
|
||||
defaultValue: 'ap-southeast-1',
|
||||
hasDefaultValue: true,
|
||||
valueType: 'string',
|
||||
}])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { load as yamlLoad } from 'js-yaml'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { loadYaml } from '@/utils/yaml'
|
||||
|
||||
export type DslEnvVarSlot = {
|
||||
key: string
|
||||
@ -38,7 +38,7 @@ export function encodeDslContent(value: string) {
|
||||
|
||||
function parseDsl(content: string) {
|
||||
try {
|
||||
return yamlLoad(content) as DslMetadata | undefined
|
||||
return loadYaml(content) as DslMetadata | undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
|
||||
@ -195,7 +195,6 @@
|
||||
"@tsslint/compat-eslint": "catalog:",
|
||||
"@tsslint/config": "catalog:",
|
||||
"@types/js-cookie": "catalog:",
|
||||
"@types/js-yaml": "catalog:",
|
||||
"@types/negotiator": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/qs": "catalog:",
|
||||
|
||||
18
web/utils/yaml.spec.ts
Normal file
18
web/utils/yaml.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { loadYaml } from './yaml'
|
||||
|
||||
describe('loadYaml', () => {
|
||||
it('should reject duplicate mapping keys', () => {
|
||||
expect(() => loadYaml('name: first\nname: second\n'))
|
||||
.toThrow(/duplicated mapping key/)
|
||||
})
|
||||
|
||||
it('should reject complex mapping keys', () => {
|
||||
expect(() => loadYaml('? [name, region]\n: deployment\n'))
|
||||
.toThrow(/does not support complex keys/)
|
||||
})
|
||||
|
||||
it('should parse explicit YAML sets as JavaScript sets', () => {
|
||||
expect(loadYaml('features: !!set\n workflow:\n chat:\n'))
|
||||
.toEqual({ features: new Set(['workflow', 'chat']) })
|
||||
})
|
||||
})
|
||||
21
web/utils/yaml.ts
Normal file
21
web/utils/yaml.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
binaryTag,
|
||||
CORE_SCHEMA,
|
||||
loadAll,
|
||||
mergeTag,
|
||||
omapTag,
|
||||
pairsTag,
|
||||
setTag,
|
||||
timestampTag,
|
||||
YAMLException,
|
||||
} from 'js-yaml'
|
||||
|
||||
const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag)
|
||||
|
||||
export function loadYaml(input: string) {
|
||||
const documents = loadAll(input, { schema: YAML_LOAD_SCHEMA })
|
||||
if (documents.length > 1)
|
||||
throw new YAMLException('expected a single document in the stream, but found more')
|
||||
|
||||
return documents[0]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user