@ultimat3/cli 9.0.0 → 10.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/package.json +26 -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 +4 -3
- package/src/cmd-build.ts +2 -2
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-deploy.ts +14 -3
- 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 +2 -2
- package/src/cmd-shot.ts +22 -2
- package/src/db-finding.ts +2 -2
- package/src/db-seed.ts +0 -3
- package/src/dev-cache.ts +12 -5
- package/src/dev-lock.ts +8 -7
- package/src/dev-runtime.ts +11 -3
- package/src/dev-storage.ts +8 -2
- package/src/dev-sync.ts +37 -2
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +3 -2
- package/src/error-codes.ts +9 -2
- package/src/error-contract.ts +5 -5
- package/src/errors.ts +1 -29
- package/src/flag-reads.ts +2 -2
- package/src/generate-write.ts +3 -2
- package/src/guards.ts +4 -4
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +32 -4
- package/src/island-routes.ts +7 -1
- package/src/mcp-errors.ts +2 -2
- package/src/metrics-endpoint.ts +0 -2
- package/src/output.ts +2 -2
- package/src/prerender.ts +32 -19
- package/src/static-report.ts +41 -3
- 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-repo.ts +16 -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 +3 -2
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,31 +37,31 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/http": "
|
|
49
|
-
"@ultimat3/i18n": "
|
|
50
|
-
"@ultimat3/jobs": "
|
|
51
|
-
"@ultimat3/mail": "
|
|
52
|
-
"@ultimat3/manifest": "
|
|
53
|
-
"@ultimat3/mcp": "
|
|
54
|
-
"@ultimat3/policy": "
|
|
55
|
-
"@ultimat3/pwa": "
|
|
56
|
-
"@ultimat3/query": "
|
|
57
|
-
"@ultimat3/realtime": "
|
|
58
|
-
"@ultimat3/render": "
|
|
59
|
-
"@ultimat3/schema": "
|
|
60
|
-
"@ultimat3/scraping": "
|
|
61
|
-
"@ultimat3/seo": "
|
|
62
|
-
"@ultimat3/storage": "
|
|
63
|
-
"@ultimat3/testing": "
|
|
64
|
-
"@ultimat3/time": "
|
|
40
|
+
"@ultimat3/action": "10.0.0",
|
|
41
|
+
"@ultimat3/admin": "10.0.0",
|
|
42
|
+
"@ultimat3/ai": "10.0.0",
|
|
43
|
+
"@ultimat3/auth": "10.0.0",
|
|
44
|
+
"@ultimat3/cache": "10.0.0",
|
|
45
|
+
"@ultimat3/core": "10.0.0",
|
|
46
|
+
"@ultimat3/db": "10.0.0",
|
|
47
|
+
"@ultimat3/entity": "10.0.0",
|
|
48
|
+
"@ultimat3/http": "10.0.0",
|
|
49
|
+
"@ultimat3/i18n": "10.0.0",
|
|
50
|
+
"@ultimat3/jobs": "10.0.0",
|
|
51
|
+
"@ultimat3/mail": "10.0.0",
|
|
52
|
+
"@ultimat3/manifest": "10.0.0",
|
|
53
|
+
"@ultimat3/mcp": "10.0.0",
|
|
54
|
+
"@ultimat3/policy": "10.0.0",
|
|
55
|
+
"@ultimat3/pwa": "10.0.0",
|
|
56
|
+
"@ultimat3/query": "10.0.0",
|
|
57
|
+
"@ultimat3/realtime": "10.0.0",
|
|
58
|
+
"@ultimat3/render": "10.0.0",
|
|
59
|
+
"@ultimat3/schema": "10.0.0",
|
|
60
|
+
"@ultimat3/scraping": "10.0.0",
|
|
61
|
+
"@ultimat3/seo": "10.0.0",
|
|
62
|
+
"@ultimat3/storage": "10.0.0",
|
|
63
|
+
"@ultimat3/testing": "10.0.0",
|
|
64
|
+
"@ultimat3/time": "10.0.0",
|
|
65
65
|
"babel-preset-solid": "^1.9.15"
|
|
66
66
|
}
|
|
67
67
|
}
|
package/src/affected.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
// directory is checkout-relative while a caller's scan yields paths relative to its own root.
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { singleLine, UltimateError } from '@ultimat3/core';
|
|
16
|
-
import { docsFor } from './error-codes';
|
|
17
16
|
import { BadFlagError } from './errors';
|
|
18
17
|
import type { ExecResult, Runner } from './exec';
|
|
19
18
|
import { execOutput } from './exec';
|
|
@@ -193,7 +192,6 @@ export async function gitRoot(runner: Runner, cwd: string, command: string): Pro
|
|
|
193
192
|
code: 'X_CLI_UNEXPECTED',
|
|
194
193
|
cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
|
|
195
194
|
fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
|
|
196
|
-
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
197
195
|
});
|
|
198
196
|
}
|
|
199
197
|
|
|
@@ -245,7 +243,6 @@ export async function changedFiles(
|
|
|
245
243
|
code: 'X_CLI_UNEXPECTED',
|
|
246
244
|
cause: `"${failed.command.join(' ')}" exited ${failed.code} in ${options.cwd}: ${singleLine(execOutput(failed))}`,
|
|
247
245
|
fix: `run it yourself to see why: ${failed.command.join(' ')}`,
|
|
248
|
-
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
249
246
|
});
|
|
250
247
|
}
|
|
251
248
|
return [...new Set(runs.flatMap((run) => paths(run.stdout)))].sort();
|
package/src/app-boundaries.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { join as joinPath } from 'node:path';
|
|
15
15
|
// The POSIX variants resolve specifiers against import-graph keys, which are POSIX on every host.
|
|
16
16
|
import { dirname, join, normalize, relative } from 'node:path/posix';
|
|
17
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
17
18
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
18
19
|
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
19
20
|
import type { Finding } from './output';
|
|
@@ -48,8 +49,6 @@ const CODE_OF: Readonly<Record<BoundaryRule, BoundaryCode>> = {
|
|
|
48
49
|
*/
|
|
49
50
|
export const boundaryCodeOf = (rule: BoundaryRule): BoundaryCode => CODE_OF[rule];
|
|
50
51
|
|
|
51
|
-
const docs = (code: BoundaryCode): string => `https://ultimate.dev/errors/${code}`;
|
|
52
|
-
|
|
53
52
|
const isRoute = (path: string): boolean => /\/(page|layout|route)\.[cm]?tsx?$/.test(path);
|
|
54
53
|
const isService = (path: string): boolean => /\/service\.[cm]?ts$/.test(path);
|
|
55
54
|
const isDbSpecifier = (specifier: string): boolean =>
|
|
@@ -128,7 +127,7 @@ const surfaceFindings = (graph: ImportGraph): readonly Finding[] =>
|
|
|
128
127
|
code,
|
|
129
128
|
cause: violation.cause,
|
|
130
129
|
fix: violation.fix,
|
|
131
|
-
docs:
|
|
130
|
+
docs: ERROR_DOCS_URL,
|
|
132
131
|
at: violation.importer,
|
|
133
132
|
};
|
|
134
133
|
});
|
|
@@ -198,7 +197,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
198
197
|
code: 'X_BOUNDARY_ROUTE_TO_DB',
|
|
199
198
|
cause: `route imports the database ("${specifier}") — routes call actions and queries`,
|
|
200
199
|
fix: generate('query', file.path, 'then call it from'),
|
|
201
|
-
docs:
|
|
200
|
+
docs: ERROR_DOCS_URL,
|
|
202
201
|
at: file.path,
|
|
203
202
|
});
|
|
204
203
|
}
|
|
@@ -207,7 +206,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
207
206
|
code: 'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
208
207
|
cause: `service imports HTTP ("${specifier}") — a service that knows about requests cannot be reused by a job`,
|
|
209
208
|
fix: generate('action', file.path, 'read the request there and pass plain values to'),
|
|
210
|
-
docs:
|
|
209
|
+
docs: ERROR_DOCS_URL,
|
|
211
210
|
at: file.path,
|
|
212
211
|
});
|
|
213
212
|
}
|
package/src/app-env.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
checkEnvExample,
|
|
12
|
+
ENV_EXAMPLE_PATH,
|
|
13
|
+
ERROR_DOCS_URL,
|
|
14
|
+
renderEnvExample,
|
|
15
|
+
} from '@ultimat3/core';
|
|
11
16
|
import { APP_CONFIG_FILE } from './app-root';
|
|
12
17
|
import type { Finding } from './output';
|
|
13
18
|
import { findingFrom } from './output';
|
|
@@ -63,7 +68,7 @@ const driftFinding = (cause: string): Finding => ({
|
|
|
63
68
|
// The generator, not the assertion: `assertEnvExample`'s own fix is a `Bun.write(…)` call for
|
|
64
69
|
// an app that has a schema object in scope, and a gate reader has a shell.
|
|
65
70
|
fix: 'x env example',
|
|
66
|
-
docs:
|
|
71
|
+
docs: ERROR_DOCS_URL,
|
|
67
72
|
at: ENV_EXAMPLE_PATH,
|
|
68
73
|
});
|
|
69
74
|
|
package/src/browser-launcher.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { existsSync } from 'node:fs';
|
|
|
7
7
|
import { UltimateError } from '@ultimat3/core';
|
|
8
8
|
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
9
9
|
import { localBrowser } from '@ultimat3/scraping';
|
|
10
|
-
import { docsFor } from './error-codes';
|
|
11
10
|
|
|
12
11
|
/**
|
|
13
12
|
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
@@ -33,7 +32,6 @@ export class ShotBrowserMissingError extends UltimateError {
|
|
|
33
32
|
// one literal, and a fix line the gate cannot read is a fix line nothing holds to the
|
|
34
33
|
// contract. `browser-launcher.test.ts` pins it against the constant instead.
|
|
35
34
|
fix: 'bun add -d puppeteer-core',
|
|
36
|
-
docs: docsFor('X_SHOT_BROWSER_MISSING'),
|
|
37
35
|
meta: { root: input.root, package: BROWSER_PACKAGE },
|
|
38
36
|
});
|
|
39
37
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
11
12
|
import type { Manifest, RouteFact } from '@ultimat3/manifest';
|
|
12
13
|
import { formatBytes, parseByteBudget } from '@ultimat3/render';
|
|
13
14
|
import type { Finding } from './output';
|
|
@@ -75,7 +76,7 @@ function unmeasuredFinding(url: string, declared: string, built: boolean): Findi
|
|
|
75
76
|
fix: built
|
|
76
77
|
? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
|
|
77
78
|
: 'x build --target static --json && x verify --json',
|
|
78
|
-
docs:
|
|
79
|
+
docs: ERROR_DOCS_URL,
|
|
79
80
|
at: url,
|
|
80
81
|
};
|
|
81
82
|
}
|
|
@@ -106,7 +107,7 @@ export function checkBudgets(
|
|
|
106
107
|
code: 'X_BUDGET_EXCEEDED',
|
|
107
108
|
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
108
109
|
fix: `x routes --json to see the chain, then move the heavy import behind hydrate: 'interaction'`,
|
|
109
|
-
docs:
|
|
110
|
+
docs: ERROR_DOCS_URL,
|
|
110
111
|
at: route.url,
|
|
111
112
|
});
|
|
112
113
|
}
|
|
@@ -115,7 +116,7 @@ export function checkBudgets(
|
|
|
115
116
|
code: 'X_BUDGET_EXCEEDED',
|
|
116
117
|
cause: `${route.url} LCP ${measured.lcpMs}ms over the ${lcp}ms budget`,
|
|
117
118
|
fix: `raise the budget in defineRoute, or switch render to 'isr' to serve it prebuilt`,
|
|
118
|
-
docs:
|
|
119
|
+
docs: ERROR_DOCS_URL,
|
|
119
120
|
at: route.url,
|
|
120
121
|
});
|
|
121
122
|
}
|
package/src/cmd-build.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
|
|
6
|
+
import { ERROR_DOCS_URL, frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
|
|
7
7
|
import { requireAppRoot } from './app-root';
|
|
8
8
|
import { runVerify } from './cmd-verify';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -143,7 +143,7 @@ export function buildResult(input: {
|
|
|
143
143
|
code: 'X_BUILD_FAILED',
|
|
144
144
|
cause: `${input.command.join(' ')} exited ${result.code}`,
|
|
145
145
|
fix: target === 'docker' ? 'x doctor --json && docker info' : 'x verify --json',
|
|
146
|
-
docs:
|
|
146
|
+
docs: ERROR_DOCS_URL,
|
|
147
147
|
},
|
|
148
148
|
],
|
|
149
149
|
data: {
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// `x db branch ls` used to clone a database called `ls`, because the argument was the name.
|
|
4
4
|
// The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
|
|
5
5
|
|
|
6
|
-
import { nearestName } from '@ultimat3/core';
|
|
6
|
+
import { ERROR_DOCS_URL, nearestName } from '@ultimat3/core';
|
|
7
7
|
import { createPostgresClient, type DbClient } from '@ultimat3/db';
|
|
8
8
|
import type { CommandContext } from './command';
|
|
9
9
|
import type { BranchRow } from './db-branch';
|
|
@@ -215,6 +215,6 @@ function notABranch(services: DevServices, name: string): CommandResult {
|
|
|
215
215
|
code: 'X_DB_BRANCH_FAILED',
|
|
216
216
|
cause: `"${name}" is not a branch of this database, so nothing was dropped (it would be ${target})`,
|
|
217
217
|
fix: LIST_FIX,
|
|
218
|
-
docs:
|
|
218
|
+
docs: ERROR_DOCS_URL,
|
|
219
219
|
});
|
|
220
220
|
}
|
package/src/cmd-deploy.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// anything that runs containers can execute.
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
7
|
import { requireAppRoot } from './app-root';
|
|
7
8
|
import type { CliCommand, CommandContext } from './command';
|
|
8
9
|
import { BadFlagError, UnknownCommandError } from './errors';
|
|
@@ -31,6 +32,13 @@ import { quoteArg } from './shell-quote';
|
|
|
31
32
|
* honours. Both compose definitions carry it — `docker/docker-compose.prod.yml`'s `backfill` and
|
|
32
33
|
* the one `templates/scaffold-container.ts` scaffolds, which also gates on `migrate` completing.
|
|
33
34
|
* This paragraph said they "still owe" both for as long as they have had them.
|
|
35
|
+
*
|
|
36
|
+
* COMPOSE-ONLY, and `backfill` is why that has to be written down. `docker/helm`'s `roles:` map is
|
|
37
|
+
* `web|sync|worker|scheduler|replicator` plus a `migrate` Job — there is no `backfill` object in
|
|
38
|
+
* the chart at all — and the helm plan is one `helm upgrade --install`, so this list describes
|
|
39
|
+
* neither the objects a chart deploy creates nor the steps it runs. Every reader of it is therefore
|
|
40
|
+
* inside the compose branch; the SUMMARY reads `plan.steps` instead, or `x deploy --method helm`
|
|
41
|
+
* reports a post-deploy sweep to an operator that nothing will ever run.
|
|
34
42
|
*/
|
|
35
43
|
export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
|
|
36
44
|
|
|
@@ -202,11 +210,14 @@ export const deployCommand: CliCommand = {
|
|
|
202
210
|
env: { ...plan.env },
|
|
203
211
|
steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
|
|
204
212
|
};
|
|
213
|
+
// The roles THIS plan has, never the compose list: on `--method helm` there is one step,
|
|
214
|
+
// `all`, and naming `backfill` there promises an operator a sweep the chart cannot run.
|
|
215
|
+
const roles = plan.steps.map((step) => step.role).join(',');
|
|
205
216
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
206
217
|
return {
|
|
207
218
|
ok: true,
|
|
208
219
|
command: 'deploy',
|
|
209
|
-
summary: msg('cli.deploy.plan', { images: 1, roles
|
|
220
|
+
summary: msg('cli.deploy.plan', { images: 1, roles }),
|
|
210
221
|
data: planJson,
|
|
211
222
|
lines: plan.steps.map(
|
|
212
223
|
(step) => ` ${step.role.padEnd(10)} ${stepLine(plan.env, step.command)}`,
|
|
@@ -225,7 +236,7 @@ export const deployCommand: CliCommand = {
|
|
|
225
236
|
code: 'X_DEPLOY_FAILED',
|
|
226
237
|
cause: `role "${step.role}" step exited ${result.code}`,
|
|
227
238
|
fix: `${stepLine(plan.env, step.command)} # run it directly to see the full output`,
|
|
228
|
-
docs:
|
|
239
|
+
docs: ERROR_DOCS_URL,
|
|
229
240
|
},
|
|
230
241
|
],
|
|
231
242
|
data: planJson,
|
|
@@ -235,7 +246,7 @@ export const deployCommand: CliCommand = {
|
|
|
235
246
|
return {
|
|
236
247
|
ok: true,
|
|
237
248
|
command: 'deploy',
|
|
238
|
-
summary: msg('cli.deploy.plan', { images: 1, roles
|
|
249
|
+
summary: msg('cli.deploy.plan', { images: 1, roles }),
|
|
239
250
|
data: planJson,
|
|
240
251
|
};
|
|
241
252
|
},
|
package/src/cmd-docs.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// should not have to guess which of 29 packages holds the answer, and it must never be handed a
|
|
5
5
|
// URL: `node_modules` already contains every doc, because the published artifact IS the source.
|
|
6
6
|
|
|
7
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
8
|
import type { DocEntry, DocHit } from '@ultimat3/manifest';
|
|
8
9
|
import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest';
|
|
9
10
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -22,7 +23,7 @@ const unresolvedFinding = (): Finding => ({
|
|
|
22
23
|
code: 'X_CLI_UNEXPECTED',
|
|
23
24
|
cause: '@ultimat3/core does not resolve from the installed CLI, so no docs could be read',
|
|
24
25
|
fix: 'bun install && x doctor --json',
|
|
25
|
-
docs:
|
|
26
|
+
docs: ERROR_DOCS_URL,
|
|
26
27
|
at: import.meta.dir,
|
|
27
28
|
});
|
|
28
29
|
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { tryResolveEnvironment, usesDevCursorSecret } from '@ultimat3/core';
|
|
7
|
+
import { ERROR_DOCS_URL, tryResolveEnvironment, usesDevCursorSecret } from '@ultimat3/core';
|
|
8
8
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
9
9
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -49,12 +49,10 @@ export interface DoctorProbe {
|
|
|
49
49
|
snapshots(): Promise<readonly Finding[]>;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
53
|
-
|
|
54
52
|
const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
|
|
55
53
|
at === undefined
|
|
56
|
-
? { code, cause, fix, docs:
|
|
57
|
-
: { code, cause, fix, docs:
|
|
54
|
+
? { code, cause, fix, docs: ERROR_DOCS_URL }
|
|
55
|
+
: { code, cause, fix, docs: ERROR_DOCS_URL, at };
|
|
58
56
|
|
|
59
57
|
export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
|
|
60
58
|
|
package/src/cmd-env.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
// Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { checkEnv, ENV_EXAMPLE_PATH, maskedEnvValues } from '@ultimat3/core';
|
|
7
|
+
import { checkEnv, ENV_EXAMPLE_PATH, ERROR_DOCS_URL, maskedEnvValues } from '@ultimat3/core';
|
|
8
8
|
import { ENV_SCHEMA_EXPORT, envExampleFor, loadEnvSchema } from './app-env';
|
|
9
9
|
import { APP_CONFIG_FILE, requireAppRoot } from './app-root';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
@@ -54,7 +54,7 @@ async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
|
|
|
54
54
|
code: 'X_ENV_MISSING',
|
|
55
55
|
cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
|
|
56
56
|
fix: issue.fix,
|
|
57
|
-
docs:
|
|
57
|
+
docs: ERROR_DOCS_URL,
|
|
58
58
|
at: ENV_EXAMPLE_PATH,
|
|
59
59
|
}));
|
|
60
60
|
return {
|
package/src/cmd-fix.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (`docs/architecture/02-boundaries.md`) — a caller runs the printed edit, or the generated
|
|
4
4
|
// `git mv`, itself.
|
|
5
5
|
|
|
6
|
-
import { nearestName } from '@ultimat3/core';
|
|
6
|
+
import { ERROR_DOCS_URL, nearestName } from '@ultimat3/core';
|
|
7
7
|
import { appImportGraph, readAppSources } from './app-boundaries';
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import type { BoundaryCut } from './boundary-cuts';
|
|
@@ -18,8 +18,6 @@ export { planBoundaryCuts };
|
|
|
18
18
|
|
|
19
19
|
export const FIX_SUBCOMMANDS = ['boundary'] as const;
|
|
20
20
|
|
|
21
|
-
const docsUrl = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
22
|
-
|
|
23
21
|
/**
|
|
24
22
|
* Accept either an app-root-relative path or a suffix that matches exactly one scanned file —
|
|
25
23
|
* an agent copying the path out of a `fix:` line has the short form
|
|
@@ -81,7 +79,7 @@ const findingForCut = (cut: BoundaryCut): Finding => ({
|
|
|
81
79
|
code: cut.code,
|
|
82
80
|
cause: cut.cause,
|
|
83
81
|
fix: cut.edit,
|
|
84
|
-
docs:
|
|
82
|
+
docs: ERROR_DOCS_URL,
|
|
85
83
|
at: cut.at,
|
|
86
84
|
});
|
|
87
85
|
|
package/src/cmd-new.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { chmod } from 'node:fs/promises';
|
|
7
7
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
8
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import { dedupe } from './cmd-generate';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
11
|
import { MissingPositionalError } from './errors';
|
|
@@ -195,7 +195,7 @@ export const newCommand: CliCommand = {
|
|
|
195
195
|
code: 'X_GENERATE_CONFLICT',
|
|
196
196
|
cause: `${target} already exists`,
|
|
197
197
|
fix: `x new ${app.kebab} --force, or choose another name`,
|
|
198
|
-
docs:
|
|
198
|
+
docs: ERROR_DOCS_URL,
|
|
199
199
|
at: target,
|
|
200
200
|
},
|
|
201
201
|
],
|
package/src/cmd-shot.ts
CHANGED
|
@@ -164,6 +164,17 @@ const intFlag = (
|
|
|
164
164
|
fallback,
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
|
|
169
|
+
* call, for the reason every `Runner` in this package is one: the failure path below — a boot that
|
|
170
|
+
* throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
|
|
171
|
+
*/
|
|
172
|
+
export type BootDevServer = (input: {
|
|
173
|
+
readonly root: string;
|
|
174
|
+
readonly port: number;
|
|
175
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
176
|
+
}) => Promise<{ readonly url: string; stop(): Promise<void> }>;
|
|
177
|
+
|
|
167
178
|
export interface ShotServer {
|
|
168
179
|
readonly url: string;
|
|
169
180
|
/** Which server the picture is of. Reported, because the two have different failure modes. */
|
|
@@ -272,6 +283,7 @@ export async function devServerFor(
|
|
|
272
283
|
root: string,
|
|
273
284
|
env: Readonly<Record<string, string | undefined>>,
|
|
274
285
|
port: number,
|
|
286
|
+
boot: BootDevServer = (input) => startDev(input),
|
|
275
287
|
): Promise<ShotServer> {
|
|
276
288
|
const services = resolveServices(root, env);
|
|
277
289
|
const file = Bun.file(lockPath(services.stateDir));
|
|
@@ -281,13 +293,21 @@ export async function devServerFor(
|
|
|
281
293
|
return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
|
|
282
294
|
}
|
|
283
295
|
}
|
|
284
|
-
await preflight({
|
|
296
|
+
const { release } = await preflight({
|
|
285
297
|
stateDir: services.stateDir,
|
|
286
298
|
port,
|
|
287
299
|
hostname: DEV_BINDING.hostname,
|
|
288
300
|
embeddedDb: services.db.mode === 'embedded',
|
|
289
301
|
});
|
|
290
|
-
|
|
302
|
+
// The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
|
|
303
|
+
// looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
|
|
304
|
+
// same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
|
|
305
|
+
// checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
|
|
306
|
+
// teardown must never replace the failure it is cleaning up after.
|
|
307
|
+
const dev = await boot({ root, port, env }).catch((error: unknown) => {
|
|
308
|
+
release();
|
|
309
|
+
throw error;
|
|
310
|
+
});
|
|
291
311
|
await writeLock(services.stateDir, {
|
|
292
312
|
pid: process.pid,
|
|
293
313
|
port,
|
package/src/db-finding.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// step that failed. Its own module because `cmd-db.ts` and `cmd-db-branch.ts` both need it and
|
|
4
4
|
// neither may import the other.
|
|
5
5
|
|
|
6
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { Finding } from './output';
|
|
8
8
|
import { findingFrom, isUltimateErrorShape } from './output';
|
|
9
9
|
|
|
@@ -24,5 +24,5 @@ export const stepFinding = (error: unknown, code: string): Finding =>
|
|
|
24
24
|
// a TypeError raised while reporting it.
|
|
25
25
|
cause: renderThrowable(error),
|
|
26
26
|
fix: 'x doctor --json',
|
|
27
|
-
docs:
|
|
27
|
+
docs: ERROR_DOCS_URL,
|
|
28
28
|
};
|
package/src/db-seed.ts
CHANGED
|
@@ -12,7 +12,6 @@ import type { Environment } from '@ultimat3/core';
|
|
|
12
12
|
import { UltimateError } from '@ultimat3/core';
|
|
13
13
|
import type { Driver, Seed, SeedTier } from '@ultimat3/entity';
|
|
14
14
|
import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
|
|
15
|
-
import { docsFor } from './error-codes';
|
|
16
15
|
import { BadFlagError } from './errors';
|
|
17
16
|
import type { Finding, JsonValue } from './output';
|
|
18
17
|
import { findingFrom } from './output';
|
|
@@ -49,7 +48,6 @@ export class SeedUnknownError extends UltimateError {
|
|
|
49
48
|
// A dry run, never a bare `x db seed`: the command that answers "which seeds are there" must
|
|
50
49
|
// not be the command that writes them.
|
|
51
50
|
fix: 'x db seed --dry-run --json',
|
|
52
|
-
docs: docsFor('X_DECLARATION_UNKNOWN'),
|
|
53
51
|
});
|
|
54
52
|
}
|
|
55
53
|
}
|
|
@@ -75,7 +73,6 @@ export class SeedEnvironmentError extends UltimateError {
|
|
|
75
73
|
code: 'X_SEED_ENVIRONMENT',
|
|
76
74
|
cause: `seed "${input.seed}" is tier ${input.tier} and ULTIMATE_ENV resolved ${input.environment}, where x db seed runs ${input.tiers.join(', ')} — ULTIMATE_SEED_TIER=${input.tier} says this deploy takes it anyway`,
|
|
77
75
|
fix: `x db seed ${input.seed} --tier ${input.tier} --json`,
|
|
78
|
-
docs: docsFor('X_SEED_ENVIRONMENT'),
|
|
79
76
|
});
|
|
80
77
|
}
|
|
81
78
|
}
|
package/src/dev-cache.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
resetTiers,
|
|
21
21
|
} from '@ultimat3/cache';
|
|
22
22
|
import type { CacheTierName } from '@ultimat3/core';
|
|
23
|
-
import { CACHE_TIERS, defineConfig, logger } from '@ultimat3/core';
|
|
23
|
+
import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/core';
|
|
24
24
|
import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
|
|
25
25
|
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
26
26
|
import { APP_CONFIG_FILE } from './app-root';
|
|
@@ -187,7 +187,7 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
187
187
|
void applyBroadcast(payload);
|
|
188
188
|
})
|
|
189
189
|
.catch((error: unknown) => {
|
|
190
|
-
logger.warn('cache.broadcast.subscribe-failed', { error:
|
|
190
|
+
logger.warn('cache.broadcast.subscribe-failed', { error: broadcastErrorText(error) });
|
|
191
191
|
return undefined;
|
|
192
192
|
});
|
|
193
193
|
|
|
@@ -199,8 +199,15 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
|
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
/**
|
|
203
|
+
* `renderThrowable`, never `instanceof Error` + `.message`. Both run on a value this process did
|
|
204
|
+
* not build — a `Proxy` traps `getPrototypeOf` and a `message` getter can raise — and a throw here
|
|
205
|
+
* is inside the handler whose whole job is to keep the subscriber loop alive: losing it ends
|
|
206
|
+
* cross-instance cache invalidation for the process, quietly, which is the failure the loop's own
|
|
207
|
+
* `try` exists to prevent. The old form also answered `'unknown error'` for every non-`Error`
|
|
208
|
+
* throw, so a driver rejecting with a string reported nothing at all.
|
|
209
|
+
*/
|
|
210
|
+
export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
|
|
204
211
|
|
|
205
212
|
/**
|
|
206
213
|
* A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
|
|
@@ -214,6 +221,6 @@ async function applyBroadcast(payload: string): Promise<void> {
|
|
|
214
221
|
const wire = parsed.filter((value): value is string => typeof value === 'string');
|
|
215
222
|
if (wire.length > 0) await receiveInvalidationBroadcast(wire);
|
|
216
223
|
} catch (error) {
|
|
217
|
-
logger.warn('cache.broadcast.apply-failed', { error:
|
|
224
|
+
logger.warn('cache.broadcast.apply-failed', { error: broadcastErrorText(error) });
|
|
218
225
|
}
|
|
219
226
|
}
|
package/src/dev-lock.ts
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { UltimateError } from '@ultimat3/core';
|
|
20
|
-
import { docsFor } from './error-codes';
|
|
19
|
+
import { stringField, UltimateError } from '@ultimat3/core';
|
|
21
20
|
import { exec, type Runner } from './exec';
|
|
22
21
|
import { quoteArg } from './shell-quote';
|
|
23
22
|
|
|
@@ -68,7 +67,11 @@ export const isProcessAlive = (pid: number): boolean => {
|
|
|
68
67
|
return true;
|
|
69
68
|
} catch (error) {
|
|
70
69
|
// EPERM means it exists and belongs to another user. Alive, and not ours to signal.
|
|
71
|
-
|
|
70
|
+
// `stringField`, never a cast plus a property read: the rule `metrics-endpoint.ts` states and
|
|
71
|
+
// `caught-value-reads.test.ts` enforces — a getter that throws would take this path down one
|
|
72
|
+
// line before the guard meant to make it safe, and this guard decides whether a second `x dev`
|
|
73
|
+
// is allowed to open a single-writer data directory.
|
|
74
|
+
return stringField(error, 'code') === 'EPERM';
|
|
72
75
|
}
|
|
73
76
|
};
|
|
74
77
|
|
|
@@ -96,7 +99,6 @@ export class DevAlreadyRunningError extends UltimateError {
|
|
|
96
99
|
code: 'X_DEV_ALREADY_RUNNING',
|
|
97
100
|
cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
|
|
98
101
|
fix: `use the one already running at ${input.lock.url}, or stop it: kill ${input.lock.pid}`,
|
|
99
|
-
docs: docsFor('X_DEV_ALREADY_RUNNING'),
|
|
100
102
|
meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
|
|
101
103
|
});
|
|
102
104
|
}
|
|
@@ -118,7 +120,6 @@ export class DevLockUnreadableError extends UltimateError {
|
|
|
118
120
|
code: 'X_DEV_LOCK_UNREADABLE',
|
|
119
121
|
cause: `${input.path} could not be parsed as a dev lock and could not be removed, so x dev cannot tell whether another process owns ${input.stateDir}`,
|
|
120
122
|
fix: `rm ${quoteArg(input.path)} # then re-run x dev`,
|
|
121
|
-
docs: docsFor('X_DEV_LOCK_UNREADABLE'),
|
|
122
123
|
meta: { path: input.path, stateDir: input.stateDir },
|
|
123
124
|
});
|
|
124
125
|
}
|
|
@@ -198,7 +199,6 @@ export class DevPortInUseError extends UltimateError {
|
|
|
198
199
|
holder.pid === undefined
|
|
199
200
|
? `x dev --port ${input.suggestion}`
|
|
200
201
|
: `x dev --port ${input.suggestion} # or free it, if that pid is yours: kill ${holder.pid}`,
|
|
201
|
-
docs: docsFor('X_PORT_IN_USE'),
|
|
202
202
|
meta: { port: input.port, ...holder },
|
|
203
203
|
});
|
|
204
204
|
}
|
|
@@ -247,7 +247,8 @@ function claimExclusive(path: string, lock: DevLock): boolean {
|
|
|
247
247
|
try {
|
|
248
248
|
fd = openSync(path, 'wx');
|
|
249
249
|
} catch (error) {
|
|
250
|
-
|
|
250
|
+
// Same rule as `isProcessAlive` above: read the field, never cast and dereference.
|
|
251
|
+
if (stringField(error, 'code') === 'EEXIST') return false;
|
|
251
252
|
throw error;
|
|
252
253
|
}
|
|
253
254
|
try {
|
package/src/dev-runtime.ts
CHANGED
|
@@ -186,8 +186,14 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
186
186
|
// missing" from a helper the operator never configured. Not an outright ban on the local disk in
|
|
187
187
|
// production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
|
|
188
188
|
// the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
// `{ env }` on ALL THREE, never the ambient `process.env`: this function is HANDED the boot's
|
|
190
|
+
// environment and reads `S3_BUCKET` off it one branch above, so a guard asking a second source
|
|
191
|
+
// could answer `development` for a process booting as `production` — or, with
|
|
192
|
+
// `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
|
|
193
|
+
// `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
|
|
194
|
+
// exists, and the name the message prints are one question about one table.
|
|
195
|
+
if (!isLocal({ env }) && usesDevStorageSecret({ env })) {
|
|
196
|
+
throw new LocalDiskUnsafeError({ environment: resolveEnvironment({ env }), root });
|
|
191
197
|
}
|
|
192
198
|
try {
|
|
193
199
|
mkdirSync(root, { recursive: true });
|
|
@@ -198,7 +204,9 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
198
204
|
`mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
|
|
199
205
|
);
|
|
200
206
|
}
|
|
201
|
-
|
|
207
|
+
// The guard three lines up reads `env`; so must the disk it guards. Otherwise the boot's
|
|
208
|
+
// environment decides whether signing is allowed and the process's decides what key is used.
|
|
209
|
+
return defineStorage({ disks: { local: localDriver({ root, env }) }, default: 'local' });
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/**
|
package/src/dev-storage.ts
CHANGED
|
@@ -151,8 +151,14 @@ export function parseByteRange(
|
|
|
151
151
|
if (from === '' && to === '') return undefined;
|
|
152
152
|
if (from === '') {
|
|
153
153
|
const wanted = Number(to);
|
|
154
|
-
// A suffix longer than the object is the whole object, not a refusal
|
|
155
|
-
|
|
154
|
+
// A suffix longer than the object is the whole object, not a refusal — but there is no whole
|
|
155
|
+
// object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
|
|
156
|
+
// -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
|
|
157
|
+
// requires 416. The non-suffix branch already gets this right through `start >= size`; this
|
|
158
|
+
// one had no equivalent test.
|
|
159
|
+
return size === 0 || wanted === 0
|
|
160
|
+
? UNSATISFIABLE
|
|
161
|
+
: { start: Math.max(size - wanted, 0), end: size - 1 };
|
|
156
162
|
}
|
|
157
163
|
const start = Number(from);
|
|
158
164
|
const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
|