@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.
@@ -0,0 +1,109 @@
1
+ import { resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { canonical } from './schema.mjs';
4
+ import { connection, record } from './config.mjs';
5
+ import { readText, parseJSON, writeOutput } from './io.mjs';
6
+ import { requestJSON } from './network.mjs';
7
+ import { fail } from './errors.mjs';
8
+ const digest = (value) =>
9
+ createHash('sha256')
10
+ .update(JSON.stringify(canonical(value)))
11
+ .digest('hex');
12
+ export async function migrate(command, opts, { cwd, env, fetcher, setKey }) {
13
+ const target = await connection(opts, env, cwd, true);
14
+ setKey(target.key);
15
+ const path = `/api/v1/management/projects/${target.workspace}/environments/${target.environment}/schema`;
16
+ if (command === 'schema migrate status') {
17
+ const result = await requestJSON(
18
+ target,
19
+ `${path}/status${opts.before ? '?before=' + encodeURIComponent(opts.before) : ''}`,
20
+ {},
21
+ fetcher,
22
+ );
23
+ context(result, target);
24
+ return { result, human: JSON.stringify(result, null, 2) };
25
+ }
26
+ const filePath = resolve(cwd, opts.file);
27
+ const migration = parseJSON(await readText(filePath, 262144));
28
+ if (
29
+ !record(migration) ||
30
+ ![1, 2].includes(migration.format_version) ||
31
+ !Array.isArray(migration.operations)
32
+ )
33
+ fail(5, 'Invalid migration file.');
34
+ let result;
35
+ if (command === 'schema migrate plan') {
36
+ result = await requestJSON(
37
+ target,
38
+ `${path}/plan`,
39
+ { method: 'POST', body: { migration } },
40
+ fetcher,
41
+ );
42
+ context(result, target);
43
+ if (result.already_applied === true) {
44
+ if (
45
+ result.receipt?.checksum !== digest(migration) ||
46
+ result.receipt?.migration_id !== migration.id
47
+ )
48
+ fail(5, 'Mismatched migration receipt.');
49
+ return { result, human: 'Migration already applied with the same checksum.' };
50
+ }
51
+ if (
52
+ result.checksum !== digest(migration) ||
53
+ typeof result.plan !== 'string' ||
54
+ result.plan.length > 8192
55
+ )
56
+ fail(5, 'Invalid or mismatched migration plan.');
57
+ // Local review artifact; never overwrite a migration/configuration file.
58
+ await writeOutput(resolve(cwd, opts.out), JSON.stringify(result, null, 2) + '\n', [
59
+ filePath,
60
+ target.configPath,
61
+ ]);
62
+ const { plan, ...safe } = result;
63
+ return {
64
+ result: { ...safe, written: true },
65
+ human: `Plan saved. Review its impact before applying. Confirm checksum: ${result.checksum}`,
66
+ };
67
+ }
68
+ const saved = parseJSON(await readText(resolve(cwd, opts.plan), 262144));
69
+ context(saved, target);
70
+ if (
71
+ saved.checksum !== digest(migration) ||
72
+ opts.confirm !== saved.checksum ||
73
+ typeof saved.plan !== 'string'
74
+ )
75
+ fail(5, 'File, reviewed plan and confirmation checksum must match.');
76
+ result = await requestJSON(
77
+ target,
78
+ `${path}/apply`,
79
+ { method: 'POST', body: { migration, plan: saved.plan } },
80
+ fetcher,
81
+ );
82
+ context(result, target);
83
+ if (
84
+ !record(result.receipt) ||
85
+ result.receipt.checksum !== saved.checksum ||
86
+ result.receipt.migration_id !== migration.id
87
+ )
88
+ fail(
89
+ 5,
90
+ 'CMS returned a mismatched migration receipt; inspect status before retrying.',
91
+ );
92
+ return {
93
+ result,
94
+ human: result.already_applied
95
+ ? 'Migration already applied; original receipt returned.'
96
+ : migration.format_version === 2
97
+ ? 'Reviewed draft backfill applied atomically. Published content is unchanged.'
98
+ : 'Schema migration applied atomically. Content publication is unchanged.',
99
+ };
100
+ }
101
+ function context(value, target) {
102
+ if (
103
+ !record(value) ||
104
+ value.ok !== true ||
105
+ value.workspace !== target.workspace ||
106
+ value.environment !== target.environment
107
+ )
108
+ fail(4, 'Management response does not match the selected workspace/environment.');
109
+ }
@@ -0,0 +1,99 @@
1
+ import { MAX_SCHEMA_BYTES, parseJSON } from './io.mjs';
2
+ import { fail, CLIError } from './errors.mjs';
3
+ import { validateSchema, schemaIdentity } from './schema.mjs';
4
+ export async function fetchSchema(target, fetcher = fetch) {
5
+ const schema = validateSchema(
6
+ await requestJSON(
7
+ target,
8
+ `/api/v1/projects/${target.workspace}/schema`,
9
+ {},
10
+ fetcher,
11
+ ),
12
+ );
13
+ const identity = schemaIdentity(schema);
14
+ if (
15
+ identity.workspace.toLowerCase() !== target.workspace.toLowerCase() ||
16
+ !(
17
+ identity.environment === target.environment ||
18
+ identity.environmentId.toLowerCase() === target.environment.toLowerCase()
19
+ )
20
+ )
21
+ fail(
22
+ 4,
23
+ 'Schema does not match the explicitly selected workspace/environment. No output was written.',
24
+ );
25
+ return schema;
26
+ }
27
+
28
+ export async function requestJSON(
29
+ target,
30
+ path,
31
+ { method = 'GET', body: requestBody } = {},
32
+ fetcher = fetch,
33
+ ) {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), 15000);
36
+ let reader;
37
+ 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
+ });
51
+ if ([401, 403].includes(response.status))
52
+ fail(
53
+ 4,
54
+ 'Credential rejected, expired or revoked; check its workspace and scope.',
55
+ );
56
+ if (path.startsWith('/api/v1/management/') && [409, 422].includes(response.status))
57
+ fail(
58
+ 3,
59
+ 'Migration rejected: drift, stale plan, order, checksum, validation or capacity. Inspect status and create a fresh reviewed plan.',
60
+ );
61
+ if (!response.ok)
62
+ fail(4, 'CMS request failed. Check connection, server status and read access.');
63
+ if (
64
+ !/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || '')
65
+ )
66
+ fail(5, 'CMS did not return a JSON schema.');
67
+ const size = response.headers.get('content-length');
68
+ if (size !== null && (!/^\d+$/.test(size) || Number(size) > MAX_SCHEMA_BYTES))
69
+ fail(5, 'Schema response exceeds the supported size.');
70
+ reader = response.body?.getReader();
71
+ if (!reader) fail(5, 'CMS returned an empty response.');
72
+ const chunks = [];
73
+ let bytes = 0;
74
+ while (true) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ bytes += value.byteLength;
78
+ if (bytes > MAX_SCHEMA_BYTES)
79
+ fail(5, 'Schema response exceeds the supported size.');
80
+ chunks.push(value);
81
+ }
82
+ let body;
83
+ try {
84
+ body = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
85
+ } catch {
86
+ fail(5, 'CMS returned invalid UTF-8 schema data.');
87
+ }
88
+ // Never persist or print a reflected credential, even on an otherwise valid server response.
89
+ if (body.includes(target.key)) fail(5, 'CMS returned unsafe schema metadata.');
90
+ return parseJSON(body);
91
+ } catch (error) {
92
+ if (error instanceof CLIError) throw error;
93
+ fail(4, 'Could not read CMS schema. Check network, TLS and server availability.');
94
+ } finally {
95
+ clearTimeout(timer);
96
+ controller.abort();
97
+ await reader?.cancel().catch(() => {});
98
+ }
99
+ }
@@ -0,0 +1,190 @@
1
+ import { resolve } from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+ const { version } = createRequire(import.meta.url)('../../package.json');
4
+ import { access } from 'node:fs/promises';
5
+ import { parseArgs, connection } from './config.mjs';
6
+ import { CLIError, fail } from './errors.mjs';
7
+ import { parseJSON, readText, writeOutput, MAX_SCHEMA_BYTES } from './io.mjs';
8
+ import {
9
+ validateSchema,
10
+ schemaIdentity,
11
+ compareSchemas,
12
+ serialize,
13
+ } from './schema.mjs';
14
+ import { migrate } from './migrate.mjs';
15
+ import { fetchSchema } from './network.mjs';
16
+ import { generateLanguage } from '../codegen/languages.mjs';
17
+ import { createMigration } from './create-migration.mjs';
18
+ export const HELP = `Lemonade CMS — delivery CLI and explicit schema migrations
19
+
20
+ lemonade check [connection options]
21
+ lemonade schema inspect [connection options]
22
+ lemonade schema pull --out schema.json [connection options]
23
+ lemonade schema export --out schema.json [connection options]
24
+ lemonade schema validate --file schema.json
25
+ lemonade schema diff --file schema.json [connection options]
26
+ lemonade types generate [--file schema.json | connection options] --out FILE [--language typescript|csharp|python] [--check]
27
+
28
+ lemonade schema migrate status [--before MIGRATION_ID] [connection options]
29
+ lemonade schema migrate create --baseline current.json --file desired.json --status status.json --id YYYYMMDDHHMMSS_description --out migration.json
30
+ lemonade schema migrate plan --file migration.json --out review.json [connection options]
31
+ lemonade schema migrate apply --file migration.json --plan review.json --confirm CHECKSUM [connection options]
32
+
33
+ Migration commands require an environment UUID and a separate management credential
34
+ (default LEMONADE_MANAGEMENT_TOKEN); delivery commands remain read-only.
35
+
36
+ Connection: --config FILE --target NAME, or --url URL --workspace UUID --environment KEY_OR_UUID
37
+ Credential: --credential-env ENV_NAME (default LEMONADE_TOKEN). Never pass a token value.
38
+ Targets live in lemonade.config.json. URL/workspace/environment also accept LEMONADE_* environment variables.
39
+ All commands: --json. Informational: --help, --version.
40
+ Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure.
41
+ Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, prompts, automatic retries or credential persistence. Node.js 22+ required.
42
+ `;
43
+ export async function run(
44
+ args,
45
+ {
46
+ env = process.env,
47
+ cwd = process.cwd(),
48
+ fetcher = fetch,
49
+ stdout = (text) => process.stdout.write(text),
50
+ stderr = (text) => process.stderr.write(text),
51
+ } = {},
52
+ ) {
53
+ let opts = {},
54
+ key;
55
+ function emit(value, human, error = false) {
56
+ const text = opts.json ? JSON.stringify(value) + '\n' : human + '\n';
57
+ if (key && text.includes(key)) fail(5, 'Unsafe reflected credential was blocked.');
58
+ (opts.json || !error ? stdout : stderr)(text);
59
+ }
60
+ try {
61
+ // Preserve structured errors even when another option is invalid.
62
+ opts.json = args.includes('--json');
63
+ const parsed = parseArgs(args);
64
+ opts = parsed.opts;
65
+ if (opts.help) {
66
+ emit({ ok: true, help: HELP }, HELP.trimEnd());
67
+ return 0;
68
+ }
69
+ if (opts.version) {
70
+ emit({ ok: true, version }, version);
71
+ return 0;
72
+ }
73
+ const command = parsed.command;
74
+ if (command === 'schema migrate create') {
75
+ const result = await createMigration(opts, cwd);
76
+ emit(
77
+ result,
78
+ 'Migration created locally. Review the file, then plan and explicitly apply it.',
79
+ );
80
+ return 0;
81
+ }
82
+ if (command.startsWith('schema migrate ')) {
83
+ const { result, human } = await migrate(command, opts, {
84
+ cwd,
85
+ env,
86
+ fetcher,
87
+ setKey: (value) => {
88
+ key = value;
89
+ },
90
+ });
91
+ emit(result, human);
92
+ return 0;
93
+ }
94
+ const inputPath = opts.file && resolve(cwd, opts.file);
95
+ const readSchema = async () => validateSchema(parseJSON(await readText(inputPath)));
96
+ const remote = async () => {
97
+ const target = await connection(opts, env, cwd);
98
+ key = target.key;
99
+ return fetchSchema(target, fetcher);
100
+ };
101
+ const schema =
102
+ inputPath && ['schema validate', 'types generate'].includes(command)
103
+ ? await readSchema()
104
+ : await remote();
105
+ const identity = schemaIdentity(schema);
106
+ let result = { ok: true, command, ...identity };
107
+ let human = `Connected: ${identity.workspace} / ${identity.environment} — ${identity.models} models, ${identity.fields} fields, revision ${identity.revision}.`;
108
+ let code = 0;
109
+ if (command === 'schema inspect') {
110
+ result.models = schema.models.map((model) => ({
111
+ key: model.key,
112
+ fields: model.fields.map((field) => ({
113
+ key: field.key,
114
+ type: field.type,
115
+ required: field.required,
116
+ })),
117
+ }));
118
+ human +=
119
+ '\n' +
120
+ result.models
121
+ .map(
122
+ (model) =>
123
+ ` ${model.key}: ${model.fields.map((field) => `${field.key} (${field.type})`).join(', ')}`,
124
+ )
125
+ .join('\n');
126
+ } else if (['schema pull', 'schema export'].includes(command)) {
127
+ const targetPath = resolve(cwd, opts.out);
128
+ await writeOutput(targetPath, serialize(schema), [
129
+ resolve(cwd, opts.config || 'lemonade.config.json'),
130
+ ]);
131
+ result.written = true;
132
+ human = 'Schema exported successfully.';
133
+ } else if (command === 'schema diff') {
134
+ result.changes = compareSchemas(await readSchema(), schema);
135
+ result.drift = result.changes.length > 0;
136
+ code = result.drift ? 3 : 0;
137
+ result.ok = !result.drift;
138
+ human = result.drift
139
+ ? `Schema differs (${result.changes.length} changes):\n${result.changes.map((item) => ` ${item.change}: ${item.path}`).join('\n')}`
140
+ : 'Schema matches the baseline.';
141
+ } else if (command === 'schema validate') {
142
+ human = 'Schema is valid for read-only inspection and type generation.';
143
+ } else if (command === 'types generate') {
144
+ const language = opts.language || 'typescript';
145
+ const output = generateLanguage(schema, language);
146
+ if (Buffer.byteLength(output) > MAX_SCHEMA_BYTES)
147
+ fail(5, 'Generated output exceeds the supported 8 MiB limit.');
148
+ result.language = language;
149
+ if (key && output.includes(key))
150
+ fail(5, 'Unsafe reflected credential was blocked.');
151
+ const path = resolve(cwd, opts.out);
152
+ if (opts.check) {
153
+ let exists = true;
154
+ try {
155
+ await access(path);
156
+ } catch (error) {
157
+ if (error.code !== 'ENOENT') fail(5, 'Could not inspect generated output.');
158
+ exists = false;
159
+ }
160
+ result.drift = !exists || (await readText(path)) !== output;
161
+ result.ok = !result.drift;
162
+ code = result.drift ? 3 : 0;
163
+ human = result.drift
164
+ ? 'Generated types differ or are missing. Run types generate without --check.'
165
+ : 'Generated types are up to date.';
166
+ } else {
167
+ await writeOutput(path, output, [
168
+ inputPath,
169
+ resolve(cwd, opts.config || 'lemonade.config.json'),
170
+ ]);
171
+ result.written = true;
172
+ human = `Types generated successfully (${language}).`;
173
+ }
174
+ }
175
+ emit(result, human);
176
+ return code;
177
+ } catch (error) {
178
+ const safe =
179
+ error instanceof CLIError
180
+ ? error
181
+ : new CLIError(
182
+ 5,
183
+ 'Operation failed. Check configuration, schema and file access.',
184
+ );
185
+ const result = { ok: false, code: safe.code, error: safe.message };
186
+ // Fixed diagnostic messages never interpolate server responses, URLs or supplied arguments.
187
+ emit(result, safe.message, true);
188
+ return safe.code;
189
+ }
190
+ }