catalyst-core 1.0.1 → 1.0.2-canary.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.
- package/bin/catalyst.js +105 -1
- package/dist/cli/README.md +119 -0
- package/dist/cli/appConfig.js +50 -0
- package/dist/cli/buildErrors.js +170 -0
- package/dist/cli/buildWarnings.js +28 -0
- package/dist/cli/devOutput.js +524 -0
- package/dist/cli/diagnostic.js +388 -0
- package/dist/cli/hints.js +69 -0
- package/dist/cli/package.json +3 -0
- package/dist/cli/spinner.js +141 -0
- package/dist/cli/theme.js +155 -0
- package/dist/errors/index.js +7 -3
- package/dist/errors/registry.js +8 -0
- package/dist/errors-index.json +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.jsx +1 -1
- package/dist/logger.js +61 -6
- package/dist/native/androidProject/app/build.gradle.kts +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +2 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +9 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashActivity.kt +65 -34
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/SplashOverlay.kt +109 -0
- package/dist/native/androidSetup.js +46 -57
- package/dist/native/buildAndroid/assets.js +22 -13
- package/dist/native/buildAndroid/build.js +98 -26
- package/dist/native/buildAndroid/config.js +43 -12
- package/dist/native/buildAndroid/index.js +49 -191
- package/dist/native/buildAppAndroid.js +19 -8
- package/dist/native/buildAppIos.js +35 -16
- package/dist/native/buildIos/assets.js +1 -6
- package/dist/native/buildIos/build.js +61 -94
- package/dist/native/buildIos/config.js +26 -8
- package/dist/native/buildIos/index.js +143 -64
- package/dist/native/buildIos/plugins.js +12 -15
- package/dist/native/internalPluginUtils.js +0 -1
- package/dist/native/iosnativeWebView/CoreLogic/Sources/CatalystCoreLogic/Constants/ConfigConstants.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashHTMLView.swift +31 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashView.swift +17 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/SplashViewModel.swift +41 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewModel.swift +2 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +3 -0
- package/dist/native/pluginComposerAndroid.js +2 -3
- package/dist/native/renameAndroidProject.js +42 -20
- package/dist/native/setupEmulatorIos.js +28 -49
- package/dist/native/setupServer.js +12 -15
- package/dist/native/terminalProgress.js +186 -147
- package/dist/native/utils.js +335 -54
- package/dist/otel.js +15 -9
- package/dist/scripts/build.js +314 -42
- package/dist/scripts/generateOfflineManifest.js +11 -1
- package/dist/scripts/loadEnvironmentVariables.js +24 -2
- package/dist/scripts/preServerInit.js +32 -10
- package/dist/scripts/preflight.js +115 -11
- package/dist/scripts/scriptUtils.js +34 -0
- package/dist/scripts/serve.js +65 -9
- package/dist/scripts/start.js +58 -21
- package/dist/server/expressServer.js +167 -32
- package/dist/server/renderer/document/Body.d.ts +0 -11
- package/dist/server/renderer/document/Body.jsx +0 -10
- package/dist/server/renderer/document/Head.d.ts +0 -13
- package/dist/server/renderer/document/Head.jsx +0 -12
- package/dist/server/renderer/handler.jsx +87 -11
- package/dist/server/utils/validator.js +14 -11
- package/dist/vite/manifest-categorization-plugin.js +7 -5
- package/dist/vite/resolveDevServerConfig.js +9 -2
- package/dist/vite/vite.config.client.js +19 -1
- package/dist/vite/vite.config.js +96 -19
- package/dist/vite/vite.config.server.js +21 -0
- package/dist/web-router/components/MetaTag.jsx +20 -14
- package/dist/web-router/components/MetaTag.test.jsx +108 -32
- package/dist/web-router/components/RouterDataProvider.d.ts +25 -1
- package/dist/web-router/components/RouterDataProvider.jsx +35 -1
- package/dist/web-router/components/RouterDataProvider.test.jsx +44 -0
- package/dist/web-router/context.d.ts +15 -1
- package/dist/web-router/context.jsx +11 -0
- package/dist/webmcp/WebMcpProvider.jsx +234 -0
- package/dist/webmcp/declarative.js +207 -0
- package/dist/webmcp/declarative.test.js +122 -0
- package/dist/webmcp/errors.js +59 -0
- package/dist/webmcp/errors.test.js +47 -0
- package/dist/webmcp/index.js +40 -0
- package/dist/webmcp/native.js +37 -0
- package/dist/webmcp/native.test.js +41 -0
- package/dist/webmcp/pageInfo.js +119 -0
- package/dist/webmcp/pageInfo.test.js +85 -0
- package/dist/webmcp/registry.dom.test.js +175 -0
- package/dist/webmcp/registry.js +540 -0
- package/dist/webmcp/registry.test.js +228 -0
- package/dist/webmcp/routeContext.js +11 -0
- package/dist/webmcp/shim-entry.js +8 -0
- package/dist/webmcp/shim.js +149 -0
- package/dist/webmcp/useTool.js +124 -0
- package/package.json +4 -7
- package/test/cli/buildErrors.test.ts +173 -0
- package/test/cli/buildWarnings.test.cjs +62 -0
- package/test/cli/devOutput.test.ts +549 -0
- package/test/cli/diagnostic.test.ts +434 -0
- package/test/cli/output.test.cjs +90 -0
- package/test/cli/spinner.test.ts +177 -0
- package/test/cli/theme.test.ts +163 -0
- package/test/native/redactSecrets.test.ts +72 -0
- package/test/native/utils.test.ts +532 -0
- package/test/scripts/build.test.ts +275 -0
- package/test/scripts/preflight.test.ts +100 -2
- package/test/scripts/serve.test.ts +102 -0
- package/test/server/fixtures/template/src/js/routes/index.jsx +8 -0
- package/test/server/handler.test.ts +17 -0
- package/test/server/logger.test.ts +185 -0
- package/test/server/manifestCache.test.ts +13 -0
- package/test/server/otel.test.ts +556 -0
- package/test/server/validator.test.ts +19 -8
- package/vitest.config.ts +4 -0
- package/dist/scripts/checkVersion.js +0 -3
- package/dist/scripts/cleanCache.js +0 -30
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
const path = require("path")
|
|
5
|
+
|
|
6
|
+
const { GUTTER, glyph, t } = require("./theme.js")
|
|
7
|
+
|
|
8
|
+
const CONTEXT_LINES = 2
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render one failure the way a person reads it: what went wrong, where, the
|
|
12
|
+
* code around it, and what to do about it.
|
|
13
|
+
*
|
|
14
|
+
* Everything except `message` is optional, so a caller with only a message
|
|
15
|
+
* still gets a well-formed diagnostic rather than a half-empty template.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {string} options.message - what went wrong, one line
|
|
19
|
+
* @param {string} [options.code] - stable error id, e.g. "BUNDLE-000". The
|
|
20
|
+
* error registry owns which code a failure carries and what it means; this
|
|
21
|
+
* layer only decides how it looks. Optional so callers without a code are
|
|
22
|
+
* unaffected.
|
|
23
|
+
* @param {string} [options.scope] - which part failed, e.g. "client bundle"
|
|
24
|
+
* @param {string} [options.file] - path to the offending file
|
|
25
|
+
* @param {number} [options.line] - 1-indexed
|
|
26
|
+
* @param {number} [options.column] - 1-indexed
|
|
27
|
+
* @param {string} [options.details] - supporting lines under the message,
|
|
28
|
+
* e.g. which config values differ
|
|
29
|
+
* @param {string} [options.hint] - how to fix it
|
|
30
|
+
* @param {string} [options.docs] - a URL that explains more
|
|
31
|
+
* @param {string} [options.cwd] - root for relative paths
|
|
32
|
+
*/
|
|
33
|
+
function diagnostic(options) {
|
|
34
|
+
const { message, code, scope, file, line, column, details, hint, docs, cwd = process.cwd() } = options
|
|
35
|
+
|
|
36
|
+
const out = []
|
|
37
|
+
out.push("")
|
|
38
|
+
// The code identifies the failure; the scope says which part produced it.
|
|
39
|
+
// Both are dim -- they orient, they are not the thing you read first.
|
|
40
|
+
const label = [code, scope].filter(Boolean).join(" ")
|
|
41
|
+
out.push(`${GUTTER}${t.bad(t.bold("error"))}${label ? ` ${t.dim(label)}` : ""}`)
|
|
42
|
+
out.push(`${GUTTER}${t.bold(message)}`)
|
|
43
|
+
|
|
44
|
+
if (details) {
|
|
45
|
+
for (const text of String(details).split("\n")) {
|
|
46
|
+
out.push(`${GUTTER}${t.dim(text)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (file) {
|
|
51
|
+
const shown = relativeIfInside(file, cwd)
|
|
52
|
+
const position = line != null ? `:${line}${column != null ? `:${column}` : ""}` : ""
|
|
53
|
+
out.push(`${GUTTER}${t.dim("at")} ${t.accent(shown)}${t.dim(position)}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const frame = file && line != null ? codeFrame(file, line, column) : null
|
|
57
|
+
if (frame) {
|
|
58
|
+
out.push("")
|
|
59
|
+
out.push(frame)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (hint) {
|
|
63
|
+
out.push("")
|
|
64
|
+
for (const [index, text] of wrap(hint, 72).entries()) {
|
|
65
|
+
out.push(index === 0 ? `${GUTTER}${t.accent("hint")} ${text}` : `${GUTTER} ${text}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (docs) {
|
|
70
|
+
// Under a hint the two read as a pair; under a code frame the docs line
|
|
71
|
+
// needs the same gap a hint gets, or it reads as part of the source.
|
|
72
|
+
if (!hint && frame) out.push("")
|
|
73
|
+
out.push(`${GUTTER}${t.dim("docs")} ${t.dim(docs)}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
out.push("")
|
|
77
|
+
return out.join("\n")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The offending line with its neighbours, and a caret under the column.
|
|
82
|
+
* Returns null rather than throwing when the file cannot be read -- a
|
|
83
|
+
* diagnostic must never fail while reporting a failure.
|
|
84
|
+
*/
|
|
85
|
+
function codeFrame(file, line, column) {
|
|
86
|
+
let source
|
|
87
|
+
try {
|
|
88
|
+
source = fs.readFileSync(file, "utf8")
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lines = source.split("\n")
|
|
94
|
+
if (line < 1 || line > lines.length) return null
|
|
95
|
+
|
|
96
|
+
const first = Math.max(1, line - CONTEXT_LINES)
|
|
97
|
+
const last = Math.min(lines.length, line + CONTEXT_LINES)
|
|
98
|
+
const width = String(last).length + 1
|
|
99
|
+
|
|
100
|
+
const rendered = []
|
|
101
|
+
for (let n = first; n <= last; n++) {
|
|
102
|
+
const text = lines[n - 1].replace(/\t/g, " ")
|
|
103
|
+
const number = String(n).padStart(width)
|
|
104
|
+
|
|
105
|
+
if (n === line) {
|
|
106
|
+
rendered.push(`${GUTTER}${t.bad(">")} ${t.dim(number)} ${t.dim(glyph.pipe)} ${text}`)
|
|
107
|
+
if (column != null && column > 0) {
|
|
108
|
+
const pad = " ".repeat(width)
|
|
109
|
+
rendered.push(`${GUTTER} ${pad} ${t.dim(glyph.pipe)} ${" ".repeat(column - 1)}${t.bad("^")}`)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
rendered.push(`${GUTTER} ${t.dim(number)} ${t.dim(glyph.pipe)} ${t.dim(text)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return rendered.join("\n")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function relativeIfInside(file, cwd) {
|
|
119
|
+
const relative = path.relative(cwd, file)
|
|
120
|
+
return relative && !relative.startsWith("..") ? relative : file
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function wrap(text, width) {
|
|
124
|
+
const words = String(text).split(/\s+/)
|
|
125
|
+
const lines = []
|
|
126
|
+
let current = ""
|
|
127
|
+
for (const word of words) {
|
|
128
|
+
if (current && current.length + word.length + 1 > width) {
|
|
129
|
+
lines.push(current)
|
|
130
|
+
current = word
|
|
131
|
+
} else {
|
|
132
|
+
current = current ? `${current} ${word}` : word
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (current) lines.push(current)
|
|
136
|
+
return lines
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Report a failed native build by showing what the compiler actually said.
|
|
141
|
+
*
|
|
142
|
+
* The alternative -- and what this replaces -- is "Command failed with exit
|
|
143
|
+
* code 1" plus a generic checklist, which sends people to Android Studio or
|
|
144
|
+
* Xcode to find out what broke.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} options
|
|
147
|
+
* @param {Error} options.error - the failure, ideally carrying `.output`
|
|
148
|
+
* @param {string} options.stage - what was being done, e.g. "Gradle build"
|
|
149
|
+
* @param {string} [options.code] - the stage's wrapper code (e.g. "ANDROID-000"),
|
|
150
|
+
* shown when the error does not carry a registry code of its own
|
|
151
|
+
* @param {string} [options.cwd]
|
|
152
|
+
*/
|
|
153
|
+
function buildFailure({ error, stage, code: wrapperCode, cwd = process.cwd() }) {
|
|
154
|
+
const { extractBuildErrors, outputTail, MAX_ERRORS } = require("./buildErrors.js")
|
|
155
|
+
const { hintFor } = require("./hints.js")
|
|
156
|
+
|
|
157
|
+
// A wrapped registry error keeps the toolchain failure as `cause`, and the
|
|
158
|
+
// captured transcript rides on whichever of the two actually ran the child.
|
|
159
|
+
const output = error?.output || error?.cause?.output || ""
|
|
160
|
+
const found = extractBuildErrors(output)
|
|
161
|
+
// A registry code on the error (IOS-003) is more specific than the stage's
|
|
162
|
+
// wrapper; anything else (a spawn's "ENOENT") yields to the wrapper, which
|
|
163
|
+
// is the code the error catalog documents for an upstream failure.
|
|
164
|
+
const ownCode = error?.code && typeof error.code === "string" ? error.code : null
|
|
165
|
+
const code = ownCode && /^[A-Z]+(?:-[A-Z]+)*-\d{3}$/.test(ownCode) ? ownCode : wrapperCode || ownCode
|
|
166
|
+
|
|
167
|
+
if (found.length > 0) {
|
|
168
|
+
const out = []
|
|
169
|
+
const label = found.length === 1 ? "error" : `${found.length} errors`
|
|
170
|
+
const scopeLabel = [code, stage].filter(Boolean).join(" ")
|
|
171
|
+
out.push(`\n${GUTTER}${t.bad(t.bold(label))} ${t.dim(scopeLabel)}\n`)
|
|
172
|
+
|
|
173
|
+
for (const item of found) {
|
|
174
|
+
out.push(`${GUTTER}${t.bold(item.message)}\n`)
|
|
175
|
+
if (item.file) {
|
|
176
|
+
const shown = relativeIfInside(item.file.replace(/^file:\/\//, ""), cwd)
|
|
177
|
+
const at =
|
|
178
|
+
item.line != null ? `:${item.line}${item.column != null ? `:${item.column}` : ""}` : ""
|
|
179
|
+
out.push(`${GUTTER}${t.dim("at")} ${t.accent(shown)}${t.dim(at)}\n`)
|
|
180
|
+
|
|
181
|
+
const frame = codeFrame(item.file.replace(/^file:\/\//, ""), item.line, item.column)
|
|
182
|
+
if (frame) out.push(`\n${frame}\n`)
|
|
183
|
+
}
|
|
184
|
+
out.push("\n")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Advice authored against a code we own beats a pattern guess. The
|
|
188
|
+
// per-stage wrapper codes (BUNDLE-000 and friends) are the exception:
|
|
189
|
+
// their suggestedAction only says to read the upstream error, which is
|
|
190
|
+
// the text we just extracted, so the mined hint stays.
|
|
191
|
+
const authored = code && !/-000$/.test(code) ? error?.suggestedAction : null
|
|
192
|
+
const first = authored ? { hint: authored, docs: error?.docUrl } : hintFor(found[0].message)
|
|
193
|
+
if (first) {
|
|
194
|
+
out.push(`${GUTTER}${t.accent("hint")} ${first.hint}\n\n`)
|
|
195
|
+
}
|
|
196
|
+
if (found.length === MAX_ERRORS) {
|
|
197
|
+
out.push(`${GUTTER}${t.dim("… more errors may follow; the full build output is above.")}\n\n`)
|
|
198
|
+
}
|
|
199
|
+
return out.join("")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Nothing recognised: show the tail rather than claiming to know better.
|
|
203
|
+
const tail = outputTail(output)
|
|
204
|
+
const known = hintFor(error?.message || "")
|
|
205
|
+
const bareExit = /^Command failed with exit code \d+$/.test(String(error?.message || "").trim())
|
|
206
|
+
return (
|
|
207
|
+
diagnostic({
|
|
208
|
+
// "Command failed with exit code 1" names the symptom, not the
|
|
209
|
+
// failure. When that is all the child gave us, say where it broke
|
|
210
|
+
// and let the output below carry the detail.
|
|
211
|
+
message: bareExit ? `${stage || "The build"} failed` : error?.message || "The build failed",
|
|
212
|
+
code,
|
|
213
|
+
// Already named in the message above; repeating it reads as a stutter.
|
|
214
|
+
scope: bareExit ? undefined : stage,
|
|
215
|
+
hint: error?.hint || error?.suggestedAction || known?.hint,
|
|
216
|
+
docs: error?.docUrl || known?.docs,
|
|
217
|
+
cwd,
|
|
218
|
+
}) +
|
|
219
|
+
(tail
|
|
220
|
+
? `\n${GUTTER}${t.dim("Last output from the build:")}\n` +
|
|
221
|
+
tail
|
|
222
|
+
.split("\n")
|
|
223
|
+
.map((line) => `${GUTTER}${t.dim(glyph.pipe)} ${t.dim(line)}`)
|
|
224
|
+
.join("\n") +
|
|
225
|
+
"\n\n"
|
|
226
|
+
: "")
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A registry error (CatalystError) as a diagnostic: its code, message, the
|
|
232
|
+
* cause or details beneath, its suggested action as the hint, and its docs.
|
|
233
|
+
* The default view for preflight and server-startup failures; the registry's
|
|
234
|
+
* own box stays for --verbose and --debug.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} error - a CatalystError, or anything with a message
|
|
237
|
+
* @param {object} [options]
|
|
238
|
+
* @param {string} [options.scope]
|
|
239
|
+
* @param {string} [options.cwd]
|
|
240
|
+
*/
|
|
241
|
+
function registryFailure(error, { scope, cwd } = {}) {
|
|
242
|
+
const cause = error?.cause
|
|
243
|
+
return diagnostic({
|
|
244
|
+
message: error?.message || String(error),
|
|
245
|
+
code: typeof error?.code === "string" ? error.code : undefined,
|
|
246
|
+
scope,
|
|
247
|
+
details: cause ? cause.message || String(cause) : error?.details,
|
|
248
|
+
hint: error?.suggestedAction,
|
|
249
|
+
docs: error?.docUrl,
|
|
250
|
+
cwd,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// V8 frame: " at fn (file:line:col)" or " at file:line:col".
|
|
255
|
+
const FRAME = /^\s*at\s+(?:(.*?)\s+\()?(?:file:\/\/)?([^()]+?):(\d+):(\d+)\)?\s*$/
|
|
256
|
+
|
|
257
|
+
/** Parse a V8 stack into frames; lines that are not frames are skipped. */
|
|
258
|
+
function parseFrames(stack) {
|
|
259
|
+
const frames = []
|
|
260
|
+
for (const line of String(stack || "").split("\n")) {
|
|
261
|
+
const match = FRAME.exec(line)
|
|
262
|
+
if (!match) continue
|
|
263
|
+
const [, fn, file, lineNo, column] = match
|
|
264
|
+
frames.push({ fn: fn || null, file, line: Number(lineNo), column: Number(column) })
|
|
265
|
+
}
|
|
266
|
+
return frames
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A frame in the user's own code: inside the app, outside node_modules and
|
|
271
|
+
* Node's internals. These are the only frames someone can act on -- a React
|
|
272
|
+
* render error otherwise arrives under ten frames of react-dom.
|
|
273
|
+
*/
|
|
274
|
+
function isAppFrame(frame, cwd) {
|
|
275
|
+
if (!frame.file || frame.file.startsWith("node:")) return false
|
|
276
|
+
if (frame.file.includes(`${path.sep}node_modules${path.sep}`)) return false
|
|
277
|
+
const relative = path.relative(cwd, frame.file)
|
|
278
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function describeFrame(frame, cwd) {
|
|
282
|
+
const where = `${relativeIfInside(frame.file, cwd)}:${frame.line}:${frame.column}`
|
|
283
|
+
return frame.fn ? `${frame.fn} ${t.dim(`(${where})`)}` : where
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Report an error raised while the server is running -- a render that threw, a
|
|
288
|
+
* data fetcher that rejected, an uncaught exception.
|
|
289
|
+
*
|
|
290
|
+
* One block per failure, pointed at the first frame in the app's own code with
|
|
291
|
+
* the source around it. The default view omits library frames entirely;
|
|
292
|
+
* `verbose` lists the app's frames, `debug` prints the whole stack and the
|
|
293
|
+
* environment.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} options
|
|
296
|
+
* @param {*} options.error - whatever was thrown
|
|
297
|
+
* @param {string} [options.code] - registry code, e.g. "RUNTIME-WEB-001"
|
|
298
|
+
* @param {string} [options.scope] - where it happened, e.g. "GET /products"
|
|
299
|
+
* @param {string} [options.docs]
|
|
300
|
+
* @param {string} [options.mode] - "default" | "verbose" | "debug"
|
|
301
|
+
* @param {object} [options.env] - debug-mode environment, key -> value
|
|
302
|
+
* @param {string} [options.cwd] - the app root
|
|
303
|
+
*/
|
|
304
|
+
function runtimeFailure({ error, code, scope, docs, mode = "default", env, cwd = process.cwd() }) {
|
|
305
|
+
const isError = error instanceof Error || (error && typeof error === "object" && "message" in error)
|
|
306
|
+
let message = isError ? String(error.message || error.name || "Error") : String(error)
|
|
307
|
+
// Bundler messages run to several sentences of generic advice; the first
|
|
308
|
+
// says what happened, and the location below says where.
|
|
309
|
+
if (message.length > 100) message = message.replace(/^(.+?\.)\s+[A-Z].*$/s, "$1")
|
|
310
|
+
const frames = isError ? parseFrames(error.stack) : []
|
|
311
|
+
const appFrames = frames.filter((frame) => isAppFrame(frame, cwd))
|
|
312
|
+
// A transform error (Vite) carries the source position as `loc`, its
|
|
313
|
+
// column already 1-based; its stack is the bundler's own and says nothing
|
|
314
|
+
// about the app.
|
|
315
|
+
const loc = isError && error.loc?.file ? error.loc : null
|
|
316
|
+
const origin = loc ? { file: loc.file, line: loc.line, column: loc.column } : appFrames[0]
|
|
317
|
+
const { hintFor } = require("./hints.js")
|
|
318
|
+
const known = hintFor(message)
|
|
319
|
+
|
|
320
|
+
let out = diagnostic({
|
|
321
|
+
message: isError && error.name && error.name !== "Error" ? `${error.name}: ${message}` : message,
|
|
322
|
+
code,
|
|
323
|
+
scope,
|
|
324
|
+
file: origin?.file,
|
|
325
|
+
line: origin?.line,
|
|
326
|
+
column: origin?.column,
|
|
327
|
+
hint: known?.hint,
|
|
328
|
+
docs: docs || known?.docs,
|
|
329
|
+
cwd,
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
if (mode === "verbose" && appFrames.length > 1) {
|
|
333
|
+
out += `${GUTTER}${t.dim("stack")}\n`
|
|
334
|
+
out += appFrames.map((frame) => `${GUTTER} ${t.dim("at")} ${describeFrame(frame, cwd)}`).join("\n")
|
|
335
|
+
out += "\n\n"
|
|
336
|
+
}
|
|
337
|
+
if (mode === "debug") {
|
|
338
|
+
const stack = isError && error.stack ? String(error.stack).split("\n").slice(1) : []
|
|
339
|
+
if (stack.length) out += `${stack.map((line) => `${GUTTER}${t.dim(line.trim())}`).join("\n")}\n\n`
|
|
340
|
+
if (env) {
|
|
341
|
+
out += Object.entries(env)
|
|
342
|
+
.map(([key, value]) => `${GUTTER}${t.dim(key.padEnd(10))}${value}`)
|
|
343
|
+
.join("\n")
|
|
344
|
+
out += "\n\n"
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The --verbose/--debug addendum for a failed build: the registry code and
|
|
352
|
+
* docs, then everything the tool printed. The default view shows only the
|
|
353
|
+
* extracted errors; when someone asks for more, the useful "more" is the raw
|
|
354
|
+
* output, not a restatement of the exit code.
|
|
355
|
+
*/
|
|
356
|
+
function verboseOutput({ code, docs, output, mode = "verbose", error, env }) {
|
|
357
|
+
// Leads with a blank line: it follows a diagnostic block directly.
|
|
358
|
+
const out = [""]
|
|
359
|
+
const label = [code, docs].filter(Boolean).map((part) => t.dim(part))
|
|
360
|
+
if (label.length) out.push(`${GUTTER}${label.join(t.dim(" · "))}`)
|
|
361
|
+
const text = String(output || "").replace(/\s+$/, "")
|
|
362
|
+
if (text) {
|
|
363
|
+
out.push(`${GUTTER}${t.dim("Full output:")}`)
|
|
364
|
+
for (const line of text.split("\n")) out.push(`${GUTTER}${t.dim(glyph.pipe)} ${line}`)
|
|
365
|
+
}
|
|
366
|
+
if (mode === "debug") {
|
|
367
|
+
const stack = error?.stack ? String(error.stack).split("\n").slice(1) : []
|
|
368
|
+
if (stack.length) out.push("", ...stack.map((line) => `${GUTTER}${t.dim(line.trim())}`))
|
|
369
|
+
if (env) {
|
|
370
|
+
out.push(
|
|
371
|
+
"",
|
|
372
|
+
...Object.entries(env).map(([key, value]) => `${GUTTER}${t.dim(key.padEnd(10))}${value}`)
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return out.length > 1 ? `${out.join("\n")}\n\n` : ""
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = {
|
|
380
|
+
diagnostic,
|
|
381
|
+
buildFailure,
|
|
382
|
+
registryFailure,
|
|
383
|
+
runtimeFailure,
|
|
384
|
+
verboseOutput,
|
|
385
|
+
codeFrame,
|
|
386
|
+
parseFrames,
|
|
387
|
+
isAppFrame,
|
|
388
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hints for failures we actually understand.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately a short list. A hint that guesses is worse than no hint: it
|
|
7
|
+
* sends people down the wrong path with false confidence. Only add an entry
|
|
8
|
+
* when the pattern reliably implies the cause, and say what to *do*, not what
|
|
9
|
+
* went wrong -- the message above the hint already said that.
|
|
10
|
+
*/
|
|
11
|
+
const HINTS = [
|
|
12
|
+
{
|
|
13
|
+
test: /Failed to resolve import ["']([^"']+)["']/,
|
|
14
|
+
hint: (match) =>
|
|
15
|
+
match[1].startsWith("@")
|
|
16
|
+
? `The alias ${match[1].split("/")[0]} is not defined in package.json _moduleAliases. Add it, or import with a relative path.`
|
|
17
|
+
: `Check the path, or install the package with npm install ${match[1]}.`,
|
|
18
|
+
docs: "https://catalyst.1mg.com/errors/unresolved-import",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
test: /EADDRINUSE[^0-9]*(?:address already in use\s*)?(\S*?:?(\d+))?\s*$/i,
|
|
22
|
+
hint: (match) => {
|
|
23
|
+
const port = match[2]
|
|
24
|
+
const where = port ? `port ${port}` : "that port"
|
|
25
|
+
return port
|
|
26
|
+
? `Something is already on ${where}. Free it with \`lsof -ti :${port} | xargs kill\`, or set NODE_SERVER_PORT in config/config.json.`
|
|
27
|
+
: `Something is already on ${where}. Stop it, or set NODE_SERVER_PORT in config/config.json.`
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
test: /SDK location not found|ANDROID_HOME/i,
|
|
32
|
+
hint: () =>
|
|
33
|
+
"Set the Android SDK path in config/config.json under WEBVIEW_CONFIG.android.sdkPath, or export ANDROID_HOME.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
test: /No booted simulator found/i,
|
|
37
|
+
hint: () => "Open Simulator and boot a device, or run catalyst setupEmulator:ios.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
test: /Unknown AVD name|no such file or directory.*avd/i,
|
|
41
|
+
hint: () =>
|
|
42
|
+
"That emulator does not exist. List the ones you have with `emulator -list-avds`, then set WEBVIEW_CONFIG.android.emulatorName.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
test: /Command not found: (.+)/,
|
|
46
|
+
hint: (match) =>
|
|
47
|
+
`${match[1].trim()} is not on PATH. Install it, or correct the path in config/config.json.`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
test: /code signing|CODE_SIGN|provisioning profile/i,
|
|
51
|
+
hint: () =>
|
|
52
|
+
"Open the project in Xcode once and pick a signing team, or build for the simulator instead of a device.",
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} message
|
|
58
|
+
* @returns {{hint: string, docs?: string}|null}
|
|
59
|
+
*/
|
|
60
|
+
function hintFor(message) {
|
|
61
|
+
if (!message) return null
|
|
62
|
+
for (const entry of HINTS) {
|
|
63
|
+
const match = entry.test.exec(message)
|
|
64
|
+
if (match) return { hint: entry.hint(match), docs: entry.docs }
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { hintFor }
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict"
|
|
2
|
+
|
|
3
|
+
const { GUTTER, SPINNER_FRAMES, t, isInteractive, step } = require("./theme.js")
|
|
4
|
+
|
|
5
|
+
const FRAME_MS = 120
|
|
6
|
+
|
|
7
|
+
// Carriage return + erase-line. Built rather than written as a literal so no
|
|
8
|
+
// raw control byte sits in the source, where editors and diffs mangle it.
|
|
9
|
+
const CLEAR_LINE = `\r${String.fromCharCode(27)}[2K`
|
|
10
|
+
|
|
11
|
+
// Every spinner currently animating, so code that has no handle on one -- a
|
|
12
|
+
// config prompt deep in the native setup -- can still stand them all down.
|
|
13
|
+
// A prompt written under a live spinner is erased by the next frame, which
|
|
14
|
+
// makes a question look like a hang.
|
|
15
|
+
const live = new Set()
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A single-line progress indicator that collapses into its own result.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately one line and no more: it owns exactly the row it is drawn on,
|
|
21
|
+
* so anything printed before it is untouched and anything after starts on a
|
|
22
|
+
* fresh row. That is what lets streamed child output and the spinner coexist
|
|
23
|
+
* without the cursor-math problems a multi-line region creates.
|
|
24
|
+
*
|
|
25
|
+
* Non-interactive (pipe, CI) prints a plain start line and a plain result
|
|
26
|
+
* line: no animation, no escape codes, and every state still recorded.
|
|
27
|
+
*/
|
|
28
|
+
class Spinner {
|
|
29
|
+
constructor(stream = process.stdout) {
|
|
30
|
+
this.stream = stream
|
|
31
|
+
this.timer = null
|
|
32
|
+
this.frame = 0
|
|
33
|
+
this.label = ""
|
|
34
|
+
this.startedAt = 0
|
|
35
|
+
this.active = false
|
|
36
|
+
this.interactive = isInteractive(stream)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
start(label) {
|
|
40
|
+
// Starting twice without an intervening stop would orphan the first
|
|
41
|
+
// interval, leaving two timers writing to the same row forever.
|
|
42
|
+
if (this.timer) {
|
|
43
|
+
clearInterval(this.timer)
|
|
44
|
+
this.timer = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.label = label
|
|
48
|
+
this.startedAt = Date.now()
|
|
49
|
+
this.active = true
|
|
50
|
+
live.add(this)
|
|
51
|
+
|
|
52
|
+
if (!this.interactive) {
|
|
53
|
+
this.stream.write(`${GUTTER}${t.dim("·")} ${label}\n`)
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.frame = 0
|
|
58
|
+
this.render()
|
|
59
|
+
this.timer = setInterval(() => {
|
|
60
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length
|
|
61
|
+
this.render()
|
|
62
|
+
}, FRAME_MS)
|
|
63
|
+
// Never hold the process open for an animation.
|
|
64
|
+
if (this.timer.unref) this.timer.unref()
|
|
65
|
+
return this
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Change the text without resetting the elapsed timer. */
|
|
69
|
+
update(label) {
|
|
70
|
+
this.label = label
|
|
71
|
+
if (this.interactive && this.active) this.render()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render() {
|
|
75
|
+
this.stream.write(`${CLEAR_LINE}${GUTTER}${t.accent(SPINNER_FRAMES[this.frame])} ${this.label}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Replace the live line with its final state. */
|
|
79
|
+
stop(state = "done", label = this.label) {
|
|
80
|
+
if (!this.active) return
|
|
81
|
+
this.active = false
|
|
82
|
+
live.delete(this)
|
|
83
|
+
|
|
84
|
+
const elapsed = Date.now() - this.startedAt
|
|
85
|
+
|
|
86
|
+
if (this.timer) {
|
|
87
|
+
clearInterval(this.timer)
|
|
88
|
+
this.timer = null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (this.interactive) {
|
|
92
|
+
// Erase the animated line, then write the settled one in its place.
|
|
93
|
+
this.stream.write(CLEAR_LINE)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.stream.write(`${step(label, elapsed, state)}\n`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stand the animation down so something else can write, without ending the
|
|
101
|
+
* step. Used when a child process streams output beneath a running step.
|
|
102
|
+
*/
|
|
103
|
+
pause() {
|
|
104
|
+
if (!this.interactive || !this.active) return
|
|
105
|
+
if (this.timer) {
|
|
106
|
+
clearInterval(this.timer)
|
|
107
|
+
this.timer = null
|
|
108
|
+
}
|
|
109
|
+
// Erase the live row entirely and leave the cursor on it. Whatever
|
|
110
|
+
// prints next takes that row, and resume() redraws below. Leaving the
|
|
111
|
+
// label behind here would strand a second copy of the step, which then
|
|
112
|
+
// settles into its own duplicate line.
|
|
113
|
+
this.stream.write(CLEAR_LINE)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
resume() {
|
|
117
|
+
if (!this.interactive || !this.active || this.timer) return
|
|
118
|
+
this.render()
|
|
119
|
+
this.timer = setInterval(() => {
|
|
120
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length
|
|
121
|
+
this.render()
|
|
122
|
+
}, FRAME_MS)
|
|
123
|
+
if (this.timer.unref) this.timer.unref()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stand every live spinner down while `fn` writes to or reads from the
|
|
129
|
+
* terminal, then bring them back -- on the failure path too.
|
|
130
|
+
*/
|
|
131
|
+
async function withSpinnersPaused(fn) {
|
|
132
|
+
const paused = [...live]
|
|
133
|
+
for (const spinner of paused) spinner.pause()
|
|
134
|
+
try {
|
|
135
|
+
return await fn()
|
|
136
|
+
} finally {
|
|
137
|
+
for (const spinner of paused) spinner.resume()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { Spinner, withSpinnersPaused }
|