From 7d7cfc8a52a08902f81eff64adf0a2b5eeda6305 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:02:21 -0400 Subject: [PATCH 1/3] Add Content mounted local source identities --- templates/content/AGENTS.md | 103 ++++---- .../actions/_database-source-utils.test.ts | 78 +++++- .../content/actions/_database-source-utils.ts | 245 +++++++++++++----- .../actions/_local-file-documents.test.ts | 30 ++- .../attach-content-database-source.db.test.ts | 170 ++++++++++++ .../actions/attach-content-database-source.ts | 173 ++++++++++--- .../change-content-database-source-role.ts | 17 +- .../content-database-source-actions.test.ts | 32 +++ .../actions/list-content-databases.db.test.ts | 81 ++++++ .../content/actions/list-content-databases.ts | 58 ++++- templates/content/actions/search-documents.ts | 24 +- .../actions/suggest-source-join-key.ts | 8 +- .../components/editor/DocumentDatabase.tsx | 13 +- .../editor/database/DatabaseView.tsx | 13 +- .../content/app/hooks/use-content-database.ts | 3 +- .../app/lib/content-command-search.test.ts | 56 ++++ .../content/app/lib/content-command-search.ts | 6 +- ...ls-can-be-registered-as-mounted-content.md | 6 + templates/content/shared/api.ts | 12 +- 19 files changed, 949 insertions(+), 179 deletions(-) create mode 100644 templates/content/actions/attach-content-database-source.db.test.ts create mode 100644 templates/content/changelog/2026-07-01-local-folders-and-github-urls-can-be-registered-as-mounted-content.md diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 4c0414b58c..2d1a99d93c 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -87,54 +87,54 @@ cd templates/content && pnpm action [args] ### Document Operations -| Action | Args | Purpose | -| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | -| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | -| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | -| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | -| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | -| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | -| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | -| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | -| `search-documents` | `--query [--format json]` | Search by title/content and return snippets | -| `get-document` | `--id [--format json]` | Get a single document with content | -| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | -| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | -| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | -| `edit-document` | `--id --edits ` | Batch surgical text edits | -| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | -| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | -| `list-local-component-files` | | List registered local MDX component source files | -| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | -| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | -| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | -| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | -| `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock\|builder-cms] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a source binding; use `items` to add more rows and `details` to match a source onto existing rows | -| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns without removing the source | -| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured | -| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | -| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | -| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | -| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | -| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | -| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | -| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | -| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | -| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | -| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | -| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | -| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | -| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | -| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | -| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | -| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | -| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | -| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | -| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | -| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | -| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | -| `delete-document` | `--id ` | Delete with recursive children | +| Action | Args | Purpose | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | +| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | +| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | +| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | +| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | +| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | +| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | +| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | +| `search-documents` | `--query [--sourceScope all\|database\|local-files] [--format json]` | Search by title/content and return snippets with source metadata | +| `get-document` | `--id [--format json]` | Get a single document with content | +| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | +| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | +| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | +| `edit-document` | `--id --edits ` | Batch surgical text edits | +| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | +| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | +| `list-local-component-files` | | List registered local MDX component source files | +| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | +| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | +| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | +| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | +| `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock-local\|builder-cms\|local-table\|local-folder\|github-url] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a mounted source binding; `local-folder` and `github-url` require a real `sourceTable` path/URL and register repo-truth workspace scope without Git automation | +| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns; mounted `local-folder` and `github-url` sources reject role changes | +| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured; mounted `local-folder` and `github-url` sources do not refresh | +| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | +| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | +| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | +| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | +| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | +| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | +| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | +| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | +| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | +| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | +| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | +| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | +| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | +| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | +| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | +| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | +| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | +| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | +| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | +| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | +| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | +| `delete-document` | `--id ` | Delete with recursive children | Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, etc.) @@ -230,6 +230,13 @@ Content has two file workflows: `.tsx`, `.jsx`, `.ts`, or `.js` files under that workspace's `components/` folder. The Vite component registry reloads after file additions/removals; edits to already-loaded files hot reload through Vite. +- **Mounted local/repo source identity:** source-backed Content databases can + register `local-folder` and `github-url` sources with + `attach-content-database-source` so local/repo work appears in the same + source vocabulary as Builder CMS. These registrations are scope and truth + labels: local folders remain file/repo truth through the local-file action + path, GitHub URLs remain Git truth, and this does not refresh files, clone, + commit, push, or make local MDX the default docs-team workflow. Minimal `agent-native.json`: diff --git a/templates/content/actions/_database-source-utils.test.ts b/templates/content/actions/_database-source-utils.test.ts index 753f6ff6f6..6c61d1aaa1 100644 --- a/templates/content/actions/_database-source-utils.test.ts +++ b/templates/content/actions/_database-source-utils.test.ts @@ -12,9 +12,11 @@ import { mockProposedValue, normalizeSourceFederation, normalizeSourceFreshness, - sourceValuesForSeededSourceRow, + serializeSourceMetadataRecord, + sourceCapabilitiesForType, sourceChangeSetKey, sourceChangeSetSummary, + sourceValuesForSeededSourceRow, } from "./_database-source-utils"; function property( @@ -67,6 +69,45 @@ describe("database source helpers", () => { expect(normalizeSourceFreshness("mysterious fog")).toBe("unknown"); }); + it("describes local folder and GitHub URL sources as repo truth", () => { + expect( + JSON.parse( + serializeSourceMetadataRecord({ + sourceType: "local-folder", + sourceTable: "docs", + }), + ), + ).toMatchObject({ + naturalKeyField: "path", + pushMode: "none", + truthSemantics: "local-folder", + }); + + expect( + JSON.parse( + serializeSourceMetadataRecord({ + sourceType: "github-url", + sourceTable: + "https://github.com/BuilderIO/agent-native/tree/main/docs", + }), + ), + ).toMatchObject({ + naturalKeyField: "url", + pushMode: "none", + truthSemantics: "github-url", + }); + + expect(JSON.parse(sourceCapabilitiesForType("local-folder"))).toMatchObject( + { + canRefresh: false, + canPush: false, + canCreateChangeSets: false, + liveWritesEnabled: false, + readOnlyRefresh: false, + }, + ); + }); + it("drops stored federation metadata with unsafe regex formulas", () => { expect( normalizeSourceFederation({ @@ -901,6 +942,41 @@ describe("database source helpers", () => { }); }); + it("captures local folder and GitHub source values without Builder fields", () => { + const localItem = item("doc-local", "Local doc"); + localItem.document.source = { + mode: "local-files", + kind: "file", + path: "docs/local-doc.mdx", + }; + + expect( + sourceValuesForSeededSourceRow({ + sourceType: "local-folder", + item: localItem, + sourceTable: "docs", + now: "2026-06-08T13:00:00.000Z", + }), + ).toEqual({ + title: "Local doc", + path: "docs/local-doc.mdx", + "sys.truth": "local-folder", + }); + + expect( + sourceValuesForSeededSourceRow({ + sourceType: "github-url", + item: item("doc-github", "GitHub doc"), + sourceTable: "https://github.com/BuilderIO/agent-native/tree/main/docs", + now: "2026-06-08T13:00:00.000Z", + }), + ).toEqual({ + title: "GitHub doc", + url: "https://github.com/BuilderIO/agent-native/tree/main/docs", + "sys.truth": "github-url", + }); + }); + it("summarizes proposed changes with the current row title and changed field names", () => { const headline = property("text", "Launch week"); const fieldChange = buildMockFieldChange({ diff --git a/templates/content/actions/_database-source-utils.ts b/templates/content/actions/_database-source-utils.ts index 6a6d7621d5..569015b7f3 100644 --- a/templates/content/actions/_database-source-utils.ts +++ b/templates/content/actions/_database-source-utils.ts @@ -172,7 +172,14 @@ function normalizeWriteOwner( function normalizeSourceType( value: string | null | undefined, ): ContentDatabaseSourceType { - if (value === "builder-cms" || value === "local-table") return value; + if ( + value === "builder-cms" || + value === "local-table" || + value === "local-folder" || + value === "github-url" + ) { + return value; + } return "mock-local"; } @@ -262,6 +269,8 @@ function sourceMetadataLabel( ) { if (sourceType === "builder-cms") return `builder.cms.${sourceTable}`; if (sourceType === "local-table") return `local.table.${sourceTable}`; + if (sourceType === "local-folder") return `local.folder.${sourceTable}`; + if (sourceType === "github-url") return `github.url.${sourceTable}`; return `mock-local.${sourceTable}`; } @@ -2100,6 +2109,38 @@ export function serializeSourceMetadataRecord(args: { if (isBuilder) { return JSON.stringify(builderCmsSourceMetadata(args.sourceTable)); } + if (args.sourceType === "local-folder") { + return JSON.stringify({ + primaryKey: "path", + titleField: "title", + naturalKeyField: "path", + pushMode: "none", + pushModeLabel: "Repo truth", + pushModeDescription: + "The mounted local folder is the source of truth; Content keeps scope and search metadata only.", + notes: + "Local folder workspace registration. File writes stay on the local-file action path and git review remains external.", + readMode: "local-files", + label: sourceMetadataLabel(args.sourceType, args.sourceTable), + truthSemantics: "local-folder", + }); + } + if (args.sourceType === "github-url") { + return JSON.stringify({ + primaryKey: "url", + titleField: "title", + naturalKeyField: "url", + pushMode: "none", + pushModeLabel: "Git truth", + pushModeDescription: + "The GitHub URL identifies repo truth; this registration does not clone, commit, or push.", + notes: + "GitHub URL workspace registration for source scope. Full Git workflow automation is intentionally out of scope.", + readMode: "github-url", + label: sourceMetadataLabel(args.sourceType, args.sourceTable), + truthSemantics: "github-url", + }); + } return JSON.stringify({ primaryKey: "id", titleField: "title", @@ -2151,6 +2192,20 @@ export function sourceCapabilitiesForType( readOnlyRefresh: true, }); } + if (sourceType === "local-folder" || sourceType === "github-url") { + return serializeSourceCapabilitiesRecord({ + canRefresh: false, + canCreateChangeSets: false, + canWriteFields: false, + canWriteBody: false, + canPush: false, + canPull: false, + canPublish: false, + canStageLocalRevision: false, + liveWritesEnabled: false, + readOnlyRefresh: false, + }); + } return serializeSourceCapabilitiesRecord(); } @@ -2199,25 +2254,31 @@ export async function seedMockSourceFields(args: { }) { const db = getDb(); const isBuilder = args.sourceType === "builder-cms"; + const isMountedRepoSource = + args.sourceType === "local-folder" || args.sourceType === "github-url"; const rows = [ - { - id: crypto.randomUUID(), - ownerEmail: args.ownerEmail, - sourceId: args.sourceId, - propertyId: null, - localFieldKey: "title", - sourceFieldKey: isBuilder ? "data.title" : "title", - sourceFieldLabel: "Title", - sourceFieldType: "string", - mappingType: "title", - writeOwner: isBuilder ? "source" : "local", - readOnly: 0, - provenance: "source title", - freshness: "fresh", - lastSyncedAt: args.now, - createdAt: args.now, - updatedAt: args.now, - }, + ...(!isMountedRepoSource + ? [ + { + id: crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + propertyId: null, + localFieldKey: "title", + sourceFieldKey: isBuilder ? "data.title" : "title", + sourceFieldLabel: "Title", + sourceFieldType: "string", + mappingType: "title", + writeOwner: isBuilder ? "source" : "local", + readOnly: 0, + provenance: "source title", + freshness: "fresh", + lastSyncedAt: args.now, + createdAt: args.now, + updatedAt: args.now, + }, + ] + : []), ...(isBuilder ? [ { @@ -2240,6 +2301,33 @@ export async function seedMockSourceFields(args: { }, ] : []), + ...(isMountedRepoSource + ? [ + { + id: crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + propertyId: null, + localFieldKey: + args.sourceType === "github-url" ? "github_url" : "source_path", + sourceFieldKey: args.sourceType === "github-url" ? "url" : "path", + sourceFieldLabel: + args.sourceType === "github-url" ? "GitHub URL" : "Local path", + sourceFieldType: args.sourceType === "github-url" ? "url" : "text", + mappingType: "system", + writeOwner: "source", + readOnly: 1, + provenance: + args.sourceType === "github-url" + ? "GitHub URL workspace identity" + : "Local folder workspace identity", + freshness: "fresh", + lastSyncedAt: args.now, + createdAt: args.now, + updatedAt: args.now, + }, + ] + : []), { id: crypto.randomUUID(), ownerEmail: args.ownerEmail, @@ -2285,56 +2373,58 @@ export async function seedMockSourceFields(args: { // "Source") and only for Builder sources, so a user's own field happening // to be named "Source" — or any non-Builder/local-table source — is left // untouched. - ...args.properties - .filter( - (property) => - !( - isBuilder && - property.definition.name === SOURCE_PROPERTY_NAME && - property.definition.type === "select" - ), - ) - .map((property) => ({ - id: crypto.randomUUID(), - ownerEmail: args.ownerEmail, - sourceId: args.sourceId, - propertyId: property.definition.id, - localFieldKey: property.definition.id, - sourceFieldKey: isBuilder - ? builderCmsSourceFieldKey( - property.definition.id, - property.definition.name, - ) - : `fields.${slugifySourceField(property.definition.name)}`, - sourceFieldLabel: property.definition.name, - sourceFieldType: property.definition.type, - mappingType: "property", - writeOwner: - property.definition.type === "created_time" || - property.definition.type === "created_by" || - property.definition.type === "last_edited_time" || - property.definition.type === "last_edited_by" - ? "derived" - : isBuilder - ? "source" - : "local", - readOnly: - property.definition.type === "created_time" || - property.definition.type === "created_by" || - property.definition.type === "last_edited_time" || - property.definition.type === "last_edited_by" - ? 1 - : 0, - provenance: - property.definition.type === "formula" || - property.definition.type === "rollup" - ? "derived" - : "source field", - freshness: "fresh", - lastSyncedAt: args.now, - createdAt: args.now, - updatedAt: args.now, - })), + ...(!isMountedRepoSource + ? args.properties + .filter( + (property) => + !( + isBuilder && + property.definition.name === SOURCE_PROPERTY_NAME && + property.definition.type === "select" + ), + ) + .map((property) => ({ + id: crypto.randomUUID(), + ownerEmail: args.ownerEmail, + sourceId: args.sourceId, + propertyId: property.definition.id, + localFieldKey: property.definition.id, + sourceFieldKey: isBuilder + ? builderCmsSourceFieldKey( + property.definition.id, + property.definition.name, + ) + : `fields.${slugifySourceField(property.definition.name)}`, + sourceFieldLabel: property.definition.name, + sourceFieldType: property.definition.type, + mappingType: "property", + writeOwner: + property.definition.type === "created_time" || + property.definition.type === "created_by" || + property.definition.type === "last_edited_time" || + property.definition.type === "last_edited_by" + ? "derived" + : isBuilder + ? "source" + : "local", + readOnly: + property.definition.type === "created_time" || + property.definition.type === "created_by" || + property.definition.type === "last_edited_time" || + property.definition.type === "last_edited_by" + ? 1 + : 0, + provenance: + property.definition.type === "formula" || + property.definition.type === "rollup" + ? "derived" + : "source field", + freshness: "fresh", + lastSyncedAt: args.now, + createdAt: args.now, + updatedAt: args.now, + })) + : []), ]; if (isBuilder) { const existingSourceFieldKeys = new Set( @@ -2493,6 +2583,23 @@ export function sourceValuesForSeededSourceRow(args: { >(args.existingSourceValuesJson); if (args.builderEntry?.sourceValues) return args.builderEntry.sourceValues; if (existingSourceValues) return existingSourceValues; + if (args.sourceType === "local-folder") { + return { + title: args.item.document.title, + path: + args.item.document.source?.path ?? + args.item.document.source?.rootPath ?? + args.sourceTable, + "sys.truth": "local-folder", + }; + } + if (args.sourceType === "github-url") { + return { + title: args.item.document.title, + url: args.sourceTable, + "sys.truth": "github-url", + }; + } if (args.sourceType !== "builder-cms") return {}; return buildBuilderCmsFixtureEntry({ item: args.item, diff --git a/templates/content/actions/_local-file-documents.test.ts b/templates/content/actions/_local-file-documents.test.ts index 9d12f627c4..364deb6c83 100644 --- a/templates/content/actions/_local-file-documents.test.ts +++ b/templates/content/actions/_local-file-documents.test.ts @@ -134,7 +134,7 @@ describe("content local file documents", () => { expect(readFile(root, "docs/guide.mdx")).toContain("Alpha gamma needle."); await expect( - searchDocuments.run({ query: "needle", limit: 10 }), + searchDocuments.run({ query: "needle", limit: 10, sourceScope: "all" }), ).resolves.toMatchObject({ documents: [ expect.objectContaining({ @@ -154,6 +154,34 @@ describe("content local file documents", () => { }); }); + it("lets search scope include or exclude local-file truth", async () => { + const root = setupLocalContentRepo(); + writeFile(root, "docs/guide.mdx", "# Guide\n\nScoped needle."); + + await expect( + searchDocuments.run({ + query: "needle", + limit: 10, + sourceScope: "local-files", + }), + ).resolves.toMatchObject({ + documents: [ + expect.objectContaining({ + id: localFileDocumentId("docs/guide.mdx"), + source: expect.objectContaining({ mode: "local-files" }), + }), + ], + }); + + await expect( + searchDocuments.run({ + query: "needle", + limit: 10, + sourceScope: "database", + }), + ).resolves.toEqual({ documents: [] }); + }); + it("reuses parsed local documents across repeated searches", async () => { const root = setupLocalContentRepo(); writeFile(root, "docs/guide.mdx", "# Guide\n\nAlpha needle."); diff --git a/templates/content/actions/attach-content-database-source.db.test.ts b/templates/content/actions/attach-content-database-source.db.test.ts new file mode 100644 index 0000000000..0e50cca8d5 --- /dev/null +++ b/templates/content/actions/attach-content-database-source.db.test.ts @@ -0,0 +1,170 @@ +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runWithRequestContext } from "@agent-native/core/server"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const TEST_DB_PATH = join( + tmpdir(), + `content-attach-source-${process.pid}-${Date.now()}.sqlite`, +); + +type Schema = typeof import("../server/db/schema.js"); +let getDb: () => any; +let schema: Schema; +let attachSourceAction: typeof import("./attach-content-database-source.js").default; +let changeSourceRoleAction: typeof import("./change-content-database-source-role.js").default; + +const OWNER = "owner@example.com"; + +beforeAll(async () => { + process.env.DATABASE_URL = `file:${TEST_DB_PATH}`; + const dbModule = await import("../server/db/index.js"); + getDb = dbModule.getDb; + schema = dbModule.schema; + attachSourceAction = (await import("./attach-content-database-source.js")) + .default; + changeSourceRoleAction = ( + await import("./change-content-database-source-role.js") + ).default; + const plugin = (await import("../server/plugins/db.js")).default; + await plugin(undefined as any); +}, 60000); + +afterAll(() => { + for (const suffix of ["", "-shm", "-wal"]) { + rmSync(`${TEST_DB_PATH}${suffix}`, { force: true }); + } +}); + +async function createDatabaseDocument(args: { + documentId: string; + databaseId: string; + title: string; +}) { + const db = getDb(); + const now = new Date().toISOString(); + await db.insert(schema.documents).values({ + id: args.documentId, + ownerEmail: OWNER, + parentId: null, + title: args.title, + content: "", + position: 1, + visibility: "private", + createdAt: now, + updatedAt: now, + }); + await db.insert(schema.contentDatabases).values({ + id: args.databaseId, + ownerEmail: OWNER, + documentId: args.documentId, + title: args.title, + }); +} + +describe("attach-content-database-source", () => { + it("registers mounted local folders without claiming row or title truth", async () => { + await createDatabaseDocument({ + documentId: "mounted-local-doc", + databaseId: "mounted-local-db", + title: "Mounted local workspace", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await attachSourceAction.run({ + databaseId: "mounted-local-db", + sourceType: "local-folder", + sourceName: "Docs folder", + sourceTable: "docs", + relationshipMode: "items", + }); + }); + + const db = getDb(); + const [source] = await db.select().from(schema.contentDatabaseSources); + expect(source).toMatchObject({ + databaseId: "mounted-local-db", + sourceType: "local-folder", + sourceName: "Docs folder", + sourceTable: "docs", + }); + expect(JSON.parse(source.capabilitiesJson)).toMatchObject({ + canRefresh: false, + canCreateChangeSets: false, + canPush: false, + readOnlyRefresh: false, + }); + + const fields = await db.select().from(schema.contentDatabaseSourceFields); + const fieldKeys = fields.map((field: any) => field.localFieldKey); + expect(fieldKeys).toContain("source_path"); + expect(fieldKeys).not.toContain("title"); + const rows = await db.select().from(schema.contentDatabaseSourceRows); + expect(rows).toEqual([]); + }); + + it("rejects invalid GitHub URLs before registering a source", async () => { + await createDatabaseDocument({ + documentId: "mounted-github-doc", + databaseId: "mounted-github-db", + title: "Mounted GitHub workspace", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + attachSourceAction.run({ + databaseId: "mounted-github-db", + sourceType: "github-url", + sourceTable: "https://gitlab.com/example/repo", + }), + ).rejects.toThrow("GitHub URL sources must be https://github.com/"); + }); + }); + + it("rejects role changes for mounted source identity records", async () => { + await createDatabaseDocument({ + documentId: "mounted-role-doc", + databaseId: "mounted-role-db", + title: "Mounted role workspace", + }); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await attachSourceAction.run({ + databaseId: "mounted-role-db", + sourceType: "github-url", + sourceName: "Docs repo", + sourceTable: "https://github.com/BuilderIO/agent-native", + }); + }); + + const db = getDb(); + const source = (await db.select().from(schema.contentDatabaseSources)).find( + (item: any) => item.databaseId === "mounted-role-db", + ); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + changeSourceRoleAction.run({ + databaseId: "mounted-role-db", + sourceId: source.id, + relationshipMode: "details", + join: { + canonicalKey: { label: "URL", type: "text" }, + primary: { + keyField: "url", + normalizationFormula: "lower(trim({url}))", + }, + secondary: { + keyField: "url", + normalizationFormula: "lower(trim({url}))", + }, + }, + }), + ).rejects.toThrow( + "Local folder and GitHub URL sources only register workspace scope", + ); + }); + }); +}); diff --git a/templates/content/actions/attach-content-database-source.ts b/templates/content/actions/attach-content-database-source.ts index 4a618832c1..2496ad6fa8 100644 --- a/templates/content/actions/attach-content-database-source.ts +++ b/templates/content/actions/attach-content-database-source.ts @@ -40,7 +40,13 @@ import { } from "./_local-table-source.js"; const sourceTypeSchema = z - .enum(["mock-local", "builder-cms", "local-table"]) + .enum([ + "mock-local", + "builder-cms", + "local-table", + "local-folder", + "github-url", + ]) .default("mock-local"); // Per-source key mapping the UI commits after the canonical-key confirm step. @@ -117,7 +123,7 @@ function identityFederation( export default defineAction({ description: - "Attach or replace a safe local source binding for a content database. Builder CMS bindings store source metadata, field mappings, row identity, provenance, freshness, capabilities, and local-only diff state without calling external APIs.", + "Attach or replace a mounted source binding for a content database. Builder CMS bindings can import rows and guarded write metadata; local-folder and github-url bindings require a real path or https://github.com/... URL and register repo-truth workspace scope without Git automation.", schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), @@ -131,7 +137,9 @@ export default defineAction({ sourceTable: z .string() .optional() - .describe("Source table/model name, for example content_items."), + .describe( + "Source table/model name. For local-folder, pass the folder path. For github-url, pass a https://github.com/... URL.", + ), relationshipMode: z .enum(["items", "details"]) .optional() @@ -162,17 +170,51 @@ export default defineAction({ "mock-local") as ContentDatabaseSourceType; const sourceName = args.sourceName?.trim() || - (sourceType === "builder-cms" ? "Builder CMS" : "Mock local source"); - const sourceTable = - args.sourceTable?.trim() || + (sourceType === "builder-cms" + ? "Builder CMS" + : sourceType === "local-folder" + ? "Local folder" + : sourceType === "github-url" + ? "GitHub URL" + : "Mock local source"); + const sourceTable = args.sourceTable?.trim() || ""; + const resolvedSourceTable = + sourceTable || (sourceType === "builder-cms" ? "blog_article" : "content_items"); + if ( + (sourceType === "local-folder" || sourceType === "github-url") && + !sourceTable + ) { + throw new Error( + sourceType === "local-folder" + ? "Local folder sources require a folder path." + : "GitHub URL sources require a https://github.com/... URL.", + ); + } const existingSource = await getExistingSource(database.id); if (sourceType === "local-table") { - if (sourceTable === database.id) { + if (resolvedSourceTable === database.id) { throw new Error("A database can't be added as a source of itself."); } - await resolveReadableLocalTableSource(sourceTable); + await resolveReadableLocalTableSource(resolvedSourceTable); + } + if (sourceType === "github-url") { + let parsed: URL | null = null; + try { + parsed = new URL(sourceTable); + } catch { + parsed = null; + } + if ( + !parsed || + parsed.protocol !== "https:" || + parsed.hostname !== "github.com" + ) { + throw new Error( + "GitHub URL sources must be https://github.com/... URLs.", + ); + } } const relationshipMode = @@ -188,15 +230,22 @@ export default defineAction({ let entries: BuilderCmsSourceEntry[]; let modelFields: BuilderCmsModelFieldSummary[]; if (sourceType === "builder-cms") { - const read = await readBuilderCmsContentEntries({ model: sourceTable }); + const read = await readBuilderCmsContentEntries({ + model: resolvedSourceTable, + }); entries = read.state === "live" ? read.entries : []; - modelFields = await readBuilderCmsModelFields({ model: sourceTable }); + modelFields = await readBuilderCmsModelFields({ + model: resolvedSourceTable, + }); } else if (sourceType === "local-table") { // sourceTable carries the target database id for a local-table source. - ({ entries, modelFields } = await readLocalTableEntries(sourceTable, { - limit: args.limit, - offset: args.offset, - })); + ({ entries, modelFields } = await readLocalTableEntries( + resolvedSourceTable, + { + limit: args.limit, + offset: args.offset, + }, + )); } else { entries = []; modelFields = []; @@ -206,14 +255,14 @@ export default defineAction({ database, sourceType, sourceName, - sourceTable, + sourceTable: resolvedSourceTable, now, }); await storeSecondarySourceRows({ sourceId: secondaryId, ownerEmail: database.ownerEmail, sourceType, - sourceTable, + sourceTable: resolvedSourceTable, entries, now, }); @@ -260,22 +309,26 @@ export default defineAction({ ) { // Don't add the same collection twice — each "add" starts a fresh source // with no prior rows, so a duplicate attach would re-import duplicate rows. - if (await databaseSourceExistsForTable(database.id, sourceTable)) { - throw new Error(`"${sourceTable}" is already attached as a source.`); + if ( + await databaseSourceExistsForTable(database.id, resolvedSourceTable) + ) { + throw new Error( + `"${resolvedSourceTable}" is already attached as a source.`, + ); } const additionalRead = await readBuilderCmsContentEntries({ - model: sourceTable, + model: resolvedSourceTable, }); const additionalEntries = additionalRead.state === "live" ? additionalRead.entries : []; const additionalModelFields = await readBuilderCmsModelFields({ - model: sourceTable, + model: resolvedSourceTable, }); const additionalSourceId = await insertSecondarySource({ database, sourceType, sourceName, - sourceTable, + sourceTable: resolvedSourceTable, now, }); // Snapshot existing items BEFORE importing so we can bind the new source @@ -289,7 +342,7 @@ export default defineAction({ database, entries: additionalEntries, now, - sourceTable, + sourceTable: resolvedSourceTable, existingSourceRows: [], skipTitleDedup: true, }); @@ -304,7 +357,7 @@ export default defineAction({ ? mapBuilderCmsEntriesToLocalItems({ entries: additionalEntries, items: importedItems, - sourceTable, + sourceTable: resolvedSourceTable, now, existingRows: [], }) @@ -322,7 +375,7 @@ export default defineAction({ sourceId: additionalSourceId, ownerEmail: database.ownerEmail, sourceType, - sourceTable, + sourceTable: resolvedSourceTable, items: importedItems, now, builderEntriesByDocumentId: additionalEntriesByDocumentId, @@ -332,7 +385,7 @@ export default defineAction({ sourceId: additionalSourceId, ownerEmail: database.ownerEmail, orgId: database.orgId, - sourceTable, + sourceTable: resolvedSourceTable, items: importedItems, builderEntriesByDocumentId: additionalEntriesByDocumentId, now, @@ -340,7 +393,7 @@ export default defineAction({ } await updateBuilderCmsSourceReadMetadata({ sourceId: additionalSourceId, - sourceTable, + sourceTable: resolvedSourceTable, readState: additionalRead.state, entryCount: additionalRead.entries.length, matchedRowCount: additionalEntriesByDocumentId?.size ?? 0, @@ -358,7 +411,39 @@ export default defineAction({ } if (relationshipMode === "items" && existingSource) { - throw new Error("Only Builder sources can add more items right now."); + if (sourceType !== "local-folder" && sourceType !== "github-url") { + throw new Error( + "Only Builder, local folder, or GitHub URL sources can add more items right now.", + ); + } + if ( + await databaseSourceExistsForTable(database.id, resolvedSourceTable) + ) { + throw new Error( + `"${resolvedSourceTable}" is already attached as a source.`, + ); + } + const additionalSourceId = await insertSecondarySource({ + database, + sourceType, + sourceName, + sourceTable: resolvedSourceTable, + now, + }); + const setup = await sourceSetupPayload(database.id); + await seedMockSourceFields({ + sourceId: additionalSourceId, + ownerEmail: database.ownerEmail, + sourceType, + properties: setup.properties, + now, + }); + await ensureDatabaseSourceProperty({ database, now }); + + return getContentDatabaseResponse(database.id, { + limit: args.limit, + offset: args.offset, + }); } const existingSourceRows = existingSource @@ -369,13 +454,13 @@ export default defineAction({ source: existingSource, sourceType, sourceName, - sourceTable, + sourceTable: resolvedSourceTable, now, }); const builderRead = sourceType === "builder-cms" ? await readBuilderCmsContentEntries({ - model: sourceTable, + model: resolvedSourceTable, }) : null; const builderEntries = @@ -383,7 +468,7 @@ export default defineAction({ const builderModelFields = sourceType === "builder-cms" ? await readBuilderCmsModelFields({ - model: sourceTable, + model: resolvedSourceTable, }) : []; if (builderRead?.state === "live") { @@ -391,7 +476,7 @@ export default defineAction({ database, entries: builderEntries, now, - sourceTable, + sourceTable: resolvedSourceTable, existingSourceRows, }); } @@ -402,7 +487,7 @@ export default defineAction({ ? mapBuilderCmsEntriesToLocalItems({ entries: builderEntries, items: refreshedSetup.response.items, - sourceTable, + sourceTable: resolvedSourceTable, now, existingRows: existingSourceRows, }) @@ -417,21 +502,23 @@ export default defineAction({ builderSampleEntries: builderEntries, now, }); - await seedMockSourceRows({ - sourceId, - ownerEmail: database.ownerEmail, - sourceType, - sourceTable, - items: refreshedSetup.response.items, - now, - builderEntriesByDocumentId, - }); + if (sourceType !== "local-folder" && sourceType !== "github-url") { + await seedMockSourceRows({ + sourceId, + ownerEmail: database.ownerEmail, + sourceType, + sourceTable: resolvedSourceTable, + items: refreshedSetup.response.items, + now, + builderEntriesByDocumentId, + }); + } if (sourceType === "builder-cms" && builderRead?.state === "live") { await enqueueBuilderBodyHydrationForItems({ sourceId, ownerEmail: database.ownerEmail, orgId: database.orgId, - sourceTable, + sourceTable: resolvedSourceTable, items: refreshedSetup.response.items, builderEntriesByDocumentId, now, @@ -440,7 +527,7 @@ export default defineAction({ if (sourceType === "builder-cms" && builderRead) { await updateBuilderCmsSourceReadMetadata({ sourceId, - sourceTable, + sourceTable: resolvedSourceTable, readState: builderRead.state, entryCount: builderRead.entries.length, matchedRowCount: builderEntriesByDocumentId?.size ?? 0, diff --git a/templates/content/actions/change-content-database-source-role.ts b/templates/content/actions/change-content-database-source-role.ts index 9d5ed11b59..6895b0f0ad 100644 --- a/templates/content/actions/change-content-database-source-role.ts +++ b/templates/content/actions/change-content-database-source-role.ts @@ -109,7 +109,14 @@ function identityFederation( } function sourceType(value: string): ContentDatabaseSourceType { - if (value === "builder-cms" || value === "local-table") return value; + if ( + value === "builder-cms" || + value === "local-table" || + value === "local-folder" || + value === "github-url" + ) { + return value; + } return "mock-local"; } @@ -265,6 +272,14 @@ export default defineAction({ if (!source) throw new Error("Source not found."); const normalizedType = sourceType(source.sourceType); + const isMountedRepoSource = + normalizedType === "local-folder" || normalizedType === "github-url"; + + if (isMountedRepoSource) { + throw new Error( + "Local folder and GitHub URL sources only register workspace scope; source role changes are not supported.", + ); + } if (args.relationshipMode === "details") { if (!args.join) { diff --git a/templates/content/actions/content-database-source-actions.test.ts b/templates/content/actions/content-database-source-actions.test.ts index 47070662eb..6d49ef4558 100644 --- a/templates/content/actions/content-database-source-actions.test.ts +++ b/templates/content/actions/content-database-source-actions.test.ts @@ -85,6 +85,38 @@ describe("content database source actions", () => { }); }); + it("accepts local folder and GitHub URL mounted source attachment args", () => { + expect( + attachSource.schema.parse({ + databaseId: "database", + sourceType: "local-folder", + sourceName: "Docs repo", + sourceTable: "docs", + relationshipMode: "items", + }), + ).toEqual({ + databaseId: "database", + sourceType: "local-folder", + sourceName: "Docs repo", + sourceTable: "docs", + relationshipMode: "items", + limit: 100, + offset: 0, + }); + + expect( + attachSource.schema.parse({ + databaseId: "database", + sourceType: "github-url", + sourceName: "Docs on GitHub", + sourceTable: "https://github.com/BuilderIO/agent-native/tree/main/docs", + }), + ).toMatchObject({ + sourceType: "github-url", + sourceTable: "https://github.com/BuilderIO/agent-native/tree/main/docs", + }); + }); + it("rejects unsafe source federation normalization formulas", () => { expect(() => attachSource.schema.parse({ diff --git a/templates/content/actions/list-content-databases.db.test.ts b/templates/content/actions/list-content-databases.db.test.ts index c136d95d69..c3a70834a0 100644 --- a/templates/content/actions/list-content-databases.db.test.ts +++ b/templates/content/actions/list-content-databases.db.test.ts @@ -77,6 +77,87 @@ describe("list-content-databases", () => { databaseId: "db-cmdk", documentId: "db-doc-cmdk", title: "CmdK Database TestDB", + sources: [], + }, + ], + }); + }); + }); + + it("returns coexisting Builder, local folder, and GitHub URL source identities", async () => { + await createDatabaseDocument({ + documentId: "db-doc-sources", + databaseId: "db-sources", + title: "Multi-source workspace", + }); + const db = getDb(); + const now = new Date().toISOString(); + await db.insert(schema.contentDatabaseSources).values([ + { + id: "source-builder", + ownerEmail: OWNER, + databaseId: "db-sources", + sourceType: "builder-cms", + sourceName: "Builder blog", + sourceTable: "blog_article", + createdAt: now, + updatedAt: now, + }, + { + id: "source-local", + ownerEmail: OWNER, + databaseId: "db-sources", + sourceType: "local-folder", + sourceName: "Docs folder", + sourceTable: "docs", + createdAt: now, + updatedAt: now, + }, + { + id: "source-github", + ownerEmail: OWNER, + databaseId: "db-sources", + sourceType: "github-url", + sourceName: "Docs repo URL", + sourceTable: "https://github.com/BuilderIO/agent-native/tree/main/docs", + createdAt: now, + updatedAt: now, + }, + ]); + + await runWithRequestContext({ userEmail: OWNER }, async () => { + await expect( + listContentDatabasesAction.run({ + query: "multi-source", + limit: 6, + }), + ).resolves.toEqual({ + databases: [ + { + databaseId: "db-sources", + documentId: "db-doc-sources", + title: "Multi-source workspace", + sources: [ + { + id: "source-builder", + sourceType: "builder-cms", + sourceName: "Builder blog", + sourceTable: "blog_article", + }, + { + id: "source-local", + sourceType: "local-folder", + sourceName: "Docs folder", + sourceTable: "docs", + }, + { + id: "source-github", + sourceType: "github-url", + sourceName: "Docs repo URL", + sourceTable: + "https://github.com/BuilderIO/agent-native/tree/main/docs", + }, + ], }, ], }); diff --git a/templates/content/actions/list-content-databases.ts b/templates/content/actions/list-content-databases.ts index 19ca17248a..85f4422e8a 100644 --- a/templates/content/actions/list-content-databases.ts +++ b/templates/content/actions/list-content-databases.ts @@ -1,16 +1,31 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, asc, eq, isNull, ne, sql } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { documentDiscoveryFilter } from "../server/lib/documents.js"; -import type { ListContentDatabasesResponse } from "../shared/api.js"; +import type { + ContentDatabaseSourceType, + ListContentDatabasesResponse, +} from "../shared/api.js"; function escapeLike(s: string): string { return s.replace(/([\\%_])/g, "\\$1"); } +function normalizeSummarySourceType(value: string): ContentDatabaseSourceType { + if ( + value === "builder-cms" || + value === "local-table" || + value === "local-folder" || + value === "github-url" + ) { + return value; + } + return "mock-local"; +} + export default defineAction({ description: "List the content databases the user can access (owned, shared, or org-shared — matching the sidebar) so any of them can be used as a local-table source. Optionally filters by title or excludes one database (e.g. the one being configured).", @@ -66,12 +81,51 @@ export default defineAction({ ? await queryBuilder.limit(args.limit) : await queryBuilder; + const sources = + rows.length > 0 + ? await db + .select({ + id: schema.contentDatabaseSources.id, + databaseId: schema.contentDatabaseSources.databaseId, + sourceType: schema.contentDatabaseSources.sourceType, + sourceName: schema.contentDatabaseSources.sourceName, + sourceTable: schema.contentDatabaseSources.sourceTable, + }) + .from(schema.contentDatabaseSources) + .where( + inArray( + schema.contentDatabaseSources.databaseId, + rows.map((row) => row.id), + ), + ) + : []; + const sourcesByDatabaseId = new Map< + string, + Array<{ + id: string; + sourceType: ContentDatabaseSourceType; + sourceName: string; + sourceTable: string; + }> + >(); + for (const source of sources) { + const databaseSources = sourcesByDatabaseId.get(source.databaseId) ?? []; + databaseSources.push({ + id: source.id, + sourceType: normalizeSummarySourceType(source.sourceType), + sourceName: source.sourceName, + sourceTable: source.sourceTable, + }); + sourcesByDatabaseId.set(source.databaseId, databaseSources); + } + const databases = rows.map((row) => ({ databaseId: row.id, documentId: row.documentId, // The document's live title (matches the sidebar) rather than the // possibly-stale content_databases.title. title: row.title ?? "Untitled database", + sources: sourcesByDatabaseId.get(row.id) ?? [], })); return { databases }; diff --git a/templates/content/actions/search-documents.ts b/templates/content/actions/search-documents.ts index 0b859af09f..6af988ad4a 100644 --- a/templates/content/actions/search-documents.ts +++ b/templates/content/actions/search-documents.ts @@ -1,6 +1,6 @@ import { defineAction } from "@agent-native/core"; import { accessFilter } from "@agent-native/core/sharing"; -import { and, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -8,6 +8,7 @@ import { documentDiscoveryFilter, parseDocumentHideFromSearch, } from "../server/lib/documents.js"; +import { serializeDocumentSource } from "./_document-source.js"; import { isContentLocalFileMode, listLocalFileDocuments, @@ -39,12 +40,21 @@ export default defineAction({ schema: z.object({ query: z.string().describe("Search text"), limit: z.coerce.number().int().min(1).max(200).default(50), + sourceScope: z + .enum(["all", "database", "local-files"]) + .default("all") + .describe( + "Optional source scope. Use local-files for repo/local-file truth and database for SQL-backed pages.", + ), }), http: { method: "GET" }, run: async (args) => { const query = args.query; if (await isContentLocalFileMode()) { + if (args.sourceScope === "database") { + return { documents: [] }; + } const normalizedQuery = query.toLowerCase(); const docs = (await listLocalFileDocuments()) .filter((doc) => doc.source?.kind !== "folder") @@ -67,6 +77,7 @@ export default defineAction({ snippet: makeSnippet(doc.content, query), contentLength: doc.content.length, hideFromSearch: doc.hideFromSearch, + source: doc.source, updatedAt: doc.updatedAt, })), }; @@ -83,6 +94,11 @@ export default defineAction({ icon: schema.documents.icon, content: schema.documents.content, hideFromSearch: schema.documents.hideFromSearch, + sourceMode: schema.documents.sourceMode, + sourceKind: schema.documents.sourceKind, + sourcePath: schema.documents.sourcePath, + sourceRootPath: schema.documents.sourceRootPath, + sourceUpdatedAt: schema.documents.sourceUpdatedAt, updatedAt: schema.documents.updatedAt, }) .from(schema.documents) @@ -90,6 +106,11 @@ export default defineAction({ and( accessFilter(schema.documents, schema.documentShares), documentDiscoveryFilter(), + args.sourceScope === "local-files" + ? eq(schema.documents.sourceMode, "local-files") + : args.sourceScope === "database" + ? sql`(${schema.documents.sourceMode} IS NULL OR ${schema.documents.sourceMode} = 'database')` + : undefined, sql`(${schema.documents.title} LIKE ${pattern} ESCAPE '\\' OR ${schema.documents.content} LIKE ${pattern} ESCAPE '\\')`, ), ) @@ -105,6 +126,7 @@ export default defineAction({ snippet: makeSnippet(doc.content, query), contentLength: doc.content.length, hideFromSearch: parseDocumentHideFromSearch(doc.hideFromSearch), + source: serializeDocumentSource(doc), updatedAt: doc.updatedAt, })), }; diff --git a/templates/content/actions/suggest-source-join-key.ts b/templates/content/actions/suggest-source-join-key.ts index b7eb1beb92..94ae22396f 100644 --- a/templates/content/actions/suggest-source-join-key.ts +++ b/templates/content/actions/suggest-source-join-key.ts @@ -20,7 +20,13 @@ export default defineAction({ schema: z.object({ databaseId: z.string().optional().describe("Database ID"), documentId: z.string().optional().describe("Database document/page ID"), - candidateSourceType: z.enum(["mock-local", "builder-cms", "local-table"]), + candidateSourceType: z.enum([ + "mock-local", + "builder-cms", + "local-table", + "local-folder", + "github-url", + ]), candidateSourceTable: z .string() .describe("Model/table name of the source being added."), diff --git a/templates/content/app/components/editor/DocumentDatabase.tsx b/templates/content/app/components/editor/DocumentDatabase.tsx index 7f148bb418..e174b9842e 100644 --- a/templates/content/app/components/editor/DocumentDatabase.tsx +++ b/templates/content/app/components/editor/DocumentDatabase.tsx @@ -16,6 +16,7 @@ import { type ContentDatabaseSourceChangeSet, type ContentDatabaseSourceJoinRequest, type ContentDatabaseSourceReviewPayload, + type ContentDatabaseSourceType, type ContentDatabaseSourceWriteMode, type ExecuteBuilderSourceBatchResponse, type SourceJoinSuggestion, @@ -3558,7 +3559,7 @@ type DatabaseSettingsPanel = // the leaf can attach without re-fetching. // A second source being added, awaiting the canonical-key confirm step. type PendingSourceCandidate = { - sourceType: "mock-local" | "builder-cms" | "local-table"; + sourceType: ContentDatabaseSourceType; sourceName: string; sourceTable: string; displayName: string; @@ -4929,11 +4930,13 @@ function SourcesListView({ ? () => onOpenSecondary(connected) : connected.sourceType === "builder-cms" ? () => onOpenConnectedBuilder(connected) - : undefined + : () => onOpenSecondary(connected) } disabled={ - connected.metadata.federation?.role !== "secondary" && - connected.sourceType !== "builder-cms" + connected.sourceType !== "builder-cms" && + connected.sourceType !== "local-folder" && + connected.sourceType !== "github-url" && + connected.metadata.federation?.role !== "secondary" } /> ))} @@ -5333,6 +5336,8 @@ function sourceRoleLabel( source: ContentDatabaseSource | null | undefined, index: number, ) { + if (source?.sourceType === "local-folder") return "Repo truth"; + if (source?.sourceType === "github-url") return "Git truth"; if (sourceAddsDetails(source)) return db("addingDetails"); return index === 0 ? db("addingItems") : db("addingItems"); } diff --git a/templates/content/app/components/editor/database/DatabaseView.tsx b/templates/content/app/components/editor/database/DatabaseView.tsx index f65459f44e..60969e5423 100644 --- a/templates/content/app/components/editor/database/DatabaseView.tsx +++ b/templates/content/app/components/editor/database/DatabaseView.tsx @@ -14,6 +14,7 @@ import { type ContentDatabaseSourceChangeSet, type ContentDatabaseSourceJoinRequest, type ContentDatabaseSourceReviewPayload, + type ContentDatabaseSourceType, type SourceJoinSuggestion, type ContentDatabaseView, type ContentDatabaseViewConfig, @@ -3451,7 +3452,7 @@ type DatabaseSettingsPanel = // the leaf can attach without re-fetching. // A second source being added, awaiting the canonical-key confirm step. type PendingSourceCandidate = { - sourceType: "mock-local" | "builder-cms" | "local-table"; + sourceType: ContentDatabaseSourceType; sourceName: string; sourceTable: string; displayName: string; @@ -4602,11 +4603,13 @@ function SourcesListView({ ? () => onOpenSecondary(connected) : connected.sourceType === "builder-cms" ? onOpenBuilder - : undefined + : () => onOpenSecondary(connected) } disabled={ - connected.metadata.federation?.role !== "secondary" && - connected.sourceType !== "builder-cms" + connected.sourceType !== "builder-cms" && + connected.sourceType !== "local-folder" && + connected.sourceType !== "github-url" && + connected.metadata.federation?.role !== "secondary" } /> ))} @@ -5007,6 +5010,8 @@ function sourceRoleLabel( source: ContentDatabaseSource | null | undefined, _index: number, ) { + if (source?.sourceType === "local-folder") return "Repo truth"; + if (source?.sourceType === "github-url") return "Git truth"; return sourceAddsDetails(source) ? dbText("addingDetails") : dbText("addingItems"); diff --git a/templates/content/app/hooks/use-content-database.ts b/templates/content/app/hooks/use-content-database.ts index 5406c87f49..a403a1d657 100644 --- a/templates/content/app/hooks/use-content-database.ts +++ b/templates/content/app/hooks/use-content-database.ts @@ -6,6 +6,7 @@ import type { BuilderCmsModelsResponse, ChangeContentDatabaseSourceRoleRequest, ContentDatabaseResponse, + ContentDatabaseSourceType, CreateInlineDatabaseRequest, CreateInlineDatabaseResponse, ListTrashedContentDatabasesResponse, @@ -408,7 +409,7 @@ export function useContentDatabases(args: { export function useSuggestSourceJoinKey(args: { documentId: string; - candidateSourceType: "mock-local" | "builder-cms" | "local-table"; + candidateSourceType: ContentDatabaseSourceType; candidateSourceTable: string; enabled: boolean; }) { diff --git a/templates/content/app/lib/content-command-search.test.ts b/templates/content/app/lib/content-command-search.test.ts index 84636b7b83..4a99b74307 100644 --- a/templates/content/app/lib/content-command-search.test.ts +++ b/templates/content/app/lib/content-command-search.test.ts @@ -60,6 +60,62 @@ describe("content command search", () => { ]); }); + it("groups SQL-backed imported local-file results by source metadata", () => { + const importedLocal = document( + "sql-imported-local", + "Imported local guide", + "Local folder result", + ); + importedLocal.source = { + mode: "local-files", + kind: "file", + path: "docs/imported-local-guide.mdx", + }; + + const groups = groupContentCommandSearchResults({ + query: "local", + documents: [document("doc-1", "Local strategy"), importedLocal], + databases: [], + }); + + expect(groups.documents.map((doc) => doc.id)).toEqual(["doc-1"]); + expect(groups.localFiles.map((doc) => doc.id)).toEqual([ + "sql-imported-local", + ]); + }); + + it("keeps connected database source identities available to command search", () => { + const groups = groupContentCommandSearchResults({ + query: "workspace", + documents: [], + databases: [ + { + databaseId: "db-1", + documentId: "db-doc-1", + title: "Workspace sources", + sources: [ + { + id: "source-builder", + sourceType: "builder-cms", + sourceName: "Builder docs", + sourceTable: "docs-content", + }, + { + id: "source-github", + sourceType: "github-url", + sourceName: "Docs repo", + sourceTable: "https://github.com/BuilderIO/agent-native", + }, + ], + }, + ], + }); + + expect( + groups.databases[0]?.sources?.map((source) => source.sourceType), + ).toEqual(["builder-cms", "github-url"]); + }); + it("uses document page routes for selectable results", () => { expect(contentCommandDocumentPath("doc-1")).toBe("/page/doc-1"); expect(contentCommandDocumentPath("local-file:ZG9jcy9sYXVuY2gubWQ")).toBe( diff --git a/templates/content/app/lib/content-command-search.ts b/templates/content/app/lib/content-command-search.ts index 9a0fe1a413..19f19583c6 100644 --- a/templates/content/app/lib/content-command-search.ts +++ b/templates/content/app/lib/content-command-search.ts @@ -1,4 +1,4 @@ -import type { ContentDatabaseSummary } from "@shared/api"; +import type { ContentDatabaseSummary, DocumentSourceInfo } from "@shared/api"; export interface CommandSearchDocumentResult { id: string; @@ -8,6 +8,7 @@ export interface CommandSearchDocumentResult { snippet: string; contentLength: number; hideFromSearch: boolean; + source?: DocumentSourceInfo; updatedAt: string; } @@ -22,9 +23,10 @@ export interface ContentCommandSearchGroups { } export function isLocalFileSearchResult( - document: Pick, + document: Pick, ) { return ( + document.source?.mode === "local-files" || document.id.startsWith("local-file:") || document.id.startsWith("local-folder:") ); diff --git a/templates/content/changelog/2026-07-01-local-folders-and-github-urls-can-be-registered-as-mounted-content.md b/templates/content/changelog/2026-07-01-local-folders-and-github-urls-can-be-registered-as-mounted-content.md new file mode 100644 index 0000000000..d881750e66 --- /dev/null +++ b/templates/content/changelog/2026-07-01-local-folders-and-github-urls-can-be-registered-as-mounted-content.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-01 +--- + +Local folders and GitHub URLs can be registered as mounted Content sources alongside Builder CMS. diff --git a/templates/content/shared/api.ts b/templates/content/shared/api.ts index 87d3f7fb9a..20ee530d0e 100644 --- a/templates/content/shared/api.ts +++ b/templates/content/shared/api.ts @@ -362,7 +362,9 @@ export interface ContentDatabaseSourceOverlay { export type ContentDatabaseSourceType = | "mock-local" | "builder-cms" - | "local-table"; + | "local-table" + | "local-folder" + | "github-url"; export type ContentDatabaseSourceSyncState = | "idle" | "linked" @@ -757,10 +759,18 @@ export interface ChangeContentDatabaseSourceRoleRequest { offset?: number; } +export interface ContentDatabaseSummarySource { + id: string; + sourceType: ContentDatabaseSourceType; + sourceName: string; + sourceTable: string; +} + export interface ContentDatabaseSummary { databaseId: string; documentId: string; title: string; + sources?: ContentDatabaseSummarySource[]; } export interface ListContentDatabasesResponse { From a4838c8853788dcc3426dbcf6ef7a463b47966d1 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:04:25 -0400 Subject: [PATCH 2/3] Sync Content agent guide with database actions --- templates/content/AGENTS.md | 104 +++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 2d1a99d93c..69b5b4da59 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -87,54 +87,57 @@ cd templates/content && pnpm action [args] ### Document Operations -| Action | Args | Purpose | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | -| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | -| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | -| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | -| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | -| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | -| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | -| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | -| `search-documents` | `--query [--sourceScope all\|database\|local-files] [--format json]` | Search by title/content and return snippets with source metadata | -| `get-document` | `--id [--format json]` | Get a single document with content | -| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | -| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | -| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | -| `edit-document` | `--id --edits ` | Batch surgical text edits | -| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | -| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | -| `list-local-component-files` | | List registered local MDX component source files | -| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | -| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | -| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | -| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | +| Action | Args | Purpose | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | +| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | +| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | +| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | +| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | +| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | +| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | +| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | +| `search-documents` | `--query [--sourceScope all\|database\|local-files] [--format json]` | Search by title/content and return snippets with source metadata | +| `get-document` | `--id [--format json]` | Get a single document with content | +| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | +| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | +| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | +| `edit-document` | `--id --edits ` | Batch surgical text edits | +| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | +| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | +| `list-local-component-files` | | List registered local MDX component source files | +| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | +| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | +| `create-inline-content-database` | `--hostDocumentId [--title ]` | Create a database owned by an inline database block in the host document | +| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | +| `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | +| `restore-content-database` | `--databaseId ` | Restore a soft-deleted database from the sidebar Trash surface | +| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | | `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock-local\|builder-cms\|local-table\|local-folder\|github-url] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a mounted source binding; `local-folder` and `github-url` require a real `sourceTable` path/URL and register repo-truth workspace scope without Git automation | -| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns; mounted `local-folder` and `github-url` sources reject role changes | -| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured; mounted `local-folder` and `github-url` sources do not refresh | -| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | -| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | -| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | -| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | -| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | -| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | -| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | -| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | -| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | -| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | -| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | -| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | -| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | -| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | -| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | -| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | -| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | -| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | -| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | -| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | -| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | -| `delete-document` | `--id ` | Delete with recursive children | +| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns; mounted `local-folder` and `github-url` sources reject role changes | +| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured; mounted `local-folder` and `github-url` sources do not refresh | +| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | +| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | +| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | +| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | +| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | +| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | +| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | +| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | +| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | +| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | +| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | +| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | +| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | +| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | +| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | +| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | +| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | +| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | +| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | +| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | +| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | +| `delete-document` | `--id ` | Delete with recursive children | Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, etc.) @@ -576,9 +579,10 @@ columns updates that row page's grouping property through multi-select, users can add a new board group from the board itself; this appends a new option to the grouped property definition. Use -`create-content-database`, `get-content-database`, -`add-database-item`, `duplicate-database-item`, `duplicate-database-items`, -`delete-database-items`, `move-database-item`, +`create-content-database`, `create-inline-content-database`, +`get-content-database`, `list-trashed-content-databases`, +`restore-content-database`, `add-database-item`, `duplicate-database-item`, +`duplicate-database-items`, `delete-database-items`, `move-database-item`, `update-content-database-view`, `list-document-properties`, `configure-document-property`, `set-document-property`, `duplicate-document-property`, and `delete-document-property`; do not edit From 9a5bef53f8e9cb3351350cbbe49784127bb692f9 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:16:11 -0400 Subject: [PATCH 3/3] Format Content agent guide --- templates/content/AGENTS.md | 100 ++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 69b5b4da59..945b03cdc3 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -87,57 +87,57 @@ cd templates/content && pnpm action [args] ### Document Operations -| Action | Args | Purpose | -| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | -| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | -| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | -| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | -| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | -| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | -| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | -| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | -| `search-documents` | `--query [--sourceScope all\|database\|local-files] [--format json]` | Search by title/content and return snippets with source metadata | -| `get-document` | `--id [--format json]` | Get a single document with content | -| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | -| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | -| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | -| `edit-document` | `--id --edits ` | Batch surgical text edits | -| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | -| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | -| `list-local-component-files` | | List registered local MDX component source files | -| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | -| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | -| `create-inline-content-database` | `--hostDocumentId [--title ]` | Create a database owned by an inline database block in the host document | -| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | -| `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | -| `restore-content-database` | `--databaseId ` | Restore a soft-deleted database from the sidebar Trash surface | -| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | +| Action | Args | Purpose | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `list-documents` | `[--format json]` | List document metadata/tree; no full bodies | +| `export-content-source` | `[--format json]` | Export editable docs as `content/*.mdx` source files | +| `import-content-source` | `--files [--dryRun true\|false]` | Import `.md`/`.mdx` source files into editable docs | +| `list-builder-docs` | `[--model docs-content\|blog-article] [--limit ]` | List Builder docs/blog entries available for `.builder.mdx` pull | +| `pull-builder-doc` | `--model --entryId [--dryRun true\|false]` | Pull one Builder entry into Content and return `.builder.mdx` plus `content/builder/.raw` sidecar files | +| `check-builder-doc` | `--files [--path ]` or `--documentId ` | Validate Builder MDX round-trip, sidecar hashes, and remote conflict status before push | +| `push-builder-doc` | `--files [--path ] [--dryRun true\|false]` | Guarded Builder autosave PATCH for the safe Builder test model; never publishes | +| `navigate` | `--path ` or `--documentId ` or `--databaseId ` | Open a route, document page, or database page in the UI | +| `search-documents` | `--query [--sourceScope all\|database\|local-files] [--format json]` | Search by title/content and return snippets with source metadata | +| `get-document` | `--id [--format json]` | Get a single document with content | +| `pull-document` | `--id [--format markdown\|text]` | Collab-aware "ingest the final" read | +| `create-document` | `--title [--content] [--parentId] [--icon]` | Create a new document | +| `edit-document` | `--id --find --replace ` | Surgical text edit (preferred for modifications) | +| `edit-document` | `--id --edits ` | Batch surgical text edits | +| `update-document` | `--id [--title] [--content] [--icon]` | Full rewrite of document fields | +| `share-local-file-document` | `--id ` | Create or refresh a DB-backed shareable copy of a local file document | +| `list-local-component-files` | | List registered local MDX component source files | +| `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | +| `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | +| `create-inline-content-database` | `--hostDocumentId [--title ]` | Create a database owned by an inline database block in the host document | +| `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | +| `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | +| `restore-content-database` | `--databaseId ` | Restore a soft-deleted database from the sidebar Trash surface | +| `get-content-database-source` | `--databaseId ` or `--documentId ` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets | | `attach-content-database-source` | `--databaseId ` or `--documentId [--sourceType mock-local\|builder-cms\|local-table\|local-folder\|github-url] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join ]` | Attach a mounted source binding; `local-folder` and `github-url` require a real `sourceTable` path/URL and register repo-truth workspace scope without Git automation | -| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns; mounted `local-folder` and `github-url` sources reject role changes | -| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured; mounted `local-folder` and `github-url` sources do not refresh | -| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | -| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | -| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | -| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | -| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | -| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | -| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | -| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | -| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | -| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | -| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | -| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | -| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | -| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | -| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | -| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | -| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | -| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | -| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | -| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | -| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | -| `delete-document` | `--id ` | Delete with recursive children | +| `change-content-database-source-role` | `--databaseId ` or `--documentId --sourceId --relationshipMode items\|details [--join ]` | Change an attached source between adding rows and adding matched detail columns; mounted `local-folder` and `github-url` sources reject role changes | +| `refresh-content-database-source` | `--databaseId ` or `--documentId ` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured; mounted `local-folder` and `github-url` sources do not refresh | +| `process-builder-body-hydration` | `--sourceId [--documentId ] [--limit ]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers | +| `set-content-database-source-write-mode` | `--databaseId ` or `--documentId --liveWritesEnabled true\|false [--allowedWriteModes ]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes | +| `stage-builder-revision` | `--databaseId ` or `--documentId ` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder | +| `review-content-database-source-change-set` | `--databaseId ` or `--documentId --changeSetId --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes | +| `prepare-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder | +| `validate-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder | +| `execute-builder-source-execution` | `--databaseId ` or `--documentId --changeSetId [--idempotencyKey ] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` | +| `add-database-item` | `--databaseId [--title ] [--propertyValues ]` | Add a page row to a database, optionally seeding property values | +| `duplicate-database-item` | `--itemId ` or `--documentId [--title ]` | Duplicate exactly one database row page and its stored property values; for two or more rows, use `duplicate-database-items` once | +| `duplicate-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row duplicate action; duplicate multiple database row pages atomically and return ordered duplicate item/document IDs | +| `delete-database-items` | `--databaseId ` or `--documentId --itemIds ` or `--documentIds ` | Preferred multi-row delete action; delete multiple database row pages atomically while preserving `delete-document` admin semantics | +| `move-database-item` | `--itemId ` or `--documentId --position ` | Move a database row page to a new zero-based table position | +| `update-content-database-view` | `--databaseId --viewConfig ` | Persist database views, sorts, filters, hidden properties, and view settings | +| `list-document-properties` | `--documentId [--format json]` | List Notion-style property definitions and values for a document | +| `configure-document-property` | `--documentId [--id ] --name --type [--visibility always_show\|hide_when_empty\|always_hide]` | Create or update a property definition | +| `duplicate-document-property` | `--documentId --propertyId ` | Duplicate a property definition and its stored values | +| `delete-document-property` | `--documentId --propertyId ` | Delete a property definition and its stored values | +| `set-document-property` | `--documentId --propertyId --value ` | Set a document property value (for a `blocks` field, the value is its markdown content) | +| `reorder-document-property` | `--documentId --propertyId --targetPropertyId [--position before\|after]` | Reorder a property definition within its database (used to reorder Blocks fields on the page) | +| `set-document-discoverability` | `--id --hideFromSearch true\|false [--includeChildren true\|false]` | Hide/show an org-accessible document in Organization/search while keeping link access | +| `move-document` | `--id [--parentId] [--position]` | Move or reorder a document in the page tree | +| `delete-document` | `--id ` | Delete with recursive children | Database views follow Notion-style tab labels. When creating or duplicating views in `viewConfig`, use unique default names (`Table 2`, `SEO copy 2`, etc.)