@dharta/cli 0.6.77 → 0.6.78
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/LICENSE +1 -1
- package/README.md +215 -11
- package/dist/agent.js +3 -2
- package/dist/agent.js.map +1 -1
- package/dist/api-error.js +6 -6
- package/dist/api-error.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +860 -149
- package/dist/cli.js.map +1 -1
- package/dist/command-tree.d.ts +3 -0
- package/dist/command-tree.js +10 -0
- package/dist/command-tree.js.map +1 -0
- package/dist/commands/agents.d.ts +54 -17
- package/dist/commands/agents.js +246 -136
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/analytics.js +6 -5
- package/dist/commands/analytics.js.map +1 -1
- package/dist/commands/api.js +71 -86
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/appearance.js +5 -6
- package/dist/commands/appearance.js.map +1 -1
- package/dist/commands/artifact.js +7 -6
- package/dist/commands/artifact.js.map +1 -1
- package/dist/commands/audit-log.js +2 -1
- package/dist/commands/audit-log.js.map +1 -1
- package/dist/commands/auth.js +26 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/caps.js +11 -15
- package/dist/commands/caps.js.map +1 -1
- package/dist/commands/deploy.js +25 -10
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +10 -1
- package/dist/commands/dev.js +56 -25
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +60 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/embed-keys.js +4 -13
- package/dist/commands/embed-keys.js.map +1 -1
- package/dist/commands/env.js +4 -7
- package/dist/commands/env.js.map +1 -1
- package/dist/commands/files.js +3 -4
- package/dist/commands/files.js.map +1 -1
- package/dist/commands/fleet.d.ts +129 -0
- package/dist/commands/fleet.js +408 -0
- package/dist/commands/fleet.js.map +1 -0
- package/dist/commands/genui.d.ts +2 -0
- package/dist/commands/genui.js +53 -0
- package/dist/commands/genui.js.map +1 -0
- package/dist/commands/git-credential.js +2 -3
- package/dist/commands/git-credential.js.map +1 -1
- package/dist/commands/kartas.d.ts +9 -0
- package/dist/commands/kartas.js +33 -11
- package/dist/commands/kartas.js.map +1 -1
- package/dist/commands/keys.d.ts +17 -18
- package/dist/commands/keys.js +108 -19
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/login.js +2 -1
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/members.d.ts +10 -0
- package/dist/commands/members.js +20 -6
- package/dist/commands/members.js.map +1 -1
- package/dist/commands/model-connections.d.ts +44 -0
- package/dist/commands/model-connections.js +435 -0
- package/dist/commands/model-connections.js.map +1 -0
- package/dist/commands/open.d.ts +2 -2
- package/dist/commands/open.js +4 -3
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/org.d.ts +9 -0
- package/dist/commands/org.js +42 -0
- package/dist/commands/org.js.map +1 -0
- package/dist/commands/organization.d.ts +16 -0
- package/dist/commands/organization.js +118 -0
- package/dist/commands/organization.js.map +1 -0
- package/dist/commands/parity.js +1 -3
- package/dist/commands/parity.js.map +1 -1
- package/dist/commands/repos.d.ts +4 -0
- package/dist/commands/repos.js +17 -0
- package/dist/commands/repos.js.map +1 -0
- package/dist/commands/rollback.js +2 -3
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/schedules.js +26 -21
- package/dist/commands/schedules.js.map +1 -1
- package/dist/commands/scopes.js +8 -1
- package/dist/commands/scopes.js.map +1 -1
- package/dist/commands/sessions.js +9 -3
- package/dist/commands/sessions.js.map +1 -1
- package/dist/commands/setup.js +5 -4
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +24 -11
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/webhooks.d.ts +4 -1
- package/dist/commands/webhooks.js +21 -16
- package/dist/commands/webhooks.js.map +1 -1
- package/dist/completions.js +24 -13
- package/dist/completions.js.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +121 -7
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -0
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/docs/cli-reference.d.ts +2 -0
- package/dist/docs/cli-reference.js +191 -0
- package/dist/docs/cli-reference.js.map +1 -0
- package/dist/exit-codes.d.ts +25 -0
- package/dist/exit-codes.js +107 -0
- package/dist/exit-codes.js.map +1 -0
- package/dist/genui.d.ts +178 -0
- package/dist/genui.js +1130 -0
- package/dist/genui.js.map +1 -0
- package/dist/harnesses.js +2 -1
- package/dist/harnesses.js.map +1 -1
- package/dist/jq.d.ts +4 -0
- package/dist/jq.js +91 -0
- package/dist/jq.js.map +1 -0
- package/dist/login-offer.js +6 -2
- package/dist/login-offer.js.map +1 -1
- package/dist/manifest.d.ts +12 -0
- package/dist/manifest.js +126 -0
- package/dist/manifest.js.map +1 -1
- package/dist/model-auth.d.ts +29 -0
- package/dist/model-auth.js +117 -0
- package/dist/model-auth.js.map +1 -0
- package/dist/model-settings.d.ts +23 -0
- package/dist/model-settings.js +186 -0
- package/dist/model-settings.js.map +1 -0
- package/dist/money.js +2 -1
- package/dist/money.js.map +1 -1
- package/dist/output.d.ts +6 -0
- package/dist/output.js +54 -0
- package/dist/output.js.map +1 -1
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +1 -1
- package/dist/scaffold.js +2 -1
- package/dist/scaffold.js.map +1 -1
- package/dist/validators.js +17 -11
- package/dist/validators.js.map +1 -1
- package/dist/version-check.d.ts +1 -1
- package/dist/version-check.js +1 -1
- package/dist/version-check.js.map +1 -1
- package/node_modules/@dharta/contracts/LICENSE +21 -0
- package/node_modules/@dharta/contracts/README.md +45 -0
- package/node_modules/@dharta/contracts/artifact-runtime.json +750 -0
- package/node_modules/@dharta/contracts/dist/activity.d.ts +62 -0
- package/node_modules/@dharta/contracts/dist/activity.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/activity.js +178 -0
- package/node_modules/@dharta/contracts/dist/activity.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-formats.d.ts +20 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-formats.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-formats.js +53 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-formats.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-validators.generated.d.ts +37 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-validators.generated.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-validators.generated.js +5107 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime-validators.generated.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.d.ts +154 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.generated.d.ts +1670 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.generated.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.generated.js +1903 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.generated.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.js +377 -0
- package/node_modules/@dharta/contracts/dist/artifact-runtime.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/index.d.ts +6 -0
- package/node_modules/@dharta/contracts/dist/index.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/index.js +6 -0
- package/node_modules/@dharta/contracts/dist/index.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/model-auth.d.ts +31 -0
- package/node_modules/@dharta/contracts/dist/model-auth.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/model-auth.js +45 -0
- package/node_modules/@dharta/contracts/dist/model-auth.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/tasks.d.ts +44 -0
- package/node_modules/@dharta/contracts/dist/tasks.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/tasks.js +127 -0
- package/node_modules/@dharta/contracts/dist/tasks.js.map +1 -0
- package/node_modules/@dharta/contracts/dist/wire-events.d.ts +71 -0
- package/node_modules/@dharta/contracts/dist/wire-events.d.ts.map +1 -0
- package/node_modules/@dharta/contracts/dist/wire-events.js +48 -0
- package/node_modules/@dharta/contracts/dist/wire-events.js.map +1 -0
- package/node_modules/@dharta/contracts/model-auth-capabilities-v1.json +168 -0
- package/node_modules/@dharta/contracts/model-auth-reasons-v1.json +38 -0
- package/node_modules/@dharta/contracts/package.json +44 -0
- package/node_modules/@dharta/contracts/src/activity.ts +215 -0
- package/node_modules/@dharta/contracts/src/artifact-runtime-formats.ts +54 -0
- package/node_modules/@dharta/contracts/src/artifact-runtime-validators.generated.ts +29 -0
- package/node_modules/@dharta/contracts/src/artifact-runtime.generated.ts +2093 -0
- package/node_modules/@dharta/contracts/src/artifact-runtime.ts +520 -0
- package/node_modules/@dharta/contracts/src/index.ts +5 -0
- package/node_modules/@dharta/contracts/src/model-auth.ts +82 -0
- package/node_modules/@dharta/contracts/src/tasks.ts +142 -0
- package/node_modules/@dharta/contracts/src/wire-events.ts +150 -0
- package/node_modules/@dharta/contracts/wire-events.json +29 -0
- package/node_modules/@dharta/sdk/LICENSE +1 -1
- package/node_modules/@dharta/sdk/README.md +22 -8
- package/node_modules/@dharta/sdk/dist/errors.d.ts +9 -7
- package/node_modules/@dharta/sdk/dist/errors.d.ts.map +1 -1
- package/node_modules/@dharta/sdk/dist/errors.js +36 -18
- package/node_modules/@dharta/sdk/dist/errors.js.map +1 -1
- package/node_modules/@dharta/sdk/dist/fleet.d.ts +69 -0
- package/node_modules/@dharta/sdk/dist/fleet.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/fleet.js +240 -0
- package/node_modules/@dharta/sdk/dist/fleet.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/http.d.ts +6 -2
- package/node_modules/@dharta/sdk/dist/http.d.ts.map +1 -1
- package/node_modules/@dharta/sdk/dist/http.js +71 -12
- package/node_modules/@dharta/sdk/dist/http.js.map +1 -1
- package/node_modules/@dharta/sdk/dist/idempotency.d.ts +12 -0
- package/node_modules/@dharta/sdk/dist/idempotency.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/idempotency.js +72 -0
- package/node_modules/@dharta/sdk/dist/idempotency.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/index.d.ts +20 -6
- package/node_modules/@dharta/sdk/dist/index.d.ts.map +1 -1
- package/node_modules/@dharta/sdk/dist/index.js +27 -4
- package/node_modules/@dharta/sdk/dist/index.js.map +1 -1
- package/node_modules/@dharta/sdk/dist/resources.d.ts +108 -13
- package/node_modules/@dharta/sdk/dist/resources.d.ts.map +1 -1
- package/node_modules/@dharta/sdk/dist/resources.js +403 -57
- package/node_modules/@dharta/sdk/dist/resources.js.map +1 -1
- package/node_modules/@dharta/sdk/dist/types.d.ts +436 -16
- package/node_modules/@dharta/sdk/dist/types.d.ts.map +1 -1
- package/node_modules/@dharta/sdk/dist/types.js +31 -2
- package/node_modules/@dharta/sdk/dist/types.js.map +1 -1
- package/node_modules/@dharta/sdk/package.json +9 -3
- package/node_modules/@dharta/sdk/src/errors.ts +41 -18
- package/node_modules/@dharta/sdk/src/fleet.ts +339 -0
- package/node_modules/@dharta/sdk/src/http.ts +110 -17
- package/node_modules/@dharta/sdk/src/idempotency.ts +101 -0
- package/node_modules/@dharta/sdk/src/index.ts +50 -11
- package/node_modules/@dharta/sdk/src/resources.ts +549 -68
- package/node_modules/@dharta/sdk/src/types.ts +629 -20
- package/package.json +16 -6
- package/src/agent.ts +3 -2
- package/src/api-error.ts +6 -5
- package/src/cli.ts +978 -163
- package/src/command-tree.ts +13 -0
- package/src/commands/agents.ts +365 -167
- package/src/commands/analytics.ts +6 -5
- package/src/commands/api.ts +73 -92
- package/src/commands/appearance.ts +5 -6
- package/src/commands/artifact.ts +7 -6
- package/src/commands/audit-log.ts +2 -1
- package/src/commands/auth.ts +25 -5
- package/src/commands/caps.ts +11 -15
- package/src/commands/deploy.ts +18 -4
- package/src/commands/dev.ts +86 -25
- package/src/commands/doctor.ts +69 -0
- package/src/commands/embed-keys.ts +4 -14
- package/src/commands/env.ts +4 -7
- package/src/commands/files.ts +3 -4
- package/src/commands/fleet.ts +672 -0
- package/src/commands/genui.ts +59 -0
- package/src/commands/git-credential.ts +2 -3
- package/src/commands/kartas.ts +45 -11
- package/src/commands/keys.ts +152 -35
- package/src/commands/login.ts +2 -1
- package/src/commands/members.ts +31 -6
- package/src/commands/model-connections.ts +547 -0
- package/src/commands/open.ts +5 -4
- package/src/commands/org.ts +57 -0
- package/src/commands/organization.ts +153 -0
- package/src/commands/parity.ts +1 -3
- package/src/commands/repos.ts +23 -0
- package/src/commands/rollback.ts +2 -3
- package/src/commands/schedules.ts +33 -22
- package/src/commands/scopes.ts +8 -1
- package/src/commands/sessions.ts +9 -3
- package/src/commands/setup.ts +5 -4
- package/src/commands/update.ts +28 -12
- package/src/commands/webhooks.ts +30 -19
- package/src/completions.ts +25 -13
- package/src/config.ts +141 -9
- package/src/context.ts +34 -7
- package/src/docs/cli-reference.ts +206 -0
- package/src/exit-codes.ts +122 -0
- package/src/genui.ts +1398 -0
- package/src/harnesses.ts +2 -1
- package/src/jq.ts +109 -0
- package/src/login-offer.ts +6 -2
- package/src/manifest.ts +134 -0
- package/src/model-auth.ts +173 -0
- package/src/model-settings.ts +222 -0
- package/src/money.ts +5 -1
- package/src/output.ts +60 -0
- package/src/runtime.ts +1 -1
- package/src/scaffold.ts +2 -1
- package/src/validators.ts +18 -11
- package/src/version-check.ts +1 -1
- package/dist/commands/model-keys.d.ts +0 -22
- package/dist/commands/model-keys.js +0 -222
- package/dist/commands/model-keys.js.map +0 -1
- package/src/commands/model-keys.ts +0 -278
package/src/genui.ts
ADDED
|
@@ -0,0 +1,1398 @@
|
|
|
1
|
+
// RFC 0056 generative UI: the CLI-side mirror of the data plane's `[genui]`
|
|
2
|
+
// contract, plus the compiler that turns a source folder into the bundle the
|
|
3
|
+
// data plane serves.
|
|
4
|
+
//
|
|
5
|
+
// SOURCE OF TRUTH: apps/dharta-python/src/dharta/genui/manifest.py (the
|
|
6
|
+
// `[genui]` table) and apps/dharta-python/src/dharta/genui/bundle.py (the
|
|
7
|
+
// `dharta.genui-bundle.v1` file). Those two files decide what a release is
|
|
8
|
+
// allowed to carry; everything here is a local mirror whose only job is to fail
|
|
9
|
+
// on the developer's machine instead of at deploy. Change either Python file and
|
|
10
|
+
// this one has to move with it. test/genui-crossplane.test.ts reads both files
|
|
11
|
+
// out of the monorepo and pins every constant below against them. The widget
|
|
12
|
+
// re-validates the same shape a third time in
|
|
13
|
+
// sdks/widget/src/transport/managedAgents.ts (`coerceGenuiCatalog`).
|
|
14
|
+
//
|
|
15
|
+
// The mirror is hand-written rather than generated from a @dharta/contracts
|
|
16
|
+
// schema because that package's established pattern is a language-neutral JSON
|
|
17
|
+
// file pinned by conformance tests on BOTH sides (see its README). The Python
|
|
18
|
+
// side of that pin lives in apps/dharta-python, so a half-pinned schema would
|
|
19
|
+
// claim a guarantee it does not have - and the CLI would gain a runtime
|
|
20
|
+
// dependency it does not otherwise need.
|
|
21
|
+
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { promises as fs } from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import TOML from "@iarna/toml";
|
|
26
|
+
import { setGenuiBundleSha256, tableScalarLiteral } from "./manifest.js";
|
|
27
|
+
|
|
28
|
+
// --- The contract, mirrored -------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export const BUNDLE_FORMAT = "dharta.genui-bundle.v1";
|
|
31
|
+
export const MAX_BUNDLE_BYTES = 5 * 1024 * 1024;
|
|
32
|
+
export const MAX_COMPONENTS = 128;
|
|
33
|
+
export const MAX_WORKER_BYTES = 500 * 1024;
|
|
34
|
+
export const MAX_HTML_BYTES = 1024 * 1024;
|
|
35
|
+
export const MAX_EVENTS = 64;
|
|
36
|
+
export const MAX_EVENT_NAME_LENGTH = 128;
|
|
37
|
+
export const EVENT_CADENCES = ["agent_action", "reference", "side_effect"] as const;
|
|
38
|
+
|
|
39
|
+
// Must equal bundle.py's TIER1_BUILTINS exactly: a Tier-1 component is only an
|
|
40
|
+
// alias for one of Dharta's own host-rendered primitives.
|
|
41
|
+
export const TIER1_BUILTINS = [
|
|
42
|
+
"Badge",
|
|
43
|
+
"Button",
|
|
44
|
+
"Card",
|
|
45
|
+
"Checkbox",
|
|
46
|
+
"Column",
|
|
47
|
+
"Divider",
|
|
48
|
+
"Heading",
|
|
49
|
+
"Row",
|
|
50
|
+
"Select",
|
|
51
|
+
"Text",
|
|
52
|
+
"TextField",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
// manifest.py's _IDENTIFIER, for both catalog_id and component names.
|
|
56
|
+
export const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.:/@-]{0,127}$/;
|
|
57
|
+
|
|
58
|
+
// Where `dharta genui build` looks for component sources when
|
|
59
|
+
// `[genui.build] source` is unset. One directory per component, named exactly
|
|
60
|
+
// as the component.
|
|
61
|
+
export const DEFAULT_SOURCE_DIR = "genui/components";
|
|
62
|
+
export const COMPONENT_SPEC_FILE = "component.json";
|
|
63
|
+
export const TIER2_SOURCE_FILE = "worker.js";
|
|
64
|
+
export const TIER3_SOURCE_FILE = "index.html";
|
|
65
|
+
|
|
66
|
+
// The data plane's own wording for the two path rejections in
|
|
67
|
+
// bundle.py::_read_regular_file. `checkGenui` reports them verbatim, because a
|
|
68
|
+
// pre-flight refusal should read exactly like the release rejection it
|
|
69
|
+
// predicts; the build appends the offending path, because there the developer
|
|
70
|
+
// is being told which link to remove.
|
|
71
|
+
const CROSSES_SYMLINK = "[genui] bundle path crosses a non-directory or symlink";
|
|
72
|
+
const NOT_A_REGULAR_FILE = "[genui] bundle must be a regular file";
|
|
73
|
+
|
|
74
|
+
/** A `[genui]` section, or a bundle, that the data plane would reject. */
|
|
75
|
+
export class GenuiError extends Error {}
|
|
76
|
+
|
|
77
|
+
export interface GenuiComponent {
|
|
78
|
+
name: string;
|
|
79
|
+
tier: 1 | 2 | 3;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface GenuiSection {
|
|
83
|
+
catalogId: string;
|
|
84
|
+
version: number;
|
|
85
|
+
/** Release-relative path to the built bundle; null when Tier-1 only. */
|
|
86
|
+
bundle: string | null;
|
|
87
|
+
bundleSha256: string | null;
|
|
88
|
+
components: GenuiComponent[];
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
/** Release-relative component source folder, from `[genui.build] source`. */
|
|
91
|
+
source: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function plainObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- [genui] parsing (mirror of manifest.py parse_genui) --------------------
|
|
99
|
+
|
|
100
|
+
export interface ParseGenuiOptions {
|
|
101
|
+
/**
|
|
102
|
+
* The data plane requires bundle_sha256 whenever bundle is set, and so does
|
|
103
|
+
* every read path here. `dharta genui build` is the one exception: it is the
|
|
104
|
+
* thing that produces the digest, so it must be able to parse a manifest that
|
|
105
|
+
* declares a bundle it has not built yet.
|
|
106
|
+
*/
|
|
107
|
+
requireDigest?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* The manifest's raw text. Supplied so `version` can be judged by its TOML
|
|
110
|
+
* literal rather than by the JavaScript number it parsed to - see
|
|
111
|
+
* `rejectFloatVersion`.
|
|
112
|
+
*/
|
|
113
|
+
text?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse the `[genui]` table out of a loaded `dharta.toml`. Returns null when
|
|
118
|
+
* there is no section (generative UI is opt-in); throws `GenuiError` on a
|
|
119
|
+
* section the data plane would reject.
|
|
120
|
+
*/
|
|
121
|
+
export function parseGenuiSection(
|
|
122
|
+
manifest: Record<string, unknown>,
|
|
123
|
+
options: ParseGenuiOptions = {},
|
|
124
|
+
): GenuiSection | null {
|
|
125
|
+
const requireDigest = options.requireDigest !== false;
|
|
126
|
+
const raw = manifest.genui;
|
|
127
|
+
if (raw === undefined) return null;
|
|
128
|
+
if (!plainObject(raw)) throw new GenuiError("[genui] must be a table");
|
|
129
|
+
|
|
130
|
+
const catalogId = raw.catalog_id;
|
|
131
|
+
if (typeof catalogId !== "string" || !IDENTIFIER.test(catalogId.trim())) {
|
|
132
|
+
throw new GenuiError("[genui] requires a non-empty string catalog_id");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawBundle = raw.bundle;
|
|
136
|
+
if (rawBundle !== undefined && (typeof rawBundle !== "string" || !rawBundle.trim())) {
|
|
137
|
+
throw new GenuiError("[genui] bundle must be a non-empty string path when set");
|
|
138
|
+
}
|
|
139
|
+
const bundle = typeof rawBundle === "string" ? rawBundle.trim() : null;
|
|
140
|
+
|
|
141
|
+
if (options.text !== undefined) rejectFloatVersion(options.text);
|
|
142
|
+
const version = raw.version === undefined ? 1 : raw.version;
|
|
143
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
144
|
+
throw new GenuiError("[genui] version must be a positive integer");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const digest = raw.bundle_sha256;
|
|
148
|
+
const digestValid = typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest);
|
|
149
|
+
if (bundle !== null) {
|
|
150
|
+
if (requireDigest && !digestValid) {
|
|
151
|
+
throw new GenuiError(
|
|
152
|
+
"[genui] bundle_sha256 must be a lowercase 64-character SHA-256 digest",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} else if (digest !== undefined) {
|
|
156
|
+
throw new GenuiError("[genui] bundle_sha256 requires bundle");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const enabled = raw.enabled === undefined ? true : raw.enabled;
|
|
160
|
+
if (typeof enabled !== "boolean") throw new GenuiError("[genui] enabled must be a boolean");
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
catalogId: catalogId.trim(),
|
|
164
|
+
version,
|
|
165
|
+
bundle,
|
|
166
|
+
bundleSha256: digestValid ? (digest as string) : null,
|
|
167
|
+
components: parseComponents(raw.components === undefined ? [] : raw.components, bundle !== null),
|
|
168
|
+
enabled,
|
|
169
|
+
source: parseBuildTable(raw.build),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `[genui.build]` is a CLI-only sub-table: `dharta genui build` reads it, the
|
|
174
|
+
// release contract never does. Safe because manifest.py's parse_genui reads
|
|
175
|
+
// only its own known keys off the section mapping (catalog_id, bundle, version,
|
|
176
|
+
// bundle_sha256, enabled, components) and nothing in releases.py rejects
|
|
177
|
+
// unrecognized keys, so an extra sub-table is simply ignored at deploy. Keeping
|
|
178
|
+
// it out of `[genui]` itself means a CLI-only key can never collide with a key
|
|
179
|
+
// the data plane later adds there.
|
|
180
|
+
function parseBuildTable(raw: unknown): string {
|
|
181
|
+
if (raw === undefined) return DEFAULT_SOURCE_DIR;
|
|
182
|
+
if (!plainObject(raw)) throw new GenuiError("[genui.build] must be a table");
|
|
183
|
+
const source = raw.source;
|
|
184
|
+
if (source === undefined) return DEFAULT_SOURCE_DIR;
|
|
185
|
+
if (typeof source !== "string" || !source.trim()) {
|
|
186
|
+
throw new GenuiError("[genui.build] source must be a non-empty string path when set");
|
|
187
|
+
}
|
|
188
|
+
return source.trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// `@iarna/toml` returns a JavaScript number for both `version = 1` and
|
|
192
|
+
// `version = 1.0`, so by the time the value reaches us the two are identical.
|
|
193
|
+
// Python's tomllib keeps them apart: `1.0` is a float, and manifest.py's
|
|
194
|
+
// `isinstance(version, int)` rejects it. Read the literal back out of the
|
|
195
|
+
// manifest text so the deploy-time failure surfaces here instead.
|
|
196
|
+
//
|
|
197
|
+
// Best effort by construction: it only sees a plain `[genui]` table with a
|
|
198
|
+
// `version = ...` line. An inline (`genui = { version = 1.0 }`) or dotted
|
|
199
|
+
// (`genui.version = 1.0`) spelling is invisible to it and still fails at
|
|
200
|
+
// deploy, with this same message.
|
|
201
|
+
function rejectFloatVersion(text: string): void {
|
|
202
|
+
const literal = tableScalarLiteral(text, "genui", "version");
|
|
203
|
+
if (literal === null || !isTomlFloatLiteral(literal)) return;
|
|
204
|
+
throw new GenuiError("[genui] version must be a positive integer");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isTomlFloatLiteral(literal: string): boolean {
|
|
208
|
+
if (/^[+-]?(inf|nan)$/.test(literal)) return true;
|
|
209
|
+
if (/^[+-]?0[xob]/i.test(literal)) return false; // hex/octal/binary are integers
|
|
210
|
+
return /[.eE]/.test(literal);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseComponents(raw: unknown, hasBundle: boolean): GenuiComponent[] {
|
|
214
|
+
if (!Array.isArray(raw)) throw new GenuiError("[genui] components must be an array of tables");
|
|
215
|
+
const out: GenuiComponent[] = [];
|
|
216
|
+
const seen = new Set<string>();
|
|
217
|
+
for (const entry of raw) {
|
|
218
|
+
if (!plainObject(entry)) throw new GenuiError("each [genui] component must be a table");
|
|
219
|
+
const rawName = entry.name;
|
|
220
|
+
if (typeof rawName !== "string" || !IDENTIFIER.test(rawName.trim())) {
|
|
221
|
+
throw new GenuiError("each [genui] component needs a non-empty name");
|
|
222
|
+
}
|
|
223
|
+
const name = rawName.trim();
|
|
224
|
+
if (seen.has(name)) throw new GenuiError(`duplicate genui component: '${name}'`);
|
|
225
|
+
seen.add(name);
|
|
226
|
+
const tier = entry.tier === undefined ? 1 : entry.tier;
|
|
227
|
+
if (tier !== 1 && tier !== 2 && tier !== 3) {
|
|
228
|
+
throw new GenuiError(`component '${name}' tier must be one of 1, 2, 3`);
|
|
229
|
+
}
|
|
230
|
+
if (tier >= 2 && !hasBundle) {
|
|
231
|
+
throw new GenuiError(
|
|
232
|
+
`component '${name}' is tier ${tier} (builder code) but [genui] declares no bundle`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
out.push({ name, tier });
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- bundle validation (mirror of bundle.py _validate_payload) --------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The catalog the data plane synthesizes when `[genui]` declares no bundle
|
|
244
|
+
* (bundle.py load_genui_bundle_with_bytes). It runs the SAME payload validation
|
|
245
|
+
* over it, so a Tier-1-only catalog is not unvalidated: a component whose name
|
|
246
|
+
* is not a Dharta builtin, or a 129th component, is rejected at deploy.
|
|
247
|
+
*/
|
|
248
|
+
export function synthesizeBundlePayload(section: GenuiSection): Record<string, unknown> {
|
|
249
|
+
return {
|
|
250
|
+
format: BUNDLE_FORMAT,
|
|
251
|
+
catalog_id: section.catalogId,
|
|
252
|
+
version: section.version,
|
|
253
|
+
components: section.components.map((component) => ({
|
|
254
|
+
name: component.name,
|
|
255
|
+
tier: component.tier,
|
|
256
|
+
implementation: { builtin: component.name },
|
|
257
|
+
})),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Throw `GenuiError` unless `payload` is a bundle this manifest can ship.
|
|
263
|
+
*
|
|
264
|
+
* `rawText` is the bundle file's bytes as read off disk, when there was one.
|
|
265
|
+
* The whole integer rule lives here because JavaScript cannot see it in the
|
|
266
|
+
* parsed value: `JSON.parse` collapses `1.0` to `1`, while Python's
|
|
267
|
+
* `json.loads` yields a float that fails `isinstance(..., int)`.
|
|
268
|
+
*/
|
|
269
|
+
export function validateBundlePayload(
|
|
270
|
+
payload: unknown,
|
|
271
|
+
section: GenuiSection,
|
|
272
|
+
rawText?: string,
|
|
273
|
+
): void {
|
|
274
|
+
if (!plainObject(payload)) throw new GenuiError("[genui] bundle root must be an object");
|
|
275
|
+
if (payload.format !== BUNDLE_FORMAT) {
|
|
276
|
+
throw new GenuiError(`[genui] bundle format must be '${BUNDLE_FORMAT}'`);
|
|
277
|
+
}
|
|
278
|
+
if (rawText !== undefined) rejectNonIntegerLiterals(rawText);
|
|
279
|
+
if (
|
|
280
|
+
payload.catalog_id !== section.catalogId ||
|
|
281
|
+
typeof payload.version !== "number" ||
|
|
282
|
+
!Number.isInteger(payload.version) ||
|
|
283
|
+
payload.version !== section.version
|
|
284
|
+
) {
|
|
285
|
+
throw new GenuiError("[genui] bundle identity does not match dharta.toml");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const rawComponents = payload.components;
|
|
289
|
+
if (!Array.isArray(rawComponents) || rawComponents.length > MAX_COMPONENTS) {
|
|
290
|
+
throw new GenuiError(
|
|
291
|
+
`[genui] bundle components must be an array of at most ${MAX_COMPONENTS} entries`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const actual = new Map<string, number>();
|
|
296
|
+
for (const component of rawComponents) {
|
|
297
|
+
if (!plainObject(component)) {
|
|
298
|
+
throw new GenuiError("[genui] every bundled component must be an object");
|
|
299
|
+
}
|
|
300
|
+
const name = component.name;
|
|
301
|
+
const tier = component.tier;
|
|
302
|
+
if (
|
|
303
|
+
typeof name !== "string" ||
|
|
304
|
+
!name ||
|
|
305
|
+
actual.has(name) ||
|
|
306
|
+
(tier !== 1 && tier !== 2 && tier !== 3)
|
|
307
|
+
) {
|
|
308
|
+
throw new GenuiError("[genui] bundled component name/tier is invalid or duplicated");
|
|
309
|
+
}
|
|
310
|
+
actual.set(name, tier);
|
|
311
|
+
if (component.props_schema !== undefined && !plainObject(component.props_schema)) {
|
|
312
|
+
throw new GenuiError(`[genui] component '${name}' props_schema must be an object`);
|
|
313
|
+
}
|
|
314
|
+
const events = component.events === undefined ? {} : component.events;
|
|
315
|
+
if (!plainObject(events)) {
|
|
316
|
+
throw new GenuiError(`[genui] component '${name}' events must be an object`);
|
|
317
|
+
}
|
|
318
|
+
validateEvents(name, events);
|
|
319
|
+
if (!plainObject(component.implementation)) {
|
|
320
|
+
throw new GenuiError(`[genui] component '${name}' needs an implementation object`);
|
|
321
|
+
}
|
|
322
|
+
validateImplementation(name, tier, component.implementation);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const expected = new Map(section.components.map((c) => [c.name, c.tier as number]));
|
|
326
|
+
const matches =
|
|
327
|
+
actual.size === expected.size &&
|
|
328
|
+
[...expected].every(([name, tier]) => actual.get(name) === tier);
|
|
329
|
+
if (!matches) {
|
|
330
|
+
throw new GenuiError(
|
|
331
|
+
"[genui] bundled components do not exactly match dharta.toml " +
|
|
332
|
+
`(dharta.toml: ${describeComponents(expected)}; bundle: ${describeComponents(actual)})`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function describeComponents(components: Map<string, number>): string {
|
|
338
|
+
const entries = [...components].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
339
|
+
return entries.length === 0
|
|
340
|
+
? "(none)"
|
|
341
|
+
: entries.map(([name, tier]) => `${name}@tier${tier}`).join(", ");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function validateImplementation(name: string, tier: number, impl: Record<string, unknown>): void {
|
|
345
|
+
if (tier === 1) {
|
|
346
|
+
const keys = Object.keys(impl);
|
|
347
|
+
const builtin = impl.builtin;
|
|
348
|
+
if (
|
|
349
|
+
keys.length !== 1 ||
|
|
350
|
+
keys[0] !== "builtin" ||
|
|
351
|
+
typeof builtin !== "string" ||
|
|
352
|
+
!(TIER1_BUILTINS as readonly string[]).includes(builtin)
|
|
353
|
+
) {
|
|
354
|
+
throw new GenuiError(
|
|
355
|
+
`[genui] Tier-1 component '${name}' must alias a Dharta builtin ` +
|
|
356
|
+
`(one of ${TIER1_BUILTINS.join(", ")})`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
// A built-in name may only alias itself. The widget merges a registered
|
|
360
|
+
// catalog OVER the built-in one, so `{ name: "Card", builtin: "Badge" }`
|
|
361
|
+
// reads as an alias but takes the built-in's name, and every Card in the
|
|
362
|
+
// transcript - including surfaces that never asked for this catalog - would
|
|
363
|
+
// draw as a Badge.
|
|
364
|
+
if ((TIER1_BUILTINS as readonly string[]).includes(name) && builtin !== name) {
|
|
365
|
+
throw new GenuiError(
|
|
366
|
+
`[genui] Tier-1 component '${name}' takes a built-in component name and aliases ` +
|
|
367
|
+
`'${builtin}'; a built-in name may only alias itself. Rename the component, or ` +
|
|
368
|
+
`drop the alias so '${name}' renders as the built-in '${name}'.`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// A builder component may not take a built-in's name: the renderer resolves
|
|
374
|
+
// by name, so a Tier-2/3 component called `Card` would shadow the host
|
|
375
|
+
// primitive every other surface expects. Tier-1 entries are aliases FOR those
|
|
376
|
+
// primitives, so they keep the name.
|
|
377
|
+
if ((TIER1_BUILTINS as readonly string[]).includes(name)) {
|
|
378
|
+
throw new GenuiError(
|
|
379
|
+
`[genui] Tier-${tier} component '${name}' may not reuse the name of a Dharta builtin; ` +
|
|
380
|
+
"rename it, or declare it as a Tier-1 alias",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const field = tier === 2 ? "worker" : "html";
|
|
384
|
+
const limit = tier === 2 ? MAX_WORKER_BYTES : MAX_HTML_BYTES;
|
|
385
|
+
const source = impl[field];
|
|
386
|
+
const keys = Object.keys(impl);
|
|
387
|
+
if (
|
|
388
|
+
keys.length !== 1 ||
|
|
389
|
+
keys[0] !== field ||
|
|
390
|
+
typeof source !== "string" ||
|
|
391
|
+
Buffer.byteLength(source, "utf8") > limit
|
|
392
|
+
) {
|
|
393
|
+
throw new GenuiError(
|
|
394
|
+
`[genui] Tier-${tier} component '${name}' needs bounded ${field} source ` +
|
|
395
|
+
`(a string of at most ${limit} bytes)`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function validateEvents(name: string, events: Record<string, unknown>): void {
|
|
401
|
+
const entries = Object.entries(events);
|
|
402
|
+
if (entries.length > MAX_EVENTS) {
|
|
403
|
+
throw new GenuiError(`[genui] component '${name}' declares too many events`);
|
|
404
|
+
}
|
|
405
|
+
for (const [eventName, raw] of entries) {
|
|
406
|
+
if (
|
|
407
|
+
!eventName ||
|
|
408
|
+
// Python's len() counts code points, so an emoji costs 1 there and 2 in
|
|
409
|
+
// UTF-16. Count the same units it does.
|
|
410
|
+
Array.from(eventName).length > MAX_EVENT_NAME_LENGTH ||
|
|
411
|
+
!plainObject(raw) ||
|
|
412
|
+
!(EVENT_CADENCES as readonly string[]).includes(raw.cadence as string)
|
|
413
|
+
) {
|
|
414
|
+
throw new GenuiError(
|
|
415
|
+
`[genui] component '${name}' has an invalid event declaration '${eventName}' ` +
|
|
416
|
+
`(each event needs cadence = ${EVENT_CADENCES.join(" | ")})`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
// `context_schema: null` is accepted: Python tests `is not None` first.
|
|
420
|
+
if (
|
|
421
|
+
raw.context_schema !== undefined &&
|
|
422
|
+
raw.context_schema !== null &&
|
|
423
|
+
!plainObject(raw.context_schema)
|
|
424
|
+
) {
|
|
425
|
+
throw new GenuiError(`[genui] component '${name}' event schema must be an object`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Reject a numeric literal the contract requires to be an integer but which was
|
|
432
|
+
* written as a float: the root object's `version`, and `tier` inside the root's
|
|
433
|
+
* `components` array. Python's `json.loads` returns a float for `1.0` / `1e0`
|
|
434
|
+
* and the data plane's `isinstance(..., int)` refuses it, while JavaScript has
|
|
435
|
+
* a single number type and cannot tell after parsing.
|
|
436
|
+
*
|
|
437
|
+
* Depth-tracked so it judges only those two positions: a component's
|
|
438
|
+
* `props_schema` may legitimately describe a `version` or `tier` of its own,
|
|
439
|
+
* and that is builder data, not the bundle's contract.
|
|
440
|
+
*/
|
|
441
|
+
function rejectNonIntegerLiterals(text: string): void {
|
|
442
|
+
// The key that opened each container: null for the root object and for the
|
|
443
|
+
// objects inside an array, "components" for the components array itself.
|
|
444
|
+
const containers: Array<string | null> = [];
|
|
445
|
+
let lastKey: string | null = null;
|
|
446
|
+
|
|
447
|
+
for (let index = 0; index < text.length; index++) {
|
|
448
|
+
const char = text[index];
|
|
449
|
+
if (char === '"') {
|
|
450
|
+
let end = index + 1;
|
|
451
|
+
while (end < text.length && text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
|
|
452
|
+
lastKey = text.slice(index + 1, end);
|
|
453
|
+
index = end;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (char === "{" || char === "[") {
|
|
457
|
+
containers.push(lastKey);
|
|
458
|
+
lastKey = null;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (char === "}" || char === "]") {
|
|
462
|
+
containers.pop();
|
|
463
|
+
lastKey = null;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (char !== ":") continue;
|
|
467
|
+
|
|
468
|
+
const atRoot = containers.length === 1;
|
|
469
|
+
const inComponent =
|
|
470
|
+
containers.length === 3 && containers[1] === "components" && containers[2] === null;
|
|
471
|
+
if (!(atRoot && lastKey === "version") && !(inComponent && lastKey === "tier")) continue;
|
|
472
|
+
|
|
473
|
+
const literal = text.slice(index + 1).match(/^\s*([^\s,}\]]+)/)?.[1];
|
|
474
|
+
if (literal === undefined || !isJsonFloatLiteral(literal)) continue;
|
|
475
|
+
throw new GenuiError(
|
|
476
|
+
lastKey === "version"
|
|
477
|
+
? "[genui] bundle identity does not match dharta.toml"
|
|
478
|
+
: "[genui] bundled component name/tier is invalid or duplicated",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isJsonFloatLiteral(literal: string): boolean {
|
|
484
|
+
return /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(literal) && /[.eE]/.test(literal);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** A number in a JSON document that a JavaScript double cannot carry. */
|
|
488
|
+
interface UnsafeNumber {
|
|
489
|
+
/** Dotted and indexed path from the root, for example `props_schema.rows[0].max`. */
|
|
490
|
+
path: string;
|
|
491
|
+
literal: string;
|
|
492
|
+
/**
|
|
493
|
+
* `integer` - beyond Number.MAX_SAFE_INTEGER, so it is silently rounded.
|
|
494
|
+
* `overflow` - beyond a double's range, so it parses to +/-Infinity and
|
|
495
|
+
* serializes as `null`.
|
|
496
|
+
*/
|
|
497
|
+
kind: "integer" | "overflow";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The first number literal in `text` that a JavaScript double cannot carry, or
|
|
502
|
+
* null when there is none. Two shapes qualify, and both are silent:
|
|
503
|
+
*
|
|
504
|
+
* An integer beyond `Number.MAX_SAFE_INTEGER`. `JSON.parse` rounds
|
|
505
|
+
* `12345678901234567890` to `12345678901234567000` and nothing downstream can
|
|
506
|
+
* tell, so a build would write digits into the bundle that the source never
|
|
507
|
+
* had - and the data plane, whose `int` is arbitrary precision, would read the
|
|
508
|
+
* builder's own digits back out of its own copy.
|
|
509
|
+
*
|
|
510
|
+
* A float that overflows a double. `1e400` parses to `Infinity`, which
|
|
511
|
+
* `JSON.stringify` writes as `null`: the bundle would carry no number at all
|
|
512
|
+
* where the source had one, while Python's `float` reads the same literal as
|
|
513
|
+
* `inf` and keeps it.
|
|
514
|
+
*
|
|
515
|
+
* Every other float is left alone. `0.1` and `1.5e300` are IEEE doubles on both
|
|
516
|
+
* planes, so the two lose exactly the same precision and still agree.
|
|
517
|
+
*
|
|
518
|
+
* The only place the real number still exists is the raw text, so read it
|
|
519
|
+
* there, the same way `rejectNonIntegerLiterals` reads `version` and `tier`.
|
|
520
|
+
*/
|
|
521
|
+
function findUnsafeNumberLiteral(text: string): UnsafeNumber | null {
|
|
522
|
+
// One frame per open container. `base` is the path of the container itself;
|
|
523
|
+
// `key` and `index` say where the container's NEXT value goes.
|
|
524
|
+
const frames: Array<{ array: boolean; base: string; key: string; index: number }> = [];
|
|
525
|
+
|
|
526
|
+
// The path of the value about to be read.
|
|
527
|
+
const at = (): string => {
|
|
528
|
+
const frame = frames[frames.length - 1];
|
|
529
|
+
if (frame === undefined) return "the document root";
|
|
530
|
+
if (frame.array) return `${frame.base}[${frame.index}]`;
|
|
531
|
+
return frame.base === "" ? frame.key : `${frame.base}.${frame.key}`;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
for (let index = 0; index < text.length; index++) {
|
|
535
|
+
const char = text[index];
|
|
536
|
+
if (char === '"') {
|
|
537
|
+
let end = index + 1;
|
|
538
|
+
while (end < text.length && text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
|
|
539
|
+
const value = text.slice(index + 1, end);
|
|
540
|
+
index = end;
|
|
541
|
+
// A string followed by `:` is its object's next key. Every other string
|
|
542
|
+
// is a value, and a value never moves the path.
|
|
543
|
+
const frame = frames[frames.length - 1];
|
|
544
|
+
if (frame !== undefined && !frame.array && /^\s*:/.test(text.slice(index + 1))) {
|
|
545
|
+
frame.key = value;
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (char === "{" || char === "[") {
|
|
550
|
+
frames.push({
|
|
551
|
+
array: char === "[",
|
|
552
|
+
base: frames.length === 0 ? "" : at(),
|
|
553
|
+
key: "",
|
|
554
|
+
index: 0,
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (char === "}" || char === "]") {
|
|
559
|
+
frames.pop();
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (char === ",") {
|
|
563
|
+
const frame = frames[frames.length - 1];
|
|
564
|
+
if (frame?.array) frame.index += 1;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (char !== "-" && (char < "0" || char > "9")) continue;
|
|
568
|
+
const literal = text.slice(index).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/)?.[0];
|
|
569
|
+
if (literal === undefined) continue;
|
|
570
|
+
index += literal.length - 1;
|
|
571
|
+
if (/^-?\d+$/.test(literal)) {
|
|
572
|
+
// BigInt, not Number: the comparison has to be exact at the boundary,
|
|
573
|
+
// and Number() has already lost the digits that decide it.
|
|
574
|
+
const digits = literal.startsWith("-") ? literal.slice(1) : literal;
|
|
575
|
+
if (BigInt(digits) > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
576
|
+
return { path: at(), literal, kind: "integer" };
|
|
577
|
+
}
|
|
578
|
+
} else if (!Number.isFinite(Number(literal))) {
|
|
579
|
+
return { path: at(), literal, kind: "overflow" };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- canonical serialization ------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Byte-for-byte reproducible JSON, matching the data plane's own canonical form
|
|
589
|
+
* (`json.dumps(..., ensure_ascii=True, separators=(",", ":"), sort_keys=True)`):
|
|
590
|
+
* sorted keys, compact separators, ASCII only. The digest recorded in
|
|
591
|
+
* dharta.toml is over these exact bytes, so a rebuild of unchanged sources must
|
|
592
|
+
* produce an unchanged file.
|
|
593
|
+
*
|
|
594
|
+
* Written out key by key rather than sorted into an object and handed to
|
|
595
|
+
* `JSON.stringify`, because a JavaScript object does not preserve the order you
|
|
596
|
+
* insert integer-like keys in: it emits those first, in ascending numeric
|
|
597
|
+
* order. `{"1","10","2","alpha"}` is Python's order and `{"1","2","10","alpha"}`
|
|
598
|
+
* is what an object round-trip produces, which is a different digest for the
|
|
599
|
+
* same catalog.
|
|
600
|
+
*
|
|
601
|
+
* Numbers are JavaScript doubles, and this writes back whatever `JSON.parse`
|
|
602
|
+
* produced. `1.0` therefore serializes as `1` and `-0.0` as `0` - the source
|
|
603
|
+
* spelling is already gone by the time the value gets here, and re-deriving it
|
|
604
|
+
* is not possible. Both forms are the same double on the data plane too, so
|
|
605
|
+
* this only ever narrows the spelling, never the value.
|
|
606
|
+
*
|
|
607
|
+
* Two shapes a double cannot carry at all: an integer beyond
|
|
608
|
+
* `Number.MAX_SAFE_INTEGER`, which arrives already rounded, and a float that
|
|
609
|
+
* overflows a double, which arrives as `Infinity` and would be written out as
|
|
610
|
+
* `null`. `findUnsafeNumberLiteral` refuses both in a component.json rather
|
|
611
|
+
* than letting the build ship a number the source never wrote.
|
|
612
|
+
*/
|
|
613
|
+
export function canonicalJson(value: unknown): string {
|
|
614
|
+
return escapeNonAscii(encodeCanonical(value));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function encodeCanonical(value: unknown): string {
|
|
618
|
+
if (Array.isArray(value)) return `[${value.map(encodeCanonical).join(",")}]`;
|
|
619
|
+
if (plainObject(value)) {
|
|
620
|
+
const members = Object.keys(value)
|
|
621
|
+
.sort(byCodePoint)
|
|
622
|
+
.map((key) => `${JSON.stringify(key)}:${encodeCanonical(value[key])}`);
|
|
623
|
+
return `{${members.join(",")}}`;
|
|
624
|
+
}
|
|
625
|
+
return JSON.stringify(value) ?? "null";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Python sorts by Unicode code point; JavaScript's default sort compares UTF-16
|
|
629
|
+
// code units, which orders astral-plane keys differently. Compare code points so
|
|
630
|
+
// a builder's exotic props_schema key cannot make the two planes disagree.
|
|
631
|
+
function byCodePoint(a: string, b: string): number {
|
|
632
|
+
const ca = Array.from(a);
|
|
633
|
+
const cb = Array.from(b);
|
|
634
|
+
for (let i = 0; i < Math.min(ca.length, cb.length); i++) {
|
|
635
|
+
const delta = (ca[i].codePointAt(0) ?? 0) - (cb[i].codePointAt(0) ?? 0);
|
|
636
|
+
if (delta !== 0) return delta;
|
|
637
|
+
}
|
|
638
|
+
return ca.length - cb.length;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function escapeNonAscii(json: string): string {
|
|
642
|
+
// U+007F..U+FFFF, matching Python's ensure_ascii, which escapes everything
|
|
643
|
+
// outside the printable ASCII range - DEL included.
|
|
644
|
+
return json.replace(
|
|
645
|
+
/[\u007f-\uffff]/g,
|
|
646
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export function sha256Hex(bytes: Buffer | string): string {
|
|
651
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// --- loading ----------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
/** A `dharta.toml` read once and reused by every genui code path. */
|
|
657
|
+
export interface ManifestSource {
|
|
658
|
+
path: string;
|
|
659
|
+
text: string;
|
|
660
|
+
parsed: Record<string, unknown>;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export interface LoadedGenui extends ManifestSource {
|
|
664
|
+
section: GenuiSection | null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Read `dir/dharta.toml`. Throws when it is missing or unparseable. */
|
|
668
|
+
export async function readManifest(dir: string): Promise<ManifestSource> {
|
|
669
|
+
const manifestPath = path.join(dir, "dharta.toml");
|
|
670
|
+
let text: string;
|
|
671
|
+
try {
|
|
672
|
+
text = await fs.readFile(manifestPath, "utf8");
|
|
673
|
+
} catch {
|
|
674
|
+
throw new GenuiError(
|
|
675
|
+
`No dharta.toml at ${manifestPath}. Run this from an agent folder, or pass its path.`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
return { path: manifestPath, text, parsed: TOML.parse(text) as Record<string, unknown> };
|
|
680
|
+
} catch (error) {
|
|
681
|
+
throw new GenuiError(`Could not parse ${manifestPath}: ${(error as Error).message}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Read and parse an agent's `[genui]` section once. Every command path threads
|
|
687
|
+
* the result rather than re-reading the manifest: `dharta dev`'s doctor already
|
|
688
|
+
* holds a parsed one and hands it straight in.
|
|
689
|
+
*/
|
|
690
|
+
export async function loadGenui(
|
|
691
|
+
source: string | ManifestSource,
|
|
692
|
+
options: ParseGenuiOptions = {},
|
|
693
|
+
): Promise<LoadedGenui> {
|
|
694
|
+
const manifest = typeof source === "string" ? await readManifest(source) : source;
|
|
695
|
+
return {
|
|
696
|
+
...manifest,
|
|
697
|
+
section: parseGenuiSection(manifest.parsed, { ...options, text: manifest.text }),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Release-relative paths are split on "/" only, matching pathlib's Path.parts
|
|
702
|
+
// on the POSIX data plane: there, a backslash is an ordinary filename
|
|
703
|
+
// character, and treating it as a separator here would reject paths the server
|
|
704
|
+
// accepts.
|
|
705
|
+
function relativeParts(relative: string): string[] {
|
|
706
|
+
return relative.split("/").filter((part) => part !== "" && part !== ".");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// A release-relative path may not escape the agent folder - the data plane
|
|
710
|
+
// rejects the same thing in load_genui_bundle.
|
|
711
|
+
function requireInsideRelease(relative: string, label: string): string {
|
|
712
|
+
if (
|
|
713
|
+
path.isAbsolute(relative) ||
|
|
714
|
+
relative.startsWith("/") ||
|
|
715
|
+
relativeParts(relative).includes("..")
|
|
716
|
+
) {
|
|
717
|
+
throw new GenuiError(`[genui] ${label} must stay inside the release: ${relative}`);
|
|
718
|
+
}
|
|
719
|
+
return relative;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Walk a release-relative path's parent components, refusing a symlink or a
|
|
724
|
+
* non-directory exactly the way bundle.py's `_read_regular_file` does. Returns
|
|
725
|
+
* the absolute leaf path.
|
|
726
|
+
*
|
|
727
|
+
* `withPath` names the offending component in the message. The read path leaves
|
|
728
|
+
* it off so a pre-flight refusal reads exactly like the release rejection it
|
|
729
|
+
* predicts; the build turns it on, because there the developer needs to know
|
|
730
|
+
* which link to remove.
|
|
731
|
+
*/
|
|
732
|
+
async function walkBundleParents(
|
|
733
|
+
root: string,
|
|
734
|
+
relative: string,
|
|
735
|
+
options: { withPath: boolean; onMissingParent: "throw" | "stop" },
|
|
736
|
+
): Promise<string | null> {
|
|
737
|
+
const parts = relativeParts(relative);
|
|
738
|
+
let cursor = root;
|
|
739
|
+
for (const part of parts.slice(0, -1)) {
|
|
740
|
+
cursor = path.join(cursor, part);
|
|
741
|
+
let stats;
|
|
742
|
+
try {
|
|
743
|
+
stats = await fs.lstat(cursor);
|
|
744
|
+
} catch {
|
|
745
|
+
// Python reaches a missing parent as "crosses a non-directory". The
|
|
746
|
+
// rejection is the same either way; which sentence is more useful
|
|
747
|
+
// depends on the caller, so it decides.
|
|
748
|
+
if (options.onMissingParent === "throw") {
|
|
749
|
+
throw new GenuiError(missingBundleMessage(relative));
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
753
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
754
|
+
throw new GenuiError(
|
|
755
|
+
options.withPath
|
|
756
|
+
? `${CROSSES_SYMLINK}: ${path.relative(root, cursor)}`
|
|
757
|
+
: CROSSES_SYMLINK,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return path.join(root, ...parts);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function missingBundleMessage(relative: string): string {
|
|
765
|
+
return `[genui] bundle does not exist: ${relative} - run \`dharta genui build\` to build it.`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Read the bundle exactly the way bundle.py's `_read_regular_file` does: no
|
|
770
|
+
* symlink anywhere on the path, a regular file at the leaf, and nothing larger
|
|
771
|
+
* than the cap. `lstat` throughout, because the point is the link itself, not
|
|
772
|
+
* what it points at - a symlinked bundle resolves fine locally and is refused
|
|
773
|
+
* by the release validator's O_NOFOLLOW open.
|
|
774
|
+
*/
|
|
775
|
+
async function readBundleBytes(root: string, relative: string): Promise<Buffer> {
|
|
776
|
+
const file = await walkBundleParents(root, relative, {
|
|
777
|
+
withPath: false,
|
|
778
|
+
onMissingParent: "throw",
|
|
779
|
+
});
|
|
780
|
+
let stats;
|
|
781
|
+
try {
|
|
782
|
+
stats = await fs.lstat(file!);
|
|
783
|
+
} catch {
|
|
784
|
+
throw new GenuiError(missingBundleMessage(relative));
|
|
785
|
+
}
|
|
786
|
+
if (stats.isSymbolicLink() || !stats.isFile()) throw new GenuiError(NOT_A_REGULAR_FILE);
|
|
787
|
+
if (stats.size > MAX_BUNDLE_BYTES) {
|
|
788
|
+
throw new GenuiError(`${NOT_A_REGULAR_FILE} no larger than 5 MiB`);
|
|
789
|
+
}
|
|
790
|
+
return fs.readFile(file!);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* The write-side of the same policy. Without it the build happily writes
|
|
795
|
+
* THROUGH a symlink that every later read then refuses, so `build` would report
|
|
796
|
+
* success and `check` would immediately contradict it.
|
|
797
|
+
*/
|
|
798
|
+
async function assertBundleWritable(root: string, relative: string): Promise<void> {
|
|
799
|
+
const file = await walkBundleParents(root, relative, {
|
|
800
|
+
withPath: true,
|
|
801
|
+
onMissingParent: "stop", // mkdir will create it; nothing to inspect yet
|
|
802
|
+
});
|
|
803
|
+
if (file === null) return;
|
|
804
|
+
const stats = await fs.lstat(file).catch(() => null);
|
|
805
|
+
if (stats === null) return; // does not exist yet: the normal first build
|
|
806
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
807
|
+
throw new GenuiError(`${NOT_A_REGULAR_FILE}: ${relative}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// --- check ------------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
export interface GenuiCheck {
|
|
814
|
+
manifest: string;
|
|
815
|
+
/** Null when there is no [genui] section, or when it failed to parse. */
|
|
816
|
+
section: GenuiSection | null;
|
|
817
|
+
/** Every reason the data plane would reject this release. Empty = accepted. */
|
|
818
|
+
problems: string[];
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Report every reason the data plane would reject this agent's `[genui]`
|
|
823
|
+
* declaration. Shared by `dharta genui check`, `dharta dev`'s doctor
|
|
824
|
+
* (warnings), and the deploy pre-flight (refusal) so all three say the same
|
|
825
|
+
* thing. Pass an already-read manifest to avoid a second read.
|
|
826
|
+
*/
|
|
827
|
+
export async function checkGenui(source: string | ManifestSource): Promise<GenuiCheck> {
|
|
828
|
+
let manifest: ManifestSource;
|
|
829
|
+
try {
|
|
830
|
+
manifest = typeof source === "string" ? await readManifest(source) : source;
|
|
831
|
+
} catch {
|
|
832
|
+
// A missing or unparseable dharta.toml is not a genui problem: the doctor
|
|
833
|
+
// and the deploy both report it in their own words, on their own path.
|
|
834
|
+
return { manifest: path.join(source as string, "dharta.toml"), section: null, problems: [] };
|
|
835
|
+
}
|
|
836
|
+
const dir = path.dirname(manifest.path);
|
|
837
|
+
|
|
838
|
+
let section: GenuiSection | null;
|
|
839
|
+
try {
|
|
840
|
+
section = parseGenuiSection(manifest.parsed, { text: manifest.text });
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return { manifest: manifest.path, section: null, problems: [(error as Error).message] };
|
|
843
|
+
}
|
|
844
|
+
// No section: generative UI is off. Disabled: the data plane skips the
|
|
845
|
+
// catalog entirely (releases.py _validate_genui_tree), so nothing below can
|
|
846
|
+
// fail a deploy.
|
|
847
|
+
if (section === null || !section.enabled) {
|
|
848
|
+
return { manifest: manifest.path, section, problems: [] };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const problems: string[] = [];
|
|
852
|
+
try {
|
|
853
|
+
// A Tier-1-only catalog carries no file, but the data plane still
|
|
854
|
+
// synthesizes and validates a payload for it, so we do too.
|
|
855
|
+
if (section.bundle === null) {
|
|
856
|
+
validateBundlePayload(synthesizeBundlePayload(section), section);
|
|
857
|
+
} else {
|
|
858
|
+
requireInsideRelease(section.bundle, "bundle");
|
|
859
|
+
const raw = await readBundleBytes(dir, section.bundle);
|
|
860
|
+
const digest = sha256Hex(raw);
|
|
861
|
+
if (digest !== section.bundleSha256) {
|
|
862
|
+
problems.push(
|
|
863
|
+
"[genui] bundle bytes do not match bundle_sha256 " +
|
|
864
|
+
`(${section.bundle} is ${digest}, dharta.toml records ${section.bundleSha256}) - ` +
|
|
865
|
+
"run `dharta genui build` to rebuild it and re-record the digest.",
|
|
866
|
+
);
|
|
867
|
+
} else {
|
|
868
|
+
const text = decodeUtf8(raw, `[genui] bundle must be valid UTF-8 JSON`);
|
|
869
|
+
let payload: unknown;
|
|
870
|
+
try {
|
|
871
|
+
payload = JSON.parse(text);
|
|
872
|
+
} catch {
|
|
873
|
+
throw new GenuiError("[genui] bundle must be valid UTF-8 JSON");
|
|
874
|
+
}
|
|
875
|
+
validateBundlePayload(payload, section, text);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
} catch (error) {
|
|
879
|
+
problems.push((error as Error).message);
|
|
880
|
+
}
|
|
881
|
+
return { manifest: manifest.path, section, problems };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Decode UTF-8 strictly. Node's default replaces an invalid byte with U+FFFD,
|
|
885
|
+
// which would silently ship a mangled worker or document.
|
|
886
|
+
function decodeUtf8(bytes: Buffer, message: string): string {
|
|
887
|
+
try {
|
|
888
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
889
|
+
} catch {
|
|
890
|
+
throw new GenuiError(message);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export interface GenuiPreflightOptions {
|
|
895
|
+
/**
|
|
896
|
+
* The files this deploy will actually ship, release-relative. Deploy sends a
|
|
897
|
+
* Git-filtered tree (committed HEAD, or a gitignore-honoring tarball) while
|
|
898
|
+
* every check here reads the WORKING tree, so an ignored bundle passes
|
|
899
|
+
* locally and arrives missing. Asking the deploy's own file list - rather
|
|
900
|
+
* than re-deriving the answer from ignore rules - gets force-added files and
|
|
901
|
+
* non-ASCII names right for free. Optional: without it the pre-flight simply
|
|
902
|
+
* skips that question.
|
|
903
|
+
*/
|
|
904
|
+
deployFiles?: () => Promise<string[]>;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Deploy pre-flight: refuse locally, before anything is pushed or uploaded,
|
|
909
|
+
* with the message the release validator would return.
|
|
910
|
+
*/
|
|
911
|
+
export async function genuiPreflight(
|
|
912
|
+
dir: string,
|
|
913
|
+
options: GenuiPreflightOptions = {},
|
|
914
|
+
): Promise<void> {
|
|
915
|
+
const { section, problems } = await checkGenui(dir);
|
|
916
|
+
if (options.deployFiles && section?.enabled && section.bundle) {
|
|
917
|
+
problems.push(...(await missingFromDeploy(dir, section.bundle, options.deployFiles)));
|
|
918
|
+
}
|
|
919
|
+
if (problems.length === 0) return;
|
|
920
|
+
throw new Error(
|
|
921
|
+
"Generative-UI pre-flight failed - the release would be rejected:\n" +
|
|
922
|
+
problems.map((problem) => ` ${problem}`).join("\n") +
|
|
923
|
+
"\n\n Fix the [genui] section in dharta.toml (or run `dharta genui build`), then re-deploy.",
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function missingFromDeploy(
|
|
928
|
+
dir: string,
|
|
929
|
+
bundle: string,
|
|
930
|
+
deployFiles: () => Promise<string[]>,
|
|
931
|
+
): Promise<string[]> {
|
|
932
|
+
// A bundle that is not on disk is already reported by checkGenui, with the
|
|
933
|
+
// fix that actually applies: build it. "git is not shipping it" is true of
|
|
934
|
+
// every file that does not exist, so saying it here would name a second
|
|
935
|
+
// cause that is only a consequence of the first.
|
|
936
|
+
const onDisk = await fs
|
|
937
|
+
.stat(path.join(dir, ...relativeParts(bundle)))
|
|
938
|
+
.then(() => true)
|
|
939
|
+
.catch(() => false);
|
|
940
|
+
if (!onDisk) return [];
|
|
941
|
+
|
|
942
|
+
// Listing can fail for reasons that are the deploy's own to report (a nested
|
|
943
|
+
// repository, a git that will not run). An advisory pre-flight never blocks
|
|
944
|
+
// on its inability to answer.
|
|
945
|
+
const files = await deployFiles().catch(() => null);
|
|
946
|
+
if (files === null) return [];
|
|
947
|
+
// Compare normalized paths, not the two spellings of one path. A bundle
|
|
948
|
+
// declared with a leading "./" or a doubled slash names the file git lists
|
|
949
|
+
// plainly, on both planes: pathlib's Path.parts drops the same "." and empty
|
|
950
|
+
// segments relativeParts does. Comparing the raw strings would refuse a
|
|
951
|
+
// bundle the deploy is carrying, with a git-ignore diagnosis that is wrong.
|
|
952
|
+
const wanted = relativeParts(bundle).join("/");
|
|
953
|
+
if (files.some((file) => relativeParts(file).join("/") === wanted)) return [];
|
|
954
|
+
return [
|
|
955
|
+
`${wanted} is not among the files this deploy ships, so the release would fail on a ` +
|
|
956
|
+
"missing [genui] bundle. Git ignore rules exclude it: unignore it, or force-add it " +
|
|
957
|
+
`with \`git add -f ${wanted}\`, then commit it and re-run \`dharta deploy\`.`,
|
|
958
|
+
];
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export interface GenuiStatus {
|
|
962
|
+
manifest: string;
|
|
963
|
+
/** False when there is no [genui] section: generative UI is off for this agent. */
|
|
964
|
+
configured: boolean;
|
|
965
|
+
catalogId?: string;
|
|
966
|
+
version?: number;
|
|
967
|
+
enabled?: boolean;
|
|
968
|
+
bundle?: string | null;
|
|
969
|
+
bundleSha256?: string | null;
|
|
970
|
+
components: GenuiComponent[];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** `dharta genui check`: the structured result. Throws when something is wrong. */
|
|
974
|
+
export async function genuiCheckData(dir: string): Promise<GenuiStatus> {
|
|
975
|
+
const manifest = await readManifest(dir); // a missing manifest is an error here
|
|
976
|
+
const { section, problems } = await checkGenui(manifest);
|
|
977
|
+
if (problems.length > 0) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
"[genui] check failed - a deploy would be rejected:\n" +
|
|
980
|
+
problems.map((problem) => ` ${problem}`).join("\n"),
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
if (section === null) {
|
|
984
|
+
return { manifest: manifest.path, configured: false, components: [] };
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
manifest: manifest.path,
|
|
988
|
+
configured: true,
|
|
989
|
+
catalogId: section.catalogId,
|
|
990
|
+
version: section.version,
|
|
991
|
+
enabled: section.enabled,
|
|
992
|
+
bundle: section.bundle,
|
|
993
|
+
bundleSha256: section.bundleSha256,
|
|
994
|
+
components: section.components,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
export function formatGenuiStatus(status: GenuiStatus): string {
|
|
999
|
+
if (!status.configured) {
|
|
1000
|
+
return `No [genui] section in ${status.manifest} - generative UI is off for this agent.`;
|
|
1001
|
+
}
|
|
1002
|
+
const count = status.components.length;
|
|
1003
|
+
const lines = [
|
|
1004
|
+
`✓ [genui] catalog '${status.catalogId}' v${status.version}: ` +
|
|
1005
|
+
`${count} component${count === 1 ? "" : "s"} (${describeTiers(status.components)})`,
|
|
1006
|
+
];
|
|
1007
|
+
if (status.enabled === false) {
|
|
1008
|
+
// The catalog is registered but not served, and neither the data plane nor
|
|
1009
|
+
// this check read its bundle. Saying the digest matches would be a claim
|
|
1010
|
+
// nothing verified.
|
|
1011
|
+
lines.push(" enabled = false: the catalog is not served, so its bundle was not checked.");
|
|
1012
|
+
} else if (status.bundle === null) {
|
|
1013
|
+
lines.push(
|
|
1014
|
+
" No bundle: every component is Tier 1, so Dharta renders them with its own primitives.",
|
|
1015
|
+
);
|
|
1016
|
+
} else {
|
|
1017
|
+
lines.push(` Bundle ${status.bundle} matches bundle_sha256 ${status.bundleSha256}.`);
|
|
1018
|
+
}
|
|
1019
|
+
return lines.join("\n");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function describeTiers(components: GenuiComponent[]): string {
|
|
1023
|
+
const parts = ([1, 2, 3] as const)
|
|
1024
|
+
.map((tier) => ({ tier, count: components.filter((c) => c.tier === tier).length }))
|
|
1025
|
+
.filter((entry) => entry.count > 0)
|
|
1026
|
+
.map((entry) => `${entry.count} Tier ${entry.tier}`);
|
|
1027
|
+
return parts.length > 0 ? parts.join(", ") : "none";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// --- build ------------------------------------------------------------------
|
|
1031
|
+
|
|
1032
|
+
export interface BuildResult {
|
|
1033
|
+
status: "built" | "nothing-to-build";
|
|
1034
|
+
/** Release-relative bundle path, when one was written. */
|
|
1035
|
+
bundle?: string;
|
|
1036
|
+
digest?: string;
|
|
1037
|
+
components: number;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const SPEC_KEYS = ["tier", "builtin", "props_schema", "events"];
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Compile `[genui.build] source` into a validated bundle payload. Nothing is
|
|
1044
|
+
* written: this is the seam at the data plane's contract, so a caller can
|
|
1045
|
+
* assert on the exact object a release would carry.
|
|
1046
|
+
*/
|
|
1047
|
+
export async function compileBundlePayload(
|
|
1048
|
+
section: GenuiSection,
|
|
1049
|
+
dir: string,
|
|
1050
|
+
): Promise<Record<string, unknown>> {
|
|
1051
|
+
requireInsideRelease(section.source, "source");
|
|
1052
|
+
const sourceDir = path.join(dir, section.source);
|
|
1053
|
+
await requireExactMatch(section, sourceDir, await discoverComponents(sourceDir, section.source));
|
|
1054
|
+
|
|
1055
|
+
const components = await Promise.all(
|
|
1056
|
+
section.components.map((component) => compileComponent(sourceDir, section.source, component)),
|
|
1057
|
+
);
|
|
1058
|
+
const payload = { format: BUNDLE_FORMAT, catalog_id: section.catalogId, version: section.version, components };
|
|
1059
|
+
validateBundlePayload(payload, section);
|
|
1060
|
+
return payload;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Compile `[genui.build] source` into the bundle at `[genui] bundle` and record
|
|
1065
|
+
* its digest in dharta.toml. Validates everything the data plane validates
|
|
1066
|
+
* before writing, so a bad catalog fails here rather than at deploy.
|
|
1067
|
+
*/
|
|
1068
|
+
export async function buildGenui(
|
|
1069
|
+
dir: string,
|
|
1070
|
+
log: (line: string) => void,
|
|
1071
|
+
): Promise<BuildResult> {
|
|
1072
|
+
const { path: manifestPath, text, section } = await loadGenui(dir, { requireDigest: false });
|
|
1073
|
+
if (section === null) {
|
|
1074
|
+
throw new GenuiError(
|
|
1075
|
+
`No [genui] section in ${manifestPath}. Add one (see the commented block a ` +
|
|
1076
|
+
"scaffolded dharta.toml ships with) before building a component bundle.",
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (section.bundle === null) {
|
|
1081
|
+
// parseGenuiSection already rejected a Tier-2/3 component without a bundle,
|
|
1082
|
+
// so reaching here means Tier-1 only. Nothing is written, but the catalog
|
|
1083
|
+
// the data plane will synthesize still has to be valid.
|
|
1084
|
+
validateBundlePayload(synthesizeBundlePayload(section), section);
|
|
1085
|
+
log(
|
|
1086
|
+
`Nothing to build: [genui] declares no bundle and all ${section.components.length} ` +
|
|
1087
|
+
"component(s) are Tier 1, which Dharta renders with its own primitives.",
|
|
1088
|
+
);
|
|
1089
|
+
return { status: "nothing-to-build", components: section.components.length };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
requireInsideRelease(section.bundle, "bundle");
|
|
1093
|
+
const payload = await compileBundlePayload(section, dir);
|
|
1094
|
+
const digest = await writeBundle(dir, { manifestPath, text, section }, payload);
|
|
1095
|
+
|
|
1096
|
+
log(
|
|
1097
|
+
`✓ Built ${section.components.length} component${section.components.length === 1 ? "" : "s"} ` +
|
|
1098
|
+
`(${describeTiers(section.components)}) -> ${section.bundle}`,
|
|
1099
|
+
);
|
|
1100
|
+
log(` bundle_sha256 = "${digest}" recorded in dharta.toml`);
|
|
1101
|
+
if (!section.enabled) {
|
|
1102
|
+
log(" Note: enabled = false, so this catalog is registered but not served.");
|
|
1103
|
+
}
|
|
1104
|
+
return {
|
|
1105
|
+
status: "built",
|
|
1106
|
+
bundle: section.bundle,
|
|
1107
|
+
digest,
|
|
1108
|
+
components: section.components.length,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function writeBundle(
|
|
1113
|
+
dir: string,
|
|
1114
|
+
manifest: { manifestPath: string; text: string; section: GenuiSection },
|
|
1115
|
+
payload: Record<string, unknown>,
|
|
1116
|
+
): Promise<string> {
|
|
1117
|
+
const { manifestPath, text, section } = manifest;
|
|
1118
|
+
const relative = section.bundle!;
|
|
1119
|
+
const bytes = Buffer.from(`${canonicalJson(payload)}\n`, "utf8");
|
|
1120
|
+
if (bytes.byteLength > MAX_BUNDLE_BYTES) {
|
|
1121
|
+
throw new GenuiError(
|
|
1122
|
+
`Built bundle is ${bytes.byteLength} bytes; the limit is ${MAX_BUNDLE_BYTES}. ` +
|
|
1123
|
+
"Move the largest component to a leaner implementation or split the catalog.",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
const digest = sha256Hex(bytes);
|
|
1127
|
+
|
|
1128
|
+
// Refuse to write through a symlink the read side would then reject: `build`
|
|
1129
|
+
// must not report success that `check` immediately contradicts.
|
|
1130
|
+
await assertBundleWritable(dir, relative);
|
|
1131
|
+
|
|
1132
|
+
// Record the digest, in memory, before anything lands. A manifest whose
|
|
1133
|
+
// [genui] table cannot be patched (an inline table, or dotted keys) must fail
|
|
1134
|
+
// with setGenuiBundleSha256's hand-edit guidance and leave no half-built tree:
|
|
1135
|
+
// a bundle on disk with no matching digest is exactly the state `check`
|
|
1136
|
+
// exists to catch.
|
|
1137
|
+
const patched = setGenuiBundleSha256(text, digest);
|
|
1138
|
+
|
|
1139
|
+
const bundlePath = path.join(dir, ...relativeParts(relative));
|
|
1140
|
+
await fs.mkdir(path.dirname(bundlePath), { recursive: true });
|
|
1141
|
+
await fs.writeFile(bundlePath, bytes);
|
|
1142
|
+
|
|
1143
|
+
// Read back what actually landed, BEFORE dharta.toml records the digest. An
|
|
1144
|
+
// editor, a filter driver, or a CRLF checkout can change the bytes between
|
|
1145
|
+
// write and hash, and a manifest patched first would be left naming a digest
|
|
1146
|
+
// the file on disk does not have - the state this check exists to prevent.
|
|
1147
|
+
const written = sha256Hex(await fs.readFile(bundlePath));
|
|
1148
|
+
if (written !== digest) {
|
|
1149
|
+
throw new GenuiError(
|
|
1150
|
+
`Wrote ${relative} but it hashes to ${written}, not the expected ${digest}. ` +
|
|
1151
|
+
"Something is rewriting the file on disk (a Git filter or editor hook is the " +
|
|
1152
|
+
"usual cause); the release would be rejected. dharta.toml was left untouched, so " +
|
|
1153
|
+
"it still records whatever digest it had before this build.",
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
try {
|
|
1158
|
+
await fs.writeFile(manifestPath, patched, "utf8");
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
// The bundle is on disk and dharta.toml still names the previous digest,
|
|
1161
|
+
// so `check` reports a mismatch until the two agree. Nothing is lost: the
|
|
1162
|
+
// build is a pure function of the sources, so a rerun produces the same
|
|
1163
|
+
// bytes and records the same digest.
|
|
1164
|
+
throw new GenuiError(
|
|
1165
|
+
`Wrote ${relative}, but could not record its digest in ${manifestPath}: ` +
|
|
1166
|
+
`${(error as Error).message}. The bundle is built and correct; only the manifest ` +
|
|
1167
|
+
"is behind, so `dharta genui check` reports a digest mismatch until it is written. " +
|
|
1168
|
+
"Fix the cause (a read-only file, a full disk), then run `dharta genui build` " +
|
|
1169
|
+
"again - it rebuilds the same bytes, so repeating it is safe.",
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
return digest;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Every component directory under `sourceDir`, named by its path relative to
|
|
1177
|
+
* it. A directory holding a `component.json` IS a component and is not
|
|
1178
|
+
* descended into, so a namespaced name like `charts/Bar` (legal in the
|
|
1179
|
+
* identifier both planes share) lives at `charts/Bar/component.json`.
|
|
1180
|
+
*
|
|
1181
|
+
* Unlike the bundle path these follow symlinks - the source layout is a CLI
|
|
1182
|
+
* concern the data plane never sees, and `readdir`'s Dirent would report a
|
|
1183
|
+
* symlinked component folder as a link rather than a directory and silently
|
|
1184
|
+
* hide it.
|
|
1185
|
+
*/
|
|
1186
|
+
async function discoverComponents(sourceDir: string, label: string): Promise<string[]> {
|
|
1187
|
+
const found: string[] = [];
|
|
1188
|
+
const walk = async (absolute: string, relative: string): Promise<void> => {
|
|
1189
|
+
let names: string[];
|
|
1190
|
+
try {
|
|
1191
|
+
names = await fs.readdir(absolute);
|
|
1192
|
+
} catch {
|
|
1193
|
+
if (relative !== "") return;
|
|
1194
|
+
throw new GenuiError(
|
|
1195
|
+
`[genui.build] source folder not found: ${label}. Create one directory per component ` +
|
|
1196
|
+
`there (for example ${label}/Card/${COMPONENT_SPEC_FILE}), or set ` +
|
|
1197
|
+
'`source = "<path>"` in [genui.build].',
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
for (const name of names.sort()) {
|
|
1201
|
+
// Dot-directories are tooling, never components.
|
|
1202
|
+
if (name.startsWith(".")) continue;
|
|
1203
|
+
const child = path.join(absolute, name);
|
|
1204
|
+
const stats = await fs.stat(child).catch(() => null);
|
|
1205
|
+
if (!stats?.isDirectory()) continue;
|
|
1206
|
+
const childRelative = relative === "" ? name : `${relative}/${name}`;
|
|
1207
|
+
const isComponent = await fs
|
|
1208
|
+
.stat(path.join(child, COMPONENT_SPEC_FILE))
|
|
1209
|
+
.then((spec) => spec.isFile())
|
|
1210
|
+
.catch(() => false);
|
|
1211
|
+
if (isComponent) found.push(childRelative);
|
|
1212
|
+
else await walk(child, childRelative);
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
await walk(sourceDir, "");
|
|
1216
|
+
return found.sort();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// The manifest's `components` array is the builder's declaration of what this
|
|
1220
|
+
// release ships and at what trust tier. A build never edits it: a mismatch is a
|
|
1221
|
+
// question only the builder can answer, so name both sides and stop.
|
|
1222
|
+
async function requireExactMatch(
|
|
1223
|
+
section: GenuiSection,
|
|
1224
|
+
sourceDir: string,
|
|
1225
|
+
found: string[],
|
|
1226
|
+
): Promise<void> {
|
|
1227
|
+
const declared = new Set(section.components.map((c) => c.name));
|
|
1228
|
+
const onDisk = new Set(found);
|
|
1229
|
+
const missing = [...declared].filter((name) => !onDisk.has(name)).sort();
|
|
1230
|
+
const extra = found.filter((name) => !declared.has(name));
|
|
1231
|
+
if (missing.length === 0 && extra.length === 0) return;
|
|
1232
|
+
|
|
1233
|
+
// A component is DISCOVERED by its component.json, so a folder that exists
|
|
1234
|
+
// but has no spec reads as "missing" here. Say the more useful thing.
|
|
1235
|
+
for (const name of missing) {
|
|
1236
|
+
const folder = componentDir(sourceDir, section.source, name);
|
|
1237
|
+
const exists = await fs.stat(folder).then((st) => st.isDirectory()).catch(() => false);
|
|
1238
|
+
if (exists) {
|
|
1239
|
+
throw new GenuiError(
|
|
1240
|
+
`[genui] component '${name}': ${section.source}/${name}/${COMPONENT_SPEC_FILE} is missing.`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const lines = ["[genui] source folders do not match the components declared in dharta.toml."];
|
|
1246
|
+
if (missing.length > 0) {
|
|
1247
|
+
lines.push(` Declared but missing from ${section.source}/: ${missing.join(", ")}`);
|
|
1248
|
+
}
|
|
1249
|
+
if (extra.length > 0) {
|
|
1250
|
+
lines.push(` Present in ${section.source}/ but not declared: ${extra.join(", ")}`);
|
|
1251
|
+
}
|
|
1252
|
+
lines.push(
|
|
1253
|
+
" Add the missing folders, or add/remove entries in the [genui] components array.",
|
|
1254
|
+
" `dharta genui build` never edits that array - the trust tier is your declaration.",
|
|
1255
|
+
);
|
|
1256
|
+
throw new GenuiError(lines.join("\n"));
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
interface ComponentSpec {
|
|
1260
|
+
builtin?: string;
|
|
1261
|
+
props_schema?: Record<string, unknown>;
|
|
1262
|
+
events?: Record<string, unknown>;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// A component name is only ever a JSON string on the data plane, but the CLI
|
|
1266
|
+
// turns it into a path - and the identifier both planes share permits "." and
|
|
1267
|
+
// "/", so "a/../b" is a legal name. Resolve here, once, and refuse to climb out
|
|
1268
|
+
// of the source folder.
|
|
1269
|
+
function componentDir(sourceDir: string, label: string, name: string): string {
|
|
1270
|
+
const parts = relativeParts(name);
|
|
1271
|
+
if (name.startsWith("/") || parts.includes("..")) {
|
|
1272
|
+
throw new GenuiError(
|
|
1273
|
+
`[genui] component '${name}' cannot be a source path: it escapes ${label}/.`,
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
return path.join(sourceDir, ...parts);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async function compileComponent(
|
|
1280
|
+
sourceDir: string,
|
|
1281
|
+
label: string,
|
|
1282
|
+
component: GenuiComponent,
|
|
1283
|
+
): Promise<Record<string, unknown>> {
|
|
1284
|
+
const dir = componentDir(sourceDir, label, component.name);
|
|
1285
|
+
const where = `${label}/${component.name}/${COMPONENT_SPEC_FILE}`;
|
|
1286
|
+
|
|
1287
|
+
let raw: string;
|
|
1288
|
+
try {
|
|
1289
|
+
raw = await fs.readFile(path.join(dir, COMPONENT_SPEC_FILE), "utf8");
|
|
1290
|
+
} catch {
|
|
1291
|
+
throw new GenuiError(`[genui] component '${component.name}': ${where} is missing.`);
|
|
1292
|
+
}
|
|
1293
|
+
let parsed: unknown;
|
|
1294
|
+
try {
|
|
1295
|
+
parsed = JSON.parse(raw);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
throw new GenuiError(
|
|
1298
|
+
`[genui] component '${component.name}': ${where} is not valid JSON (${(error as Error).message}).`,
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
if (!plainObject(parsed)) {
|
|
1302
|
+
throw new GenuiError(`[genui] component '${component.name}': ${where} must be a JSON object.`);
|
|
1303
|
+
}
|
|
1304
|
+
const unsafe = findUnsafeNumberLiteral(raw);
|
|
1305
|
+
if (unsafe !== null) {
|
|
1306
|
+
throw new GenuiError(
|
|
1307
|
+
`[genui] component '${component.name}': ${where} has the number ${unsafe.literal} at ` +
|
|
1308
|
+
`${unsafe.path}, which ` +
|
|
1309
|
+
(unsafe.kind === "integer"
|
|
1310
|
+
? "is outside the range JavaScript represents exactly (up to " +
|
|
1311
|
+
`${Number.MAX_SAFE_INTEGER}). The build would round it and ship a number you ` +
|
|
1312
|
+
"never wrote, while the data plane keeps yours exactly."
|
|
1313
|
+
: "overflows a JavaScript double to Infinity. The build would ship `null` where " +
|
|
1314
|
+
"you wrote a number, while the data plane reads the same literal as a float " +
|
|
1315
|
+
"and keeps it.") +
|
|
1316
|
+
" Write it as a string, or use a number a double can hold.",
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
const unknownKeys = Object.keys(parsed).filter((key) => !SPEC_KEYS.includes(key));
|
|
1320
|
+
if (unknownKeys.length > 0) {
|
|
1321
|
+
throw new GenuiError(
|
|
1322
|
+
`[genui] component '${component.name}': ${where} has unknown key(s) ` +
|
|
1323
|
+
`${unknownKeys.join(", ")}. Supported: ${SPEC_KEYS.join(", ")}.`,
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
const tier = parsed.tier;
|
|
1327
|
+
if (tier !== 1 && tier !== 2 && tier !== 3) {
|
|
1328
|
+
throw new GenuiError(`[genui] component '${component.name}': ${where} needs tier 1, 2, or 3.`);
|
|
1329
|
+
}
|
|
1330
|
+
if (tier !== component.tier) {
|
|
1331
|
+
throw new GenuiError(
|
|
1332
|
+
`[genui] component '${component.name}': ${where} declares tier ${tier} but ` +
|
|
1333
|
+
`dharta.toml declares tier ${component.tier}. The manifest is the trust declaration; ` +
|
|
1334
|
+
"make them agree.",
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
if (parsed.builtin !== undefined && typeof parsed.builtin !== "string") {
|
|
1338
|
+
throw new GenuiError(
|
|
1339
|
+
`[genui] component '${component.name}': ${where} builtin must be a string.`,
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
for (const key of ["props_schema", "events"] as const) {
|
|
1343
|
+
if (parsed[key] !== undefined && !plainObject(parsed[key])) {
|
|
1344
|
+
throw new GenuiError(
|
|
1345
|
+
`[genui] component '${component.name}': ${where} ${key} must be an object.`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
const spec: ComponentSpec = {
|
|
1350
|
+
builtin: parsed.builtin as string | undefined,
|
|
1351
|
+
props_schema: parsed.props_schema as Record<string, unknown> | undefined,
|
|
1352
|
+
events: parsed.events as Record<string, unknown> | undefined,
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
const out: Record<string, unknown> = { name: component.name, tier: component.tier };
|
|
1356
|
+
if (spec.props_schema !== undefined) out.props_schema = spec.props_schema;
|
|
1357
|
+
if (spec.events !== undefined) out.events = spec.events;
|
|
1358
|
+
out.implementation = await readImplementation(dir, label, component, spec);
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
async function readImplementation(
|
|
1363
|
+
dir: string,
|
|
1364
|
+
label: string,
|
|
1365
|
+
component: GenuiComponent,
|
|
1366
|
+
spec: ComponentSpec,
|
|
1367
|
+
): Promise<Record<string, unknown>> {
|
|
1368
|
+
if (component.tier === 1) {
|
|
1369
|
+
// A Tier-1 component is an alias for a Dharta builtin; the common case is
|
|
1370
|
+
// that the folder is named after the builtin it aliases.
|
|
1371
|
+
return { builtin: spec.builtin ?? component.name };
|
|
1372
|
+
}
|
|
1373
|
+
if (spec.builtin !== undefined) {
|
|
1374
|
+
throw new GenuiError(
|
|
1375
|
+
`[genui] component '${component.name}': builtin is Tier-1 only; ` +
|
|
1376
|
+
`a Tier-${component.tier} component ships its own source.`,
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
const file = component.tier === 2 ? TIER2_SOURCE_FILE : TIER3_SOURCE_FILE;
|
|
1380
|
+
const field = component.tier === 2 ? "worker" : "html";
|
|
1381
|
+
const where = `${label}/${component.name}/${file}`;
|
|
1382
|
+
let bytes: Buffer;
|
|
1383
|
+
try {
|
|
1384
|
+
bytes = await fs.readFile(path.join(dir, file));
|
|
1385
|
+
} catch {
|
|
1386
|
+
throw new GenuiError(
|
|
1387
|
+
`[genui] component '${component.name}' is tier ${component.tier}, so it needs ${where}.`,
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
// The bundle is UTF-8 JSON. Decoding leniently would replace an invalid byte
|
|
1391
|
+
// with U+FFFD and ship a silently corrupted worker or document.
|
|
1392
|
+
return {
|
|
1393
|
+
[field]: decodeUtf8(
|
|
1394
|
+
bytes,
|
|
1395
|
+
`[genui] component '${component.name}': ${where} is not valid UTF-8.`,
|
|
1396
|
+
),
|
|
1397
|
+
};
|
|
1398
|
+
}
|