@freelygive/canvas-jsonapi 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @freelygive/canvas-jsonapi
2
+
3
+ CLI for managing content in Drupal Canvas / Acquia Source via JSON:API.
4
+
5
+ List, fetch, create, update, and delete pages, media, and other content
6
+ entities. Upload images, videos, and documents. Validate component inputs
7
+ against local `component.yml` definitions before sending to the server.
8
+
9
+ ## Quick start
10
+
11
+ Run directly with `npx` (no install required):
12
+
13
+ ```bash
14
+ npx @freelygive/canvas-jsonapi <command> [args...]
15
+ ```
16
+
17
+ Or inside a DDEV environment:
18
+
19
+ ```bash
20
+ ddev npx @freelygive/canvas-jsonapi <command> [args...]
21
+ ```
22
+
23
+ ## Setup
24
+
25
+ ### Component directory
26
+
27
+ Create a `canvas.config.json` in your project root (shared with
28
+ `@drupal-canvas/cli`):
29
+
30
+ ```json
31
+ {
32
+ "componentDir": "./src/components"
33
+ }
34
+ ```
35
+
36
+ This tells the CLI where to find `component.yml` files for input validation.
37
+
38
+ ### API credentials
39
+
40
+ Create a `.env` file in your project root:
41
+
42
+ ```env
43
+ CANVAS_SITE_URL=https://your-site.acquia.site
44
+ CANVAS_JSONAPI_PREFIX=api
45
+ CANVAS_CLIENT_ID=cli
46
+ CANVAS_CLIENT_SECRET=your-secret
47
+ ```
48
+
49
+ | Variable | Required | Default | Description |
50
+ | ----------------------- | -------- | --------- | ------------------------------------ |
51
+ | `CANVAS_SITE_URL` | Yes | | Base URL of Acquia Source site |
52
+ | `CANVAS_JSONAPI_PREFIX` | No | `jsonapi` | API path prefix |
53
+ | `CANVAS_CLIENT_ID` | No | | OAuth client ID |
54
+ | `CANVAS_CLIENT_SECRET` | No | | OAuth client secret |
55
+ | `CONTENT_OAUTH_SCOPE` | No | | OAuth scope override |
56
+ | `CONTENT_NO_AUTH` | No | | Set to `true` to skip authentication |
57
+
58
+ The component directory is resolved in this order:
59
+
60
+ 1. `componentDir` in `canvas.config.json`
61
+ 2. `CANVAS_COMPONENT_DIR` env var (legacy fallback)
62
+ 3. `./src/components` (default)
63
+
64
+ ## Commands
65
+
66
+ ### List content
67
+
68
+ ```bash
69
+ npx @freelygive/canvas-jsonapi list <type>
70
+ npx @freelygive/canvas-jsonapi list --types # Discover available types
71
+ ```
72
+
73
+ ### Get content
74
+
75
+ Fetch and save content items locally to `content/<type>/<uuid>.json`:
76
+
77
+ ```bash
78
+ npx @freelygive/canvas-jsonapi get <type> <uuid> [<uuid>...]
79
+ npx @freelygive/canvas-jsonapi get <type> <uuid> --include <relationships>
80
+ npx @freelygive/canvas-jsonapi get <file-path> # Refresh from existing file
81
+ ```
82
+
83
+ ### Create content
84
+
85
+ ```bash
86
+ npx @freelygive/canvas-jsonapi create <file-path>
87
+ ```
88
+
89
+ ### Update content
90
+
91
+ ```bash
92
+ npx @freelygive/canvas-jsonapi update <file-path>
93
+ ```
94
+
95
+ ### Delete content
96
+
97
+ ```bash
98
+ npx @freelygive/canvas-jsonapi delete <type> <uuid> [<uuid>...]
99
+ ```
100
+
101
+ ### Upload media
102
+
103
+ ```bash
104
+ npx @freelygive/canvas-jsonapi upload-image <file-path> [alt-text]
105
+ npx @freelygive/canvas-jsonapi upload-video <file-path> [name]
106
+ npx @freelygive/canvas-jsonapi upload-document <file-path> [name]
107
+ ```
108
+
109
+ ### Utilities
110
+
111
+ ```bash
112
+ npx @freelygive/canvas-jsonapi uuid [count] # Generate random UUIDs
113
+ npx @freelygive/canvas-jsonapi whoami # Check OAuth status
114
+ ```
115
+
116
+ ## Local content storage
117
+
118
+ Content is saved to `content/` in the current working directory:
119
+
120
+ ```
121
+ content/
122
+ page/
123
+ <uuid>.<title-slug>.json
124
+ media--image/
125
+ <uuid>.<filename>.json
126
+ media--document/
127
+ <uuid>.<name>.json
128
+ ```
129
+
130
+ Add `content/` to your `.gitignore`.
131
+
132
+ ## Component validation
133
+
134
+ Before creating or updating pages, component inputs are validated against
135
+ `component.yml` files found via `canvas.config.json`. This catches type errors
136
+ and missing required props before the API returns a generic 503.
137
+
138
+ ## AI assistant usage
139
+
140
+ When using this tool with an AI assistant (e.g., Claude Code), add the following
141
+ to your `CLAUDE.md` or equivalent instructions file:
142
+
143
+ ```markdown
144
+ ## Content Management
145
+
146
+ Use `npx @freelygive/canvas-jsonapi` (or `ddev npx @freelygive/canvas-jsonapi`
147
+ in DDEV) to manage content in Acquia Source.
148
+
149
+ Available commands: list, get, create, update, delete, upload-image,
150
+ upload-video, upload-document, uuid, whoami.
151
+
152
+ Run `npx @freelygive/canvas-jsonapi` with no arguments for full usage.
153
+ ```
154
+
155
+ ## License
156
+
157
+ GPL-2.0-or-later
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@freelygive/canvas-jsonapi",
3
+ "version": "0.0.0",
4
+ "description": "CLI for managing content in Drupal Canvas / Acquia Source via JSON:API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "canvas-jsonapi": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "dependencies": {
17
+ "dotenv": "^16.0.0",
18
+ "drupal-jsonapi-params": "^3.0.0",
19
+ "js-yaml": "^4.1.1"
20
+ },
21
+ "keywords": [
22
+ "drupal",
23
+ "canvas",
24
+ "acquia",
25
+ "jsonapi",
26
+ "cms",
27
+ "content-management"
28
+ ],
29
+ "license": "GPL-2.0-or-later"
30
+ }
package/src/client.js ADDED
@@ -0,0 +1,181 @@
1
+ /* global process */
2
+ /**
3
+ * API client utilities for JSON:API access.
4
+ * Uses environment variables from .env file.
5
+ *
6
+ * Required env vars:
7
+ * - CANVAS_SITE_URL: Base URL of Acquia Source site
8
+ *
9
+ * Optional env vars:
10
+ * - CANVAS_JSONAPI_PREFIX: API prefix (default: "jsonapi")
11
+ * - CANVAS_CLIENT_ID: OAuth client ID (if authentication required)
12
+ * - CANVAS_CLIENT_SECRET: OAuth client secret (if authentication required)
13
+ * - CONTENT_OAUTH_SCOPE: OAuth scope for content access
14
+ * - CONTENT_NO_AUTH: Set to "true" to disable authentication
15
+ */
16
+ import { config } from 'dotenv';
17
+
18
+ // Load environment variables
19
+ config();
20
+
21
+ // Cache for OAuth token
22
+ let tokenCache = null;
23
+
24
+ // Cache for JSON:API endpoint mapping
25
+ let endpointMapCache = null;
26
+
27
+ /**
28
+ * Get the site base URL
29
+ * @returns {string} Site base URL
30
+ */
31
+ export function getSiteUrl() {
32
+ const baseUrl = process.env.CANVAS_SITE_URL;
33
+ if (!baseUrl) {
34
+ throw new Error('Missing required environment variable: CANVAS_SITE_URL');
35
+ }
36
+ return baseUrl.replace(/\/$/, '');
37
+ }
38
+
39
+ /**
40
+ * Get the API base URL with prefix
41
+ * @returns {string} Full API base URL
42
+ */
43
+ export function getApiBaseUrl() {
44
+ const apiPrefix = process.env.CANVAS_JSONAPI_PREFIX || 'jsonapi';
45
+ return `${getSiteUrl()}/${apiPrefix}`;
46
+ }
47
+
48
+ /**
49
+ * Get OAuth access token using client credentials flow
50
+ * @returns {Promise<string|null>} Access token or null if auth disabled
51
+ */
52
+ async function getAccessToken() {
53
+ const noAuth = process.env.CONTENT_NO_AUTH === 'true';
54
+ if (noAuth) {
55
+ return null;
56
+ }
57
+
58
+ const clientId = process.env.CANVAS_CLIENT_ID;
59
+ const clientSecret = process.env.CANVAS_CLIENT_SECRET;
60
+
61
+ if (!clientId || !clientSecret) {
62
+ return null;
63
+ }
64
+
65
+ // Check cache
66
+ if (tokenCache && tokenCache.expiresAt > Date.now()) {
67
+ return tokenCache.accessToken;
68
+ }
69
+
70
+ // Request new token
71
+ const tokenUrl = `${getSiteUrl()}/oauth/token`;
72
+ const params = new URLSearchParams({
73
+ grant_type: 'client_credentials',
74
+ client_id: clientId,
75
+ client_secret: clientSecret,
76
+ });
77
+
78
+ const scope = process.env.CONTENT_OAUTH_SCOPE;
79
+ if (scope) {
80
+ params.append('scope', scope);
81
+ }
82
+
83
+ const response = await fetch(tokenUrl, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
86
+ body: params,
87
+ });
88
+
89
+ if (!response.ok) {
90
+ const text = await response.text();
91
+ throw new Error(`OAuth token request failed: ${response.status} ${text}`);
92
+ }
93
+
94
+ const data = await response.json();
95
+
96
+ // Cache token with 60 second buffer before expiry
97
+ tokenCache = {
98
+ accessToken: data.access_token,
99
+ expiresAt: Date.now() + (data.expires_in - 60) * 1000,
100
+ };
101
+
102
+ return tokenCache.accessToken;
103
+ }
104
+
105
+ /**
106
+ * Fetch with OAuth authentication
107
+ * @param {string} url - URL to fetch
108
+ * @param {RequestInit} [init] - Fetch options
109
+ * @returns {Promise<Response>} Fetch response
110
+ */
111
+ export async function authenticatedFetch(url, init = {}) {
112
+ const token = await getAccessToken();
113
+
114
+ const headers = { ...init.headers };
115
+ if (token) {
116
+ headers['Authorization'] = `Bearer ${token}`;
117
+ }
118
+
119
+ return fetch(url, { ...init, headers });
120
+ }
121
+
122
+ /**
123
+ * Fetch and cache the JSON:API endpoint mapping from the API root.
124
+ * The mapping provides the correct URL for each entity type.
125
+ * @returns {Promise<Map<string, string>>} Map of type names to endpoint URLs
126
+ */
127
+ export async function getEndpointMap() {
128
+ if (endpointMapCache) {
129
+ return endpointMapCache;
130
+ }
131
+
132
+ const response = await authenticatedFetch(getApiBaseUrl());
133
+ if (!response.ok) {
134
+ throw new Error(`Failed to fetch API root: ${response.status}`);
135
+ }
136
+
137
+ const data = await response.json();
138
+ const links = data.links || {};
139
+
140
+ // Build map from type name to endpoint URL
141
+ endpointMapCache = new Map();
142
+ for (const [typeName, linkInfo] of Object.entries(links)) {
143
+ if (typeName === 'self') continue;
144
+ const href = typeof linkInfo === 'string' ? linkInfo : linkInfo?.href;
145
+ if (href) {
146
+ endpointMapCache.set(typeName, href);
147
+ }
148
+ }
149
+
150
+ return endpointMapCache;
151
+ }
152
+
153
+ /**
154
+ * Get the API endpoint URL for an entity type.
155
+ * Fetches the endpoint mapping from the API root if not cached.
156
+ * @param {string} type - Entity type (e.g., "node--news_article", "taxonomy_term--categories")
157
+ * @returns {Promise<string>} The endpoint URL for the entity type
158
+ */
159
+ export async function getEntityEndpoint(type) {
160
+ const map = await getEndpointMap();
161
+ const url = map.get(type);
162
+
163
+ if (!url) {
164
+ throw new Error(
165
+ `Unknown entity type: ${type}. Use 'canvas-jsonapi list --types' to see available types.`,
166
+ );
167
+ }
168
+
169
+ return url;
170
+ }
171
+
172
+ /**
173
+ * Get the API URL for a specific entity by type and UUID.
174
+ * @param {string} type - Entity type (e.g., "node--news_article")
175
+ * @param {string} uuid - Entity UUID
176
+ * @returns {Promise<string>} The full URL for the entity
177
+ */
178
+ export async function getEntityUrl(type, uuid) {
179
+ const endpoint = await getEntityEndpoint(type);
180
+ return `${endpoint}/${uuid}`;
181
+ }
package/src/create.js ADDED
@@ -0,0 +1,120 @@
1
+ /* global process */
2
+ /**
3
+ * Creates a new content item from a local JSON file.
4
+ */
5
+ import { unlinkSync } from 'fs';
6
+
7
+ import { authenticatedFetch, getEntityEndpoint } from './client.js';
8
+ import {
9
+ displayEntitySummary,
10
+ fetchEntity,
11
+ getLabelField,
12
+ stringifyComponentInputs,
13
+ } from './entity.js';
14
+ import { readContent, saveContent } from './utils.js';
15
+ import {
16
+ formatValidationErrors,
17
+ validateContentComponents,
18
+ } from './validate.js';
19
+
20
+ export async function createContent(filePath) {
21
+ if (!filePath) {
22
+ console.error('Usage: canvas-jsonapi create <file-path>');
23
+ console.error('Example: canvas-jsonapi create content/page/new-123.json');
24
+ process.exit(1);
25
+ }
26
+
27
+ try {
28
+ // Read local file
29
+ const data = readContent(filePath);
30
+
31
+ // Extract type from the data
32
+ const type = data.data?.type || data.type;
33
+
34
+ if (!type) {
35
+ throw new Error('File must contain data.type field');
36
+ }
37
+
38
+ // Validate component inputs before sending to server
39
+ const validation = validateContentComponents(data);
40
+ if (!validation.valid) {
41
+ console.error(formatValidationErrors(validation.errors));
42
+ process.exit(1);
43
+ }
44
+ if (validation.warnings?.length > 0) {
45
+ for (const warning of validation.warnings) {
46
+ console.warn(`Warning: ${warning}`);
47
+ }
48
+ }
49
+
50
+ // Prepare create body - remove any existing id
51
+ const attributes = data.data?.attributes || data.attributes;
52
+ const createBody = {
53
+ data: {
54
+ type: type,
55
+ attributes: stringifyComponentInputs(attributes),
56
+ relationships: data.data?.relationships || data.relationships,
57
+ },
58
+ };
59
+
60
+ // Remove id if present (API will generate one)
61
+ delete createBody.data.id;
62
+
63
+ // Send create request using the correct endpoint from API root
64
+ const url = await getEntityEndpoint(type);
65
+ const response = await authenticatedFetch(url, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/vnd.api+json' },
68
+ body: JSON.stringify(createBody),
69
+ });
70
+ const contentType = response.headers.get('content-type') || '';
71
+
72
+ // Handle non-JSON responses
73
+ if (
74
+ !contentType.includes('application/vnd.api+json') &&
75
+ !contentType.includes('application/json')
76
+ ) {
77
+ const text = await response.text();
78
+ console.error(`HTTP ${response.status}: ${response.statusText}`);
79
+ console.error(text.slice(0, 500));
80
+ process.exit(1);
81
+ }
82
+
83
+ const result = await response.json();
84
+
85
+ // Check for errors in response
86
+ if (!response.ok || result.errors) {
87
+ console.error(`Error creating ${type}:`);
88
+ console.error(JSON.stringify(result, null, 2));
89
+ process.exit(1);
90
+ }
91
+
92
+ // Extract the new UUID from the response
93
+ const newUuid = result.data?.id || result.id;
94
+
95
+ if (!newUuid) {
96
+ throw new Error('No UUID returned from API');
97
+ }
98
+
99
+ // Remove the original temp file
100
+ try {
101
+ unlinkSync(filePath);
102
+ console.log(`Removed temporary file: ${filePath}`);
103
+ } catch {
104
+ // Ignore if file already gone
105
+ }
106
+
107
+ // Fetch the full entity
108
+ const fullEntity = await fetchEntity(type, newUuid);
109
+
110
+ // Save with the new UUID and label
111
+ const labelField = getLabelField(type);
112
+ const label = fullEntity.data?.attributes?.[labelField] || '(untitled)';
113
+ const newFilePath = saveContent(type, newUuid, fullEntity, label);
114
+
115
+ displayEntitySummary('Created', type, newUuid, fullEntity, newFilePath);
116
+ } catch (error) {
117
+ console.error(`Error creating from ${filePath}:`, error.message);
118
+ process.exit(1);
119
+ }
120
+ }
package/src/delete.js ADDED
@@ -0,0 +1,68 @@
1
+ /* global process */
2
+ /**
3
+ * Deletes content items from Acquia Source.
4
+ */
5
+ import { unlinkSync } from 'fs';
6
+
7
+ import { authenticatedFetch, getEntityUrl } from './client.js';
8
+ import { findContentByUuid } from './utils.js';
9
+
10
+ export async function deleteContent(args) {
11
+ if (!args || args.length < 2) {
12
+ console.error('Usage: canvas-jsonapi delete <type> <uuid> [<uuid>...]');
13
+ console.error('Example: canvas-jsonapi delete page abc-123-def');
14
+ console.error('Example: canvas-jsonapi delete media--image uuid1 uuid2');
15
+ process.exit(1);
16
+ }
17
+
18
+ const type = args[0];
19
+ const uuids = args.slice(1);
20
+
21
+ let hasError = false;
22
+ for (const uuid of uuids) {
23
+ try {
24
+ await deleteSingleContent(type, uuid);
25
+ } catch (error) {
26
+ console.error(`Error deleting ${type}/${uuid}:`, error.message);
27
+ hasError = true;
28
+ }
29
+ }
30
+
31
+ if (hasError) {
32
+ process.exit(1);
33
+ }
34
+ }
35
+
36
+ async function deleteSingleContent(type, uuid) {
37
+ // Get the correct endpoint URL from the API root mapping
38
+ const url = await getEntityUrl(type, uuid);
39
+
40
+ const response = await authenticatedFetch(url, {
41
+ method: 'DELETE',
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const contentType = response.headers.get('content-type') || '';
46
+ if (contentType.includes('json')) {
47
+ const result = await response.json();
48
+ throw new Error(JSON.stringify(result.errors || result, null, 2));
49
+ } else {
50
+ const text = await response.text();
51
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
52
+ }
53
+ }
54
+
55
+ // Try to remove local file if it exists
56
+ const localFile = findContentByUuid(type, uuid);
57
+ if (localFile) {
58
+ try {
59
+ unlinkSync(localFile);
60
+ console.log(`Deleted: ${uuid}`);
61
+ console.log(` Removed local file: ${localFile}`);
62
+ } catch {
63
+ console.log(`Deleted: ${uuid}`);
64
+ }
65
+ } else {
66
+ console.log(`Deleted: ${uuid}`);
67
+ }
68
+ }