diff --git a/README.md b/README.md index 6ffadce..1b5c670 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,21 @@ To log out and remove stored credentials: cloudcannon logout ``` +## Shell completions + +Enable tab completion for your shell by adding the following to your shell config: + +```sh +# zsh +echo 'source <(cloudcannon complete zsh)' >> ~/.zshrc + +# bash +echo 'source <(cloudcannon complete bash)' >> ~/.bashrc + +# fish +echo 'cloudcannon complete fish | source' >> ~/.config/fish/config.fish +``` + ## Usage ``` diff --git a/package-lock.json b/package-lock.json index 6f505a9..a0c8470 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.0.0", "license": "ISC", "dependencies": { + "@bomb.sh/tab": "0.0.15", "@clack/prompts": "1.2.0", "@cloudcannon/configuration-loader": "0.0.10", "@cloudcannon/configuration-types": "0.0.58", - "@cloudcannon/gadget": "0.0.32", + "@cloudcannon/gadget": "0.0.33", "@cloudcannon/sdk": "0.0.5", "ajv": "8.20.0", "citty": "0.2.2", + "fastest-levenshtein": "1.0.16", "yaml": "2.8.3" }, "bin": { @@ -206,6 +208,31 @@ "node": ">=14.21.3" } }, + "node_modules/@bomb.sh/tab": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@bomb.sh/tab/-/tab-0.0.15.tgz", + "integrity": "sha512-Y90ub44TAvbdO9P8mcD/XPyQjFhiR5xmd4Fk7JErmWmEWEUimNnjWiBrVZ16Tj3GA1rLZ+uvCN2V/pzLawv31g==", + "license": "MIT", + "bin": { + "tab": "dist/bin/cli.mjs" + }, + "peerDependencies": { + "cac": "^6.7.14", + "citty": "^0.1.6 || ^0.2.0", + "commander": "^13.1.0" + }, + "peerDependenciesMeta": { + "cac": { + "optional": true + }, + "citty": { + "optional": true + }, + "commander": { + "optional": true + } + } + }, "node_modules/@clack/core": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", @@ -258,9 +285,9 @@ } }, "node_modules/@cloudcannon/gadget": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/@cloudcannon/gadget/-/gadget-0.0.32.tgz", - "integrity": "sha512-qbWYn6ugm3cJri/nVy0CxVQQrLhmmlMncL6orYCDYvbilgXLga9JbnWv96fkbm0zDhEaBi/aonH0+CX1b/pDmw==", + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@cloudcannon/gadget/-/gadget-0.0.33.tgz", + "integrity": "sha512-BI2riFpWcLGmaSuOW8DLv6+TgTXN52q6pKR7F8mfY7xWnGraUpUbW4XlIXCIrXXXVlda8okJjp7EWUwv51lO0w==", "license": "ISC", "dependencies": { "@sindresorhus/slugify": "3.0.0", diff --git a/package.json b/package.json index 54ffdc9..28f8d66 100644 --- a/package.json +++ b/package.json @@ -58,13 +58,15 @@ }, "license": "ISC", "dependencies": { + "@bomb.sh/tab": "0.0.15", "@clack/prompts": "1.2.0", "@cloudcannon/configuration-loader": "0.0.10", "@cloudcannon/configuration-types": "0.0.58", - "@cloudcannon/gadget": "0.0.32", + "@cloudcannon/gadget": "0.0.33", "@cloudcannon/sdk": "0.0.5", "ajv": "8.20.0", "citty": "0.2.2", + "fastest-levenshtein": "1.0.16", "yaml": "2.8.3" } } diff --git a/src/configure/utility.ts b/src/configure/utility.ts index 09b858f..e05474c 100644 --- a/src/configure/utility.ts +++ b/src/configure/utility.ts @@ -95,6 +95,7 @@ export const text = { good: (t: string): string => styleText(['green'], t), bad: (t: string): string => styleText(['red'], t), secondary: (t: string): string => styleText(['dim'], t), + value: (t: string): string => styleText(['cyan'], t), }; export const checkSsg = (value: unknown | undefined): SsgKey | undefined => { diff --git a/src/configure/validate.ts b/src/configure/validate.ts index 0c69aa2..864aa71 100644 --- a/src/configure/validate.ts +++ b/src/configure/validate.ts @@ -1,351 +1,20 @@ -import { access, readFile } from 'node:fs/promises'; import { relative, resolve } from 'node:path'; -import { type GlobTypeKey, loadConfiguration } from '@cloudcannon/configuration-loader'; -import type { Configuration } from '@cloudcannon/configuration-types'; -import collectionsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-collections.schema.json' with { - type: 'json', -}; -import configSchema from '@cloudcannon/configuration-types/dist/cloudcannon-config.latest.schema.json' with { - type: 'json', -}; -import editablesSchema from '@cloudcannon/configuration-types/dist/cloudcannon-editables.schema.json' with { - type: 'json', -}; -import settingsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-initial-site-settings.schema.json' with { - type: 'json', -}; -import inputsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-inputs.schema.json' with { - type: 'json', -}; -import routingSchema from '@cloudcannon/configuration-types/dist/cloudcannon-routing.schema.json' with { - type: 'json', -}; -import schemasSchema from '@cloudcannon/configuration-types/dist/cloudcannon-schemas.schema.json' with { - type: 'json', -}; -import snippetsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets.schema.json' with { - type: 'json', -}; -import snippetsDefinitionsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets-definitions.schema.json' with { - type: 'json', -}; -import snippetsImportsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets-imports.schema.json' with { - type: 'json', -}; -import structureValueSchema from '@cloudcannon/configuration-types/dist/cloudcannon-structure-value.schema.json' with { - type: 'json', -}; -import structuresSchema from '@cloudcannon/configuration-types/dist/cloudcannon-structures.schema.json' with { - type: 'json', -}; -import { Ajv, type ErrorObject, type ValidateFunction } from 'ajv'; import { type CommandContext, defineCommand } from 'citty'; -import { parse as parseYaml } from 'yaml'; import { pathArg, text } from './utility.ts'; - -const CONFIG_FILENAMES = [ - 'cloudcannon.config.yml', - 'cloudcannon.config.yaml', - 'cloudcannon.config.json', -] as const; - -const SETTINGS_PATH = '.cloudcannon/initial-site-settings.json'; -const ROUTING_PATH = '.cloudcannon/routing.json'; - -const ajv = new Ajv({ strict: false, allErrors: true, verbose: true }); -const validateConfig = ajv.compile(configSchema); -const validateSettings = ajv.compile(settingsSchema); -const validateRouting = ajv.compile(routingSchema); -const validateCollections = ajv.compile(collectionsSchema); -const validateEditables = ajv.compile(editablesSchema); -const validateInputs = ajv.compile(inputsSchema); -const validateSchemas = ajv.compile(schemasSchema); -const validateSnippets = ajv.compile(snippetsSchema); -const validateSnippetsDefinitions = ajv.compile(snippetsDefinitionsSchema); -const validateSnippetsImports = ajv.compile(snippetsImportsSchema); -const validateStructureValue = ajv.compile(structureValueSchema); -const validateStructures = ajv.compile(structuresSchema); - -const GLOB_KEY_VALIDATORS: Partial> = { - collections_config_from_glob: validateCollections, - schemas_from_glob: validateSchemas, - _editables_from_glob: validateEditables, - _inputs_from_glob: validateInputs, - _snippets_from_glob: validateSnippets, - _snippets_definitions_from_glob: validateSnippetsDefinitions, - _snippets_imports_from_glob: validateSnippetsImports, - _structures_from_glob: validateStructures, - values_from_glob: validateStructureValue, -}; - -async function findSplitConfigFiles( - configPath: string, - parsedConfig: Configuration, - targetPath: string -): Promise> { - const { pathsToGlobKey } = await loadConfiguration(configPath, { - parseFile: (contents: string, filePath: string) => { - if (filePath === configPath) { - return parsedConfig; - } - - return filePath.endsWith('.json') ? JSON.parse(contents) : parseYaml(contents); - }, - }); - - const entries = Object.entries(pathsToGlobKey); - const results: Array<{ filePath: string; validate: ValidateFunction }> = []; - - for (let i = 0; i < entries.length; i++) { - const [filePath, globKey] = entries[i]; - const validate = GLOB_KEY_VALIDATORS[globKey]; - if (validate) { - results.push({ filePath, validate }); - } else { - console.log(`- unable to validate: ${text.em(relative(targetPath, filePath))}`); - } - } - - return results; -} - -// Collapses duplicate non-value errors emitted once per oneOf/anyOf branch into one per -// (instancePath, schemaPath, keyword). const/enum are left intact for aggregateValueErrors. -function filterBranchErrors(errors: ErrorObject[]): ErrorObject[] { - const seen = new Set(); - const result: ErrorObject[] = []; - - for (let i = 0; i < errors.length; i++) { - if (isValueKeyword(errors[i].keyword)) { - result.push(errors[i]); - continue; - } - - const key = `${errors[i].instancePath}|${errors[i].schemaPath}|${errors[i].keyword}`; - if (addNew(seen, key)) { - result.push(errors[i]); - } - } - - return result; -} - -// When the same field fails across multiple oneOf/anyOf branches (e.g. _inputs.*.type), each -// branch contributes its own const/enum error with a different allowed value. This merges all -// of them into a single enum error so the user sees all valid values at once. -function aggregateValueErrors(errors: ErrorObject[]): ErrorObject[] { - const allowedValuesMap = new Map>(); - - for (let i = 0; i < errors.length; i++) { - if (!isValueKeyword(errors[i].keyword)) { - continue; - } - - let allowedValues = allowedValuesMap.get(errors[i].instancePath); - if (!allowedValues) { - allowedValues = new Set(); - allowedValuesMap.set(errors[i].instancePath, allowedValues); - } - - if (errors[i].keyword === 'const') { - allowedValues.add(errors[i].params.allowedValue); - } else { - for (let j = 0; j < errors[i].params.allowedValues.length; j++) { - allowedValues.add(errors[i].params.allowedValues[j]); - } - } - } - - const seen = new Set(); - const result: ErrorObject[] = []; - - for (let i = 0; i < errors.length; i++) { - if (!isValueKeyword(errors[i].keyword)) { - result.push(errors[i]); - continue; - } - - if (!addNew(seen, errors[i].instancePath)) { - continue; - } - - const allowedValues = Array.from(allowedValuesMap.get(errors[i].instancePath) ?? []); - result.push( - allowedValues.length === 1 && errors[i].keyword === 'const' - ? errors[i] - : { ...errors[i], keyword: 'enum', params: { allowedValues } } - ); - } - - return result; -} - -function isValueKeyword(keyword: string): boolean { - return keyword === 'const' || keyword === 'enum'; -} - -function isStructuralKeyword(keyword: string): boolean { - return keyword === 'oneOf' || keyword === 'anyOf' || keyword === 'additionalProperties'; -} - -// Hide oneOf/anyOf errors when more specific child errors exist -function filterStructuralErrors(errors: ErrorObject[]): ErrorObject[] { - const compositionPaths = new Set(); - const nonStructuralPaths: string[] = []; - - for (let i = 0; i < errors.length; i++) { - if (errors[i].keyword === 'oneOf' || errors[i].keyword === 'anyOf') { - compositionPaths.add(errors[i].instancePath); - } - - if (!isStructuralKeyword(errors[i].keyword)) { - nonStructuralPaths.push(errors[i].instancePath); - } - } - - return errors.filter( - (error) => - !isStructuralKeyword(error.keyword) || - !compositionPaths.has(error.instancePath) || - !nonStructuralPaths.some((p) => p.startsWith(`${error.instancePath}/`)) - ); -} - -function addNew(set: Set, key: string): boolean { - if (set.has(key)) { - return false; - } - - set.add(key); - return true; -} - -function quote(v: unknown): string { - return typeof v === 'string' ? `'${v}'` : String(v); -} - -function formatError(error: ErrorObject): string { - switch (error.keyword) { - case 'const': - return `value ${quote(error.data)} should be ${quote(error.params.allowedValue)}`; - case 'additionalProperties': - return `unexpected property '${error.params.additionalProperty}'`; - case 'enum': { - const shown = error.params.allowedValues.slice(0, 5).map(quote).join(', '); - const extra = error.params.allowedValues.length - 5; - const suffix = extra > 0 ? ` and ${extra} more` : ''; - return `unexpected value ${quote(error.data)}, allowed values: ${shown}${suffix}`; - } - case 'type': { - const expected = Array.isArray(error.params.type) - ? error.params.type.join(' or ') - : error.params.type; - - const actual = Array.isArray(error.data) ? 'array' : typeof error.data; - return `unexpected type ${actual} instead of ${expected}`; - } - default: - return error.message ?? 'unknown error'; - } -} - -async function findConfigFile(targetPath: string, filePath?: string): Promise { - const candidates = filePath - ? [resolve(filePath)] - : CONFIG_FILENAMES.map((f) => resolve(targetPath, f)); - - for (const candidate of candidates) { - try { - await access(candidate); - return candidate; - } catch { - // intentionally ignored - } - } -} - -function checkParsed(displayName: string, validate: ValidateFunction, parsed: unknown): boolean { - if (validate(parsed)) { - console.log(`${text.good('✓ valid')}: ${text.em(displayName)}`); - return true; - } - - console.log(`${text.bad('✗ invalid')}: ${text.em(displayName)}`); - - const errors = filterStructuralErrors( - aggregateValueErrors(filterBranchErrors(validate.errors ?? [])) - ); - - const seen = new Set(); - for (let i = 0; i < errors.length; i++) { - const path = errors[i].instancePath ? `$${errors[i].instancePath.replace(/\//g, '.')}` : '$'; - const line = ` ${text.em(path)}: ${formatError(errors[i])}`; - if (addNew(seen, line)) { - console.log(line); - } - } - - return false; -} - -async function readStdin(): Promise { - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks).toString('utf-8'); -} - -async function readAndParseFile(filePath: string, displayName: string): Promise { - let content: string; - try { - content = await readFile(filePath, 'utf-8'); - } catch { - console.error(`File not found: ${text.em(displayName)}`); - return null; - } - - let parsed: unknown; - try { - parsed = filePath.endsWith('.json') ? JSON.parse(content) : parseYaml(content); - } catch (err) { - console.error( - `Failed to parse ${text.em(displayName)}: ${err instanceof Error ? err.message : String(err)}` - ); - return null; - } - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - console.error(`Expected an object in ${text.em(displayName)}`); - return null; - } - - return parsed; -} - -async function checkFile( - filePath: string, - validate: ValidateFunction, - targetPath: string, - optional = false -): Promise { - const displayName = relative(targetPath, filePath); - - if (optional) { - try { - await access(filePath); - } catch { - return true; - } - } - - const parsed = await readAndParseFile(filePath, displayName); - if (!parsed) { - return false; - } - - return checkParsed(displayName, validate, parsed); -} +import { + CONFIG_FILENAMES, + checkFile, + checkParsed, + findConfigFile, + findSplitConfigFiles, + ROUTING_PATH, + readAndParseFile, + readAndParseStdin, + SETTINGS_PATH, + validateConfig, + validateRouting, + validateSettings, +} from './validator.ts'; const args = { ...pathArg, @@ -405,25 +74,8 @@ export const validateCommand = defineCommand({ ? validateSettings : validateRouting; - const content = await readStdin(); - let parsed: unknown; - try { - try { - parsed = JSON.parse(content); - } catch { - parsed = parseYaml(content); - } - } catch (err) { - console.error(`Failed to parse stdin: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - console.error('Expected an object from stdin'); - process.exit(1); - } - - if (!checkParsed('', validator, parsed)) { + const parsed = await readAndParseStdin(); + if (!parsed || !checkParsed('', validator, parsed)) { process.exit(1); } @@ -454,11 +106,7 @@ export const validateCommand = defineCommand({ allValid = checkParsed(configDisplayName, validateConfig, parsedConfig) && allValid; - const splitConfigFiles = await findSplitConfigFiles( - configPath, - parsedConfig as Configuration, - targetPath - ); + const splitConfigFiles = await findSplitConfigFiles(configPath, parsedConfig, targetPath); for (let i = 0; i < splitConfigFiles.length; i++) { allValid = (await checkFile( diff --git a/src/configure/validator.ts b/src/configure/validator.ts new file mode 100644 index 0000000..5b91ced --- /dev/null +++ b/src/configure/validator.ts @@ -0,0 +1,710 @@ +import { access, readFile } from 'node:fs/promises'; +import { relative, resolve } from 'node:path'; +import { type GlobTypeKey, loadConfiguration } from '@cloudcannon/configuration-loader'; +import type { Configuration } from '@cloudcannon/configuration-types'; +import collectionsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-collections.schema.json' with { + type: 'json', +}; +import configSchema from '@cloudcannon/configuration-types/dist/cloudcannon-config.latest.schema.json' with { + type: 'json', +}; +import editablesSchema from '@cloudcannon/configuration-types/dist/cloudcannon-editables.schema.json' with { + type: 'json', +}; +import settingsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-initial-site-settings.schema.json' with { + type: 'json', +}; +import inputsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-inputs.schema.json' with { + type: 'json', +}; +import routingSchema from '@cloudcannon/configuration-types/dist/cloudcannon-routing.schema.json' with { + type: 'json', +}; +import schemasSchema from '@cloudcannon/configuration-types/dist/cloudcannon-schemas.schema.json' with { + type: 'json', +}; +import snippetsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets.schema.json' with { + type: 'json', +}; +import snippetsDefinitionsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets-definitions.schema.json' with { + type: 'json', +}; +import snippetsImportsSchema from '@cloudcannon/configuration-types/dist/cloudcannon-snippets-imports.schema.json' with { + type: 'json', +}; +import structureValueSchema from '@cloudcannon/configuration-types/dist/cloudcannon-structure-value.schema.json' with { + type: 'json', +}; +import structuresSchema from '@cloudcannon/configuration-types/dist/cloudcannon-structures.schema.json' with { + type: 'json', +}; +import { Ajv, type AnySchemaObject, type ErrorObject, type ValidateFunction } from 'ajv'; +import { closest } from 'fastest-levenshtein'; +import { parse as parseYaml } from 'yaml'; +import { text } from './utility.ts'; + +// Everything the `validate` command needs to compile CloudCannon's schemas, locate and read the +// config (and its split files / stdin), and print a concise pass/fail report. The command in +// validate.ts only interprets flags and exit codes; all validation lives here. + +export const SETTINGS_PATH = '.cloudcannon/initial-site-settings.json'; +export const ROUTING_PATH = '.cloudcannon/routing.json'; +export const CONFIG_FILENAMES = [ + 'cloudcannon.config.yml', + 'cloudcannon.config.yaml', + 'cloudcannon.config.json', +] as const; + +const ajv = new Ajv({ strict: false, allErrors: true, verbose: true }); +export const validateConfig = ajv.compile(configSchema); +export const validateSettings = ajv.compile(settingsSchema); +export const validateRouting = ajv.compile(routingSchema); + +const GLOB_KEY_VALIDATORS: Partial> = { + collections_config_from_glob: ajv.compile(collectionsSchema), + schemas_from_glob: ajv.compile(schemasSchema), + _editables_from_glob: ajv.compile(editablesSchema), + _inputs_from_glob: ajv.compile(inputsSchema), + _snippets_from_glob: ajv.compile(snippetsSchema), + _snippets_definitions_from_glob: ajv.compile(snippetsDefinitionsSchema), + _snippets_imports_from_glob: ajv.compile(snippetsImportsSchema), + _structures_from_glob: ajv.compile(structuresSchema), + values_from_glob: ajv.compile(structureValueSchema), +}; + +// Resolves the config file to validate: an explicit path if given, otherwise the first of the +// known filenames that exists under targetPath. Returns undefined when none is found. +export async function findConfigFile( + targetPath: string, + filePath?: string +): Promise { + const candidates = filePath + ? [resolve(filePath)] + : CONFIG_FILENAMES.map((f) => resolve(targetPath, f)); + + for (const candidate of candidates) { + try { + await access(candidate); + return candidate; + } catch { + // intentionally ignored + } + } +} + +// Discovers the split configuration files referenced from the config (via `*_from_glob` keys), +// pairing each with the validator for its type. Files of an unknown type are reported and skipped. +export async function findSplitConfigFiles( + configPath: string, + parsedConfig: unknown, + targetPath: string +): Promise> { + const { pathsToGlobKey } = await loadConfiguration(configPath, { + parseFile: (contents: string, filePath: string) => { + if (filePath === configPath) { + return parsedConfig as Configuration; + } + + return filePath.endsWith('.json') ? JSON.parse(contents) : parseYaml(contents); + }, + }); + + const entries = Object.entries(pathsToGlobKey); + const results: Array<{ filePath: string; validate: ValidateFunction }> = []; + + for (let i = 0; i < entries.length; i++) { + const [filePath, globKey] = entries[i]; + const validate = GLOB_KEY_VALIDATORS[globKey]; + if (validate) { + results.push({ filePath, validate }); + } else { + console.log(`- unable to validate: ${text.em(relative(targetPath, filePath))}`); + } + } + + return results; +} + +// Reads and parses a YAML/JSON file into an object, printing a message and returning null on a +// missing file, a parse error, or a non-object top level. +export async function readAndParseFile( + filePath: string, + displayName: string +): Promise { + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + console.error(`File not found: ${text.em(displayName)}`); + return null; + } + + let parsed: unknown; + try { + parsed = filePath.endsWith('.json') ? JSON.parse(content) : parseYaml(content); + } catch (err) { + console.error( + `Failed to parse ${text.em(displayName)}: ${err instanceof Error ? err.message : String(err)}` + ); + return null; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.error(`Expected an object in ${text.em(displayName)}`); + return null; + } + + return parsed; +} + +// Reads stdin to completion and parses it as JSON (falling back to YAML), printing a message and +// returning null on a parse error or a non-object top level. +export async function readAndParseStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const content = Buffer.concat(chunks).toString('utf-8'); + + let parsed: unknown; + try { + try { + parsed = JSON.parse(content); + } catch { + parsed = parseYaml(content); + } + } catch (err) { + console.error(`Failed to parse stdin: ${err instanceof Error ? err.message : String(err)}`); + return null; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + console.error('Expected an object from stdin'); + return null; + } + + return parsed; +} + +// Reads a file (optionally skipping a missing one) and validates it, returning whether it's valid. +export async function checkFile( + filePath: string, + validate: ValidateFunction, + targetPath: string, + optional = false +): Promise { + const displayName = relative(targetPath, filePath); + + if (optional) { + try { + await access(filePath); + } catch { + return true; + } + } + + const parsed = await readAndParseFile(filePath, displayName); + if (!parsed) { + return false; + } + + return checkParsed(displayName, validate, parsed); +} + +// Validates an already-parsed object against a schema, printing a pass line or the formatted +// errors, and returns whether it's valid. +export function checkParsed( + displayName: string, + validate: ValidateFunction, + parsed: unknown +): boolean { + if (validate(parsed)) { + console.log(`${text.good('✓ valid')}: ${text.em(displayName)}`); + return true; + } + + console.log(`${text.bad('✗ invalid')}: ${text.em(displayName)}`); + + const errors = filterStructuralErrors( + aggregateBranchExpectations( + suppressNonMatchingBranchErrors(validate.errors ?? [], validate.schema as AnySchemaObject) + ) + ); + + // Deduplicate by the rendered line: identical messages (e.g. the same required/structural + // error emitted once per union branch) collapse, while distinct ones are all reported. + const seen = new Set(); + for (let i = 0; i < errors.length; i++) { + const path = formatInstancePath(errors[i].instancePath, parsed); + const line = ` ${text.em(path)}: ${formatError(errors[i])}`; + if (!seen.has(line)) { + seen.add(line); + console.log(line); + } + } + + return false; +} + +// --- AJV error presentation ------------------------------------------------------------------ +// Turns AJV's raw, `allErrors` output into the concise messages checkParsed prints. The pipeline +// is: suppressNonMatchingBranchErrors -> aggregateBranchExpectations -> filterStructuralErrors, +// then formatError / formatInstancePath render each surviving error. + +// The field that discriminates an input's anyOf union (_inputs.*.type — text, markdown, …). +const DISCRIMINATOR = 'type'; +// Guards both `$ref` resolution and the recursion into a chosen branch's own nested unions. +const MAX_BRANCH_DEPTH = 5; + +// A separate AJV instance for re-validating individual union branches. Compiled branch +// validators are cached by branch-schema object, since the same branch is reused across +// every instance that hits the union (e.g. one validator per input type for all _inputs). +const branchAjv = new Ajv({ strict: false, allErrors: true, verbose: true }); +const branchValidators = new WeakMap(); + +// With `allErrors`, AJV reports failures from every branch of an anyOf/oneOf — including +// branches the data never targeted (e.g. a `markdown` input collecting errors from the +// `number` and `color` branches). Inputs are discriminated by their `type`, so for each failing +// input union we re-validate the data against only the branch whose `type` matches and replace +// the union's noisy errors with that branch's real errors. Non-input unions (and inputs whose +// `type` matches no branch) are left untouched — see isInputUnion / matchedBranchErrors. +function suppressNonMatchingBranchErrors( + errors: ErrorObject[], + rootSchema: AnySchemaObject, + depth = 0 +): ErrorObject[] { + if (depth > MAX_BRANCH_DEPTH) { + return errors; + } + + // Group the union (anyOf/oneOf) errors by the instance location they apply to. + const unionsByPath = new Map(); + for (const error of errors) { + if (error.keyword === 'anyOf' || error.keyword === 'oneOf') { + const existing = unionsByPath.get(error.instancePath); + if (existing) { + existing.push(error); + } else { + unionsByPath.set(error.instancePath, [error]); + } + } + } + + const replacements: Array<{ path: string; errors: ErrorObject[] }> = []; + + // Process outer unions before nested ones so a replaced subtree's inner unions are handled + // by the recursion below rather than against the (about to be discarded) original errors. + const paths = Array.from(unionsByPath.keys()).sort((a, b) => a.length - b.length); + for (const path of paths) { + if (replacements.some((r) => r.path !== path && isUnderPath(path, r.path))) { + continue; + } + + // Prefer the most specific (deepest) union reported at this location. + const unions = unionsByPath.get(path) ?? []; + unions.sort((a, b) => b.schemaPath.length - a.schemaPath.length); + + for (const union of unions) { + const branchErrors = matchedBranchErrors(union, rootSchema); + if (!branchErrors) { + continue; + } + + const rebased = branchErrors.map((error) => ({ + ...error, + instancePath: path + error.instancePath, + })); + replacements.push({ + path, + errors: suppressNonMatchingBranchErrors(rebased, rootSchema, depth + 1), + }); + break; + } + } + + if (replacements.length === 0) { + return errors; + } + + const kept = errors.filter( + (error) => !replacements.some((r) => isUnderPath(error.instancePath, r.path)) + ); + return [...kept, ...replacements.flatMap((r) => r.errors)]; +} + +function isUnderPath(path: string, base: string): boolean { + return path === base || path.startsWith(`${base}/`); +} + +// Inputs are the only `type`-discriminated union in CloudCannon's schemas, so the suppression is +// scoped to input positions: an entry in an `_inputs` map (top-level or nested under a structure), +// or a top-level entry of a standalone inputs file (which is itself an `_inputs` map). This keeps +// the discriminator logic from ever acting on an unrelated `type` field elsewhere in a config. +function isInputUnion(instancePath: string, rootSchema: AnySchemaObject): boolean { + const parent = instancePath.slice(0, instancePath.lastIndexOf('/')); + if (parent.endsWith('/_inputs')) { + return true; + } + + return rootSchema.$id === inputsSchema.$id && instancePath.lastIndexOf('/') === 0; +} + +// If `union` is a discriminated input union whose `type` matches a branch (and at least one other +// branch rejects it), re-validates the data against the matching branch(es) and returns their +// errors. Returns undefined when the union isn't an input, isn't discriminated, nothing matches, +// or a branch can't be re-validated — in which case the caller leaves the original errors in place. +function matchedBranchErrors( + union: ErrorObject, + rootSchema: AnySchemaObject +): ErrorObject[] | undefined { + if (!isInputUnion(union.instancePath, rootSchema)) { + return undefined; + } + + const branches = union.parentSchema?.[union.keyword]; + const data = union.data; + if (!Array.isArray(branches) || !data || typeof data !== 'object') { + return undefined; + } + + const actual = (data as Record)[DISCRIMINATOR]; + if (actual === undefined) { + return undefined; + } + + let discriminated = 0; + const matched: AnySchemaObject[] = []; + for (const branch of branches) { + const allowed = branchDiscriminatorValues(branch, rootSchema); + if (!allowed) { + continue; + } + discriminated += 1; + if (allowed.includes(actual)) { + matched.push(branch); + } + } + + // Only suppress when this really is a discriminated union: something matched, and at least + // one other discriminated branch didn't (otherwise there's no cross-branch noise to remove). + if (matched.length === 0 || discriminated <= matched.length) { + return undefined; + } + + const result: ErrorObject[] = []; + for (const branch of matched) { + const branchErrors = revalidateBranch(data, branch, rootSchema); + if (!branchErrors) { + return undefined; + } + result.push(...branchErrors); + } + return result; +} + +// The `type` values a branch accepts (following a single `$ref`), or undefined if the branch +// doesn't pin `type` with a const/enum and therefore isn't part of the discrimination. +function branchDiscriminatorValues( + branch: unknown, + rootSchema: AnySchemaObject +): unknown[] | undefined { + const type = resolveRef(branch, rootSchema)?.properties?.[DISCRIMINATOR]; + + if (!type || typeof type !== 'object') { + return undefined; + } + + if ('const' in type) { + return [type.const]; + } + + return Array.isArray(type.enum) ? type.enum : undefined; +} + +function resolveRef( + schema: unknown, + rootSchema: AnySchemaObject, + depth = 0 +): AnySchemaObject | undefined { + if (schema && typeof schema === 'object' && depth <= MAX_BRANCH_DEPTH && '$ref' in schema) { + if (typeof schema.$ref === 'string') { + const match = schema.$ref.match(/^#\/(definitions|\$defs)\/(.+)$/); + if (match) { + const dictionary = (rootSchema as Record)[match[1]]; + return resolveRef(dictionary?.[match[2]], rootSchema, depth + 1); + } + } + } + return (schema ?? undefined) as AnySchemaObject | undefined; +} + +// Re-validates `data` against a single union branch, resolving the branch's `$ref`s via the +// root schema's definitions. Returns its errors, or undefined if the branch can't compile. +function revalidateBranch( + data: unknown, + branch: AnySchemaObject, + rootSchema: AnySchemaObject +): ErrorObject[] | undefined { + let validate = branchValidators.get(branch); + if (validate === undefined) { + try { + validate = branchAjv.compile({ + allOf: [branch], + definitions: rootSchema.definitions, + $defs: rootSchema.$defs, + }); + } catch { + validate = null; + } + branchValidators.set(branch, validate); + } + + if (!validate) { + return undefined; + } + + validate(data); + return (validate.errors ?? []).map((error) => ({ ...error })); +} + +// When a field fails against multiple oneOf/anyOf branches, AJV reports one error per branch. +// This collapses the duplicates a single field accumulates into one error per kind of mismatch: +// all the const/enum branches merge into one "allowed values: …" list (e.g. _inputs.*.type), and +// all the `type` branches merge into one "instead of string or object" (e.g. options.structures). +function aggregateBranchExpectations(errors: ErrorObject[]): ErrorObject[] { + const valuesByPath = new Map>(); + const typesByPath = new Map>(); + + for (const error of errors) { + if (error.keyword === 'const') { + addTo(valuesByPath, error.instancePath, [error.params.allowedValue]); + } else if (error.keyword === 'enum') { + addTo(valuesByPath, error.instancePath, error.params.allowedValues); + } else if (error.keyword === 'type') { + const type = error.params.type; + addTo(typesByPath, error.instancePath, Array.isArray(type) ? type : [type]); + } + } + + const seen = new Set(); + const result: ErrorObject[] = []; + + for (const error of errors) { + if (isValueKeyword(error.keyword)) { + if (!addNew(seen, `value|${error.instancePath}`)) { + continue; + } + // Values whose type is also listed by a `type` branch (e.g. {type: boolean, const: false}) + // are folded into that type's label below, so leave them off the standalone value line. + const types = typesByPath.get(error.instancePath); + const allowedValues = Array.from(valuesByPath.get(error.instancePath) ?? []).filter( + (value) => !types?.has(jsonType(value)) + ); + if (allowedValues.length === 0) { + continue; + } + result.push( + allowedValues.length === 1 && error.keyword === 'const' + ? error + : { ...error, keyword: 'enum', params: { allowedValues } } + ); + } else if (error.keyword === 'type') { + if (!addNew(seen, `type|${error.instancePath}`)) { + continue; + } + const type = Array.from(typesByPath.get(error.instancePath) ?? []); + const values = Array.from(valuesByPath.get(error.instancePath) ?? []); + // Annotate a type with the literal value(s) it's pinned to, so {type: boolean, const: false} + // reads as "boolean (false)" rather than the looser bare "boolean". + const literalsByType: Record = {}; + for (const name of type) { + const literals = values.filter((value) => jsonType(value) === name); + if (literals.length > 0) { + literalsByType[name] = literals; + } + } + result.push({ + ...error, + params: { ...error.params, type: type.length === 1 ? type[0] : type, literalsByType }, + }); + } else { + result.push(error); + } + } + + return result; +} + +function isValueKeyword(keyword: string): boolean { + return keyword === 'const' || keyword === 'enum'; +} + +// The JSON Schema type name for a runtime value, matching how `type` errors name the actual type. +function jsonType(value: unknown): string { + return value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value; +} + +function isStructuralKeyword(keyword: string): boolean { + return keyword === 'oneOf' || keyword === 'anyOf' || keyword === 'additionalProperties'; +} + +// Trims redundant union noise. At an anyOf/oneOf location AJV reports both an umbrella "no branch +// matched" error and each branch's own complaint; this drops: +// - the umbrella when a concrete error already explains the failure at that path or deeper, and +// - a losing branch's "wrong type" error when the value actually matched another branch and +// failed deeper inside it (e.g. an object given to a string|object union that fails within the +// object — reporting "allowed types: string" there would wrongly imply an object is invalid). +function filterStructuralErrors(errors: ErrorObject[]): ErrorObject[] { + const compositionPaths = new Set(); + const nonStructuralPaths: string[] = []; + + for (const error of errors) { + if (error.keyword === 'oneOf' || error.keyword === 'anyOf') { + compositionPaths.add(error.instancePath); + } + + if (!isStructuralKeyword(error.keyword)) { + nonStructuralPaths.push(error.instancePath); + } + } + + const hasDeeperError = (path: string): boolean => + nonStructuralPaths.some((p) => p.startsWith(`${path}/`)); + const hasSameOrDeeperError = (path: string): boolean => + hasDeeperError(path) || nonStructuralPaths.includes(path); + + return errors.filter((error) => { + if (!compositionPaths.has(error.instancePath)) { + return true; + } + + if (isStructuralKeyword(error.keyword)) { + return !hasSameOrDeeperError(error.instancePath); + } + + if (error.keyword === 'type') { + return !hasDeeperError(error.instancePath); + } + + return true; + }); +} + +function addNew(set: Set, key: string): boolean { + if (set.has(key)) { + return false; + } + + set.add(key); + return true; +} + +function addTo(map: Map>, key: string, values: Iterable): void { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + + for (const value of values) { + set.add(value); + } +} + +function highlight(v: unknown): string { + return text.value(String(v)); +} + +function formatError(error: ErrorObject): string { + switch (error.keyword) { + case 'const': + return `value ${highlight(error.data)} should be ${highlight(error.params.allowedValue)}`; + case 'additionalProperties': + return `unexpected property ${highlight(error.params.additionalProperty)}`; + case 'required': + return `must have required property ${highlight(error.params.missingProperty)}`; + case 'enum': { + const visibleValues = 20; + const allowedValues: unknown[] = error.params.allowedValues; + + // Too many to list usefully (e.g. the ~3500 icon names): show the count, and point at the + // nearest match so the user has something actionable rather than a truncated dump. + if (allowedValues.length > visibleValues) { + const candidates = allowedValues.filter( + (value): value is string => typeof value === 'string' + ); + const match = + typeof error.data === 'string' && candidates.length > 0 + ? ` (closest: ${highlight(closest(error.data, candidates))})` + : ''; + return `unexpected value ${highlight(error.data)}, allowed values: ${allowedValues.length} values${match}`; + } + + const shown = allowedValues.map(highlight).join(', '); + return `unexpected value ${highlight(error.data)}, allowed values: ${shown}`; + } + case 'type': { + const types = Array.isArray(error.params.type) ? error.params.type : [error.params.type]; + const literalsByType: Record = error.params.literalsByType ?? {}; + const allowed = types + .map((name: string) => { + const literals = literalsByType[name]; + return literals?.length + ? `${highlight(name)} (${literals.map(highlight).join(', ')})` + : highlight(name); + }) + .join(', '); + + return `unexpected type ${highlight(jsonType(error.data))}, allowed types: ${allowed}`; + } + case 'anyOf': + return 'does not match any of the allowed formats'; + case 'oneOf': + return 'must match exactly one of the allowed formats'; + case 'pattern': + return `does not match pattern ${highlight(error.params.pattern)}`; + case 'format': + return `not a valid ${highlight(error.params.format)}`; + case 'propertyNames': + return `invalid property name ${highlight(error.params.propertyName)}`; + case 'uniqueItems': + return 'must not contain duplicate items'; + case 'not': + return 'must not match the disallowed format'; + case 'minimum': + case 'maximum': + case 'exclusiveMinimum': + case 'exclusiveMaximum': { + const comparisons: Record = { + '>=': 'at least', + '<=': 'at most', + '>': 'greater than', + '<': 'less than', + }; + const comparison = comparisons[error.params.comparison] ?? error.params.comparison; + return `must be ${comparison} ${highlight(error.params.limit)}`; + } + default: + return error.message ?? 'unknown error'; + } +} + +// Renders an AJV instancePath as a JS-style accessor. Walks the parsed data so a numeric segment +// only becomes a bracketed index when it actually indexes an array — a numeric object key (e.g. +// {"0": …}) stays a dotted key: "/values/1/_inputs" -> "$.values[1]._inputs". +function formatInstancePath(instancePath: string, data: unknown): string { + let result = '$'; + let current = data; + + for (const segment of instancePath.split('/').slice(1)) { + // instancePath is a JSON Pointer: ~1 is an escaped "/" and ~0 an escaped "~" within a key. + const key = segment.replace(/~1/g, '/').replace(/~0/g, '~'); + result += Array.isArray(current) ? `[${key}]` : `.${key}`; + current = + current && typeof current === 'object' + ? (current as Record)[key] + : undefined; + } + + return result; +} diff --git a/src/index.ts b/src/index.ts index 07e836a..b69d1f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import tab from '@bomb.sh/tab/citty'; import { defineCommand, runMain } from 'citty'; import pkg from '../package.json' with { type: 'json' }; import { buildsCommand } from './builds.ts'; @@ -29,4 +30,5 @@ const main = defineCommand({ }, }); +await tab(main); runMain(main); diff --git a/src/login.ts b/src/login.ts index 4f59c7d..6fbe844 100644 --- a/src/login.ts +++ b/src/login.ts @@ -6,6 +6,7 @@ import { decodeUserAccessKey, saveUserAccessKey } from './sdk-client.ts'; export const loginCommand = defineCommand({ meta: { name: 'login', + description: 'Log in to your CloudCannon account.', }, args: { 'access-key-id': { diff --git a/src/logout.ts b/src/logout.ts index 585d7cc..1ad382c 100644 --- a/src/logout.ts +++ b/src/logout.ts @@ -4,6 +4,7 @@ import { deleteUserAccessKey } from './sdk-client.ts'; export const logoutCommand = defineCommand({ meta: { name: 'logout', + description: 'Log out and remove stored credentials.', }, args: {}, async run(): Promise { diff --git a/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml b/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml index 371e85f..4fa972b 100644 --- a/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml +++ b/toolproof-tests/validate/initial-site-settings-invalid.toolproof.yml @@ -25,10 +25,10 @@ steps: snapshot_content: |- ╎✓ valid: cloudcannon.config.yml ╎✗ invalid: .cloudcannon/initial-site-settings.json - ╎ $.mode: unexpected value 'invalid', allowed values: 'hosted', 'headless' - ╎ $.ssg: unexpected value 'unknown-ssg', allowed values: 'hugo', 'jekyll', 'eleventy', 'astro', 'lume' and 12 more - ╎ $.build.install_command: unexpected type number instead of string - ╎ $.build.output_path: unexpected type boolean instead of string - ╎ $.build.environment_variables.0: must have required property 'value' - ╎ $.build.environment_variables.0: unexpected property 'pickle' - ╎ $.build.environment_variables.0.key: unexpected type array instead of string + ╎ $.mode: unexpected value invalid, allowed values: hosted, headless + ╎ $.ssg: unexpected value unknown-ssg, allowed values: hugo, jekyll, eleventy, astro, lume, mkdocs, nextjs, sveltekit, bridgetown, docusaurus, gatsby, hexo, nuxtjs, sphinx, static, legacy, other + ╎ $.build.install_command: unexpected type number, allowed types: string + ╎ $.build.output_path: unexpected type boolean, allowed types: string + ╎ $.build.environment_variables[0]: must have required property value + ╎ $.build.environment_variables[0]: unexpected property pickle + ╎ $.build.environment_variables[0].key: unexpected type array, allowed types: string diff --git a/toolproof-tests/validate/invalid.toolproof.yml b/toolproof-tests/validate/invalid.toolproof.yml index fa702f2..e5948f1 100644 --- a/toolproof-tests/validate/invalid.toolproof.yml +++ b/toolproof-tests/validate/invalid.toolproof.yml @@ -30,11 +30,10 @@ steps: - snapshot: stdout snapshot_content: |- ╎✗ invalid: cloudcannon.config.yml - ╎ $: unexpected property '_select_datasets' - ╎ $.version: value 'newest' should be 'latest' - ╎ $.collections_config.posts.icon: unexpected value 'nature_person', allowed values: '123', '360', '10k', '10mp', '11mp' and 3579 more - ╎ $.collections_config.posts.create: unexpected type number instead of object - ╎ $.collections_config.data: must have required property 'path' - ╎ $.collections_config.data.icon: unexpected value 'date_usage', allowed values: '123', '360', '10k', '10mp', '11mp' and 3579 more - ╎ $._inputs.date.instance_value: unexpected value 'now', allowed values: 'UUID', 'NOW' - ╎ $._inputs.date.type: unexpected value 'datetime', allowed values: 'text', 'email', 'disabled', 'pinterest', 'facebook' and 24 more + ╎ $: unexpected property _select_datasets + ╎ $.version: value newest should be latest + ╎ $.collections_config.posts.icon: unexpected value nature_person, allowed values: 3584 values (closest: nature_people) + ╎ $.collections_config.posts.create: unexpected type number, allowed types: object + ╎ $.collections_config.data: must have required property path + ╎ $.collections_config.data.icon: unexpected value date_usage, allowed values: 3584 values (closest: data_usage) + ╎ $._inputs.date.instance_value: unexpected value now, allowed values: UUID, NOW diff --git a/toolproof-tests/validate/literal-type-annotation-invalid.toolproof.yml b/toolproof-tests/validate/literal-type-annotation-invalid.toolproof.yml new file mode 100644 index 0000000..6ff1ab3 --- /dev/null +++ b/toolproof-tests/validate/literal-type-annotation-invalid.toolproof.yml @@ -0,0 +1,18 @@ +name: cloudcannon validate [invalid, literal annotated in allowed types] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + team: + path: src/content/team + preview: + image: + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $.collections_config.team.preview.image: unexpected type null, allowed types: array, string, boolean (false) diff --git a/toolproof-tests/validate/markdown-input-invalid.toolproof.yml b/toolproof-tests/validate/markdown-input-invalid.toolproof.yml new file mode 100644 index 0000000..bc5a67e --- /dev/null +++ b/toolproof-tests/validate/markdown-input-invalid.toolproof.yml @@ -0,0 +1,29 @@ +name: cloudcannon validate [invalid, markdown input] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + _inputs: + text: + type: markdown + comment: The testimonial quote text. + options: + blockquote: false + bold: true + format: + italic: true + link: true + strike: false + subscript: true + superscript: true + underline: false + bulletedlist: false + numberedlist: false + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $._inputs.text.options.format: unexpected type null, allowed types: string diff --git a/toolproof-tests/validate/multiple-type-branches-invalid.toolproof.yml b/toolproof-tests/validate/multiple-type-branches-invalid.toolproof.yml new file mode 100644 index 0000000..6a5448f --- /dev/null +++ b/toolproof-tests/validate/multiple-type-branches-invalid.toolproof.yml @@ -0,0 +1,22 @@ +name: cloudcannon validate [invalid, multiple anyOf type branches] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + _inputs: + verticalOffset: + type: object + comment: Offset section up above or below the previous section. + options: + structures: + - label: Vertical Offset + value: + size: xl + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $._inputs.verticalOffset.options.structures: unexpected type array, allowed types: string, object diff --git a/toolproof-tests/validate/multiple-unexpected-properties-invalid.toolproof.yml b/toolproof-tests/validate/multiple-unexpected-properties-invalid.toolproof.yml new file mode 100644 index 0000000..6395098 --- /dev/null +++ b/toolproof-tests/validate/multiple-unexpected-properties-invalid.toolproof.yml @@ -0,0 +1,17 @@ +name: cloudcannon validate [invalid, multiple unexpected properties] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + source: src + foo: 1 + bar: 2 + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $: unexpected property foo + ╎ $: unexpected property bar diff --git a/toolproof-tests/validate/routing-only-invalid.toolproof.yml b/toolproof-tests/validate/routing-only-invalid.toolproof.yml index cb30687..c4bfc8c 100644 --- a/toolproof-tests/validate/routing-only-invalid.toolproof.yml +++ b/toolproof-tests/validate/routing-only-invalid.toolproof.yml @@ -20,7 +20,7 @@ steps: - snapshot: stdout snapshot_content: |- ╎✗ invalid: .cloudcannon/routing.json - ╎ $.routes.0: must have required property 'to' - ╎ $.routes.0: unexpected property 'two' - ╎ $.routes.0.from: unexpected type array instead of string - ╎ $.routes.0.status: unexpected value 999, allowed values: 200, 301, 302, 303, 307 and 3 more + ╎ $.routes[0]: must have required property to + ╎ $.routes[0]: unexpected property two + ╎ $.routes[0].from: unexpected type array, allowed types: string + ╎ $.routes[0].status: unexpected value 999, allowed values: 200, 301, 302, 303, 307, 308, 404, 410 diff --git a/toolproof-tests/validate/split-configuration-invalid.toolproof.yml b/toolproof-tests/validate/split-configuration-invalid.toolproof.yml index 56e7986..fcab9d3 100644 --- a/toolproof-tests/validate/split-configuration-invalid.toolproof.yml +++ b/toolproof-tests/validate/split-configuration-invalid.toolproof.yml @@ -83,36 +83,31 @@ steps: - snapshot: stdout snapshot_content: |- ╎✗ invalid: cloudcannon.config.yml - ╎ $: unexpected property 'something' + ╎ $: unexpected property something ╎✗ invalid: cloudcannon.snippets.yml - ╎ $.callout.template: unexpected type number instead of string + ╎ $.callout.template: unexpected type number, allowed types: string ╎✗ invalid: cloudcannon.snippets-imports.yml - ╎ $.mdx: unexpected type object instead of boolean - ╎ $.mdx: must have required property 'exclude' - ╎ $.mdx: unexpected property 'path' - ╎ $.mdx: must have required property 'include' - ╎ $.mdx: must match a schema in anyOf + ╎ $.mdx: unexpected type object, allowed types: boolean + ╎ $.mdx: must have required property exclude + ╎ $.mdx: must have required property include ╎✓ valid: cloudcannon.snippets-definitions.yml ╎✗ invalid: cloudcannon.collections.yml - ╎ $.posts: must have required property 'path' - ╎ $.posts: unexpected property 'sort' + ╎ $.posts: must have required property path + ╎ $.posts: unexpected property sort ╎✗ invalid: custom-colls.yml - ╎ $.staff.sort_options.0: unexpected type string instead of object + ╎ $.staff.sort_options[0]: unexpected type string, allowed types: object ╎✗ invalid: blog.cloudcannon.inputs.yml - ╎ $.date.instance_value: unexpected value 'now', allowed values: 'UUID', 'NOW' - ╎ $.date.type: unexpected value 'datetime', allowed values: 'text', 'email', 'disabled', 'pinterest', 'facebook' and 24 more + ╎ $.date.instance_value: unexpected value now, allowed values: UUID, NOW ╎✗ invalid: cloudcannon.inputs.json - ╎ $.date.instance_value: unexpected value 'now', allowed values: 'UUID', 'NOW' - ╎ $.date.type: unexpected value 'datetime', allowed values: 'text', 'email', 'disabled', 'pinterest', 'facebook' and 24 more + ╎ $.date.instance_value: unexpected value now, allowed values: UUID, NOW ╎✗ invalid: cloudcannon.inputs.yml - ╎ $.date.instance_value: unexpected value 'now', allowed values: 'UUID', 'NOW' - ╎ $.date.type: unexpected value 'datetime', allowed values: 'text', 'email', 'disabled', 'pinterest', 'facebook' and 24 more + ╎ $.date.instance_value: unexpected value now, allowed values: UUID, NOW ╎✗ invalid: cloudcannon.editables.yml - ╎ $.content.bold: unexpected type string instead of boolean + ╎ $.content.bold: unexpected type string, allowed types: boolean ╎✗ invalid: cloudcannon.structures.yml - ╎ $.article.reorder_inputs: unexpected type string instead of boolean + ╎ $.article.reorder_inputs: unexpected type string, allowed types: boolean ╎✗ invalid: cloudcannon.structure-value.yml - ╎ $: must have required property 'value' + ╎ $: must have required property value ╎✗ invalid: cloudcannon.schemas.yml - ╎ $.article: must have required property 'path' - ╎ $.article.reorder_inputs: unexpected type string instead of boolean + ╎ $.article: must have required property path + ╎ $.article.reorder_inputs: unexpected type string, allowed types: boolean diff --git a/toolproof-tests/configure/validate/split-configuration-outside-root.toolproof.yml b/toolproof-tests/validate/split-configuration-outside-root.toolproof.yml similarity index 94% rename from toolproof-tests/configure/validate/split-configuration-outside-root.toolproof.yml rename to toolproof-tests/validate/split-configuration-outside-root.toolproof.yml index e0ad45d..2088df6 100644 --- a/toolproof-tests/configure/validate/split-configuration-outside-root.toolproof.yml +++ b/toolproof-tests/validate/split-configuration-outside-root.toolproof.yml @@ -14,7 +14,7 @@ steps: yaml: |- title: type: text - - ref: ../../core/run-cli.toolproof.yml + - ref: ../core/run-cli.toolproof.yml - step: I run {command} command: | npm -s run cloudcannon -- validate project --configuration diff --git a/toolproof-tests/validate/union-branch-deep-failure-invalid.toolproof.yml b/toolproof-tests/validate/union-branch-deep-failure-invalid.toolproof.yml new file mode 100644 index 0000000..904f705 --- /dev/null +++ b/toolproof-tests/validate/union-branch-deep-failure-invalid.toolproof.yml @@ -0,0 +1,22 @@ +name: cloudcannon validate [invalid, matched union branch fails deeper] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + _inputs: + price: + type: object + options: + structures: + values: + - label: First + value: {} + - label: Second + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $._inputs.price.options.structures.values[1]: must have required property value diff --git a/toolproof-tests/validate/unique-items-invalid.toolproof.yml b/toolproof-tests/validate/unique-items-invalid.toolproof.yml new file mode 100644 index 0000000..3d71110 --- /dev/null +++ b/toolproof-tests/validate/unique-items-invalid.toolproof.yml @@ -0,0 +1,19 @@ +name: cloudcannon validate [invalid, duplicate array items] + +steps: + - step: I have a "cloudcannon.config.yml" file with the content {yaml} + yaml: |- + collections_config: + posts: + path: p + view_options: + - card + - card + - ref: ../core/run-cli.toolproof.yml + - step: I run {command} + command: | + npm -s run cloudcannon -- validate . --configuration 2>&1 || true + - snapshot: stdout + snapshot_content: |- + ╎✗ invalid: cloudcannon.config.yml + ╎ $.collections_config.posts.view_options: must not contain duplicate items