@intentius/chant-lexicon-fly 0.124.30 → 0.124.32

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.
Files changed (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Where a Fly release's Machine config is kept, so a rollback can put it back
3
+ * (#2736, ws-056).
4
+ *
5
+ * The Machine's metadata says which release it serves and which one it
6
+ * replaced; the config that earlier release applied is kept on the
7
+ * `chant/lifecycle` branch beside the release ledger, one file per release at
8
+ * `<env>/fly/<app>/<machine>/<digest>.json`. It is written through core's
9
+ * ledger plumbing, so a workspace member's copy lands under its own prefix,
10
+ * and every checkout that fetches the branch can roll back.
11
+ *
12
+ * Not exported from the package entry point: it spawns git, which stays off
13
+ * the build path. The `fly-release` and `fly-rollback` capabilities import it.
14
+ */
15
+ import { readBlobFromPath, writeBlobToPath } from "@intentius/chant/lifecycle/git";
16
+ const safe = (s) => s.replace(/[^a-zA-Z0-9_.-]/g, "_");
17
+ /** The file a release's Machine config is kept in, under the environment's ledger directory. */
18
+ export function machineConfigFile(ref) {
19
+ return `fly/${safe(ref.app)}/${safe(ref.machine)}/${safe(ref.digest.replace(/^sha256:/, ""))}.json`;
20
+ }
21
+ /** The lifecycle-branch store for `environment`. */
22
+ export function lifecycleMachineConfigStore(environment, opts) {
23
+ return {
24
+ async read(ref) {
25
+ const content = await readBlobFromPath(environment, machineConfigFile(ref), opts);
26
+ return content === null ? undefined : JSON.parse(content);
27
+ },
28
+ async write(ref, config) {
29
+ await writeBlobToPath(environment, machineConfigFile(ref), `${JSON.stringify(config, null, 2)}\n`, `Fly release config ${ref.app}/${ref.machine} ${ref.digest}`, opts);
30
+ },
31
+ };
32
+ }
33
+ /** An in-memory store, for tests and for a caller with nowhere to keep configs. */
34
+ export function memoryMachineConfigStore() {
35
+ const entries = new Map();
36
+ return {
37
+ entries,
38
+ async read(ref) {
39
+ const found = entries.get(machineConfigFile(ref));
40
+ return found ? structuredClone(found) : undefined;
41
+ },
42
+ async write(ref, config) {
43
+ entries.set(machineConfigFile(ref), structuredClone(config));
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * fly (Machines API / "flaps") serializer.
3
+ *
4
+ * Turns declared `App`, `Machine`, `Volume`, `IPAddress`, `Certificate`, and
5
+ * `Secret` resources into the JSON create bodies the flaps REST API accepts, so
6
+ * #739's applier can POST them straight through and mudflaps (#740) can
7
+ * round-trip them.
8
+ *
9
+ * Output shape — a JSON object keyed by entity name. Each value is a single
10
+ * flaps request:
11
+ *
12
+ * {
13
+ * "<entityName>": {
14
+ * "endpoint": "/v1/apps", // or /v1/apps/{app}/machines
15
+ * "method": "POST",
16
+ * "body": { ... } // the create body
17
+ * }
18
+ * }
19
+ *
20
+ * Field names are taken from mudflaps' Go structs (the wire oracle), which win
21
+ * over the OpenAPI-generated TypeScript names:
22
+ * - App → CreateAppRequest `{ app_name, org_slug? }` (NOT `name`).
23
+ * - Machine→ CreateMachineRequest `{ name?, region?, config?, skip_launch? }`;
24
+ * `config` is the full MachineConfig. The owning app is a URL path segment,
25
+ * not a body field, so it lands in `endpoint`.
26
+ *
27
+ * D2: every serialized Machine carries the `managed-by: chant` ownership marker
28
+ * (plus the stack/env identity from `context.ownership`) merged into
29
+ * `config.metadata`, even when the user supplied no metadata.
30
+ */
31
+ import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
32
+ import { ownershipEntries, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
33
+ import { walkValue } from "@intentius/chant/serializer-walker";
34
+ import { INTRINSIC_MARKER } from "@intentius/chant/intrinsic";
35
+ import { mergeResourceAttributes, telemetryEnvironment } from "@intentius/chant/telemetry-attribution";
36
+ import { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership.js";
37
+ const APP_ENTITY_TYPE = "Fly::Machines::App";
38
+ const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
39
+ const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
40
+ const IP_ENTITY_TYPE = "Fly::Machines::IPAddress";
41
+ const CERTIFICATE_ENTITY_TYPE = "Fly::Machines::Certificate";
42
+ const SECRET_ENTITY_TYPE = "Fly::Machines::Secret";
43
+ /**
44
+ * Visitor for the generic serializer walker. Property declarables (MachineConfig,
45
+ * MachineGuest, MachineService, ...) are unwrapped to plain objects; resource
46
+ * references resolve to their logical name.
47
+ */
48
+ function flyVisitor() {
49
+ return {
50
+ attrRef: (name) => name,
51
+ resourceRef: (name) => name,
52
+ propertyDeclarable: (entity, walk) => {
53
+ const props = isResourceDeclarable(entity) ? entity.props : undefined;
54
+ if (!props || typeof props !== "object")
55
+ return undefined;
56
+ const result = {};
57
+ for (const [key, value] of Object.entries(props)) {
58
+ if (value !== undefined)
59
+ result[key] = walk(value);
60
+ }
61
+ return Object.keys(result).length > 0 ? result : undefined;
62
+ },
63
+ };
64
+ }
65
+ function readProps(entity) {
66
+ const props = isResourceDeclarable(entity) ? entity.props : undefined;
67
+ return props && typeof props === "object" ? props : {};
68
+ }
69
+ /**
70
+ * Pseudo-parameter → environment variable mapping. flaps create bodies are
71
+ * plain JSON, so `Fly.Region` / `Fly.OrgSlug` / `Fly.AppName` (which the walker
72
+ * lowers to a `{ Ref: "Fly::..." }` marker) are resolved from the environment
73
+ * at build time, mirroring gcp's PSEUDO_ENV_MAP. The first set env var wins;
74
+ * absent all of them, the fallback keeps output valid offline (mudflaps).
75
+ */
76
+ const PSEUDO_ENV_MAP = {
77
+ "Fly::Region": { envVars: ["FLY_REGION"], fallback: "iad" },
78
+ "Fly::OrgSlug": { envVars: ["FLY_ORG", "FLY_ORG_SLUG"], fallback: "personal" },
79
+ "Fly::AppName": { envVars: ["FLY_APP_NAME"], fallback: "app" },
80
+ };
81
+ function resolvePseudoRef(ref) {
82
+ const mapping = PSEUDO_ENV_MAP[ref];
83
+ if (!mapping)
84
+ return undefined;
85
+ for (const envVar of mapping.envVars) {
86
+ const value = process.env[envVar];
87
+ if (value)
88
+ return value;
89
+ }
90
+ return mapping.fallback;
91
+ }
92
+ /**
93
+ * Recursively replace pseudo-parameters with their resolved environment value.
94
+ * Handles both a raw `PseudoParameter` instance (any intrinsic carrying the
95
+ * marker, unwrapped via `toJSON()`) and the already-walked `{ Ref: "Fly::X" }`
96
+ * shape. Non-Fly `{ Ref }` shapes and every other value pass through untouched.
97
+ */
98
+ function resolvePseudoParameters(value) {
99
+ if (Array.isArray(value)) {
100
+ return value.map(resolvePseudoParameters);
101
+ }
102
+ if (value && typeof value === "object") {
103
+ const record = value;
104
+ // Unwrap an intrinsic (e.g. a PseudoParameter assigned without walking) to
105
+ // the { Ref } envelope it serializes to, then resolve that.
106
+ if (INTRINSIC_MARKER in record && typeof record.toJSON === "function") {
107
+ return resolvePseudoParameters(record.toJSON());
108
+ }
109
+ const keys = Object.keys(record);
110
+ if (keys.length === 1 && keys[0] === "Ref" && typeof record.Ref === "string") {
111
+ const resolved = resolvePseudoRef(record.Ref);
112
+ if (resolved !== undefined)
113
+ return resolved;
114
+ }
115
+ const result = {};
116
+ for (const [key, val] of Object.entries(record)) {
117
+ result[key] = resolvePseudoParameters(val);
118
+ }
119
+ return result;
120
+ }
121
+ return value;
122
+ }
123
+ /** The app_name flaps expects: an explicit `name` prop, else the entity name. */
124
+ function appName(entity, entityName) {
125
+ const name = readProps(entity).name;
126
+ return typeof name === "string" ? name : entityName;
127
+ }
128
+ // ── Telemetry attribution (#3060, D22) ────────────────────────────
129
+ /** The digest of an image reference pinned with `@sha256:...`, or undefined. */
130
+ function imageDigest(image) {
131
+ if (typeof image !== "string")
132
+ return undefined;
133
+ const at = image.lastIndexOf("@");
134
+ return at === -1 ? undefined : image.slice(at + 1);
135
+ }
136
+ /**
137
+ * Stamp one Machine's attributes as `OTEL_SERVICE_NAME` and
138
+ * `OTEL_RESOURCE_ATTRIBUTES` in its `config.env`, the env the Machine's
139
+ * process gets. `service` is the owning app's name on Fly, `decl` the
140
+ * Machine's declaration id. A value the Machine already sets is kept: its own
141
+ * `OTEL_SERVICE_NAME` wins, and its own resource attributes keep their keys
142
+ * while the missing ones are appended.
143
+ *
144
+ * Machine env values are literal strings, so there is no reference to the
145
+ * release attributes here: `fly-release` writes them into the value it
146
+ * deploys (#3061, ws-081).
147
+ */
148
+ function stampTelemetry(config, service, decl, telemetry) {
149
+ const version = imageDigest(config.image);
150
+ const vars = telemetryEnvironment(telemetry, { service, decl, ...(version ? { version } : {}) });
151
+ const env = config.env && typeof config.env === "object" && !Array.isArray(config.env) ? { ...config.env } : {};
152
+ if (!("OTEL_SERVICE_NAME" in env))
153
+ env.OTEL_SERVICE_NAME = vars.OTEL_SERVICE_NAME;
154
+ const own = env.OTEL_RESOURCE_ATTRIBUTES;
155
+ if (own === undefined)
156
+ env.OTEL_RESOURCE_ATTRIBUTES = vars.OTEL_RESOURCE_ATTRIBUTES;
157
+ else if (typeof own === "string")
158
+ env.OTEL_RESOURCE_ATTRIBUTES = mergeResourceAttributes(own, vars.OTEL_RESOURCE_ATTRIBUTES);
159
+ config.env = env;
160
+ }
161
+ /**
162
+ * fly flaps serializer.
163
+ */
164
+ export const flySerializer = {
165
+ name: "fly",
166
+ rulePrefix: "FLY",
167
+ serialize(entities, _outputs, context) {
168
+ const entityNames = new Map();
169
+ for (const [name, entity] of entities)
170
+ entityNames.set(entity, name);
171
+ const visitor = flyVisitor();
172
+ // D2 ownership marker. `managed-by: chant` is always stamped; stack/env are
173
+ // added when the build threads an ownership marker through the context.
174
+ const ownershipMeta = context?.ownership
175
+ ? ownershipEntries(FLY_METADATA_OWNERSHIP_KEYS, context.ownership)
176
+ : { [FLY_METADATA_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
177
+ // Resolve which app a machine belongs to (a URL path segment, not a body
178
+ // field). A machine may name its app explicitly via an `app` prop (a string
179
+ // or an `App` reference); otherwise, when the stack declares exactly one
180
+ // app, machines default to it. Failing both, a `{app}` placeholder is left
181
+ // for the applier to fill.
182
+ const apps = [];
183
+ for (const [name, entity] of entities) {
184
+ if (entity.entityType === APP_ENTITY_TYPE) {
185
+ apps.push([name, entity]);
186
+ }
187
+ }
188
+ const soleApp = apps.length === 1 ? appName(apps[0][1], apps[0][0]) : undefined;
189
+ // The owning app of any app-scoped resource (machine, volume, ip, cert,
190
+ // secret): an explicit `app` prop (string or `App` reference), else the
191
+ // stack's sole app, else a `{app}` placeholder for the applier to fill.
192
+ const resolveOwningApp = (entity) => {
193
+ const app = readProps(entity).app;
194
+ if (typeof app === "string")
195
+ return app;
196
+ if (app && typeof app === "object" && "entityType" in app) {
197
+ const decl = app;
198
+ return appName(decl, entityNames.get(decl) ?? "{app}");
199
+ }
200
+ return soleApp ?? "{app}";
201
+ };
202
+ const requests = {};
203
+ for (const [name, entity] of entities) {
204
+ if (isPropertyDeclarable(entity))
205
+ continue;
206
+ const entityType = entity.entityType;
207
+ const props = readProps(entity);
208
+ if (entityType === APP_ENTITY_TYPE) {
209
+ const body = { app_name: appName(entity, name) };
210
+ if (props.org_slug !== undefined)
211
+ body.org_slug = props.org_slug;
212
+ if (props.network !== undefined)
213
+ body.network = props.network;
214
+ if (props.enable_subdomains !== undefined)
215
+ body.enable_subdomains = props.enable_subdomains;
216
+ requests[name] = { endpoint: "/v1/apps", method: "POST", body };
217
+ continue;
218
+ }
219
+ if (entityType === MACHINE_ENTITY_TYPE) {
220
+ const walkedConfig = props.config
221
+ ? walkValue(props.config, entityNames, visitor)
222
+ : undefined;
223
+ const config = walkedConfig && typeof walkedConfig === "object"
224
+ ? { ...walkedConfig }
225
+ : {};
226
+ // Merge the ownership marker into config.metadata. The marker wins over
227
+ // any colliding user key so ownership can never be silently unset.
228
+ const userMeta = config.metadata && typeof config.metadata === "object"
229
+ ? config.metadata
230
+ : {};
231
+ config.metadata = { ...userMeta, ...ownershipMeta };
232
+ // Spread the create-body fields (name, region, skip_launch, ...) and
233
+ // override with the walked config. `app` is our association hint, not a
234
+ // flaps field, so it is dropped.
235
+ const body = {};
236
+ for (const [key, value] of Object.entries(props)) {
237
+ if (key === "config" || key === "app" || value === undefined)
238
+ continue;
239
+ body[key] = walkValue(value, entityNames, visitor);
240
+ }
241
+ body.config = config;
242
+ // Telemetry attribution (#3060): service.name is the app's name on
243
+ // Fly, or the Machine's export name when no app is resolved.
244
+ const owningApp = resolveOwningApp(entity);
245
+ if (context?.telemetry) {
246
+ stampTelemetry(config, owningApp === "{app}" ? name : owningApp, name, context.telemetry);
247
+ }
248
+ requests[name] = {
249
+ endpoint: `/v1/apps/${owningApp}/machines`,
250
+ method: "POST",
251
+ body,
252
+ };
253
+ continue;
254
+ }
255
+ // ── App-scoped, metadata-less resources (#741, D2). ──────────────────
256
+ // Each spreads its scalar create-body props straight through; the `app`
257
+ // association hint is a URL segment, not a body field, so it is dropped.
258
+ // Ownership is at the app boundary, so none carry a metadata marker.
259
+ if (entityType === VOLUME_ENTITY_TYPE ||
260
+ entityType === IP_ENTITY_TYPE ||
261
+ entityType === CERTIFICATE_ENTITY_TYPE ||
262
+ entityType === SECRET_ENTITY_TYPE) {
263
+ const app = resolveOwningApp(entity);
264
+ if (entityType === SECRET_ENTITY_TYPE) {
265
+ // Apply-only (D7): name is the URL segment; `value` is the only body
266
+ // field. mudflaps returns just a digest, so this never enters a diff.
267
+ // A Secret with no value is set out of band. It is marked
268
+ // `mustExist`, and the applier checks for it instead of posting.
269
+ const secretName = typeof props.name === "string" ? props.name : name;
270
+ const endpoint = `/v1/apps/${app}/secrets/${encodeURIComponent(secretName)}`;
271
+ requests[name] =
272
+ props.value !== undefined
273
+ ? { endpoint, method: "POST", body: { value: props.value }, applyOnly: true }
274
+ : { endpoint, method: "POST", body: {}, applyOnly: true, mustExist: true };
275
+ continue;
276
+ }
277
+ // A Certificate is an ACME certificate. flaps creates those at
278
+ // `.../certificates/acme` and answers a bare POST to `.../certificates`
279
+ // with 404 (#3114).
280
+ const segment = entityType === VOLUME_ENTITY_TYPE
281
+ ? "volumes"
282
+ : entityType === IP_ENTITY_TYPE
283
+ ? "ip_assignments"
284
+ : "certificates/acme";
285
+ const body = {};
286
+ for (const [key, value] of Object.entries(props)) {
287
+ if (key === "app" || value === undefined)
288
+ continue;
289
+ body[key] = walkValue(value, entityNames, visitor);
290
+ }
291
+ requests[name] = { endpoint: `/v1/apps/${app}/${segment}`, method: "POST", body };
292
+ continue;
293
+ }
294
+ }
295
+ // Resolve pseudo-parameter markers (Fly.Region, Fly.OrgSlug, ...) to their
296
+ // environment value so create bodies carry plain strings, not `{ Ref }`.
297
+ return JSON.stringify(resolvePseudoParameters(requests), null, 2);
298
+ },
299
+ };
@@ -0,0 +1,32 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { fetchWithCache } from "@intentius/chant/codegen/fetch";
4
+ /**
5
+ * Fly's published Machines API (flaps) OpenAPI 3.0.1 spec.
6
+ *
7
+ * This is an unversioned live endpoint: it always serves the current spec with
8
+ * no version or release tag to pin. There is nothing for the self-upgrade
9
+ * tooling (#685) to bump, so the plugin declares no `upstreamPin` — unlike gcp
10
+ * (`KCC_VERSION`) or k8s (`K8S_SCHEMA_VERSION`), which pin a released schema
11
+ * version. Re-running `generate` simply picks up whatever this URL currently
12
+ * serves.
13
+ */
14
+ const SCHEMA_URL = "https://docs.machines.dev/openapi.json";
15
+ /**
16
+ * Cache under `~/.chant` (like the k8s lexicon) so CI's `~/.chant` schema
17
+ * cache covers it — a `docs.machines.dev` hiccup then falls back to the cache
18
+ * instead of failing the generate step.
19
+ */
20
+ const CACHE_FILE = join(homedir(), ".chant", "fly-machines-openapi.json");
21
+ /**
22
+ * Fetch the flaps OpenAPI spec and return it as a Map<typeName, Buffer>
23
+ * compatible with the generatePipeline fetchSchemas callback.
24
+ *
25
+ * The flaps spec is a single document, so we return a single entry keyed by
26
+ * "Fly::OpenAPI" — the parse step splits it into the curated resources and
27
+ * their reachable property types.
28
+ */
29
+ export async function fetchSchemas(options) {
30
+ const raw = await fetchWithCache({ url: SCHEMA_URL, cacheFile: CACHE_FILE }, options?.force);
31
+ return new Map([["Fly::OpenAPI", raw]]);
32
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Fly Machines API OpenAPI 3.0.1 parser.
3
+ *
4
+ * The flaps spec has no resource marker and is mostly request/response DTOs,
5
+ * so we generate a curated set of resources rather than every schema. Each
6
+ * curated resource pairs a request schema (writable authoring surface) with a
7
+ * response schema (read-only attributes). The property types reachable from the
8
+ * request schemas — notably the fly.MachineConfig graph — are emitted as
9
+ * standalone property-type classes so `config` is fully typed.
10
+ */
11
+ import { extractConstraints as coreExtractConstraints, primaryType, isEnumDefinition, } from "@intentius/chant/codegen/json-schema";
12
+ // ── Curated manifest ───────────────────────────────────────────────
13
+ /** The single service segment for all fly type names. */
14
+ const SERVICE = "Machines";
15
+ const RESOURCES = [
16
+ { typeName: `Fly::${SERVICE}::App`, request: "CreateAppRequest", response: "App" },
17
+ { typeName: `Fly::${SERVICE}::Machine`, request: "CreateMachineRequest", response: "Machine" },
18
+ { typeName: `Fly::${SERVICE}::Volume`, request: "CreateVolumeRequest", response: "Volume" },
19
+ { typeName: `Fly::${SERVICE}::IPAddress`, request: "assignIPRequest", response: "IPAssignment" },
20
+ { typeName: `Fly::${SERVICE}::Certificate`, request: "createAcmeCertificateRequest", response: "CertificateDetail" },
21
+ {
22
+ typeName: `Fly::${SERVICE}::Secret`,
23
+ request: "SetAppSecretRequest",
24
+ response: "AppSecret",
25
+ pathProps: [
26
+ {
27
+ name: "name",
28
+ tsType: "string",
29
+ required: false,
30
+ description: "The secret's name on the app. Defaults to the declaration's export name. " +
31
+ "A Secret with no value is never sent: apply checks that the app already has it.",
32
+ constraints: {},
33
+ },
34
+ ],
35
+ },
36
+ ];
37
+ const REF_PREFIX = "#/components/schemas/";
38
+ // ── Parser ─────────────────────────────────────────────────────────
39
+ /**
40
+ * Parse the flaps OpenAPI spec into the curated resources and the property
41
+ * types reachable from their request schemas.
42
+ */
43
+ export function parseFlyOpenAPI(data) {
44
+ const spec = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
45
+ const schemas = spec.components?.schemas ?? {};
46
+ // Phase 1: discover the emitted property-type set — object schemas reachable
47
+ // (transitively) from the request schemas' properties. Enums are inlined, so
48
+ // they are never emitted as classes.
49
+ const emitted = collectPropertyTypes(schemas);
50
+ const className = (schemaName) => schemaToClassName(schemaName);
51
+ const resolve = (prop) => resolveType(prop, schemas, emitted);
52
+ const results = [];
53
+ // Phase 2: resources.
54
+ for (const rspec of RESOURCES) {
55
+ const req = schemas[rspec.request];
56
+ const res = schemas[rspec.response];
57
+ const reqProps = req?.properties ?? {};
58
+ const requiredSet = new Set(req?.required ?? []);
59
+ const properties = [];
60
+ for (const [name, prop] of Object.entries(reqProps)) {
61
+ properties.push({
62
+ name,
63
+ tsType: resolve(prop),
64
+ required: requiredSet.has(name),
65
+ description: prop.description,
66
+ constraints: coreExtractConstraints(prop),
67
+ });
68
+ }
69
+ for (const extra of rspec.pathProps ?? []) {
70
+ if (!(extra.name in reqProps))
71
+ properties.push(extra);
72
+ }
73
+ // Attributes = response props not present in the request schema.
74
+ const attributes = [];
75
+ for (const [name, prop] of Object.entries(res?.properties ?? {})) {
76
+ if (properties.some((p) => p.name === name))
77
+ continue;
78
+ attributes.push({ name, tsType: resolve(prop) });
79
+ }
80
+ results.push({
81
+ resource: { typeName: rspec.typeName, description: req?.description, properties, attributes },
82
+ propertyTypes: [],
83
+ enums: [],
84
+ });
85
+ }
86
+ // Phase 3: property-type classes.
87
+ for (const schemaName of emitted) {
88
+ const def = schemas[schemaName];
89
+ const requiredSet = new Set(def.required ?? []);
90
+ const properties = [];
91
+ for (const [name, prop] of Object.entries(def.properties ?? {})) {
92
+ properties.push({
93
+ name,
94
+ tsType: resolve(prop),
95
+ required: requiredSet.has(name),
96
+ description: prop.description,
97
+ constraints: coreExtractConstraints(prop),
98
+ });
99
+ }
100
+ results.push({
101
+ resource: {
102
+ typeName: `Fly::${SERVICE}::${className(schemaName)}`,
103
+ description: def.description,
104
+ properties,
105
+ attributes: [],
106
+ },
107
+ propertyTypes: [],
108
+ enums: [],
109
+ isProperty: true,
110
+ });
111
+ }
112
+ return results;
113
+ }
114
+ /**
115
+ * Walk the request schemas and collect the set of object schemas reachable
116
+ * through their properties (following $ref, allOf, arrays, and maps). Returns
117
+ * the schema names in insertion order, deduplicated across resources.
118
+ */
119
+ function collectPropertyTypes(schemas) {
120
+ const emitted = new Set();
121
+ const queue = [];
122
+ const seedFrom = (node) => {
123
+ for (const ref of collectRefs(node)) {
124
+ const target = schemas[ref];
125
+ if (!target)
126
+ continue;
127
+ if (isObjectSchema(target) && !emitted.has(ref)) {
128
+ emitted.add(ref);
129
+ queue.push(ref);
130
+ }
131
+ }
132
+ };
133
+ for (const spec of RESOURCES) {
134
+ seedFrom(schemas[spec.request]?.properties);
135
+ }
136
+ while (queue.length > 0) {
137
+ const name = queue.shift();
138
+ seedFrom(schemas[name]?.properties);
139
+ }
140
+ return [...emitted];
141
+ }
142
+ /** Collect every `#/components/schemas/X` ref name nested anywhere in a node. */
143
+ function collectRefs(node, acc = new Set()) {
144
+ if (!node || typeof node !== "object")
145
+ return acc;
146
+ if (Array.isArray(node)) {
147
+ for (const item of node)
148
+ collectRefs(item, acc);
149
+ return acc;
150
+ }
151
+ const obj = node;
152
+ const ref = obj.$ref;
153
+ if (typeof ref === "string" && ref.startsWith(REF_PREFIX))
154
+ acc.add(ref.slice(REF_PREFIX.length));
155
+ for (const [key, value] of Object.entries(obj)) {
156
+ if (key === "$ref")
157
+ continue;
158
+ collectRefs(value, acc);
159
+ }
160
+ return acc;
161
+ }
162
+ /** An object schema with properties (not a pure enum). */
163
+ function isObjectSchema(def) {
164
+ return !!def.properties && Object.keys(def.properties).length > 0 && !isEnumDefinition(def);
165
+ }
166
+ // ── Type resolution ────────────────────────────────────────────────
167
+ /**
168
+ * Resolve an OpenAPI schema node to its TypeScript type string. Emitted object
169
+ * schemas resolve to their class name; enums inline as string-literal unions;
170
+ * everything else falls back to primitives or `Record<string, any>`.
171
+ */
172
+ function resolveType(prop, schemas, emitted) {
173
+ if (!prop)
174
+ return "any";
175
+ if (prop.$ref)
176
+ return resolveRefType(prop.$ref, schemas, emitted);
177
+ // allOf: [{ $ref }] — the OpenAPI idiom for "typed as this schema".
178
+ if (prop.allOf && prop.allOf.length > 0) {
179
+ const withRef = prop.allOf.find((s) => s.$ref);
180
+ if (withRef?.$ref)
181
+ return resolveRefType(withRef.$ref, schemas, emitted);
182
+ }
183
+ if (prop.enum && prop.enum.length > 0) {
184
+ return [...prop.enum].sort().map((v) => JSON.stringify(v)).join(" | ");
185
+ }
186
+ const pt = primaryType(prop.type);
187
+ switch (pt) {
188
+ case "string":
189
+ return "string";
190
+ case "integer":
191
+ case "number":
192
+ return "number";
193
+ case "boolean":
194
+ return "boolean";
195
+ case "array": {
196
+ if (prop.items) {
197
+ const itemType = resolveType(prop.items, schemas, emitted);
198
+ return itemType.includes(" | ") ? `(${itemType})[]` : `${itemType}[]`;
199
+ }
200
+ return "any[]";
201
+ }
202
+ case "object":
203
+ if (prop.additionalProperties && typeof prop.additionalProperties === "object") {
204
+ return `Record<string, ${resolveType(prop.additionalProperties, schemas, emitted)}>`;
205
+ }
206
+ return "Record<string, any>";
207
+ default:
208
+ return "any";
209
+ }
210
+ }
211
+ /** Resolve a `#/components/schemas/X` ref to a TypeScript type. */
212
+ function resolveRefType(ref, schemas, emitted) {
213
+ if (!ref.startsWith(REF_PREFIX))
214
+ return "any";
215
+ const name = ref.slice(REF_PREFIX.length);
216
+ const def = schemas[name];
217
+ if (!def)
218
+ return "any";
219
+ // Emitted object schemas reference their class by name.
220
+ if (emitted.includes(name))
221
+ return schemaToClassName(name);
222
+ // Enums inline as string-literal unions.
223
+ if (isEnumDefinition(def)) {
224
+ return [...(def.enum ?? [])].sort().map((v) => JSON.stringify(v)).join(" | ");
225
+ }
226
+ // Object schemas we did not emit (response-only shapes) loosen to a map.
227
+ if (def.properties)
228
+ return "Record<string, any>";
229
+ const pt = primaryType(def.type);
230
+ switch (pt) {
231
+ case "string":
232
+ return "string";
233
+ case "integer":
234
+ case "number":
235
+ return "number";
236
+ case "boolean":
237
+ return "boolean";
238
+ default:
239
+ return "any";
240
+ }
241
+ }
242
+ // ── Naming helpers ─────────────────────────────────────────────────
243
+ /**
244
+ * Convert a schema name into a PascalCase class-name segment.
245
+ * "fly.MachineConfig" → "MachineConfig", "fly.dnsOption" → "DnsOption".
246
+ */
247
+ export function schemaToClassName(schemaName) {
248
+ const base = schemaName.slice(schemaName.lastIndexOf(".") + 1);
249
+ return base.charAt(0).toUpperCase() + base.slice(1);
250
+ }
251
+ /** Extract short name: "Fly::Machines::Machine" → "Machine". */
252
+ export function flyShortName(typeName) {
253
+ const parts = typeName.split("::");
254
+ return parts[parts.length - 1];
255
+ }
256
+ /** Extract service name: "Fly::Machines::Machine" → "Machines". */
257
+ export function flyServiceName(typeName) {
258
+ const parts = typeName.split("::");
259
+ return parts.length >= 2 ? parts[1] : SERVICE;
260
+ }