@zenithbuild/cli 0.5.0-beta.2.6 → 0.6.2

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.
@@ -245,6 +245,9 @@ function findNextKnownTag(source, registry, startIndex) {
245
245
  if (!registry.has(name)) {
246
246
  continue;
247
247
  }
248
+ if (isInsideExpressionScope(source, match.index)) {
249
+ continue;
250
+ }
248
251
  return {
249
252
  name,
250
253
  start: match.index,
@@ -256,6 +259,111 @@ function findNextKnownTag(source, registry, startIndex) {
256
259
  return null;
257
260
  }
258
261
 
262
+ /**
263
+ * Detect whether `index` is inside a `{ ... }` expression scope.
264
+ *
265
+ * This prevents component macro expansion inside embedded markup expressions,
266
+ * which must remain expression-local so the compiler can lower them safely.
267
+ *
268
+ * @param {string} source
269
+ * @param {number} index
270
+ * @returns {boolean}
271
+ */
272
+ function isInsideExpressionScope(source, index) {
273
+ let depth = 0;
274
+ let mode = 'code';
275
+ let escaped = false;
276
+ const lower = source.toLowerCase();
277
+
278
+ for (let i = 0; i < index; i++) {
279
+ if (mode === 'code') {
280
+ if (lower.startsWith('<script', i)) {
281
+ const close = lower.indexOf('</script>', i + 7);
282
+ if (close < 0 || close >= index) {
283
+ return false;
284
+ }
285
+ i = close + '</script>'.length - 1;
286
+ continue;
287
+ }
288
+ if (lower.startsWith('<style', i)) {
289
+ const close = lower.indexOf('</style>', i + 6);
290
+ if (close < 0 || close >= index) {
291
+ return false;
292
+ }
293
+ i = close + '</style>'.length - 1;
294
+ continue;
295
+ }
296
+ }
297
+
298
+ const ch = source[i];
299
+ const next = i + 1 < index ? source[i + 1] : '';
300
+
301
+ if (mode === 'line-comment') {
302
+ if (ch === '\n') {
303
+ mode = 'code';
304
+ }
305
+ continue;
306
+ }
307
+ if (mode === 'block-comment') {
308
+ if (ch === '*' && next === '/') {
309
+ mode = 'code';
310
+ i += 1;
311
+ }
312
+ continue;
313
+ }
314
+ if (mode === 'single-quote' || mode === 'double-quote' || mode === 'template') {
315
+ if (escaped) {
316
+ escaped = false;
317
+ continue;
318
+ }
319
+ if (ch === '\\') {
320
+ escaped = true;
321
+ continue;
322
+ }
323
+ if (
324
+ (mode === 'single-quote' && ch === "'") ||
325
+ (mode === 'double-quote' && ch === '"') ||
326
+ (mode === 'template' && ch === '`')
327
+ ) {
328
+ mode = 'code';
329
+ }
330
+ continue;
331
+ }
332
+
333
+ if (ch === '/' && next === '/') {
334
+ mode = 'line-comment';
335
+ i += 1;
336
+ continue;
337
+ }
338
+ if (ch === '/' && next === '*') {
339
+ mode = 'block-comment';
340
+ i += 1;
341
+ continue;
342
+ }
343
+ if (ch === "'") {
344
+ mode = 'single-quote';
345
+ continue;
346
+ }
347
+ if (ch === '"') {
348
+ mode = 'double-quote';
349
+ continue;
350
+ }
351
+ if (ch === '`') {
352
+ mode = 'template';
353
+ continue;
354
+ }
355
+ if (ch === '{') {
356
+ depth += 1;
357
+ continue;
358
+ }
359
+ if (ch === '}') {
360
+ depth = Math.max(0, depth - 1);
361
+ }
362
+ }
363
+
364
+ return depth > 0;
365
+ }
366
+
259
367
  /**
260
368
  * Find the matching </Name> for an opening tag, accounting for nested
261
369
  * tags with the same name.
@@ -2,9 +2,73 @@
2
2
  // ---------------------------------------------------------------------------
3
3
  // Shared validation and payload resolution logic for <script server> blocks.
4
4
 
5
- const NEW_KEYS = new Set(['data', 'load', 'prerender']);
5
+ const NEW_KEYS = new Set(['data', 'load', 'guard', 'prerender']);
6
6
  const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
7
- const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
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
+ }
8
72
 
9
73
  export function validateServerExports({ exports, filePath }) {
10
74
  const exportKeys = Object.keys(exports);
@@ -16,6 +80,7 @@ export function validateServerExports({ exports, filePath }) {
16
80
 
17
81
  const hasData = 'data' in exports;
18
82
  const hasLoad = 'load' in exports;
83
+ const hasGuard = 'guard' in exports;
19
84
 
20
85
  const hasNew = hasData || hasLoad;
21
86
  const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
@@ -47,6 +112,20 @@ export function validateServerExports({ exports, filePath }) {
47
112
  throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
48
113
  }
49
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
+ }
50
129
  }
51
130
 
52
131
  export function assertJsonSerializable(value, where = 'payload') {
@@ -110,37 +189,97 @@ export function assertJsonSerializable(value, where = 'payload') {
110
189
  walk(value, '$');
111
190
  }
112
191
 
113
- export async function resolveServerPayload({ exports, ctx, filePath }) {
192
+ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false }) {
114
193
  validateServerExports({ exports, filePath });
115
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
+ if (guardResult.kind === 'data') {
204
+ throw new Error(`[Zenith] ${filePath}: guard(ctx) returned data(payload) which is a critical invariant violation. guard() can only return allow(), redirect(), or deny(). Use load(ctx) for data injection.`);
205
+ }
206
+ assertValidRouteResultShape(
207
+ guardResult,
208
+ `${filePath}: guard(ctx) return`,
209
+ new Set(['allow', 'redirect', 'deny'])
210
+ );
211
+ trace.guard = guardResult.kind;
212
+ if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
213
+ return { result: guardResult, trace };
214
+ }
215
+ }
216
+
217
+ if (guardOnly) {
218
+ return { result: allow(), trace };
219
+ }
220
+
116
221
  let payload;
117
222
  if ('load' in exports) {
118
- payload = await exports.load(ctx);
119
- assertJsonSerializable(payload, `${filePath}: load(ctx) return`);
120
- return payload;
223
+ const loadRaw = await exports.load(ctx);
224
+ let loadResult = null;
225
+ if (isRouteResultLike(loadRaw)) {
226
+ loadResult = loadRaw;
227
+ assertValidRouteResultShape(
228
+ loadResult,
229
+ `${filePath}: load(ctx) return`,
230
+ new Set(['data', 'redirect', 'deny'])
231
+ );
232
+ } else {
233
+ assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
234
+ loadResult = data(loadRaw);
235
+ }
236
+ trace.load = loadResult.kind;
237
+ return { result: loadResult, trace };
121
238
  }
122
239
  if ('data' in exports) {
123
240
  payload = exports.data;
124
241
  assertJsonSerializable(payload, `${filePath}: data export`);
125
- return payload;
242
+ trace.load = 'data';
243
+ return { result: data(payload), trace };
126
244
  }
127
245
 
128
246
  // legacy fallback
129
247
  if ('ssr_data' in exports) {
130
248
  payload = exports.ssr_data;
131
249
  assertJsonSerializable(payload, `${filePath}: ssr_data export`);
132
- return payload;
250
+ trace.load = 'data';
251
+ return { result: data(payload), trace };
133
252
  }
134
253
  if ('props' in exports) {
135
254
  payload = exports.props;
136
255
  assertJsonSerializable(payload, `${filePath}: props export`);
137
- return payload;
256
+ trace.load = 'data';
257
+ return { result: data(payload), trace };
138
258
  }
139
259
  if ('ssr' in exports) {
140
260
  payload = exports.ssr;
141
261
  assertJsonSerializable(payload, `${filePath}: ssr export`);
142
- return payload;
262
+ trace.load = 'data';
263
+ return { result: data(payload), trace };
264
+ }
265
+
266
+ return { result: data({}), trace };
267
+ }
268
+
269
+ export async function resolveServerPayload({ exports, ctx, filePath }) {
270
+ const resolved = await resolveRouteResult({ exports, ctx, filePath });
271
+ if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
272
+ return {};
273
+ }
274
+
275
+ if (resolved.result.kind === 'data') {
276
+ return resolved.result.data;
277
+ }
278
+ if (resolved.result.kind === 'allow') {
279
+ return {};
143
280
  }
144
281
 
145
- return {};
282
+ throw new Error(
283
+ `[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`
284
+ );
146
285
  }
package/dist/ui/env.js CHANGED
@@ -10,6 +10,14 @@ function flagEnabled(value) {
10
10
  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
11
11
  }
12
12
 
13
+ function parseLogLevel(value) {
14
+ const normalized = String(value || '').trim().toLowerCase();
15
+ if (normalized === 'quiet' || normalized === 'verbose') {
16
+ return normalized;
17
+ }
18
+ return 'normal';
19
+ }
20
+
13
21
  /**
14
22
  * @param {{ env?: Record<string, string | undefined>, stdout?: { isTTY?: boolean } }} runtime
15
23
  */
@@ -21,10 +29,17 @@ export function getUiMode(runtime = process) {
21
29
  const noColor = env.NO_COLOR !== undefined && String(env.NO_COLOR).length >= 0;
22
30
  const forceColor = flagEnabled(env.FORCE_COLOR);
23
31
  const debug = flagEnabled(env.ZENITH_DEBUG);
32
+ let logLevel = parseLogLevel(env.ZENITH_LOG_LEVEL);
24
33
 
25
34
  const plain = noUi || ci || !tty;
26
35
  const color = !plain && !noColor && (forceColor || tty);
27
36
  const spinner = tty && !plain && !ci;
37
+ if (flagEnabled(env.ZENITH_DEV_TRACE)) {
38
+ logLevel = 'verbose';
39
+ }
40
+ if (debug && logLevel !== 'quiet') {
41
+ logLevel = 'verbose';
42
+ }
28
43
 
29
44
  return {
30
45
  plain,
@@ -32,7 +47,8 @@ export function getUiMode(runtime = process) {
32
47
  tty,
33
48
  ci,
34
49
  spinner,
35
- debug
50
+ debug,
51
+ logLevel
36
52
  };
37
53
  }
38
54
 
package/dist/ui/format.js CHANGED
@@ -1,50 +1,78 @@
1
- /**
2
- * Deterministic text formatters for CLI UX.
3
- */
4
-
1
+ import pc from 'picocolors';
5
2
  import { relative, sep } from 'node:path';
6
3
 
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'
4
+ const DEFAULT_PHASE = 'cli';
5
+ const DEFAULT_FILE = '.';
6
+ const DEFAULT_HINT_BASE = 'https://github.com/zenithbuild/zenith/blob/main/zenith-cli/CLI_CONTRACT.md';
7
+ const PREFIX = '[zenith]';
8
+ const TAG_WIDTH = 6;
9
+
10
+ const TAG_COLORS = {
11
+ DEV: (colors, value) => colors.cyan(value),
12
+ BUILD: (colors, value) => colors.blue(value),
13
+ HMR: (colors, value) => colors.magenta(value),
14
+ ROUTER: (colors, value) => colors.cyan(value),
15
+ CSS: (colors, value) => colors.yellow(value),
16
+ OK: (colors, value) => colors.green(value),
17
+ WARN: (colors, value) => colors.bold(colors.yellow(value)),
18
+ ERR: (colors, value) => colors.bold(colors.red(value))
15
19
  };
16
20
 
17
- function colorize(mode, token, text) {
21
+ function getColors(mode) {
22
+ return pc.createColors(Boolean(mode?.color));
23
+ }
24
+
25
+ export function formatPrefix(mode) {
26
+ return mode.color ? getColors(mode).dim(PREFIX) : PREFIX;
27
+ }
28
+
29
+ function colorizeTag(mode, tag) {
30
+ const padded = String(tag || '').padEnd(TAG_WIDTH, ' ');
18
31
  if (!mode.color) {
19
- return text;
32
+ return padded;
20
33
  }
21
- return `${ANSI[token]}${text}${ANSI.reset}`;
34
+ const colors = getColors(mode);
35
+ const colorizer = TAG_COLORS[tag] || ((_colors, value) => colors.white(value));
36
+ return colorizer(colors, padded);
22
37
  }
23
38
 
24
- export function formatHeading(mode, text) {
25
- const label = mode.plain ? 'ZENITH CLI' : colorize(mode, 'bold', 'Zenith CLI');
26
- return `${label} ${text}`.trim();
39
+ function colorizeGlyph(mode, glyph, tag) {
40
+ if (!mode.color) {
41
+ return glyph;
42
+ }
43
+ const colors = getColors(mode);
44
+ const colorizer = TAG_COLORS[tag] || ((_colors, value) => value);
45
+ return colorizer(colors, glyph);
27
46
  }
28
47
 
29
- export function formatStep(mode, text) {
30
- if (mode.plain) {
31
- return `[zenith] INFO: ${text}`;
32
- }
33
- const bullet = colorize(mode, 'cyan', '');
34
- return `[zenith] ${bullet} ${text}`;
48
+ export function formatLine(mode, { glyph = '•', tag = 'DEV', text = '' }) {
49
+ return `${formatPrefix(mode)} ${colorizeGlyph(mode, glyph, tag)} ${colorizeTag(mode, tag)} ${String(text || '')}`;
50
+ }
51
+
52
+ export function formatStep(mode, text, tag = 'BUILD') {
53
+ return formatLine(mode, { glyph: '•', tag, text });
54
+ }
55
+
56
+ export function formatHint(mode, text) {
57
+ const body = ` hint: ${String(text || '').trim()}`;
58
+ return mode.color ? getColors(mode).dim(body) : body;
59
+ }
60
+
61
+ export function formatHeading(mode, text) {
62
+ const label = mode.color ? getColors(mode).bold('Zenith CLI') : 'Zenith CLI';
63
+ return `${label} ${String(text || '').trim()}`.trim();
35
64
  }
36
65
 
37
- export function formatSummaryTable(mode, rows) {
66
+ export function formatSummaryTable(mode, rows, tag = 'BUILD') {
38
67
  if (!Array.isArray(rows) || rows.length === 0) {
39
68
  return '';
40
69
  }
41
- const maxLabel = rows.reduce((acc, row) => Math.max(acc, String(row.label || '').length), 0);
42
70
  return rows
43
- .map((row) => {
44
- const label = String(row.label || '').padEnd(maxLabel, ' ');
45
- const value = String(row.value || '');
46
- return `[zenith] ${label} : ${value}`;
47
- })
71
+ .map((row) => formatLine(mode, {
72
+ glyph: '',
73
+ tag,
74
+ text: `${String(row.label || '')}: ${String(row.value || '')}`
75
+ }))
48
76
  .join('\n');
49
77
  }
50
78
 
@@ -62,21 +90,53 @@ function normalizeFileLinePath(line) {
62
90
 
63
91
  const prefix = match[1];
64
92
  const filePath = match[2].trim();
65
- if (!filePath.startsWith('/') && !/^[A-Za-z]:\\/.test(filePath)) {
66
- return line;
93
+ const normalized = normalizePathForDisplay(filePath);
94
+ return `${prefix}${normalized}`;
95
+ }
96
+
97
+ function normalizePathForDisplay(filePath) {
98
+ const value = String(filePath || '').trim();
99
+ if (!value) {
100
+ return DEFAULT_FILE;
101
+ }
102
+ if (!value.startsWith('/') && !/^[A-Za-z]:\\/.test(value)) {
103
+ return value;
67
104
  }
68
105
 
69
106
  const cwd = process.cwd();
70
107
  const cwdWithSep = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`;
71
- if (filePath === cwd) {
72
- return `${prefix}.`;
108
+ if (value === cwd) {
109
+ return DEFAULT_FILE;
73
110
  }
74
- if (filePath.startsWith(cwdWithSep)) {
75
- const relativePath = relative(cwd, filePath).replaceAll('\\', '/');
76
- return `${prefix}${relativePath || '.'}`;
111
+ if (value.startsWith(cwdWithSep)) {
112
+ const relativePath = relative(cwd, value).replaceAll('\\', '/');
113
+ return relativePath || DEFAULT_FILE;
77
114
  }
78
115
 
79
- return line;
116
+ return value;
117
+ }
118
+
119
+ function inferPhaseFromArgv() {
120
+ const knownPhases = new Set(['build', 'dev', 'preview']);
121
+ for (const arg of process.argv.slice(2)) {
122
+ if (knownPhases.has(arg)) {
123
+ return arg;
124
+ }
125
+ }
126
+ return DEFAULT_PHASE;
127
+ }
128
+
129
+ function extractFileFromMessage(message) {
130
+ const match = String(message || '').match(/\bFile:\s+([^\n]+)/);
131
+ return match ? match[1].trim() : '';
132
+ }
133
+
134
+ function formatHintUrl(code) {
135
+ const slug = String(code || 'CLI_ERROR')
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9]+/g, '-')
138
+ .replace(/^-+|-+$/g, '');
139
+ return `${DEFAULT_HINT_BASE}#${slug || 'cli-error'}`;
80
140
  }
81
141
 
82
142
  export function normalizeErrorMessagePaths(message) {
@@ -96,32 +156,49 @@ export function normalizeError(err) {
96
156
  return new Error(sanitizeErrorMessage(err));
97
157
  }
98
158
 
159
+ function firstMeaningfulLine(text) {
160
+ return String(text || '')
161
+ .split('\n')
162
+ .map((line) => line.trim())
163
+ .find((line) => line.length > 0) || '';
164
+ }
165
+
99
166
  /**
100
167
  * @param {unknown} err
101
- * @param {{ plain: boolean, color: boolean, debug: boolean }} mode
168
+ * @param {{ plain: boolean, color: boolean, debug?: boolean, logLevel?: string }} mode
102
169
  */
103
170
  export function formatErrorBlock(err, mode) {
104
171
  const normalized = normalizeError(err);
105
- const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown }} */ (normalized);
106
- const kind = sanitizeErrorMessage(maybe.kind || maybe.code || 'CLI_ERROR');
107
- const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : '';
108
- const code = maybe.code ? sanitizeErrorMessage(maybe.code) : '';
172
+ const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown, file?: unknown, hint?: unknown }} */ (normalized);
173
+ const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : inferPhaseFromArgv();
174
+ const code = maybe.code
175
+ ? sanitizeErrorMessage(maybe.code)
176
+ : `${phase.toUpperCase().replace(/[^A-Z0-9]+/g, '_') || 'CLI'}_FAILED`;
109
177
  const rawMessage = sanitizeErrorMessage(normalized.message || String(normalized));
110
178
  const message = normalizeErrorMessagePaths(rawMessage);
179
+ const compactMessage = firstMeaningfulLine(message) || 'Command failed';
180
+ const file = normalizePathForDisplay(
181
+ sanitizeErrorMessage(maybe.file || extractFileFromMessage(message) || DEFAULT_FILE)
182
+ );
183
+ const hint = sanitizeErrorMessage(maybe.hint || formatHintUrl(code));
184
+
185
+ if (mode.logLevel !== 'verbose' && !mode.debug) {
186
+ return [
187
+ formatLine(mode, { glyph: '✖', tag: 'ERR', text: compactMessage }),
188
+ formatHint(mode, hint)
189
+ ].join('\n');
190
+ }
111
191
 
112
192
  const lines = [];
113
- lines.push('[zenith] ERROR: Command failed');
114
- lines.push(`[zenith] Error Kind: ${kind}`);
115
- if (phase) {
116
- lines.push(`[zenith] Phase: ${phase}`);
117
- }
118
- if (code) {
119
- lines.push(`[zenith] Code: ${code}`);
120
- }
121
- lines.push(`[zenith] Message: ${message}`);
193
+ lines.push(formatLine(mode, { glyph: '✖', tag: 'ERR', text: compactMessage }));
194
+ lines.push(formatHint(mode, hint || formatHintUrl(code)));
195
+ lines.push(`${formatPrefix(mode)} code: ${code || 'CLI_FAILED'}`);
196
+ lines.push(`${formatPrefix(mode)} phase: ${phase || DEFAULT_PHASE}`);
197
+ lines.push(`${formatPrefix(mode)} file: ${file || DEFAULT_FILE}`);
198
+ lines.push(`${formatPrefix(mode)} detail: ${message}`);
122
199
 
123
200
  if (mode.debug && normalized.stack) {
124
- lines.push('[zenith] Stack:');
201
+ lines.push(`${formatPrefix(mode)} stack:`);
125
202
  lines.push(...String(normalized.stack).split('\n').slice(0, 20));
126
203
  }
127
204