@ultimat3/cli 2.0.0 → 4.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 +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
package/src/cmd-deploy.ts
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
// not know the name of a cloud, a KV store or an edge runtime (axiom 7). What it emits is a plan
|
|
3
3
|
// anything that runs containers can execute.
|
|
4
4
|
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { requireAppRoot } from './app-root';
|
|
8
7
|
import type { CliCommand, CommandContext } from './command';
|
|
9
|
-
import { BadFlagError,
|
|
8
|
+
import { BadFlagError, UnknownCommandError } from './errors';
|
|
10
9
|
import { msg } from './messages';
|
|
11
10
|
import type { CommandResult, JsonValue } from './output';
|
|
12
11
|
import { flagBool, flagString } from './parse';
|
|
@@ -28,11 +27,35 @@ import { flagBool, flagString } from './parse';
|
|
|
28
27
|
* the serving roles were asked to start and not after they are ready. The barrier that makes
|
|
29
28
|
* "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
|
|
30
29
|
* service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
|
|
31
|
-
* honours. Both compose definitions — `docker/docker-compose.prod.yml` and
|
|
32
|
-
* `templates/scaffold-container.ts` scaffolds
|
|
30
|
+
* honours. Both compose definitions carry it — `docker/docker-compose.prod.yml`'s `backfill` and
|
|
31
|
+
* the one `templates/scaffold-container.ts` scaffolds, which also gates on `migrate` completing.
|
|
32
|
+
* This paragraph said they "still owe" both for as long as they have had them.
|
|
33
33
|
*/
|
|
34
34
|
export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
|
|
35
35
|
|
|
36
|
+
/** The two ways to run the plan. Closed, and read three ways: the default, the check, the refusal. */
|
|
37
|
+
export const DEPLOY_METHODS = ['compose', 'helm'] as const;
|
|
38
|
+
|
|
39
|
+
export type DeployMethod = (typeof DEPLOY_METHODS)[number];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Refused, never defaulted. `=== 'helm' ? 'helm' : 'compose'` made every other spelling a Compose
|
|
43
|
+
* deploy that reported `ok: true` and `method: "compose"` — so `x deploy --method helmm` (or
|
|
44
|
+
* `Helm`, or `kubectl`) ran the six-step Compose plan against a cluster whose operator had asked
|
|
45
|
+
* for a Helm upgrade, and the report agreed with the plan rather than with the request.
|
|
46
|
+
* `cmd-build.ts`'s `readTarget` is the same shape for the same reason.
|
|
47
|
+
*/
|
|
48
|
+
export function readMethod(raw: string | undefined): DeployMethod {
|
|
49
|
+
const methods: readonly string[] = DEPLOY_METHODS;
|
|
50
|
+
if (raw === undefined) return 'compose';
|
|
51
|
+
if (methods.includes(raw)) return raw as DeployMethod;
|
|
52
|
+
throw new UnknownCommandError({
|
|
53
|
+
path: `deploy --method ${raw}`,
|
|
54
|
+
known: DEPLOY_METHODS,
|
|
55
|
+
suggestion: 'deploy --method compose',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
/** The roles that run to completion and exit, as against the ones that stay up serving. */
|
|
37
60
|
const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
|
|
38
61
|
|
|
@@ -62,7 +85,7 @@ export function helmImageOverrides(image: string): readonly string[] {
|
|
|
62
85
|
: ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
|
|
63
86
|
}
|
|
64
87
|
|
|
65
|
-
export function planDeploy(image: string, method:
|
|
88
|
+
export function planDeploy(image: string, method: DeployMethod, root: string): DeployPlan {
|
|
66
89
|
if (method === 'helm') {
|
|
67
90
|
// `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
|
|
68
91
|
// has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
|
|
@@ -119,24 +142,29 @@ export const deployCommand: CliCommand = {
|
|
|
119
142
|
{ name: 'image', type: 'string', summary: 'image reference to deploy' },
|
|
120
143
|
{ name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
|
|
121
144
|
{ name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
|
|
122
|
-
|
|
145
|
+
// `--critical` was here and is gone. It parsed, it was echoed into the plan JSON as
|
|
146
|
+
// `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
|
|
147
|
+
// nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
|
|
148
|
+
// `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
|
|
149
|
+
// into a field with no reader. Forcing a reload is `@ultimat3/pwa`'s
|
|
150
|
+
// `updateSignal({ reason: 'security' })`, which has no runtime caller either; a flag that
|
|
151
|
+
// triggers it is a change in that package, and this was not it.
|
|
123
152
|
],
|
|
124
153
|
},
|
|
125
154
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
126
155
|
const root = requireAppRoot('deploy', ctx.cwd).dir;
|
|
127
156
|
const image = flagString(ctx.args, 'image') ?? 'ultimate-app:dev';
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
157
|
+
// No "is there a chart?" branch. It threw X_NOT_IMPLEMENTED — "this build does not implement
|
|
158
|
+
// helm" — over a build that implements it completely (`planDeploy` above); what was missing was
|
|
159
|
+
// a FILE, and its fix said to copy it from the framework repository, which `packages/cli`'s
|
|
160
|
+
// `files:` ships in no tarball. `x new` writes `docker/helm` now, the way it has always written
|
|
161
|
+
// `docker/docker-compose.prod.yml`. An app that deleted the chart gets helm's own error through
|
|
162
|
+
// X_DEPLOY_FAILED, whose fix is the exact command to rerun.
|
|
163
|
+
const method = readMethod(flagString(ctx.args, 'method'));
|
|
135
164
|
const plan = planDeploy(image, method, root);
|
|
136
165
|
const planJson: JsonValue = {
|
|
137
166
|
image: plan.image,
|
|
138
167
|
method,
|
|
139
|
-
critical: flagBool(ctx.args, 'critical'),
|
|
140
168
|
steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
|
|
141
169
|
};
|
|
142
170
|
if (flagBool(ctx.args, 'dry-run')) {
|
package/src/cmd-dev.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { msg } from './messages';
|
|
|
42
42
|
import type { CommandResult, Finding } from './output';
|
|
43
43
|
import { findingFrom } from './output';
|
|
44
44
|
import { flagString } from './parse';
|
|
45
|
+
import { metricsPortFor } from './serve';
|
|
45
46
|
import { loopFacts, loopFinding, loopNotice } from './statement-loop';
|
|
46
47
|
|
|
47
48
|
const DEFAULT_PORT = 3000;
|
|
@@ -185,6 +186,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
185
186
|
const running = await startRoles({
|
|
186
187
|
roles: options.roles ?? DEV_ROLES,
|
|
187
188
|
port: options.port,
|
|
189
|
+
// `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
|
|
190
|
+
// container and ignored here, so the scrape port an operator moved was the one port `x dev`
|
|
191
|
+
// could not move — and the second `x dev` on a box died binding the hardcoded 9090.
|
|
192
|
+
metricsPort: metricsPortFor(options.env, options.port),
|
|
188
193
|
buildId,
|
|
189
194
|
runtime,
|
|
190
195
|
routes,
|
|
@@ -266,14 +271,16 @@ export const devCommand: CliCommand = {
|
|
|
266
271
|
spec: {
|
|
267
272
|
name: 'dev',
|
|
268
273
|
summary: 'all roles in one process: embedded services, sub-second reload, /_x mounted',
|
|
269
|
-
usage: 'x dev [--port 3000] [--role web,worker] [--json]',
|
|
274
|
+
usage: 'x dev [--port 3000] [--role web,worker] [--once] [--json]',
|
|
270
275
|
requiresApp: true,
|
|
271
276
|
flags: [
|
|
272
277
|
{ name: 'port', type: 'string', summary: 'HTTP port', default: String(DEFAULT_PORT) },
|
|
273
278
|
{
|
|
274
279
|
name: 'role',
|
|
275
280
|
type: 'string',
|
|
276
|
-
|
|
281
|
+
// `replicator` is named because it is selectable and NOT default — it takes a replication
|
|
282
|
+
// slot on a shared database, which is not something every `x dev` should do by starting.
|
|
283
|
+
summary: `roles to run (default: all of ${DEV_ROLES.join(',')}; replicator is opt-in)`,
|
|
277
284
|
},
|
|
278
285
|
{ name: 'once', type: 'boolean', summary: 'boot, report, exit — for smoke tests and CI' },
|
|
279
286
|
],
|
package/src/cmd-docs.ts
CHANGED
|
@@ -9,8 +9,10 @@ import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest
|
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
10
|
import { MissingPositionalError } from './errors';
|
|
11
11
|
import { frameworkScopeDir } from './framework-scope';
|
|
12
|
+
import { parseLimitFlag } from './jobs-report';
|
|
12
13
|
import { msg } from './messages';
|
|
13
14
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
15
|
+
import { flagString } from './parse';
|
|
14
16
|
|
|
15
17
|
/** Matches printed by default. Enough to choose between, few enough to read all of. */
|
|
16
18
|
const DEFAULT_LIMIT = 5;
|
|
@@ -150,9 +152,11 @@ export const docsCommand: CliCommand = {
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
const entries = await scanInstalledDocs(scope);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
// `x jobs ls --limit`'s reader, and its `command` parameter exists for exactly this second
|
|
156
|
+
// caller. A local `Number.parseInt` accepted `--limit 1e9` as 1 and answered with one match,
|
|
157
|
+
// and fell silently through to the default for `abc`, `0` and `-3` — a bound other than the one
|
|
158
|
+
// typed, from the same binary that refuses all four one command over.
|
|
159
|
+
const limit = parseLimitFlag(flagString(ctx.args, 'limit'), 'docs') ?? DEFAULT_LIMIT;
|
|
156
160
|
const hits = searchDocs(entries, query, limit);
|
|
157
161
|
if (hits.length === 0) return missResult(query, entries);
|
|
158
162
|
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -11,9 +11,10 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
11
11
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
12
12
|
import { ICON_SOURCE } from './dev-assets';
|
|
13
13
|
import { checkSourceDrift } from './drift';
|
|
14
|
-
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
14
|
+
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
15
15
|
import { msg } from './messages';
|
|
16
16
|
import type { CommandResult, Finding } from './output';
|
|
17
|
+
import type { ParsedArgs } from './parse';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -126,7 +127,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
126
127
|
finding(
|
|
127
128
|
'X_PORT_IN_USE',
|
|
128
129
|
`port ${probe.port} is already listening`,
|
|
129
|
-
`x dev --port ${probe.port
|
|
130
|
+
`x dev --port ${neighbouringPort(probe.port)}`,
|
|
130
131
|
),
|
|
131
132
|
);
|
|
132
133
|
}
|
|
@@ -165,6 +166,18 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
165
166
|
return findings;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
|
|
171
|
+
* --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
|
|
172
|
+
* succeeds — so the port check could not fail, which is worse than not running it.
|
|
173
|
+
*/
|
|
174
|
+
export const doctorPort = (args: ParsedArgs): number =>
|
|
175
|
+
intFlagOr(
|
|
176
|
+
args,
|
|
177
|
+
{ name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
|
|
178
|
+
DEFAULT_DOCTOR_PORT,
|
|
179
|
+
);
|
|
180
|
+
|
|
168
181
|
const portFree = async (port: number): Promise<boolean> => {
|
|
169
182
|
try {
|
|
170
183
|
const server = Bun.serve({ port, fetch: () => new Response('') });
|
|
@@ -213,11 +226,7 @@ export const doctorCommand: CliCommand = {
|
|
|
213
226
|
],
|
|
214
227
|
},
|
|
215
228
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
216
|
-
const port =
|
|
217
|
-
ctx.args,
|
|
218
|
-
{ name: 'port', command: 'doctor', ...PORT_RANGE, example: 'x doctor --port 3000' },
|
|
219
|
-
DEFAULT_DOCTOR_PORT,
|
|
220
|
-
);
|
|
229
|
+
const port = doctorPort(ctx.args);
|
|
221
230
|
const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
|
|
222
231
|
return {
|
|
223
232
|
ok: findings.length === 0,
|
package/src/cmd-fix.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { requireAppRoot } from './app-root';
|
|
|
8
8
|
import type { BoundaryCut } from './boundary-cuts';
|
|
9
9
|
import { planBoundaryCuts } from './boundary-cuts';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
-
import { BadFlagError, FixTargetUnknownError } from './errors';
|
|
11
|
+
import { BadFlagError, FixTargetUnknownError, MissingPositionalError } from './errors';
|
|
12
12
|
import { msg } from './messages';
|
|
13
13
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
14
14
|
import { nearest } from './parse';
|
|
@@ -103,10 +103,22 @@ export const fixCommand: CliCommand = {
|
|
|
103
103
|
},
|
|
104
104
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
105
105
|
const root = requireAppRoot('fix', ctx.cwd).dir;
|
|
106
|
+
// Refused before the scan, never defaulted to `''`: an empty string reached `resolveTarget` as
|
|
107
|
+
// a file NAME, so a bare `x fix` answered X_FIX_TARGET_UNKNOWN with `"" is not one of the 42
|
|
108
|
+
// source file(s)…` — and `nearest('')` almost never suggests anything, so the fix degraded to
|
|
109
|
+
// `x routes --json` for a caller who had simply not said which file.
|
|
110
|
+
const file = ctx.args.positionals[0];
|
|
111
|
+
if (file === undefined) {
|
|
112
|
+
throw new MissingPositionalError({
|
|
113
|
+
command: 'fix boundary',
|
|
114
|
+
positional: 'file',
|
|
115
|
+
example: 'x routes --json # every registered route file, app-root-relative',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
106
118
|
const files = await readAppSources(root);
|
|
107
119
|
const target = resolveTarget(
|
|
108
|
-
|
|
109
|
-
files.map((
|
|
120
|
+
file,
|
|
121
|
+
files.map((source) => source.path),
|
|
110
122
|
);
|
|
111
123
|
const cuts = planBoundaryCuts(target, appImportGraph(files));
|
|
112
124
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -286,7 +286,12 @@ type WritePlan =
|
|
|
286
286
|
| { readonly kind: 'skip' }
|
|
287
287
|
| { readonly kind: 'conflict'; readonly finding: Finding };
|
|
288
288
|
|
|
289
|
-
function planFile(
|
|
289
|
+
function planFile(
|
|
290
|
+
file: GeneratedFile,
|
|
291
|
+
absolute: string,
|
|
292
|
+
force: boolean,
|
|
293
|
+
invocation: string,
|
|
294
|
+
): WritePlan {
|
|
290
295
|
// A foundation file belongs to the slice, not to the generator that needs it: several generators
|
|
291
296
|
// emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
|
|
292
297
|
// overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
|
|
@@ -303,7 +308,10 @@ function planFile(file: GeneratedFile, absolute: string, force: boolean): WriteP
|
|
|
303
308
|
finding: {
|
|
304
309
|
code: 'X_GENERATE_CONFLICT',
|
|
305
310
|
cause: `${file.path} already exists`,
|
|
306
|
-
|
|
311
|
+
// The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
|
|
312
|
+
// when run, and a `fix:` is copied and pasted verbatim. Same construction as
|
|
313
|
+
// `generate-kinds.ts`'s `assertSurfaceSupported`.
|
|
314
|
+
fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
|
|
307
315
|
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
308
316
|
at: file.path,
|
|
309
317
|
},
|
|
@@ -325,12 +333,20 @@ export async function writeFiles(
|
|
|
325
333
|
root: string,
|
|
326
334
|
files: readonly GeneratedFile[],
|
|
327
335
|
force: boolean,
|
|
336
|
+
/**
|
|
337
|
+
* The command line that produced these files, so a conflict's `fix:` can hand it back with
|
|
338
|
+
* `--force` on the end. Optional for a caller assembling files itself; the fallback is the
|
|
339
|
+
* shape, not a runnable line, and every generator path supplies the real one.
|
|
340
|
+
*/
|
|
341
|
+
invocation = 'x g <kind> <name>',
|
|
328
342
|
): Promise<WriteReport> {
|
|
329
343
|
const plans: WritePlan[] = [];
|
|
330
344
|
for (const file of files) {
|
|
331
345
|
const absolute = containedPath(root, file.path);
|
|
332
346
|
plans.push(
|
|
333
|
-
file.merge === 'json'
|
|
347
|
+
file.merge === 'json'
|
|
348
|
+
? await planJsonMerge(file, absolute)
|
|
349
|
+
: planFile(file, absolute, force, invocation),
|
|
334
350
|
);
|
|
335
351
|
}
|
|
336
352
|
const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
|
|
@@ -381,6 +397,10 @@ export const generateCommand: CliCommand = {
|
|
|
381
397
|
// drifted — it omitted `backfill` — and a usage line that can disagree with the list it
|
|
382
398
|
// describes is exactly the second source of truth axiom 2 forbids.
|
|
383
399
|
usage: `x g ${GENERATORS.join('|')} <name> [--feature f]`,
|
|
400
|
+
// Declared from the SAME constant `readKind` validates against: without it `fix-command.ts`
|
|
401
|
+
// has no set to judge the word after `x g`, and two shipped `@ultimat3/admin` fix lines said
|
|
402
|
+
// `x g migration` — a generator that has never existed — straight through the `errors` gate.
|
|
403
|
+
positionalChoices: GENERATORS,
|
|
384
404
|
requiresApp: true,
|
|
385
405
|
flags: [
|
|
386
406
|
{ name: 'feature', type: 'string', summary: 'feature slice to write into' },
|
|
@@ -425,7 +445,12 @@ export const generateCommand: CliCommand = {
|
|
|
425
445
|
lines: files.map((file) => msg('cli.file.added', { path: file.path })),
|
|
426
446
|
};
|
|
427
447
|
}
|
|
428
|
-
const report = await writeFiles(
|
|
448
|
+
const report = await writeFiles(
|
|
449
|
+
root,
|
|
450
|
+
files,
|
|
451
|
+
flagBool(ctx.args, 'force'),
|
|
452
|
+
`x g ${kind} ${name}`,
|
|
453
|
+
);
|
|
429
454
|
// A locale's catalog existing on disk and the app being able to select it are two different
|
|
430
455
|
// facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
|
|
431
456
|
// this same invocation just wrote never gets projected against a stale catalog registration.
|
package/src/cmd-help.ts
CHANGED
|
@@ -14,10 +14,31 @@ const flagLine = (flag: FlagSpec): string => {
|
|
|
14
14
|
return ` ${short}${name.padEnd(24)} ${flag.summary}`;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
|
|
19
|
+
* of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
|
|
20
|
+
* generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
|
|
21
|
+
* `g`, and `x help nosuch --json` answered `[]` while the page printed the whole catalogue.
|
|
22
|
+
*/
|
|
23
|
+
const specFor = (
|
|
24
|
+
specs: readonly CommandSpec[],
|
|
25
|
+
topic: string | undefined,
|
|
26
|
+
): CommandSpec | undefined =>
|
|
27
|
+
topic === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: specs.find((entry) => entry.name === topic || (entry.aliases ?? []).includes(topic));
|
|
30
|
+
|
|
31
|
+
/** What `--json` reports: the one resolved command, or — as the human render does — all of them. */
|
|
32
|
+
export function helpTopic(
|
|
33
|
+
specs: readonly CommandSpec[],
|
|
34
|
+
topic: string | undefined,
|
|
35
|
+
): readonly CommandSpec[] {
|
|
36
|
+
const spec = specFor(specs, topic);
|
|
37
|
+
return spec === undefined ? specs : [spec];
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
export function renderHelp(specs: readonly CommandSpec[], topic: string | undefined): string[] {
|
|
18
|
-
const spec = specs
|
|
19
|
-
(entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
|
|
20
|
-
);
|
|
41
|
+
const spec = specFor(specs, topic);
|
|
21
42
|
if (spec === undefined) {
|
|
22
43
|
// `cli.hint.help` is deliberately absent from this list: it is the command's own `summary`, and
|
|
23
44
|
// `renderHuman` prints every line and THEN the summary — so the catalogue ended with the same
|
|
@@ -77,7 +98,7 @@ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliComma
|
|
|
77
98
|
command: 'help',
|
|
78
99
|
summary: msg('cli.hint.help'),
|
|
79
100
|
lines: renderHelp(all, topic),
|
|
80
|
-
data:
|
|
101
|
+
data: catalogue(helpTopic(all, topic)),
|
|
81
102
|
};
|
|
82
103
|
},
|
|
83
104
|
};
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { catalogKeys, catalogMissingKeys } from '@ultimat3/i18n';
|
|
|
12
12
|
import { loadApp } from './app-load';
|
|
13
13
|
import { requireAppRoot } from './app-root';
|
|
14
14
|
import type { CliCommand, CommandContext } from './command';
|
|
15
|
-
import { BadFlagError, CatalogExistsError } from './errors';
|
|
15
|
+
import { BadFlagError, CatalogExistsError, MissingPositionalError } from './errors';
|
|
16
16
|
import {
|
|
17
17
|
auditApp,
|
|
18
18
|
loadCatalogs,
|
|
@@ -64,10 +64,13 @@ async function writeNewCatalog(absolute: string, locale: string, contents: strin
|
|
|
64
64
|
function requireLocalePositional(ctx: CommandContext, sub: string): string {
|
|
65
65
|
const raw = ctx.args.positionals[0];
|
|
66
66
|
if (raw === undefined) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// The locale is a positional. `--locale on "x i18n"` is the cause a MALFORMED one takes, from
|
|
68
|
+
// the shared validator below — and there the flag name is what `resolveLocales` was told to
|
|
69
|
+
// report; here there is no value at all, and naming a flag sends the retry to a flag loop.
|
|
70
|
+
throw new MissingPositionalError({
|
|
71
|
+
command: `i18n ${sub}`,
|
|
72
|
+
positional: 'locale',
|
|
73
|
+
example: `x i18n ${sub} es`,
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
76
|
return raw;
|
package/src/cmd-jobs.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { JobDriver } from '@ultimat3/jobs';
|
|
|
8
8
|
import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
|
|
9
9
|
import { requireAppRoot } from './app-root';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
-
import { BadFlagError, JobUnknownError } from './errors';
|
|
11
|
+
import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
|
|
12
12
|
import { drainJobs } from './jobs-drain';
|
|
13
13
|
import { withJobDriver } from './jobs-driver';
|
|
14
14
|
import {
|
|
@@ -33,10 +33,11 @@ const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
|
|
|
33
33
|
function requireIdPositional(ctx: CommandContext, sub: string): string {
|
|
34
34
|
const id = ctx.args.positionals[0];
|
|
35
35
|
if (id === undefined) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
command:
|
|
39
|
-
|
|
36
|
+
// `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
|
|
37
|
+
throw new MissingPositionalError({
|
|
38
|
+
command: `jobs ${sub}`,
|
|
39
|
+
positional: 'id',
|
|
40
|
+
example: 'x jobs ls --json',
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
return id;
|
package/src/cmd-mcp.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
|
|
|
8
8
|
import { requireAppRoot } from './app-root';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
10
|
import { BadFlagError } from './errors';
|
|
11
|
+
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
11
12
|
import { holdUntilShutdown } from './hold';
|
|
12
13
|
import type { CliMcpServer } from './mcp-host';
|
|
13
14
|
import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
|
|
@@ -128,19 +129,22 @@ async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
|
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
/**
|
|
133
|
+
* `flag-number.ts`'s reader, never a bare `Number.parseInt`: the range check alone accepted every
|
|
134
|
+
* prefix parse, so `--port 1e5` bound port 1 and `--port 0x10` bound 0 — a socket at an address the
|
|
135
|
+
* operator did not type, on the transport whose whole output is the url it is reachable at.
|
|
136
|
+
*/
|
|
137
|
+
const readPort = (ctx: CommandContext): number =>
|
|
138
|
+
intFlagOr(
|
|
139
|
+
ctx.args,
|
|
140
|
+
{
|
|
141
|
+
name: 'port',
|
|
137
142
|
command: 'mcp serve',
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
143
|
+
...PORT_RANGE,
|
|
144
|
+
example: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
|
|
145
|
+
},
|
|
146
|
+
DEFAULT_PORT,
|
|
147
|
+
);
|
|
144
148
|
|
|
145
149
|
export const mcpCommand: CliCommand = {
|
|
146
150
|
spec: {
|
package/src/cmd-new.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { chmod } from 'node:fs/promises';
|
|
|
7
7
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
8
|
import { dedupe } from './cmd-generate';
|
|
9
9
|
import type { CliCommand, CommandContext } from './command';
|
|
10
|
+
import { MissingPositionalError } from './errors';
|
|
10
11
|
import { msg } from './messages';
|
|
11
12
|
import type { CommandResult } from './output';
|
|
12
13
|
import { flagBool, flagString } from './parse';
|
|
@@ -25,7 +26,7 @@ export function planNewApp(options: NewAppOptions): readonly GeneratedFile[] {
|
|
|
25
26
|
const app = names(options.name);
|
|
26
27
|
const files: GeneratedFile[] = [
|
|
27
28
|
...repoFiles(app, loadVersion(), options.example),
|
|
28
|
-
...appFiles(app),
|
|
29
|
+
...appFiles(app, options.example),
|
|
29
30
|
];
|
|
30
31
|
if (options.example) {
|
|
31
32
|
files.push(...resourceFiles('post', { surfaceDir: 'apps/web/app', feature: 'post' }));
|
|
@@ -67,7 +68,7 @@ export const newCommand: CliCommand = {
|
|
|
67
68
|
spec: {
|
|
68
69
|
name: 'new',
|
|
69
70
|
summary: 'scaffold a new Ultimate monorepo that already runs',
|
|
70
|
-
usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--json]',
|
|
71
|
+
usage: 'x new <name> [--dir path] [--no-example] [--dry-run] [--force] [--json]',
|
|
71
72
|
flags: [
|
|
72
73
|
{ name: 'dir', type: 'string', summary: 'parent directory (default: cwd)' },
|
|
73
74
|
{
|
|
@@ -82,20 +83,15 @@ export const newCommand: CliCommand = {
|
|
|
82
83
|
},
|
|
83
84
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
84
85
|
const raw = ctx.args.positionals[0];
|
|
86
|
+
// The class, not a hand-built finding with the same code: `MissingPositionalError` is what
|
|
87
|
+
// names the missing POSITIONAL, and a finding assembled here is a second, unenforced copy of
|
|
88
|
+
// a cause the class already writes — one that said "x new needs a name" and not what a name is.
|
|
85
89
|
if (raw === undefined) {
|
|
86
|
-
|
|
87
|
-
ok: false,
|
|
90
|
+
throw new MissingPositionalError({
|
|
88
91
|
command: 'new',
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
code: 'X_CLI_BAD_FLAG',
|
|
93
|
-
cause: 'x new needs a name',
|
|
94
|
-
fix: 'x new myapp',
|
|
95
|
-
docs: 'https://ultimate.dev/errors/X_CLI_BAD_FLAG',
|
|
96
|
-
},
|
|
97
|
-
],
|
|
98
|
-
};
|
|
92
|
+
positional: 'name',
|
|
93
|
+
example: 'x new myapp',
|
|
94
|
+
});
|
|
99
95
|
}
|
|
100
96
|
const app = names(raw);
|
|
101
97
|
const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
|
package/src/cmd-planned.ts
CHANGED
|
@@ -91,6 +91,19 @@ export const PLANNED_COMMANDS: readonly PlannedCommand[] = [
|
|
|
91
91
|
},
|
|
92
92
|
];
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The planned command a resolved command NAME belongs to, if any. Exported for `dispatch.ts`'s
|
|
96
|
+
* parse-failure branch: the parser refuses an undeclared flag before any `run` is reached, and a
|
|
97
|
+
* planned command declares only the four global flags — so `x logs tail --follow` answered
|
|
98
|
+
* X_CLI_BAD_FLAG listing "known: json, help, cwd, verbose", a flag set belonging to a command that
|
|
99
|
+
* does not exist yet, while `x logs tail` answered the honest X_NOT_IMPLEMENTED one invocation away.
|
|
100
|
+
*
|
|
101
|
+
* Takes the name `commandFor` resolved, never the raw word: aliases are that function's business
|
|
102
|
+
* and a second matcher here would be a second answer to "which command did they type".
|
|
103
|
+
*/
|
|
104
|
+
export const plannedCommandFor = (name: string | undefined): PlannedCommand | undefined =>
|
|
105
|
+
PLANNED_COMMANDS.find((planned) => planned.name === name);
|
|
106
|
+
|
|
94
107
|
export interface PlannedSubcommand {
|
|
95
108
|
readonly command: string;
|
|
96
109
|
readonly subcommand: string;
|
package/src/cmd-policy.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { loadApp } from './app-load';
|
|
6
6
|
import { requireAppRoot } from './app-root';
|
|
7
7
|
import type { CliCommand, CommandContext } from './command';
|
|
8
|
-
import {
|
|
8
|
+
import { DeclarationUnknownError, MissingPositionalError } from './errors';
|
|
9
9
|
import { msg } from './messages';
|
|
10
10
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
11
11
|
import { nearest } from './parse';
|
|
@@ -74,11 +74,13 @@ function declarationLines(declaration: DeclarationExplanation): readonly string[
|
|
|
74
74
|
function requireSubject(ctx: CommandContext): string {
|
|
75
75
|
const name = ctx.args.positionals[0];
|
|
76
76
|
if (name === undefined) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
// Never `BadFlagError`: its cause read `--subject on "x policy"`, and the next thing an agent
|
|
78
|
+
// typed was `x policy explain --subject posts:read`, which is a second X_CLI_BAD_FLAG for a
|
|
79
|
+
// flag this command does not declare. The positional is what is missing, so it is what is named.
|
|
80
|
+
throw new MissingPositionalError({
|
|
81
|
+
command: 'policy explain',
|
|
82
|
+
positional: 'subject',
|
|
83
|
+
example: 'x policy list --json',
|
|
82
84
|
});
|
|
83
85
|
}
|
|
84
86
|
return name;
|
package/src/cmd-registries.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { describeQueries, getQuery } from '@ultimat3/query';
|
|
|
13
13
|
import { loadApp } from './app-load';
|
|
14
14
|
import { requireAppRoot } from './app-root';
|
|
15
15
|
import type { CliCommand, CommandContext } from './command';
|
|
16
|
-
import {
|
|
16
|
+
import { DeclarationUnknownError, MissingPositionalError } from './errors';
|
|
17
17
|
import { msg } from './messages';
|
|
18
18
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
19
19
|
import type { CommandSpec } from './parse';
|
|
@@ -143,11 +143,12 @@ function describeResult<D extends { readonly name: string }, Raw extends { descr
|
|
|
143
143
|
): CommandResult {
|
|
144
144
|
const name = ctx.args.positionals[0];
|
|
145
145
|
if (name === undefined) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
// A positional, so `MissingPositionalError` — `--name on "x actions"` named a flag no registry
|
|
147
|
+
// command declares, and reading it as one is a second refusal for the first one's advice.
|
|
148
|
+
throw new MissingPositionalError({
|
|
149
|
+
command: `${kind.kind} describe`,
|
|
150
|
+
positional: 'name',
|
|
151
|
+
example: `x ${kind.kind} list --json`,
|
|
151
152
|
});
|
|
152
153
|
}
|
|
153
154
|
const raw = kind.find(name);
|