Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rude-jars-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': patch
---

feat: add docs.update cli command
2 changes: 1 addition & 1 deletion packages/create-root/src/root-version.ts
Original file line number Diff line number Diff line change
@@ -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';
36 changes: 35 additions & 1 deletion packages/root-cms/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -136,6 +142,33 @@ class CliRunner {
'doc mode: "draft" or "published" (default: "draft")'
)
.action(docsDownload);
program
.command('docs.update <docId> <fieldPath> [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 <filepath>', 'read the value from a JSON file')
.option('--no-validate', 'skip schema validation')
.action(docsUpdate);
program
.command('docs.upload <collection> <dir>')
.description(
Expand All @@ -157,6 +190,7 @@ export {
CliRunner,
docsGet,
docsSet,
docsUpdate,
docsDownload,
docsUpload,
exportData,
Expand Down
96 changes: 95 additions & 1 deletion packages/root-cms/cli/docs.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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<T>(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.
*/
Expand Down
36 changes: 34 additions & 2 deletions packages/root-cms/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fields = any> {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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.`
Expand Down Expand Up @@ -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'})) || {};
Expand All @@ -410,6 +441,7 @@ export class RootCMSClient {
// Save the updated document using saveDraftData.
await this.saveDraftData(docId, fieldsData, {
validate: options?.validate,
collectionSchema,
});
}

Expand Down
78 changes: 78 additions & 0 deletions packages/root-cms/core/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading