@lemonadecms/client 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — initial npm candidate (unpublished)
4
+
5
+ - Framework-independent ESM client and TypeScript declarations for published REST
6
+ collection/item reads, field selection, bounded pagination, filters, sorting,
7
+ localization, references and media helpers.
8
+ - Separate server-only draft-preview client with browser import/runtime guards.
9
+ - Schema-based model generation and the `lemonade` / `cms-codegen` commands.
10
+ - Fixed `https://lemonadecms.com` destination for customer reads; configure the
11
+ workspace ID and appropriately scoped credential. URL/host/environment overrides
12
+ fail before I/O. The credential selects the content environment.
13
+ - Optional request deadlines and cancellation. No automatic cache or retry policy.
14
+ - MIT license for this client package. This does not license the rest of the repository.
15
+
16
+ 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lemonade CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,117 @@
1
- # Temporary Holding Version
1
+ # @lemonadecms/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-independent JavaScript and TypeScript SDK for reading Lemonade CMS
4
+ content. Published-content requests use REST. The package includes TypeScript
5
+ declarations, schema type generation and the `lemonade` developer CLI.
6
+
7
+ ## Install
8
+
9
+ For a version that has been published to npm:
10
+
11
+ ```sh
12
+ npm install @lemonadecms/client
13
+ # or
14
+ pnpm add @lemonadecms/client
15
+ ```
16
+
17
+ Before a version is published, install its reviewed candidate archive instead.
18
+ Check the supplied `SHA256SUMS`, then use the actual archive path:
19
+
20
+ ```sh
21
+ npm install --ignore-scripts /absolute/path/to/lemonadecms-client-0.1.0.tgz
22
+ # or
23
+ pnpm add /absolute/path/to/lemonadecms-client-0.1.0.tgz
24
+ ```
25
+
26
+ The package uses ESM and declares Node.js 22+. TypeScript consumers need
27
+ TypeScript 5+. Browser reads require standard `fetch`, `URL`, `URLSearchParams`
28
+ and `AbortController`; browser builds must use public credentials only. No
29
+ runtime library or framework dependency is required.
30
+
31
+ ## Configure and read content
32
+
33
+ The SDK connects to `https://lemonadecms.com` automatically. Configure your
34
+ workspace and delivery key; there is no URL or local CMS option. Your website
35
+ can run locally while reading content from Lemonade production. Installing the
36
+ package does not create an account, workspace or delivery key.
37
+
38
+ Replace the workspace and key placeholders with values from your Lemonade workspace. Replace `content_item` and `title` with your content type and field
39
+ API identifiers, and publish an entry before fetching it.
40
+
41
+ ```js
42
+ import { createCMSClient } from '@lemonadecms/client';
43
+
44
+ const cms = createCMSClient({
45
+ projectId: 'YOUR_WORKSPACE_UUID',
46
+ publicKey: 'YOUR_PUBLIC_DELIVERY_KEY',
47
+ });
48
+
49
+ const page = await cms.content('content_item').list({
50
+ select: ['title'],
51
+ limit: 10,
52
+ });
53
+
54
+ for (const entry of page.data) {
55
+ console.log(entry.id, entry.fields.title);
56
+ }
57
+
58
+ const first = page.data[0];
59
+ if (first) {
60
+ const entry = await cms.content('content_item').get(first.id);
61
+ console.log(entry.fields, entry.publishedAt);
62
+ }
63
+ ```
64
+
65
+ Entry values are under `fields`; metadata includes `id` and `publishedAt`.
66
+ An empty collection is `data: []`. Historical or selected fields can be missing
67
+ or `null`, so handle both when rendering. Continue a collection using
68
+ `page.meta.nextCursor` as the next request's `after` value while keeping the
69
+ other query options unchanged.
70
+
71
+ Use an existing `pk_` public delivery key or an `lmd_pk_` application token with
72
+ the required published-content grants. Anyone with that credential can read
73
+ the content it permits. Browser login cookies do not authorize SDK requests.
74
+ The credential selects the content environment; there is no separate SDK
75
+ environment override. Ask your Owner for a credential belonging to the intended
76
+ environment. The server rechecks current access, expiry and revocation on every
77
+ request.
78
+
79
+ ## Types, queries and framework examples
80
+
81
+ The [SDK guide](https://lemonadecms.com/guides/sdk) covers generated `Models`,
82
+ field selection, filtering, sorting, localization, cursor and numbered
83
+ pagination, errors, cancellation, images, and JavaScript/React/Vue/Nuxt examples.
84
+ Generate types from your own authorized schema and regenerate them after model
85
+ changes. Types help at compile time; they do not authenticate a request or
86
+ validate every field at runtime.
87
+
88
+ There is no automatic caching or retry policy. Your application owns rendering,
89
+ loading and error states, cache invalidation and safe HTML handling. Pass an
90
+ `AbortSignal` to cancel a read. Optional `timeoutMs` sets a per-request deadline;
91
+ combining a timeout with a caller signal requires `AbortSignal.any`.
92
+
93
+ ## Private previews
94
+
95
+ For draft reads, use `createCMSPreviewClient` from
96
+ `@lemonadecms/client/server` in trusted server code with a server-only
97
+ `secretKey`. Authenticate and authorize the requesting user in your application
98
+ before fetching or returning drafts. Do not create an anonymous preview proxy.
99
+ Never put preview credentials or private responses in browser bundles, public
100
+ environment variables, shared caches, static output or logs. The server export
101
+ rejects browser use; that guard does not replace your application's authorization.
102
+
103
+ `@lemonadecms/client/preview` is a separate optional click-to-edit helper for an
104
+ already authorized website preview. It provides no preview credential or draft
105
+ fetch client.
106
+
107
+ ## Guides
108
+
109
+ - [Getting started](https://lemonadecms.com/guides)
110
+ - [SDK reference and examples](https://lemonadecms.com/guides/sdk)
111
+ - [Troubleshooting](https://lemonadecms.com/guides/troubleshooting)
112
+ - [Repository](https://github.com/ilara-booqself/lemonadecms)
113
+
114
+ ## License
115
+
116
+ This SDK package is licensed under the [MIT License](LICENSE). Its license does
117
+ not grant a license to the entire CMS repository or the separate native clients.
@@ -0,0 +1,254 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import { readText, parseJSON } from './io.mjs';
4
+ import { fail } from './errors.mjs';
5
+ import { languages } from '../codegen/definitions.mjs';
6
+ export const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ export const record = (value) =>
8
+ value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const environmentKey = /^[a-z][a-z0-9_-]{0,31}$/;
10
+ const envName = /^[A-Z][A-Z0-9_]{0,127}$/;
11
+ const options = [
12
+ 'config',
13
+ 'target',
14
+ 'url',
15
+ 'workspace',
16
+ 'environment',
17
+ 'credential-env',
18
+ 'file',
19
+ 'out',
20
+ 'plan',
21
+ 'confirm',
22
+ 'before',
23
+ 'language',
24
+ 'baseline',
25
+ 'status',
26
+ 'id',
27
+ ];
28
+ const flags = ['json', 'check', 'help', 'version'];
29
+ export function parseArgs(args) {
30
+ const opts = {},
31
+ words = [];
32
+ for (let i = 0; i < args.length; i++) {
33
+ const word = args[i];
34
+ if (!word.startsWith('-')) {
35
+ words.push(word);
36
+ continue;
37
+ }
38
+ const key = word.slice(2);
39
+ if (
40
+ !word.startsWith('--') ||
41
+ (!options.includes(key) && !flags.includes(key)) ||
42
+ Object.hasOwn(opts, key)
43
+ )
44
+ fail(2, 'Unknown or repeated option. Use lemonade --help.');
45
+ if (flags.includes(key)) opts[key] = true;
46
+ else {
47
+ const value = args[++i];
48
+ if (!value || value.startsWith('--')) fail(2, 'An option value is missing.');
49
+ opts[key] = value;
50
+ }
51
+ }
52
+ const command = words.join(' ');
53
+ const allowed = {
54
+ check: [],
55
+ 'schema inspect': [],
56
+ 'schema pull': ['out'],
57
+ 'schema export': ['out'],
58
+ 'schema validate': ['file'],
59
+ 'schema diff': ['file'],
60
+ 'types generate': ['file', 'out', 'check', 'language'],
61
+ 'schema migrate status': ['before'],
62
+ 'schema migrate create': ['file', 'baseline', 'status', 'id', 'out'],
63
+ 'schema migrate plan': ['file', 'out'],
64
+ 'schema migrate apply': ['file', 'plan', 'confirm'],
65
+ };
66
+ if (opts.help || opts.version) {
67
+ if (
68
+ words.length ||
69
+ Object.keys(opts).some((key) => !['help', 'version', 'json'].includes(key)) ||
70
+ (opts.help && opts.version)
71
+ )
72
+ fail(2, 'Use lemonade --help or lemonade --version on its own.');
73
+ } else {
74
+ if (!Object.hasOwn(allowed, command))
75
+ fail(2, 'Unknown command. Use lemonade --help.');
76
+ for (const key of [
77
+ 'file',
78
+ 'out',
79
+ 'check',
80
+ 'plan',
81
+ 'confirm',
82
+ 'before',
83
+ 'language',
84
+ 'baseline',
85
+ 'status',
86
+ 'id',
87
+ ])
88
+ if (Object.hasOwn(opts, key) && !allowed[command].includes(key))
89
+ fail(2, 'Option is not supported by this command.');
90
+ if (opts.language && !languages.includes(opts.language))
91
+ fail(2, 'Choose typescript, csharp or python as the output language.');
92
+ if (
93
+ (command === 'schema validate' ||
94
+ command === 'schema migrate create' ||
95
+ (command === 'types generate' && opts.file)) &&
96
+ ['config', 'target', 'url', 'workspace', 'environment', 'credential-env'].some(
97
+ (key) => Object.hasOwn(opts, key),
98
+ )
99
+ )
100
+ fail(2, 'Offline file commands do not accept connection options.');
101
+ if (
102
+ [
103
+ 'schema pull',
104
+ 'schema export',
105
+ 'types generate',
106
+ 'schema migrate plan',
107
+ 'schema migrate create',
108
+ ].includes(command) &&
109
+ !opts.out
110
+ )
111
+ fail(2, '--out is required.');
112
+ if (
113
+ [
114
+ 'schema validate',
115
+ 'schema diff',
116
+ 'schema migrate plan',
117
+ 'schema migrate apply',
118
+ 'schema migrate create',
119
+ ].includes(command) &&
120
+ !opts.file
121
+ )
122
+ fail(2, '--file is required.');
123
+ if (
124
+ command === 'schema migrate create' &&
125
+ (!opts.baseline ||
126
+ !opts.status ||
127
+ !/^[0-9]{14}_[a-z][a-z0-9_]{0,63}$/.test(opts.id || ''))
128
+ )
129
+ fail(
130
+ 2,
131
+ 'Creating requires --baseline, --status, --id YYYYMMDDHHMMSS_description, --file and --out.',
132
+ );
133
+ }
134
+ if (
135
+ command === 'schema migrate apply' &&
136
+ (!opts.plan || !/^[a-f0-9]{64}$/.test(opts.confirm || ''))
137
+ )
138
+ fail(2, 'Applying requires --file, --plan and --confirm REVIEWED_CHECKSUM.');
139
+ return { command, opts };
140
+ }
141
+ export async function connection(opts, env, cwd, management = false) {
142
+ const configPath = resolve(cwd, opts.config || 'lemonade.config.json');
143
+ let config = {};
144
+ let found = true;
145
+ try {
146
+ await access(configPath);
147
+ } catch (error) {
148
+ if (error.code !== 'ENOENT' || opts.config)
149
+ fail(2, 'Could not open project configuration.');
150
+ found = false;
151
+ }
152
+ if (found) {
153
+ try {
154
+ config = parseJSON(await readText(configPath, 65536));
155
+ } catch {
156
+ fail(2, 'Could not read valid project configuration.');
157
+ }
158
+ if (
159
+ !record(config) ||
160
+ config.version !== 1 ||
161
+ !record(config.targets) ||
162
+ Object.keys(config).some((key) => !['version', 'targets'].includes(key)) ||
163
+ Object.keys(config.targets).length > 20
164
+ )
165
+ fail(2, 'Invalid project configuration.');
166
+ for (const [name, target] of Object.entries(config.targets)) {
167
+ if (
168
+ !environmentKey.test(name) ||
169
+ !record(target) ||
170
+ Object.keys(target).some(
171
+ (key) => !['url', 'workspace', 'environment', 'credentialEnv'].includes(key),
172
+ ) ||
173
+ Object.values(target).some((value) => typeof value !== 'string') ||
174
+ !uuid.test(target.workspace || '') ||
175
+ !(
176
+ uuid.test(target.environment || '') ||
177
+ environmentKey.test(target.environment || '')
178
+ ) ||
179
+ (target.credentialEnv !== undefined && !envName.test(target.credentialEnv))
180
+ )
181
+ fail(
182
+ 2,
183
+ 'Invalid target configuration; keep credentials in environment variables.',
184
+ );
185
+ safeURL(target.url);
186
+ }
187
+ }
188
+ 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.');
191
+ const selected = {
192
+ 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,
195
+ credentialEnv:
196
+ opts['credential-env'] ||
197
+ target.credentialEnv ||
198
+ (management ? 'LEMONADE_MANAGEMENT_TOKEN' : 'LEMONADE_TOKEN'),
199
+ configPath,
200
+ };
201
+ if (
202
+ !uuid.test(selected.workspace || '') ||
203
+ !(
204
+ uuid.test(selected.environment || '') ||
205
+ environmentKey.test(selected.environment || '')
206
+ ) ||
207
+ !envName.test(selected.credentialEnv)
208
+ )
209
+ fail(
210
+ 2,
211
+ 'Set an explicit workspace UUID, environment key/UUID and credential environment-variable name.',
212
+ );
213
+ if (management && !uuid.test(selected.environment || ''))
214
+ fail(2, 'Schema management requires an explicit environment UUID.');
215
+ const url = safeURL(selected.url);
216
+ const key = env[selected.credentialEnv];
217
+ if (
218
+ typeof key !== 'string' ||
219
+ !(
220
+ management
221
+ ? /^lmd_mgmt_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/
222
+ : /^(?:(?:pk|sk)_[A-Za-z0-9_-]{43}|lmd_(?:pk|sk)_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43})$/
223
+ ).test(key)
224
+ )
225
+ fail(
226
+ 2,
227
+ 'Set a valid command-specific credential in the configured environment variable.',
228
+ );
229
+ selected.url = url.href.replace(/\/+$/, '');
230
+ return { ...selected, key };
231
+ }
232
+
233
+ function safeURL(value) {
234
+ let url;
235
+ try {
236
+ url = new URL(value);
237
+ } catch {
238
+ fail(2, 'Set a valid CMS URL.');
239
+ }
240
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
241
+ if (
242
+ !['https:', 'http:'].includes(url.protocol) ||
243
+ (url.protocol === 'http:' && !loopback) ||
244
+ url.username ||
245
+ url.password ||
246
+ url.search ||
247
+ url.hash
248
+ )
249
+ fail(
250
+ 2,
251
+ 'Use HTTPS (HTTP only on loopback), without URL credentials, query or fragment.',
252
+ );
253
+ return url;
254
+ }
@@ -0,0 +1,157 @@
1
+ import { resolve } from 'node:path';
2
+ import { validateSchema, canonical } from './schema.mjs';
3
+ import { parseJSON, readText, writeOutput } from './io.mjs';
4
+ import { record } from './config.mjs';
5
+ import { fail } from './errors.mjs';
6
+
7
+ const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
8
+ const rejected = () =>
9
+ fail(
10
+ 5,
11
+ 'Only new models and appended optional fields are supported. Existing definitions, languages and remote bindings must remain unchanged.',
12
+ );
13
+ function migrationFields(fields, adding = false) {
14
+ for (const field of fields) {
15
+ if (
16
+ ['component', 'components', 'blocks'].includes(field.type) ||
17
+ Object.hasOwn(field, 'default') ||
18
+ Object.hasOwn(field, 'seo') ||
19
+ (adding && field.required)
20
+ )
21
+ rejected();
22
+ }
23
+ }
24
+
25
+ function orderedOperations(operations, existingKeys) {
26
+ const sorted = [...operations].sort((a, b) =>
27
+ a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
28
+ );
29
+ const pending = sorted.filter((operation) => operation.op === 'create_model');
30
+ const available = new Set(existingKeys);
31
+ const ordered = [];
32
+ while (pending.length) {
33
+ const index = pending.findIndex((operation) =>
34
+ operation.fields.every((field) => {
35
+ const targets = ['reference', 'references'].includes(field.type)
36
+ ? [field.target]
37
+ : field.type === 'rich_text'
38
+ ? field.targets || []
39
+ : [];
40
+ return targets.every(
41
+ (target) => target === operation.key || available.has(target),
42
+ );
43
+ }),
44
+ );
45
+ if (index < 0)
46
+ fail(
47
+ 5,
48
+ 'New-model reference cycles require separate reviewed additions. No output was written.',
49
+ );
50
+ const [next] = pending.splice(index, 1);
51
+ ordered.push(next);
52
+ available.add(next.key);
53
+ }
54
+ return [...ordered, ...sorted.filter((operation) => operation.op === 'add_fields')];
55
+ }
56
+
57
+ export async function createMigration(opts, cwd) {
58
+ const paths = [opts.baseline, opts.file, opts.status].map((name) =>
59
+ resolve(cwd, name),
60
+ );
61
+ const baseline = validateSchema(parseJSON(await readText(paths[0])));
62
+ const desired = validateSchema(parseJSON(await readText(paths[1])));
63
+ const status = parseJSON(await readText(paths[2]));
64
+ const environment = baseline.environment?.id || baseline.project_id;
65
+ const modelSummary = (schema) =>
66
+ schema.models
67
+ .map((model) => ({
68
+ key: model.key,
69
+ ...(model.singleton ? { singleton: true } : {}),
70
+ fields: model.fields.map(({ key, type }) => ({ key, type })),
71
+ }))
72
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
73
+ if (
74
+ !record(status) ||
75
+ status.ok !== true ||
76
+ status.workspace !== baseline.project_id ||
77
+ status.environment !== environment ||
78
+ status.revision !== baseline.schema_revision ||
79
+ !/^[a-f0-9]{64}$/.test(status.schema_hash || '') ||
80
+ !(
81
+ status.latest === null ||
82
+ /^[0-9]{14}_[a-z][a-z0-9_]{0,63}$/.test(status.latest || '')
83
+ ) ||
84
+ (status.latest !== null && opts.id <= status.latest) ||
85
+ !Array.isArray(status.models) ||
86
+ !equal(
87
+ [...status.models].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
88
+ modelSummary(baseline),
89
+ )
90
+ )
91
+ fail(
92
+ 5,
93
+ 'Status and baseline identity, revision or model summary do not match. Capture fresh inputs.',
94
+ );
95
+ // Require an exact portable editing baseline. Never turn unsupported edits into
96
+ // omissions, removals, or a blind desired-state replacement.
97
+ const withoutModels = ({ models, ...rest }) => rest;
98
+ if (!equal(withoutModels(baseline), withoutModels(desired))) rejected();
99
+ const operations = [];
100
+ const prior = new Map(baseline.models.map((model) => [model.key, model]));
101
+ for (const model of desired.models) {
102
+ const before = prior.get(model.key);
103
+ if (!before) {
104
+ if (model.remote) rejected();
105
+ migrationFields(model.fields);
106
+ operations.push({
107
+ op: 'create_model',
108
+ ...(model.singleton ? { singleton: true } : {}),
109
+ key: model.key,
110
+ name: model.name,
111
+ fields: model.fields,
112
+ });
113
+ } else {
114
+ if (
115
+ !equal({ ...before, fields: [] }, { ...model, fields: [] }) ||
116
+ model.fields.length < before.fields.length ||
117
+ !equal(before.fields, model.fields.slice(0, before.fields.length))
118
+ )
119
+ rejected();
120
+ const added = model.fields.slice(before.fields.length);
121
+ if (added.length) {
122
+ if (model.remote) rejected();
123
+ migrationFields(added, true);
124
+ operations.push({ op: 'add_fields', key: model.key, fields: added });
125
+ }
126
+ prior.delete(model.key);
127
+ }
128
+ }
129
+ if (prior.size) rejected();
130
+ if (!operations.length || operations.length > 16)
131
+ fail(5, 'Create between 1 and 16 supported model operations per migration.');
132
+ const migration = {
133
+ format_version: 1,
134
+ id: opts.id,
135
+ previous: status.latest,
136
+ from: status.schema_hash,
137
+ operations: orderedOperations(
138
+ operations,
139
+ baseline.models.map((model) => model.key),
140
+ ),
141
+ };
142
+ const text = JSON.stringify(migration, null, 2) + '\n';
143
+ if (Buffer.byteLength(text) > 262144)
144
+ fail(5, 'Migration exceeds the supported size.');
145
+ await writeOutput(resolve(cwd, opts.out), text, [
146
+ ...paths,
147
+ resolve(cwd, 'lemonade.config.json'),
148
+ ]);
149
+ return {
150
+ ok: true,
151
+ command: 'schema migrate create',
152
+ written: true,
153
+ operations: operations.length,
154
+ publication: 'unchanged',
155
+ requiresReview: true,
156
+ };
157
+ }
@@ -0,0 +1,9 @@
1
+ export class CLIError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }
7
+ export function fail(code, message) {
8
+ throw new CLIError(code, message);
9
+ }
package/bin/cli/io.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import { open, mkdir, rename, unlink, realpath } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { fail } from './errors.mjs';
6
+
7
+ export const MAX_SCHEMA_BYTES = 8 * 1024 * 1024;
8
+ export async function readText(path, max = MAX_SCHEMA_BYTES) {
9
+ let file;
10
+ try {
11
+ file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
12
+ const stat = await file.stat();
13
+ if (!stat.isFile() || stat.size > max)
14
+ fail(5, 'Input must be a bounded regular file.');
15
+ const buffer = Buffer.alloc(max + 1);
16
+ let size = 0;
17
+ while (size < buffer.length) {
18
+ const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
19
+ if (!bytesRead) break;
20
+ size += bytesRead;
21
+ }
22
+ if (size > max) fail(5, 'Input exceeds the supported size.');
23
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size));
24
+ } catch (error) {
25
+ if (error.code === 5) throw error;
26
+ fail(5, 'Could not read the input file.');
27
+ } finally {
28
+ await file?.close();
29
+ }
30
+ }
31
+ export function parseJSON(text) {
32
+ try {
33
+ return JSON.parse(text);
34
+ } catch {
35
+ fail(5, 'Input is not valid JSON.');
36
+ }
37
+ }
38
+ export async function writeOutput(path, text, protectedPaths = []) {
39
+ const destination = resolve(path);
40
+ const existingDestination = await realpath(destination).catch(() => destination);
41
+ const existingProtected = await Promise.all(
42
+ protectedPaths
43
+ .filter(Boolean)
44
+ .map((value) => realpath(resolve(value)).catch(() => resolve(value))),
45
+ );
46
+ if (
47
+ existingProtected.includes(existingDestination) ||
48
+ protectedPaths.some((value) => value && resolve(value) === destination) ||
49
+ /^(?:\.env(?:\..*)?|lemonade\.config\.json)$/i.test(
50
+ destination.split(/[\\/]/).pop(),
51
+ )
52
+ ) {
53
+ fail(2, 'Output must not replace configuration, credentials or the input schema.');
54
+ }
55
+ const temp = `${destination}.${randomUUID()}.tmp`;
56
+ try {
57
+ await mkdir(dirname(destination), { recursive: true });
58
+ const file = await open(temp, 'wx', 0o600);
59
+ try {
60
+ await file.writeFile(text);
61
+ } finally {
62
+ await file.close();
63
+ }
64
+ await rename(temp, destination);
65
+ } catch {
66
+ fail(6, 'Could not write the output file.');
67
+ } finally {
68
+ await unlink(temp).catch(() => {});
69
+ }
70
+ }