@ultimat3/cli 23.0.0 → 25.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,31 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
ERROR_DOCS_URL,
|
|
10
|
-
tryResolveEnvironment,
|
|
11
|
-
usesDevCursorSecret,
|
|
12
|
-
} from '@ultimat3/core';
|
|
13
|
-
import {
|
|
14
|
-
checkDb,
|
|
15
|
-
createPostgresClient,
|
|
16
|
-
PGLITE_FIX,
|
|
17
|
-
PGLITE_MISSING,
|
|
18
|
-
PGLITE_PACKAGE,
|
|
19
|
-
} from '@ultimat3/db';
|
|
20
|
-
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
7
|
+
import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
8
|
+
import { checkDb, PGLITE_FIX, PGLITE_MISSING, PGLITE_PACKAGE, postgresClient } from '@ultimat3/db';
|
|
21
9
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
22
|
-
import {
|
|
10
|
+
import { doctorSpec } from './cmd-doctor-spec';
|
|
23
11
|
import type { CliCommand, CommandContext } from './command';
|
|
24
12
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
|
+
import { appUrlFindings } from './doctor-app-url';
|
|
14
|
+
import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
|
|
25
15
|
import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
|
|
26
16
|
import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
|
|
27
17
|
import type { OfflineFallbackFact } from './doctor-offline';
|
|
28
18
|
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
29
19
|
import type { SealedKeysFact } from './doctor-sealed';
|
|
30
20
|
import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
|
|
31
|
-
import {
|
|
21
|
+
import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
|
|
22
|
+
import { machineSecretFindings } from './framework-env';
|
|
32
23
|
import { ICON_SOURCE } from './icon-assets';
|
|
33
24
|
import { msg } from './messages';
|
|
34
25
|
import type { CommandResult, Finding } from './output';
|
|
@@ -37,6 +28,8 @@ import type { ParsedArgs } from './parse';
|
|
|
37
28
|
import { portFree } from './port-probe';
|
|
38
29
|
import { syncPortFor } from './role-sync';
|
|
39
30
|
import { checkMigrationDrift } from './schema-drift';
|
|
31
|
+
import { portFromEnv } from './serve-env';
|
|
32
|
+
import { quoteArg } from './shell-quote';
|
|
40
33
|
|
|
41
34
|
/**
|
|
42
35
|
* The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
|
|
@@ -49,17 +42,15 @@ export interface DoctorProbe {
|
|
|
49
42
|
/** App root, or undefined when the command runs outside an app. */
|
|
50
43
|
readonly root: string | undefined;
|
|
51
44
|
readonly port: number;
|
|
52
|
-
/**
|
|
53
|
-
readonly
|
|
45
|
+
/** `APP_URL` as this environment declares it; `undefined` when unset. */
|
|
46
|
+
readonly appUrl: string | undefined;
|
|
54
47
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
48
|
+
* The framework's own secrets this machine lacks — `framework-env.ts`'s
|
|
49
|
+
* `machineSecretFindings`, gated by `namedDeployed`: only an environment NAMED staging or
|
|
50
|
+
* production (an unnamed developer shell owes nothing; `x env check` is the fail-closed answer).
|
|
51
|
+
* The list's own `needed`/`unsafe` decide; doctor reports.
|
|
59
52
|
*/
|
|
60
|
-
readonly
|
|
61
|
-
/** True when this process believes it is serving real clients. */
|
|
62
|
-
readonly production: boolean;
|
|
53
|
+
readonly frameworkFindings: readonly Finding[];
|
|
63
54
|
exists(relativePath: string): boolean;
|
|
64
55
|
portFree(port: number): Promise<boolean>;
|
|
65
56
|
/**
|
|
@@ -101,6 +92,11 @@ export interface DoctorProbe {
|
|
|
101
92
|
* a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
|
|
102
93
|
*/
|
|
103
94
|
sealedKeys(): Promise<SealedKeysFact>;
|
|
95
|
+
/**
|
|
96
|
+
* What an upgrade left in the auth tables for an operator to finish: unsealed second-factor
|
|
97
|
+
* secrets, and tables no release reads. Asked of an external database only.
|
|
98
|
+
*/
|
|
99
|
+
authStorage(): Promise<AuthStorageFact>;
|
|
104
100
|
}
|
|
105
101
|
|
|
106
102
|
/** What `x doctor` reads about the embedded database, without opening it. */
|
|
@@ -181,7 +177,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
|
181
177
|
finding(
|
|
182
178
|
'X_PORT_IN_USE',
|
|
183
179
|
`port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
|
|
184
|
-
`x dev --port ${portPairAfter(probe.port)}`,
|
|
180
|
+
`x dev --port ${quoteArg(String(portPairAfter(probe.port)))}`,
|
|
185
181
|
),
|
|
186
182
|
);
|
|
187
183
|
}
|
|
@@ -221,42 +217,16 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
221
217
|
// `.env.example` is the committed projection of `envSchema`, so the copy lands every
|
|
222
218
|
// declared key with its default and blank secrets; `x env example` writes it where an app
|
|
223
219
|
// has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
|
|
224
|
-
`cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
|
|
220
|
+
`cp ${quoteArg(ENV_EXAMPLE_PATH)} ${quoteArg(ENV_DEVELOPMENT)}`,
|
|
225
221
|
ENV_DEVELOPMENT,
|
|
226
222
|
),
|
|
227
223
|
);
|
|
228
224
|
}
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
// reader to skim past the report. The key is a defect only where cursors reach real clients,
|
|
233
|
-
// who can read it out of the published package and forge a page position.
|
|
234
|
-
// Sits here because it costs two env reads and a comparison — cheaper than binding a port.
|
|
235
|
-
if (probe.production && probe.devCursorSecret) {
|
|
236
|
-
findings.push(
|
|
237
|
-
finding(
|
|
238
|
-
'X_CURSOR_SECRET_DEV',
|
|
239
|
-
'cursors are signed with the shipped development key, so a client can forge a page position',
|
|
240
|
-
'export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"',
|
|
241
|
-
),
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
// The storage twin of the cursor key above, and the more expensive one to get wrong: the
|
|
245
|
-
// published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
|
|
246
|
-
// `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
|
|
247
|
-
// Production only, for the reason the cursor check gives — every dev environment signs with the
|
|
248
|
-
// shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
|
|
249
|
-
// reports it before a deploy reaches the refusal.
|
|
250
|
-
if (probe.production && probe.devStorageSecret) {
|
|
251
|
-
findings.push(
|
|
252
|
-
finding(
|
|
253
|
-
'X_STORAGE_SECRET_DEV',
|
|
254
|
-
`${STORAGE_SIGNING_SECRET_KEY} is unset or holds the shipped development key, so a local-disk deploy would accept forged upload grants that override its own uploadPolicy`,
|
|
255
|
-
'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
256
|
-
),
|
|
257
|
-
);
|
|
258
|
-
}
|
|
225
|
+
// Silent in development and on a shell naming no environment; reported where this machine names
|
|
226
|
+
// a deploy. One list, one wording (`framework-env.ts`, #679).
|
|
227
|
+
findings.push(...probe.frameworkFindings);
|
|
259
228
|
findings.push(...(await portFindings(probe)));
|
|
229
|
+
findings.push(...appUrlFindings(probe.appUrl, probe.port));
|
|
260
230
|
// `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
|
|
261
231
|
// `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
|
|
262
232
|
// names — two codes for one condition, one of them registered by nobody, so `x errors explain`
|
|
@@ -293,20 +263,28 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
293
263
|
findings.push(...(await probe.snapshots()));
|
|
294
264
|
// Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
|
|
295
265
|
findings.push(...sealedKeyFindings(await probe.sealedKeys()));
|
|
266
|
+
findings.push(...authStorageFindings(await probe.authStorage()));
|
|
296
267
|
return findings;
|
|
297
268
|
}
|
|
298
269
|
|
|
299
270
|
/**
|
|
300
|
-
* The port to TEST, and the one place `x doctor` reads it
|
|
301
|
-
*
|
|
302
|
-
*
|
|
271
|
+
* The port to TEST, and the one place `x doctor` reads it: `devPortFor`'s order — `--port`, then
|
|
272
|
+
* `PORT` (`portFromEnv`, the one reader of it), then 3000 — so the probe answers about the port
|
|
273
|
+
* `x dev` will bind. It probed 3000 under `PORT=4000` while dev bound 4000 and 4001. `PORT_RANGE.min`
|
|
274
|
+
* is 0 because `x dev --port 0` means "let the kernel pick"; here 0 means nothing, and
|
|
275
|
+
* `Bun.serve({ port: 0 })` always succeeds — so the port check could not fail.
|
|
303
276
|
*/
|
|
304
|
-
export const doctorPort = (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
277
|
+
export const doctorPort = (
|
|
278
|
+
args: ParsedArgs,
|
|
279
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
280
|
+
): number =>
|
|
281
|
+
readIntFlag(args, {
|
|
282
|
+
name: 'port',
|
|
283
|
+
command: 'doctor',
|
|
284
|
+
...PORT_RANGE,
|
|
285
|
+
min: 1,
|
|
286
|
+
example: 'x doctor --port 3000',
|
|
287
|
+
}) ?? portFromEnv(env);
|
|
310
288
|
|
|
311
289
|
/**
|
|
312
290
|
* `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
|
|
@@ -357,7 +335,7 @@ const installedAbove = (dir: string, specifier: string): boolean => {
|
|
|
357
335
|
*/
|
|
358
336
|
async function probeDatabase(url: string | undefined): Promise<Finding | null> {
|
|
359
337
|
if (externalUrl(url) === undefined) return null;
|
|
360
|
-
const client =
|
|
338
|
+
const client = postgresClient({ url, applicationName: 'x-doctor' });
|
|
361
339
|
try {
|
|
362
340
|
const report = await checkDb(client);
|
|
363
341
|
if (report.ok) return null;
|
|
@@ -375,22 +353,20 @@ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
|
|
|
375
353
|
}
|
|
376
354
|
}
|
|
377
355
|
|
|
378
|
-
export function probeFor(
|
|
356
|
+
export function probeFor(
|
|
357
|
+
cwd: string,
|
|
358
|
+
bunVersion: string,
|
|
359
|
+
port: number,
|
|
360
|
+
// The environment `doctorPort` read `PORT` from, so `APP_URL` is judged against the same one.
|
|
361
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
362
|
+
): DoctorProbe {
|
|
379
363
|
const root = findAppRoot(cwd)?.dir;
|
|
380
364
|
return {
|
|
381
365
|
bunVersion,
|
|
382
366
|
root,
|
|
383
367
|
port,
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
|
|
387
|
-
// as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
|
|
388
|
-
// repo reads, so a deploy declaring production the framework's own way was told it was not
|
|
389
|
-
// production and skipped both secret findings; and the `??` short-circuited, so any non-empty
|
|
390
|
-
// `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
|
|
391
|
-
// `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
|
|
392
|
-
// that crashes on a typo is the one thing worse than a diagnostic that misses.
|
|
393
|
-
production: tryResolveEnvironment() === 'production',
|
|
368
|
+
appUrl: env['APP_URL'],
|
|
369
|
+
frameworkFindings: machineSecretFindings(env),
|
|
394
370
|
exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
|
|
395
371
|
portFree,
|
|
396
372
|
database: () => probeDatabase(process.env['DATABASE_URL']),
|
|
@@ -408,14 +384,15 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
408
384
|
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
409
385
|
sealedKeys: async () =>
|
|
410
386
|
root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
|
|
387
|
+
authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
|
|
411
388
|
};
|
|
412
389
|
}
|
|
413
390
|
|
|
414
391
|
export const doctorCommand: CliCommand = {
|
|
415
392
|
spec: doctorSpec,
|
|
416
393
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
417
|
-
const port = doctorPort(ctx.args);
|
|
418
|
-
const probe = probeFor(ctx.cwd, ctx.bunVersion, port);
|
|
394
|
+
const port = doctorPort(ctx.args, ctx.env);
|
|
395
|
+
const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
|
|
419
396
|
const findings = await runDoctor(probe);
|
|
420
397
|
const listed = withGuardListing(
|
|
421
398
|
{
|
package/src/cmd-env.ts
CHANGED
|
@@ -4,15 +4,40 @@
|
|
|
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 {
|
|
7
|
+
import {
|
|
8
|
+
checkEnv,
|
|
9
|
+
ENV_EXAMPLE_PATH,
|
|
10
|
+
ERROR_DOCS_URL,
|
|
11
|
+
maskedEnvValues,
|
|
12
|
+
UltimateError,
|
|
13
|
+
} from '@ultimat3/core';
|
|
8
14
|
import { envExampleFor, loadEnvSchema } from './app-env';
|
|
9
15
|
import { requireAppRoot } from './app-root';
|
|
10
16
|
import { envSpec } from './cmd-env-spec';
|
|
11
17
|
import type { CliCommand, CommandContext } from './command';
|
|
12
|
-
import {
|
|
18
|
+
import { frameworkSecretFindings } from './framework-env';
|
|
13
19
|
import { msg } from './messages';
|
|
14
20
|
import type { CommandResult, Finding, JsonValue } from './output';
|
|
15
21
|
|
|
22
|
+
/**
|
|
23
|
+
* `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
|
|
24
|
+
* writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
|
|
25
|
+
* present", both read as a working environment declaration to whoever runs the command next.
|
|
26
|
+
*
|
|
27
|
+
* `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
|
|
28
|
+
* `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
|
|
29
|
+
* than minting a synonym.
|
|
30
|
+
*/
|
|
31
|
+
export class EnvSchemaMissingError extends UltimateError {
|
|
32
|
+
constructor(input: { subcommand: string }) {
|
|
33
|
+
super({
|
|
34
|
+
code: 'X_CONFIG_INVALID',
|
|
35
|
+
cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
|
|
36
|
+
fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
/**
|
|
17
42
|
* Every subcommand needs the declaration, and an app without one is a usage error rather than an
|
|
18
43
|
* empty success: `x env example` writing a two-comment file would look like it worked.
|
|
@@ -43,33 +68,47 @@ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
|
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
/**
|
|
46
|
-
* The values are read from the
|
|
47
|
-
* `
|
|
71
|
+
* The values are read from the environment the command was handed (`ctx.env`, the process's own
|
|
72
|
+
* in `bin.ts`) — one table for the app's keys and the framework's — and only ever printed through
|
|
73
|
+
* `maskedEnvValues`: `checkEnv().values` holds the actual secrets because `defineEnv()` has to
|
|
48
74
|
* return them, and a `--json` report is the last place a DSN should appear in full.
|
|
75
|
+
*
|
|
76
|
+
* The framework's deploy-required secrets (`framework-env.ts`) are asked exactly where the boot
|
|
77
|
+
* refuses them — outside development/test, and where no environment is named at all — so `ULTIMATE_ENV=production x env check` answers what
|
|
78
|
+
* the first deployed boot would, instead of each role crash-looping on one key at a time (#679).
|
|
49
79
|
*/
|
|
50
80
|
async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
|
|
51
81
|
const { schema } = await requireSchema(ctx.cwd, 'check');
|
|
52
|
-
const report = checkEnv(schema);
|
|
82
|
+
const report = checkEnv(schema, { env: ctx.env });
|
|
53
83
|
const total = Object.keys(schema).length;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
84
|
+
// A key the app's own declaration already reported is one finding, not two.
|
|
85
|
+
const reported = new Set(report.issues.map((issue) => issue.key));
|
|
86
|
+
// Gated by `deployed()` inside — the boot's own rule, so a table naming no environment is
|
|
87
|
+
// checked as the production process it boots as.
|
|
88
|
+
const framework = frameworkSecretFindings(ctx.env, reported);
|
|
89
|
+
const findings: readonly Finding[] = [
|
|
90
|
+
...report.issues.map((issue) => ({
|
|
91
|
+
code: 'X_ENV_MISSING',
|
|
92
|
+
cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
|
|
93
|
+
fix: issue.fix,
|
|
94
|
+
docs: ERROR_DOCS_URL,
|
|
95
|
+
at: ENV_EXAMPLE_PATH,
|
|
96
|
+
})),
|
|
97
|
+
...framework,
|
|
98
|
+
];
|
|
99
|
+
const ok = findings.length === 0;
|
|
61
100
|
return {
|
|
62
|
-
ok
|
|
101
|
+
ok,
|
|
63
102
|
command: 'env',
|
|
64
|
-
summary:
|
|
103
|
+
summary: ok
|
|
65
104
|
? msg('cli.env.checked', { count: total })
|
|
66
|
-
: msg('cli.env.invalid', { count:
|
|
105
|
+
: msg('cli.env.invalid', { count: findings.length, total }),
|
|
67
106
|
findings,
|
|
68
107
|
data: {
|
|
69
108
|
variables: total,
|
|
70
109
|
values: maskedEnvValues(schema, report.values) as JsonValue,
|
|
71
110
|
},
|
|
72
|
-
exitCode:
|
|
111
|
+
exitCode: ok ? 0 : 1,
|
|
73
112
|
};
|
|
74
113
|
}
|
|
75
114
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -5,20 +5,23 @@
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
7
7
|
import { registerAdminResources } from './admin-registration';
|
|
8
|
-
import { registerGeneratedPrimitives } from './api-registration';
|
|
8
|
+
import { indexBindingFindings, registerGeneratedPrimitives } from './api-registration';
|
|
9
9
|
import { writeAppArtifacts } from './app-artifacts';
|
|
10
10
|
import { appManifest } from './app-manifest';
|
|
11
11
|
import { requireAppRoot } from './app-root';
|
|
12
12
|
import { generateSpec } from './cmd-generate-spec';
|
|
13
13
|
import type { CliCommand, CommandContext } from './command';
|
|
14
14
|
import { invocationOf } from './command';
|
|
15
|
+
import { localiseCatalogs } from './generate-catalog-locales';
|
|
15
16
|
import { assertFeatureExists } from './generate-feature';
|
|
16
17
|
import { generate, sliceDir } from './generate-files';
|
|
17
18
|
import { ungrantedByGenerator } from './generate-grant-findings';
|
|
18
19
|
import { grantGeneratedPermissions } from './generate-grants';
|
|
19
20
|
import type { Generator } from './generate-kinds';
|
|
20
21
|
import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
|
|
21
|
-
import {
|
|
22
|
+
import { refusePluralTable } from './generate-plural';
|
|
23
|
+
import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
|
|
24
|
+
import { containedPath, planWrites, writeFiles } from './generate-write';
|
|
22
25
|
import { declareGeneratedImports } from './generated-imports';
|
|
23
26
|
import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
|
|
24
27
|
import { resolveCatalogModule } from './i18n-audit';
|
|
@@ -28,6 +31,7 @@ import { msg } from './messages';
|
|
|
28
31
|
import type { CommandResult, Finding } from './output';
|
|
29
32
|
import { flagBool, flagList, flagString } from './parse';
|
|
30
33
|
import { quoteArg } from './shell-quote';
|
|
34
|
+
import type { GeneratedFile } from './templates';
|
|
31
35
|
import { kebab, names, resolveLocales } from './templates';
|
|
32
36
|
|
|
33
37
|
// One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
|
|
@@ -40,6 +44,24 @@ export { GENERATORS } from './generate-kinds';
|
|
|
40
44
|
export type { WriteReport } from './generate-write';
|
|
41
45
|
export { dedupe, writeFiles } from './generate-write';
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Names to suggest when the API index already holds the requested one, in order: the feature's
|
|
49
|
+
* prefix, the kind's suffix, then that suffix numbered — kebab output, so each pastes into a shell
|
|
50
|
+
* as one argument. The caller re-plans each and offers the first the index does not hold either.
|
|
51
|
+
*/
|
|
52
|
+
const freeNames = (
|
|
53
|
+
kind: Generator,
|
|
54
|
+
name: string,
|
|
55
|
+
feature: string | undefined,
|
|
56
|
+
): readonly string[] => {
|
|
57
|
+
const suffixed = `${kebab(name)}-${kind.split(':').at(-1) ?? kind}`;
|
|
58
|
+
return [
|
|
59
|
+
...(feature === undefined ? [] : [`${kebab(feature)}-${kebab(name)}`]),
|
|
60
|
+
suffixed,
|
|
61
|
+
...Array.from({ length: 8 }, (_unused, index) => `${suffixed}-${index + 2}`),
|
|
62
|
+
];
|
|
63
|
+
};
|
|
64
|
+
|
|
43
65
|
export const generateCommand: CliCommand = {
|
|
44
66
|
spec: generateSpec,
|
|
45
67
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -72,41 +94,76 @@ export const generateCommand: CliCommand = {
|
|
|
72
94
|
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
73
95
|
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
74
96
|
const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
const planFor = (planned: string) =>
|
|
98
|
+
generate({
|
|
99
|
+
kind,
|
|
100
|
+
name: planned,
|
|
101
|
+
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
102
|
+
...(sliceErrors === undefined ? {} : { sliceErrors }),
|
|
103
|
+
...(sliceEntity === undefined ? {} : { sliceEntity }),
|
|
104
|
+
...(sliceRepo === undefined ? {} : { sliceRepo }),
|
|
105
|
+
...(at === undefined ? {} : { at }),
|
|
106
|
+
...(permission === undefined ? {} : { permission }),
|
|
107
|
+
surface,
|
|
108
|
+
live: flagBool(ctx.args, 'live'),
|
|
109
|
+
admin: flagBool(ctx.args, 'admin'),
|
|
110
|
+
locales,
|
|
111
|
+
...(catalogModule === undefined ? {} : { catalogModule }),
|
|
112
|
+
...(dbModule === undefined ? {} : { dbModule }),
|
|
113
|
+
shell,
|
|
114
|
+
});
|
|
115
|
+
// A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
|
|
116
|
+
// with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
|
|
117
|
+
const files = await localiseCatalogs(root, planFor(name));
|
|
118
|
+
// On the planned files, before a dry run answers or anything lands: a name whose type spelling
|
|
119
|
+
// the emitted code also uses as a global is a slice that does not compile.
|
|
120
|
+
refuseShadowedTypes(files, kind, name);
|
|
121
|
+
// And one whose binding a planned file both imports and declares (`x g job job`).
|
|
122
|
+
refuseShadowedValues(files, kind, name);
|
|
123
|
+
// And a plural slice name that an `entity.ts` this run writes would pluralise again.
|
|
124
|
+
refusePluralTable(files, kind, name, featureFlag, (path) =>
|
|
125
|
+
existsSync(containedPath(root, path)),
|
|
126
|
+
);
|
|
127
|
+
// The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
|
|
128
|
+
// a second slice beside the one that conflicted.
|
|
129
|
+
const flags = reproducedFlags(generateCommand.spec, ctx.args);
|
|
130
|
+
const invocation = [invocationOf(ctx, 'g'), kind, quoteArg(name), ...flags].join(' ');
|
|
131
|
+
// A module the API index would list under a name it already holds: refused before anything is
|
|
132
|
+
// written, with the same run under a name that is free — re-planned and re-checked, so the
|
|
133
|
+
// suggestion is never itself a conflict.
|
|
134
|
+
const pathsOf = (planned: readonly GeneratedFile[]) => planned.map((file) => file.path);
|
|
135
|
+
const held = await indexBindingFindings(root, pathsOf(files), '');
|
|
136
|
+
let bindings: readonly Finding[] = [];
|
|
137
|
+
if (held.length > 0) {
|
|
138
|
+
let free = freeNames(kind, name, featureFlag).at(-1) ?? name;
|
|
139
|
+
for (const candidate of freeNames(kind, name, featureFlag)) {
|
|
140
|
+
if ((await indexBindingFindings(root, pathsOf(planFor(candidate)), '')).length > 0)
|
|
141
|
+
continue;
|
|
142
|
+
free = candidate;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
const renamed = [invocationOf(ctx, 'g'), kind, quoteArg(free), ...flags].join(' ');
|
|
146
|
+
bindings = held.map((finding) => ({ ...finding, fix: renamed }));
|
|
147
|
+
}
|
|
148
|
+
const force = flagBool(ctx.args, 'force');
|
|
92
149
|
if (flagBool(ctx.args, 'dry-run')) {
|
|
150
|
+
// The write plan, never the bare file list: what the real run would write, skip and refuse.
|
|
151
|
+
const plan = await planWrites(root, files, force, invocation);
|
|
152
|
+
const findings = [...plan.conflicts, ...bindings];
|
|
153
|
+
const planned = findings.length === 0 ? plan.written : [];
|
|
93
154
|
return {
|
|
94
|
-
ok:
|
|
155
|
+
ok: findings.length === 0,
|
|
95
156
|
command: 'g',
|
|
96
|
-
summary: msg('cli.generate.planned', { count:
|
|
97
|
-
data: { files:
|
|
98
|
-
lines:
|
|
157
|
+
summary: msg('cli.generate.planned', { count: planned.length, kind, name }),
|
|
158
|
+
data: { files: planned, dryRun: true },
|
|
159
|
+
lines: planned.map((file) => msg('cli.file.added', { path: file })),
|
|
160
|
+
findings,
|
|
99
161
|
};
|
|
100
162
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
kind,
|
|
106
|
-
quoteArg(name),
|
|
107
|
-
...reproducedFlags(generateCommand.spec, ctx.args),
|
|
108
|
-
].join(' ');
|
|
109
|
-
const report = await writeFiles(root, files, flagBool(ctx.args, 'force'), invocation);
|
|
163
|
+
const report =
|
|
164
|
+
bindings.length > 0
|
|
165
|
+
? { written: [], conflicts: bindings }
|
|
166
|
+
: await writeFiles(root, files, force, invocation);
|
|
110
167
|
// The three edits a generated primitive needs outside its own slice, performed rather than
|
|
111
168
|
// left as findings: a declared permission granted to a role, a job listed in `defineApi`, and
|
|
112
169
|
// an entity added to the typed handle its `repo.ts` reads through. Before the manifest load
|
package/src/cmd-help.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// never describe a flag that does not exist. Built as a factory over the spec list to keep the
|
|
3
3
|
// registry acyclic.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import type { CliCommand, CommandContext } from './command';
|
|
7
|
+
import { UnknownCommandError } from './errors';
|
|
6
8
|
import { msg } from './messages';
|
|
7
9
|
import type { CommandResult, JsonValue } from './output';
|
|
8
10
|
import type { CommandSpec, FlagSpec } from './parse';
|
|
@@ -18,17 +20,28 @@ const flagLine = (flag: FlagSpec): string => {
|
|
|
18
20
|
* The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
|
|
19
21
|
* of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
|
|
20
22
|
* generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
|
|
21
|
-
* `g
|
|
23
|
+
* `g`. A topic naming no command is refused, as `x <topic>` itself is: answering the catalogue
|
|
24
|
+
* with exit 0 read as that command's usage to a caller that never compared the names.
|
|
22
25
|
*/
|
|
23
26
|
const specFor = (
|
|
24
27
|
specs: readonly CommandSpec[],
|
|
25
28
|
topic: string | undefined,
|
|
26
|
-
): CommandSpec | undefined =>
|
|
27
|
-
topic === undefined
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
): CommandSpec | undefined => {
|
|
30
|
+
if (topic === undefined) return undefined;
|
|
31
|
+
const found = specs.find(
|
|
32
|
+
(entry) => entry.name === topic || (entry.aliases ?? []).includes(topic),
|
|
33
|
+
);
|
|
34
|
+
if (found !== undefined) return found;
|
|
35
|
+
const names = specs.map((entry) => entry.name);
|
|
36
|
+
const nearest = nearestName(topic, [...names, ...specs.flatMap((entry) => entry.aliases ?? [])]);
|
|
37
|
+
throw new UnknownCommandError({
|
|
38
|
+
path: `help ${topic}`,
|
|
39
|
+
known: names,
|
|
40
|
+
suggestion: nearest === undefined ? 'help' : `help ${nearest}`,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
30
43
|
|
|
31
|
-
/** What `--json` reports: the one resolved command, or —
|
|
44
|
+
/** What `--json` reports: the one resolved command, or — with no topic — all of them. */
|
|
32
45
|
export function helpTopic(
|
|
33
46
|
specs: readonly CommandSpec[],
|
|
34
47
|
topic: string | undefined,
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -185,7 +185,7 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
|
|
|
185
185
|
const path = catalogPath(locale);
|
|
186
186
|
const app = await loadApp(root);
|
|
187
187
|
const catalogs = await loadCatalogs(root);
|
|
188
|
-
const from = resolveDefaultLocale(app.defaultLocale, catalogs);
|
|
188
|
+
const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
|
|
189
189
|
const seeded = seedCatalog(from === undefined ? {} : (catalogs[from] ?? {}));
|
|
190
190
|
await writeNewCatalog(join(root, path), locale, serializeCatalog(seeded));
|
|
191
191
|
// The second half of adding a locale, and without it this command SHIPPED A RED GATE: the file
|
|
@@ -196,14 +196,15 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
|
|
|
196
196
|
const { registered, findings: indexFindings } = await syncI18nIndex(root);
|
|
197
197
|
|
|
198
198
|
const keys = catalogKeys(seeded).length;
|
|
199
|
+
// The catalog is on disk either way, but a finding fails the run: a `packages/i18n/src/index.ts`
|
|
200
|
+
// that would not import is why `from` may be the framework's `en` instead of the app's own
|
|
201
|
+
// default, and an index the locale could not be registered in is a red `i18n` gate step.
|
|
202
|
+
const findings = [...indexFindings, ...app.findings];
|
|
199
203
|
return {
|
|
200
|
-
|
|
201
|
-
// rather than flip that: a `packages/i18n/src/index.ts` that would not import is why `from` may
|
|
202
|
-
// be the framework's `en` instead of the app's own default, and silence there is the bug.
|
|
203
|
-
ok: true,
|
|
204
|
+
ok: findings.length === 0,
|
|
204
205
|
command: 'i18n',
|
|
205
206
|
summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
|
|
206
|
-
findings
|
|
207
|
+
findings,
|
|
207
208
|
// `registered` is false only for an app with no `packages/i18n` at all — a fact a caller has
|
|
208
209
|
// to be able to read, because it is the one case where the locale is on disk and unselectable.
|
|
209
210
|
data: { locale, from: from ?? locale, keys, path, registered },
|
|
@@ -258,13 +259,15 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
|
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
const app = await loadApp(root);
|
|
261
|
-
const from = resolveDefaultLocale(app.defaultLocale, catalogs);
|
|
262
|
+
const from = resolveDefaultLocale(app.locales.defaultLocale, catalogs);
|
|
262
263
|
// `from === locale` is the default locale asked to sync itself, and `undefined` is an app with
|
|
263
264
|
// no resolvable default at all — one branch, because both have no catalog to merge from.
|
|
265
|
+
// A real merge copies the default locale's strings MARKED (`seedCatalog`, as `x i18n add` does):
|
|
266
|
+
// copied bare they rendered English to every `fr` reader under a green gate.
|
|
264
267
|
const seeded = from === undefined || from === locale;
|
|
265
268
|
const source = seeded
|
|
266
269
|
? placeholderCatalog(await untranslatedKeys(root, catalogs, locale))
|
|
267
|
-
: (catalogs[from] ?? {});
|
|
270
|
+
: seedCatalog(catalogs[from] ?? {});
|
|
268
271
|
const { merged, added } = syncCatalog(target, source);
|
|
269
272
|
if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
|
|
270
273
|
// Unconditional, and not only when keys moved: a catalog that reached disk some other way — a
|
|
@@ -274,16 +277,16 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
|
|
|
274
277
|
const { registered, findings: indexFindings } = await syncI18nIndex(root);
|
|
275
278
|
|
|
276
279
|
const total = catalogKeys(merged).length;
|
|
280
|
+
// Same as `runAdd`: the merge landed, and a finding still fails the run.
|
|
281
|
+
const findings = [...indexFindings, ...app.findings];
|
|
277
282
|
return {
|
|
278
|
-
|
|
279
|
-
// own default or the framework's fallback standing in for an i18n module that would not import.
|
|
280
|
-
ok: true,
|
|
283
|
+
ok: findings.length === 0,
|
|
281
284
|
command: 'i18n',
|
|
282
285
|
summary: msg('cli.i18n.synced', { locale, from: from ?? locale, added: added.length, total }),
|
|
283
286
|
// The keys themselves, raw — `runCheck` lists gaps the same way, because a key is a value an
|
|
284
|
-
// author copies and never prose the catalog owns.
|
|
285
|
-
lines:
|
|
286
|
-
findings
|
|
287
|
+
// author copies and never prose the catalog owns. Every added key is one a human still owes.
|
|
288
|
+
lines: added.map((key) => ` ${key}`),
|
|
289
|
+
findings,
|
|
287
290
|
data: {
|
|
288
291
|
locale,
|
|
289
292
|
from: from ?? locale,
|
|
@@ -291,9 +294,10 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
|
|
|
291
294
|
total,
|
|
292
295
|
path: catalogPath(locale),
|
|
293
296
|
registered,
|
|
294
|
-
// Which of `added` still need a human
|
|
295
|
-
// string
|
|
296
|
-
|
|
297
|
+
// Which of `added` still need a human: all of them. `⟦key⟧` where there was no catalog to
|
|
298
|
+
// copy from, `⟦<default string>⟧` on a real merge — both are what `x i18n check` counts as
|
|
299
|
+
// missing until replaced, so `--json` never reports a copy as a translation.
|
|
300
|
+
placeholders: added,
|
|
297
301
|
},
|
|
298
302
|
};
|
|
299
303
|
}
|