@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.15
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/README.md +8 -0
- package/dist/build.js +1521 -0
- package/dist/dev-server.js +278 -0
- package/dist/index.js +175 -0
- package/dist/manifest.js +273 -0
- package/dist/preview.js +866 -0
- package/dist/resolve-components.js +490 -0
- package/dist/server/resolve-request-route.js +169 -0
- package/dist/server-contract.js +278 -0
- package/dist/types/generate-env-dts.js +52 -0
- package/dist/types/generate-routes-dts.js +22 -0
- package/dist/types/index.js +34 -0
- package/dist/ui/env.js +41 -0
- package/dist/ui/format.js +172 -0
- package/dist/ui/logger.js +105 -0
- package/package.json +21 -49
- package/bin/zen-build.ts +0 -2
- package/bin/zen-dev.ts +0 -2
- package/bin/zen-preview.ts +0 -2
- package/bin/zenith.ts +0 -2
- package/dist/zen-build.js +0 -9622
- package/dist/zen-dev.js +0 -9622
- package/dist/zen-preview.js +0 -9622
- package/dist/zenith.js +0 -9622
- package/src/commands/add.ts +0 -37
- package/src/commands/build.ts +0 -36
- package/src/commands/create.ts +0 -702
- package/src/commands/dev.ts +0 -472
- package/src/commands/index.ts +0 -112
- package/src/commands/preview.ts +0 -62
- package/src/commands/remove.ts +0 -33
- package/src/index.ts +0 -10
- package/src/main.ts +0 -101
- package/src/utils/branding.ts +0 -178
- package/src/utils/logger.ts +0 -52
- package/src/utils/plugin-manager.ts +0 -114
- package/src/utils/project.ts +0 -77
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// server-contract.js — Zenith CLI V0
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Shared validation and payload resolution logic for <script server> blocks.
|
|
4
|
+
|
|
5
|
+
const NEW_KEYS = new Set(['data', 'load', 'guard', 'prerender']);
|
|
6
|
+
const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
|
|
7
|
+
const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'prerender', 'ssr_data', 'props', 'ssr']);
|
|
8
|
+
|
|
9
|
+
const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data']);
|
|
10
|
+
|
|
11
|
+
export function allow() {
|
|
12
|
+
return { kind: 'allow' };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function redirect(location, status = 302) {
|
|
16
|
+
return {
|
|
17
|
+
kind: 'redirect',
|
|
18
|
+
location: String(location || ''),
|
|
19
|
+
status: Number.isInteger(status) ? status : 302
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deny(status = 403, message = undefined) {
|
|
24
|
+
return {
|
|
25
|
+
kind: 'deny',
|
|
26
|
+
status: Number.isInteger(status) ? status : 403,
|
|
27
|
+
message: typeof message === 'string' ? message : undefined
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function data(payload) {
|
|
32
|
+
return { kind: 'data', data: payload };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRouteResultLike(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const kind = value.kind;
|
|
40
|
+
return typeof kind === 'string' && ROUTE_RESULT_KINDS.has(kind);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertValidRouteResultShape(value, where, allowedKinds) {
|
|
44
|
+
if (!isRouteResultLike(value)) {
|
|
45
|
+
throw new Error(`[Zenith] ${where}: invalid route result. Expected object with kind.`);
|
|
46
|
+
}
|
|
47
|
+
const kind = value.kind;
|
|
48
|
+
if (!allowedKinds.has(kind)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[Zenith] ${where}: kind "${kind}" is not allowed here (allowed: ${Array.from(allowedKinds).join(', ')}).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (kind === 'redirect') {
|
|
55
|
+
if (typeof value.location !== 'string' || value.location.length === 0) {
|
|
56
|
+
throw new Error(`[Zenith] ${where}: redirect requires non-empty string location.`);
|
|
57
|
+
}
|
|
58
|
+
if (value.status !== undefined && (!Number.isInteger(value.status) || value.status < 300 || value.status > 399)) {
|
|
59
|
+
throw new Error(`[Zenith] ${where}: redirect status must be an integer 3xx.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (kind === 'deny') {
|
|
64
|
+
if (!Number.isInteger(value.status) || (value.status !== 401 && value.status !== 403)) {
|
|
65
|
+
throw new Error(`[Zenith] ${where}: deny status must be 401 or 403.`);
|
|
66
|
+
}
|
|
67
|
+
if (value.message !== undefined && typeof value.message !== 'string') {
|
|
68
|
+
throw new Error(`[Zenith] ${where}: deny message must be a string when provided.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateServerExports({ exports, filePath }) {
|
|
74
|
+
const exportKeys = Object.keys(exports);
|
|
75
|
+
const illegalKeys = exportKeys.filter(k => !ALLOWED_KEYS.has(k));
|
|
76
|
+
|
|
77
|
+
if (illegalKeys.length > 0) {
|
|
78
|
+
throw new Error(`[Zenith] ${filePath}: illegal export(s): ${illegalKeys.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hasData = 'data' in exports;
|
|
82
|
+
const hasLoad = 'load' in exports;
|
|
83
|
+
const hasGuard = 'guard' in exports;
|
|
84
|
+
|
|
85
|
+
const hasNew = hasData || hasLoad;
|
|
86
|
+
const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
|
|
87
|
+
|
|
88
|
+
if (hasData && hasLoad) {
|
|
89
|
+
throw new Error(`[Zenith] ${filePath}: cannot export both "data" and "load". Choose one.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasNew && hasLegacy) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`[Zenith] ${filePath}: cannot mix new ("data"/"load") with legacy ("ssr_data"/"props"/"ssr") exports.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if ('prerender' in exports && typeof exports.prerender !== 'boolean') {
|
|
99
|
+
throw new Error(`[Zenith] ${filePath}: "prerender" must be a boolean.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (hasLoad && typeof exports.load !== 'function') {
|
|
103
|
+
throw new Error(`[Zenith] ${filePath}: "load" must be a function.`);
|
|
104
|
+
}
|
|
105
|
+
if (hasLoad) {
|
|
106
|
+
if (exports.load.length !== 1) {
|
|
107
|
+
throw new Error(`[Zenith] ${filePath}: "load(ctx)" must take exactly 1 argument.`);
|
|
108
|
+
}
|
|
109
|
+
const fnStr = exports.load.toString();
|
|
110
|
+
const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
|
|
111
|
+
if (paramsMatch && paramsMatch[1].includes('...')) {
|
|
112
|
+
throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (hasGuard && typeof exports.guard !== 'function') {
|
|
117
|
+
throw new Error(`[Zenith] ${filePath}: "guard" must be a function.`);
|
|
118
|
+
}
|
|
119
|
+
if (hasGuard) {
|
|
120
|
+
if (exports.guard.length !== 1) {
|
|
121
|
+
throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must take exactly 1 argument.`);
|
|
122
|
+
}
|
|
123
|
+
const fnStr = exports.guard.toString();
|
|
124
|
+
const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
|
|
125
|
+
if (paramsMatch && paramsMatch[1].includes('...')) {
|
|
126
|
+
throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must not contain rest parameters.`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function assertJsonSerializable(value, where = 'payload') {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
|
|
134
|
+
function walk(v, path) {
|
|
135
|
+
const t = typeof v;
|
|
136
|
+
|
|
137
|
+
if (v === null) return;
|
|
138
|
+
if (t === 'string' || t === 'number' || t === 'boolean') return;
|
|
139
|
+
|
|
140
|
+
if (t === 'bigint' || t === 'function' || t === 'symbol') {
|
|
141
|
+
throw new Error(`[Zenith] ${where}: non-serializable ${t} at ${path}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (t === 'undefined') {
|
|
145
|
+
throw new Error(`[Zenith] ${where}: undefined is not allowed at ${path}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (v instanceof Date) {
|
|
149
|
+
throw new Error(`[Zenith] ${where}: Date is not allowed at ${path} (convert to ISO string)`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (v instanceof Map || v instanceof Set) {
|
|
153
|
+
throw new Error(`[Zenith] ${where}: Map/Set not allowed at ${path}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (t === 'object') {
|
|
157
|
+
if (seen.has(v)) throw new Error(`[Zenith] ${where}: circular reference at ${path}`);
|
|
158
|
+
seen.add(v);
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(v)) {
|
|
161
|
+
if (path === '$') {
|
|
162
|
+
throw new Error(`[Zenith] ${where}: top-level payload must be a plain object, not an array at ${path}`);
|
|
163
|
+
}
|
|
164
|
+
for (let i = 0; i < v.length; i++) walk(v[i], `${path}[${i}]`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const proto = Object.getPrototypeOf(v);
|
|
169
|
+
const isPlainObject = proto === null ||
|
|
170
|
+
proto === Object.prototype ||
|
|
171
|
+
(proto && proto.constructor && proto.constructor.name === 'Object');
|
|
172
|
+
|
|
173
|
+
if (!isPlainObject) {
|
|
174
|
+
throw new Error(`[Zenith] ${where}: non-plain object at ${path}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const k of Object.keys(v)) {
|
|
178
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') {
|
|
179
|
+
throw new Error(`[Zenith] ${where}: forbidden prototype pollution key "${k}" at ${path}.${k}`);
|
|
180
|
+
}
|
|
181
|
+
walk(v[k], `${path}.${k}`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new Error(`[Zenith] ${where}: unsupported type at ${path}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
walk(value, '$');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function resolveRouteResult({ exports, ctx, filePath }) {
|
|
193
|
+
validateServerExports({ exports, filePath });
|
|
194
|
+
|
|
195
|
+
const trace = {
|
|
196
|
+
guard: 'none',
|
|
197
|
+
load: 'none'
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
if ('guard' in exports) {
|
|
201
|
+
const guardRaw = await exports.guard(ctx);
|
|
202
|
+
const guardResult = guardRaw == null ? allow() : guardRaw;
|
|
203
|
+
assertValidRouteResultShape(
|
|
204
|
+
guardResult,
|
|
205
|
+
`${filePath}: guard(ctx) return`,
|
|
206
|
+
new Set(['allow', 'redirect', 'deny'])
|
|
207
|
+
);
|
|
208
|
+
trace.guard = guardResult.kind;
|
|
209
|
+
if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
|
|
210
|
+
return { result: guardResult, trace };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let payload;
|
|
215
|
+
if ('load' in exports) {
|
|
216
|
+
const loadRaw = await exports.load(ctx);
|
|
217
|
+
let loadResult = null;
|
|
218
|
+
if (isRouteResultLike(loadRaw)) {
|
|
219
|
+
loadResult = loadRaw;
|
|
220
|
+
assertValidRouteResultShape(
|
|
221
|
+
loadResult,
|
|
222
|
+
`${filePath}: load(ctx) return`,
|
|
223
|
+
new Set(['data', 'redirect', 'deny'])
|
|
224
|
+
);
|
|
225
|
+
} else {
|
|
226
|
+
assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
|
|
227
|
+
loadResult = data(loadRaw);
|
|
228
|
+
}
|
|
229
|
+
trace.load = loadResult.kind;
|
|
230
|
+
return { result: loadResult, trace };
|
|
231
|
+
}
|
|
232
|
+
if ('data' in exports) {
|
|
233
|
+
payload = exports.data;
|
|
234
|
+
assertJsonSerializable(payload, `${filePath}: data export`);
|
|
235
|
+
trace.load = 'data';
|
|
236
|
+
return { result: data(payload), trace };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// legacy fallback
|
|
240
|
+
if ('ssr_data' in exports) {
|
|
241
|
+
payload = exports.ssr_data;
|
|
242
|
+
assertJsonSerializable(payload, `${filePath}: ssr_data export`);
|
|
243
|
+
trace.load = 'data';
|
|
244
|
+
return { result: data(payload), trace };
|
|
245
|
+
}
|
|
246
|
+
if ('props' in exports) {
|
|
247
|
+
payload = exports.props;
|
|
248
|
+
assertJsonSerializable(payload, `${filePath}: props export`);
|
|
249
|
+
trace.load = 'data';
|
|
250
|
+
return { result: data(payload), trace };
|
|
251
|
+
}
|
|
252
|
+
if ('ssr' in exports) {
|
|
253
|
+
payload = exports.ssr;
|
|
254
|
+
assertJsonSerializable(payload, `${filePath}: ssr export`);
|
|
255
|
+
trace.load = 'data';
|
|
256
|
+
return { result: data(payload), trace };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { result: data({}), trace };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function resolveServerPayload({ exports, ctx, filePath }) {
|
|
263
|
+
const resolved = await resolveRouteResult({ exports, ctx, filePath });
|
|
264
|
+
if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (resolved.result.kind === 'data') {
|
|
269
|
+
return resolved.result.data;
|
|
270
|
+
}
|
|
271
|
+
if (resolved.result.kind === 'allow') {
|
|
272
|
+
return {};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
throw new Error(
|
|
276
|
+
`[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function generateEnvDts(projectRoot) {
|
|
5
|
+
const content = `// .zenith/zenith-env.d.ts
|
|
6
|
+
// Auto-generated by Zenith. Do not edit.
|
|
7
|
+
|
|
8
|
+
export {};
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
namespace Zenith {
|
|
12
|
+
type Params = Record<string, string>;
|
|
13
|
+
|
|
14
|
+
interface LoadContext {
|
|
15
|
+
params: Params;
|
|
16
|
+
url: URL;
|
|
17
|
+
request: Request;
|
|
18
|
+
route: {
|
|
19
|
+
id: string;
|
|
20
|
+
file: string;
|
|
21
|
+
pattern: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ErrorState {
|
|
26
|
+
status?: number;
|
|
27
|
+
code?: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PageData = Record<string, unknown> & {
|
|
32
|
+
__zenith_error?: ErrorState;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Load<T extends PageData = PageData> = (ctx: LoadContext) => Promise<T> | T;
|
|
36
|
+
|
|
37
|
+
type Renderable =
|
|
38
|
+
| string
|
|
39
|
+
| number
|
|
40
|
+
| boolean
|
|
41
|
+
| null
|
|
42
|
+
| undefined
|
|
43
|
+
| Renderable[]
|
|
44
|
+
| { __zenith_fragment: true };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const outPath = join(projectRoot, '.zenith', 'zenith-env.d.ts');
|
|
50
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
51
|
+
await writeFile(outPath, content, 'utf8');
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function generateRoutesDts(projectRoot, manifest) {
|
|
5
|
+
const routes = (manifest || []).map(r => r.path).filter(Boolean);
|
|
6
|
+
const typeDef = routes.length > 0
|
|
7
|
+
? routes.map(r => '"' + r + '"').join(' | ')
|
|
8
|
+
: 'string';
|
|
9
|
+
|
|
10
|
+
const content = '// .zenith/zenith-routes.d.ts\\n' +
|
|
11
|
+
'// Auto-generated by Zenith. Do not edit.\\n\\n' +
|
|
12
|
+
'export {};\\n\\n' +
|
|
13
|
+
'declare global {\\n' +
|
|
14
|
+
' namespace Zenith {\\n' +
|
|
15
|
+
' type RoutePattern = ' + typeDef + ';\\n' +
|
|
16
|
+
' }\\n' +
|
|
17
|
+
'}\\n';
|
|
18
|
+
|
|
19
|
+
const outPath = join(projectRoot, '.zenith', 'zenith-routes.d.ts');
|
|
20
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
21
|
+
await writeFile(outPath, content, 'utf8');
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { generateEnvDts } from './generate-env-dts.js';
|
|
2
|
+
import { generateRoutesDts } from './generate-routes-dts.js';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { access, constants } from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
export async function ensureZenithTypes(projectRoot, manifest) {
|
|
7
|
+
try {
|
|
8
|
+
await generateEnvDts(projectRoot);
|
|
9
|
+
if (manifest) {
|
|
10
|
+
await generateRoutesDts(projectRoot, manifest);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Check if tsconfig.json exists, if it does, check if .zenith is included
|
|
14
|
+
const tsconfigPath = join(projectRoot, 'tsconfig.json');
|
|
15
|
+
let hasTsConfig = false;
|
|
16
|
+
try {
|
|
17
|
+
await access(tsconfigPath, constants.F_OK);
|
|
18
|
+
hasTsConfig = true;
|
|
19
|
+
} catch {
|
|
20
|
+
hasTsConfig = false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (hasTsConfig) {
|
|
24
|
+
// In a real implementation this would parse the JSON and check "include".
|
|
25
|
+
// For now, we simply inform the user to include it if they haven't.
|
|
26
|
+
if (!globalThis.__zenithTypesWarned) {
|
|
27
|
+
console.warn('\\x1b[33m[zenith]\\x1b[0m For the best TypeScript experience, ensure ".zenith/**/*.d.ts" is in your tsconfig.json "include" array.');
|
|
28
|
+
globalThis.__zenithTypesWarned = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error('[zenith] Failed to generate type definitions:', err);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/ui/env.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI environment mode detection for deterministic CLI output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function flagEnabled(value) {
|
|
6
|
+
if (value === undefined || value === null) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const normalized = String(value).trim().toLowerCase();
|
|
10
|
+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ env?: Record<string, string | undefined>, stdout?: { isTTY?: boolean } }} runtime
|
|
15
|
+
*/
|
|
16
|
+
export function getUiMode(runtime = process) {
|
|
17
|
+
const env = runtime.env || {};
|
|
18
|
+
const tty = Boolean(runtime.stdout?.isTTY);
|
|
19
|
+
const ci = flagEnabled(env.CI);
|
|
20
|
+
const noUi = flagEnabled(env.ZENITH_NO_UI);
|
|
21
|
+
const noColor = env.NO_COLOR !== undefined && String(env.NO_COLOR).length >= 0;
|
|
22
|
+
const forceColor = flagEnabled(env.FORCE_COLOR);
|
|
23
|
+
const debug = flagEnabled(env.ZENITH_DEBUG);
|
|
24
|
+
|
|
25
|
+
const plain = noUi || ci || !tty;
|
|
26
|
+
const color = !plain && !noColor && (forceColor || tty);
|
|
27
|
+
const spinner = tty && !plain && !ci;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
plain,
|
|
31
|
+
color,
|
|
32
|
+
tty,
|
|
33
|
+
ci,
|
|
34
|
+
spinner,
|
|
35
|
+
debug
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isUiPlain(runtime = process) {
|
|
40
|
+
return getUiMode(runtime).plain;
|
|
41
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic text formatters for CLI UX.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { relative, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const ANSI = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
red: '\x1b[31m',
|
|
12
|
+
yellow: '\x1b[33m',
|
|
13
|
+
green: '\x1b[32m',
|
|
14
|
+
cyan: '\x1b[36m'
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_PHASE = 'cli';
|
|
17
|
+
const DEFAULT_FILE = '.';
|
|
18
|
+
const DEFAULT_HINT_BASE = 'https://github.com/zenithbuild/zenith/blob/main/zenith-cli/CLI_CONTRACT.md';
|
|
19
|
+
|
|
20
|
+
function colorize(mode, token, text) {
|
|
21
|
+
if (!mode.color) {
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
return `${ANSI[token]}${text}${ANSI.reset}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatHeading(mode, text) {
|
|
28
|
+
const label = mode.plain ? 'ZENITH CLI' : colorize(mode, 'bold', 'Zenith CLI');
|
|
29
|
+
return `${label} ${text}`.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatStep(mode, text) {
|
|
33
|
+
if (mode.plain) {
|
|
34
|
+
return `[zenith] INFO: ${text}`;
|
|
35
|
+
}
|
|
36
|
+
const bullet = colorize(mode, 'cyan', '•');
|
|
37
|
+
return `[zenith] ${bullet} ${text}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatSummaryTable(mode, rows) {
|
|
41
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
const maxLabel = rows.reduce((acc, row) => Math.max(acc, String(row.label || '').length), 0);
|
|
45
|
+
return rows
|
|
46
|
+
.map((row) => {
|
|
47
|
+
const label = String(row.label || '').padEnd(maxLabel, ' ');
|
|
48
|
+
const value = String(row.value || '');
|
|
49
|
+
return `[zenith] ${label} : ${value}`;
|
|
50
|
+
})
|
|
51
|
+
.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sanitizeErrorMessage(input) {
|
|
55
|
+
return String(input ?? '')
|
|
56
|
+
.replace(/\r/g, '')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeFileLinePath(line) {
|
|
61
|
+
const match = line.match(/^(\s*File:\s+)(.+)$/);
|
|
62
|
+
if (!match) {
|
|
63
|
+
return line;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const prefix = match[1];
|
|
67
|
+
const filePath = match[2].trim();
|
|
68
|
+
const normalized = normalizePathForDisplay(filePath);
|
|
69
|
+
return `${prefix}${normalized}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizePathForDisplay(filePath) {
|
|
73
|
+
const value = String(filePath || '').trim();
|
|
74
|
+
if (!value) {
|
|
75
|
+
return DEFAULT_FILE;
|
|
76
|
+
}
|
|
77
|
+
if (!value.startsWith('/') && !/^[A-Za-z]:\\/.test(value)) {
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cwd = process.cwd();
|
|
82
|
+
const cwdWithSep = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`;
|
|
83
|
+
if (value === cwd) {
|
|
84
|
+
return DEFAULT_FILE;
|
|
85
|
+
}
|
|
86
|
+
if (value.startsWith(cwdWithSep)) {
|
|
87
|
+
const relativePath = relative(cwd, value).replaceAll('\\', '/');
|
|
88
|
+
return relativePath || DEFAULT_FILE;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inferPhaseFromArgv() {
|
|
95
|
+
const knownPhases = new Set(['build', 'dev', 'preview']);
|
|
96
|
+
for (const arg of process.argv.slice(2)) {
|
|
97
|
+
if (knownPhases.has(arg)) {
|
|
98
|
+
return arg;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return DEFAULT_PHASE;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractFileFromMessage(message) {
|
|
105
|
+
const match = String(message || '').match(/\bFile:\s+([^\n]+)/);
|
|
106
|
+
return match ? match[1].trim() : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatHintUrl(code) {
|
|
110
|
+
const slug = String(code || 'CLI_ERROR')
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
113
|
+
.replace(/^-+|-+$/g, '');
|
|
114
|
+
return `${DEFAULT_HINT_BASE}#${slug || 'cli-error'}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function normalizeErrorMessagePaths(message) {
|
|
118
|
+
return String(message || '')
|
|
119
|
+
.split('\n')
|
|
120
|
+
.map((line) => normalizeFileLinePath(line))
|
|
121
|
+
.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {unknown} err
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeError(err) {
|
|
128
|
+
if (err instanceof Error) {
|
|
129
|
+
return err;
|
|
130
|
+
}
|
|
131
|
+
return new Error(sanitizeErrorMessage(err));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {unknown} err
|
|
136
|
+
* @param {{ plain: boolean, color: boolean, debug: boolean }} mode
|
|
137
|
+
*/
|
|
138
|
+
export function formatErrorBlock(err, mode) {
|
|
139
|
+
const normalized = normalizeError(err);
|
|
140
|
+
const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown, file?: unknown, hint?: unknown }} */ (normalized);
|
|
141
|
+
const kind = sanitizeErrorMessage(maybe.kind || maybe.code || 'CLI_ERROR');
|
|
142
|
+
const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : inferPhaseFromArgv();
|
|
143
|
+
const code = maybe.code
|
|
144
|
+
? sanitizeErrorMessage(maybe.code)
|
|
145
|
+
: `${phase.toUpperCase().replace(/[^A-Z0-9]+/g, '_') || 'CLI'}_FAILED`;
|
|
146
|
+
const rawMessage = sanitizeErrorMessage(normalized.message || String(normalized));
|
|
147
|
+
const message = normalizeErrorMessagePaths(rawMessage);
|
|
148
|
+
const file = normalizePathForDisplay(
|
|
149
|
+
sanitizeErrorMessage(maybe.file || extractFileFromMessage(message) || DEFAULT_FILE)
|
|
150
|
+
);
|
|
151
|
+
const hint = sanitizeErrorMessage(maybe.hint || formatHintUrl(code));
|
|
152
|
+
|
|
153
|
+
const lines = [];
|
|
154
|
+
lines.push('[zenith] ERROR: Command failed');
|
|
155
|
+
lines.push(`[zenith] Error Kind: ${kind}`);
|
|
156
|
+
lines.push(`[zenith] Phase: ${phase || DEFAULT_PHASE}`);
|
|
157
|
+
lines.push(`[zenith] Code: ${code || 'CLI_FAILED'}`);
|
|
158
|
+
lines.push(`[zenith] File: ${file || DEFAULT_FILE}`);
|
|
159
|
+
lines.push(`[zenith] Hint: ${hint || formatHintUrl(code)}`);
|
|
160
|
+
lines.push(`[zenith] Message: ${message}`);
|
|
161
|
+
|
|
162
|
+
if (mode.debug && normalized.stack) {
|
|
163
|
+
lines.push('[zenith] Stack:');
|
|
164
|
+
lines.push(...String(normalized.stack).split('\n').slice(0, 20));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function containsAnsi(value) {
|
|
171
|
+
return /\x1b\[[0-9;]*m/.test(String(value || ''));
|
|
172
|
+
}
|