# Canvas CLI & component schema

Lookup material for the `@drupal-canvas/cli` npm package (the `canvas` command) and the [component](/start-here/glossary/#component) schema, for the in-platform-rendering path on Source CMS (see [choose your path](/start-here/choose-your-backend/)). For step-by-step instruction, use the [Canvas quickstart](/source-cms/canvas-components/quickstart/) and the [Canvas components section](/source-cms/canvas-components/).

Covers `@drupal-canvas/cli` 0.20.1.

## Installation

```bash
# per project (the Nebula scaffold includes it)
npm install @drupal-canvas/cli
# global
npm install @drupal-canvas/cli -g
```

Projects scaffolded with `npx @drupal-canvas/create@latest` have it preinstalled; run it as `npx canvas <command>`.

## Authentication

Two flows, requiring the [site](/start-here/glossary/#site)'s Canvas OAuth module (`canvas_oauth`, shipped as a submodule of [Drupal Canvas](/start-here/glossary/#drupal-canvas)):

- **Interactive login** (individual developers): `npx canvas login`: OAuth 2.0 authorization code + PKCE via the browser. Tokens are stored in `~/.config/drupal-canvas/oauth.json`, keyed by site URL, and used automatically.
- **Client credentials** (CI/CD, service accounts): an [API client](/start-here/glossary/#api-client) with the `Client Credentials` checkbox and the [scopes](/start-here/glossary/#scope) below, supplied via environment variables. This is a distinct OAuth client from the `DRUPAL_*` credentials used for the Content API and MCP server; the CLI reads its own `CANVAS_*` variable names (below), scoped to pushing/pulling components rather than reading content.

**Stored login tokens win over environment credentials (verified 2026-07-03).** When `oauth.json` holds tokens for the target site, the CLI uses them and ignores `CANVAS_CLIENT_ID`/`CANVAS_CLIENT_SECRET`. If commands fail with `You must be logged in to access this resource.` despite valid client credentials in the environment, run [`canvas logout`](#canvas-logout) for that site first. On a Source CMS site, the browser login delegates to Acquia ID, and when your Acquia ID lacks access to that particular site, the stored tokens are rejected by its Canvas API while still shadowing your working client credentials. On a site your Acquia ID does have access to, the browser login works end-to-end (both cases verified 2026-07-03).

### OAuth scopes

| Scope | Grants |
|---|---|
| `canvas:js_component` | Create and update code components; `canvas push` uploads built components as `js_component` config entities |
| `canvas:asset_library` | Upload the built global CSS and asset bundles; `canvas push` uploads Tailwind CSS assets as the site's `asset_library` |

Both scopes are required for the standard push/pull workflow; they are the scopes to add when creating the API client (`API > API clients`). Additional scopes the CLI requests when the matching features are enabled: `canvas:page:create`, `canvas:page:read`, `canvas:page:edit` (pages), `canvas:content_template` (content templates), `canvas:page_region` (global regions), and `canvas:brand_kit` (fonts, with `--include-brand-kit`). When `CANVAS_SCOPE` is unset, the CLI requests the `canvas_oauth` defaults.

## Configuration

### Environment variables (`.env`)

| CLI argument | Environment variable | Description |
|---|---|---|
| `--site-url` | `CANVAS_SITE_URL` | Base URL of the Drupal site |
| `--client-id` | `CANVAS_CLIENT_ID` | OAuth client ID |
| `--client-secret` | `CANVAS_CLIENT_SECRET` | OAuth client secret (never commit it) |
| `--scope` | `CANVAS_SCOPE` | Optional space-separated scope list; defaults to the `canvas_oauth` defaults |
| — | `CANVAS_ACCESS_TOKEN` | Optional pre-issued access token; when set, client ID/secret/scope are ignored |
| — | `CANVAS_JSONAPI_PREFIX` | Set if the site's [JSON:API](/start-here/glossary/#jsonapi) prefix differs from the CLI default `jsonapi`. There is no auto-detection (confirmed in the CLI source 2026-07-03: the value falls back from flag to this variable to the Drupal-core default). Source CMS sites serve JSON:API at `/api`, so set `CANVAS_JSONAPI_PREFIX=api` there |
| `--include-brand-kit` | `CANVAS_INCLUDE_BRAND_KIT` | Optional; include Brand Kit fonts in `pull`/`push` (default `false`) |

Precedence, highest first: command-line arguments → environment variables → project `.env` → `~/.canvasrc` → defaults. `~/.canvasrc` is a dotenv-format file (`KEY=value` lines) holding the same variables as a project `.env`, for values shared across projects; the CLI loads it first and the project `.env` after, so local values win (confirmed in the CLI source 2026-07-03).

### `canvas.config.json` (optional, committed)

| Property | Default | Description |
|---|---|---|
| `componentDir` | `"src/components"` | Where components live; must be inside `aliasBaseDir` |
| `pagesDir` | `"pages"` | Where page specs live |
| `contentTemplatesDir` | `"content-templates"` | Where content template specs live |
| `aliasBaseDir` | `"src"` | Base directory for `@/` import resolution |
| `outputDir` | `"dist"` | Build output directory |
| `globalCssPath` | `"src/global.css"` | Global (Tailwind) CSS entry point |
| `sync.pages` / `sync.contentTemplates` / `sync.regions` | `true` | Include pages / content templates / global regions in `pull`, `push`, and `reconcile-media` |

## Commands

### `canvas login`

Log in to a Canvas site via browser (OAuth 2.0 authorization code + PKCE); stores tokens in `~/.config/drupal-canvas/oauth.json`.

```bash
npx canvas login [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` | Canvas site URL (prompted if omitted) |
| `--client-id <id>` | OAuth client ID (overrides auto-discovery) |
| `--port <number>` | Local callback port (default `4444`); the [API client](/start-here/glossary/#api-client)'s `Redirect URIs` (configured at `API > API clients` in the site's admin UI) must include `http://localhost:<port>/callback` |

On Source CMS, OAuth discovery delegates to Acquia ID with a pre-registered client, so the browser opens an `id.acquia.com` login, not the site's own:

```text
$ npx canvas login --site-url https://your-site.example.com

┌   Drupal Canvas  login
│
◇  OAuth configuration discovered
│
●  Opening browser for login...
│  If it does not open automatically:
│  https://id.acquia.com/oauth2/default/v1/authorize?response_type=code&client_id=…&redirect_uri=http%3A%2F%2Flocalhost%3A4444%2Fcallback&…
│
◆  Credentials stored for https://your-site.example.com
│
└  Run `canvas push` or `canvas pull` to get started.
```

The callback wait times out after 5 minutes; the failure output is (captured):

```text
│  Failed
│    ✗ Authorization failed
│      Timed out waiting for OAuth callback after 5 minutes.
│
└  Login failed
```

See the [authentication caveat](#authentication) before relying on browser-login tokens for `pull`/`push` on a Source CMS site.

### `canvas logout`

Remove stored credentials for a site from `~/.config/drupal-canvas/oauth.json`.

```bash
npx canvas logout [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` | Site to log out of (prompted if omitted) |

Example:

```bash
$ npx canvas logout --site-url https://example.com

┌  Drupal Canvas logout
│
●  Credentials removed: https://example.com
│
└  Logout completed
```

### `canvas pull`

Pull components, global CSS, and optional pages, content templates, global regions, and Brand Kit fonts from Drupal to the local filesystem.

```bash
npx canvas pull [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` / `--client-id <id>` / `--client-secret <secret>` / `--scope <scope>` | Credentials (usually from `.env`) |
| `-d, --dir <directory>` | Component directory (default: `componentDir` or `src/components`) |
| `--include-pages [enabled]` / `--include-content-templates [enabled]` / `--include-regions [enabled]` | Include pages / content templates / global regions (preset `true`), the inverse of the `--no-*` flags below |
| `--no-pages` / `--no-content-templates` / `--no-regions` | Exclude pages / content templates / global regions |
| `--include-brand-kit [enabled]` | Include Brand Kit fonts |
| `--skip-overwrite` | Skip items that already exist locally |
| `-y, --yes` | Skip all confirmation prompts (CI-safe) |

Example: `npx canvas pull --yes --skip-overwrite`: non-interactive, pulls only items not present locally.

With `--yes` and credentials from `.env`, the plan runs first, then each stage reports what it wrote; components land in `componentDir`, pages in `pages/`, regions in `regions/`, global CSS at `src/global.css`:

```text
$ npx canvas pull --yes

┌   Drupal Canvas  pull
│
│  Plan
│  Components: 1 pull (1 new)
│  Assets: global CSS pull
│  Pages: 4 pull (4 new)
│  Global regions: 2 pull (2 new)
│
◇  Pulled components
│  Succeeded: text
│
◇  Pulled assets
│  Succeeded: global.css
│
◇  Pulled pages
│  Succeeded: Access denied, Homepage, Not found, …
│
◇  Pulled global regions
│  Succeeded: footer, header
│
└  Pull completed
```

### `canvas push`

Build and push local components, global CSS, component dependencies (bundled vendor and `@/` imports), and optional pages, content templates, global regions, and fonts to Drupal. Components upload as `js_component` entities (scope `canvas:js_component`); built CSS assets upload as `asset_library` (scope `canvas:asset_library`).

:::danger[Push is a full sync: it deletes remote components missing locally]
The push plan mirrors the local component directory exactly. Pushing from a directory holding 1 of a site's 26 components produced this plan: `Components: 1 update, 25 delete`. Never run `push --yes` from a partial checkout; `pull` first, and read the `Plan` block before confirming.
:::

```bash
npx canvas push [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` / `--client-id <id>` / `--client-secret <secret>` / `--scope <scope>` | Credentials (usually from `.env`) |
| `-d, --dir <directory>` | Component directory (default: `componentDir` or `src/components`) |
| `--include-pages [enabled]` / `--include-content-templates [enabled]` / `--include-regions [enabled]` | Include pages / content templates / global regions (preset `true`), the inverse of the `--no-*` flags below |
| `--no-pages` / `--no-content-templates` / `--no-regions` | Exclude pages / content templates / global regions |
| `--include-brand-kit [enabled]` | Include Brand Kit fonts |
| `-y, --yes` | Skip confirmation prompts (CI-safe) |

For example:

```text
$ npx canvas push --yes --no-pages --no-regions --no-content-templates

┌   Drupal Canvas  push
│
●  Local pages were found but excluded by --no-pages. Remove that flag to push them.
│
│  Plan
│  Components: 1 update
│
◇  Pushed components
│  Updated: text
│
◇  Prepared assets
│
◇  Pushed assets
│  Global CSS (Tailwind CSS build)
│
└  ✓ Push completed
```

Two verified caveats on full pushes (everything included):

- **Pulled system pages don't push back.** A full push of a freshly pulled site failed on the `Access denied` and `Not found` pages with `Unable to update field path for entity "…"` (captured); regular pages updated fine. Later stages are then skipped (`Not started`) and the run ends `✗ Push incomplete`. Exclude them (`--no-pages`) or expect those two failures.
- **Failures abort stage-by-stage.** Push validates and builds everything before uploading; lint or build errors mean nothing at all is uploaded. Push lints component imports first; components authored against the pre-`drupal-canvas`-package layout fail like this:

```text
│  Plan
│  Components: 26 update
│  Pages: 6 update
│  Global regions: 2 update
│
│  Failed
│    ✗ src/components/button/index.tsx
│      Line 2, Column 1: Utilities were moved into the `drupal-canvas` package. The `@/lib/utils` path is provided by Canvas and cannot be used for local files. (drupal-canvas/component-imports)
│    ✗ src/components/image/index.tsx
│      Line 1, Column 1: Using `next-image-standalone` directly is deprecated. Use the `Image` component from the `drupal-canvas` package instead. (drupal-canvas/component-imports)
```

A component that imports a site-provided extension module (e.g. `import … from 'canvas_forms/useCanvasForm'`, the alias exposed in-site by a Canvas extension) fails the pre-push build outside the site, and one failing component aborts the whole push:

```text
│  Failed
│    ✗ Build failed
│      Could not resolve entry module "canvas_forms/useCanvasForm".
│
│    ✗ Push failed
```

Another captured failure, against a URL that is not a Canvas-enabled site:

```text
│  Failed
│    ✗ Push failed
│      API endpoint not found: /oauth/token
│
│      Possible causes:
│        • Canvas module is not enabled
│        • Site URL is incorrect
│        • Server is not responding correctly
│
└  ✗ Push failed
```

### `canvas build`

Build local components, vendor dependencies, and Tailwind CSS assets with automatic component discovery. Requires the global CSS file at `globalCssPath`. Output goes to per-component `dist/` directories plus a top-level `dist/` with `vendor/`, `local/`, Tailwind assets, and a `canvas-manifest.json` import map.

```bash
npx canvas build [options]
```

| Flag | Description |
|---|---|
| `-d, --dir <directory>` | Directory to scan (default: `componentDir` or `src/components`) |
| `--alias-base-dir <directory>` | Base directory for module resolution (default `src`) |
| `--output-dir <directory>` | Build output directory (default `dist`) |
| `-y, --yes` | Skip confirmation prompts |

Example:

```bash
$ npx canvas build --yes

┌  Drupal Canvas build
│
◇  Discovered components
│
◇  Built components
│
│  Succeeded: my-hero
│
│  Built assets
│  Tailwind CSS
│
●  Generated canvas-manifest.json — 0 vendor
   packages, 0 local imports
│
└  Build completed
```

### `canvas validate`

Validate local components (ESLint with the `required` rules from `@drupal-canvas/eslint-config`, including allowed import patterns) and authored pages, content templates, and global regions.

```bash
npx canvas validate [options]
```

| Flag | Description |
|---|---|
| `-d, --dir <directory>` | Component directory to validate |
| `--fix` | Apply available automatic fixes (default `false`) |

Example (failure shows file, position, and rule):

```text
$ npx canvas validate

┌  Drupal Canvas validate
│
◇  Validated components
│
│  Failed
│    ✗ src/components/my-hero/component.yml
│      Line 2, Column 14: Directory name "my-hero" does not
│      match machineName "hello-world".
│      (drupal-canvas/component-dir-name)
│
└  Validation failed
```

### `canvas scaffold`

Create a new code component scaffold (`component.yml`, `index.jsx`, `index.css`) in the component directory. Prompts for the directory if no `canvas.config.json` is present.

```bash
npx canvas scaffold [options]
```

| Flag | Description |
|---|---|
| `-n, --name <n>` | Component name (used for directory and metadata) |
| `-d, --dir <directory>` | Component directory to create the component in |

Example:

```text
$ npx canvas scaffold --name my-hero

┌  Drupal Canvas scaffold
│
◇  Created component
│
│  Created: my-hero
│  Directory: src/components/my-hero
│  Component metadata: src/components/my-hero/component.yml
│  Source file: src/components/my-hero/index.jsx
│  CSS file: src/components/my-hero/index.css
│
└  Scaffold completed
```

Note: as of 0.20.1 the generated `component.yml` contains `machineName: hello-world` regardless of `--name`; set it to the directory name or `canvas validate` fails (see example above).

### `canvas agents-context`

**Experimental.** Pull context for local AI agents from the Drupal site into `.agents/drupal-canvas/`: `prop-sources.json` (available field bindings per entity bundle and component), `view-modes.json` (view modes per entity type and bundle), and a `.gitignore` (the directory is not meant to be committed).

```bash
npx canvas agents-context [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` / `--client-id <id>` / `--client-secret <secret>` / `--scope <scope>` | Credentials (usually from `.env`) |

The context lands in `.agents/drupal-canvas`:

```text
$ npx canvas agents-context

┌   Drupal Canvas  agents-context
│
◇  Saved agents context to .agents/drupal-canvas
│
└  Agents context pulled
```

### `canvas reconcile-media`

Upload external media referenced in local page specs, content templates, and global regions to Drupal as media entities, and update the local specs with the resolved image data and provenance (`target_id`) so those resources can be pushed.

```bash
npx canvas reconcile-media [options]
```

| Flag | Description |
|---|---|
| `--site-url <url>` / `--client-id <id>` / `--client-secret <secret>` / `--scope <scope>` | Credentials (usually from `.env`) |
| `-d, --dir <directory>` | Component directory |
| `--include-pages [enabled]` / `--include-content-templates [enabled]` / `--include-regions [enabled]` | Include pages / content templates / global regions (preset `true`), the inverse of the `--no-*` flags below |
| `--no-pages` / `--no-content-templates` / `--no-regions` | Exclude pages / content templates / global regions |
| `-y, --yes` | Skip confirmation prompts |

The nothing-to-do path, immediately after a fresh `pull`:

```text
$ npx canvas reconcile-media --yes

┌   Drupal Canvas  reconcile media
│
●  No unreconciled media found.
│
└  Media reconciliation skipped
```

### `canvas download` / `canvas upload` (removed)

Both commands were removed in favor of `pull` and `push`. Running them prints the pointer and exits non-zero:

```text
$ npx canvas download

┌  Drupal Canvas download
│
▲  The `canvas download` command has been removed.
   Use `canvas pull` instead.
│
└  Command removed
```

`canvas upload` prints the same for `canvas push`.

## Component schema

A code component is a directory (its name is the component's [machine name](/start-here/glossary/#machine-name)) containing:

- `component.yml`: metadata and schema.
- The source file with the default export: `index.jsx` (`canvas pull` writes TypeScript components as `index.tsx`, verified 2026-07-03).
- `index.css`: optional component CSS.
- `mocks.json`: optional preview data for Canvas Workbench.

On the server, `component.yml` maps onto the Canvas module's `JavaScriptComponent` config entity; the canonical schema is the `canvas.js_component.*` entry in that module's config schema. The shape below matches components generated by `canvas scaffold` 0.20.1 and the Nebula template's example components.

### Top-level fields (`component.yml`)

| Field | Type | Constraints |
|---|---|---|
| `name` | string | Human-readable label shown in the component library |
| `machineName` | string | Must equal the component's directory name (`drupal-canvas/component-dir-name` rule) |
| `status` | boolean | Whether the component is available for placement |
| `required` | array of strings | [Prop](/start-here/glossary/#prop) names that must have a value |
| `props` | object | JSON Schema object (see below) |
| `slots` | object or `[]` | Map of [slot](/start-here/glossary/#slot) definitions (see below) |

### Prop definitions (`props.properties.<propName>`)

Each entry is a JSON Schema property; the component receives it as a camelCase argument of the same name.

| Field | Type | Use |
|---|---|---|
| `type` | string | JSON Schema type: `string`, `integer`, `number`, `boolean`, `object` (e.g. image) |
| `title` | string | Field label in the editor's `Props` tab |
| `description` | string | Help text in the editor |
| `examples` | array | Example value(s) used for previews |
| `enum` | array | Allowed values (renders as a dropdown) |
| `meta:enum` | object | Human-readable label per `enum` value |
| `format` | string | String format: `uri`, `uri-reference` (links) |
| `$ref` | string | Shared definition, e.g. `json-schema-definitions://canvas.module/image` for image props (`src`, `alt`, `width`, `height`) |
| `contentMediaType` | string | `text/html` marks a formatted-text prop |
| `x-formatting-context` | string | Formatting context for formatted text, e.g. `block` |

Prop types offered in the Canvas editor UI: `Text`, `Formatted text`, `Link`, `Image`, `Video`, `Date and time`, `Integer`, `Number`, `Boolean`, `List: text`, `List: integer`, and `Content entity reference`.

Multi-value ("Allow multiple values", delivered to the component as an array) can be switched on in the editor for every type except `Formatted text`, `Boolean`, and `Content entity reference`. A hand-written `component.yml` may still declare `type: array` over a Formatted text or Boolean item shape, which Canvas validates and stores; the editor then offers no checkbox for it.

Constraints once a component is in the library:

- A prop's **name** and **type** cannot change, and a single-value prop cannot become multi-value; delete and recreate the prop instead.
- Adding a **required** prop (or making an optional prop required) needs a default or example value; existing instances are auto-filled with it.
- Changed default/example values are **not backfilled** to instances already saved on pages.
- Prop names are converted to camelCase (**Heading Text** → `headingText`).

### Prop type declarations

What declares each editor type, written exactly as the editor's Component Data pane writes it into the schema:

| Editor type | Declaration |
|---|---|
| `Text` | `type: string` |
| `Formatted text` | `type: string` plus `contentMediaType: text/html` (with `x-formatting-context: block`, the only supported context) |
| `Link` | `type: string` plus `format: uri-reference` (relative or absolute), or `format: uri` (absolute only) |
| `Image` | `type: object` plus `$ref: json-schema-definitions://canvas.module/image` (an object of `src` (required), `alt`, `width`, `height`) |
| `Video` | `type: object` plus `$ref: json-schema-definitions://canvas.module/video` (`src` required, optional `poster`) |
| `Date and time` | `type: string` plus `format: date` (date only) or `format: date-time` (date and time) |
| `Integer` | `type: integer` |
| `Number` | `type: number` |
| `Boolean` | `type: boolean` |
| `List: text` | `type: string` plus `enum` listing the allowed values, with a `meta:enum` label per value |
| `List: integer` | `type: integer` plus `enum` and `meta:enum` |
| `Content entity reference` | `type: object` plus `$ref: json-schema-definitions://canvas.module/content-entity-reference`; declare it through the editor's Component Data pane, which also records the entity type and bundle it accepts. It cannot be listed under `required` and takes no `examples` |

Three rules sit across every row:

- **Multi-value wraps the declaration.** `type: array` with the single-value declaration moved inside `items`; an optional `maxItems` caps the count and must be at least 2 (a single-value prop is a non-array type, never `maxItems: 1`). A required multi-value prop also carries `minItems: 1`, which the platform writes for you.
- **`examples[0]` is the default value** the editor prefills; a JSON Schema `default:` key is discarded. A prop listed under `required` must carry an example value, or the component fails validation.
- **An unrecognized declaration disables the whole component**, not just the prop. The error names both: `Drupal Canvas does not know of a field type/widget to allow populating the <prop> prop, with the shape <schema>`. Everything in the table above maps; a declaration outside it (an arbitrary `type: object` without a `$ref`, an unsupported `format`) does not.

Worked YAML for each type is in Canvas's own reference, <a href="https://project.pages.drupalcode.org/canvas/code-components/component-metadata" target="_blank" rel="noopener">component metadata</a>. An SDC's `*.component.yml` on Cloud Platform accepts the same declarations (under `props.properties`, with `required` inside `props` rather than top-level) plus wider JSON Schema (`pattern`, `maxLength`, `minimum`/`maximum`); its worked examples are on the <a href="https://project.pages.drupalcode.org/canvas/sdc-components/props" target="_blank" rel="noopener">SDC props page</a>.

### Slot definitions (`slots.<slotName>`)

| Field | Type | Use |
|---|---|---|
| `title` | string | Label in the editor's `Slots` tab |
| `description` | string | Help text in the editor |
| `examples` | array | Example HTML/JSX that determines the slot's default preview rendering |

The component receives the slot as an argument named after the slot key and renders it as children. A slot's **name cannot be renamed** once the component is in the library: renaming acts as delete-plus-add and hides child components placed in the original slot (re-adding a slot with the exact original name before saving restores them).

### Example

```yaml
name: My Hero
machineName: my-hero
status: false
required: []
props:
  properties:
    greeting:
      type: string
      title: Greeting
      description: The greeting to display
      examples:
        - My Hero
    ctaLink:
      type: string
      format: uri
      title: CTA Link
      examples:
        - https://example.com
slots:
  content:
    title: Content
    description: Content to display below the greeting
    examples:
      - <div>Example slot content</div>
```

## Used in

- [Canvas quickstart](/source-cms/canvas-components/quickstart/): `scaffold`, `validate`, `push`, and the client-credentials setup.
- [Push to a live site](/source-cms/canvas-components/push-to-a-live-site/): `push`'s plan and confirmation step, `pull --skip-overwrite`, and what the site does with a planned delete.
- [Canvas components section](/source-cms/canvas-components/): `validate`, `build`, and the prop/slot schema.
- GitHub Sync: the branch-namespaced `CANVAS_*` credentials the sync workflows consume.
