@ultimat3/cli 21.0.0 → 22.1.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 +84 -1650
- package/README.md +40 -2
- package/package.json +30 -30
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +11 -1
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +111 -43
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +55 -36
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +3 -3
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +28 -30
- package/src/error-pages.ts +19 -7
- package/src/errors.ts +14 -0
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -348
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +15 -8
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +1 -1
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +28 -27
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +23 -17
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +22 -8
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -347
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sync-url.ts +21 -1
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +49 -12
- package/src/templates/resource.ts +10 -4
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +24 -159
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +10 -7
- package/src/verify-run.ts +11 -1
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +1 -26
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -94
- package/src/cdp-connection.ts +0 -247
- package/src/cdp-e2e-page.ts +0 -180
- package/src/cdp-e2e-session.ts +0 -199
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -193
- package/src/cdp-offline-script.ts +0 -73
- package/src/cdp-pipe.ts +0 -77
- package/src/e2e-app.ts +0 -103
- package/src/e2e-browser-handle.ts +0 -55
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -117
- package/src/e2e-errors.ts +0 -117
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -153
- package/src/e2e-preload.ts +0 -64
- package/src/e2e-probe.ts +0 -23
- package/src/e2e-selection.ts +0 -182
- package/src/e2e-spawn.ts +0 -169
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
- /package/src/{dev-render.ts → runtime-render.ts} +0 -0
package/src/drift.ts
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
-
|
|
14
|
+
// `canonicalJson` is core's, the one serializer every content hash in the framework uses. A private
|
|
15
|
+
// copy here wrote `"key":null` for an unset optional field where core drops the key, so the schema
|
|
16
|
+
// hash agreed with nothing else (plan 101 slice 18 h; 22.0.0 re-stamps every sidecar once).
|
|
17
|
+
import { canonicalJson, ERROR_DOCS_URL } from '@ultimat3/core';
|
|
15
18
|
import { describeEntities } from '@ultimat3/entity';
|
|
16
19
|
import { countDeclaredEntities } from './app-entities';
|
|
17
20
|
import { loadApp } from './app-load';
|
|
@@ -23,23 +26,6 @@ import type { Finding } from './output';
|
|
|
23
26
|
export const DB_PACKAGE = join('packages', 'db');
|
|
24
27
|
const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
|
|
25
28
|
|
|
26
|
-
/**
|
|
27
|
-
* Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
|
|
28
|
-
* BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
|
|
29
|
-
* otherwise move every app's hash and report drift over a framework upgrade nobody made.
|
|
30
|
-
*/
|
|
31
|
-
function canonicalJson(value: unknown): string {
|
|
32
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
33
|
-
if (typeof value === 'object' && value !== null) {
|
|
34
|
-
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
35
|
-
a < b ? -1 : a > b ? 1 : 0,
|
|
36
|
-
);
|
|
37
|
-
return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
|
|
38
|
-
}
|
|
39
|
-
// `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
|
|
40
|
-
return JSON.stringify(value) ?? 'null';
|
|
41
|
-
}
|
|
42
|
-
|
|
43
29
|
/**
|
|
44
30
|
* What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
|
|
45
31
|
* see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
|
package/src/error-catalog.ts
CHANGED
|
@@ -46,22 +46,29 @@ export const CATALOG_PACKAGES = [
|
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* The
|
|
50
|
-
* JSX runtime an app has and a bare CLI process does not, so a hard dependency would
|
|
51
|
-
* uninstallable where the codes are merely absent today.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them. `admin` and `ui`
|
|
50
|
+
* reach for a JSX runtime an app has and a bare CLI process does not, so a hard dependency would
|
|
51
|
+
* make the CLI uninstallable where the codes are merely absent today. `scraping` is an app's own
|
|
52
|
+
* choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
|
|
53
|
+
* answer where the app installed it and are reported `unavailable` where it did not. Every other
|
|
54
|
+
* entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
|
|
55
|
+
* holds the list to exactly that, because an undeclared one resolves through workspace symlinks
|
|
56
|
+
* here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
|
|
57
|
+
* refuses a code the wiki promises.
|
|
55
58
|
*/
|
|
56
|
-
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
59
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
|
|
60
|
+
'@ultimat3/admin',
|
|
61
|
+
'@ultimat3/scraping',
|
|
62
|
+
'@ultimat3/ui',
|
|
63
|
+
];
|
|
57
64
|
|
|
58
65
|
export interface ErrorCatalog {
|
|
59
66
|
/** Packages whose codes are now registered. */
|
|
60
67
|
readonly loaded: readonly string[];
|
|
61
68
|
/**
|
|
62
69
|
* Packages this process could not *resolve*, so their codes are absent from the answer. The one
|
|
63
|
-
* tolerated case is the optional host
|
|
64
|
-
*
|
|
70
|
+
* tolerated case is the optional host (`CATALOG_OPTIONAL_HOSTS`), which a bare CLI process may
|
|
71
|
+
* not have, and a list silently missing their codes is
|
|
65
72
|
* worse than one that says which packages are missing. A package that resolved and then threw is
|
|
66
73
|
* a defect, not a host gap, and goes to `failed`.
|
|
67
74
|
*/
|
package/src/error-codes.ts
CHANGED
|
@@ -178,30 +178,26 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
178
178
|
'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
|
|
179
179
|
'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
|
|
180
180
|
'X_SHOT_ISLAND_MISSING',
|
|
181
|
-
// The
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
'X_E2E_EVALUATE_THREW',
|
|
188
|
-
'X_E2E_LOCATOR_EMPTY',
|
|
189
|
-
'X_E2E_LOCATOR_AMBIGUOUS',
|
|
190
|
-
'X_E2E_SERVICE_WORKER_ABSENT',
|
|
191
|
-
'X_E2E_APP_FAILED',
|
|
192
|
-
// The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
|
|
193
|
-
// `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
|
|
194
|
-
// install a browser, read the browser's own stderr, look at the page, raise a deadline.
|
|
195
|
-
'X_CDP_BROWSER_MISSING',
|
|
196
|
-
'X_CDP_LAUNCH_FAILED',
|
|
197
|
-
'X_CDP_CALL_FAILED',
|
|
198
|
-
'X_CDP_TIMEOUT',
|
|
181
|
+
// The raw-CDP shot page's own refusals (`cdp-shot-errors.ts` holds the classes): which host it
|
|
182
|
+
// may open, which element it was pointed at, and which key it was asked to press.
|
|
183
|
+
'X_SHOT_HOST_REFUSED',
|
|
184
|
+
'X_SHOT_ELEMENT_MISSING',
|
|
185
|
+
'X_SHOT_ELEMENT_UNREADY',
|
|
186
|
+
'X_SHOT_KEY_INVALID',
|
|
199
187
|
'X_GH_UNAVAILABLE',
|
|
200
188
|
'X_GH_NOT_AUTHENTICATED',
|
|
201
189
|
'X_GH_COMMAND_FAILED',
|
|
202
190
|
'X_GH_RESPONSE_INVALID',
|
|
203
191
|
'X_PR_NOT_FOUND',
|
|
204
192
|
'X_CI_RUN_NOT_FOUND',
|
|
193
|
+
'X_APP_NAME_EMPTY',
|
|
194
|
+
'X_APP_EMPTY',
|
|
195
|
+
'X_BOUNDARY_SURFACE_IMPORT',
|
|
196
|
+
'X_PERMISSION_UNGRANTED',
|
|
197
|
+
'X_JOB_UNREGISTERED',
|
|
198
|
+
'X_FEATURE_UNKNOWN',
|
|
199
|
+
'X_ROUTE_ASYNC_PAGE',
|
|
200
|
+
'X_BUDGET_PARAMS_UNDECLARED',
|
|
205
201
|
] as const;
|
|
206
202
|
|
|
207
203
|
/**
|
|
@@ -316,7 +312,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
316
312
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
317
313
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
318
314
|
X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
|
|
319
|
-
X_SHOT_BROWSER_MISSING: 'x shot
|
|
315
|
+
X_SHOT_BROWSER_MISSING: '`x shot` has no browser to launch',
|
|
320
316
|
X_UI_SHOT_ROUTE_UNKNOWN: 'a ui.* tool was asked for a path no route answers',
|
|
321
317
|
X_UI_SHOT_ROUTE_UNBUDGETED: 'a ui.* tool refused a route that declares no budget.js',
|
|
322
318
|
X_UI_INTERACT_STEPS_INVALID: 'a ui.interact step list is over its bounds or malformed',
|
|
@@ -330,23 +326,25 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
330
326
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
|
331
327
|
X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
|
|
332
328
|
X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
|
|
338
|
-
X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
|
|
339
|
-
X_E2E_APP_FAILED: 'the app an e2e run spawned did not come up',
|
|
340
|
-
X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
|
|
341
|
-
X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
|
|
342
|
-
X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
|
|
343
|
-
X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
|
|
329
|
+
X_SHOT_HOST_REFUSED: "x shot was asked to open a host outside the run's allow list",
|
|
330
|
+
X_SHOT_ELEMENT_MISSING: 'no element matched the selector before the deadline',
|
|
331
|
+
X_SHOT_ELEMENT_UNREADY: 'the element matched and never became ready to act on',
|
|
332
|
+
X_SHOT_KEY_INVALID: 'a key chord the browser cannot press',
|
|
344
333
|
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
345
334
|
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
346
335
|
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
|
347
336
|
X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
|
|
348
337
|
X_PR_NOT_FOUND: 'no pull request for this checkout',
|
|
349
338
|
X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
|
|
339
|
+
X_APP_NAME_EMPTY: 'the app name has no letters or digits',
|
|
340
|
+
X_APP_EMPTY: 'an app with an app.config.ts registered no primitive',
|
|
341
|
+
X_BOUNDARY_SURFACE_IMPORT: 'a surface imports one SURFACE_SPECS does not allow',
|
|
342
|
+
X_PERMISSION_UNGRANTED: 'an action, query or route requires a permission no role grants',
|
|
343
|
+
X_JOB_UNREGISTERED: 'a job reached the manifest under its positional anonymous name',
|
|
344
|
+
X_FEATURE_UNKNOWN: 'x g --feature names a slice that does not exist',
|
|
345
|
+
X_ROUTE_ASYNC_PAGE: 'a route exports an async Page',
|
|
346
|
+
X_BUDGET_PARAMS_UNDECLARED:
|
|
347
|
+
'a dynamic route with a budget declares no prerender() paths to weigh it by',
|
|
350
348
|
};
|
|
351
349
|
|
|
352
350
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/error-pages.ts
CHANGED
|
@@ -34,9 +34,8 @@ const isStatus = (status: number): boolean =>
|
|
|
34
34
|
/**
|
|
35
35
|
* The app's page for one status, or `undefined`.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* answering the framework's page until the server was restarted.
|
|
37
|
+
* One read. How often it is asked is `errorPageHook`'s decision: per request under `x dev`, for
|
|
38
|
+
* `favicon.ts`'s reason, and once per status in a container.
|
|
40
39
|
*/
|
|
41
40
|
export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
|
|
42
41
|
if (!isStatus(status)) return undefined;
|
|
@@ -48,11 +47,24 @@ export async function errorPageOverride(root: string, status: number): Promise<s
|
|
|
48
47
|
* `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
|
|
49
48
|
* container cannot answer a browser differently — the rule `assetRoutes` already holds for
|
|
50
49
|
* `/favicon.ico`.
|
|
50
|
+
*
|
|
51
|
+
* `perRequest` is `x dev`'s: an author drops a file into a running process. A container's image
|
|
52
|
+
* cannot change under it, so there each status is read ONCE and kept — per request, a burst of 500s
|
|
53
|
+
* during an outage paid a file read each, on the path that was already failing (plan 101, 12 g).
|
|
51
54
|
*/
|
|
52
|
-
export const errorPageHook =
|
|
53
|
-
(
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
export const errorPageHook = (root: string, options: { readonly perRequest: boolean }) => {
|
|
56
|
+
if (options.perRequest) {
|
|
57
|
+
return (status: number): Promise<string | undefined> => errorPageOverride(root, status);
|
|
58
|
+
}
|
|
59
|
+
const read = new Map<number, Promise<string | undefined>>();
|
|
60
|
+
return (status: number): Promise<string | undefined> => {
|
|
61
|
+
const known = read.get(status);
|
|
62
|
+
if (known !== undefined) return known;
|
|
63
|
+
const once = errorPageOverride(root, status);
|
|
64
|
+
read.set(status, once);
|
|
65
|
+
return once;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
56
68
|
|
|
57
69
|
/**
|
|
58
70
|
* The document that goes into a static export: the app's file if it has one, the framework's page
|
package/src/errors.ts
CHANGED
|
@@ -72,6 +72,20 @@ export class AppNameIsPathError extends UltimateError {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* `x new '!!!'`: a name that slugifies to nothing names no directory, and the scaffold landed in
|
|
77
|
+
* the cwd itself — where `--force` then committed every file the user already had there.
|
|
78
|
+
*/
|
|
79
|
+
export class AppNameEmptyError extends UltimateError {
|
|
80
|
+
constructor(input: { name: string; invocation: string; flags: readonly string[] }) {
|
|
81
|
+
super({
|
|
82
|
+
code: 'X_APP_NAME_EMPTY',
|
|
83
|
+
cause: `"${input.name}" has no letters or digits, so it names no directory`,
|
|
84
|
+
fix: [input.invocation, '<name-with-letters>', ...input.flags].join(' '),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
/**
|
|
76
90
|
* A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
|
|
77
91
|
*
|
package/src/favicon.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no path-join primitive, and `FAVICON_SOURCE` is app-root-relative, so resolving
|
|
7
7
|
// it against the root is string work no `Bun.file` overload does — the same necessity
|
|
8
|
-
// `
|
|
8
|
+
// `runtime-assets.ts` records for `ICON_SOURCE`.
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { createRaster, encodeImage } from '@ultimat3/core';
|
|
11
11
|
import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
|
|
@@ -69,7 +69,7 @@ const builtinBytes = (): Uint8Array => {
|
|
|
69
69
|
const iconResponse = (bytes: Uint8Array, contentType: string): Response =>
|
|
70
70
|
applyCacheHeaders(
|
|
71
71
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
72
|
-
// `SharedArrayBuffer`, which `Response` does not accept — `
|
|
72
|
+
// `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
|
|
73
73
|
new Response(new Uint8Array(bytes), { headers: { 'content-type': contentType } }),
|
|
74
74
|
FAVICON_CACHE,
|
|
75
75
|
);
|
package/src/fix-imports.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// `dirname`/`join` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
|
+
import { endOfLiteral, maskLiterals } from '@ultimat3/core';
|
|
7
8
|
import type { FixHelper } from './fix-scan';
|
|
8
9
|
import { scanFixHelpers } from './fix-scan';
|
|
9
|
-
import { endOfLiteral, maskLiterals } from './ts-scan';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* A named import, matched over the MASKED source and anchored at the start of a line, so an
|
package/src/fix-scan.ts
CHANGED
|
@@ -3,16 +3,9 @@
|
|
|
3
3
|
// class's constructor. Split out of `ts-scan.ts` when the third shape and cross-file resolution
|
|
4
4
|
// (`fix-imports.ts`) took the file past the 500-line ceiling; the masking primitives stay there.
|
|
5
5
|
|
|
6
|
+
import { endOfLiteral, maskLiterals, QUOTES } from '@ultimat3/core';
|
|
6
7
|
import type { FixSite } from './ts-scan';
|
|
7
|
-
import {
|
|
8
|
-
CLOSERS,
|
|
9
|
-
endOfLiteral,
|
|
10
|
-
lineIndex,
|
|
11
|
-
maskLiterals,
|
|
12
|
-
OPENERS,
|
|
13
|
-
QUOTES,
|
|
14
|
-
valueLiterals,
|
|
15
|
-
} from './ts-scan';
|
|
8
|
+
import { CLOSERS, lineIndex, OPENERS, valueLiterals } from './ts-scan';
|
|
16
9
|
|
|
17
10
|
/** The lookbehind rejects member access: `cond ? e.fix : ''` is a ternary, not a declaration. */
|
|
18
11
|
const FIX_KEY = /(?<![.\w$])fix\s*:\s*/g;
|
package/src/flag-reads.ts
CHANGED
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
|
|
11
11
|
import { join, relative } from 'node:path';
|
|
12
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
12
|
+
import { ERROR_DOCS_URL, stripComments } from '@ultimat3/core';
|
|
13
13
|
import type { Finding } from './output';
|
|
14
14
|
import type { CommandSpec, FlagSpec } from './parse';
|
|
15
15
|
import { GLOBAL_FLAGS } from './parse';
|
|
16
|
-
import { stripComments } from './ts-scan';
|
|
17
16
|
|
|
18
17
|
/** A flag as declared, with the command that declares it. */
|
|
19
18
|
export interface DeclaredFlag {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The one fence for foreign text an agent reads: a PR review body (`x pr review`) and a CI log
|
|
2
|
+
// (`x ci`) are both written by anyone who can comment or push, and rendered bare they arrive in the
|
|
3
|
+
// agent's context indistinguishable from this CLI's own output. A leaf, so both commands share it.
|
|
4
|
+
|
|
5
|
+
const BLOCK_OPEN = '<comment id=';
|
|
6
|
+
const BLOCK_CLOSE = '</comment>';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The fence, as a READER would parse it rather than as this file spells it.
|
|
10
|
+
*
|
|
11
|
+
* Two literal `replaceAll`s were the whole neutralisation, and markup is not spelled one way:
|
|
12
|
+
* `</comment >`, `</COMMENT>` and `< comment id=` all end or open a block for anything reading
|
|
13
|
+
* tags, and none of the three matched. One pattern over `<`, an optional `/`, and whitespace
|
|
14
|
+
* around a case-insensitive `comment` covers every spelling of the delimiter; the escape goes on
|
|
15
|
+
* the `<`, so what the reviewer wrote after it survives byte for byte.
|
|
16
|
+
*/
|
|
17
|
+
const BLOCK_DELIMITER = /<(\s*\/?\s*comment\b)/gi;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One comment body, fenced and labelled with the thread id it came from — `@ultimat3/ai`'s
|
|
21
|
+
* `documentBlock` (`rag.ts`), applied to the other place foreign text enters an agent's context.
|
|
22
|
+
* `x pr review` exists because an agent cannot read the GitHub web UI, and a review body is
|
|
23
|
+
* written by anyone who can comment on the pull request: rendered as bare indented text it arrived
|
|
24
|
+
* in that agent's context indistinguishable from the command's own output, which is prompt
|
|
25
|
+
* injection with a shell attached.
|
|
26
|
+
*
|
|
27
|
+
* The fence is neutralised INSIDE the payload rather than deleted, so every word the reviewer
|
|
28
|
+
* wrote still reads, and the label is stripped of the three characters that would end the
|
|
29
|
+
* attribute. Influence only, and deliberately not sold as more: a fence tells a reader this text
|
|
30
|
+
* is data, and it can never stop one that decides otherwise.
|
|
31
|
+
*/
|
|
32
|
+
export function commentBlock(id: string, lines: readonly string[]): readonly string[] {
|
|
33
|
+
const label = id.replaceAll('"', "'").replaceAll('>', ')').replaceAll('<', '(');
|
|
34
|
+
const body = lines.map((line) => line.replace(BLOCK_DELIMITER, '<\\$1'));
|
|
35
|
+
return [`${BLOCK_OPEN}"${label}">`, ...body, BLOCK_CLOSE];
|
|
36
|
+
}
|
package/src/framework-schema.ts
CHANGED
|
@@ -78,12 +78,11 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
|
|
|
78
78
|
/**
|
|
79
79
|
* The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* rows already follow.
|
|
81
|
+
* Until this row nothing applied them, in dev or in production: they are not `entity()`
|
|
82
|
+
* declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
|
|
83
|
+
* into a migration nobody wrote — nobody could hold a session in the reference app. Those
|
|
84
|
+
* exports are gone; `AUTH_TABLES` is the whole DDL, the 1.3 `x_users` upgrade included, applied
|
|
85
|
+
* here on exactly the rule the rate-limit and audit rows already follow.
|
|
87
86
|
*
|
|
88
87
|
* `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
|
|
89
88
|
* second copy is a second thing to keep right when a table is added.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `x g <kind> --feature <f>` writes INTO a slice, and a slice that does not exist is refused rather
|
|
2
|
+
// than invented. It was invented: `x g task nightly --feature reports` laid down an
|
|
3
|
+
// `entity('reports', { title, price })` nobody asked for, and the `drift` step then demanded a
|
|
4
|
+
// migration for the table. Only `x g entity` and `x g resource` create a feature's data.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs'; // why: a synchronous directory probe; Bun.file answers files, not directories.
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { Generator } from './generate-kinds';
|
|
9
|
+
import { containedPath } from './generate-write';
|
|
10
|
+
import { quoteArg } from './shell-quote';
|
|
11
|
+
|
|
12
|
+
/** The generators whose `--feature` names an existing slice to write into. */
|
|
13
|
+
const WRITES_INTO_SLICE: ReadonlySet<Generator> = new Set<Generator>([
|
|
14
|
+
'action',
|
|
15
|
+
'mutator',
|
|
16
|
+
'query',
|
|
17
|
+
'job',
|
|
18
|
+
'task',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** Declared beside its one thrower: `errors.ts` sits at its 500-line ceiling. */
|
|
22
|
+
export class FeatureUnknownError extends UltimateError {
|
|
23
|
+
constructor(input: { readonly feature: string; readonly slice: string }) {
|
|
24
|
+
super({
|
|
25
|
+
code: 'X_FEATURE_UNKNOWN',
|
|
26
|
+
cause: `--feature ${quoteArg(input.feature)} names ${input.slice}, which does not exist — a generator writing into a slice never invents one, or its entity table`,
|
|
27
|
+
fix: `x g resource ${quoteArg(input.feature)}`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Refuses a `--feature` whose slice directory is absent, for the kinds that write into one. */
|
|
33
|
+
export function assertFeatureExists(
|
|
34
|
+
root: string,
|
|
35
|
+
kind: Generator,
|
|
36
|
+
feature: string | undefined,
|
|
37
|
+
slice: string,
|
|
38
|
+
): void {
|
|
39
|
+
if (feature === undefined || !WRITES_INTO_SLICE.has(kind)) return;
|
|
40
|
+
if (existsSync(containedPath(root, slice))) return;
|
|
41
|
+
throw new FeatureUnknownError({ feature, slice });
|
|
42
|
+
}
|
package/src/generate-files.ts
CHANGED
|
@@ -56,8 +56,8 @@ export interface GenerateOptions {
|
|
|
56
56
|
*/
|
|
57
57
|
readonly sliceErrors?: string;
|
|
58
58
|
/**
|
|
59
|
-
* `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
60
|
-
* entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
59
|
+
* `action`, `mutator`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
|
|
60
|
+
* the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
61
61
|
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
62
62
|
* `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
|
|
63
63
|
* `sliceDir(surface, feature)/entity.ts`.
|
|
@@ -84,7 +84,9 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
84
84
|
const surface: Surface = options.surface ?? 'app';
|
|
85
85
|
assertSurfaceSupported(options.kind, surface, options.name);
|
|
86
86
|
const surfaceDir = DEFAULT_SURFACE_DIR[surface];
|
|
87
|
-
|
|
87
|
+
// Kebab, always: `x g entity BlogPost` wrote `app/BlogPost/` while `resource` wrote
|
|
88
|
+
// `app/blog-post/`, so one feature grew two slice directories depending on the generator.
|
|
89
|
+
const feature = kebab(options.feature ?? options.name);
|
|
88
90
|
const target = { surfaceDir, feature };
|
|
89
91
|
switch (options.kind) {
|
|
90
92
|
case 'resource':
|
|
@@ -101,6 +103,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
101
103
|
actionFiles(options.name, {
|
|
102
104
|
...target,
|
|
103
105
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
106
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
104
107
|
}),
|
|
105
108
|
);
|
|
106
109
|
case 'mutator':
|
|
@@ -109,6 +112,7 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
109
112
|
...target,
|
|
110
113
|
mutator: true,
|
|
111
114
|
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
115
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
112
116
|
}),
|
|
113
117
|
);
|
|
114
118
|
case 'backfill':
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `x g policy` / `x g resource` grant what they declare. A generated `policy.ts` declares
|
|
2
|
+
// `<feature>:read` and `<feature>:write`; nothing granted them, so every generated endpoint
|
|
3
|
+
// answered 403 to every actor a role mints. The edit lands in the scaffold's one role map,
|
|
4
|
+
// `apps/web/shared/roles.ts`: `:read` to `member`, `:write` to `admin` (which inherits member).
|
|
5
|
+
|
|
6
|
+
import { containedPath } from './generate-write';
|
|
7
|
+
import { ROLES_FILE } from './permission-grants';
|
|
8
|
+
import { wrapList } from './templates/wrap';
|
|
9
|
+
|
|
10
|
+
/** One permission to add to one role's `grants`. */
|
|
11
|
+
export interface RoleGrant {
|
|
12
|
+
readonly role: string;
|
|
13
|
+
readonly permission: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A generated policy file: `<surface>/<feature>/policy.ts`, never a test beside it. */
|
|
17
|
+
const POLICY_PATH = /^apps\/[^/]+\/[^/]+\/(?<feature>[a-z0-9-]+)\/policy\.ts$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The grants a written file set implies: the lowest role that makes sense for each verb. `member`
|
|
21
|
+
* reads, `admin` writes — the scaffold's dev actor is `admin`, so both reach it through
|
|
22
|
+
* `inherits`, and a `member` cookie still cannot write.
|
|
23
|
+
*/
|
|
24
|
+
export function grantsForWritten(written: readonly string[]): readonly RoleGrant[] {
|
|
25
|
+
return written.flatMap((path) => {
|
|
26
|
+
const feature = POLICY_PATH.exec(path)?.groups?.['feature'];
|
|
27
|
+
return feature === undefined
|
|
28
|
+
? []
|
|
29
|
+
: [
|
|
30
|
+
{ role: 'member', permission: `${feature}:read` },
|
|
31
|
+
{ role: 'admin', permission: `${feature}:write` },
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `source` with each grant added to its role's `grants: [...]`, re-wrapped the way Biome prints
|
|
40
|
+
* it. A role or a `grants` array the scaffold's shape does not have is left alone and returned in
|
|
41
|
+
* `skipped` — the `policy` step's X_PERMISSION_UNGRANTED names the edit, so a guess here would be
|
|
42
|
+
* a second, worse answer.
|
|
43
|
+
*/
|
|
44
|
+
export function insertGrants(
|
|
45
|
+
source: string,
|
|
46
|
+
grants: readonly RoleGrant[],
|
|
47
|
+
): { readonly source: string; readonly skipped: readonly RoleGrant[] } {
|
|
48
|
+
let next = source;
|
|
49
|
+
const skipped: RoleGrant[] = [];
|
|
50
|
+
for (const grant of grants) {
|
|
51
|
+
const role = new RegExp(`\\n ${escapeRegExp(grant.role)}: \\{`).exec(next);
|
|
52
|
+
const open = role === null ? -1 : next.indexOf('grants: [', role.index);
|
|
53
|
+
const close = open === -1 ? -1 : next.indexOf(']', open);
|
|
54
|
+
const blockEnd = role === null ? -1 : next.indexOf('\n },', role.index);
|
|
55
|
+
if (role === null || open === -1 || close === -1 || (blockEnd !== -1 && open > blockEnd)) {
|
|
56
|
+
skipped.push(grant);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const current = [...next.slice(open, close).matchAll(/'([^']+)'/g)].map((m) => m[1] ?? '');
|
|
60
|
+
if (current.includes(grant.permission)) continue;
|
|
61
|
+
const entries = [...current, grant.permission].map((permission) => `'${permission}'`);
|
|
62
|
+
const lineStart = next.lastIndexOf('\n', open) + 1;
|
|
63
|
+
const indent = next.slice(lineStart, open);
|
|
64
|
+
const rewritten = wrapList(indent, 'grants: [', entries, ']').slice(indent.length);
|
|
65
|
+
next = `${next.slice(0, open)}${rewritten}${next.slice(close + 1)}`;
|
|
66
|
+
}
|
|
67
|
+
return { source: next, skipped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Performs `insertGrants` on the app's role map. Answers the paths it rewrote. */
|
|
71
|
+
export async function grantGeneratedPermissions(
|
|
72
|
+
root: string,
|
|
73
|
+
written: readonly string[],
|
|
74
|
+
): Promise<readonly string[]> {
|
|
75
|
+
const grants = grantsForWritten(written);
|
|
76
|
+
const file = containedPath(root, ROLES_FILE);
|
|
77
|
+
if (grants.length === 0 || !(await Bun.file(file).exists())) return [];
|
|
78
|
+
const before = await Bun.file(file).text();
|
|
79
|
+
const { source } = insertGrants(before, grants);
|
|
80
|
+
if (source === before) return [];
|
|
81
|
+
await Bun.write(file, source);
|
|
82
|
+
return [ROLES_FILE];
|
|
83
|
+
}
|
package/src/generate-kinds.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
UnknownCommandError,
|
|
11
11
|
} from './errors';
|
|
12
12
|
import type { Surface } from './templates';
|
|
13
|
+
import { camel, kebab } from './templates/naming';
|
|
13
14
|
|
|
14
15
|
export const GENERATORS = [
|
|
15
16
|
'resource',
|
|
@@ -106,11 +107,62 @@ export function readSurface(raw: string | undefined, kind: Generator, name: stri
|
|
|
106
107
|
* is a command that runs.
|
|
107
108
|
*/
|
|
108
109
|
export function readName(raw: string | undefined, kind: Generator): string {
|
|
109
|
-
if (raw
|
|
110
|
-
|
|
110
|
+
if (raw === undefined) {
|
|
111
|
+
throw new MissingPositionalError({
|
|
112
|
+
command: `g ${kind}`,
|
|
113
|
+
positional: 'name',
|
|
114
|
+
example: exampleFor(kind),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
refusePath(raw, 'name', kind);
|
|
118
|
+
refuseBadIdentifier(raw, kind);
|
|
119
|
+
return raw;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `--feature`, refused where it is a path: it is a DIRECTORY under the surface, one segment. */
|
|
123
|
+
export function readFeature(raw: string | undefined, kind: Generator): string | undefined {
|
|
124
|
+
if (raw !== undefined) refusePath(raw, 'feature', kind);
|
|
125
|
+
return raw;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A name is one directory segment: `x g action ../../../evil` wrote outside `apps/`. */
|
|
129
|
+
function refusePath(raw: string, flag: string, kind: Generator): void {
|
|
130
|
+
if (!/[\\/]/.test(raw) && raw.trim() !== '..' && raw.trim() !== '.') return;
|
|
131
|
+
throw new BadFlagError({
|
|
132
|
+
flag,
|
|
133
|
+
command: `g ${kind}`,
|
|
134
|
+
reason: `"${raw}" is a path, and a generator ${flag} is one directory segment under the surface`,
|
|
135
|
+
fix: exampleFor(kind),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Strict-mode reserved words: `export const delete = …` does not parse. */
|
|
140
|
+
const RESERVED = new Set(
|
|
141
|
+
(
|
|
142
|
+
'break case catch class const continue debugger default delete do else enum export extends ' +
|
|
143
|
+
'false finally for function if import in instanceof new null return super switch this throw ' +
|
|
144
|
+
'true try typeof var void while with yield let static implements interface package private ' +
|
|
145
|
+
'protected public await arguments eval'
|
|
146
|
+
).split(' '),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The name becomes `camel(name)` in emitted source, so it has to be an identifier there: not a
|
|
151
|
+
* reserved word, not starting with a digit. The fix is the same run with a name that is one — the
|
|
152
|
+
* kind appended to a reserved word, prepended to a leading digit.
|
|
153
|
+
*/
|
|
154
|
+
function refuseBadIdentifier(raw: string, kind: Generator): void {
|
|
155
|
+
const identifier = camel(raw);
|
|
156
|
+
const digit = /^[0-9]/.test(identifier);
|
|
157
|
+
if (identifier !== '' && !digit && !RESERVED.has(identifier)) return;
|
|
158
|
+
const noun = kind.split(':').at(-1) ?? kind;
|
|
159
|
+
const suggestion = digit ? `${noun}-${kebab(raw)}` : `${kebab(raw)}-${noun}`;
|
|
160
|
+
throw new BadFlagError({
|
|
161
|
+
flag: 'name',
|
|
111
162
|
command: `g ${kind}`,
|
|
112
|
-
|
|
113
|
-
|
|
163
|
+
reason: `"${raw}" becomes the identifier "${identifier}" in generated source, which ${digit ? 'starts with a digit' : identifier === '' ? 'is empty' : 'is a reserved word'}`,
|
|
164
|
+
// Joined, never spliced: the suggestion is kebab output, and the rule reads the shape.
|
|
165
|
+
fix: ['x g', kind, suggestion].join(' '),
|
|
114
166
|
});
|
|
115
167
|
}
|
|
116
168
|
|