@lemonadecms/client 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,71 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 — initial npm candidate (unpublished)
3
+ ## Unreleased
4
+
5
+ ## 0.1.1 — prepared release
6
+
7
+ - Add a native terminal environment picker for online `types generate` using
8
+ validated configured targets and their credential variables. Explicit settings,
9
+ CI, JSON/check/offline and piped runs stay noninteractive. Cancellation restores
10
+ the terminal and preserves files without network work. No dependencies added.
11
+
12
+ - Add bounded nested `and`/`or`/`not` filters, `ne`/`not_in`, literal text patterns
13
+ and case-insensitive matching. Existing arrays and scalar/choice operators stay
14
+ supported. Sort by 1–3 ordered fields with stable NULL/tie/backward pagination.
15
+ - Add typed paginated `search()` and reusable search options for reads, counts,
16
+ existence and lazy iteration. Uses the requested publication/locale snapshot
17
+ and PostgreSQL plain full-text relevance, with additive REST/RPC/GraphQL support.
18
+ Existing cursor forms and single-sort syntax remain compatible. No runtime,
19
+ optional or peer dependency was added; the updated CMS backend is required.
20
+
21
+ - `count()` and `exists()` query matching authorized snapshots directly without
22
+ downloading entries. `getMany()` reads at most 100 IDs in one request and
23
+ preserves order/duplicates with nullable missing entries. `findOneOrThrow()`
24
+ raises the exported `CMSNotFoundError` only for a successful empty match. All
25
+ support public/server-preview reads, typed options and existing cancellation,
26
+ deadline and error behavior; the updated CMS REST endpoints are required.
27
+
28
+ - `content(model).findOne()` returns the first typed matching entry or null using
29
+ one bounded query; preserves projections, metadata, filters, preview isolation
30
+ and existing error/cancellation/deadline behavior.
31
+ - `richTextToHtml()` and `richTextToPlainText()` render version-1 rich-text values
32
+ in browsers/servers with bounded traversal, escaped HTML, safe URLs and matching
33
+ expanded resources. Plain text excludes embeds and link URLs.
34
+
35
+ - Staff Admin manages release versions and notes in this Markdown file, with
36
+ exact-source preparation, durable activity and explicit npm publication through
37
+ a protected GitHub Actions job. Operator setup and live publishing qualification
38
+ remain separate; `--from-changelog` also selects this version locally.
39
+
40
+ - Repository maintainers can run `npm run release:sdk` to manage stable versions,
41
+ changelog notes, validation and exact archive preparation. `--publish` explicitly
42
+ enables npm upload and installed-registry verification; `--current` resumes the
43
+ selected version. The source package remains private.
44
+
45
+ - Entries include content-type identity, workspace/environment and locale beside
46
+ their existing ID, modeled fields and publication time. Optional TypeScript
47
+ metadata preserves existing entry fixtures; request identity supports older
48
+ CMS servers, while malformed/conflicting metadata is rejected safely.
49
+ - Expanded reference types describe additive raw REST context metadata. The
50
+ generated `Models` map still describes only custom fields, not entry envelopes.
51
+
52
+ - `lemonade types generate` loads the current project's `.env`, defaults to
53
+ `CMS_PROJECT_ID` / `CMS_PUBLIC_KEY`, Production and `src/types/cms-models.ts`.
54
+ Explicit flags, named targets and legacy `LEMONADE_*` settings still take
55
+ precedence; offline/management commands retain their credential boundaries.
56
+ C# and Python defaults use matching filenames in `src/types`.
57
+
58
+ - Keep CLI request deadlines bounded when Node 22's response-body transport
59
+ ignores native abort; best-effort stream cancellation cannot delay completion.
60
+
61
+ - Release preparation reads the selected version from package metadata, checks
62
+ package-lock/changelog agreement, and supports future stable/prerelease versions.
63
+ - Explicit public-registry verification checks archive integrity and isolated
64
+ npm/pnpm installations, runtime/types, both CLIs and browser-preview guards.
65
+ - Current installation documentation uses the published npm package. These
66
+ checkout changes do not replace the existing immutable registry artifact.
67
+
68
+ ## 0.1.0 — published package
4
69
 
5
70
  - Framework-independent ESM client and TypeScript declarations for published REST
6
71
  collection/item reads, field selection, bounded pagination, filters, sorting,
@@ -14,6 +79,6 @@
14
79
  - MIT license for this client package. This does not license the rest of the repository.
15
80
 
16
81
  New applications import `@lemonadecms/client`. Existing local `@cms/client` imports
17
- can use the documented file alias to the same archive. No package has been
18
- published by this preparation; registry availability and scope ownership are
19
- separate release gates.
82
+ can use the documented file alias to a reviewed archive. Public availability of
83
+ 0.1.0 is verified separately from publisher access, provenance and authorization
84
+ 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
- For a version that has been published to npm:
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
- Before a version is published, install its reviewed candidate archive instead.
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/lemonadecms-client-0.1.0.tgz
21
+ npm install --ignore-scripts /absolute/path/to/reviewed-client.tgz
22
22
  # or
23
- pnpm add /absolute/path/to/lemonadecms-client-0.1.0.tgz
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
@@ -63,6 +63,15 @@ if (first) {
63
63
  ```
64
64
 
65
65
  Entry values are under `fields`; metadata includes `id` and `publishedAt`.
66
+ Unreleased source builds also expose `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 unreleased methods require the updated 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://lemonadecms.com",
244
+ "workspace": "YOUR_WORKSPACE_UUID",
245
+ "environment": "production",
246
+ "credentialEnv": "CMS_PUBLIC_KEY"
247
+ },
248
+ "dev": {
249
+ "url": "https://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
+ This requires the updated source package; published 0.1.0 lacks the picker.
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
@@ -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 connection(opts, env, cwd, management = false) {
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
- const target = name ? config.targets?.[name] : {};
190
- if (name && !target) fail(2, 'Selected target is not configured.');
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
232
  url: opts.url || target.url || env.LEMONADE_URL || 'https://lemonadecms.com',
193
- workspace: opts.workspace || target.workspace || env.LEMONADE_WORKSPACE,
194
- environment: opts.environment || target.environment || env.LEMONADE_ENVIRONMENT,
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 ? 'LEMONADE_MANAGEMENT_TOKEN' : 'LEMONADE_TOKEN'),
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
  !(
@@ -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 fetcher(`${target.url}${path}`, {
39
- method,
40
- headers: {
41
- Accept: 'application/json',
42
- Authorization: `Bearer ${target.key}`,
43
- ...(requestBody ? { 'Content-Type': 'application/json' } : {}),
44
- },
45
- ...(requestBody ? { body: JSON.stringify(requestBody) } : {}),
46
- redirect: 'error',
47
- credentials: 'omit',
48
- cache: 'no-store',
49
- signal: controller.signal,
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 reader.read();
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
- await reader?.cancel().catch(() => {});
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
+ }