diff --git a/.changeset/rude-jars-confess.md b/.changeset/rude-jars-confess.md new file mode 100644 index 000000000..5f95c770c --- /dev/null +++ b/.changeset/rude-jars-confess.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: add docs.update cli command diff --git a/packages/create-root/src/root-version.ts b/packages/create-root/src/root-version.ts index 5e9f28fc6..5415b54cb 100644 --- a/packages/create-root/src/root-version.ts +++ b/packages/create-root/src/root-version.ts @@ -1,2 +1,2 @@ // This file is autogenerated by update-root-version.mjs. -export const ROOT_VERSION = '2.4.1'; +export const ROOT_VERSION = '2.5.9'; diff --git a/packages/root-cms/cli/cli.ts b/packages/root-cms/cli/cli.ts index c0887fd85..b69cde2bf 100644 --- a/packages/root-cms/cli/cli.ts +++ b/packages/root-cms/cli/cli.ts @@ -1,6 +1,12 @@ import {Command} from 'commander'; import {bgGreen, black} from 'kleur/colors'; -import {docsGet, docsSet, docsDownload, docsUpload} from './docs.js'; +import { + docsGet, + docsSet, + docsUpdate, + docsDownload, + docsUpload, +} from './docs.js'; import {exportData} from './export.js'; import {generateTypes} from './generate-types.js'; import {importData} from './import.js'; @@ -136,6 +142,33 @@ class CliRunner { 'doc mode: "draft" or "published" (default: "draft")' ) .action(docsDownload); + program + .command('docs.update [value]') + .description( + 'updates a single field in a draft doc by its key path\n\n' + + 'The value can be provided as an inline JSON argument, from a file\n' + + 'via --file, or piped via stdin.\n\n' + + 'Validates the updated document against the collection schema by default.\n' + + 'Use --no-validate to skip validation.\n\n' + + 'Key paths use dot notation. For array fields, use 0-based\n' + + 'numeric indices to target a specific item within the array.\n\n' + + 'Usage examples:\n' + + ' $ root-cms docs.update Pages/home hero.title \'"New Title"\'\n' + + ' $ root-cms docs.update Pages/home meta.count 42\n' + + ' $ root-cms docs.update Pages/home hero.image --file image.json\n' + + ' $ cat value.json | root-cms docs.update Pages/home hero.title\n' + + ' $ root-cms docs.update Pages/home hero.title \'"New Title"\' --no-validate\n\n' + + 'Array field examples:\n' + + ' # Update the title of the first item in an array field called "sections":\n' + + ' $ root-cms docs.update Pages/home sections.0.title \'"Introduction"\'\n\n' + + ' # Update the entire second item in the array:\n' + + ' $ root-cms docs.update Pages/home sections.1 \'{"title": "About", "body": "..."}"\'\n\n' + + ' # Update a deeply nested value inside an array item:\n' + + ' $ root-cms docs.update Pages/home sections.0.cta.label \'"Learn more"\'' + ) + .option('--file ', 'read the value from a JSON file') + .option('--no-validate', 'skip schema validation') + .action(docsUpdate); program .command('docs.upload ') .description( @@ -157,6 +190,7 @@ export { CliRunner, docsGet, docsSet, + docsUpdate, docsDownload, docsUpload, exportData, diff --git a/packages/root-cms/cli/docs.ts b/packages/root-cms/cli/docs.ts index 115f101e1..087571cc7 100644 --- a/packages/root-cms/cli/docs.ts +++ b/packages/root-cms/cli/docs.ts @@ -1,11 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; import * as readline from 'node:readline'; -import {loadRootConfig} from '@blinkk/root/node'; +import {fileURLToPath} from 'node:url'; +import {loadRootConfig, viteSsrLoadModule} from '@blinkk/root/node'; import {Timestamp, GeoPoint} from 'firebase-admin/firestore'; import {RootCMSClient, unmarshalData, getCmsPlugin} from '../core/client.js'; import {convertForExport} from './utils.js'; +type ProjectModule = typeof import('../core/project.js'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + export interface DocsGetOptions { /** Doc mode: "draft" or "published". */ mode?: string; @@ -28,6 +32,13 @@ export interface DocsUploadOptions { mode?: string; } +export interface DocsUpdateOptions { + /** Whether to skip schema validation. */ + noValidate?: boolean; + /** Path to a JSON file containing the value. */ + file?: string; +} + /** * Fetches a single doc and outputs it as JSON. * If an output path is provided, writes to a file. Otherwise, writes to stdout. @@ -212,6 +223,89 @@ export async function docsUpload( ); } +/** + * Updates a single field in a draft doc by its deep key path. + * + * The value can be provided as: + * - An inline argument (parsed as JSON, falling back to a plain string) + * - A file path via the --file option + * - Piped via stdin (when neither inline value nor --file is provided) + * + * Validates the updated document against the collection schema by default. + * Use --no-validate to skip validation. + * + * Usage: + * root-cms docs.update Pages/home hero.title '"New Title"' + * root-cms docs.update Pages/home meta.og '{"title": "OG"}' + * root-cms docs.update Pages/home hero.title --file value.json + * cat value.json | root-cms docs.update Pages/home hero.title + */ +export async function docsUpdate( + docId: string, + fieldPath: string, + inlineValue: string | undefined, + options: DocsUpdateOptions +) { + const rootDir = process.cwd(); + const rootConfig = await loadRootConfig(rootDir, {command: 'root-cms'}); + const client = new RootCMSClient(rootConfig); + + let fieldValue: any; + if (isDef(inlineValue)) { + fieldValue = parseValue(inlineValue); + } else if (options.file) { + const filePath = path.resolve(options.file); + if (!fs.existsSync(filePath)) { + throw new Error(`file not found: ${filePath}`); + } + const jsonStr = fs.readFileSync(filePath, 'utf-8'); + fieldValue = JSON.parse(jsonStr); + } else { + const jsonStr = await readStdin(); + fieldValue = JSON.parse(jsonStr); + } + + const validate = !options.noValidate; + + // Load the collection schema via Vite SSR so that `import.meta.glob` in + // project.ts is resolved correctly (it does not work in plain Node.js). + const collectionId = docId.split('/')[0]; + let collectionSchema; + if (validate) { + const modulePath = path.resolve(__dirname, '../core/project.js'); + const project = (await viteSsrLoadModule( + rootConfig, + modulePath + )) as ProjectModule; + collectionSchema = project.getCollectionSchema(collectionId); + } + + await client.updateDraftData(docId, fieldPath, fieldValue, { + validate, + collectionSchema: collectionSchema ?? undefined, + }); + console.log(`Updated ${docId} field "${fieldPath}"`); +} + +/** + * Returns true if the value is not undefined and not null. + */ +function isDef(value: T | null | undefined): value is T { + return value !== undefined && value !== null; +} + +/** + * Parses a CLI value string. Attempts JSON.parse first, falling back to a + * plain string if parsing fails. + */ +function parseValue(raw: string): any { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + /** * Reads all data from stdin as a string. */ diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 82c9a4bfb..5e766a22a 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -12,7 +12,7 @@ import {normalizeSlug} from '../shared/slug.js'; import {CMSPlugin} from './plugin.js'; import {Collection} from './schema.js'; import {TranslationsManager} from './translations-manager.js'; -import {validateFields} from './validation.js'; +import {validateFields, resolveFieldAtPath} from './validation.js'; import {setValueAtPath} from './values.js'; export interface Doc { @@ -109,6 +109,13 @@ export interface SaveDraftOptions { * If validation fails, an error will be thrown with details about the validation errors. */ validate?: boolean; + + /** + * Pre-loaded collection schema for validation. When provided, skips the + * `getCollection()` call (which requires Vite's `import.meta.glob`). This is + * useful for CLI contexts where schemas are loaded via `viteSsrLoadModule`. + */ + collectionSchema?: Collection; } export interface UpdateDraftOptions { @@ -117,6 +124,13 @@ export interface UpdateDraftOptions { * If validation fails, an error will be thrown with details about the validation errors. */ validate?: boolean; + + /** + * Pre-loaded collection schema for validation. When provided, skips the + * `getCollection()` call (which requires Vite's `import.meta.glob`). This is + * useful for CLI contexts where schemas are loaded via `viteSsrLoadModule`. + */ + collectionSchema?: Collection; } export interface ListDocsOptions { @@ -343,7 +357,8 @@ export class RootCMSClient { // Validate fieldsData if requested. if (options?.validate) { - const collectionSchema = await this.getCollection(collection); + const collectionSchema = + options.collectionSchema ?? (await this.getCollection(collection)); if (!collectionSchema) { throw new Error( `Collection schema not found for: ${collection}. Unable to validate.` @@ -399,6 +414,22 @@ export class RootCMSClient { ) { const {collection, slug} = parseDocId(docId); + // Resolve the collection schema once for both path and data validation. + let collectionSchema = options?.collectionSchema; + if (options?.validate && !collectionSchema) { + collectionSchema = (await this.getCollection(collection)) ?? undefined; + } + + // Validate that the field path exists in the schema. + if (options?.validate && collectionSchema) { + const field = resolveFieldAtPath(collectionSchema, path); + if (!field) { + throw new Error( + `Unknown field path "${path}" for collection "${collection}".` + ); + } + } + // Get current draft doc. const draftDoc = (await this.getRawDoc(collection, slug, {mode: 'draft'})) || {}; @@ -410,6 +441,7 @@ export class RootCMSClient { // Save the updated document using saveDraftData. await this.saveDraftData(docId, fieldsData, { validate: options?.validate, + collectionSchema, }); } diff --git a/packages/root-cms/core/validation.ts b/packages/root-cms/core/validation.ts index e6d7b2384..65dc7dd8d 100644 --- a/packages/root-cms/core/validation.ts +++ b/packages/root-cms/core/validation.ts @@ -6,6 +6,84 @@ import type { Schema, } from './schema.js'; +/** + * Resolves a dot-separated field path against a schema and returns the + * matching field definition, or `null` if the path does not exist. + * + * - Object fields are traversed by field `id`. + * - Array fields expect a numeric index, then traverse into the `of` type. + * - OneOf, richtext, image, file, and reference fields are treated as opaque: + * once reached, any remaining path segments are accepted. + * + * Examples: + * resolveFieldAtPath(schema, 'meta.title') // ObjectField → StringField + * resolveFieldAtPath(schema, 'sections.0.title') // ArrayField → ObjectField → StringField + * resolveFieldAtPath(schema, 'foo.bar') // null (unknown top-level field) + */ +export function resolveFieldAtPath( + schema: Schema, + path: string +): FieldWithId | null { + const parts = path.split('.'); + let fields = schema.fields; + let currentField: FieldWithId | null = null; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + + // Numeric indices navigate into an array item's type. + if (/^\d+$/.test(part)) { + if (currentField?.type !== 'array') { + return null; + } + const arrayField = currentField as ArrayField; + currentField = arrayField.of as FieldWithId; + // If the array item type can't be statically traversed, accept the rest. + if (!hasStaticNestedFields(currentField)) { + if (i < parts.length - 1) { + return currentField; + } + } + fields = getStaticNestedFields(currentField); + continue; + } + + // Look up the field by id in the current scope. + const found = fields.find((f) => f.id === part); + if (!found) { + // If the current field cannot be statically traversed (e.g. oneof, + // richtext, image), accept the unresolvable path gracefully. + if (currentField && !hasStaticNestedFields(currentField)) { + return currentField; + } + return null; + } + currentField = found; + fields = getStaticNestedFields(currentField); + } + + return currentField; +} + +/** + * Returns the nested fields for a field type that can be statically traversed. + */ +function getStaticNestedFields(field: FieldWithId): FieldWithId[] { + if (field.type === 'object' && 'fields' in field) { + return (field as ObjectField).fields || []; + } + return []; +} + +/** + * Returns true if the field type has statically-known nested fields that can + * be validated (only object fields). OneOf, richtext, image, file, and + * reference types have dynamic or implicit sub-properties. + */ +function hasStaticNestedFields(field: FieldWithId): boolean { + return field.type === 'object'; +} + /** * Represents a validation error for a field. */