@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/errors.ts
CHANGED
|
@@ -1,136 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
/** Codes this package declares and owns. */
|
|
7
|
-
export const CLI_OWNED_ERROR_CODES = [
|
|
8
|
-
'X_CLI_UNKNOWN_COMMAND',
|
|
9
|
-
'X_CLI_BAD_FLAG',
|
|
10
|
-
'X_VERIFY_FAILED',
|
|
11
|
-
'X_NOT_IN_APP',
|
|
12
|
-
'X_BUN_VERSION',
|
|
13
|
-
'X_TEST_NO_FILES',
|
|
14
|
-
'X_TEST_SHARD_FAILED',
|
|
15
|
-
'X_SCAFFOLD_PATH_ESCAPE',
|
|
16
|
-
'X_GENERATE_JSON_INVALID',
|
|
17
|
-
'X_APP_PACKAGE_INVALID',
|
|
18
|
-
'X_ERROR_CODE_UNKNOWN',
|
|
19
|
-
'X_DECLARATION_UNKNOWN',
|
|
20
|
-
'X_JOB_UNKNOWN',
|
|
21
|
-
'X_FIX_TARGET_UNKNOWN',
|
|
22
|
-
'X_ERROR_FIX_INVALID',
|
|
23
|
-
'X_ERROR_CODE_UNDOCUMENTED',
|
|
24
|
-
'X_ERROR_CODE_UNREGISTERED',
|
|
25
|
-
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
26
|
-
// `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
|
|
27
|
-
// carries an `X_*` code to the same reader a throw does; the registry is what makes that code
|
|
28
|
-
// explainable, unique and documented-or-fail, so a code the CLI emits is a code the CLI owns.
|
|
29
|
-
'X_CLI_UNEXPECTED',
|
|
30
|
-
'X_TYPECHECK_FAILED',
|
|
31
|
-
'X_LINT_FAILED',
|
|
32
|
-
'X_TEST_FAILED',
|
|
33
|
-
'X_FILE_TOO_LONG',
|
|
34
|
-
'X_PACKAGE_SHAPE',
|
|
35
|
-
'X_RELEASE_VERSION_SKEW',
|
|
36
|
-
'X_MANIFEST_STALE',
|
|
37
|
-
'X_BUDGET_UNMEASURED',
|
|
38
|
-
'X_BUILD_FAILED',
|
|
39
|
-
'X_BUILD_ENTRY_MISSING',
|
|
40
|
-
'X_DEPLOY_FAILED',
|
|
41
|
-
// The two the container's own environment can get wrong. A PaaS injects `PORT` and a supervisor
|
|
42
|
-
// injects `ROLE`; both arrive as strings from outside the app, so both are validated at boot
|
|
43
|
-
// rather than defaulted — a web role that quietly bound 3000 when the platform said 8080 fails
|
|
44
|
-
// its health check with nothing in the log that names the cause.
|
|
45
|
-
'X_ROLE_UNKNOWN',
|
|
46
|
-
'X_PORT_INVALID',
|
|
47
|
-
'X_GENERATE_CONFLICT',
|
|
48
|
-
'X_PORT_IN_USE',
|
|
49
|
-
'X_DB_GEN_FAILED',
|
|
50
|
-
'X_DB_MIGRATE_FAILED',
|
|
51
|
-
'X_DB_BRANCH_FAILED',
|
|
52
|
-
'X_DB_STUDIO_FAILED',
|
|
53
|
-
// The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
|
|
54
|
-
// and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
|
|
55
|
-
// that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
|
|
56
|
-
'X_BOUNDARY_SITE_TO_APP',
|
|
57
|
-
'X_BOUNDARY_SHARED_LEAF',
|
|
58
|
-
'X_BOUNDARY_APP_TO_API',
|
|
59
|
-
'X_BOUNDARY_ROUTE_TO_DB',
|
|
60
|
-
'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
61
|
-
] as const;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — `CliNotImplementedError` and every planned command
|
|
65
|
-
* throw it, and none of them may declare a title for it. The CLI is the process that imports every
|
|
66
|
-
* package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
|
|
67
|
-
* order rather than by ownership.
|
|
68
|
-
*/
|
|
69
|
-
export const CLI_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
70
|
-
|
|
71
|
-
/** Every code the CLI can throw: the ones it owns plus the one it borrows. */
|
|
72
|
-
export const CLI_ERROR_CODES = [...CLI_OWNED_ERROR_CODES, ...CLI_BORROWED_ERROR_CODES] as const;
|
|
73
|
-
|
|
74
|
-
export type CliOwnedErrorCode = (typeof CLI_OWNED_ERROR_CODES)[number];
|
|
75
|
-
export type CliErrorCode = (typeof CLI_ERROR_CODES)[number];
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Registered titles, so `x errors list` enumerates the CLI's codes alongside every other
|
|
79
|
-
* package's instead of leaving a hole an agent has to read source to fill. Typed over
|
|
80
|
-
* `CliOwnedErrorCode`, so adding a code without a title is a build error.
|
|
81
|
-
*/
|
|
82
|
-
export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
83
|
-
X_CLI_UNKNOWN_COMMAND: 'not a command in the registry',
|
|
84
|
-
X_CLI_BAD_FLAG: 'unknown flag, missing value, or a value the command refuses',
|
|
85
|
-
X_VERIFY_FAILED: 'at least one x verify step failed',
|
|
86
|
-
X_NOT_IN_APP: 'the command needs an app root and found none',
|
|
87
|
-
X_BUN_VERSION: 'Bun is older than the framework floor',
|
|
88
|
-
X_TEST_NO_FILES: 'the test selection matched no files',
|
|
89
|
-
X_TEST_SHARD_FAILED: 'a test shard exited non-zero',
|
|
90
|
-
X_SCAFFOLD_PATH_ESCAPE: 'a generated path resolves outside the directory it is written into',
|
|
91
|
-
X_GENERATE_JSON_INVALID: "a generator's own merge: 'json' output does not parse as a JSON object",
|
|
92
|
-
X_APP_PACKAGE_INVALID: "the app's package.json supplies no name and version",
|
|
93
|
-
X_ERROR_CODE_UNKNOWN: 'no package registered this error code',
|
|
94
|
-
X_DECLARATION_UNKNOWN: 'no declaration with this name is registered',
|
|
95
|
-
X_JOB_UNKNOWN: 'the queue holds no job with this id',
|
|
96
|
-
X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
|
|
97
|
-
X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
|
|
98
|
-
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
99
|
-
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
100
|
-
X_CLI_UNEXPECTED: 'the CLI itself failed',
|
|
101
|
-
X_TYPECHECK_FAILED: 'tsc failed',
|
|
102
|
-
X_LINT_FAILED: 'Biome failed',
|
|
103
|
-
X_TEST_FAILED: 'a test type failed',
|
|
104
|
-
X_FILE_TOO_LONG: 'a source file is over 500 lines',
|
|
105
|
-
X_PACKAGE_SHAPE: 'a workspace package is missing a contract file',
|
|
106
|
-
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
107
|
-
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
108
|
-
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
109
|
-
X_BUILD_FAILED: 'x build failed',
|
|
110
|
-
X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
|
|
111
|
-
X_DEPLOY_FAILED: 'a deploy step failed',
|
|
112
|
-
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
113
|
-
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
114
|
-
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
115
|
-
X_PORT_IN_USE: 'the dev port is taken',
|
|
116
|
-
X_DB_GEN_FAILED: 'x db gen failed',
|
|
117
|
-
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
118
|
-
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
119
|
-
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
120
|
-
X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
|
|
121
|
-
X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
|
|
122
|
-
X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
|
|
123
|
-
X_BOUNDARY_ROUTE_TO_DB: 'a route touched the database',
|
|
124
|
-
X_BOUNDARY_SERVICE_TO_HTTP: 'a service imported HTTP',
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
|
128
|
-
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
129
|
-
registerErrorCodes(
|
|
130
|
-
Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
export const docsFor = (code: CliErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
1
|
+
// The error classes @ultimat3/cli throws. One class per condition, each naming the exact command
|
|
2
|
+
// that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
|
|
3
|
+
// so a package importing a class does not pull the table and vice versa.
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { docsFor } from './error-codes';
|
|
134
6
|
|
|
135
7
|
/** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
|
|
136
8
|
export class UnknownCommandError extends UltimateError {
|
|
@@ -160,6 +32,49 @@ export class BadFlagError extends UltimateError {
|
|
|
160
32
|
}
|
|
161
33
|
}
|
|
162
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A required POSITIONAL argument that was not given. Its own class rather than a `BadFlagError`,
|
|
37
|
+
* because the cause then names a flag that does not exist — `x errors --json` reported
|
|
38
|
+
* `--code on "x errors"` and sent an agent straight into a second `X_CLI_BAD_FLAG` for the
|
|
39
|
+
* `--code` flag it had just been told about — and rather than `X_CLI_UNKNOWN_COMMAND`, which said
|
|
40
|
+
* "x g route is not a command" about a command form that is. `example` is a REAL invocation:
|
|
41
|
+
* `x g route <name>` pasted into a shell is a redirect, not a command.
|
|
42
|
+
*/
|
|
43
|
+
export class MissingPositionalError extends UltimateError {
|
|
44
|
+
constructor(input: { command: string; positional: string; example: string }) {
|
|
45
|
+
super({
|
|
46
|
+
code: 'X_CLI_BAD_FLAG',
|
|
47
|
+
cause: `"x ${input.command}" needs a <${input.positional}> positional and got none`,
|
|
48
|
+
fix: input.example,
|
|
49
|
+
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
|
|
56
|
+
*
|
|
57
|
+
* `X_CLI_BAD_FLAG` is the code a missing positional already takes (`MissingPositionalError`), and a
|
|
58
|
+
* subcommand is one — a second code for "you left out an argument" is the synonym the registry
|
|
59
|
+
* exists to prevent. Its own class because the cause must not name a flag: the parser answered
|
|
60
|
+
* `subcommands[0]` before this existed, so `x db` ran `gen` and wrote a migration file nobody asked
|
|
61
|
+
* for. Help is the fix because which of six was meant is exactly what the caller did not say.
|
|
62
|
+
*
|
|
63
|
+
* `x help <command>`, never `x <command> --help`: the parser resolves the subcommand AFTER the
|
|
64
|
+
* flag loop, so `x db --help` throws THIS error again — a fix line that reproduces its own
|
|
65
|
+
* failure, verbatim, forever. `x help db` prints the subcommand list and the flags.
|
|
66
|
+
*/
|
|
67
|
+
export class MissingSubcommandError extends UltimateError {
|
|
68
|
+
constructor(input: { command: string; known: readonly string[] }) {
|
|
69
|
+
super({
|
|
70
|
+
code: 'X_CLI_BAD_FLAG',
|
|
71
|
+
cause: `"x ${input.command}" takes a subcommand and got none (one of: ${input.known.join(', ')})`,
|
|
72
|
+
fix: `x help ${input.command}`,
|
|
73
|
+
docs: docsFor('X_CLI_BAD_FLAG'),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
163
78
|
/** At least one `x verify` step failed. The step findings carry the per-step fixes. */
|
|
164
79
|
export class VerifyFailedError extends UltimateError {
|
|
165
80
|
constructor(input: { failed: readonly string[] }) {
|
|
@@ -211,7 +126,7 @@ export class NoTestFilesError extends UltimateError {
|
|
|
211
126
|
super({
|
|
212
127
|
code: 'X_TEST_NO_FILES',
|
|
213
128
|
cause: `no *.test.ts files${where} under ${input.root}`,
|
|
214
|
-
fix: parts.length === 0 ? 'x test --
|
|
129
|
+
fix: parts.length === 0 ? 'x test --json # run it from the repo root' : 'x test',
|
|
215
130
|
docs: docsFor('X_TEST_NO_FILES'),
|
|
216
131
|
});
|
|
217
132
|
}
|
|
@@ -282,7 +197,7 @@ export class AppPackageInvalidError extends UltimateError {
|
|
|
282
197
|
super({
|
|
283
198
|
code: 'X_APP_PACKAGE_INVALID',
|
|
284
199
|
cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
|
|
285
|
-
fix: 'bun pm pkg set name
|
|
200
|
+
fix: 'bun pm pkg set name=my-app version=0.1.0',
|
|
286
201
|
docs: docsFor('X_APP_PACKAGE_INVALID'),
|
|
287
202
|
});
|
|
288
203
|
}
|
|
@@ -374,12 +289,28 @@ export class BuildEntryMissingError extends UltimateError {
|
|
|
374
289
|
super({
|
|
375
290
|
code: 'X_BUILD_ENTRY_MISSING',
|
|
376
291
|
cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
|
|
377
|
-
fix: `x new
|
|
292
|
+
fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
|
|
378
293
|
docs: docsFor('X_BUILD_ENTRY_MISSING'),
|
|
379
294
|
});
|
|
380
295
|
}
|
|
381
296
|
}
|
|
382
297
|
|
|
298
|
+
/**
|
|
299
|
+
* A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
|
|
300
|
+
* entry point like any other, and the target's own logs are what says which line. The fix builds
|
|
301
|
+
* exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
|
|
302
|
+
*/
|
|
303
|
+
export class IslandBuildFailedError extends UltimateError {
|
|
304
|
+
constructor(input: { file: string; logs: string }) {
|
|
305
|
+
super({
|
|
306
|
+
code: 'X_BUILD_FAILED',
|
|
307
|
+
cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
|
|
308
|
+
fix: `bun build --target browser ${input.file}`,
|
|
309
|
+
docs: docsFor('X_BUILD_FAILED'),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
383
314
|
/**
|
|
384
315
|
* `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
|
|
385
316
|
* otherwise start, serve nothing and report healthy — the one failure a rolling deploy cannot see.
|
|
@@ -389,12 +320,35 @@ export class RoleUnknownError extends UltimateError {
|
|
|
389
320
|
super({
|
|
390
321
|
code: 'X_ROLE_UNKNOWN',
|
|
391
322
|
cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
|
|
392
|
-
fix: `docker run -e ROLE=web
|
|
323
|
+
fix: `docker run -e ROLE=web my-app:latest # one of: ${input.known.join(', ')}`,
|
|
393
324
|
docs: docsFor('X_ROLE_UNKNOWN'),
|
|
394
325
|
});
|
|
395
326
|
}
|
|
396
327
|
}
|
|
397
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The enqueue side and the claim side are looking at two different queues.
|
|
331
|
+
*
|
|
332
|
+
* `startServices` builds the drivers and captures them; `loadApp` imports the app's modules after
|
|
333
|
+
* it, and a module calling `setJobDriver()` at import time moves the ambient slot without touching
|
|
334
|
+
* the captured object. The worker then claims from what was captured while every
|
|
335
|
+
* `handle.enqueue()` publishes to what is ambient — jobs that are accepted, acknowledged, visible
|
|
336
|
+
* in `/_x` and never run. Refused at boot, because the alternative is a deployment that only ever
|
|
337
|
+
* looks healthy.
|
|
338
|
+
*/
|
|
339
|
+
export class RuntimeDriverSplitError extends UltimateError {
|
|
340
|
+
constructor(input: { driver: string; ambient: string; captured: string }) {
|
|
341
|
+
super({
|
|
342
|
+
code: 'X_RUNTIME_DRIVER_SPLIT',
|
|
343
|
+
// Both names are printed even when they are the same string — two `memory` drivers are two
|
|
344
|
+
// queues, and "they match" is exactly the reading that makes this bug invisible.
|
|
345
|
+
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
|
+
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
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
398
352
|
/**
|
|
399
353
|
* Every PaaS injects `PORT` and expects the process to bind exactly it. Defaulting past a value
|
|
400
354
|
* that will not parse is how a deploy comes up on 3000, fails the platform's health probe, and
|
|
@@ -407,12 +361,32 @@ export class PortInvalidError extends UltimateError {
|
|
|
407
361
|
super({
|
|
408
362
|
code: 'X_PORT_INVALID',
|
|
409
363
|
cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
|
|
410
|
-
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090}
|
|
364
|
+
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} my-app:latest`,
|
|
411
365
|
docs: docsFor('X_PORT_INVALID'),
|
|
412
366
|
});
|
|
413
367
|
}
|
|
414
368
|
}
|
|
415
369
|
|
|
370
|
+
/**
|
|
371
|
+
* `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
|
|
372
|
+
* writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
|
|
373
|
+
* present", both read as a working environment declaration to whoever runs the command next.
|
|
374
|
+
*
|
|
375
|
+
* `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
|
|
376
|
+
* `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
|
|
377
|
+
* than minting a synonym.
|
|
378
|
+
*/
|
|
379
|
+
export class EnvSchemaMissingError extends UltimateError {
|
|
380
|
+
constructor(input: { subcommand: string }) {
|
|
381
|
+
super({
|
|
382
|
+
code: 'X_CONFIG_INVALID',
|
|
383
|
+
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
384
|
+
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
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
416
390
|
/** An interface-complete command path whose remote/native half is not written yet. */
|
|
417
391
|
export class CliNotImplementedError extends UltimateError {
|
|
418
392
|
constructor(input: { feature: string; fix: string }) {
|
|
@@ -424,3 +398,92 @@ export class CliNotImplementedError extends UltimateError {
|
|
|
424
398
|
});
|
|
425
399
|
}
|
|
426
400
|
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The process could not obtain a storage disk to write to.
|
|
404
|
+
*
|
|
405
|
+
* Thrown at boot rather than at the first upload, and with a `fix` naming the two real options —
|
|
406
|
+
* a writable volume or an object store — because the failure it replaces was a bare `EROFS` from
|
|
407
|
+
* inside Bun's `mkdirSync`, with no code, no fix, and no mention of storage. A hardened container
|
|
408
|
+
* (`readOnlyRootFilesystem: true`) CrashLooped 22 times on it before anyone could tell what the
|
|
409
|
+
* process wanted.
|
|
410
|
+
*/
|
|
411
|
+
export class StorageUnwritableError extends UltimateError {
|
|
412
|
+
constructor(cause: string, fix: string) {
|
|
413
|
+
super({ code: 'X_STORAGE_UNWRITABLE', cause, fix, docs: docsFor('X_STORAGE_UNWRITABLE') });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* A non-local boot that fell through to the embedded disk with no `STORAGE_SIGNING_SECRET`. The
|
|
419
|
+
* key it would sign with is a string published in this repo, and `acceptSignedUpload` trusts a
|
|
420
|
+
* signed `maxBytes`/`contentType` over the app's own `uploadPolicy` — so anyone holding it mints
|
|
421
|
+
* an unlimited upload of any type, for any key, including another org's.
|
|
422
|
+
*
|
|
423
|
+
* `X_ENV_MISSING`, the code `@ultimat3/storage` already refuses this with, rather than a CLI twin:
|
|
424
|
+
* two codes for one condition is what `cmd-doctor.ts` says out loud about the PWA pair. What this
|
|
425
|
+
* adds is the sentence storage cannot write — that the disk itself was a fallback nobody chose.
|
|
426
|
+
* The fix names object storage first, because that is the answer for most deployments; the volume
|
|
427
|
+
* rung is behind the `#`, so the line still runs verbatim.
|
|
428
|
+
*/
|
|
429
|
+
export class LocalDiskUnsafeError extends UltimateError {
|
|
430
|
+
constructor(input: { environment: string; root: string }) {
|
|
431
|
+
super({
|
|
432
|
+
code: 'X_ENV_MISSING',
|
|
433
|
+
cause:
|
|
434
|
+
`no S3_ENDPOINT/S3_BUCKET, so this ${input.environment} process fell back to the embedded ` +
|
|
435
|
+
`disk at ${input.root} — and with no STORAGE_SIGNING_SECRET it would sign upload grants ` +
|
|
436
|
+
'with the development key published in @ultimat3/storage',
|
|
437
|
+
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
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
|
|
445
|
+
* guessing one and opening a decrypted file in it is the last place a surprise belongs.
|
|
446
|
+
*/
|
|
447
|
+
export class SecretsEditorMissingError extends UltimateError {
|
|
448
|
+
constructor(input: { vars: readonly string[] }) {
|
|
449
|
+
super({
|
|
450
|
+
code: 'X_SECRETS_EDITOR_MISSING',
|
|
451
|
+
cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
|
|
452
|
+
fix: 'EDITOR=nano x secrets edit',
|
|
453
|
+
docs: docsFor('X_SECRETS_EDITOR_MISSING'),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
|
|
460
|
+
* and the committed file is left exactly as it was: resealing a buffer whose editor failed would
|
|
461
|
+
* commit whatever half-written state the crash left behind.
|
|
462
|
+
*/
|
|
463
|
+
export class SecretsEditFailedError extends UltimateError {
|
|
464
|
+
constructor(input: { editor: string; code: number }) {
|
|
465
|
+
super({
|
|
466
|
+
code: 'X_SECRETS_EDIT_FAILED',
|
|
467
|
+
cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
|
|
468
|
+
fix: 'x secrets edit',
|
|
469
|
+
docs: docsFor('X_SECRETS_EDIT_FAILED'),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
|
|
476
|
+
* package's own code for exactly that, and a second name for "a generator would clobber something"
|
|
477
|
+
* is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
|
|
478
|
+
* the committed file it opens is then ciphertext nobody can read again.
|
|
479
|
+
*/
|
|
480
|
+
export class SecretsExistsError extends UltimateError {
|
|
481
|
+
constructor(input: { path: string; fix: string }) {
|
|
482
|
+
super({
|
|
483
|
+
code: 'X_GENERATE_CONFLICT',
|
|
484
|
+
cause: `${input.path} already exists, and x secrets init would replace it`,
|
|
485
|
+
fix: input.fix,
|
|
486
|
+
docs: docsFor('X_GENERATE_CONFLICT'),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
package/src/exec.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// `UltimateError` straight from core rather than a class in `./errors`: this module is imported by
|
|
6
6
|
// every command, and `./errors` runs `registerErrorCodes` on import — a subprocess boundary must
|
|
7
7
|
// not decide when the CLI's registry is populated. `X_CLI_UNEXPECTED` is owned there all the same.
|
|
8
|
-
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
9
|
+
// `shell-quote.ts` is a leaf — it imports nothing, so the subprocess boundary stays importable
|
|
10
|
+
// from anywhere, this file's header rule about a single boundary included.
|
|
11
|
+
import { quoteArg } from './shell-quote';
|
|
9
12
|
|
|
10
13
|
export interface ExecResult {
|
|
11
14
|
readonly command: readonly string[];
|
|
@@ -27,6 +30,43 @@ export type Runner = (command: readonly string[], options: ExecOptions) => Promi
|
|
|
27
30
|
/** performance.now(), not Date.now(): the test preload freezes the wall clock on purpose. */
|
|
28
31
|
const now = (): number => performance.now();
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The failure mode this file's header already promised was identical everywhere, and the one it
|
|
35
|
+
* never coded. `x deploy` on a machine without `docker` threw Bun's own `Error: Executable not
|
|
36
|
+
* found in $PATH`; `dispatch.ts` rendered it as `X_CLI_UNEXPECTED` with `fix: x doctor --json`,
|
|
37
|
+
* and `runDoctor` checks nothing about an absent binary — an instruction that cannot close the
|
|
38
|
+
* error is axiom 4 inverted. The code stays `X_CLI_UNEXPECTED` (the CLI already owns it for a
|
|
39
|
+
* failure of its own machinery); what changes is that the fix names the program to install.
|
|
40
|
+
*
|
|
41
|
+
* The program name goes through `quoteArg` at BOTH references: `docker compose` or any name a
|
|
42
|
+
* shell would resplit produced a `fix:` that runs something else, which is axiom 4 inverted twice
|
|
43
|
+
* in one line.
|
|
44
|
+
*
|
|
45
|
+
* The thrown value goes through core's render helper and is never interpolated: an `unknown`
|
|
46
|
+
* reaching a `cause:` through `${…}` is what `bun run error-render` refuses, and this one is
|
|
47
|
+
* genuinely unknown — Bun raises `ENOENT` for a missing program, `EACCES` for an unrunnable one.
|
|
48
|
+
*
|
|
49
|
+
* The return type is inferred so this stays one statement of `Bun.spawn`'s own shape.
|
|
50
|
+
*/
|
|
51
|
+
function spawnOrRefuse(command: readonly string[], options: ExecOptions) {
|
|
52
|
+
const [head = '', ...rest] = command;
|
|
53
|
+
try {
|
|
54
|
+
return Bun.spawn([head, ...rest], {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
57
|
+
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new UltimateError({
|
|
63
|
+
code: 'X_CLI_UNEXPECTED',
|
|
64
|
+
cause: `the CLI could not run "${head}" from ${options.cwd}: ${renderThrowable(error)}`,
|
|
65
|
+
fix: `install ${quoteArg(head)} and put it on PATH, then re-run — confirm with: command -v ${quoteArg(head)}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
export const exec: Runner = async (command, options) => {
|
|
31
71
|
const started = now();
|
|
32
72
|
const [head, ...rest] = command;
|
|
@@ -40,13 +80,7 @@ export const exec: Runner = async (command, options) => {
|
|
|
40
80
|
fix: 'pass the program as the first element: exec(["bun", "test"], { cwd })',
|
|
41
81
|
});
|
|
42
82
|
}
|
|
43
|
-
const proc =
|
|
44
|
-
cwd: options.cwd,
|
|
45
|
-
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
46
|
-
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
47
|
-
stdout: 'pipe',
|
|
48
|
-
stderr: 'pipe',
|
|
49
|
-
});
|
|
83
|
+
const proc = spawnOrRefuse([head, ...rest], options);
|
|
50
84
|
const [stdout, stderr, code] = await Promise.all([
|
|
51
85
|
new Response(proc.stdout).text(),
|
|
52
86
|
new Response(proc.stderr).text(),
|