@lemonadecms/client 0.0.0-stage → 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 +84 -0
- package/LICENSE +21 -0
- package/README.md +304 -2
- package/bin/cli/config.mjs +309 -0
- package/bin/cli/create-migration.mjs +157 -0
- package/bin/cli/errors.mjs +9 -0
- package/bin/cli/io.mjs +70 -0
- package/bin/cli/migrate.mjs +109 -0
- package/bin/cli/network.mjs +134 -0
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +240 -0
- package/bin/cli/schema.mjs +397 -0
- package/bin/codegen/csharp.mjs +180 -0
- package/bin/codegen/definitions.mjs +52 -0
- package/bin/codegen/languages.mjs +16 -0
- package/bin/codegen/python.mjs +125 -0
- package/bin/generate-types.mjs +202 -0
- package/bin/lemonade.mjs +11 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +39 -0
- package/dist/images.d.ts +11 -0
- package/dist/images.js +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +17 -0
- package/dist/preview.d.ts +11 -0
- package/dist/preview.js +92 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.browser.d.ts +2 -0
- package/dist/server.browser.js +9 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +17 -0
- package/dist/transport.d.ts +3 -0
- package/dist/transport.js +519 -0
- package/dist/types.d.ts +202 -0
- package/dist/types.js +6 -0
- package/package.json +52 -4
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
4
|
+
import { readText, parseJSON } from './io.mjs';
|
|
5
|
+
import { fail } from './errors.mjs';
|
|
6
|
+
import { languages } from '../codegen/definitions.mjs';
|
|
7
|
+
export const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8
|
+
export const record = (value) =>
|
|
9
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const environmentKey = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
11
|
+
const envName = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
12
|
+
const options = [
|
|
13
|
+
'config',
|
|
14
|
+
'target',
|
|
15
|
+
'url',
|
|
16
|
+
'workspace',
|
|
17
|
+
'environment',
|
|
18
|
+
'credential-env',
|
|
19
|
+
'file',
|
|
20
|
+
'out',
|
|
21
|
+
'plan',
|
|
22
|
+
'confirm',
|
|
23
|
+
'before',
|
|
24
|
+
'language',
|
|
25
|
+
'baseline',
|
|
26
|
+
'status',
|
|
27
|
+
'id',
|
|
28
|
+
];
|
|
29
|
+
const flags = ['json', 'check', 'help', 'version'];
|
|
30
|
+
export function parseArgs(args) {
|
|
31
|
+
const opts = {},
|
|
32
|
+
words = [];
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
const word = args[i];
|
|
35
|
+
if (!word.startsWith('-')) {
|
|
36
|
+
words.push(word);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const key = word.slice(2);
|
|
40
|
+
if (
|
|
41
|
+
!word.startsWith('--') ||
|
|
42
|
+
(!options.includes(key) && !flags.includes(key)) ||
|
|
43
|
+
Object.hasOwn(opts, key)
|
|
44
|
+
)
|
|
45
|
+
fail(2, 'Unknown or repeated option. Use lemonade --help.');
|
|
46
|
+
if (flags.includes(key)) opts[key] = true;
|
|
47
|
+
else {
|
|
48
|
+
const value = args[++i];
|
|
49
|
+
if (!value || value.startsWith('--')) fail(2, 'An option value is missing.');
|
|
50
|
+
opts[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const command = words.join(' ');
|
|
54
|
+
const allowed = {
|
|
55
|
+
check: [],
|
|
56
|
+
'schema inspect': [],
|
|
57
|
+
'schema pull': ['out'],
|
|
58
|
+
'schema export': ['out'],
|
|
59
|
+
'schema validate': ['file'],
|
|
60
|
+
'schema diff': ['file'],
|
|
61
|
+
'types generate': ['file', 'out', 'check', 'language'],
|
|
62
|
+
'schema migrate status': ['before'],
|
|
63
|
+
'schema migrate create': ['file', 'baseline', 'status', 'id', 'out'],
|
|
64
|
+
'schema migrate plan': ['file', 'out'],
|
|
65
|
+
'schema migrate apply': ['file', 'plan', 'confirm'],
|
|
66
|
+
};
|
|
67
|
+
if (opts.help || opts.version) {
|
|
68
|
+
if (
|
|
69
|
+
words.length ||
|
|
70
|
+
Object.keys(opts).some((key) => !['help', 'version', 'json'].includes(key)) ||
|
|
71
|
+
(opts.help && opts.version)
|
|
72
|
+
)
|
|
73
|
+
fail(2, 'Use lemonade --help or lemonade --version on its own.');
|
|
74
|
+
} else {
|
|
75
|
+
if (!Object.hasOwn(allowed, command))
|
|
76
|
+
fail(2, 'Unknown command. Use lemonade --help.');
|
|
77
|
+
for (const key of [
|
|
78
|
+
'file',
|
|
79
|
+
'out',
|
|
80
|
+
'check',
|
|
81
|
+
'plan',
|
|
82
|
+
'confirm',
|
|
83
|
+
'before',
|
|
84
|
+
'language',
|
|
85
|
+
'baseline',
|
|
86
|
+
'status',
|
|
87
|
+
'id',
|
|
88
|
+
])
|
|
89
|
+
if (Object.hasOwn(opts, key) && !allowed[command].includes(key))
|
|
90
|
+
fail(2, 'Option is not supported by this command.');
|
|
91
|
+
if (opts.language && !languages.includes(opts.language))
|
|
92
|
+
fail(2, 'Choose typescript, csharp or python as the output language.');
|
|
93
|
+
if (
|
|
94
|
+
(command === 'schema validate' ||
|
|
95
|
+
command === 'schema migrate create' ||
|
|
96
|
+
(command === 'types generate' && opts.file)) &&
|
|
97
|
+
['config', 'target', 'url', 'workspace', 'environment', 'credential-env'].some(
|
|
98
|
+
(key) => Object.hasOwn(opts, key),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
fail(2, 'Offline file commands do not accept connection options.');
|
|
102
|
+
if (
|
|
103
|
+
[
|
|
104
|
+
'schema pull',
|
|
105
|
+
'schema export',
|
|
106
|
+
'schema migrate plan',
|
|
107
|
+
'schema migrate create',
|
|
108
|
+
].includes(command) &&
|
|
109
|
+
!opts.out
|
|
110
|
+
)
|
|
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
|
+
}`;
|
|
120
|
+
if (
|
|
121
|
+
[
|
|
122
|
+
'schema validate',
|
|
123
|
+
'schema diff',
|
|
124
|
+
'schema migrate plan',
|
|
125
|
+
'schema migrate apply',
|
|
126
|
+
'schema migrate create',
|
|
127
|
+
].includes(command) &&
|
|
128
|
+
!opts.file
|
|
129
|
+
)
|
|
130
|
+
fail(2, '--file is required.');
|
|
131
|
+
if (
|
|
132
|
+
command === 'schema migrate create' &&
|
|
133
|
+
(!opts.baseline ||
|
|
134
|
+
!opts.status ||
|
|
135
|
+
!/^[0-9]{14}_[a-z][a-z0-9_]{0,63}$/.test(opts.id || ''))
|
|
136
|
+
)
|
|
137
|
+
fail(
|
|
138
|
+
2,
|
|
139
|
+
'Creating requires --baseline, --status, --id YYYYMMDDHHMMSS_description, --file and --out.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
command === 'schema migrate apply' &&
|
|
144
|
+
(!opts.plan || !/^[a-f0-9]{64}$/.test(opts.confirm || ''))
|
|
145
|
+
)
|
|
146
|
+
fail(2, 'Applying requires --file, --plan and --confirm REVIEWED_CHECKSUM.');
|
|
147
|
+
return { command, opts };
|
|
148
|
+
}
|
|
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) {
|
|
169
|
+
const configPath = resolve(cwd, opts.config || 'lemonade.config.json');
|
|
170
|
+
let config = {};
|
|
171
|
+
let found = true;
|
|
172
|
+
try {
|
|
173
|
+
await access(configPath);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.code !== 'ENOENT' || opts.config)
|
|
176
|
+
fail(2, 'Could not open project configuration.');
|
|
177
|
+
found = false;
|
|
178
|
+
}
|
|
179
|
+
if (found) {
|
|
180
|
+
try {
|
|
181
|
+
config = parseJSON(await readText(configPath, 65536));
|
|
182
|
+
} catch {
|
|
183
|
+
fail(2, 'Could not read valid project configuration.');
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
!record(config) ||
|
|
187
|
+
config.version !== 1 ||
|
|
188
|
+
!record(config.targets) ||
|
|
189
|
+
Object.keys(config).some((key) => !['version', 'targets'].includes(key)) ||
|
|
190
|
+
Object.keys(config.targets).length > 20
|
|
191
|
+
)
|
|
192
|
+
fail(2, 'Invalid project configuration.');
|
|
193
|
+
for (const [name, target] of Object.entries(config.targets)) {
|
|
194
|
+
if (
|
|
195
|
+
!environmentKey.test(name) ||
|
|
196
|
+
!record(target) ||
|
|
197
|
+
Object.keys(target).some(
|
|
198
|
+
(key) => !['url', 'workspace', 'environment', 'credentialEnv'].includes(key),
|
|
199
|
+
) ||
|
|
200
|
+
Object.values(target).some((value) => typeof value !== 'string') ||
|
|
201
|
+
!uuid.test(target.workspace || '') ||
|
|
202
|
+
!(
|
|
203
|
+
uuid.test(target.environment || '') ||
|
|
204
|
+
environmentKey.test(target.environment || '')
|
|
205
|
+
) ||
|
|
206
|
+
(target.credentialEnv !== undefined && !envName.test(target.credentialEnv))
|
|
207
|
+
)
|
|
208
|
+
fail(
|
|
209
|
+
2,
|
|
210
|
+
'Invalid target configuration; keep credentials in environment variables.',
|
|
211
|
+
);
|
|
212
|
+
safeURL(target.url);
|
|
213
|
+
}
|
|
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));
|
|
227
|
+
const name = opts.target || env.LEMONADE_TARGET;
|
|
228
|
+
if (name && !Object.hasOwn(targets, name))
|
|
229
|
+
fail(2, 'Selected target is not configured.');
|
|
230
|
+
const target = name ? targets[name] : {};
|
|
231
|
+
const selected = {
|
|
232
|
+
url: opts.url || target.url || env.LEMONADE_URL || 'https://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,
|
|
243
|
+
credentialEnv:
|
|
244
|
+
opts['credential-env'] ||
|
|
245
|
+
target.credentialEnv ||
|
|
246
|
+
(management
|
|
247
|
+
? 'LEMONADE_MANAGEMENT_TOKEN'
|
|
248
|
+
: defaults.credentialEnv || 'LEMONADE_TOKEN'),
|
|
249
|
+
configPath,
|
|
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
|
+
);
|
|
256
|
+
if (
|
|
257
|
+
!uuid.test(selected.workspace || '') ||
|
|
258
|
+
!(
|
|
259
|
+
uuid.test(selected.environment || '') ||
|
|
260
|
+
environmentKey.test(selected.environment || '')
|
|
261
|
+
) ||
|
|
262
|
+
!envName.test(selected.credentialEnv)
|
|
263
|
+
)
|
|
264
|
+
fail(
|
|
265
|
+
2,
|
|
266
|
+
'Set an explicit workspace UUID, environment key/UUID and credential environment-variable name.',
|
|
267
|
+
);
|
|
268
|
+
if (management && !uuid.test(selected.environment || ''))
|
|
269
|
+
fail(2, 'Schema management requires an explicit environment UUID.');
|
|
270
|
+
const url = safeURL(selected.url);
|
|
271
|
+
const key = env[selected.credentialEnv];
|
|
272
|
+
if (
|
|
273
|
+
typeof key !== 'string' ||
|
|
274
|
+
!(
|
|
275
|
+
management
|
|
276
|
+
? /^lmd_mgmt_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/
|
|
277
|
+
: /^(?:(?:pk|sk)_[A-Za-z0-9_-]{43}|lmd_(?:pk|sk)_[0-9a-f-]{36}\.[A-Za-z0-9_-]{43})$/
|
|
278
|
+
).test(key)
|
|
279
|
+
)
|
|
280
|
+
fail(
|
|
281
|
+
2,
|
|
282
|
+
'Set a valid command-specific credential in the configured environment variable.',
|
|
283
|
+
);
|
|
284
|
+
selected.url = url.href.replace(/\/+$/, '');
|
|
285
|
+
return { ...selected, key };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function safeURL(value) {
|
|
289
|
+
let url;
|
|
290
|
+
try {
|
|
291
|
+
url = new URL(value);
|
|
292
|
+
} catch {
|
|
293
|
+
fail(2, 'Set a valid CMS URL.');
|
|
294
|
+
}
|
|
295
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
296
|
+
if (
|
|
297
|
+
!['https:', 'http:'].includes(url.protocol) ||
|
|
298
|
+
(url.protocol === 'http:' && !loopback) ||
|
|
299
|
+
url.username ||
|
|
300
|
+
url.password ||
|
|
301
|
+
url.search ||
|
|
302
|
+
url.hash
|
|
303
|
+
)
|
|
304
|
+
fail(
|
|
305
|
+
2,
|
|
306
|
+
'Use HTTPS (HTTP only on loopback), without URL credentials, query or fragment.',
|
|
307
|
+
);
|
|
308
|
+
return url;
|
|
309
|
+
}
|
|
@@ -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
|
+
}
|
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
|
+
}
|
|
@@ -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
|
+
}
|