@intentius/chant-lexicon-fly 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +91 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +27 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +6 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +18 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/fly-deploy.d.ts +110 -0
  19. package/dist/composites/fly-deploy.d.ts.map +1 -0
  20. package/dist/describe-resources.d.ts +74 -0
  21. package/dist/describe-resources.d.ts.map +1 -0
  22. package/dist/detect.d.ts +22 -0
  23. package/dist/detect.d.ts.map +1 -0
  24. package/dist/export-resources.d.ts +25 -0
  25. package/dist/export-resources.d.ts.map +1 -0
  26. package/dist/generated/index.d.ts +42 -0
  27. package/dist/generated/index.d.ts.map +1 -0
  28. package/dist/generated/runtime.d.ts +5 -0
  29. package/dist/generated/runtime.d.ts.map +1 -0
  30. package/dist/import/generator.d.ts +21 -0
  31. package/dist/import/generator.d.ts.map +1 -0
  32. package/dist/import/live-export.d.ts +42 -0
  33. package/dist/import/live-export.d.ts.map +1 -0
  34. package/dist/import/parser.d.ts +40 -0
  35. package/dist/import/parser.d.ts.map +1 -0
  36. package/dist/index.d.ts +8 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/integrity.json +9 -0
  39. package/dist/lint/post-synth/fly-helpers.d.ts +15 -0
  40. package/dist/lint/post-synth/fly-helpers.d.ts.map +1 -0
  41. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts +10 -0
  42. package/dist/lint/post-synth/fly010-machine-requires-image.d.ts.map +1 -0
  43. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts +12 -0
  44. package/dist/lint/post-synth/fly011-mount-references-declared-volume.d.ts.map +1 -0
  45. package/dist/lint/post-synth/index.d.ts +3 -0
  46. package/dist/lint/post-synth/index.d.ts.map +1 -0
  47. package/dist/lint/rules/guest-sizing.d.ts +9 -0
  48. package/dist/lint/rules/guest-sizing.d.ts.map +1 -0
  49. package/dist/lint/rules/index.d.ts +7 -0
  50. package/dist/lint/rules/index.d.ts.map +1 -0
  51. package/dist/lint/rules/no-secret-literals.d.ts +13 -0
  52. package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
  53. package/dist/lint/rules/valid-region.d.ts +9 -0
  54. package/dist/lint/rules/valid-region.d.ts.map +1 -0
  55. package/dist/lsp/completions.d.ts +9 -0
  56. package/dist/lsp/completions.d.ts.map +1 -0
  57. package/dist/lsp/hover.d.ts +9 -0
  58. package/dist/lsp/hover.d.ts.map +1 -0
  59. package/dist/manifest.json +4 -0
  60. package/dist/meta.json +285 -0
  61. package/dist/op/activities/flaps.d.ts +29 -0
  62. package/dist/op/activities/flaps.d.ts.map +1 -0
  63. package/dist/op/activities/fly-apply.d.ts +382 -0
  64. package/dist/op/activities/fly-apply.d.ts.map +1 -0
  65. package/dist/op/activities/index.d.ts +12 -0
  66. package/dist/op/activities/index.d.ts.map +1 -0
  67. package/dist/ownership.d.ts +27 -0
  68. package/dist/ownership.d.ts.map +1 -0
  69. package/dist/package-cli.d.ts +3 -0
  70. package/dist/package-cli.d.ts.map +1 -0
  71. package/dist/plugin.d.ts +8 -0
  72. package/dist/plugin.d.ts.map +1 -0
  73. package/dist/pseudo.d.ts +23 -0
  74. package/dist/pseudo.d.ts.map +1 -0
  75. package/dist/serializer.d.ts +36 -0
  76. package/dist/serializer.d.ts.map +1 -0
  77. package/dist/spec/fetch.d.ts +12 -0
  78. package/dist/spec/fetch.d.ts.map +1 -0
  79. package/dist/spec/parse.d.ts +58 -0
  80. package/dist/spec/parse.d.ts.map +1 -0
  81. package/dist/types/index.d.ts +590 -0
  82. package/dist/validate-cli.d.ts +3 -0
  83. package/dist/validate-cli.d.ts.map +1 -0
  84. package/dist/validate.d.ts +15 -0
  85. package/dist/validate.d.ts.map +1 -0
  86. package/package.json +76 -0
  87. package/src/codegen/docs-cli.ts +7 -0
  88. package/src/codegen/docs.ts +208 -0
  89. package/src/codegen/generate-cli.ts +36 -0
  90. package/src/codegen/generate-lexicon.ts +52 -0
  91. package/src/codegen/generate-typescript.ts +69 -0
  92. package/src/codegen/generate.ts +106 -0
  93. package/src/codegen/naming.ts +45 -0
  94. package/src/codegen/package.ts +25 -0
  95. package/src/composites/fly-deploy.ts +169 -0
  96. package/src/describe-resources.test.ts +188 -0
  97. package/src/describe-resources.ts +0 -0
  98. package/src/detect.ts +64 -0
  99. package/src/export-resources.ts +123 -0
  100. package/src/generated/.gitkeep +0 -0
  101. package/src/generated/index.d.ts +590 -0
  102. package/src/generated/index.ts +47 -0
  103. package/src/generated/lexicon-fly.json +285 -0
  104. package/src/generated/runtime.ts +4 -0
  105. package/src/import/generator.test.ts +123 -0
  106. package/src/import/generator.ts +242 -0
  107. package/src/import/live-export.test.ts +150 -0
  108. package/src/import/live-export.ts +107 -0
  109. package/src/import/parser.test.ts +132 -0
  110. package/src/import/parser.ts +185 -0
  111. package/src/import/roundtrip.test.ts +77 -0
  112. package/src/index.ts +23 -0
  113. package/src/lint/post-synth/fly-helpers.ts +25 -0
  114. package/src/lint/post-synth/fly010-machine-requires-image.test.ts +59 -0
  115. package/src/lint/post-synth/fly010-machine-requires-image.ts +39 -0
  116. package/src/lint/post-synth/fly011-mount-references-declared-volume.test.ts +92 -0
  117. package/src/lint/post-synth/fly011-mount-references-declared-volume.ts +61 -0
  118. package/src/lint/post-synth/index.ts +9 -0
  119. package/src/lint/rules/guest-sizing.ts +100 -0
  120. package/src/lint/rules/index.ts +15 -0
  121. package/src/lint/rules/no-secret-literals.ts +67 -0
  122. package/src/lint/rules/rules.test.ts +127 -0
  123. package/src/lint/rules/valid-region.ts +58 -0
  124. package/src/lsp/completions.test.ts +10 -0
  125. package/src/lsp/completions.ts +14 -0
  126. package/src/lsp/hover.test.ts +10 -0
  127. package/src/lsp/hover.ts +14 -0
  128. package/src/op/activities/describe-resources.integration.test.ts +105 -0
  129. package/src/op/activities/flaps.test.ts +29 -0
  130. package/src/op/activities/flaps.ts +44 -0
  131. package/src/op/activities/fly-apply.integration.test.ts +245 -0
  132. package/src/op/activities/fly-apply.test.ts +513 -0
  133. package/src/op/activities/fly-apply.ts +1014 -0
  134. package/src/op/activities/index.ts +48 -0
  135. package/src/ownership.ts +32 -0
  136. package/src/package-cli.ts +24 -0
  137. package/src/plugin.test.ts +17 -0
  138. package/src/plugin.ts +285 -0
  139. package/src/pseudo.test.ts +32 -0
  140. package/src/pseudo.ts +29 -0
  141. package/src/serializer.test.ts +232 -0
  142. package/src/serializer.ts +293 -0
  143. package/src/skills/chant-fly-ops.md +55 -0
  144. package/src/skills/chant-fly-patterns.md +71 -0
  145. package/src/skills/chant-fly.md +123 -0
  146. package/src/spec/fetch.ts +34 -0
  147. package/src/spec/parse.ts +323 -0
  148. package/src/validate-cli.ts +5 -0
  149. package/src/validate.ts +28 -0
@@ -0,0 +1,150 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { stripMachineServerFields, stripAppServerFields, buildExportFromApp } from "./live-export";
3
+ import { FlyGenerator } from "./generator";
4
+ import { exportResources } from "../export-resources";
5
+ import type { FlapsMachine, FlyHttp } from "../op/activities/fly-apply";
6
+
7
+ const OWNED_META = { "managed-by": "chant" };
8
+
9
+ /** A live flaps machine as list/get returns it, carrying server-written fields. */
10
+ function liveMachine(name: string, image: string, owned = true): FlapsMachine {
11
+ return {
12
+ id: `m-${name}`,
13
+ name,
14
+ state: "started",
15
+ instance_id: "INST0",
16
+ private_ip: "fdaa::1",
17
+ config: { image, metadata: owned ? { ...OWNED_META } : { role: "db" } },
18
+ } as unknown as FlapsMachine;
19
+ }
20
+
21
+ /** A live app as GET /v1/apps/{app} returns it. */
22
+ function liveApp() {
23
+ return {
24
+ id: "app-1",
25
+ name: "my-app",
26
+ status: "deployed",
27
+ machine_count: 1,
28
+ volume_count: 0,
29
+ internal_numeric_id: 42,
30
+ organization: { slug: "acme" },
31
+ };
32
+ }
33
+
34
+ describe("fly stripMachineServerFields", () => {
35
+ test("drops server-written read-only fields, keeps config", () => {
36
+ const cleaned = stripMachineServerFields(liveMachine("web", "nginx:1") as unknown as Record<string, unknown>);
37
+ expect(cleaned.id).toBeUndefined();
38
+ expect(cleaned.state).toBeUndefined();
39
+ expect(cleaned.instance_id).toBeUndefined();
40
+ expect(cleaned.private_ip).toBeUndefined();
41
+ expect(cleaned.name).toBe("web");
42
+ expect(cleaned.config).toEqual({ image: "nginx:1", metadata: { "managed-by": "chant" } });
43
+ });
44
+
45
+ test("does not mutate the input", () => {
46
+ const input = liveMachine("web", "nginx:1") as unknown as Record<string, unknown>;
47
+ stripMachineServerFields(input);
48
+ expect(input.id).toBe("m-web");
49
+ });
50
+ });
51
+
52
+ describe("fly stripAppServerFields", () => {
53
+ test("drops read-only app fields and carries organization.slug into org_slug", () => {
54
+ const cleaned = stripAppServerFields(liveApp() as unknown as Record<string, unknown>);
55
+ expect(cleaned.id).toBeUndefined();
56
+ expect(cleaned.status).toBeUndefined();
57
+ expect(cleaned.machine_count).toBeUndefined();
58
+ expect(cleaned.organization).toBeUndefined();
59
+ expect(cleaned.name).toBe("my-app");
60
+ expect(cleaned.org_slug).toBe("acme");
61
+ });
62
+ });
63
+
64
+ describe("fly buildExportFromApp", () => {
65
+ test("maps an app and its machines to export IR, stripped by default", () => {
66
+ const ir = buildExportFromApp(liveApp(), [liveMachine("web", "nginx:1")]);
67
+ expect(ir.resources.map((r) => r.type)).toEqual([
68
+ "Fly::Machines::App",
69
+ "Fly::Machines::Machine",
70
+ ]);
71
+ const machine = ir.resources.find((r) => r.type === "Fly::Machines::Machine")!;
72
+ expect((machine.properties as Record<string, unknown>).id).toBeUndefined();
73
+ expect(machine.properties.config).toEqual({ image: "nginx:1", metadata: { "managed-by": "chant" } });
74
+ const app = ir.resources.find((r) => r.type === "Fly::Machines::App")!;
75
+ expect(app.properties.org_slug).toBe("acme");
76
+ });
77
+
78
+ test("verbatim is inert: fly maps only the writable surface, so server fields never round-trip", () => {
79
+ const ir = buildExportFromApp(liveApp(), [liveMachine("web", "nginx:1")], { verbatim: true });
80
+ const machine = ir.resources.find((r) => r.type === "Fly::Machines::Machine")!;
81
+ expect((machine.properties as Record<string, unknown>).id).toBeUndefined();
82
+ expect((machine.properties as Record<string, unknown>).instance_id).toBeUndefined();
83
+ });
84
+
85
+ test("owned filter drops unmarked machines and keeps the app (it has a managed machine)", () => {
86
+ const ir = buildExportFromApp(liveApp(), [liveMachine("web", "nginx:1", true), liveMachine("legacy", "redis:7", false)], { owned: true });
87
+ const machines = ir.resources.filter((r) => r.type === "Fly::Machines::Machine");
88
+ expect(machines.map((m) => m.logicalId)).toEqual(["web"]);
89
+ expect(ir.resources.some((r) => r.type === "Fly::Machines::App")).toBe(true);
90
+ });
91
+
92
+ test("owned filter drops the app when no machine is chant-managed, and logs the boundary inference", () => {
93
+ const onBoundaryInference = vi.fn();
94
+ const ir = buildExportFromApp(liveApp(), [liveMachine("legacy", "redis:7", false)], { owned: true, onBoundaryInference });
95
+ expect(ir.resources.some((r) => r.type === "Fly::Machines::App")).toBe(false);
96
+ expect(ir.resources.some((r) => r.type === "Fly::Machines::Machine")).toBe(false);
97
+ expect(onBoundaryInference).toHaveBeenCalled();
98
+ });
99
+
100
+ test("selector by type narrows the export", () => {
101
+ const ir = buildExportFromApp(liveApp(), [liveMachine("web", "nginx:1")], {
102
+ selector: { type: "Fly::Machines::Machine" },
103
+ });
104
+ expect(ir.resources.map((r) => r.type)).toEqual(["Fly::Machines::Machine"]);
105
+ });
106
+
107
+ test("export IR feeds FlyGenerator (templateGenerator) unchanged", () => {
108
+ const ir = buildExportFromApp(liveApp(), [liveMachine("web", "nginx:1")]);
109
+ const files = new FlyGenerator().generate(ir);
110
+ expect(files[0].content).toContain("new Machine({");
111
+ });
112
+ });
113
+
114
+ describe("fly exportResources (live I/O over injected flaps)", () => {
115
+ const APP = "my-app";
116
+
117
+ /** A fake flaps serving the app listing, one app GET, and one machine list. */
118
+ function fakeHttp(machines: FlapsMachine[]): FlyHttp {
119
+ return async (method, url) => {
120
+ if (method === "GET" && url.includes("/v1/apps?org_slug=")) {
121
+ return { status: 200, text: JSON.stringify({ apps: [{ name: APP }] }) };
122
+ }
123
+ if (method === "GET" && new RegExp(`/v1/apps/${APP}$`).test(url)) {
124
+ return { status: 200, text: JSON.stringify(liveApp()) };
125
+ }
126
+ if (method === "GET" && url.endsWith("/machines")) {
127
+ return { status: 200, text: JSON.stringify(machines) };
128
+ }
129
+ return { status: 404, text: "" };
130
+ };
131
+ }
132
+
133
+ test("discovers the org's apps, lists machines, and returns the declared shape", async () => {
134
+ const http = fakeHttp([liveMachine("web", "nginx:1")]);
135
+ const ir = await exportResources({ environment: "prod" }, http);
136
+ expect(ir.resources.map((r) => r.type)).toEqual([
137
+ "Fly::Machines::App",
138
+ "Fly::Machines::Machine",
139
+ ]);
140
+ const machine = ir.resources.find((r) => r.type === "Fly::Machines::Machine")!;
141
+ expect((machine.properties as Record<string, unknown>).instance_id).toBeUndefined();
142
+ });
143
+
144
+ test("owned filter drops the foreign machine", async () => {
145
+ const http = fakeHttp([liveMachine("web", "nginx:1", true), liveMachine("legacy", "redis:7", false)]);
146
+ const ir = await exportResources({ environment: "prod", owned: true }, http);
147
+ const machines = ir.resources.filter((r) => r.type === "Fly::Machines::Machine");
148
+ expect(machines.map((m) => m.logicalId)).toEqual(["web"]);
149
+ });
150
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure export helpers for the fly lexicon — strip live flaps state to the
3
+ * authored shape and build import IR from it. All flaps I/O stays in the caller
4
+ * (`../export-resources`); this module is deterministic and unit-testable.
5
+ *
6
+ * The strip mirrors the read-only field lists the parser drops, so a live
7
+ * machine/app reaches the same declared shape a user would have written. Machine
8
+ * ownership is a per-resource marker (`managed-by: chant` in `config.metadata`,
9
+ * read via `isChantOwned`); the app carries no marker, so under the `owned`
10
+ * filter its ownership is inferred at the app boundary (an app is chant-managed
11
+ * when one of its machines is), and the limitation is logged rather than
12
+ * silently returning everything — the seam contract, matching gcp/aws.
13
+ *
14
+ * `verbatim` is inert for fly (the core contract permits this for targets whose
15
+ * importable surface is already the declared shape): the App/Machine
16
+ * constructors accept only the writable surface, so server-written fields are
17
+ * not constructor parameters and cannot round-trip into the generated code. The
18
+ * option is accepted for signature parity.
19
+ */
20
+
21
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
22
+ import { isChantOwned, type FlapsMachine } from "../op/activities/fly-apply";
23
+ import {
24
+ FlyParser,
25
+ MACHINE_SERVER_FIELDS,
26
+ APP_SERVER_FIELDS,
27
+ } from "./parser";
28
+
29
+ function isRecord(v: unknown): v is Record<string, unknown> {
30
+ return typeof v === "object" && v !== null && !Array.isArray(v);
31
+ }
32
+
33
+ /** Drop server-written read-only machine fields to reach the authored shape. Returns a new object. */
34
+ export function stripMachineServerFields(machine: Record<string, unknown>): Record<string, unknown> {
35
+ const clone = JSON.parse(JSON.stringify(machine)) as Record<string, unknown>;
36
+ for (const f of MACHINE_SERVER_FIELDS) delete clone[f];
37
+ return clone;
38
+ }
39
+
40
+ /** Drop server-written read-only app fields, keeping `name`/`org_slug`. Returns a new object. */
41
+ export function stripAppServerFields(app: Record<string, unknown>): Record<string, unknown> {
42
+ const clone = JSON.parse(JSON.stringify(app)) as Record<string, unknown>;
43
+ // Carry the org slug down before dropping the organization object it rides on.
44
+ if (clone.org_slug === undefined && isRecord(clone.organization) && typeof clone.organization.slug === "string") {
45
+ clone.org_slug = clone.organization.slug;
46
+ }
47
+ for (const f of APP_SERVER_FIELDS) delete clone[f];
48
+ return clone;
49
+ }
50
+
51
+ /**
52
+ * Build export IR from one app's live flaps state. Reuses {@link FlyParser} as
53
+ * the single wire→authoring mapping authority by feeding it a cleaned
54
+ * app-with-machines bundle.
55
+ *
56
+ * `opts.owned` keeps only machines carrying the chant marker, and includes the
57
+ * app only when it is chant-managed (a machine is). `opts.verbatim` keeps server
58
+ * fields. Pure: logging is delegated to `opts.onBoundaryInference`, which the
59
+ * I/O caller wires to a single warn.
60
+ */
61
+ export function buildExportFromApp(
62
+ app: Record<string, unknown> | undefined,
63
+ machines: FlapsMachine[],
64
+ opts: {
65
+ verbatim?: boolean;
66
+ selector?: ResourceSelector;
67
+ owned?: boolean;
68
+ onBoundaryInference?: () => void;
69
+ } = {},
70
+ ): ExportedTemplate {
71
+ const appManaged = machines.some((m) => isChantOwned(m.config?.metadata));
72
+
73
+ const keptMachines = opts.owned
74
+ ? machines.filter((m) => isChantOwned(m.config?.metadata))
75
+ : machines;
76
+
77
+ // The app has no per-resource marker; under `owned` it rides the app boundary.
78
+ let keptApp = app;
79
+ if (opts.owned && app) {
80
+ opts.onBoundaryInference?.();
81
+ if (!appManaged) keptApp = undefined;
82
+ }
83
+
84
+ // The parser maps to the writable surface regardless; the explicit strip here
85
+ // keeps the pure cleaning layer visible and testable. `verbatim` is inert (see
86
+ // module header) — server fields are not App/Machine constructor parameters.
87
+ const cleanMachines = keptMachines.map((m) =>
88
+ stripMachineServerFields(m as unknown as Record<string, unknown>),
89
+ );
90
+ const cleanApp = keptApp ? stripAppServerFields(keptApp) : keptApp;
91
+
92
+ const bundle: Record<string, unknown> = { ...(cleanApp ?? {}), machines: cleanMachines };
93
+ const ir = new FlyParser().parse(JSON.stringify(bundle));
94
+
95
+ const selector = opts.selector;
96
+ if (!selector || (selector.type === undefined && selector.name === undefined)) {
97
+ return ir;
98
+ }
99
+ return {
100
+ ...ir,
101
+ resources: ir.resources.filter(
102
+ (r) =>
103
+ (selector.type === undefined || r.type === selector.type) &&
104
+ (selector.name === undefined || r.logicalId === selector.name),
105
+ ),
106
+ };
107
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { FlyParser } from "./parser";
3
+
4
+ const parser = new FlyParser();
5
+
6
+ describe("FlyParser", () => {
7
+ test("empty input returns empty resources", () => {
8
+ const ir = parser.parse("");
9
+ expect(ir.resources).toEqual([]);
10
+ expect(ir.parameters).toEqual([]);
11
+ });
12
+
13
+ test("serializer plan: /v1/apps body → App, app_name mapped to name", () => {
14
+ const plan = JSON.stringify({
15
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "my-app", org_slug: "personal" } },
16
+ });
17
+ const ir = parser.parse(plan);
18
+ expect(ir.resources).toHaveLength(1);
19
+ const app = ir.resources[0];
20
+ expect(app.type).toBe("Fly::Machines::App");
21
+ expect(app.logicalId).toBe("my-app");
22
+ expect(app.properties.name).toBe("my-app");
23
+ expect(app.properties.org_slug).toBe("personal");
24
+ // The wire field name never leaks into the authoring surface.
25
+ expect((app.properties as Record<string, unknown>).app_name).toBeUndefined();
26
+ });
27
+
28
+ test("serializer plan: machines endpoint → Machine with name/region/config", () => {
29
+ const plan = JSON.stringify({
30
+ web: {
31
+ endpoint: "/v1/apps/my-app/machines",
32
+ method: "POST",
33
+ body: { name: "web", region: "iad", config: { image: "nginx:1" } },
34
+ },
35
+ });
36
+ const ir = parser.parse(plan);
37
+ expect(ir.resources).toHaveLength(1);
38
+ const m = ir.resources[0];
39
+ expect(m.type).toBe("Fly::Machines::Machine");
40
+ expect(m.logicalId).toBe("web");
41
+ expect(m.properties.name).toBe("web");
42
+ expect(m.properties.region).toBe("iad");
43
+ expect(m.properties.config).toEqual({ image: "nginx:1" });
44
+ });
45
+
46
+ test("serializer plan with an App and a Machine → both resources", () => {
47
+ const plan = JSON.stringify({
48
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "my-app" } },
49
+ web: {
50
+ endpoint: "/v1/apps/my-app/machines",
51
+ method: "POST",
52
+ body: { name: "web", region: "iad", config: { image: "nginx:1" } },
53
+ },
54
+ });
55
+ const ir = parser.parse(plan);
56
+ expect(ir.resources.map((r) => r.type)).toEqual([
57
+ "Fly::Machines::App",
58
+ "Fly::Machines::Machine",
59
+ ]);
60
+ });
61
+
62
+ test("machines listing (array) → one Machine each", () => {
63
+ const listing = JSON.stringify([
64
+ { id: "m1", name: "web", region: "iad", state: "started", config: { image: "nginx:1" } },
65
+ { id: "m2", name: "worker", region: "iad", state: "started", config: { image: "redis:7" } },
66
+ ]);
67
+ const ir = parser.parse(listing);
68
+ expect(ir.resources.map((r) => r.logicalId)).toEqual(["web", "worker"]);
69
+ expect(ir.resources.every((r) => r.type === "Fly::Machines::Machine")).toBe(true);
70
+ });
71
+
72
+ test("machine object strips server-written read-only fields", () => {
73
+ const machine = JSON.stringify({
74
+ id: "abc123",
75
+ name: "web",
76
+ region: "iad",
77
+ state: "started",
78
+ instance_id: "INST0",
79
+ private_ip: "fdaa::1",
80
+ created_at: "2026-01-01T00:00:00Z",
81
+ updated_at: "2026-01-02T00:00:00Z",
82
+ nonce: "n1",
83
+ events: [{ type: "start" }],
84
+ checks: [{ status: "passing" }],
85
+ host_status: "ok",
86
+ image_ref: { tag: "latest" },
87
+ incomplete_config: { image: "x" },
88
+ config: { image: "nginx:1", metadata: { "managed-by": "chant" } },
89
+ });
90
+ const ir = parser.parse(machine);
91
+ const props = ir.resources[0].properties;
92
+ expect(props).toEqual({
93
+ name: "web",
94
+ region: "iad",
95
+ config: { image: "nginx:1", metadata: { "managed-by": "chant" } },
96
+ });
97
+ });
98
+
99
+ test("app-with-machines bundle → App plus its Machines", () => {
100
+ const bundle = JSON.stringify({
101
+ name: "my-app",
102
+ org_slug: "personal",
103
+ machines: [{ name: "web", region: "iad", config: { image: "nginx:1" } }],
104
+ });
105
+ const ir = parser.parse(bundle);
106
+ expect(ir.resources.map((r) => r.type)).toEqual([
107
+ "Fly::Machines::App",
108
+ "Fly::Machines::Machine",
109
+ ]);
110
+ expect(ir.resources[0].properties.name).toBe("my-app");
111
+ });
112
+
113
+ test("app GET shape: name + organization.slug → App with org_slug", () => {
114
+ const appGet = JSON.stringify({
115
+ id: "app-1",
116
+ name: "my-app",
117
+ status: "deployed",
118
+ machine_count: 2,
119
+ organization: { slug: "acme" },
120
+ });
121
+ const ir = parser.parse(appGet);
122
+ expect(ir.resources).toHaveLength(1);
123
+ expect(ir.resources[0].type).toBe("Fly::Machines::App");
124
+ expect(ir.resources[0].properties.name).toBe("my-app");
125
+ expect(ir.resources[0].properties.org_slug).toBe("acme");
126
+ });
127
+
128
+ test("parameters are always empty (flaps has no template parameters)", () => {
129
+ const ir = parser.parse(JSON.stringify({ app_name: "x" }));
130
+ expect(ir.parameters).toEqual([]);
131
+ });
132
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * flaps JSON template parser.
3
+ *
4
+ * Turns Fly Machines API ("flaps") JSON into the import IR for conversion to
5
+ * chant TypeScript. Three input shapes are accepted, all mapped to the authoring
6
+ * surface (the writable fields a user would have declared):
7
+ *
8
+ * 1. The #738 serializer plan — `{ <entity>: { endpoint, method, body } }`.
9
+ * `/v1/apps` bodies become `App`s; `.../machines` bodies become `Machine`s.
10
+ * 2. A machines listing (an array) or an app-with-machines bundle
11
+ * (`{ ...appFields, machines: [...] }`) — an `App` plus its `Machine`s.
12
+ * 3. A single machine (`{ name, region, config }`) or a single app
13
+ * (`{ app_name }` / the `GET /v1/apps/{app}` shape).
14
+ *
15
+ * flaps wire field names are mapped back to the authoring surface: an app's
16
+ * `app_name` becomes the `App`'s `name`, and server-written read-only machine
17
+ * fields (id, state, instance_id, ...) are dropped so the IR is the declared
18
+ * shape. `logicalId` is the app/machine name (the generator camelCases it into a
19
+ * variable).
20
+ */
21
+
22
+ import type {
23
+ TemplateParser,
24
+ TemplateIR,
25
+ ResourceIR,
26
+ } from "@intentius/chant/import/parser";
27
+ import { BaseValueParser } from "@intentius/chant/import/base-parser";
28
+ import { isSerializerPlan } from "../detect";
29
+
30
+ export const APP_TYPE = "Fly::Machines::App";
31
+ export const MACHINE_TYPE = "Fly::Machines::Machine";
32
+
33
+ /** Server-written read-only machine fields — never part of the authored shape. */
34
+ export const MACHINE_SERVER_FIELDS = [
35
+ "id",
36
+ "state",
37
+ "created_at",
38
+ "updated_at",
39
+ "private_ip",
40
+ "instance_id",
41
+ "nonce",
42
+ "checks",
43
+ "events",
44
+ "host_status",
45
+ "image_ref",
46
+ "incomplete_config",
47
+ ];
48
+
49
+ /** Server-written read-only app fields — never part of the authored shape. */
50
+ export const APP_SERVER_FIELDS = [
51
+ "id",
52
+ "status",
53
+ "machine_count",
54
+ "volume_count",
55
+ "internal_numeric_id",
56
+ "organization",
57
+ ];
58
+
59
+ function isRecord(v: unknown): v is Record<string, unknown> {
60
+ return typeof v === "object" && v !== null && !Array.isArray(v);
61
+ }
62
+
63
+ export class FlyParser extends BaseValueParser implements TemplateParser {
64
+ protected dispatchIntrinsic(
65
+ _key: string,
66
+ _value: unknown,
67
+ _obj: Record<string, unknown>,
68
+ ): unknown | null {
69
+ // flaps JSON has no intrinsic functions.
70
+ return null;
71
+ }
72
+
73
+ parse(input: string): TemplateIR {
74
+ const trimmed = input.trim();
75
+ if (!trimmed) return { resources: [], parameters: [] };
76
+
77
+ const data = JSON.parse(trimmed) as unknown;
78
+ const resources: ResourceIR[] = [];
79
+
80
+ if (isSerializerPlan(data)) {
81
+ for (const [entityName, req] of Object.entries(data as Record<string, unknown>)) {
82
+ const r = this.resourceFromRequest(entityName, req as Record<string, unknown>);
83
+ if (r) resources.push(r);
84
+ }
85
+ return { resources, parameters: [] };
86
+ }
87
+
88
+ if (Array.isArray(data)) {
89
+ for (const m of data) {
90
+ if (isRecord(m)) resources.push(this.machineResource(m));
91
+ }
92
+ return { resources, parameters: [] };
93
+ }
94
+
95
+ if (isRecord(data)) {
96
+ // App-with-machines bundle: top-level app fields plus a `machines` array.
97
+ if (Array.isArray(data.machines)) {
98
+ const { machines, ...appFields } = data;
99
+ if (this.looksLikeApp(appFields)) resources.push(this.appResource(appFields));
100
+ for (const m of machines) {
101
+ if (isRecord(m)) resources.push(this.machineResource(m));
102
+ }
103
+ return { resources, parameters: [] };
104
+ }
105
+
106
+ // A single machine (has a config) wins over an app read, since a machine
107
+ // GET also carries a `name`.
108
+ if (isRecord(data.config)) {
109
+ resources.push(this.machineResource(data));
110
+ return { resources, parameters: [] };
111
+ }
112
+
113
+ if (this.looksLikeApp(data)) {
114
+ resources.push(this.appResource(data));
115
+ return { resources, parameters: [] };
116
+ }
117
+ }
118
+
119
+ return { resources, parameters: [] };
120
+ }
121
+
122
+ /** Map one serializer-plan request to a resource by its endpoint. */
123
+ private resourceFromRequest(
124
+ entityName: string,
125
+ req: Record<string, unknown>,
126
+ ): ResourceIR | null {
127
+ const endpoint = typeof req.endpoint === "string" ? req.endpoint : "";
128
+ const body = isRecord(req.body) ? req.body : {};
129
+
130
+ if (/^\/v1\/apps\/?$/.test(endpoint)) {
131
+ return this.appResource(body, entityName);
132
+ }
133
+ if (/^\/v1\/apps\/[^/]+\/machines\/?$/.test(endpoint)) {
134
+ return this.machineResource(body, entityName);
135
+ }
136
+ // Other endpoints (volumes, ip_assignments, certificates, secrets) are not
137
+ // part of the App/Machine import surface.
138
+ return null;
139
+ }
140
+
141
+ private looksLikeApp(obj: Record<string, unknown>): boolean {
142
+ return typeof obj.app_name === "string" || typeof obj.name === "string";
143
+ }
144
+
145
+ /** An `App` resource. flaps `app_name` (create body) or `name` (GET) → `name`. */
146
+ private appResource(obj: Record<string, unknown>, entityName?: string): ResourceIR {
147
+ const name =
148
+ (typeof obj.app_name === "string" && obj.app_name) ||
149
+ (typeof obj.name === "string" && obj.name) ||
150
+ entityName ||
151
+ "app";
152
+
153
+ const properties: Record<string, unknown> = { name };
154
+ // org_slug either sits on the create body directly, or is carried on the GET
155
+ // shape under `organization.slug`.
156
+ if (obj.org_slug !== undefined) {
157
+ properties.org_slug = this.parseValue(obj.org_slug);
158
+ } else if (isRecord(obj.organization) && typeof obj.organization.slug === "string") {
159
+ properties.org_slug = obj.organization.slug;
160
+ }
161
+ if (obj.network !== undefined) properties.network = this.parseValue(obj.network);
162
+ if (obj.enable_subdomains !== undefined) {
163
+ properties.enable_subdomains = this.parseValue(obj.enable_subdomains);
164
+ }
165
+
166
+ return { logicalId: name, type: APP_TYPE, properties };
167
+ }
168
+
169
+ /** A `Machine` resource, stripped of server-written read-only fields. */
170
+ private machineResource(obj: Record<string, unknown>, entityName?: string): ResourceIR {
171
+ const name =
172
+ (typeof obj.name === "string" && obj.name) || entityName || "machine";
173
+
174
+ const properties: Record<string, unknown> = {};
175
+ for (const [key, value] of Object.entries(obj)) {
176
+ if (MACHINE_SERVER_FIELDS.includes(key)) continue;
177
+ if (value === undefined) continue;
178
+ properties[key] = this.parseValue(value);
179
+ }
180
+ // Always carry a name so the round-trip keeps the machine's identity.
181
+ properties.name = name;
182
+
183
+ return { logicalId: name, type: MACHINE_TYPE, properties };
184
+ }
185
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { writeFileSync, mkdtempSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { tmpdir } from "os";
5
+ import { fileURLToPath } from "url";
6
+ import { FlyParser } from "./parser";
7
+ import { FlyGenerator } from "./generator";
8
+ import { flySerializer } from "../serializer";
9
+ import { App, Machine, MachineConfig, MachineGuest } from "../generated/index";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const generatedIndex = join(here, "..", "generated", "index");
13
+
14
+ const parser = new FlyParser();
15
+ const generator = new FlyGenerator();
16
+
17
+ describe("roundtrip: flaps plan → parse → generate TypeScript", () => {
18
+ test("an App + Machine plan generates equivalent chant TS", () => {
19
+ const plan = JSON.stringify({
20
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: "my-app", org_slug: "personal" } },
21
+ web: {
22
+ endpoint: "/v1/apps/my-app/machines",
23
+ method: "POST",
24
+ body: { name: "web", region: "iad", config: { image: "nginx:1", guest: { cpu_kind: "shared", cpus: 1 } } },
25
+ },
26
+ });
27
+ const ts = generator.generate(parser.parse(plan))[0].content;
28
+ expect(ts).toContain("new App({");
29
+ expect(ts).toContain("new Machine({");
30
+ expect(ts).toContain("new MachineConfig({");
31
+ expect(ts).toContain('image: "nginx:1"');
32
+ expect(ts).toContain('region: "iad"');
33
+ });
34
+ });
35
+
36
+ describe("roundtrip: declared entities → serialize → parse → generate → re-serialize", () => {
37
+ test("the generated code compiles, runs, and re-serializes to equivalent flaps bodies", async () => {
38
+ // 1. Declare an App + Machine and serialize with the #738 serializer.
39
+ const app = new App({ name: "my-app", org_slug: "personal" });
40
+ const web = new Machine({
41
+ name: "web",
42
+ region: "iad",
43
+ config: new MachineConfig({
44
+ image: "flyio/hellofly:latest",
45
+ guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
46
+ }),
47
+ });
48
+ const entities = new Map<string, any>([
49
+ ["app", app],
50
+ ["web", web],
51
+ ]);
52
+ const planA = JSON.parse(flySerializer.serialize(entities));
53
+
54
+ // 2. Parse the plan and generate TypeScript.
55
+ const ir = parser.parse(JSON.stringify(planA));
56
+ const ts = generator.generate(ir)[0].content;
57
+
58
+ // 3. Execute the generated code: rewrite the package import to the local
59
+ // generated module, then dynamic-import it.
60
+ const runnable = ts.replace(
61
+ '"@intentius/chant-lexicon-fly"',
62
+ JSON.stringify(generatedIndex),
63
+ );
64
+ const file = join(mkdtempSync(join(tmpdir(), "fly-roundtrip-")), "main.ts");
65
+ writeFileSync(file, runnable);
66
+ const mod = await import(file);
67
+
68
+ // 4. Re-serialize the regenerated entities.
69
+ const regenerated = new Map<string, any>(Object.entries(mod));
70
+ const planB = JSON.parse(flySerializer.serialize(regenerated));
71
+
72
+ // The bodies survive the round-trip. Entity keys differ (variable names are
73
+ // camelCased from the resource name), so compare the create bodies by kind.
74
+ const bodies = (plan: Record<string, any>) => Object.values(plan).map((r) => r.body).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
75
+ expect(bodies(planB)).toEqual(bodies(planA));
76
+ });
77
+ });
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Plugin
2
+ export { flyPlugin } from "./plugin";
3
+
4
+ // Serializer
5
+ export { flySerializer } from "./serializer";
6
+
7
+ // Pseudo-parameters — environment-resolved values (`Fly.Region`, `Fly.OrgSlug`,
8
+ // `Fly.AppName`) usable in place of hard-coded strings.
9
+ export { Fly, Region, OrgSlug, AppName, PseudoParameter } from "./pseudo";
10
+
11
+ // Ownership marker convention (machine config.metadata keys)
12
+ export { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
13
+
14
+ // Deploy Op composite + typed step builders (#744). `flyDeploy` returns a
15
+ // `boot → build → flyApply → wait → teardown` Op; the step builders wrap the
16
+ // generic `activity()` so the fly activities resolve by name without a core change.
17
+ export { flyDeploy, flapsUp, flapsDown, flyApplyStep, LOCAL_FLAPS_ENDPOINT } from "./composites/fly-deploy";
18
+ export type { FlyDeployOpts, FlyApplyStepOpts, FlapsStepOpts } from "./composites/fly-deploy";
19
+
20
+ // Generated resources — export everything from generated index.
21
+ // Provides `App`, `Machine`, `Volume`, and the property types
22
+ // (`MachineConfig`, `MachineGuest`, `MachineService`, ...) for authoring.
23
+ export * from "./generated/index";