@thedesignagent/mcp 0.2.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -5
- package/dist/auth-capture.js +245 -110
- package/dist/auth-store.d.ts +1 -0
- package/dist/auth-store.js +7 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +771 -0
- package/dist/credentials.d.ts +8 -0
- package/dist/credentials.js +44 -0
- package/dist/index.js +65 -44
- package/dist/screenshot.d.ts +1 -0
- package/package.json +8 -5
package/dist/cli.js
ADDED
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* thedesignagent: TheDesignAgent from a terminal, a headless agent run or CI.
|
|
4
|
+
*
|
|
5
|
+
* check <url|file> Score a page (screenshot) or a source file. Exit 1 below --threshold.
|
|
6
|
+
* brief "<task>" Write a build brief to .thedesignagent-brief.md for any agent to read.
|
|
7
|
+
* login [key] Save your tda_ API key to ~/.thedesignagent/credentials.
|
|
8
|
+
*
|
|
9
|
+
* Exit codes: 0 ok · 1 below threshold · 2 error · 3 page is behind login (nothing charged).
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { parseArgs } from 'node:util';
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
import { captureScreenshot } from './screenshot.js';
|
|
19
|
+
import { loadStoredAuth } from './auth-store.js';
|
|
20
|
+
import { loadApiKey, looksLikeApiKey, saveApiKey, MISSING_KEY_HELP } from './credentials.js';
|
|
21
|
+
const API_BASE = process.env.THEDESIGNAGENT_API_BASE ?? 'https://bxqifcmkydlsgwgcpqda.supabase.co/functions/v1';
|
|
22
|
+
const EDGE_TIMEOUT_MS = 280_000;
|
|
23
|
+
const MAX_ARTIFACT_CHARS = 60_000;
|
|
24
|
+
const BRIEF_FILE = '.thedesignagent-brief.md';
|
|
25
|
+
const EXIT = { ok: 0, belowThreshold: 1, error: 2, authRequired: 3 };
|
|
26
|
+
const USAGE = `Usage: thedesignagent <command> [options]
|
|
27
|
+
|
|
28
|
+
Commands:
|
|
29
|
+
check [url|file] Score a page or a source file; with no target, every page in .thedesignagent
|
|
30
|
+
brief "<task>" Write a build brief for the screen you're about to build
|
|
31
|
+
login [key] Save your API key (or pipe it on stdin)
|
|
32
|
+
|
|
33
|
+
check options:
|
|
34
|
+
--task <text> What the screen is for (default: a generic review)
|
|
35
|
+
--checks <list> visual, ux or visual,ux (default: visual for a URL, ux for a file)
|
|
36
|
+
--code <file> Source of the page; needed for --checks ux on a URL
|
|
37
|
+
--threshold <n> Exit 1 if a check's overall score is below n (0-10)
|
|
38
|
+
--base-url <url> With no target: where the configured pages live (e.g. a preview deploy)
|
|
39
|
+
--changed Check only the pages this branch changed (Next.js app/ routes)
|
|
40
|
+
--pr-comment In GitHub Actions: post the results as one PR comment, updated on every run
|
|
41
|
+
--base <ref> Branch to diff against with --changed (default: origin/main, or the PR base in GitHub Actions)
|
|
42
|
+
--json Print the result as JSON
|
|
43
|
+
|
|
44
|
+
brief options:
|
|
45
|
+
--out <file> Where to write the brief (default: ${BRIEF_FILE}; "-" for stdout)
|
|
46
|
+
--project-model <file> Project model JSON, for a project's first brief
|
|
47
|
+
--json Print the raw result as JSON
|
|
48
|
+
|
|
49
|
+
Both use the project_id in .thedesignagent, or --project <id>, or THEDESIGNAGENT_PROJECT_ID.
|
|
50
|
+
|
|
51
|
+
Exit codes: 0 ok, 1 below threshold, 2 error, 3 page is behind login (nothing charged).`;
|
|
52
|
+
// ---- Small helpers --------------------------------------------------------
|
|
53
|
+
class CliError extends Error {
|
|
54
|
+
code;
|
|
55
|
+
constructor(message, code = EXIT.error) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.code = code;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function version() {
|
|
61
|
+
try {
|
|
62
|
+
return createRequire(import.meta.url)('../package.json').version;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function requireKey() {
|
|
69
|
+
const { key } = loadApiKey();
|
|
70
|
+
if (!key)
|
|
71
|
+
throw new CliError(MISSING_KEY_HELP);
|
|
72
|
+
return key;
|
|
73
|
+
}
|
|
74
|
+
function gitRoot() {
|
|
75
|
+
try {
|
|
76
|
+
return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let cachedConfig;
|
|
83
|
+
// .thedesignagent in the working directory or the repo root.
|
|
84
|
+
function loadConfig() {
|
|
85
|
+
if (cachedConfig !== undefined)
|
|
86
|
+
return cachedConfig;
|
|
87
|
+
cachedConfig = null;
|
|
88
|
+
for (const dir of [process.cwd(), gitRoot()]) {
|
|
89
|
+
if (!dir)
|
|
90
|
+
continue;
|
|
91
|
+
const file = join(dir, '.thedesignagent');
|
|
92
|
+
if (!existsSync(file) || !statSync(file).isFile())
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
cachedConfig = { config: JSON.parse(readFileSync(file, 'utf-8')), dir };
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new CliError(`${file} is not valid JSON. Expected { "project_id": "proj_..." }.`);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
return cachedConfig;
|
|
103
|
+
}
|
|
104
|
+
function resolveProject(flag) {
|
|
105
|
+
const explicit = flag ?? process.env.THEDESIGNAGENT_PROJECT_ID;
|
|
106
|
+
if (explicit)
|
|
107
|
+
return { project_id: explicit };
|
|
108
|
+
const id = loadConfig()?.config.project_id;
|
|
109
|
+
if (id)
|
|
110
|
+
return { project_id: id };
|
|
111
|
+
// Same recipe as the agent skills. CI clones often use a different remote
|
|
112
|
+
// URL (https vs ssh), which hashes differently, so commit .thedesignagent.
|
|
113
|
+
try {
|
|
114
|
+
const remote = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
115
|
+
if (remote)
|
|
116
|
+
return { repo_hash: createHash('sha256').update(remote).digest('hex') };
|
|
117
|
+
}
|
|
118
|
+
catch { /* not a git repo */ }
|
|
119
|
+
throw new CliError('No project found. Run this in a repo with a .thedesignagent file, or pass --project <id>.');
|
|
120
|
+
}
|
|
121
|
+
async function callEdge(path, body) {
|
|
122
|
+
let response;
|
|
123
|
+
try {
|
|
124
|
+
response = await fetch(`${API_BASE}/${path}`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { Authorization: `Bearer ${requireKey()}`, 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify(body),
|
|
128
|
+
signal: AbortSignal.timeout(EDGE_TIMEOUT_MS),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
if (e instanceof CliError)
|
|
133
|
+
throw e;
|
|
134
|
+
throw new CliError(`Couldn't reach TheDesignAgent: ${e instanceof Error ? e.message : String(e)}`);
|
|
135
|
+
}
|
|
136
|
+
if (response.ok)
|
|
137
|
+
return await response.json();
|
|
138
|
+
let err = {};
|
|
139
|
+
try {
|
|
140
|
+
err = await response.json();
|
|
141
|
+
}
|
|
142
|
+
catch { /* non-JSON error body */ }
|
|
143
|
+
const message = err.message ?? `HTTP ${response.status}`;
|
|
144
|
+
if (response.status === 401 || response.status === 403) {
|
|
145
|
+
throw new CliError(`Authentication failed: ${message}\nCheck your API key (THEDESIGNAGENT_API_KEY or "thedesignagent login").`);
|
|
146
|
+
}
|
|
147
|
+
if (response.status === 402)
|
|
148
|
+
throw new CliError(message);
|
|
149
|
+
if (response.status === 429)
|
|
150
|
+
throw new CliError(`Rate limit reached: ${message}`);
|
|
151
|
+
throw new CliError(`${path} failed (${response.status}): ${message}${err.detail ? `\nDetail: ${err.detail}` : ''}`);
|
|
152
|
+
}
|
|
153
|
+
function isUrl(target) {
|
|
154
|
+
return /^https?:\/\//i.test(target);
|
|
155
|
+
}
|
|
156
|
+
function readArtifact(path) {
|
|
157
|
+
const abs = resolve(path);
|
|
158
|
+
if (!existsSync(abs) || !statSync(abs).isFile())
|
|
159
|
+
throw new CliError(`File not found: ${path}`);
|
|
160
|
+
const text = readFileSync(abs, 'utf-8');
|
|
161
|
+
return text.length > MAX_ARTIFACT_CHARS ? text.slice(0, MAX_ARTIFACT_CHARS) + '\n/* truncated */' : text;
|
|
162
|
+
}
|
|
163
|
+
function parseChecks(raw, target) {
|
|
164
|
+
if (!raw)
|
|
165
|
+
return isUrl(target) ? ['visual'] : ['ux'];
|
|
166
|
+
const names = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
167
|
+
for (const n of names) {
|
|
168
|
+
if (n !== 'visual' && n !== 'ux')
|
|
169
|
+
throw new CliError(`Unknown check "${n}". Use visual, ux or visual,ux.`);
|
|
170
|
+
}
|
|
171
|
+
return [...new Set(names)];
|
|
172
|
+
}
|
|
173
|
+
function parseThreshold(raw) {
|
|
174
|
+
if (raw === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
if (raw.trim() === '')
|
|
177
|
+
throw new CliError('--threshold is empty. Pass a number from 0 to 10.');
|
|
178
|
+
const n = Number(raw);
|
|
179
|
+
if (!Number.isFinite(n) || n < 0 || n > 10)
|
|
180
|
+
throw new CliError(`--threshold must be a number from 0 to 10, got "${raw}".`);
|
|
181
|
+
return n;
|
|
182
|
+
}
|
|
183
|
+
function envAuthSeed() {
|
|
184
|
+
const raw = process.env.THEDESIGNAGENT_AUTH_SEED;
|
|
185
|
+
if (!raw)
|
|
186
|
+
return undefined;
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(raw);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new CliError('THEDESIGNAGENT_AUTH_SEED is not valid JSON.');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function defaultTask(target) {
|
|
195
|
+
return isUrl(target) ? `Review the page at ${new URL(target).pathname}` : `Review the UI in ${target}`;
|
|
196
|
+
}
|
|
197
|
+
function validateSpec(spec) {
|
|
198
|
+
if (isUrl(spec.target) && spec.checks.includes('ux') && !spec.code) {
|
|
199
|
+
throw new CliError(`The ux check reviews source code, not a screenshot. Pass the page's source with --code <file> (or "code" in .thedesignagent), or use --checks visual. (${spec.target})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function checkPage(spec, identity, json) {
|
|
203
|
+
const url = isUrl(spec.target) ? spec.target : undefined;
|
|
204
|
+
const artifact = url
|
|
205
|
+
? (spec.code ? readArtifact(spec.code) : `The rendered page at ${url}. Judge it from the screenshot.`)
|
|
206
|
+
: readArtifact(spec.target);
|
|
207
|
+
let screenshot;
|
|
208
|
+
if (url && spec.checks.includes('visual')) {
|
|
209
|
+
status(json, `Screenshotting ${url}…`);
|
|
210
|
+
const seed = envAuthSeed() ?? loadStoredAuth(url) ?? undefined;
|
|
211
|
+
const shot = await captureScreenshot(url, seed).catch((e) => {
|
|
212
|
+
throw new CliError(`Screenshot failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
213
|
+
});
|
|
214
|
+
if (shot.authRequired) {
|
|
215
|
+
return {
|
|
216
|
+
result: { target: spec.target, screenshot: null, authRequired: true, finalUrl: shot.finalUrl, outcomes: [] },
|
|
217
|
+
identity,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
screenshot = { base64: shot.base64, localPath: shot.localPath };
|
|
221
|
+
}
|
|
222
|
+
const outcomes = [];
|
|
223
|
+
let callIdentity = identity;
|
|
224
|
+
for (const check of spec.checks) {
|
|
225
|
+
status(json, `Running ${check} review${url ? ` of ${url}` : ''}…`);
|
|
226
|
+
const result = check === 'visual'
|
|
227
|
+
? await callEdge('visual', {
|
|
228
|
+
task: spec.task, artifact, ...callIdentity,
|
|
229
|
+
...(screenshot && { screenshot: screenshot.base64 }),
|
|
230
|
+
...(url && { render_url: url }),
|
|
231
|
+
})
|
|
232
|
+
: await callEdge('ux', { task: spec.task, artifact, ...callIdentity });
|
|
233
|
+
// Later calls use the project the first one resolved, so an unknown id
|
|
234
|
+
// or a repo_hash doesn't create a second project.
|
|
235
|
+
if (result.project_id)
|
|
236
|
+
callIdentity = { project_id: result.project_id };
|
|
237
|
+
const score = (check === 'visual' ? result.visual_score : result.ux_score) ?? NaN;
|
|
238
|
+
outcomes.push({
|
|
239
|
+
check,
|
|
240
|
+
score,
|
|
241
|
+
passed: spec.threshold === null ? null : score >= spec.threshold,
|
|
242
|
+
summary: result.summary,
|
|
243
|
+
scores: result.scores ?? {},
|
|
244
|
+
findings: result.findings ?? [],
|
|
245
|
+
recommendations: result.recommendations ?? [],
|
|
246
|
+
degradations: result.degradations ?? [],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
result: { target: spec.target, screenshot: screenshot?.localPath ?? null, authRequired: false, outcomes },
|
|
251
|
+
identity: callIdentity,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function loginHelp(r) {
|
|
255
|
+
return [
|
|
256
|
+
`${r.target} redirected to a login page (${r.finalUrl}). No review ran and nothing was charged.`,
|
|
257
|
+
'Save a session first:',
|
|
258
|
+
` npx -y --package=@thedesignagent/mcp thedesignagent-auth capture ${r.target}`,
|
|
259
|
+
'In CI, set THEDESIGNAGENT_AUTH_SEED to an AuthSeed object ({ "cookies": [...] }), or check a preview with protection bypass.',
|
|
260
|
+
].join('\n');
|
|
261
|
+
}
|
|
262
|
+
// Pages from .thedesignagent: { "check": { "base_url", "threshold", "pages": [...] } }.
|
|
263
|
+
function configuredSpecs(opts, thresholdFlag) {
|
|
264
|
+
const loaded = loadConfig();
|
|
265
|
+
const cfg = loaded?.config.check;
|
|
266
|
+
if (!cfg?.pages?.length) {
|
|
267
|
+
throw new CliError([
|
|
268
|
+
'Nothing to check. Pass a URL or file, or list pages in .thedesignagent:',
|
|
269
|
+
' { "project_id": "proj_...", "check": { "base_url": "http://localhost:3000", "threshold": 7,',
|
|
270
|
+
' "pages": [ { "path": "/checkout", "task": "Pay for the cart" } ] } }',
|
|
271
|
+
].join('\n'));
|
|
272
|
+
}
|
|
273
|
+
const base = opts['base-url'] ?? process.env.THEDESIGNAGENT_BASE_URL ?? cfg.base_url;
|
|
274
|
+
const flagChecks = opts.checks ? parseChecks(opts.checks, 'http://x') : undefined;
|
|
275
|
+
return cfg.pages.map((page, i) => {
|
|
276
|
+
if (!page?.path)
|
|
277
|
+
throw new CliError(`.thedesignagent check.pages[${i}] needs a "path".`);
|
|
278
|
+
const isAbsolute = isUrl(page.path);
|
|
279
|
+
if (!isAbsolute && !base) {
|
|
280
|
+
throw new CliError(`No base URL for "${page.path}". Set check.base_url in .thedesignagent, or pass --base-url.`);
|
|
281
|
+
}
|
|
282
|
+
const target = isAbsolute ? page.path : new URL(page.path, base).href;
|
|
283
|
+
const configThreshold = page.threshold ?? cfg.threshold;
|
|
284
|
+
return {
|
|
285
|
+
target,
|
|
286
|
+
task: page.task ?? defaultTask(target),
|
|
287
|
+
code: page.code && loaded ? resolve(loaded.dir, page.code) : undefined,
|
|
288
|
+
checks: flagChecks ?? page.checks ?? ['visual'],
|
|
289
|
+
threshold: thresholdFlag ?? (typeof configThreshold === 'number' ? configThreshold : null),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// ---- --changed: map a branch's changed files to the routes they render ----
|
|
294
|
+
const PAGE_FILE = /^page\.(tsx|jsx|ts|js|mdx)$/;
|
|
295
|
+
function changedFiles(base) {
|
|
296
|
+
const ref = base
|
|
297
|
+
?? (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'origin/main');
|
|
298
|
+
try {
|
|
299
|
+
return execSync(`git diff --name-only --diff-filter=d ${JSON.stringify(ref)}...HEAD`, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
300
|
+
.toString().split('\n').map(l => l.trim()).filter(Boolean);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
throw new CliError(`Couldn't diff against ${ref}. Fetch it first (in CI: actions/checkout with fetch-depth: 0), or pass --base <ref>.`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Next.js App Router: app/(group)/orders/page.tsx -> /orders. Dynamic
|
|
307
|
+
// segments ([id]) have no URL to visit, so they're skipped unless
|
|
308
|
+
// .thedesignagent lists a concrete page for them.
|
|
309
|
+
function routeForAppDir(segments) {
|
|
310
|
+
const parts = [];
|
|
311
|
+
for (const seg of segments) {
|
|
312
|
+
if (seg.startsWith('(') && seg.endsWith(')'))
|
|
313
|
+
continue;
|
|
314
|
+
if (seg.startsWith('@') || seg.startsWith('_'))
|
|
315
|
+
return null;
|
|
316
|
+
if (seg.startsWith('['))
|
|
317
|
+
return null;
|
|
318
|
+
parts.push(seg);
|
|
319
|
+
}
|
|
320
|
+
return '/' + parts.join('/');
|
|
321
|
+
}
|
|
322
|
+
// For each changed file, the nearest enclosing App Router page: a change to
|
|
323
|
+
// app/orders/_components/table.tsx re-checks /orders.
|
|
324
|
+
function pagesForChangedFiles(root, files) {
|
|
325
|
+
const found = new Map();
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
if (!/\.(tsx|jsx|ts|js|mdx|css|scss)$/.test(file))
|
|
328
|
+
continue;
|
|
329
|
+
const parts = file.split('/');
|
|
330
|
+
const appIdx = parts.findIndex((p, i) => p === 'app' && (i === 0 || (i === 1 && parts[0] === 'src')));
|
|
331
|
+
if (appIdx === -1)
|
|
332
|
+
continue;
|
|
333
|
+
for (let end = parts.length - 1; end > appIdx; end--) {
|
|
334
|
+
const dir = parts.slice(0, end);
|
|
335
|
+
const page = ['tsx', 'jsx', 'ts', 'js', 'mdx'].map(ext => [...dir, `page.${ext}`].join('/'))
|
|
336
|
+
.find(candidate => existsSync(join(root, candidate)));
|
|
337
|
+
if (!page)
|
|
338
|
+
continue;
|
|
339
|
+
const route = routeForAppDir(dir.slice(appIdx + 1));
|
|
340
|
+
if (route && !found.has(route))
|
|
341
|
+
found.set(route, { route, pageFile: page });
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return [...found.values()];
|
|
346
|
+
}
|
|
347
|
+
function changedSpecs(opts, thresholdFlag) {
|
|
348
|
+
const root = gitRoot();
|
|
349
|
+
if (!root)
|
|
350
|
+
throw new CliError('--changed needs a git repository.');
|
|
351
|
+
const files = changedFiles(opts.base);
|
|
352
|
+
const loaded = loadConfig();
|
|
353
|
+
const cfg = loaded?.config.check;
|
|
354
|
+
const base = opts['base-url'] ?? process.env.THEDESIGNAGENT_BASE_URL ?? cfg?.base_url;
|
|
355
|
+
if (!base)
|
|
356
|
+
throw new CliError('No base URL. Pass --base-url, set THEDESIGNAGENT_BASE_URL, or set check.base_url in .thedesignagent.');
|
|
357
|
+
const flagChecks = opts.checks ? parseChecks(opts.checks, 'http://x') : undefined;
|
|
358
|
+
const changed = new Set(files);
|
|
359
|
+
const specs = new Map();
|
|
360
|
+
const add = (route, page, pageFile) => {
|
|
361
|
+
const target = isUrl(route) ? route : new URL(route, base).href;
|
|
362
|
+
if (specs.has(target))
|
|
363
|
+
return;
|
|
364
|
+
const t = page?.threshold ?? cfg?.threshold;
|
|
365
|
+
specs.set(target, {
|
|
366
|
+
target,
|
|
367
|
+
task: page?.task ?? defaultTask(target),
|
|
368
|
+
code: page?.code && loaded ? resolve(loaded.dir, page.code) : pageFile ? join(root, pageFile) : undefined,
|
|
369
|
+
checks: flagChecks ?? page?.checks ?? ['visual'],
|
|
370
|
+
threshold: thresholdFlag ?? (typeof t === 'number' ? t : null),
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
// Listed pages whose source changed, then routes found from the diff
|
|
374
|
+
// (using the listed page's task and threshold when the route matches).
|
|
375
|
+
for (const page of cfg?.pages ?? []) {
|
|
376
|
+
if (page.code && changed.has(page.code.replace(/^\.\//, '')))
|
|
377
|
+
add(page.path, page, undefined);
|
|
378
|
+
}
|
|
379
|
+
for (const { route, pageFile } of pagesForChangedFiles(root, files)) {
|
|
380
|
+
add(route, cfg?.pages?.find(p => p.path === route), pageFile);
|
|
381
|
+
}
|
|
382
|
+
return [...specs.values()];
|
|
383
|
+
}
|
|
384
|
+
async function runCheck(target, opts) {
|
|
385
|
+
const thresholdFlag = parseThreshold(opts.threshold);
|
|
386
|
+
if (target && opts.changed)
|
|
387
|
+
throw new CliError('Pass a target or --changed, not both.');
|
|
388
|
+
const specs = opts.changed
|
|
389
|
+
? changedSpecs(opts, thresholdFlag)
|
|
390
|
+
: target
|
|
391
|
+
? [{
|
|
392
|
+
target,
|
|
393
|
+
task: opts.task ?? defaultTask(target),
|
|
394
|
+
code: opts.code,
|
|
395
|
+
checks: parseChecks(opts.checks, target),
|
|
396
|
+
threshold: thresholdFlag,
|
|
397
|
+
}]
|
|
398
|
+
: configuredSpecs(opts, thresholdFlag);
|
|
399
|
+
specs.forEach(validateSpec);
|
|
400
|
+
if (!specs.length) {
|
|
401
|
+
if (opts['pr-comment'])
|
|
402
|
+
await postPrComment(`${COMMENT_MARKER}\n### TheDesignAgent design check: no UI pages changed`, opts.pr);
|
|
403
|
+
if (opts.json)
|
|
404
|
+
console.log(JSON.stringify({ ok: true, passed: true, project_id: null, pages: [] }, null, 2));
|
|
405
|
+
else
|
|
406
|
+
console.log('No changed UI pages to check.');
|
|
407
|
+
return EXIT.ok;
|
|
408
|
+
}
|
|
409
|
+
let identity = resolveProject(opts.project);
|
|
410
|
+
requireKey();
|
|
411
|
+
const results = [];
|
|
412
|
+
for (const spec of specs) {
|
|
413
|
+
const { result, identity: next } = await checkPage(spec, identity, opts.json);
|
|
414
|
+
identity = next;
|
|
415
|
+
results.push(result);
|
|
416
|
+
}
|
|
417
|
+
const failed = results.some(r => r.outcomes.some(o => o.passed === false));
|
|
418
|
+
const loginBlocked = results.filter(r => r.authRequired);
|
|
419
|
+
const anyThreshold = specs.some(s => s.threshold !== null);
|
|
420
|
+
const passed = anyThreshold ? !failed && loginBlocked.length === 0 : null;
|
|
421
|
+
if (opts.json) {
|
|
422
|
+
const pageJson = (r, spec) => ({
|
|
423
|
+
target: r.target,
|
|
424
|
+
threshold: spec.threshold,
|
|
425
|
+
screenshot: r.screenshot,
|
|
426
|
+
...(r.authRequired && { error: 'auth_required', message: loginHelp(r), final_url: r.finalUrl }),
|
|
427
|
+
checks: r.outcomes,
|
|
428
|
+
});
|
|
429
|
+
const body = target
|
|
430
|
+
// One target keeps the flat shape.
|
|
431
|
+
? { ok: !results[0].authRequired, passed, project_id: identity.project_id ?? null, ...pageJson(results[0], specs[0]) }
|
|
432
|
+
: { ok: loginBlocked.length === 0, passed, project_id: identity.project_id ?? null, pages: results.map((r, i) => pageJson(r, specs[i])) };
|
|
433
|
+
console.log(JSON.stringify(body, null, 2));
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
results.forEach((r, i) => {
|
|
437
|
+
if (r.authRequired)
|
|
438
|
+
console.error(loginHelp(r));
|
|
439
|
+
else
|
|
440
|
+
console.log(formatCheck(r.target, r.outcomes, specs[i].threshold, r.screenshot ?? undefined));
|
|
441
|
+
if (i < results.length - 1)
|
|
442
|
+
console.log('');
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
results.forEach((r, i) => { if (!r.authRequired)
|
|
446
|
+
writeStepSummary(r.target, r.outcomes, specs[i].threshold); });
|
|
447
|
+
if (opts['pr-comment'])
|
|
448
|
+
await postPrComment(prCommentBody(results, specs), opts.pr);
|
|
449
|
+
if (failed)
|
|
450
|
+
return EXIT.belowThreshold;
|
|
451
|
+
if (loginBlocked.length)
|
|
452
|
+
return EXIT.authRequired;
|
|
453
|
+
return EXIT.ok;
|
|
454
|
+
}
|
|
455
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
|
|
456
|
+
const severityRank = (s) => { const i = SEVERITY_ORDER.indexOf(s); return i === -1 ? SEVERITY_ORDER.length : i; };
|
|
457
|
+
function fmt(n) {
|
|
458
|
+
return n === null || n === undefined || Number.isNaN(n) ? 'n/a' : n.toFixed(1);
|
|
459
|
+
}
|
|
460
|
+
function verdict(o, threshold) {
|
|
461
|
+
if (o.passed === null)
|
|
462
|
+
return '';
|
|
463
|
+
return o.passed ? ` pass (threshold ${threshold})` : ` FAIL (threshold ${threshold})`;
|
|
464
|
+
}
|
|
465
|
+
function formatCheck(target, outcomes, threshold, shotPath) {
|
|
466
|
+
const lines = [`TheDesignAgent · ${target}`, ''];
|
|
467
|
+
for (const o of outcomes) {
|
|
468
|
+
lines.push(`${o.check} ${fmt(o.score)}/10${verdict(o, threshold)}`);
|
|
469
|
+
lines.push(' ' + Object.entries(o.scores).map(([k, v]) => `${k} ${fmt(v)}`).join(' · '));
|
|
470
|
+
if (o.degradations.length)
|
|
471
|
+
lines.push(` degraded: ${o.degradations.join(', ')}`);
|
|
472
|
+
lines.push('', ` ${o.summary}`, '');
|
|
473
|
+
const top = [...o.findings]
|
|
474
|
+
.sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
|
|
475
|
+
.slice(0, 5);
|
|
476
|
+
for (const f of top) {
|
|
477
|
+
const loc = f.location ? ` (${f.location})` : '';
|
|
478
|
+
lines.push(` [${f.severity}] ${f.dimension ?? f.track ?? 'general'}: ${f.description}${loc}`);
|
|
479
|
+
}
|
|
480
|
+
if (o.recommendations.length) {
|
|
481
|
+
lines.push('', ' Fix first:');
|
|
482
|
+
for (const r of o.recommendations.slice(0, 3))
|
|
483
|
+
lines.push(` - ${r.change}`);
|
|
484
|
+
}
|
|
485
|
+
lines.push('');
|
|
486
|
+
}
|
|
487
|
+
if (shotPath)
|
|
488
|
+
lines.push(`Screenshot: ${shotPath}`);
|
|
489
|
+
return lines.join('\n').trimEnd();
|
|
490
|
+
}
|
|
491
|
+
// GitHub Actions shows this file on the run's summary page.
|
|
492
|
+
function writeStepSummary(target, outcomes, threshold) {
|
|
493
|
+
const path = process.env.GITHUB_STEP_SUMMARY;
|
|
494
|
+
if (!path)
|
|
495
|
+
return;
|
|
496
|
+
const rows = outcomes.map(o => {
|
|
497
|
+
const result = o.passed === null ? '' : o.passed ? 'pass' : 'fail';
|
|
498
|
+
return `| ${o.check} | ${fmt(o.score)} | ${result} |`;
|
|
499
|
+
});
|
|
500
|
+
const findings = outcomes.flatMap(o => o.findings
|
|
501
|
+
.filter(f => f.severity === 'critical' || f.severity === 'high')
|
|
502
|
+
.map(f => `- **${o.check} · ${f.severity}**: ${f.description}`));
|
|
503
|
+
const md = [
|
|
504
|
+
`### TheDesignAgent: ${target}`,
|
|
505
|
+
'',
|
|
506
|
+
`| Check | Score | ${threshold === null ? '' : `Threshold ${threshold}`} |`,
|
|
507
|
+
'| --- | --- | --- |',
|
|
508
|
+
...rows,
|
|
509
|
+
'',
|
|
510
|
+
...(findings.length ? ['**Critical and high findings**', '', ...findings, ''] : []),
|
|
511
|
+
].join('\n');
|
|
512
|
+
try {
|
|
513
|
+
appendFileSync(path, md + '\n');
|
|
514
|
+
}
|
|
515
|
+
catch { /* summary is best-effort */ }
|
|
516
|
+
}
|
|
517
|
+
// ---- --pr-comment: one comment per pull request, updated on every run ----
|
|
518
|
+
const COMMENT_MARKER = '<!-- thedesignagent-check -->';
|
|
519
|
+
function prCommentBody(results, specs) {
|
|
520
|
+
const failed = results.some(r => r.outcomes.some(o => o.passed === false));
|
|
521
|
+
const blocked = results.filter(r => r.authRequired);
|
|
522
|
+
const headline = failed
|
|
523
|
+
? '❌ Below threshold'
|
|
524
|
+
: blocked.length ? `⚠️ ${blocked.length} page${blocked.length > 1 ? 's' : ''} couldn't be checked (login)`
|
|
525
|
+
: specs.some(s => s.threshold !== null) ? '✅ Passed' : 'Scores';
|
|
526
|
+
const pagePath = (target) => { try {
|
|
527
|
+
return new URL(target).pathname;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return target;
|
|
531
|
+
} };
|
|
532
|
+
const rows = [];
|
|
533
|
+
results.forEach((r, i) => {
|
|
534
|
+
if (r.authRequired) {
|
|
535
|
+
rows.push(`| \`${pagePath(r.target)}\` | | | ${specs[i].threshold ?? ''} | login required |`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
for (const o of r.outcomes) {
|
|
539
|
+
const result = o.passed === null ? '' : o.passed ? 'pass' : '**fail**';
|
|
540
|
+
rows.push(`| \`${pagePath(r.target)}\` | ${o.check} | ${fmt(o.score)} | ${specs[i].threshold ?? ''} | ${result} |`);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
const details = [];
|
|
544
|
+
for (const r of results) {
|
|
545
|
+
for (const o of r.outcomes) {
|
|
546
|
+
const top = [...o.findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity)).slice(0, 5);
|
|
547
|
+
if (!top.length && !o.recommendations.length)
|
|
548
|
+
continue;
|
|
549
|
+
details.push(`<details><summary><code>${pagePath(r.target)}</code> · ${o.check} ${fmt(o.score)}</summary>`, '', o.summary, '', ...top.map(f => `- **${f.severity}** ${f.dimension ?? f.track ?? 'general'}: ${f.description}${f.location ? ` (${f.location})` : ''}`), ...(o.recommendations.length ? ['', '**Fix first**', ...o.recommendations.slice(0, 3).map(rc => `- ${rc.change}`)] : []), '', '</details>');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return [
|
|
553
|
+
COMMENT_MARKER,
|
|
554
|
+
`### TheDesignAgent design check: ${headline}`,
|
|
555
|
+
'',
|
|
556
|
+
'| Page | Check | Score | Threshold | Result |',
|
|
557
|
+
'| --- | --- | --- | --- | --- |',
|
|
558
|
+
...rows,
|
|
559
|
+
'',
|
|
560
|
+
...details,
|
|
561
|
+
].join('\n');
|
|
562
|
+
}
|
|
563
|
+
function pullRequestNumber(flag) {
|
|
564
|
+
if (flag)
|
|
565
|
+
return Number(flag) || null;
|
|
566
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
567
|
+
if (!eventPath || !existsSync(eventPath))
|
|
568
|
+
return null;
|
|
569
|
+
try {
|
|
570
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
571
|
+
return event.pull_request?.number ?? event.number ?? null;
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Best-effort: a failed comment never changes the check's exit code.
|
|
578
|
+
async function postPrComment(body, prFlag) {
|
|
579
|
+
const token = process.env.GITHUB_TOKEN;
|
|
580
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
581
|
+
const pr = pullRequestNumber(prFlag);
|
|
582
|
+
if (!token || !repo || !pr) {
|
|
583
|
+
console.error('--pr-comment: skipped. It needs GITHUB_TOKEN, GITHUB_REPOSITORY and a pull_request event (or --pr <number>).');
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const api = process.env.GITHUB_API_URL ?? 'https://api.github.com';
|
|
587
|
+
const headers = {
|
|
588
|
+
Authorization: `Bearer ${token}`,
|
|
589
|
+
Accept: 'application/vnd.github+json',
|
|
590
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
591
|
+
'Content-Type': 'application/json',
|
|
592
|
+
};
|
|
593
|
+
try {
|
|
594
|
+
let existing = null;
|
|
595
|
+
for (let page = 1; page <= 10 && existing === null; page++) {
|
|
596
|
+
const res = await fetch(`${api}/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, { headers });
|
|
597
|
+
if (!res.ok)
|
|
598
|
+
throw new Error(`listing comments: HTTP ${res.status}`);
|
|
599
|
+
const comments = await res.json();
|
|
600
|
+
existing = comments.find(c => c.body?.startsWith(COMMENT_MARKER))?.id ?? null;
|
|
601
|
+
if (comments.length < 100)
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
const res = existing
|
|
605
|
+
? await fetch(`${api}/repos/${repo}/issues/comments/${existing}`, { method: 'PATCH', headers, body: JSON.stringify({ body }) })
|
|
606
|
+
: await fetch(`${api}/repos/${repo}/issues/${pr}/comments`, { method: 'POST', headers, body: JSON.stringify({ body }) });
|
|
607
|
+
if (!res.ok)
|
|
608
|
+
throw new Error(`${existing ? 'updating' : 'posting'} comment: HTTP ${res.status}`);
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
const hint = String(e).includes('403') ? ' Give the job "permissions: pull-requests: write".' : '';
|
|
612
|
+
console.error(`--pr-comment: couldn't comment on PR #${pr}: ${e instanceof Error ? e.message : String(e)}.${hint}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function status(json, msg) {
|
|
616
|
+
// Progress goes to stderr so --json stdout stays parseable.
|
|
617
|
+
if (!json && process.stderr.isTTY)
|
|
618
|
+
console.error(msg);
|
|
619
|
+
}
|
|
620
|
+
async function runBrief(task, opts) {
|
|
621
|
+
const identity = resolveProject(opts.project);
|
|
622
|
+
let projectModel;
|
|
623
|
+
if (opts.projectModel) {
|
|
624
|
+
try {
|
|
625
|
+
projectModel = JSON.parse(readFileSync(resolve(opts.projectModel), 'utf-8'));
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
throw new CliError(`Couldn't read --project-model: ${e instanceof Error ? e.message : String(e)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
status(opts.json, 'Getting a build brief…');
|
|
632
|
+
const result = await callEdge('discovery', {
|
|
633
|
+
task, ...identity, ...(projectModel && { projectModel }),
|
|
634
|
+
});
|
|
635
|
+
if (opts.json) {
|
|
636
|
+
console.log(JSON.stringify(result, null, 2));
|
|
637
|
+
return result.status === 'ok' ? EXIT.ok : EXIT.error;
|
|
638
|
+
}
|
|
639
|
+
if (result.status !== 'ok') {
|
|
640
|
+
const why = result.status === 'needs_setup'
|
|
641
|
+
? 'This project has no stored model yet.'
|
|
642
|
+
: 'The stored project model is out of date with your schema.';
|
|
643
|
+
console.error([
|
|
644
|
+
why,
|
|
645
|
+
'Run one brief from your coding agent (it scans the repo and builds the model),',
|
|
646
|
+
'or build the model yourself and pass it with --project-model <file>. The extraction task:',
|
|
647
|
+
'',
|
|
648
|
+
result.extraction_task ?? '(none returned)',
|
|
649
|
+
].join('\n'));
|
|
650
|
+
return EXIT.error;
|
|
651
|
+
}
|
|
652
|
+
const md = [
|
|
653
|
+
`# Build brief: ${task}`,
|
|
654
|
+
'',
|
|
655
|
+
`Project ${result.project_id}${result.active_job ? ` · active job: ${result.active_job}` : ''}`,
|
|
656
|
+
`Generated by TheDesignAgent ${new Date().toISOString().slice(0, 10)}. Follow it while building this screen.`,
|
|
657
|
+
'',
|
|
658
|
+
result.brief ?? '',
|
|
659
|
+
'',
|
|
660
|
+
].join('\n');
|
|
661
|
+
const out = opts.out ?? BRIEF_FILE;
|
|
662
|
+
if (out === '-') {
|
|
663
|
+
process.stdout.write(md);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
writeFileSync(resolve(out), md);
|
|
667
|
+
console.log(`Wrote ${out}. Point your agent at it, e.g. add "Read ${out} before building UI." to AGENTS.md or CLAUDE.md.`);
|
|
668
|
+
}
|
|
669
|
+
if (!identity.project_id && result.project_id) {
|
|
670
|
+
console.error(`Tip: save { "project_id": "${result.project_id}" } to .thedesignagent and commit it, so CI and every agent share this project.`);
|
|
671
|
+
}
|
|
672
|
+
return EXIT.ok;
|
|
673
|
+
}
|
|
674
|
+
// ---- login ----------------------------------------------------------------
|
|
675
|
+
async function readKeyInteractively() {
|
|
676
|
+
if (!process.stdin.isTTY) {
|
|
677
|
+
const chunks = [];
|
|
678
|
+
for await (const chunk of process.stdin)
|
|
679
|
+
chunks.push(chunk);
|
|
680
|
+
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
681
|
+
}
|
|
682
|
+
console.error('Get a key at https://thedesignagent.ai/dashboard/api-keys');
|
|
683
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
684
|
+
// Hide what's typed: the key is a secret and terminals get recorded.
|
|
685
|
+
const rlOut = rl;
|
|
686
|
+
let prompted = false;
|
|
687
|
+
rlOut._writeToOutput = (s) => {
|
|
688
|
+
if (!prompted) {
|
|
689
|
+
rlOut.output.write(s);
|
|
690
|
+
prompted = true;
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
rl.on('SIGINT', () => {
|
|
694
|
+
rl.close();
|
|
695
|
+
process.stderr.write('\n');
|
|
696
|
+
process.exit(130);
|
|
697
|
+
});
|
|
698
|
+
const key = await new Promise(res => rl.question('Paste your API key: ', res));
|
|
699
|
+
rl.close();
|
|
700
|
+
process.stderr.write('\n');
|
|
701
|
+
return key.trim();
|
|
702
|
+
}
|
|
703
|
+
async function runLogin(keyArg) {
|
|
704
|
+
const key = (keyArg ?? await readKeyInteractively()).trim();
|
|
705
|
+
if (!looksLikeApiKey(key)) {
|
|
706
|
+
throw new CliError('That doesn\'t look like a TheDesignAgent key. Keys start with tda_.');
|
|
707
|
+
}
|
|
708
|
+
const path = saveApiKey(key);
|
|
709
|
+
console.log(`Saved to ${path} (readable only by you).`);
|
|
710
|
+
if (process.env.THEDESIGNAGENT_API_KEY) {
|
|
711
|
+
console.error('Note: THEDESIGNAGENT_API_KEY is set in this shell and takes precedence over the saved key.');
|
|
712
|
+
}
|
|
713
|
+
return EXIT.ok;
|
|
714
|
+
}
|
|
715
|
+
// ---- main -----------------------------------------------------------------
|
|
716
|
+
async function main() {
|
|
717
|
+
const { values, positionals } = parseArgs({
|
|
718
|
+
allowPositionals: true,
|
|
719
|
+
options: {
|
|
720
|
+
task: { type: 'string' },
|
|
721
|
+
checks: { type: 'string' },
|
|
722
|
+
code: { type: 'string' },
|
|
723
|
+
threshold: { type: 'string' },
|
|
724
|
+
json: { type: 'boolean' },
|
|
725
|
+
project: { type: 'string' },
|
|
726
|
+
'base-url': { type: 'string' },
|
|
727
|
+
changed: { type: 'boolean' },
|
|
728
|
+
'pr-comment': { type: 'boolean' },
|
|
729
|
+
pr: { type: 'string' },
|
|
730
|
+
base: { type: 'string' },
|
|
731
|
+
out: { type: 'string' },
|
|
732
|
+
'project-model': { type: 'string' },
|
|
733
|
+
help: { type: 'boolean', short: 'h' },
|
|
734
|
+
version: { type: 'boolean', short: 'v' },
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
if (values.version) {
|
|
738
|
+
console.log(version());
|
|
739
|
+
return EXIT.ok;
|
|
740
|
+
}
|
|
741
|
+
const [command, ...rest] = positionals;
|
|
742
|
+
if (values.help || !command) {
|
|
743
|
+
console.log(USAGE);
|
|
744
|
+
return command || values.help ? EXIT.ok : EXIT.error;
|
|
745
|
+
}
|
|
746
|
+
switch (command) {
|
|
747
|
+
case 'check':
|
|
748
|
+
if (rest.length > 1)
|
|
749
|
+
throw new CliError('Usage: thedesignagent check [url|file] [--threshold n] [--json]');
|
|
750
|
+
return runCheck(rest[0], values);
|
|
751
|
+
case 'brief':
|
|
752
|
+
if (!rest.length)
|
|
753
|
+
throw new CliError('Usage: thedesignagent brief "<what you are about to build>"');
|
|
754
|
+
return runBrief(rest.join(' '), { ...values, projectModel: values['project-model'] });
|
|
755
|
+
case 'login':
|
|
756
|
+
return runLogin(rest[0]);
|
|
757
|
+
default:
|
|
758
|
+
throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Set exitCode rather than calling process.exit(): on macOS, stdout to a pipe
|
|
762
|
+
// is async, and exiting early truncates large --json output.
|
|
763
|
+
const wantsJson = process.argv.includes('--json');
|
|
764
|
+
main().then(code => { process.exitCode = code; }, (e) => {
|
|
765
|
+
// parseArgs throws TypeError with a readable message for bad flags.
|
|
766
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
767
|
+
if (wantsJson)
|
|
768
|
+
console.log(JSON.stringify({ ok: false, error: 'error', message }, null, 2));
|
|
769
|
+
console.error(message);
|
|
770
|
+
process.exitCode = e instanceof CliError ? e.code : EXIT.error;
|
|
771
|
+
});
|