@lemonadecms/client 0.1.0 → 0.2.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/CHANGELOG.md +85 -4
- package/README.md +206 -5
- package/bin/cli/config.mjs +63 -8
- package/bin/cli/network.mjs +50 -15
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +55 -5
- package/bin/cli/schema.mjs +6 -6
- package/bin/generate-types.mjs +9 -8
- package/dist/composer.d.ts +27 -0
- package/dist/composer.js +240 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1 -0
- package/dist/transport.js +198 -30
- package/dist/types.d.ts +79 -16
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,87 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0
|
|
6
|
+
|
|
7
|
+
- Use `https://api.lemonadecms.com` for new SDK/CLI defaults. The server's strict
|
|
8
|
+
hostname mode intentionally returns 404 for old main-domain API URLs; upgrade
|
|
9
|
+
installed clients and configured targets before using that deployment.
|
|
10
|
+
Explicit CLI flags and environment precedence remain unchanged.
|
|
11
|
+
Studio/preview message origins stay separate; no redirect, fallback retry or
|
|
12
|
+
runtime dependency. Package publication and server deployment are separate.
|
|
13
|
+
|
|
14
|
+
- Add the optional zero-dependency `@lemonadecms/client/composer` browser bridge:
|
|
15
|
+
versioned website capabilities, stable section/slot locators, selection and
|
|
16
|
+
arrangement commands, cancellable serial rendering and latest-only commits.
|
|
17
|
+
Requires an explicitly opted-in private CMS destination and website renderer.
|
|
18
|
+
- Generate three-level nested component unions and localized text leaves in
|
|
19
|
+
TypeScript, Python and C#, retaining legacy flat schemas and CLI behavior.
|
|
20
|
+
|
|
21
|
+
## 0.1.1 — published package
|
|
22
|
+
|
|
23
|
+
- Add a native terminal environment picker for online `types generate` using
|
|
24
|
+
validated configured targets and their credential variables. Explicit settings,
|
|
25
|
+
CI, JSON/check/offline and piped runs stay noninteractive. Cancellation restores
|
|
26
|
+
the terminal and preserves files without network work. No dependencies added.
|
|
27
|
+
|
|
28
|
+
- Add bounded nested `and`/`or`/`not` filters, `ne`/`not_in`, literal text patterns
|
|
29
|
+
and case-insensitive matching. Existing arrays and scalar/choice operators stay
|
|
30
|
+
supported. Sort by 1–3 ordered fields with stable NULL/tie/backward pagination.
|
|
31
|
+
- Add typed paginated `search()` and reusable search options for reads, counts,
|
|
32
|
+
existence and lazy iteration. Uses the requested publication/locale snapshot
|
|
33
|
+
and PostgreSQL plain full-text relevance, with additive REST/RPC/GraphQL support.
|
|
34
|
+
Existing cursor forms and single-sort syntax remain compatible. No runtime,
|
|
35
|
+
optional or peer dependency was added; the updated CMS backend is required.
|
|
36
|
+
|
|
37
|
+
- `count()` and `exists()` query matching authorized snapshots directly without
|
|
38
|
+
downloading entries. `getMany()` reads at most 100 IDs in one request and
|
|
39
|
+
preserves order/duplicates with nullable missing entries. `findOneOrThrow()`
|
|
40
|
+
raises the exported `CMSNotFoundError` only for a successful empty match. All
|
|
41
|
+
support public/server-preview reads, typed options and existing cancellation,
|
|
42
|
+
deadline and error behavior; the updated CMS REST endpoints are required.
|
|
43
|
+
|
|
44
|
+
- `content(model).findOne()` returns the first typed matching entry or null using
|
|
45
|
+
one bounded query; preserves projections, metadata, filters, preview isolation
|
|
46
|
+
and existing error/cancellation/deadline behavior.
|
|
47
|
+
- `richTextToHtml()` and `richTextToPlainText()` render version-1 rich-text values
|
|
48
|
+
in browsers/servers with bounded traversal, escaped HTML, safe URLs and matching
|
|
49
|
+
expanded resources. Plain text excludes embeds and link URLs.
|
|
50
|
+
|
|
51
|
+
- Staff Admin manages release versions and notes in this Markdown file, with
|
|
52
|
+
exact-source preparation, durable activity and explicit npm publication through
|
|
53
|
+
a protected GitHub Actions job. Operator setup and live publishing qualification
|
|
54
|
+
remain separate; `--from-changelog` also selects this version locally.
|
|
55
|
+
|
|
56
|
+
- Repository maintainers can run `npm run release:sdk` to manage stable versions,
|
|
57
|
+
changelog notes, validation and exact archive preparation. `--publish` explicitly
|
|
58
|
+
enables npm upload and installed-registry verification; `--current` resumes the
|
|
59
|
+
selected version. The source package remains private.
|
|
60
|
+
|
|
61
|
+
- Entries include content-type identity, workspace/environment and locale beside
|
|
62
|
+
their existing ID, modeled fields and publication time. Optional TypeScript
|
|
63
|
+
metadata preserves existing entry fixtures; request identity supports older
|
|
64
|
+
CMS servers, while malformed/conflicting metadata is rejected safely.
|
|
65
|
+
- Expanded reference types describe additive raw REST context metadata. The
|
|
66
|
+
generated `Models` map still describes only custom fields, not entry envelopes.
|
|
67
|
+
|
|
68
|
+
- `lemonade types generate` loads the current project's `.env`, defaults to
|
|
69
|
+
`CMS_PROJECT_ID` / `CMS_PUBLIC_KEY`, Production and `src/types/cms-models.ts`.
|
|
70
|
+
Explicit flags, named targets and legacy `LEMONADE_*` settings still take
|
|
71
|
+
precedence; offline/management commands retain their credential boundaries.
|
|
72
|
+
C# and Python defaults use matching filenames in `src/types`.
|
|
73
|
+
|
|
74
|
+
- Keep CLI request deadlines bounded when Node 22's response-body transport
|
|
75
|
+
ignores native abort; best-effort stream cancellation cannot delay completion.
|
|
76
|
+
|
|
77
|
+
- Release preparation reads the selected version from package metadata, checks
|
|
78
|
+
package-lock/changelog agreement, and supports future stable/prerelease versions.
|
|
79
|
+
- Explicit public-registry verification checks archive integrity and isolated
|
|
80
|
+
npm/pnpm installations, runtime/types, both CLIs and browser-preview guards.
|
|
81
|
+
- Current installation documentation uses the published npm package. These
|
|
82
|
+
checkout changes do not replace the existing immutable registry artifact.
|
|
83
|
+
|
|
84
|
+
## 0.1.0 — published package
|
|
4
85
|
|
|
5
86
|
- Framework-independent ESM client and TypeScript declarations for published REST
|
|
6
87
|
collection/item reads, field selection, bounded pagination, filters, sorting,
|
|
@@ -14,6 +95,6 @@
|
|
|
14
95
|
- MIT license for this client package. This does not license the rest of the repository.
|
|
15
96
|
|
|
16
97
|
New applications import `@lemonadecms/client`. Existing local `@cms/client` imports
|
|
17
|
-
can use the documented file alias to
|
|
18
|
-
|
|
19
|
-
|
|
98
|
+
can use the documented file alias to a reviewed archive. Public availability of
|
|
99
|
+
0.1.0 is verified separately from publisher access, provenance and authorization
|
|
100
|
+
for a future release. Local preparation never publishes.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ declarations, schema type generation and the `lemonade` developer CLI.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Install the published package in your application:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
12
|
npm install @lemonadecms/client
|
|
@@ -14,13 +14,13 @@ npm install @lemonadecms/client
|
|
|
14
14
|
pnpm add @lemonadecms/client
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Maintainers can test a future version using its reviewed candidate archive.
|
|
18
18
|
Check the supplied `SHA256SUMS`, then use the actual archive path:
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
npm install --ignore-scripts /absolute/path/to/
|
|
21
|
+
npm install --ignore-scripts /absolute/path/to/reviewed-client.tgz
|
|
22
22
|
# or
|
|
23
|
-
pnpm add /absolute/path/to/
|
|
23
|
+
pnpm add /absolute/path/to/reviewed-client.tgz
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
The package uses ESM and declares Node.js 22+. TypeScript consumers need
|
|
@@ -30,7 +30,7 @@ runtime library or framework dependency is required.
|
|
|
30
30
|
|
|
31
31
|
## Configure and read content
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Version 0.2.0 connects to `https://api.lemonadecms.com` automatically. Strict hostname routing returns 404 for API URLs on `https://lemonadecms.com`. Upgrade earlier clients to 0.2.0 and change explicit CLI/configured-target URLs to `https://api.lemonadecms.com`. Explicit CLI settings still take precedence; no redirect or fallback is attempted. Configure your
|
|
34
34
|
workspace and delivery key; there is no URL or local CMS option. Your website
|
|
35
35
|
can run locally while reading content from Lemonade production. Installing the
|
|
36
36
|
package does not create an account, workspace or delivery key.
|
|
@@ -63,6 +63,15 @@ if (first) {
|
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
Entry values are under `fields`; metadata includes `id` and `publishedAt`.
|
|
66
|
+
The client also exposes `contentType` (ID/key/name), `projectId`,
|
|
67
|
+
`environment` (ID/key) and resolved `locale`. Field projections retain metadata.
|
|
68
|
+
For older CMS responses, the SDK derives only the request's model key/workspace
|
|
69
|
+
and explicit locale; unavailable metadata stays absent. Metadata properties are
|
|
70
|
+
optional in TypeScript for compatibility with existing manually constructed
|
|
71
|
+
entries. Use `entry.contentType?.key`, not a custom `fields.contentType` field.
|
|
72
|
+
Generated `Models` describes fields; `ContentEntry<Models['content_item'],
|
|
73
|
+
'content_item'>` from the SDK describes the complete entry. Expanded reference
|
|
74
|
+
metadata remains raw snake_case. Published `0.1.0` does not include these additions.
|
|
66
75
|
An empty collection is `data: []`. Historical or selected fields can be missing
|
|
67
76
|
or `null`, so handle both when rendering. Continue a collection using
|
|
68
77
|
`page.meta.nextCursor` as the next request's `after` value while keeping the
|
|
@@ -76,6 +85,131 @@ environment override. Ask your Owner for a credential belonging to the intended
|
|
|
76
85
|
environment. The server rechecks current access, expiry and revocation on every
|
|
77
86
|
request.
|
|
78
87
|
|
|
88
|
+
## Find one entry
|
|
89
|
+
|
|
90
|
+
In the updated source build, `findOne()` returns the first matching complete
|
|
91
|
+
entry or `null`. It makes one request with `limit=1` and preserves existing
|
|
92
|
+
filters, search, sort, select, locale, includeRemote and signal options. It does not check
|
|
93
|
+
uniqueness. Pagination/limit options are rejected; HTTP/authorization, transport,
|
|
94
|
+
cancellation/deadline and malformed-response errors still reject. `get(id)` keeps
|
|
95
|
+
its existing UUID lookup and 404 behavior. Published `0.1.0` lacks these helpers.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const entry = await cms.content('content_item').findOne({
|
|
99
|
+
filters: [{ field: 'title', op: 'eq', value: 'Welcome' }],
|
|
100
|
+
select: ['title'],
|
|
101
|
+
});
|
|
102
|
+
console.log(entry?.fields.title ?? 'No matching content');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Counts, existence and bulk reads
|
|
106
|
+
|
|
107
|
+
These methods require the current SDK and CMS backend. They are
|
|
108
|
+
available in both published and server preview clients:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { CMSNotFoundError } from '@lemonadecms/client';
|
|
112
|
+
|
|
113
|
+
const content = cms.content('content_item');
|
|
114
|
+
const filters = [{ field: 'title', op: 'eq', value: 'Welcome' }];
|
|
115
|
+
const total = await content.count({ filters });
|
|
116
|
+
const available = await content.exists({ filters });
|
|
117
|
+
try {
|
|
118
|
+
const required = await content.findOneOrThrow({ filters, select: ['title'] });
|
|
119
|
+
const entries = await content.getMany([required.id], { select: ['title'] });
|
|
120
|
+
console.log(total, available, entries[0]?.fields.title);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!(error instanceof CMSNotFoundError)) throw error;
|
|
123
|
+
console.log('Required content is unavailable');
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`count()`/`exists()` accept filters, search, locale and signal, using direct database
|
|
128
|
+
queries without downloading entries or expanding references. Count includes all
|
|
129
|
+
matches across pages; separate requests can see different publication states.
|
|
130
|
+
`getMany()` accepts up to 100 UUIDs and select/locale/includeRemote/signal. One
|
|
131
|
+
request returns entries in input order, preserving duplicate positions and using
|
|
132
|
+
`null` for missing/unpublished/foreign IDs in an otherwise authorized model.
|
|
133
|
+
Empty input returns `[]` without I/O after validation. Split larger ID sets into
|
|
134
|
+
batches; use the existing lazy `entries()`/`pages()` methods for collections.
|
|
135
|
+
`findOneOrThrow()` shares findOne options and throws `CMSNotFoundError` only for
|
|
136
|
+
a successful empty match. Authorization, HTTP, malformed-response, network,
|
|
137
|
+
cancellation and deadline failures retain their existing errors. Preview fields
|
|
138
|
+
may still be missing/null. No automatic collection-download fallback occurs.
|
|
139
|
+
|
|
140
|
+
## Advanced filters, search and sorting
|
|
141
|
+
|
|
142
|
+
Updated source builds support nested `and`/`or`/`not`, `ne`/`not_in`, and text
|
|
143
|
+
`eqi`, `contains`/`containsi`, `starts_with`/`starts_withi`, `ends_with`/`ends_withi`.
|
|
144
|
+
Existing filter arrays remain AND. Limits are ten leaves, 31 nodes, depth four
|
|
145
|
+
and 4 KiB; groups are nonempty. SQL wildcard characters are treated literally.
|
|
146
|
+
Positive comparisons on missing values are false; NOT and ne/not_in complement
|
|
147
|
+
them, including missing values. Use eq/ne null for explicit null checks.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const matches = await cms.content('content_item').search('welcome', {
|
|
151
|
+
fields: ['title'],
|
|
152
|
+
select: ['title'],
|
|
153
|
+
filters: {
|
|
154
|
+
or: [
|
|
155
|
+
{ field: 'title', op: 'containsi', value: 'WELCOME' },
|
|
156
|
+
{ not: { field: 'title', op: 'eq', value: 'Archived' } },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
sort: [
|
|
160
|
+
{ field: 'position', direction: 'desc' },
|
|
161
|
+
{ field: 'title', direction: 'asc' },
|
|
162
|
+
],
|
|
163
|
+
limit: 10,
|
|
164
|
+
});
|
|
165
|
+
console.log(matches.data);
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`fields` scopes matching to 1–24 text/long_text/markdown fields; `select` projects
|
|
169
|
+
the returned fields. Omit fields to search all top-level fields of those types.
|
|
170
|
+
Query accepts 1–200 Unicode codepoints, up to 512 UTF-8 bytes and 16 whitespace
|
|
171
|
+
terms. PostgreSQL simple-config plain search requires every parsed term, with
|
|
172
|
+
cover-density relevance order by default. Explicit 1–3 unique sort fields override
|
|
173
|
+
relevance; NULLs stay last and UUID breaks ties. No stemming, fuzzy/prefix/phrase
|
|
174
|
+
search or recursive rich-text/reference search is implied. Matching and ranking
|
|
175
|
+
use only the authorized locale/fallback and published/preview snapshot.
|
|
176
|
+
|
|
177
|
+
`search()` makes one normal paginated collection request. Pass the same
|
|
178
|
+
`search: {query, fields}` object to list/findOne/findOneOrThrow/count/exists/pages/
|
|
179
|
+
entries to count or traverse matches. Keep the full query and ordered sort fields
|
|
180
|
+
unchanged when following cursors. Existing single-sort syntax remains supported.
|
|
181
|
+
Signed cursors have an 8 KiB limit. Unusually large Unicode text anchors that
|
|
182
|
+
exceed it after compression return `invalid_query`; choose more compact scalar
|
|
183
|
+
sort fields in that case.
|
|
184
|
+
These methods require the updated SDK/backend. Runtime/peer/optional dependencies
|
|
185
|
+
remain empty; no external search service is used.
|
|
186
|
+
|
|
187
|
+
## Rich-text helpers
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
import { richTextToHtml, richTextToPlainText } from '@lemonadecms/client';
|
|
191
|
+
|
|
192
|
+
// Replace rich_body with a rich_text field from your schema.
|
|
193
|
+
const richEntry = await cms.content('content_item').findOne({ select: ['rich_body'] });
|
|
194
|
+
const html = richTextToHtml(richEntry?.fields.rich_body);
|
|
195
|
+
const plainText = richTextToPlainText(richEntry?.fields.rich_body);
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Both accept nullable/unknown version-1 rich-text JSON and work in browsers or
|
|
199
|
+
servers without a framework or DOM. HTML escapes text/attributes and renders only
|
|
200
|
+
supported nodes/marks, safe links and matching expanded image/entry-caption
|
|
201
|
+
resources. Unavailable embeds are omitted; entry routes are never invented.
|
|
202
|
+
Plain text retains authored text with block/list/hard-break newlines, omitting
|
|
203
|
+
embeds and link URLs. Plain text is not escaped HTML: use text interpolation.
|
|
204
|
+
|
|
205
|
+
Traversal is bounded to 500 nodes, depth 8, 200 children per node, 20 embeds,
|
|
206
|
+
20,000 UTF-8 bytes per text and 64 KiB of rendered source strings. Unsupported
|
|
207
|
+
versions, invalid roots/containers or exhausted budgets return empty strings;
|
|
208
|
+
unsupported parts are omitted. No raw HTML, resource-field recursion, I/O or
|
|
209
|
+
automatic Markdown conversion is performed. HTML URL/alt bounds and exact
|
|
210
|
+
preview-image conventions are documented in the SDK guide. The helpers are also
|
|
211
|
+
exported from the server entry; preview authorization remains your responsibility.
|
|
212
|
+
|
|
79
213
|
## Types, queries and framework examples
|
|
80
214
|
|
|
81
215
|
The [SDK guide](https://lemonadecms.com/guides/sdk) covers generated `Models`,
|
|
@@ -90,6 +224,60 @@ loading and error states, cache invalidation and safe HTML handling. Pass an
|
|
|
90
224
|
`AbortSignal` to cancel a read. Optional `timeoutMs` sets a per-request deadline;
|
|
91
225
|
combining a timeout with a caller signal requires `AbortSignal.any`.
|
|
92
226
|
|
|
227
|
+
## Choose an environment for generated types
|
|
228
|
+
|
|
229
|
+
The updated source CLI offers an interactive picker from configured targets:
|
|
230
|
+
|
|
231
|
+
```sh
|
|
232
|
+
npx lemonade types generate
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Create `lemonade.config.json` in your application. Replace both workspace
|
|
236
|
+
placeholders with your workspace UUID; the target labels distinguish environments.
|
|
237
|
+
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"version": 1,
|
|
241
|
+
"targets": {
|
|
242
|
+
"production": {
|
|
243
|
+
"url": "https://api.lemonadecms.com",
|
|
244
|
+
"workspace": "YOUR_WORKSPACE_UUID",
|
|
245
|
+
"environment": "production",
|
|
246
|
+
"credentialEnv": "CMS_PUBLIC_KEY"
|
|
247
|
+
},
|
|
248
|
+
"dev": {
|
|
249
|
+
"url": "https://api.lemonadecms.com",
|
|
250
|
+
"workspace": "YOUR_WORKSPACE_UUID",
|
|
251
|
+
"environment": "dev",
|
|
252
|
+
"credentialEnv": "CMS_DEV_PUBLIC_KEY"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Keep actual credentials in your current directory's `.env` or process environment;
|
|
259
|
+
process values win. A dev target needs a dev-scoped credential. The credential
|
|
260
|
+
selects access; the CLI verifies the returned workspace/environment before writing.
|
|
261
|
+
The picker lists configured targets, not environments discovered from the CMS.
|
|
262
|
+
|
|
263
|
+
Use arrows and Enter. Production is initially selected when configured; otherwise
|
|
264
|
+
the first target alphabetically. Escape/Ctrl+C/EOF exits 130 without network or
|
|
265
|
+
output-file writes. Terminal input mode is restored on success, cancellation/error.
|
|
266
|
+
Missing/revoked/mismatched credentials fail without replacing existing types.
|
|
267
|
+
|
|
268
|
+
Explicit `--target`, `--environment`, `--url`, `--workspace` or `--credential-env`
|
|
269
|
+
bypasses the picker, as do `LEMONADE_TARGET`, `LEMONADE_ENVIRONMENT` or
|
|
270
|
+
`CMS_ENVIRONMENT`. CI, `--json`, `--check`, piped input/output, `--file` and other
|
|
271
|
+
commands never prompt. `--config` can choose the target configuration file.
|
|
272
|
+
Without configured targets, existing defaults remain unchanged. For automation:
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
npx lemonade types generate --target dev --check
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Version 0.2.0 includes the picker; historical 0.1.0 does not.
|
|
279
|
+
It uses native Node APIs and retains zero runtime, peer or optional dependencies.
|
|
280
|
+
|
|
93
281
|
## Private previews
|
|
94
282
|
|
|
95
283
|
For draft reads, use `createCMSPreviewClient` from
|
|
@@ -115,3 +303,16 @@ fetch client.
|
|
|
115
303
|
|
|
116
304
|
This SDK package is licensed under the [MIT License](LICENSE). Its license does
|
|
117
305
|
not grant a license to the entire CMS repository or the separate native clients.
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
## Visual Composer
|
|
309
|
+
|
|
310
|
+
The optional Visual Composer adds approved nested sections, inspector editing and
|
|
311
|
+
private live updates. A developer connects the website registry and explicitly
|
|
312
|
+
enables unsaved projection on its trusted preview destination. Save draft and
|
|
313
|
+
Publish remain separate. Component text can be translated by stable block ID;
|
|
314
|
+
each language keeps its live tree until explicitly published. Existing form and
|
|
315
|
+
saved-draft previews remain supported. The browser helper is the dependency-free
|
|
316
|
+
`@lemonadecms/client/composer` export.
|
|
317
|
+
|
|
318
|
+
See [setup, editor workflow, Astro reference and protocol](https://github.com/ilara-booqself/lemonadecms/blob/main/docs/visual-composer.md).
|
package/bin/cli/config.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
3
4
|
import { readText, parseJSON } from './io.mjs';
|
|
4
5
|
import { fail } from './errors.mjs';
|
|
5
6
|
import { languages } from '../codegen/definitions.mjs';
|
|
@@ -102,13 +103,20 @@ export function parseArgs(args) {
|
|
|
102
103
|
[
|
|
103
104
|
'schema pull',
|
|
104
105
|
'schema export',
|
|
105
|
-
'types generate',
|
|
106
106
|
'schema migrate plan',
|
|
107
107
|
'schema migrate create',
|
|
108
108
|
].includes(command) &&
|
|
109
109
|
!opts.out
|
|
110
110
|
)
|
|
111
111
|
fail(2, '--out is required.');
|
|
112
|
+
if (command === 'types generate' && !opts.out)
|
|
113
|
+
opts.out = `src/types/${
|
|
114
|
+
{
|
|
115
|
+
typescript: 'cms-models.ts',
|
|
116
|
+
csharp: 'cms-models.cs',
|
|
117
|
+
python: 'cms_models.py',
|
|
118
|
+
}[opts.language || 'typescript']
|
|
119
|
+
}`;
|
|
112
120
|
if (
|
|
113
121
|
[
|
|
114
122
|
'schema validate',
|
|
@@ -138,7 +146,26 @@ export function parseArgs(args) {
|
|
|
138
146
|
fail(2, 'Applying requires --file, --plan and --confirm REVIEWED_CHECKSUM.');
|
|
139
147
|
return { command, opts };
|
|
140
148
|
}
|
|
141
|
-
export async function
|
|
149
|
+
export async function typeGenerationEnv(env, cwd) {
|
|
150
|
+
const path = resolve(cwd, '.env');
|
|
151
|
+
try {
|
|
152
|
+
await access(path);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error.code === 'ENOENT') return { ...env };
|
|
155
|
+
fail(2, 'Could not open project .env. Check file access.');
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
// Parse data only: never source shell code or change process.env/startup settings.
|
|
159
|
+
return { ...parseEnv(await readText(path, 65536)), ...env };
|
|
160
|
+
} catch {
|
|
161
|
+
fail(
|
|
162
|
+
2,
|
|
163
|
+
'Could not read project .env. Use a UTF-8 regular file no larger than 64 KiB.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function readConfiguration(opts, cwd) {
|
|
142
169
|
const configPath = resolve(cwd, opts.config || 'lemonade.config.json');
|
|
143
170
|
let config = {};
|
|
144
171
|
let found = true;
|
|
@@ -185,19 +212,47 @@ export async function connection(opts, env, cwd, management = false) {
|
|
|
185
212
|
safeURL(target.url);
|
|
186
213
|
}
|
|
187
214
|
}
|
|
215
|
+
return { configPath, targets: config.targets || {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function connection(
|
|
219
|
+
opts,
|
|
220
|
+
env,
|
|
221
|
+
cwd,
|
|
222
|
+
management = false,
|
|
223
|
+
defaults = {},
|
|
224
|
+
configuration,
|
|
225
|
+
) {
|
|
226
|
+
const { configPath, targets } = configuration || (await readConfiguration(opts, cwd));
|
|
188
227
|
const name = opts.target || env.LEMONADE_TARGET;
|
|
189
|
-
|
|
190
|
-
|
|
228
|
+
if (name && !Object.hasOwn(targets, name))
|
|
229
|
+
fail(2, 'Selected target is not configured.');
|
|
230
|
+
const target = name ? targets[name] : {};
|
|
191
231
|
const selected = {
|
|
192
|
-
url: opts.url || target.url || env.LEMONADE_URL || 'https://lemonadecms.com',
|
|
193
|
-
workspace:
|
|
194
|
-
|
|
232
|
+
url: opts.url || target.url || env.LEMONADE_URL || 'https://api.lemonadecms.com',
|
|
233
|
+
workspace:
|
|
234
|
+
opts.workspace ||
|
|
235
|
+
target.workspace ||
|
|
236
|
+
env.LEMONADE_WORKSPACE ||
|
|
237
|
+
defaults.workspace,
|
|
238
|
+
environment:
|
|
239
|
+
opts.environment ||
|
|
240
|
+
target.environment ||
|
|
241
|
+
env.LEMONADE_ENVIRONMENT ||
|
|
242
|
+
defaults.environment,
|
|
195
243
|
credentialEnv:
|
|
196
244
|
opts['credential-env'] ||
|
|
197
245
|
target.credentialEnv ||
|
|
198
|
-
(management
|
|
246
|
+
(management
|
|
247
|
+
? 'LEMONADE_MANAGEMENT_TOKEN'
|
|
248
|
+
: defaults.credentialEnv || 'LEMONADE_TOKEN'),
|
|
199
249
|
configPath,
|
|
200
250
|
};
|
|
251
|
+
if (defaults.credentialEnv && !selected.workspace)
|
|
252
|
+
fail(
|
|
253
|
+
2,
|
|
254
|
+
'Set CMS_PROJECT_ID in .env or the process environment, or pass --workspace UUID.',
|
|
255
|
+
);
|
|
201
256
|
if (
|
|
202
257
|
!uuid.test(selected.workspace || '') ||
|
|
203
258
|
!(
|
package/bin/cli/network.mjs
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import { MAX_SCHEMA_BYTES, parseJSON } from './io.mjs';
|
|
2
2
|
import { fail, CLIError } from './errors.mjs';
|
|
3
3
|
import { validateSchema, schemaIdentity } from './schema.mjs';
|
|
4
|
+
|
|
5
|
+
// Bound caller completion even when a transport/body ignores native abort.
|
|
6
|
+
export function withinSignal(signal, operation) {
|
|
7
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const abort = () => {
|
|
10
|
+
signal.removeEventListener('abort', abort);
|
|
11
|
+
reject(signal.reason);
|
|
12
|
+
};
|
|
13
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
14
|
+
Promise.resolve()
|
|
15
|
+
.then(() => {
|
|
16
|
+
if (signal.aborted) throw signal.reason;
|
|
17
|
+
return operation();
|
|
18
|
+
})
|
|
19
|
+
.then(
|
|
20
|
+
(value) => {
|
|
21
|
+
signal.removeEventListener('abort', abort);
|
|
22
|
+
if (signal.aborted) reject(signal.reason);
|
|
23
|
+
else resolve(value);
|
|
24
|
+
},
|
|
25
|
+
(error) => {
|
|
26
|
+
signal.removeEventListener('abort', abort);
|
|
27
|
+
reject(error);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
4
32
|
export async function fetchSchema(target, fetcher = fetch) {
|
|
5
33
|
const schema = validateSchema(
|
|
6
34
|
await requestJSON(
|
|
@@ -35,19 +63,21 @@ export async function requestJSON(
|
|
|
35
63
|
const timer = setTimeout(() => controller.abort(), 15000);
|
|
36
64
|
let reader;
|
|
37
65
|
try {
|
|
38
|
-
const response = await
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
const response = await withinSignal(controller.signal, () =>
|
|
67
|
+
fetcher(`${target.url}${path}`, {
|
|
68
|
+
method,
|
|
69
|
+
headers: {
|
|
70
|
+
Accept: 'application/json',
|
|
71
|
+
Authorization: `Bearer ${target.key}`,
|
|
72
|
+
...(requestBody ? { 'Content-Type': 'application/json' } : {}),
|
|
73
|
+
},
|
|
74
|
+
...(requestBody ? { body: JSON.stringify(requestBody) } : {}),
|
|
75
|
+
redirect: 'error',
|
|
76
|
+
credentials: 'omit',
|
|
77
|
+
cache: 'no-store',
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
51
81
|
if ([401, 403].includes(response.status))
|
|
52
82
|
fail(
|
|
53
83
|
4,
|
|
@@ -72,7 +102,9 @@ export async function requestJSON(
|
|
|
72
102
|
const chunks = [];
|
|
73
103
|
let bytes = 0;
|
|
74
104
|
while (true) {
|
|
75
|
-
const { done, value } = await
|
|
105
|
+
const { done, value } = await withinSignal(controller.signal, () =>
|
|
106
|
+
reader.read(),
|
|
107
|
+
);
|
|
76
108
|
if (done) break;
|
|
77
109
|
bytes += value.byteLength;
|
|
78
110
|
if (bytes > MAX_SCHEMA_BYTES)
|
|
@@ -94,6 +126,9 @@ export async function requestJSON(
|
|
|
94
126
|
} finally {
|
|
95
127
|
clearTimeout(timer);
|
|
96
128
|
controller.abort();
|
|
97
|
-
|
|
129
|
+
// Cancellation is best effort: a stalled cleanup must not extend the deadline.
|
|
130
|
+
try {
|
|
131
|
+
reader?.cancel().catch(() => {});
|
|
132
|
+
} catch {}
|
|
98
133
|
}
|
|
99
134
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
2
|
+
import { CLIError } from './errors.mjs';
|
|
3
|
+
|
|
4
|
+
export function canPickTarget(opts, env, { input, output, stdoutIsTTY }) {
|
|
5
|
+
return Boolean(
|
|
6
|
+
input.isTTY &&
|
|
7
|
+
output.isTTY &&
|
|
8
|
+
stdoutIsTTY &&
|
|
9
|
+
typeof input.setRawMode === 'function' &&
|
|
10
|
+
!env.CI &&
|
|
11
|
+
!opts.json &&
|
|
12
|
+
!opts.check &&
|
|
13
|
+
!['target', 'environment', 'url', 'workspace', 'credential-env'].some(
|
|
14
|
+
(key) => opts[key],
|
|
15
|
+
) &&
|
|
16
|
+
!['LEMONADE_TARGET', 'LEMONADE_ENVIRONMENT', 'CMS_ENVIRONMENT'].some(
|
|
17
|
+
(key) => env[key],
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Targets have already passed bounded configuration validation. Only safe target
|
|
23
|
+
// names/environment identities reach the terminal, never URLs or credential data.
|
|
24
|
+
export function pickTarget(targets, { input, output }) {
|
|
25
|
+
const production = (target) =>
|
|
26
|
+
target.environment === 'production' ||
|
|
27
|
+
target.environment.toLowerCase() === target.workspace.toLowerCase();
|
|
28
|
+
const choices = Object.entries(targets).sort(
|
|
29
|
+
([a, left], [b, right]) =>
|
|
30
|
+
Number(production(right)) - Number(production(left)) ||
|
|
31
|
+
(a < b ? -1 : a > b ? 1 : 0),
|
|
32
|
+
);
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let selected = 0,
|
|
35
|
+
settled = false,
|
|
36
|
+
renderedLines = 0;
|
|
37
|
+
const wasRaw = input.isRaw === true;
|
|
38
|
+
const wasFlowing = input.readableFlowing === true;
|
|
39
|
+
const cancelled = () =>
|
|
40
|
+
finish(
|
|
41
|
+
new CLIError(130, 'Environment selection cancelled. No output was written.'),
|
|
42
|
+
);
|
|
43
|
+
const failed = () =>
|
|
44
|
+
finish(
|
|
45
|
+
new CLIError(
|
|
46
|
+
2,
|
|
47
|
+
'Could not read the environment selection. No output was written.',
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
const render = () => {
|
|
51
|
+
const width = Math.max(10, (output.columns || 80) - 1);
|
|
52
|
+
const visible = Math.min(choices.length, Math.max(1, (output.rows || 24) - 4));
|
|
53
|
+
const start = Math.min(
|
|
54
|
+
Math.max(0, selected - visible + 1),
|
|
55
|
+
choices.length - visible,
|
|
56
|
+
);
|
|
57
|
+
const lines = choices
|
|
58
|
+
.slice(start, start + visible)
|
|
59
|
+
.map(([name, target], index) => {
|
|
60
|
+
const label = `${index + start === selected ? '>' : ' '} ${name} (${target.environment})`;
|
|
61
|
+
return label.length > width ? label.slice(0, width - 3) + '...' : label;
|
|
62
|
+
});
|
|
63
|
+
if (renderedLines) output.write(`\x1b[${renderedLines}A\r`);
|
|
64
|
+
output.write(lines.map((line) => `\x1b[2K${line}\n`).join(''));
|
|
65
|
+
renderedLines = lines.length;
|
|
66
|
+
};
|
|
67
|
+
function finish(error) {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
input.removeListener('keypress', onKey);
|
|
71
|
+
input.removeListener('end', cancelled);
|
|
72
|
+
input.removeListener('close', cancelled);
|
|
73
|
+
input.removeListener('error', failed);
|
|
74
|
+
output.removeListener('error', failed);
|
|
75
|
+
process.removeListener('SIGINT', cancelled);
|
|
76
|
+
try {
|
|
77
|
+
input.setRawMode(wasRaw);
|
|
78
|
+
if (!wasFlowing) input.pause();
|
|
79
|
+
} catch {
|
|
80
|
+
error ||= new CLIError(2, 'Could not restore terminal input.');
|
|
81
|
+
}
|
|
82
|
+
if (error) reject(error);
|
|
83
|
+
else resolve(choices[selected][0]);
|
|
84
|
+
}
|
|
85
|
+
function onKey(_, key = {}) {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
if (key.name === 'escape' || (key.ctrl && ['c', 'd'].includes(key.name)))
|
|
88
|
+
return cancelled();
|
|
89
|
+
if (['return', 'enter'].includes(key.name)) return finish();
|
|
90
|
+
if (['up', 'down'].includes(key.name)) {
|
|
91
|
+
selected =
|
|
92
|
+
(selected + (key.name === 'up' ? -1 : 1) + choices.length) % choices.length;
|
|
93
|
+
try {
|
|
94
|
+
render();
|
|
95
|
+
} catch {
|
|
96
|
+
failed();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
if (input.destroyed || input.readableEnded) return cancelled();
|
|
102
|
+
if (output.destroyed || output.writableEnded) return failed();
|
|
103
|
+
emitKeypressEvents(input);
|
|
104
|
+
input.on('keypress', onKey);
|
|
105
|
+
input.once('end', cancelled);
|
|
106
|
+
input.once('close', cancelled);
|
|
107
|
+
input.once('error', failed);
|
|
108
|
+
output.once('error', failed);
|
|
109
|
+
process.once('SIGINT', cancelled);
|
|
110
|
+
input.setRawMode(true);
|
|
111
|
+
input.resume();
|
|
112
|
+
output.write('Select an environment (arrows, Enter; Esc/Ctrl+C to cancel):\n');
|
|
113
|
+
render();
|
|
114
|
+
} catch {
|
|
115
|
+
failed();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|