@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
package/src/api-registration.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Every generator hands the primitives it wrote to `apps/web/api/index.ts`. The scan registers
|
|
2
|
+
// actions and queries by export name on its own and registers NO job: a job module nothing lists
|
|
3
|
+
// keeps the positional `anonymous-job-2` that `job()` minted, on the queue row, in
|
|
4
4
|
// `x.manifest.json` and in every dead-letter trace — under a green gate (plan 101 slice 11 b).
|
|
5
|
+
// Actions and queries are listed for the TYPE: `Api` is what the browser's client is shaped from.
|
|
5
6
|
|
|
7
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
8
|
import { API_INDEX } from './app-root';
|
|
7
9
|
import { containedPath } from './generate-write';
|
|
10
|
+
import type { Finding } from './output';
|
|
8
11
|
import { camel } from './templates/naming';
|
|
9
12
|
import { wrapList } from './templates/wrap';
|
|
10
13
|
|
|
11
14
|
/** One module to import as a namespace and list under a `defineApi` key. */
|
|
12
15
|
export interface ApiEntry {
|
|
13
|
-
readonly key: 'jobs' | 'tasks';
|
|
16
|
+
readonly key: 'actions' | 'queries' | 'jobs' | 'tasks';
|
|
14
17
|
/** The namespace binding: the file's own name, camelCased — `reindexPost`. */
|
|
15
18
|
readonly binding: string;
|
|
16
19
|
/** From `apps/web/api/`: `../app/post/jobs/reindex-post`. */
|
|
@@ -18,17 +21,37 @@ export interface ApiEntry {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const PRIMITIVE_PATH =
|
|
21
|
-
/^apps\/web\/(?<rest>[^/]+\/[^/]+\/(?<dir>jobs|tasks)\/(?<file>[a-z0-9-]+))\.ts$/;
|
|
24
|
+
/^apps\/web\/(?<rest>[^/]+\/[^/]+\/(?<dir>actions|queries|live|jobs|tasks)\/(?<file>[a-z0-9-]+))\.ts$/;
|
|
22
25
|
|
|
23
|
-
/** The
|
|
26
|
+
/** The order `x new` writes the lists in, which a list this adds keeps. */
|
|
27
|
+
const LIST_ORDER: readonly ApiEntry['key'][] = ['actions', 'queries', 'jobs', 'tasks'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The `defineApi` list each generated directory belongs in. `actions/` holds mutators too, and a
|
|
31
|
+
* mutator IS an action — `defineApi` merges both lists into one registration — so one key serves.
|
|
32
|
+
*/
|
|
33
|
+
const KEY_OF: Readonly<Record<string, ApiEntry['key']>> = {
|
|
34
|
+
actions: 'actions',
|
|
35
|
+
queries: 'queries',
|
|
36
|
+
live: 'queries',
|
|
37
|
+
jobs: 'jobs',
|
|
38
|
+
tasks: 'tasks',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The primitive modules among the paths a generator wrote. Tests are not modules to list.
|
|
43
|
+
*
|
|
44
|
+
* Actions and queries too, though the scan registers those at boot without being told: `Api` —
|
|
45
|
+
* the type the browser's typed client is shaped from — is `typeof defineApi({ … })`, so one the
|
|
46
|
+
* index does not list is an endpoint the page has no typed call for.
|
|
47
|
+
*/
|
|
24
48
|
export function apiEntriesFor(written: readonly string[]): readonly ApiEntry[] {
|
|
25
49
|
return written.flatMap((path) => {
|
|
26
50
|
const groups = PRIMITIVE_PATH.exec(path)?.groups;
|
|
27
51
|
if (groups === undefined) return [];
|
|
28
52
|
const { rest = '', dir, file = '' } = groups;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
];
|
|
53
|
+
const key = dir !== undefined && Object.hasOwn(KEY_OF, dir) ? KEY_OF[dir] : undefined;
|
|
54
|
+
return key === undefined ? [] : [{ key, binding: camel(file), specifier: `../${rest}` }];
|
|
32
55
|
});
|
|
33
56
|
}
|
|
34
57
|
|
|
@@ -87,8 +110,12 @@ export function insertApiEntries(
|
|
|
87
110
|
const list = listOf(next, entry.key, call);
|
|
88
111
|
if (list?.items.includes(entry.binding) === true) continue;
|
|
89
112
|
if (list === undefined) {
|
|
90
|
-
//
|
|
91
|
-
|
|
113
|
+
// In the order `x new` writes — actions, queries, jobs, tasks: after the nearest list
|
|
114
|
+
// before it that the call already holds, which is `actions` at the least.
|
|
115
|
+
const earlier = LIST_ORDER.slice(0, LIST_ORDER.indexOf(entry.key))
|
|
116
|
+
.map((key) => listOf(next, key, call))
|
|
117
|
+
.findLast((found) => found !== undefined);
|
|
118
|
+
const after = next.indexOf('\n', (earlier ?? actions).end);
|
|
92
119
|
const line = ` ${entry.key}: [${entry.binding}],`;
|
|
93
120
|
next = `${next.slice(0, after + 1)}${line}\n${next.slice(after + 1)}`;
|
|
94
121
|
} else {
|
|
@@ -118,6 +145,93 @@ function withImport(source: string, importLine: string): string {
|
|
|
118
145
|
return lines.join('\n');
|
|
119
146
|
}
|
|
120
147
|
|
|
148
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
149
|
+
|
|
150
|
+
/** Every binding an import clause declares: `* as x`, `x`, `{ a, b as c, type d }`. */
|
|
151
|
+
const clauseBindings = (clause: string): readonly string[] =>
|
|
152
|
+
clause
|
|
153
|
+
.replace(/^type\s+/, '')
|
|
154
|
+
.replace(/[{}]/g, ',')
|
|
155
|
+
.split(',')
|
|
156
|
+
.map((part) =>
|
|
157
|
+
part
|
|
158
|
+
.trim()
|
|
159
|
+
.replace(/^type\s+/, '')
|
|
160
|
+
.replace(/^\*\s+as\s+/, ''),
|
|
161
|
+
)
|
|
162
|
+
.map(
|
|
163
|
+
(part) =>
|
|
164
|
+
part
|
|
165
|
+
.split(/\s+as\s+/)
|
|
166
|
+
.at(-1)
|
|
167
|
+
?.trim() ?? '',
|
|
168
|
+
)
|
|
169
|
+
.filter((name) => IDENTIFIER.test(name));
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Every top-level name the index declares, and what holds it: a namespace import by its
|
|
173
|
+
* specifier, anything else (`defineApi`, `api`, `Api`, a named import) by its own name. A second
|
|
174
|
+
* binding of any of them is a module that does not load — `import * as api` beside
|
|
175
|
+
* `export const api` is a ReferenceError in every app-loading command.
|
|
176
|
+
*/
|
|
177
|
+
function declaredBindings(source: string): ReadonlyMap<string, string> {
|
|
178
|
+
const held = new Map<string, string>();
|
|
179
|
+
for (const found of source.matchAll(/^import\s+([^;]*?)\s+from\s+'([^']+)';/gm)) {
|
|
180
|
+
const [, clause = '', specifier = ''] = found;
|
|
181
|
+
const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(clause.trim())?.[1];
|
|
182
|
+
if (namespace !== undefined) held.set(namespace, specifier);
|
|
183
|
+
else for (const name of clauseBindings(clause)) held.set(name, `an import from ${specifier}`);
|
|
184
|
+
}
|
|
185
|
+
const declaration =
|
|
186
|
+
/^(?:export\s+)?(?:declare\s+)?(?:const|let|var|function\*?|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)/gm;
|
|
187
|
+
for (const found of source.matchAll(declaration)) {
|
|
188
|
+
const [, name = ''] = found;
|
|
189
|
+
if (!held.has(name)) held.set(name, `the index's own ${name}`);
|
|
190
|
+
}
|
|
191
|
+
return held;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A written primitive whose namespace binding the index already holds — for another module, or as
|
|
196
|
+
* one of its own names — is a finding, decided BEFORE anything is written. Skipped silently, the
|
|
197
|
+
* job stayed unregistered under a green run; inserted, the index stopped loading.
|
|
198
|
+
*/
|
|
199
|
+
export function apiBindingFindings(
|
|
200
|
+
source: string,
|
|
201
|
+
written: readonly string[],
|
|
202
|
+
fix: string,
|
|
203
|
+
): readonly Finding[] {
|
|
204
|
+
const held = new Map(declaredBindings(source));
|
|
205
|
+
const findings: Finding[] = [];
|
|
206
|
+
for (const entry of apiEntriesFor(written)) {
|
|
207
|
+
const holder = held.get(entry.binding);
|
|
208
|
+
if (holder === entry.specifier) continue;
|
|
209
|
+
if (holder === undefined) {
|
|
210
|
+
held.set(entry.binding, entry.specifier);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
findings.push({
|
|
214
|
+
code: 'X_GENERATE_CONFLICT',
|
|
215
|
+
cause: `${API_INDEX} binds every listed module under its file name, and "${entry.binding}" is already ${holder.startsWith('.') ? `the namespace of ${holder}` : holder} — ${entry.specifier} cannot be listed under it`,
|
|
216
|
+
fix,
|
|
217
|
+
docs: ERROR_DOCS_URL,
|
|
218
|
+
at: API_INDEX,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return findings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `apiBindingFindings` against the app's own index; none when the app has no index. */
|
|
225
|
+
export async function indexBindingFindings(
|
|
226
|
+
root: string,
|
|
227
|
+
written: readonly string[],
|
|
228
|
+
fix: string,
|
|
229
|
+
): Promise<readonly Finding[]> {
|
|
230
|
+
const file = containedPath(root, API_INDEX);
|
|
231
|
+
if (!(await Bun.file(file).exists())) return [];
|
|
232
|
+
return apiBindingFindings(await Bun.file(file).text(), written, fix);
|
|
233
|
+
}
|
|
234
|
+
|
|
121
235
|
/** Performs `insertApiEntries` on the app's index. Answers the paths it rewrote. */
|
|
122
236
|
export async function registerGeneratedPrimitives(
|
|
123
237
|
root: string,
|
package/src/app-artifacts.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The two generated contracts an app commits — `x.manifest.json` and `openapi.json` — written by one
|
|
2
2
|
// function, because two commands wrote them and only one wrote both: `x g` refreshed the manifest
|
|
3
|
-
// and left `openapi.json` behind, so its own output failed the `
|
|
3
|
+
// and left `openapi.json` behind, so its own output failed the gate's `manifest` step next.
|
|
4
4
|
|
|
5
5
|
import { join } from 'node:path'; // why: Bun ships no path-join primitive.
|
|
6
6
|
import type { Manifest } from '@ultimat3/manifest';
|
package/src/app-auth.ts
CHANGED
|
@@ -1,32 +1,9 @@
|
|
|
1
1
|
// The one fact `x dev` and `serve.ts` need out of `app.config.ts` before a listener binds: where
|
|
2
|
-
// this app's sign-in page is
|
|
3
|
-
// for the same reason — a regex over the app's source is the pattern `app-load.ts` refuses.
|
|
2
|
+
// this app's sign-in page is, off the one loader (`app-config-load.ts`).
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
4
|
+
import { loadAppConfig } from './app-config-load';
|
|
8
5
|
|
|
9
|
-
/**
|
|
10
|
-
export const APP_CONFIG_EXPORT = 'config';
|
|
11
|
-
|
|
12
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
13
|
-
typeof value === 'object' && value !== null;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* `auth.signInPath`, or `null` when the app declares none.
|
|
17
|
-
*
|
|
18
|
-
* Structural, not `instanceof`: `defineConfig` returns a plain object, and a config that resolved
|
|
19
|
-
* through an older version of core simply has no `auth` section. `null` is the safe answer either
|
|
20
|
-
* way — it is what turns the browser redirect off and leaves the problem document in place.
|
|
21
|
-
*/
|
|
6
|
+
/** `auth.signInPath`, or `null` when the app declares none or the root has no config file. */
|
|
22
7
|
export async function loadSignInPath(root: string): Promise<string | null> {
|
|
23
|
-
|
|
24
|
-
if (!existsSync(configPath)) return null;
|
|
25
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
26
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
27
|
-
if (!isRecord(config)) return null;
|
|
28
|
-
const auth = config['auth'];
|
|
29
|
-
if (!isRecord(auth)) return null;
|
|
30
|
-
const path = auth['signInPath'];
|
|
31
|
-
return typeof path === 'string' && path.startsWith('/') ? path : null;
|
|
8
|
+
return (await loadAppConfig(root))?.auth.signInPath ?? null;
|
|
32
9
|
}
|
package/src/app-boundaries.ts
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
// Runtime imports only: `Bun.Transpiler.scanImports` sees what survives type erasure, which is
|
|
10
10
|
// exactly the distinction the `app/ -> api/` rule needs (`import type` is allowed, a value
|
|
11
11
|
// import is not).
|
|
12
|
+
//
|
|
13
|
+
// The browser-transport rule rides the same read: `boundary-findings.ts`'s `appBoundaryFindings`
|
|
14
|
+
// hands the files read here to `app-transport.ts`, so a raw `fetch(` in an island is a finding of
|
|
15
|
+
// this step with no second walk of the app.
|
|
12
16
|
|
|
13
17
|
// Bun ships no `Bun.*` path API: `joinPath` reaches a file on disk with the host's separator.
|
|
14
18
|
import { join as joinPath } from 'node:path';
|
|
@@ -17,6 +21,7 @@ import { dirname, join, normalize, relative } from 'node:path/posix';
|
|
|
17
21
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
18
22
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
19
23
|
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
24
|
+
import { commentSafe } from './comment-safe';
|
|
20
25
|
import { scanRuntimeImports, stripShebang } from './import-scan';
|
|
21
26
|
import type { Finding } from './output';
|
|
22
27
|
import { hasPathSegment } from './path-segments';
|
|
@@ -156,20 +161,6 @@ function subjectOf(path: string): string | undefined {
|
|
|
156
161
|
return parent === undefined || SURFACE_NAMES.has(parent) ? undefined : parent;
|
|
157
162
|
}
|
|
158
163
|
|
|
159
|
-
/**
|
|
160
|
-
* Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
|
|
161
|
-
* and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
|
|
162
|
-
* after it is a second command in a line whose whole purpose is to be pasted into a shell.
|
|
163
|
-
* Escaped rather than deleted, because the comment still has to name the file the reader owns.
|
|
164
|
-
*/
|
|
165
|
-
const commentSafe = (path: string): string =>
|
|
166
|
-
[...path]
|
|
167
|
-
.map((char) => {
|
|
168
|
-
const code = char.codePointAt(0) ?? 0;
|
|
169
|
-
return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
|
|
170
|
-
})
|
|
171
|
-
.join('');
|
|
172
|
-
|
|
173
164
|
/**
|
|
174
165
|
* `x g query posts` where the path names a resource, `x g query <name>` where it names a surface.
|
|
175
166
|
* The placeholder form is the shape `MissingPositionalError` already hands out (`x g route
|
|
@@ -233,7 +224,7 @@ const APP_GLOBS = ['apps/*/{site,app,api,shared}/**/*.{ts,tsx}'];
|
|
|
233
224
|
/**
|
|
234
225
|
* Read every source file under an app's site, app, api and shared surfaces (`APP_GLOBS`). The
|
|
235
226
|
* only I/O in this module, and the one definition of "which files are the app's sources" —
|
|
236
|
-
* `
|
|
227
|
+
* `appBoundaryFindings` reads through this, and so does `x fix boundary`, never a second glob.
|
|
237
228
|
*/
|
|
238
229
|
export async function readAppSources(root: string): Promise<readonly SourceFile[]> {
|
|
239
230
|
const files: SourceFile[] = [];
|
|
@@ -252,8 +243,3 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
|
|
|
252
243
|
export function appImportGraph(files: readonly SourceFile[]): ImportGraph {
|
|
253
244
|
return graphOf(scan(files));
|
|
254
245
|
}
|
|
255
|
-
|
|
256
|
-
/** Read an app's sources and check them. */
|
|
257
|
-
export async function checkAppBoundaries(root: string): Promise<readonly Finding[]> {
|
|
258
|
-
return checkImportRules(await readAppSources(root));
|
|
259
|
-
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The one reader of an app's `app.config.ts`: imported here and nowhere else in the tree
|
|
2
|
+
// (`bun run boundaries`, `X_CONFIG_IMPORT_OUTSIDE_LOADER`), its `config` export handed back through
|
|
3
|
+
// core's own `defineConfig` — so every boot fact the CLI reads is the validated, default-merged
|
|
4
|
+
// `AppConfig`, never a structural walk with a fallback of its own.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { AppConfig, AppConfigInput } from '@ultimat3/core';
|
|
9
|
+
import { ConfigInvalidError, defineConfig, describeValue } from '@ultimat3/core';
|
|
10
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
|
|
12
|
+
/** The export every app declares. Named, never default — the CLI and the runtime both import it. */
|
|
13
|
+
export const APP_CONFIG_EXPORT = 'config';
|
|
14
|
+
|
|
15
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One named export of `root`'s `app.config.ts`, or `undefined` when the root has no such file or
|
|
20
|
+
* the module does not export `name`. A module that will not import throws as it did — the callers
|
|
21
|
+
* that report rather than crash (`x doctor`'s env check) turn that into a finding.
|
|
22
|
+
*/
|
|
23
|
+
export async function appConfigExport(root: string, name: string): Promise<unknown> {
|
|
24
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
25
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
26
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
27
|
+
return Object.hasOwn(module, name) ? module[name] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `root`'s `AppConfig`, or `undefined` when the root has no `app.config.ts` — not an app, and each
|
|
32
|
+
* caller's "nothing declared" answer is its own. A file that IS there is held to `defineConfig`'s
|
|
33
|
+
* one validator: a hand-built `config` object, or one resolved through an older core, reaches the
|
|
34
|
+
* boot validated and defaulted exactly as one built by `defineConfig` does, which re-merges to
|
|
35
|
+
* itself. A file with no `config` object is refused rather than read as "every default".
|
|
36
|
+
*/
|
|
37
|
+
export async function loadAppConfig(root: string): Promise<AppConfig | undefined> {
|
|
38
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
39
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
40
|
+
const exported = await appConfigExport(root, APP_CONFIG_EXPORT);
|
|
41
|
+
if (!isRecord(exported)) {
|
|
42
|
+
throw new ConfigInvalidError({
|
|
43
|
+
cause: `${path} exports no ${APP_CONFIG_EXPORT} object (${describeValue(exported)})`,
|
|
44
|
+
fix: "export const config = defineConfig({ name: 'my-app' });",
|
|
45
|
+
meta: { key: APP_CONFIG_EXPORT },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// A cast over a record whose structure `defineConfig` screens first (`shapeIssues`, per layer):
|
|
49
|
+
// the validator IS the parse, and a second hand-written schema here would be a second answer.
|
|
50
|
+
return defineConfig(exported as unknown as AppConfigInput);
|
|
51
|
+
}
|
package/src/app-env.ts
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
|
|
3
3
|
// declaration, both files (axiom 2) — nothing here holds a second list of variable names.
|
|
4
4
|
|
|
5
|
-
// why: Bun ships no synchronous exists; `existsSync` answers whether this root is an app.
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
5
|
// why: Bun exposes no path API — the two files this module reads are joined to the app root.
|
|
8
6
|
import { join } from 'node:path';
|
|
9
7
|
import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
|
|
@@ -13,6 +11,7 @@ import {
|
|
|
13
11
|
ERROR_DOCS_URL,
|
|
14
12
|
renderEnvExample,
|
|
15
13
|
} from '@ultimat3/core';
|
|
14
|
+
import { appConfigExport } from './app-config-load';
|
|
16
15
|
import { APP_CONFIG_FILE } from './app-root';
|
|
17
16
|
import type { Finding } from './output';
|
|
18
17
|
import { findingFrom } from './output';
|
|
@@ -45,17 +44,12 @@ export const isEnvSchema = (value: unknown): value is EnvSchema =>
|
|
|
45
44
|
isRecord(value) && Object.values(value).every(isDecl);
|
|
46
45
|
|
|
47
46
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* that will not import throws, and the caller turns that into the finding.
|
|
47
|
+
* The declaration `app.config.ts` exports, through the one loader (`app-config-load.ts`).
|
|
48
|
+
* `undefined` means "this root declares no environment"; a config that will not import throws, and
|
|
49
|
+
* the caller turns that into the finding.
|
|
52
50
|
*/
|
|
53
51
|
export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined> {
|
|
54
|
-
const
|
|
55
|
-
if (!existsSync(configPath)) return undefined;
|
|
56
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
57
|
-
const declared = module[ENV_SCHEMA_EXPORT];
|
|
58
|
-
if (declared === undefined) return undefined;
|
|
52
|
+
const declared = await appConfigExport(root, ENV_SCHEMA_EXPORT);
|
|
59
53
|
return isEnvSchema(declared) ? declared : undefined;
|
|
60
54
|
}
|
|
61
55
|
|
package/src/app-load.ts
CHANGED
|
@@ -151,7 +151,31 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
|
151
151
|
return found.includes(index) ? [index, ...found.filter((path) => path !== index)] : found;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/** What one scan of the app's modules imported, and what would not. */
|
|
155
|
+
export interface AppScan {
|
|
156
|
+
/** App-root-relative POSIX paths of every module that imported, sorted. */
|
|
157
|
+
readonly files: readonly string[];
|
|
158
|
+
/** Modules that would not import, and primitives that would not register. */
|
|
159
|
+
readonly findings: readonly Finding[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ScanOptions {
|
|
163
|
+
/**
|
|
164
|
+
* Records each module's bytes and pins so a LATER scan can evict what changed: a tool's and
|
|
165
|
+
* `x dev`'s. A container scans once per process, so its boot passes `false` and neither reads a
|
|
166
|
+
* module before importing it nor hashes one (`role-load.ts`).
|
|
167
|
+
*/
|
|
168
|
+
readonly track: boolean;
|
|
169
|
+
/** `false` leaves a module out: neither read nor imported. Absent: every module is imported. */
|
|
170
|
+
readonly include?: (absolute: string) => boolean | Promise<boolean>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The scan itself: every app module `options.include` admits, imported in `appModulePaths` order
|
|
175
|
+
* and registered. `loadApp` is this plus the facts a tool reads; a container role calls it through
|
|
176
|
+
* `loadAppForRole`, which decides what the role imports.
|
|
177
|
+
*/
|
|
178
|
+
export async function scanAppModules(root: string, options: ScanOptions): Promise<AppScan> {
|
|
155
179
|
// Before any import: a stylesheet's surface is read below the app root, never off its absolute
|
|
156
180
|
// path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
|
|
157
181
|
// sheet, the site's included, classified as app CSS.
|
|
@@ -164,7 +188,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
164
188
|
const findings: Finding[] = [];
|
|
165
189
|
// A rescan: whatever changed on disk since the last one, and everything importing it, leaves
|
|
166
190
|
// Bun's module registry before the loop below imports it again.
|
|
167
|
-
if (registered.size > 0) await evictChanged(root);
|
|
191
|
+
if (options.track && registered.size > 0) await evictChanged(root);
|
|
168
192
|
|
|
169
193
|
for (const absolute of await appModulePaths(root)) {
|
|
170
194
|
// A SEGMENT, never a substring: an app checked out under
|
|
@@ -177,15 +201,19 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
177
201
|
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
178
202
|
continue;
|
|
179
203
|
}
|
|
204
|
+
if (options.include !== undefined && !(await options.include(absolute))) continue;
|
|
180
205
|
// The source is read BEFORE the import, on the file's first pass and for every route module —
|
|
181
206
|
// a rescan of any other registered module reads nothing here. What is recorded is bound to the
|
|
182
207
|
// bytes the module was evaluated from, and a read AFTER the import cannot know which bytes
|
|
183
208
|
// those were: a save landing between the two bound V1's component to V2's hash, so the next
|
|
184
209
|
// scan saw nothing to do and served V1 until the save after. Read first, the worst case is one
|
|
185
|
-
// re-import the next tick, of a file that did change.
|
|
210
|
+
// re-import the next tick, of a file that did change. An untracked scan reads nothing here:
|
|
211
|
+
// no save lands in an image, so a route module's text is read after its import, once.
|
|
186
212
|
const first = !registered.has(absolute) && !failures.has(absolute);
|
|
187
213
|
const snapshot =
|
|
188
|
-
first || routeModules.has(absolute)
|
|
214
|
+
options.track && (first || routeModules.has(absolute))
|
|
215
|
+
? await Bun.file(absolute).text()
|
|
216
|
+
: undefined;
|
|
189
217
|
if (first && snapshot !== undefined) await trackModule(absolute, snapshot, root);
|
|
190
218
|
let module: Record<string, unknown>;
|
|
191
219
|
try {
|
|
@@ -195,16 +223,26 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
195
223
|
continue;
|
|
196
224
|
}
|
|
197
225
|
files.push(file);
|
|
198
|
-
const finding = await register(absolute, file, module, snapshot);
|
|
226
|
+
const finding = await register(absolute, file, module, snapshot, options.track);
|
|
199
227
|
if (finding !== undefined) findings.push(finding);
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
files.sort();
|
|
231
|
+
return { files, findings };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
235
|
+
const scan = await scanAppModules(root, { track: true });
|
|
236
|
+
// After the scan, because the scan is what mounts an admin: a declaration the globs above cannot
|
|
237
|
+
// reach is a finding here, owned by `manifest` like every other load finding. Imported here, not
|
|
238
|
+
// at the top: a container boots through `scanAppModules` and never asks (`serve-graph.test.ts`).
|
|
239
|
+
const { unscannedAdminFindings } = await import('./unscanned-admin');
|
|
240
|
+
const findings = [...scan.findings, ...(await unscannedAdminFindings(root))];
|
|
203
241
|
await trackStylesheets(root);
|
|
204
242
|
// An app that imported NOTHING and reported nothing is a registry every later step reads as
|
|
205
243
|
// empty-and-fine. With an `app.config.ts` beside it, that is never what the author meant.
|
|
206
244
|
if (
|
|
207
|
-
files.length === 0 &&
|
|
245
|
+
scan.files.length === 0 &&
|
|
208
246
|
findings.length === 0 &&
|
|
209
247
|
registersNothing() &&
|
|
210
248
|
(await Bun.file(join(root, APP_CONFIG_FILE)).exists())
|
|
@@ -214,7 +252,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
214
252
|
// Read after the loop, never before it: `configureLocales` runs on the app's own import.
|
|
215
253
|
return {
|
|
216
254
|
root,
|
|
217
|
-
files,
|
|
255
|
+
files: scan.files,
|
|
218
256
|
errorCodes: await appErrorCodes(root),
|
|
219
257
|
defaultLocale: localeConfig().fallback,
|
|
220
258
|
findings,
|
|
@@ -240,13 +278,15 @@ const emptyAppFinding = (root: string): Finding => ({
|
|
|
240
278
|
/**
|
|
241
279
|
* Registers a module once; every later call replays whatever the first one reported — except for
|
|
242
280
|
* a route module, which a later call re-registers when the import handed back a new instance.
|
|
243
|
-
* `snapshot` is the source read before this import: always for a first pass and a route module
|
|
281
|
+
* `snapshot` is the source read before this import: always for a first pass and a route module of
|
|
282
|
+
* a TRACKED scan. An untracked one hands none, and a route module's text is read here instead.
|
|
244
283
|
*/
|
|
245
284
|
async function register(
|
|
246
285
|
absolute: string,
|
|
247
286
|
file: string,
|
|
248
287
|
module: Record<string, unknown>,
|
|
249
288
|
snapshot: string | undefined,
|
|
289
|
+
track: boolean,
|
|
250
290
|
): Promise<Finding | undefined> {
|
|
251
291
|
const previous = failures.get(absolute);
|
|
252
292
|
if (previous !== undefined) return previous;
|
|
@@ -255,11 +295,15 @@ async function register(
|
|
|
255
295
|
try {
|
|
256
296
|
const config = module['config'];
|
|
257
297
|
const route = isRouteConfig(config) ? config : undefined;
|
|
258
|
-
if (route !== undefined
|
|
259
|
-
registerRouteModule(file, module, route, snapshot);
|
|
298
|
+
if (route !== undefined) {
|
|
299
|
+
registerRouteModule(file, module, route, snapshot ?? (await Bun.file(absolute).text()));
|
|
260
300
|
routeModules.set(absolute, module);
|
|
261
301
|
}
|
|
262
|
-
|
|
302
|
+
// A pin is the reload graph's: it stops an eviction a process that never rescans never runs,
|
|
303
|
+
// and deciding it walks every export of every module against the entity registry.
|
|
304
|
+
if (track && (definesPrimitive(module, route !== undefined) || file === API_INDEX)) {
|
|
305
|
+
pinModule(absolute);
|
|
306
|
+
}
|
|
263
307
|
registerActions(module);
|
|
264
308
|
registerQueries(module);
|
|
265
309
|
return undefined;
|
package/src/app-manifest.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// Bun ships no `Bun.*` path API: `join` builds the host-separator path to `x.manifest.json`.
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { describeActions } from '@ultimat3/action';
|
|
9
|
+
import { primitiveRegistrar } from '@ultimat3/core';
|
|
9
10
|
import { registeredLocales } from '@ultimat3/i18n';
|
|
10
11
|
import { registeredTasks } from '@ultimat3/jobs';
|
|
12
|
+
import { sendMailJob } from '@ultimat3/mail';
|
|
11
13
|
import type { Manifest, PolicyFact, RouteFact, TaskFact } from '@ultimat3/manifest';
|
|
12
14
|
import {
|
|
13
15
|
buildManifest,
|
|
@@ -30,9 +32,21 @@ export interface AppManifest {
|
|
|
30
32
|
readonly findings: readonly Finding[];
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The jobs every role process registers whatever the app imports: `runtime-services.ts` imports
|
|
37
|
+
* `@ultimat3/mail` in every role, and that import declares `mail.send` — so every app's worker
|
|
38
|
+
* runs it, and the manifest records it. Seated here rather than left to an import's side effect:
|
|
39
|
+
* `x manifest`'s module graph never reached the mail barrel and `x verify`'s did, so the same app
|
|
40
|
+
* got two job lists and the `fix:` of `X_MANIFEST_DRIFT` could never clear it.
|
|
41
|
+
*/
|
|
42
|
+
const RUNTIME_JOBS: Readonly<Record<string, unknown>> = { sendMailJob };
|
|
43
|
+
|
|
33
44
|
/** Load the app, then describe it. Every command that needs facts goes through here. */
|
|
34
45
|
export async function appManifest(root: string): Promise<AppManifest> {
|
|
35
46
|
const loaded = await loadApp(root);
|
|
47
|
+
// Through the registrar `defineApi` uses (`define-api.ts`): the declared name wins, and the
|
|
48
|
+
// same handle seated twice is one registration, so a second build of one tree is unchanged.
|
|
49
|
+
primitiveRegistrar('job')(RUNTIME_JOBS);
|
|
36
50
|
const manifest = buildManifest(
|
|
37
51
|
frameworkSources({
|
|
38
52
|
app: await appIdentity(root),
|
|
@@ -103,14 +117,8 @@ const routeFacts = (): readonly RouteFact[] =>
|
|
|
103
117
|
...budgetOf(route),
|
|
104
118
|
}));
|
|
105
119
|
|
|
106
|
-
function budgetOf(route: RouteDescriptor): { budget?: { js
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
budget: {
|
|
110
|
-
...(route.budgetJs === null ? {} : { js: route.budgetJs }),
|
|
111
|
-
...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
|
|
112
|
-
},
|
|
113
|
-
};
|
|
120
|
+
function budgetOf(route: RouteDescriptor): { budget?: { js: string } } {
|
|
121
|
+
return route.budgetJs === null ? {} : { budget: { js: route.budgetJs } };
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
/**
|
package/src/app-mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
-
// why: Bun exposes no path-join primitive;
|
|
12
|
+
// why: Bun exposes no path-join primitive; each candidate is joined to root.
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { logger } from '@ultimat3/core';
|
|
15
15
|
import type { Route } from '@ultimat3/http';
|
|
@@ -20,8 +20,7 @@ import {
|
|
|
20
20
|
McpPathDuplicateError,
|
|
21
21
|
PROTECTED_RESOURCE_WELL_KNOWN,
|
|
22
22
|
} from '@ultimat3/mcp';
|
|
23
|
-
import {
|
|
24
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
23
|
+
import { loadAppConfig } from './app-config-load';
|
|
25
24
|
|
|
26
25
|
/** The one file an app writes, per app directory. */
|
|
27
26
|
export const APP_MCP_GLOB = 'apps/*/mcp.ts';
|
|
@@ -57,26 +56,9 @@ interface ExposeDeclaration {
|
|
|
57
56
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
58
57
|
typeof value === 'object' && value !== null;
|
|
59
58
|
|
|
60
|
-
/**
|
|
61
|
-
* `config.ai.mcp`, off the app's own resolved config — the exported `config` is what
|
|
62
|
-
* `defineConfig` returned, so both keys are present and defaulted. Read the same way
|
|
63
|
-
* `loadSignInPath` reads `auth.signInPath`: the config file is imported, never re-parsed.
|
|
64
|
-
* An app with no config file has nothing exposed and nothing to warn about.
|
|
65
|
-
*/
|
|
59
|
+
/** `config.ai.mcp` off the one loader (`app-config-load.ts`); nothing exposed with no config file. */
|
|
66
60
|
async function exposeDeclaration(root: string): Promise<ExposeDeclaration | undefined> {
|
|
67
|
-
|
|
68
|
-
if (!(await Bun.file(configPath).exists())) return undefined;
|
|
69
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
70
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
71
|
-
if (!isRecord(config) || !isRecord(config['ai']) || !isRecord(config['ai']['mcp'])) {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
const mcp = config['ai']['mcp'];
|
|
75
|
-
const path = mcp['path'];
|
|
76
|
-
return {
|
|
77
|
-
expose: mcp['expose'] === true,
|
|
78
|
-
path: typeof path === 'string' && path.startsWith('/') ? path : '/mcp',
|
|
79
|
-
};
|
|
61
|
+
return (await loadAppConfig(root))?.ai.mcp;
|
|
80
62
|
}
|
|
81
63
|
|
|
82
64
|
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
@@ -213,8 +195,13 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
|
|
|
213
195
|
// The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
|
|
214
196
|
// `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
|
|
215
197
|
// on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
|
|
198
|
+
// The client ADDRESS likewise: failed tokens are metered per address, and the descriptor
|
|
199
|
+
// has no other way to learn it.
|
|
216
200
|
handler: (request, ctx) =>
|
|
217
|
-
route.handle(request.raw, {
|
|
201
|
+
route.handle(request.raw, {
|
|
202
|
+
origin: selfOrigin(ctx.url, ctx.https),
|
|
203
|
+
address: ctx.ip ?? undefined,
|
|
204
|
+
}),
|
|
218
205
|
meta: { name, auth: 'public', enforcedBy: 'handler' },
|
|
219
206
|
},
|
|
220
207
|
...metadata,
|