@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,134 @@
|
|
|
1
|
+
import { MAX_SCHEMA_BYTES, parseJSON } from './io.mjs';
|
|
2
|
+
import { fail, CLIError } from './errors.mjs';
|
|
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
|
+
}
|
|
32
|
+
export async function fetchSchema(target, fetcher = fetch) {
|
|
33
|
+
const schema = validateSchema(
|
|
34
|
+
await requestJSON(
|
|
35
|
+
target,
|
|
36
|
+
`/api/v1/projects/${target.workspace}/schema`,
|
|
37
|
+
{},
|
|
38
|
+
fetcher,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const identity = schemaIdentity(schema);
|
|
42
|
+
if (
|
|
43
|
+
identity.workspace.toLowerCase() !== target.workspace.toLowerCase() ||
|
|
44
|
+
!(
|
|
45
|
+
identity.environment === target.environment ||
|
|
46
|
+
identity.environmentId.toLowerCase() === target.environment.toLowerCase()
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
fail(
|
|
50
|
+
4,
|
|
51
|
+
'Schema does not match the explicitly selected workspace/environment. No output was written.',
|
|
52
|
+
);
|
|
53
|
+
return schema;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function requestJSON(
|
|
57
|
+
target,
|
|
58
|
+
path,
|
|
59
|
+
{ method = 'GET', body: requestBody } = {},
|
|
60
|
+
fetcher = fetch,
|
|
61
|
+
) {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
64
|
+
let reader;
|
|
65
|
+
try {
|
|
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
|
+
);
|
|
81
|
+
if ([401, 403].includes(response.status))
|
|
82
|
+
fail(
|
|
83
|
+
4,
|
|
84
|
+
'Credential rejected, expired or revoked; check its workspace and scope.',
|
|
85
|
+
);
|
|
86
|
+
if (path.startsWith('/api/v1/management/') && [409, 422].includes(response.status))
|
|
87
|
+
fail(
|
|
88
|
+
3,
|
|
89
|
+
'Migration rejected: drift, stale plan, order, checksum, validation or capacity. Inspect status and create a fresh reviewed plan.',
|
|
90
|
+
);
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
fail(4, 'CMS request failed. Check connection, server status and read access.');
|
|
93
|
+
if (
|
|
94
|
+
!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || '')
|
|
95
|
+
)
|
|
96
|
+
fail(5, 'CMS did not return a JSON schema.');
|
|
97
|
+
const size = response.headers.get('content-length');
|
|
98
|
+
if (size !== null && (!/^\d+$/.test(size) || Number(size) > MAX_SCHEMA_BYTES))
|
|
99
|
+
fail(5, 'Schema response exceeds the supported size.');
|
|
100
|
+
reader = response.body?.getReader();
|
|
101
|
+
if (!reader) fail(5, 'CMS returned an empty response.');
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let bytes = 0;
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await withinSignal(controller.signal, () =>
|
|
106
|
+
reader.read(),
|
|
107
|
+
);
|
|
108
|
+
if (done) break;
|
|
109
|
+
bytes += value.byteLength;
|
|
110
|
+
if (bytes > MAX_SCHEMA_BYTES)
|
|
111
|
+
fail(5, 'Schema response exceeds the supported size.');
|
|
112
|
+
chunks.push(value);
|
|
113
|
+
}
|
|
114
|
+
let body;
|
|
115
|
+
try {
|
|
116
|
+
body = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
117
|
+
} catch {
|
|
118
|
+
fail(5, 'CMS returned invalid UTF-8 schema data.');
|
|
119
|
+
}
|
|
120
|
+
// Never persist or print a reflected credential, even on an otherwise valid server response.
|
|
121
|
+
if (body.includes(target.key)) fail(5, 'CMS returned unsafe schema metadata.');
|
|
122
|
+
return parseJSON(body);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof CLIError) throw error;
|
|
125
|
+
fail(4, 'Could not read CMS schema. Check network, TLS and server availability.');
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
controller.abort();
|
|
129
|
+
// Cancellation is best effort: a stalled cleanup must not extend the deadline.
|
|
130
|
+
try {
|
|
131
|
+
reader?.cancel().catch(() => {});
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
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
|
+
}
|
package/bin/cli/run.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
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 {
|
|
6
|
+
parseArgs,
|
|
7
|
+
connection,
|
|
8
|
+
typeGenerationEnv,
|
|
9
|
+
readConfiguration,
|
|
10
|
+
} from './config.mjs';
|
|
11
|
+
import { canPickTarget, pickTarget } from './picker.mjs';
|
|
12
|
+
import { CLIError, fail } from './errors.mjs';
|
|
13
|
+
import { parseJSON, readText, writeOutput, MAX_SCHEMA_BYTES } from './io.mjs';
|
|
14
|
+
import {
|
|
15
|
+
validateSchema,
|
|
16
|
+
schemaIdentity,
|
|
17
|
+
compareSchemas,
|
|
18
|
+
serialize,
|
|
19
|
+
} from './schema.mjs';
|
|
20
|
+
import { migrate } from './migrate.mjs';
|
|
21
|
+
import { fetchSchema } from './network.mjs';
|
|
22
|
+
import { generateLanguage } from '../codegen/languages.mjs';
|
|
23
|
+
import { createMigration } from './create-migration.mjs';
|
|
24
|
+
export const HELP = `Lemonade CMS — delivery CLI and explicit schema migrations
|
|
25
|
+
|
|
26
|
+
lemonade check [connection options]
|
|
27
|
+
lemonade schema inspect [connection options]
|
|
28
|
+
lemonade schema pull --out schema.json [connection options]
|
|
29
|
+
lemonade schema export --out schema.json [connection options]
|
|
30
|
+
lemonade schema validate --file schema.json
|
|
31
|
+
lemonade schema diff --file schema.json [connection options]
|
|
32
|
+
lemonade types generate [--file schema.json | connection options] [--out FILE] [--language typescript|csharp|python] [--check]
|
|
33
|
+
|
|
34
|
+
lemonade schema migrate status [--before MIGRATION_ID] [connection options]
|
|
35
|
+
lemonade schema migrate create --baseline current.json --file desired.json --status status.json --id YYYYMMDDHHMMSS_description --out migration.json
|
|
36
|
+
lemonade schema migrate plan --file migration.json --out review.json [connection options]
|
|
37
|
+
lemonade schema migrate apply --file migration.json --plan review.json --confirm CHECKSUM [connection options]
|
|
38
|
+
|
|
39
|
+
Migration commands require an environment UUID and a separate management credential
|
|
40
|
+
(default LEMONADE_MANAGEMENT_TOKEN); delivery commands remain read-only.
|
|
41
|
+
|
|
42
|
+
Connection: --config FILE --target NAME, or --url URL --workspace UUID --environment KEY_OR_UUID
|
|
43
|
+
Credential: --credential-env ENV_NAME (default LEMONADE_TOKEN). Never pass a token value.
|
|
44
|
+
Targets live in lemonade.config.json. URL/workspace/environment also accept LEMONADE_* environment variables.
|
|
45
|
+
Types generate: loads cwd/.env (process values win); workspace falls back to CMS_PROJECT_ID,
|
|
46
|
+
environment to CMS_ENVIRONMENT or production, credential to CMS_PUBLIC_KEY when LEMONADE_TOKEN is unset.
|
|
47
|
+
Default output: src/types/cms-models.ts (csharp: cms-models.cs; python: cms_models.py).
|
|
48
|
+
Explicit options, named targets and existing LEMONADE_* settings take precedence. --out is a file path.
|
|
49
|
+
Offline generation and other commands do not load .env. Use the selected environment's credential.
|
|
50
|
+
Interactive types generate offers configured targets (production first) when no connection selection is explicit.
|
|
51
|
+
Use arrows/Enter to choose; Escape/Ctrl+C/EOF cancels without network or output-file writes.
|
|
52
|
+
CI, --json, --check, piped input/output, --file and explicit connection/environment settings never prompt.
|
|
53
|
+
All commands: --json. Informational: --help, --version.
|
|
54
|
+
Exit codes: 0 success, 2 usage/config, 3 drift, 4 connection/auth/context, 5 invalid schema/input, 6 output failure, 130 selection cancelled.
|
|
55
|
+
Only schema migrate apply writes compatible schema additions or reviewed missing-draft backfills. No content publication, automatic retries or credential persistence. Node.js 22+ required.
|
|
56
|
+
`;
|
|
57
|
+
export async function run(
|
|
58
|
+
args,
|
|
59
|
+
{
|
|
60
|
+
env = process.env,
|
|
61
|
+
cwd = process.cwd(),
|
|
62
|
+
fetcher = fetch,
|
|
63
|
+
stdout = (text) => process.stdout.write(text),
|
|
64
|
+
stderr = (text) => process.stderr.write(text),
|
|
65
|
+
terminal = {
|
|
66
|
+
input: process.stdin,
|
|
67
|
+
output: process.stderr,
|
|
68
|
+
stdoutIsTTY: process.stdout.isTTY,
|
|
69
|
+
},
|
|
70
|
+
} = {},
|
|
71
|
+
) {
|
|
72
|
+
let opts = {},
|
|
73
|
+
key;
|
|
74
|
+
function emit(value, human, error = false) {
|
|
75
|
+
const text = opts.json ? JSON.stringify(value) + '\n' : human + '\n';
|
|
76
|
+
if (key && text.includes(key)) fail(5, 'Unsafe reflected credential was blocked.');
|
|
77
|
+
(opts.json || !error ? stdout : stderr)(text);
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
// Preserve structured errors even when another option is invalid.
|
|
81
|
+
opts.json = args.includes('--json');
|
|
82
|
+
const parsed = parseArgs(args);
|
|
83
|
+
opts = parsed.opts;
|
|
84
|
+
if (opts.help) {
|
|
85
|
+
emit({ ok: true, help: HELP }, HELP.trimEnd());
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (opts.version) {
|
|
89
|
+
emit({ ok: true, version }, version);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
const command = parsed.command;
|
|
93
|
+
if (command === 'schema migrate create') {
|
|
94
|
+
const result = await createMigration(opts, cwd);
|
|
95
|
+
emit(
|
|
96
|
+
result,
|
|
97
|
+
'Migration created locally. Review the file, then plan and explicitly apply it.',
|
|
98
|
+
);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
if (command.startsWith('schema migrate ')) {
|
|
102
|
+
const { result, human } = await migrate(command, opts, {
|
|
103
|
+
cwd,
|
|
104
|
+
env,
|
|
105
|
+
fetcher,
|
|
106
|
+
setKey: (value) => {
|
|
107
|
+
key = value;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
emit(result, human);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
const inputPath = opts.file && resolve(cwd, opts.file);
|
|
114
|
+
const readSchema = async () => validateSchema(parseJSON(await readText(inputPath)));
|
|
115
|
+
const remote = async () => {
|
|
116
|
+
const generation = command === 'types generate';
|
|
117
|
+
const selectedEnv = generation ? await typeGenerationEnv(env, cwd) : env;
|
|
118
|
+
const defaults = generation
|
|
119
|
+
? {
|
|
120
|
+
workspace: selectedEnv.CMS_PROJECT_ID,
|
|
121
|
+
environment: selectedEnv.CMS_ENVIRONMENT || 'production',
|
|
122
|
+
credentialEnv: Object.hasOwn(selectedEnv, 'LEMONADE_TOKEN')
|
|
123
|
+
? 'LEMONADE_TOKEN'
|
|
124
|
+
: 'CMS_PUBLIC_KEY',
|
|
125
|
+
}
|
|
126
|
+
: {};
|
|
127
|
+
const configuration = await readConfiguration(opts, cwd);
|
|
128
|
+
let selected = opts;
|
|
129
|
+
if (
|
|
130
|
+
generation &&
|
|
131
|
+
canPickTarget(opts, selectedEnv, terminal) &&
|
|
132
|
+
Object.keys(configuration.targets).length
|
|
133
|
+
) {
|
|
134
|
+
selected = {
|
|
135
|
+
...opts,
|
|
136
|
+
target: await pickTarget(configuration.targets, terminal),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const target = await connection(
|
|
140
|
+
selected,
|
|
141
|
+
selectedEnv,
|
|
142
|
+
cwd,
|
|
143
|
+
false,
|
|
144
|
+
defaults,
|
|
145
|
+
configuration,
|
|
146
|
+
);
|
|
147
|
+
key = target.key;
|
|
148
|
+
return fetchSchema(target, fetcher);
|
|
149
|
+
};
|
|
150
|
+
const schema =
|
|
151
|
+
inputPath && ['schema validate', 'types generate'].includes(command)
|
|
152
|
+
? await readSchema()
|
|
153
|
+
: await remote();
|
|
154
|
+
const identity = schemaIdentity(schema);
|
|
155
|
+
let result = { ok: true, command, ...identity };
|
|
156
|
+
let human = `Connected: ${identity.workspace} / ${identity.environment} — ${identity.models} models, ${identity.fields} fields, revision ${identity.revision}.`;
|
|
157
|
+
let code = 0;
|
|
158
|
+
if (command === 'schema inspect') {
|
|
159
|
+
result.models = schema.models.map((model) => ({
|
|
160
|
+
key: model.key,
|
|
161
|
+
fields: model.fields.map((field) => ({
|
|
162
|
+
key: field.key,
|
|
163
|
+
type: field.type,
|
|
164
|
+
required: field.required,
|
|
165
|
+
})),
|
|
166
|
+
}));
|
|
167
|
+
human +=
|
|
168
|
+
'\n' +
|
|
169
|
+
result.models
|
|
170
|
+
.map(
|
|
171
|
+
(model) =>
|
|
172
|
+
` ${model.key}: ${model.fields.map((field) => `${field.key} (${field.type})`).join(', ')}`,
|
|
173
|
+
)
|
|
174
|
+
.join('\n');
|
|
175
|
+
} else if (['schema pull', 'schema export'].includes(command)) {
|
|
176
|
+
const targetPath = resolve(cwd, opts.out);
|
|
177
|
+
await writeOutput(targetPath, serialize(schema), [
|
|
178
|
+
resolve(cwd, opts.config || 'lemonade.config.json'),
|
|
179
|
+
]);
|
|
180
|
+
result.written = true;
|
|
181
|
+
human = 'Schema exported successfully.';
|
|
182
|
+
} else if (command === 'schema diff') {
|
|
183
|
+
result.changes = compareSchemas(await readSchema(), schema);
|
|
184
|
+
result.drift = result.changes.length > 0;
|
|
185
|
+
code = result.drift ? 3 : 0;
|
|
186
|
+
result.ok = !result.drift;
|
|
187
|
+
human = result.drift
|
|
188
|
+
? `Schema differs (${result.changes.length} changes):\n${result.changes.map((item) => ` ${item.change}: ${item.path}`).join('\n')}`
|
|
189
|
+
: 'Schema matches the baseline.';
|
|
190
|
+
} else if (command === 'schema validate') {
|
|
191
|
+
human = 'Schema is valid for read-only inspection and type generation.';
|
|
192
|
+
} else if (command === 'types generate') {
|
|
193
|
+
const language = opts.language || 'typescript';
|
|
194
|
+
const output = generateLanguage(schema, language);
|
|
195
|
+
if (Buffer.byteLength(output) > MAX_SCHEMA_BYTES)
|
|
196
|
+
fail(5, 'Generated output exceeds the supported 8 MiB limit.');
|
|
197
|
+
result.language = language;
|
|
198
|
+
if (key && output.includes(key))
|
|
199
|
+
fail(5, 'Unsafe reflected credential was blocked.');
|
|
200
|
+
const path = resolve(cwd, opts.out);
|
|
201
|
+
if (opts.check) {
|
|
202
|
+
let exists = true;
|
|
203
|
+
try {
|
|
204
|
+
await access(path);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error.code !== 'ENOENT') fail(5, 'Could not inspect generated output.');
|
|
207
|
+
exists = false;
|
|
208
|
+
}
|
|
209
|
+
result.drift = !exists || (await readText(path)) !== output;
|
|
210
|
+
result.ok = !result.drift;
|
|
211
|
+
code = result.drift ? 3 : 0;
|
|
212
|
+
human = result.drift
|
|
213
|
+
? 'Generated types differ or are missing. Run types generate without --check.'
|
|
214
|
+
: 'Generated types are up to date.';
|
|
215
|
+
} else {
|
|
216
|
+
await writeOutput(path, output, [
|
|
217
|
+
inputPath,
|
|
218
|
+
resolve(cwd, opts.config || 'lemonade.config.json'),
|
|
219
|
+
resolve(cwd, '.env'),
|
|
220
|
+
]);
|
|
221
|
+
result.written = true;
|
|
222
|
+
human = `Types generated successfully (${language}).`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
emit(result, human);
|
|
226
|
+
return code;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const safe =
|
|
229
|
+
error instanceof CLIError
|
|
230
|
+
? error
|
|
231
|
+
: new CLIError(
|
|
232
|
+
5,
|
|
233
|
+
'Operation failed. Check configuration, schema and file access.',
|
|
234
|
+
);
|
|
235
|
+
const result = { ok: false, code: safe.code, error: safe.message };
|
|
236
|
+
// Fixed diagnostic messages never interpolate server responses, URLs or supplied arguments.
|
|
237
|
+
emit(result, safe.message, true);
|
|
238
|
+
return safe.code;
|
|
239
|
+
}
|
|
240
|
+
}
|