@ultimat3/cli 9.0.0 → 11.0.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/CLAUDE.md +71 -0
- package/package.json +28 -26
- package/src/affected.ts +0 -3
- package/src/app-boundaries.ts +4 -5
- package/src/app-env.ts +7 -2
- package/src/browser-launcher.ts +0 -2
- package/src/budgets.ts +10 -3
- package/src/cmd-build.ts +2 -2
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-deploy.ts +14 -3
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-docs.ts +2 -1
- package/src/cmd-doctor.ts +3 -5
- package/src/cmd-env.ts +2 -2
- package/src/cmd-fix.ts +2 -4
- package/src/cmd-new.ts +36 -6
- package/src/cmd-shot.ts +22 -2
- package/src/command.ts +12 -0
- package/src/db-finding.ts +2 -2
- package/src/db-seed.ts +0 -3
- package/src/dev-assets.ts +6 -0
- package/src/dev-cache.ts +12 -5
- package/src/dev-hooks.ts +8 -0
- package/src/dev-lock.ts +8 -7
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dev-runtime.ts +11 -3
- package/src/dev-storage.ts +8 -2
- package/src/dev-sync.ts +37 -2
- package/src/dispatch.ts +8 -0
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +3 -2
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +19 -2
- package/src/error-contract.ts +30 -7
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +26 -29
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/flag-reads.ts +2 -2
- package/src/generate-write.ts +3 -2
- package/src/guards.ts +4 -4
- package/src/hold.ts +73 -7
- package/src/index.ts +16 -6
- package/src/island-bundle.ts +32 -4
- package/src/island-routes.ts +7 -1
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +7 -2
- package/src/messages.ts +6 -4
- package/src/metrics-endpoint.ts +0 -2
- package/src/output.ts +2 -2
- package/src/prerender.ts +64 -22
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/static-report.ts +41 -3
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +1 -1
- package/src/templates/scaffold-domain-package.ts +3 -1
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +17 -8
- package/src/templates/slice-foundation.ts +3 -5
- package/src/test-shards.ts +2 -2
- package/src/tsconfig-references.ts +2 -2
- package/src/verify-checks.ts +10 -3
- package/src/verify-floor.ts +8 -6
- package/src/verify-run.ts +2 -2
- package/src/verify-step.ts +2 -1
- package/src/verify-test-run.ts +2 -2
- package/src/workspace-checks.ts +10 -12
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +7 -1
package/src/error-codes.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The X_* codes owned by @ultimat3/cli, and nothing else: the two lists, their titles, the one
|
|
2
|
-
// registration call
|
|
2
|
+
// registration call. Every code names the exact command that resolves it, because
|
|
3
3
|
// the CLI is the surface an agent reads first — a failure here has to be actionable without a doc
|
|
4
4
|
// lookup or a second round-trip. The classes that throw these codes live in `./errors`.
|
|
5
5
|
import { registerErrorCodes } from '@ultimat3/core';
|
|
@@ -21,6 +21,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
21
21
|
'X_JOB_UNKNOWN',
|
|
22
22
|
'X_FIX_TARGET_UNKNOWN',
|
|
23
23
|
'X_ERROR_FIX_INVALID',
|
|
24
|
+
// The second half of the same contract: X_ERROR_FIX_INVALID means the fix is not an instruction,
|
|
25
|
+
// this one means it is one and cites a file that is not there. Two conditions, two repairs.
|
|
26
|
+
'X_ERROR_FIX_PATH_MISSING',
|
|
24
27
|
'X_ERROR_CODE_UNDOCUMENTED',
|
|
25
28
|
'X_ERROR_CODE_UNREGISTERED',
|
|
26
29
|
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
@@ -46,6 +49,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
46
49
|
'X_STORAGE_SECRET_DEV',
|
|
47
50
|
'X_MANIFEST_STALE',
|
|
48
51
|
'X_BUDGET_UNMEASURED',
|
|
52
|
+
// The other half of #271, and the half no runtime can raise: a route reads a live hook and boots
|
|
53
|
+
// no module in a browser, so its rows have nowhere to arrive and the page renders its loading
|
|
54
|
+
// branch forever, at 200. Only this package can see it — `@ultimat3/realtime` cannot see a route
|
|
55
|
+
// and `@ultimat3/render` may not import realtime.
|
|
56
|
+
'X_LIVE_ROUTE_NO_ISLAND',
|
|
49
57
|
'X_BUILD_FAILED',
|
|
50
58
|
'X_BUILD_ENTRY_MISSING',
|
|
51
59
|
'X_DEPLOY_FAILED',
|
|
@@ -160,6 +168,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
160
168
|
X_JOB_UNKNOWN: 'the queue holds no job with this id',
|
|
161
169
|
X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
|
|
162
170
|
X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
|
|
171
|
+
X_ERROR_FIX_PATH_MISSING: "an error's fix line cites a file this repository does not have",
|
|
163
172
|
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
164
173
|
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
165
174
|
X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
|
|
@@ -175,6 +184,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
175
184
|
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
176
185
|
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
177
186
|
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
187
|
+
X_LIVE_ROUTE_NO_ISLAND: 'a route reads live rows and boots nothing that could receive them',
|
|
178
188
|
X_BUILD_FAILED: 'x build failed',
|
|
179
189
|
X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
|
|
180
190
|
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
@@ -217,4 +227,11 @@ registerErrorCodes(
|
|
|
217
227
|
Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
218
228
|
);
|
|
219
229
|
|
|
220
|
-
|
|
230
|
+
// This file exports NO `docsFor(code)`, and adding one back is the defect. A CLI error passes no
|
|
231
|
+
// `docs:` at all — `UltimateError` fills it from `describeErrorCode(code).docs`, which is
|
|
232
|
+
// `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every code, never one per code, because `wiki/`
|
|
233
|
+
// is the framework's only public documentation surface and a code lives there in a TABLE ROW, which
|
|
234
|
+
// has no anchor. A `Finding` is a plain object with no constructor to fill it, so it carries
|
|
235
|
+
// `ERROR_DOCS_URL` imported from core — the same constant, not a second copy of it. The
|
|
236
|
+
// `https://ultimate.dev/errors/<code>` links `docsFor` built until 9.x answered 404, host included,
|
|
237
|
+
// on every error the CLI has ever thrown.
|
package/src/error-contract.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
// `join` is `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import {
|
|
9
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
10
|
import { citedCommandProblem, loadCommandCatalog } from './fix-command';
|
|
11
11
|
import { createHelperResolver } from './fix-imports';
|
|
12
|
+
import { citedPathProblem, FILE_TOKEN_PATTERN } from './fix-path';
|
|
12
13
|
import { scanFixSites } from './fix-scan';
|
|
13
14
|
import type { Finding } from './output';
|
|
14
15
|
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
@@ -38,7 +39,9 @@ export const COMMAND_TOKENS: readonly RegExp[] = [
|
|
|
38
39
|
/(?:^|[\s;|&("'`])x\s+[a-z][a-z-]*/,
|
|
39
40
|
/\b(?:bun|bunx|npm|npx|node|git|docker|kubectl|helm|psql|curl|openssl|biome|tsc)\b/,
|
|
40
41
|
/\b[A-Za-z_$][\w$]*\(/,
|
|
41
|
-
|
|
42
|
+
// Built from `fix-path.ts`'s extension list, because the token that makes a fix count as an
|
|
43
|
+
// instruction is exactly the token `citedPathProblem` then has to resolve.
|
|
44
|
+
new RegExp(FILE_TOKEN_PATTERN),
|
|
42
45
|
/\b(?:app\.config\.ts|package\.json|tsconfig\.json|bunfig\.toml|\.env(?:\.[\w.-]+)?)\b/,
|
|
43
46
|
];
|
|
44
47
|
|
|
@@ -63,7 +66,20 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
|
|
|
63
66
|
code: 'X_ERROR_FIX_INVALID',
|
|
64
67
|
cause: problem,
|
|
65
68
|
fix: `rewrite the fix at ${site.at}:${site.line} as a command to run, a call to paste, or an edit naming a file`,
|
|
66
|
-
docs:
|
|
69
|
+
docs: ERROR_DOCS_URL,
|
|
70
|
+
at: `${site.at}:${site.line}`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Its own code, not `X_ERROR_FIX_INVALID`: that one means the fix is not an instruction, and this
|
|
75
|
+
* one means it IS one and points at nothing. The repairs are different — rewrite the sentence
|
|
76
|
+
* versus correct the path — and one code for both would hand two readers the same wrong edit.
|
|
77
|
+
*/
|
|
78
|
+
const pathFinding = (site: FixSite, problem: string): Finding => ({
|
|
79
|
+
code: 'X_ERROR_FIX_PATH_MISSING',
|
|
80
|
+
cause: `the fix at ${site.at}:${site.line} ${problem}`,
|
|
81
|
+
fix: `correct the path in the fix at ${site.at}:${site.line} to one this repo holds, or create the file it names`,
|
|
82
|
+
docs: ERROR_DOCS_URL,
|
|
67
83
|
at: `${site.at}:${site.line}`,
|
|
68
84
|
});
|
|
69
85
|
|
|
@@ -110,7 +126,14 @@ export async function checkErrorFixReport(root: string): Promise<ErrorFixReport>
|
|
|
110
126
|
// resolve, and reading `<value>` as one would be a finding nobody can act on.
|
|
111
127
|
const fix = staticFix(site.fix);
|
|
112
128
|
const problem = fixProblem(site.fix) ?? citedCommandProblem(fix, catalog);
|
|
113
|
-
if (problem !== undefined)
|
|
129
|
+
if (problem !== undefined) {
|
|
130
|
+
findings.push(fixFinding(site, problem));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Third rule, and the one nothing resolved: a fix that names a file this repo does not have.
|
|
134
|
+
// Reported only where the first two hold — a line already being rewritten needs one finding.
|
|
135
|
+
const missing = await citedPathProblem(fix, root);
|
|
136
|
+
if (missing !== undefined) findings.push(pathFinding(site, missing));
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
139
|
return { findings, checked, unreadable };
|
|
@@ -132,7 +155,7 @@ const undocumentedFinding = (code: string, at: string, line: number, page: strin
|
|
|
132
155
|
code: 'X_ERROR_CODE_UNDOCUMENTED',
|
|
133
156
|
cause: `${code} is declared at ${at}:${line} and ${page} has no entry for it`,
|
|
134
157
|
fix: `add a row for ${code} to ${page}, with its cause and the command that fixes it`,
|
|
135
|
-
docs:
|
|
158
|
+
docs: ERROR_DOCS_URL,
|
|
136
159
|
at: page,
|
|
137
160
|
});
|
|
138
161
|
|
|
@@ -163,7 +186,7 @@ const unregisteredFinding = (code: string, page: string): Finding => ({
|
|
|
163
186
|
code: 'X_ERROR_CODE_UNREGISTERED',
|
|
164
187
|
cause: `${page} documents ${code} as a live code and nothing registers it, so "x errors explain ${code}" refuses a code this page promises`,
|
|
165
188
|
fix: `register ${code} through registerErrorCodes() in its package's src/errors.ts, or move its row under "${RESERVED_HEADING}" in ${page}`,
|
|
166
|
-
docs:
|
|
189
|
+
docs: ERROR_DOCS_URL,
|
|
167
190
|
at: page,
|
|
168
191
|
});
|
|
169
192
|
|
|
@@ -249,7 +272,7 @@ export async function checkErrorCodeDocs(root: string, page: string): Promise<re
|
|
|
249
272
|
code: 'X_ERROR_CODE_UNDOCUMENTED',
|
|
250
273
|
cause: `the error reference ${page} does not exist, so no code can be documented`,
|
|
251
274
|
fix: `create ${page} with a row per X_* code, or stop naming it as the error reference`,
|
|
252
|
-
docs:
|
|
275
|
+
docs: ERROR_DOCS_URL,
|
|
253
276
|
at: page,
|
|
254
277
|
},
|
|
255
278
|
];
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The app's own error page, which only a process with a disk can find: `@ultimat3/http` renders the
|
|
2
|
+
// framework's and declares the seam, this file is the half that reads a file. Rails' `public/404.html`,
|
|
3
|
+
// one directory further in — `apps/web/site/` is already where an app's public files live, which is
|
|
4
|
+
// where `favicon.ico` is overridden too.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the source path is app-root-relative — the same
|
|
7
|
+
// necessity `favicon.ts` records for `FAVICON_SOURCE`.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { renderErrorPage } from '@ultimat3/http';
|
|
10
|
+
import { currentLocale } from '@ultimat3/i18n';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One file per status, named by the status, and NO generic `error.html` beside it: a second rung
|
|
14
|
+
* would need a precedence rule, and a `{{status}}` slot would be a template language the framework
|
|
15
|
+
* does not otherwise have. An app that wants one page for three statuses writes three files.
|
|
16
|
+
*/
|
|
17
|
+
export const ERROR_PAGE_DIR = 'apps/web/site/errors';
|
|
18
|
+
|
|
19
|
+
/** What `x verify`, a `fix:` and this reader all name — never spelled twice. */
|
|
20
|
+
export const errorPageSource = (status: number): string =>
|
|
21
|
+
`${ERROR_PAGE_DIR}/${String(status)}.html`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What a static host serves for a path that matches no file. `404.html` at the export root is the
|
|
25
|
+
* convention S3, Cloudflare Pages, Netlify and nginx already look for, so the artifact needs no
|
|
26
|
+
* configuration to answer the way the served process does.
|
|
27
|
+
*/
|
|
28
|
+
export const STATIC_ERROR_PAGE = '404.html';
|
|
29
|
+
|
|
30
|
+
/** A status a `Response` can carry. A number outside it never becomes a path. */
|
|
31
|
+
const isStatus = (status: number): boolean =>
|
|
32
|
+
Number.isInteger(status) && status >= 100 && status <= 599;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The app's page for one status, or `undefined`.
|
|
36
|
+
*
|
|
37
|
+
* Read per REQUEST, never cached at boot, for `favicon.ts`'s reason: `x dev` is a running process
|
|
38
|
+
* an author drops a file into, and a reader that captured "there was none" at startup would keep
|
|
39
|
+
* answering the framework's page until the server was restarted.
|
|
40
|
+
*/
|
|
41
|
+
export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
|
|
42
|
+
if (!isStatus(status)) return undefined;
|
|
43
|
+
const file = Bun.file(join(root, errorPageSource(status)));
|
|
44
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
|
|
49
|
+
* container cannot answer a browser differently — the rule `assetRoutes` already holds for
|
|
50
|
+
* `/favicon.ico`.
|
|
51
|
+
*/
|
|
52
|
+
export const errorPageHook =
|
|
53
|
+
(root: string) =>
|
|
54
|
+
(status: number): Promise<string | undefined> =>
|
|
55
|
+
errorPageOverride(root, status);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The document that goes into a static export: the app's file if it has one, the framework's page
|
|
59
|
+
* otherwise. Its own function because the export has no process to ask, exactly as `faviconBytes`
|
|
60
|
+
* is — a second rule for which page a static build carries would be an artifact that disagrees
|
|
61
|
+
* with the server it was built from.
|
|
62
|
+
*
|
|
63
|
+
* No request id and no pathname: nothing about this file is per-request, and the renderer omits
|
|
64
|
+
* both rather than inventing them.
|
|
65
|
+
*/
|
|
66
|
+
export async function errorPageDocument(root: string, status: number): Promise<string> {
|
|
67
|
+
const own = await errorPageOverride(root, status);
|
|
68
|
+
return (
|
|
69
|
+
own ??
|
|
70
|
+
renderErrorPage({
|
|
71
|
+
status,
|
|
72
|
+
// What the served 404 carries, so the artifact and the process name one code.
|
|
73
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
74
|
+
// The app's default, resolved after `loadApp` registered its catalogs — a build has no
|
|
75
|
+
// request to negotiate against.
|
|
76
|
+
locale: currentLocale(),
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
|
|
3
3
|
// so a package importing a class does not pull the table and vice versa.
|
|
4
4
|
import { UltimateError } from '@ultimat3/core';
|
|
5
|
-
import {
|
|
5
|
+
import { quoteArg } from './shell-quote';
|
|
6
6
|
|
|
7
7
|
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
8
8
|
export class UnknownCommandError extends UltimateError {
|
|
@@ -11,7 +11,6 @@ export class UnknownCommandError extends UltimateError {
|
|
|
11
11
|
code: 'X_CLI_UNKNOWN_COMMAND',
|
|
12
12
|
cause: `"x ${input.path}" is not a command (known: ${input.known.join(', ')})`,
|
|
13
13
|
fix: input.suggestion === undefined ? 'x help' : `x ${input.suggestion}`,
|
|
14
|
-
docs: docsFor('X_CLI_UNKNOWN_COMMAND'),
|
|
15
14
|
});
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -27,7 +26,6 @@ export class BadFlagError extends UltimateError {
|
|
|
27
26
|
code: 'X_CLI_BAD_FLAG',
|
|
28
27
|
cause: `--${input.flag} on "x ${input.command}": ${input.reason}`,
|
|
29
28
|
fix: input.fix ?? `x ${input.command} --help`,
|
|
30
|
-
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
31
29
|
});
|
|
32
30
|
}
|
|
33
31
|
}
|
|
@@ -46,7 +44,30 @@ export class MissingPositionalError extends UltimateError {
|
|
|
46
44
|
code: 'X_CLI_BAD_FLAG',
|
|
47
45
|
cause: `"x ${input.command}" needs a <${input.positional}> positional and got none`,
|
|
48
46
|
fix: input.example,
|
|
49
|
-
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `x new /srv/apps/shop` — a PATH where a NAME goes.
|
|
53
|
+
*
|
|
54
|
+
* `names()` slugifies whatever it is given, so the separators became hyphens and the whole path
|
|
55
|
+
* turned into ONE directory inside the cwd: `x new /tmp/probe/vision` wrote `tmp-probe-vision`
|
|
56
|
+
* into the current directory and `git init`-ed it there. Nothing failed, and the app the caller
|
|
57
|
+
* asked for did not exist.
|
|
58
|
+
*
|
|
59
|
+
* `--dir` is the flag that takes a path, so the two are one flag apart and the fix is the
|
|
60
|
+
* invocation the caller meant — built from what they typed, never a placeholder.
|
|
61
|
+
*/
|
|
62
|
+
export class AppNameIsPathError extends UltimateError {
|
|
63
|
+
constructor(input: { name: string; parent: string; base: string; invocation?: string }) {
|
|
64
|
+
// The invocation the caller typed, not the literal `x new`: the same command is the whole of
|
|
65
|
+
// `bunx create-ultimate`, which runs before `x` is installed.
|
|
66
|
+
const invocation = input.invocation ?? 'x new';
|
|
67
|
+
super({
|
|
68
|
+
code: 'X_CLI_BAD_FLAG',
|
|
69
|
+
cause: `"${invocation}" takes a NAME and got the path "${input.name}" — it would be slugified into one directory name`,
|
|
70
|
+
fix: `${invocation} ${quoteArg(input.base)} --dir ${quoteArg(input.parent)}`,
|
|
50
71
|
});
|
|
51
72
|
}
|
|
52
73
|
}
|
|
@@ -70,7 +91,6 @@ export class MissingSubcommandError extends UltimateError {
|
|
|
70
91
|
code: 'X_CLI_BAD_FLAG',
|
|
71
92
|
cause: `"x ${input.command}" takes a subcommand and got none (one of: ${input.known.join(', ')})`,
|
|
72
93
|
fix: `x help ${input.command}`,
|
|
73
|
-
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
74
94
|
});
|
|
75
95
|
}
|
|
76
96
|
}
|
|
@@ -82,7 +102,6 @@ export class VerifyFailedError extends UltimateError {
|
|
|
82
102
|
code: 'X_VERIFY_FAILED',
|
|
83
103
|
cause: `${input.failed.length} verify step(s) failed: ${input.failed.join(', ')}`,
|
|
84
104
|
fix: 'x verify --json',
|
|
85
|
-
docs: docsFor('X_VERIFY_FAILED'),
|
|
86
105
|
});
|
|
87
106
|
}
|
|
88
107
|
}
|
|
@@ -94,7 +113,6 @@ export class NotInAppError extends UltimateError {
|
|
|
94
113
|
code: 'X_NOT_IN_APP',
|
|
95
114
|
cause: `"x ${input.command}" must run inside an Ultimate app; no app.config.ts at or above ${input.from}`,
|
|
96
115
|
fix: 'x new myapp && cd myapp',
|
|
97
|
-
docs: docsFor('X_NOT_IN_APP'),
|
|
98
116
|
});
|
|
99
117
|
}
|
|
100
118
|
}
|
|
@@ -106,7 +124,6 @@ export class BunVersionError extends UltimateError {
|
|
|
106
124
|
code: 'X_BUN_VERSION',
|
|
107
125
|
cause: `Bun ${input.found} is older than the required ${input.required}`,
|
|
108
126
|
fix: 'bun upgrade',
|
|
109
|
-
docs: docsFor('X_BUN_VERSION'),
|
|
110
127
|
});
|
|
111
128
|
}
|
|
112
129
|
}
|
|
@@ -127,7 +144,6 @@ export class NoTestFilesError extends UltimateError {
|
|
|
127
144
|
code: 'X_TEST_NO_FILES',
|
|
128
145
|
cause: `no *.test.ts files${where} under ${input.root}`,
|
|
129
146
|
fix: parts.length === 0 ? 'x test --json # run it from the repo root' : 'x test',
|
|
130
|
-
docs: docsFor('X_TEST_NO_FILES'),
|
|
131
147
|
});
|
|
132
148
|
}
|
|
133
149
|
}
|
|
@@ -146,7 +162,6 @@ export class ScaffoldPathEscapeError extends UltimateError {
|
|
|
146
162
|
fix:
|
|
147
163
|
input.fix ??
|
|
148
164
|
`make the path relative to the app root with no ".." segment, then re-run: bun test packages/cli/src/scaffold-typecheck.contract.test.ts`,
|
|
149
|
-
docs: docsFor('X_SCAFFOLD_PATH_ESCAPE'),
|
|
150
165
|
});
|
|
151
166
|
}
|
|
152
167
|
}
|
|
@@ -163,7 +178,6 @@ export class GenerateJsonInvalidError extends UltimateError {
|
|
|
163
178
|
code: 'X_GENERATE_JSON_INVALID',
|
|
164
179
|
cause: `${input.path} is declared merge: 'json' but the generator's own contents for it do not parse as a JSON object`,
|
|
165
180
|
fix: `fix the template that emits ${input.path}, then re-run: bun test packages/cli/src/cmd-generate.test.ts`,
|
|
166
|
-
docs: docsFor('X_GENERATE_JSON_INVALID'),
|
|
167
181
|
});
|
|
168
182
|
}
|
|
169
183
|
}
|
|
@@ -182,7 +196,6 @@ export class CatalogExistsError extends UltimateError {
|
|
|
182
196
|
code: 'X_GENERATE_CONFLICT',
|
|
183
197
|
cause: `${input.path} already exists`,
|
|
184
198
|
fix: `x i18n sync ${input.locale}`,
|
|
185
|
-
docs: docsFor('X_GENERATE_CONFLICT'),
|
|
186
199
|
});
|
|
187
200
|
}
|
|
188
201
|
}
|
|
@@ -198,7 +211,6 @@ export class AppPackageInvalidError extends UltimateError {
|
|
|
198
211
|
code: 'X_APP_PACKAGE_INVALID',
|
|
199
212
|
cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
|
|
200
213
|
fix: 'bun pm pkg set name=my-app version=0.1.0',
|
|
201
|
-
docs: docsFor('X_APP_PACKAGE_INVALID'),
|
|
202
214
|
});
|
|
203
215
|
}
|
|
204
216
|
}
|
|
@@ -216,7 +228,6 @@ export class ErrorCodeUnknownError extends UltimateError {
|
|
|
216
228
|
input.suggestion === undefined
|
|
217
229
|
? 'x errors list --json'
|
|
218
230
|
: `x errors explain ${input.suggestion}`,
|
|
219
|
-
docs: docsFor('X_ERROR_CODE_UNKNOWN'),
|
|
220
231
|
});
|
|
221
232
|
}
|
|
222
233
|
}
|
|
@@ -243,7 +254,6 @@ export class DeclarationUnknownError extends UltimateError {
|
|
|
243
254
|
input.suggestion === undefined
|
|
244
255
|
? `x ${input.kind} list --json`
|
|
245
256
|
: `x ${input.kind} ${input.verb ?? 'describe'} ${input.suggestion}`,
|
|
246
|
-
docs: docsFor('X_DECLARATION_UNKNOWN'),
|
|
247
257
|
});
|
|
248
258
|
}
|
|
249
259
|
}
|
|
@@ -255,7 +265,6 @@ export class JobUnknownError extends UltimateError {
|
|
|
255
265
|
code: 'X_JOB_UNKNOWN',
|
|
256
266
|
cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
|
|
257
267
|
fix: 'x jobs ls --json',
|
|
258
|
-
docs: docsFor('X_JOB_UNKNOWN'),
|
|
259
268
|
});
|
|
260
269
|
}
|
|
261
270
|
}
|
|
@@ -274,7 +283,6 @@ export class FixTargetUnknownError extends UltimateError {
|
|
|
274
283
|
input.suggestion === undefined
|
|
275
284
|
? 'x routes --json # every registered route file, app-root-relative'
|
|
276
285
|
: `x fix boundary ${input.suggestion}`,
|
|
277
|
-
docs: docsFor('X_FIX_TARGET_UNKNOWN'),
|
|
278
286
|
});
|
|
279
287
|
}
|
|
280
288
|
}
|
|
@@ -290,7 +298,6 @@ export class BuildEntryMissingError extends UltimateError {
|
|
|
290
298
|
code: 'X_BUILD_ENTRY_MISSING',
|
|
291
299
|
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
292
300
|
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
293
|
-
docs: docsFor('X_BUILD_ENTRY_MISSING'),
|
|
294
301
|
});
|
|
295
302
|
}
|
|
296
303
|
}
|
|
@@ -306,7 +313,6 @@ export class IslandBuildFailedError extends UltimateError {
|
|
|
306
313
|
code: 'X_BUILD_FAILED',
|
|
307
314
|
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
308
315
|
fix: `bun build --target browser ${input.file}`,
|
|
309
|
-
docs: docsFor('X_BUILD_FAILED'),
|
|
310
316
|
});
|
|
311
317
|
}
|
|
312
318
|
}
|
|
@@ -321,7 +327,6 @@ export class RoleUnknownError extends UltimateError {
|
|
|
321
327
|
code: 'X_ROLE_UNKNOWN',
|
|
322
328
|
cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
|
|
323
329
|
fix: `docker run -e ROLE=web my-app:latest # one of: ${input.known.join(', ')}`,
|
|
324
|
-
docs: docsFor('X_ROLE_UNKNOWN'),
|
|
325
330
|
});
|
|
326
331
|
}
|
|
327
332
|
}
|
|
@@ -344,7 +349,6 @@ export class RuntimeDriverSplitError extends UltimateError {
|
|
|
344
349
|
// queues, and "they match" is exactly the reading that makes this bug invisible.
|
|
345
350
|
cause: `an app module installed a ${input.driver} driver (ambient: "${input.ambient}") that is not the object this boot captured ("${input.captured}"), so enqueues and claims would use different queues`,
|
|
346
351
|
fix: `pass the driver to the boot instead of installing it from an app module: runRole({ root, env, runtime: { ${input.driver}: yourDriver } })`,
|
|
347
|
-
docs: docsFor('X_RUNTIME_DRIVER_SPLIT'),
|
|
348
352
|
});
|
|
349
353
|
}
|
|
350
354
|
}
|
|
@@ -362,7 +366,6 @@ export class PortInvalidError extends UltimateError {
|
|
|
362
366
|
code: 'X_PORT_INVALID',
|
|
363
367
|
cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
|
|
364
368
|
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} my-app:latest`,
|
|
365
|
-
docs: docsFor('X_PORT_INVALID'),
|
|
366
369
|
});
|
|
367
370
|
}
|
|
368
371
|
}
|
|
@@ -382,7 +385,6 @@ export class EnvSchemaMissingError extends UltimateError {
|
|
|
382
385
|
code: 'X_CONFIG_INVALID',
|
|
383
386
|
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
384
387
|
fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
|
|
385
|
-
docs: docsFor('X_CONFIG_INVALID'),
|
|
386
388
|
});
|
|
387
389
|
}
|
|
388
390
|
}
|
|
@@ -394,7 +396,6 @@ export class CliNotImplementedError extends UltimateError {
|
|
|
394
396
|
code: 'X_NOT_IMPLEMENTED',
|
|
395
397
|
cause: `${input.feature} is not implemented in this build`,
|
|
396
398
|
fix: input.fix,
|
|
397
|
-
docs: docsFor('X_NOT_IMPLEMENTED'),
|
|
398
399
|
});
|
|
399
400
|
}
|
|
400
401
|
}
|
|
@@ -410,7 +411,7 @@ export class CliNotImplementedError extends UltimateError {
|
|
|
410
411
|
*/
|
|
411
412
|
export class StorageUnwritableError extends UltimateError {
|
|
412
413
|
constructor(cause: string, fix: string) {
|
|
413
|
-
super({ code: 'X_STORAGE_UNWRITABLE', cause, fix
|
|
414
|
+
super({ code: 'X_STORAGE_UNWRITABLE', cause, fix });
|
|
414
415
|
}
|
|
415
416
|
}
|
|
416
417
|
|
|
@@ -435,7 +436,6 @@ export class LocalDiskUnsafeError extends UltimateError {
|
|
|
435
436
|
`disk at ${input.root} — and with no STORAGE_SIGNING_SECRET it would sign upload grants ` +
|
|
436
437
|
'with the development key published in @ultimat3/storage',
|
|
437
438
|
fix: 'export S3_ENDPOINT=https://s3.example.com S3_BUCKET=my-app-uploads # or keep the disk on a mounted volume: export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
438
|
-
docs: docsFor('X_ENV_MISSING'),
|
|
439
439
|
});
|
|
440
440
|
}
|
|
441
441
|
}
|
|
@@ -450,7 +450,6 @@ export class SecretsEditorMissingError extends UltimateError {
|
|
|
450
450
|
code: 'X_SECRETS_EDITOR_MISSING',
|
|
451
451
|
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
452
452
|
fix: 'EDITOR=nano x secrets edit',
|
|
453
|
-
docs: docsFor('X_SECRETS_EDITOR_MISSING'),
|
|
454
453
|
});
|
|
455
454
|
}
|
|
456
455
|
}
|
|
@@ -466,7 +465,6 @@ export class SecretsEditFailedError extends UltimateError {
|
|
|
466
465
|
code: 'X_SECRETS_EDIT_FAILED',
|
|
467
466
|
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
468
467
|
fix: 'x secrets edit',
|
|
469
|
-
docs: docsFor('X_SECRETS_EDIT_FAILED'),
|
|
470
468
|
});
|
|
471
469
|
}
|
|
472
470
|
}
|
|
@@ -483,7 +481,6 @@ export class SecretsExistsError extends UltimateError {
|
|
|
483
481
|
code: 'X_GENERATE_CONFLICT',
|
|
484
482
|
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
485
483
|
fix: input.fix,
|
|
486
|
-
docs: docsFor('X_GENERATE_CONFLICT'),
|
|
487
484
|
});
|
|
488
485
|
}
|
|
489
486
|
}
|
package/src/favicon.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// `/favicon.ico`, which every browser requests unprompted and no Ultimate app answered: the
|
|
2
|
+
// scaffold wrote no file and neither served surface mounted a route, so a permanent 404 sat in the
|
|
3
|
+
// console of every app the framework produces. The app's own file wins; the framework answers when
|
|
4
|
+
// there is none, so an app inherits a clean console rather than a file it has to remember to add.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
|
|
7
|
+
// it against the root is string work no `Bun.file` overload does — the same necessity
|
|
8
|
+
// `dev-assets.ts` records for `ICON_SOURCE`.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { createRaster, encodeImage } from '@ultimat3/core';
|
|
11
|
+
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
12
|
+
import { applyCacheHeaders } from '@ultimat3/http';
|
|
13
|
+
|
|
14
|
+
/** What a browser asks for with no `<link rel="icon">` to tell it otherwise. */
|
|
15
|
+
export const FAVICON_PATH = '/favicon.ico';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The one file an app overrides it with — beside `ICON_SOURCE`, in the same directory, because
|
|
19
|
+
* `apps/web/site/` is already where an app's public files live. One path, never a search order: a
|
|
20
|
+
* mechanism that accepted `favicon.png` too would be two ways to do one thing, and an app whose
|
|
21
|
+
* icon is not there would have to be told which of them won.
|
|
22
|
+
*/
|
|
23
|
+
export const FAVICON_SOURCE = 'apps/web/site/favicon.ico';
|
|
24
|
+
|
|
25
|
+
/** 32px, which is what a browser tab and a bookmark bar both ask for. */
|
|
26
|
+
const DEFAULT_SIZE = 32;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Not a colour: one mid-grey LEVEL written to all three channels, exactly as
|
|
30
|
+
* `templates/scaffold-icon.ts` argues — `@ultimat3/ui` owns the colour roles and `cli -> ui` is a
|
|
31
|
+
* boundary error, so a placeholder here must claim no brand colour to begin with.
|
|
32
|
+
*/
|
|
33
|
+
const MARK_LEVEL = 128;
|
|
34
|
+
const OPAQUE = 255;
|
|
35
|
+
|
|
36
|
+
const FAVICON_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The framework's answer when the app declares none: a solid 32x32 PNG, encoded through
|
|
40
|
+
* `@ultimat3/core`'s own pipeline rather than shipped as an opaque blob — the same encoder
|
|
41
|
+
* `x new`'s icon goes through, so there is no second image format in the tree and no base64
|
|
42
|
+
* constant nobody can verify. Served as `image/png` under an `.ico` URL, which every browser reads
|
|
43
|
+
* by content type; a real ICO container would be a fourth format for one placeholder.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately NOT derived from `ICON_SOURCE`: resizing the app's install icon needs
|
|
46
|
+
* `@ultimat3/pwa`'s pipeline and would make the answer depend on a file that may be missing, which
|
|
47
|
+
* is a third rung under a mechanism that has exactly two.
|
|
48
|
+
*/
|
|
49
|
+
export function defaultFavicon(): Uint8Array {
|
|
50
|
+
const raster = createRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
|
|
51
|
+
const { pixels } = raster;
|
|
52
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
53
|
+
pixels[i] = MARK_LEVEL;
|
|
54
|
+
pixels[i + 1] = MARK_LEVEL;
|
|
55
|
+
pixels[i + 2] = MARK_LEVEL;
|
|
56
|
+
pixels[i + 3] = OPAQUE;
|
|
57
|
+
}
|
|
58
|
+
return encodeImage(raster, 'png');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Encoded once per process: the bytes are a pure function of two constants. */
|
|
62
|
+
let builtin: Uint8Array | undefined;
|
|
63
|
+
|
|
64
|
+
const builtinBytes = (): Uint8Array => {
|
|
65
|
+
builtin ??= defaultFavicon();
|
|
66
|
+
return builtin;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
|
|
70
|
+
applyCacheHeaders(
|
|
71
|
+
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
72
|
+
// `SharedArrayBuffer`, which `Response` does not accept — `dev-assets.ts`'s rule, verbatim.
|
|
73
|
+
new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
|
|
74
|
+
FAVICON_CACHE,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/** What a surface answers with, and what it says the bytes are. */
|
|
78
|
+
export interface FaviconBytes {
|
|
79
|
+
readonly bytes: Uint8Array;
|
|
80
|
+
readonly contentType: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read per REQUEST, never once at boot: `x dev` is a running process an author drops a favicon
|
|
85
|
+
* into, and a route that captured "there was no file" at startup would keep answering the
|
|
86
|
+
* placeholder until the server was restarted — the class of dev/prod difference this package's
|
|
87
|
+
* own header forbids.
|
|
88
|
+
*
|
|
89
|
+
* Its own function because the static export has no process to ask: `prerenderSite` writes these
|
|
90
|
+
* same bytes into the artifact, and a second rule for which favicon a static build carries would
|
|
91
|
+
* be a build whose tab icon differs from the server's.
|
|
92
|
+
*/
|
|
93
|
+
export async function faviconBytes(root: string): Promise<FaviconBytes> {
|
|
94
|
+
const own = Bun.file(join(root, FAVICON_SOURCE));
|
|
95
|
+
if (await own.exists()) return { bytes: await own.bytes(), contentType: 'image/x-icon' };
|
|
96
|
+
return { bytes: builtinBytes(), contentType: 'image/png' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function faviconResponse(root: string): Promise<Response> {
|
|
100
|
+
const favicon = await faviconBytes(root);
|
|
101
|
+
return iconResponse(favicon.bytes, favicon.contentType);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mounted through `assetRoutes`, so `x dev`, the container and every test that boots either get it
|
|
106
|
+
* from one place. Public by definition — a browser requests it before anyone has signed in.
|
|
107
|
+
*/
|
|
108
|
+
export const faviconRoute = (root: string): Route => ({
|
|
109
|
+
method: 'GET',
|
|
110
|
+
path: FAVICON_PATH,
|
|
111
|
+
meta: { name: 'assets.favicon', auth: 'public', cache: FAVICON_CACHE, tags: ['assets'] },
|
|
112
|
+
handler: async (_request: UltimateRequest): Promise<Response> => faviconResponse(root),
|
|
113
|
+
});
|