@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,169 @@
1
+ /**
2
+ * fly deploy Op (#744) — the integration proof: `boot → build → flyApply → wait
3
+ * → teardown` on the local executor. The peer of the aws/gcp deploy Ops in
4
+ * `examples/local-cloud-trio`, for a single cloud.
5
+ *
6
+ * The phases compose modeled activities, no raw shell:
7
+ * Emulator boot mudflaps (the Fly Machines API fake) via `flapsUp`
8
+ * Build run the project's `build:fly` npm script → the serialized plan
9
+ * Apply `flyApply` the plan straight to flaps (creates App + Machine and
10
+ * waits each machine to `started` via `/wait`)
11
+ * Verify GET the app's machines and assert one reached `started`
12
+ * Teardown stop and remove the mudflaps container via `flapsDown`
13
+ *
14
+ * The activity steps are the generic `activity(fn, args)` builder tagged with
15
+ * the exported activity function names the fly lexicon contributes
16
+ * (`flapsUp`/`flapsDown`/`flyApply` from `src/op/activities`). The core activity
17
+ * registry resolves them by name when a project lists the `fly` lexicon, so
18
+ * these typed builders give `gcpApply()`-style DX without a core change.
19
+ *
20
+ * D3 (local vs real Fly): the Apply step's flaps endpoint defaults to local
21
+ * mudflaps (`http://localhost:4280`). Pass `endpoint: null` to drop the override
22
+ * and let `flyApply` fall through to `FLY_FLAPS_BASE_URL` / real Fly (with a
23
+ * `FLY_API_TOKEN`).
24
+ */
25
+
26
+ import { Op, phase, build, activity, httpCheck } from "@intentius/chant/op";
27
+ import type { OpResource } from "@intentius/chant/op";
28
+ import type { ActivityStep } from "@intentius/chant/op";
29
+
30
+ /** The local mudflaps endpoint the deploy Op targets by default. */
31
+ export const LOCAL_FLAPS_ENDPOINT = "http://localhost:4280";
32
+
33
+ /** Options accepted by the mudflaps lifecycle step builders. */
34
+ export interface FlapsStepOpts {
35
+ /** Container name. Default: `chant-mudflaps`. */
36
+ name?: string;
37
+ /** Host port mapped to the emulator's `:4280`. Default: `4280`. */
38
+ port?: number;
39
+ /** Image. Default: `ghcr.io/intentius/mudflaps:0.3.0`. */
40
+ image?: string;
41
+ /** Readiness timeout in ms. */
42
+ timeoutMs?: number;
43
+ /** Health poll interval in ms. */
44
+ intervalMs?: number;
45
+ /** Activity profile override. */
46
+ profile?: ActivityStep["profile"];
47
+ }
48
+
49
+ /**
50
+ * Boot a local mudflaps (Fly Machines API emulator) in Docker and point the
51
+ * apply step at it — the typed twin of `flociGcpUp` for flaps. Resolves to the
52
+ * `flapsUp` activity (#740). Defaults to the `longInfra` profile (the image may
53
+ * pull); override via `opts.profile`.
54
+ */
55
+ export const flapsUp = (opts?: FlapsStepOpts): ActivityStep => {
56
+ const { profile, ...args } = opts ?? {};
57
+ return activity("flapsUp", args as Record<string, unknown>, profile ?? "longInfra");
58
+ };
59
+
60
+ /** Stop and remove the local mudflaps container. Resolves to the `flapsDown` activity. Defaults to the `fastIdempotent` profile (override via `opts.profile`). */
61
+ export const flapsDown = (opts?: FlapsStepOpts): ActivityStep => {
62
+ const { profile, ...args } = opts ?? {};
63
+ return activity("flapsDown", args as Record<string, unknown>, profile ?? "fastIdempotent");
64
+ };
65
+
66
+ /** Options for the flaps apply step (mirror `FlyApplyArgs`, minus `planPath`). */
67
+ export interface FlyApplyStepOpts {
68
+ /** flaps endpoint override (D3). Default: `FLY_FLAPS_BASE_URL` env, else real Fly. */
69
+ endpoint?: string;
70
+ /** Bearer token for real Fly. Default: `FLY_API_TOKEN`. mudflaps ignores it. */
71
+ token?: string;
72
+ /** Prune declared-then-removed resources (D2). Destructive — off by default. */
73
+ prune?: boolean;
74
+ /** Wait-loop tuning (mainly for tests). */
75
+ wait?: Record<string, unknown>;
76
+ /** Activity profile override. */
77
+ profile?: ActivityStep["profile"];
78
+ }
79
+
80
+ /**
81
+ * Apply the serialized flaps plan straight to the Machines API — the typed twin
82
+ * of `gcpApply` for fly. Resolves to the `flyApply` activity (#739): create
83
+ * App + Machine, wait each machine to `started`, prune owned-only when asked.
84
+ * Defaults to the `longInfra` profile (override via `opts.profile`).
85
+ */
86
+ export const flyApplyStep = (planPath: string, opts?: FlyApplyStepOpts): ActivityStep => {
87
+ const { profile, ...rest } = opts ?? {};
88
+ return activity("flyApply", { planPath, ...(rest as Record<string, unknown>) }, profile ?? "longInfra");
89
+ };
90
+
91
+ /** Options for {@link flyDeploy}. */
92
+ export interface FlyDeployOpts {
93
+ /** Op name (the `chant run <name>` target). Default: `fly`. */
94
+ name?: string;
95
+ /** Op overview line. */
96
+ overview?: string;
97
+ /** Temporal task queue (unused by the local executor). Default: `local-fly`. */
98
+ taskQueue?: string;
99
+ /** Directory the `build:fly` script and plan path are relative to. Default: `.`. */
100
+ path?: string;
101
+ /** npm build script to run. Default: `build:fly`. */
102
+ buildScript?: string;
103
+ /** Path to the serialized flaps plan the build writes. Default: `dist/fly.json`. */
104
+ planPath?: string;
105
+ /**
106
+ * flaps endpoint the apply + verify steps target (D3). Defaults to local
107
+ * mudflaps ({@link LOCAL_FLAPS_ENDPOINT}). Pass `null` to drop the override so
108
+ * `flyApply` targets real Fly via `FLY_FLAPS_BASE_URL` / a `FLY_API_TOKEN`.
109
+ */
110
+ endpoint?: string | null;
111
+ /**
112
+ * App name for the Verify step. When set (with a local `endpoint`), the Op GETs
113
+ * the app's machines and asserts one reached `started`. Omitted → no Verify
114
+ * step (the Apply step still waits each machine to `started`).
115
+ */
116
+ app?: string;
117
+ /** Prune declared-then-removed resources on apply (D2). Off by default. */
118
+ prune?: boolean;
119
+ /** Wait-loop tuning passed through to `flyApply` (mainly for tests). */
120
+ wait?: Record<string, unknown>;
121
+ }
122
+
123
+ /**
124
+ * Build a fly deploy Op: boot mudflaps, build the plan, apply App + Machine,
125
+ * verify the machine reached `started`, tear the emulator down. See the module
126
+ * docstring for the phase layout and the D3 endpoint story.
127
+ */
128
+ export function flyDeploy(opts: FlyDeployOpts = {}): InstanceType<typeof OpResource> {
129
+ const path = opts.path ?? ".";
130
+ const planPath = opts.planPath ?? "dist/fly.json";
131
+ const endpoint = opts.endpoint === undefined ? LOCAL_FLAPS_ENDPOINT : opts.endpoint;
132
+
133
+ const applyOpts: FlyApplyStepOpts = {
134
+ ...(endpoint ? { endpoint } : {}),
135
+ ...(opts.prune ? { prune: true } : {}),
136
+ ...(opts.wait ? { wait: opts.wait } : {}),
137
+ };
138
+
139
+ const phases = [
140
+ phase("Emulator", [flapsUp()]),
141
+ phase("Build", [build(path, { script: opts.buildScript ?? "build:fly" })]),
142
+ phase("Apply", [flyApplyStep(planPath, applyOpts)]),
143
+ ];
144
+
145
+ // Verify only makes sense against a reachable local endpoint: mudflaps needs
146
+ // no auth, so a plain GET of the app's machines can assert `started`. Against
147
+ // real Fly (no endpoint) the flaps API needs a bearer token httpCheck can't
148
+ // carry, so the Apply step's own `/wait` is the verification there.
149
+ if (endpoint && opts.app) {
150
+ phases.push(
151
+ phase("Verify", [
152
+ httpCheck(`${endpoint}/v1/apps/${opts.app}/machines`, {
153
+ contains: "started",
154
+ retries: 15,
155
+ intervalMs: 2000,
156
+ }),
157
+ ]),
158
+ );
159
+ }
160
+
161
+ phases.push(phase("Teardown", [flapsDown()]));
162
+
163
+ return Op({
164
+ name: opts.name ?? "fly",
165
+ overview: opts.overview ?? "Fly: App + Machine → mudflaps (direct flaps apply), local, no account",
166
+ taskQueue: opts.taskQueue ?? "local-fly",
167
+ phases,
168
+ });
169
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { describeResources, flyPlan } from "./describe-resources";
3
+ import type { FlyHttp } from "./op/activities/fly-apply";
4
+
5
+ // Injected-HTTP unit tests, mirroring fly-apply.test.ts: a scripted flaps answers
6
+ // list/get reads, and describeResources maps them to ResourceMetadata with the
7
+ // right ownership verdict.
8
+
9
+ const ENDPOINT = "http://localhost:4280";
10
+ const APP = "demo";
11
+ const OWNED_META = { "managed-by": "chant" };
12
+
13
+ /** A scripted flaps machine as list/get would return it. */
14
+ function liveMachine(
15
+ name: string,
16
+ image: string,
17
+ extra: Partial<{ id: string; state: string; instance_id: string; owned: boolean }> = {},
18
+ ): Record<string, unknown> {
19
+ const metadata = extra.owned === false ? { role: "db" } : { ...OWNED_META };
20
+ return {
21
+ id: extra.id ?? `m-${name}`,
22
+ name,
23
+ state: extra.state ?? "started",
24
+ instance_id: extra.instance_id ?? "INST0",
25
+ config: { image, metadata },
26
+ };
27
+ }
28
+
29
+ /** entities map keyed by declared entity name → its entityType (props unused here). */
30
+ function entities(pairs: Array<[string, string]>): Map<string, { entityType: string; props: Record<string, unknown> }> {
31
+ return new Map(pairs.map(([name, entityType]) => [name, { entityType, props: {} }]));
32
+ }
33
+
34
+ /**
35
+ * A fake flaps that serves an app GET, a machine list, and empty breadth lists.
36
+ * `machines` is the live machine array returned for the app.
37
+ */
38
+ function fakeHttp(machines: Array<Record<string, unknown>>): FlyHttp {
39
+ return async (method, url) => {
40
+ if (method === "GET" && /\/v1\/apps\/[^/]+$/.test(url)) return { status: 200, text: "{}" }; // app exists
41
+ if (method === "GET" && url.endsWith("/machines")) return { status: 200, text: JSON.stringify(machines) };
42
+ if (method === "GET" && url.endsWith("/volumes")) return { status: 200, text: "[]" };
43
+ if (method === "GET" && url.endsWith("/ip_assignments")) return { status: 200, text: JSON.stringify({ ips: [] }) };
44
+ if (method === "GET" && url.endsWith("/certificates")) return { status: 200, text: JSON.stringify({ certificates: [] }) };
45
+ return { status: 404, text: "" };
46
+ };
47
+ }
48
+
49
+ const PLAN = JSON.stringify({
50
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
51
+ web: {
52
+ endpoint: `/v1/apps/${APP}/machines`,
53
+ method: "POST",
54
+ body: { name: "web", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
55
+ },
56
+ });
57
+
58
+ const ENTS = entities([
59
+ ["app", "Fly::Machines::App"],
60
+ ["web", "Fly::Machines::Machine"],
61
+ ]);
62
+
63
+ describe("describeResources: live machines → ResourceMetadata verdicts", () => {
64
+ test("a managed-by:chant machine is owned; an unmarked one is foreign", async () => {
65
+ const http = fakeHttp([
66
+ liveMachine("web", "nginx:1", { id: "m-web" }), // declared + owned
67
+ liveMachine("legacy", "redis:1", { id: "m-legacy", owned: false }), // orphan + unmarked
68
+ ]);
69
+ const res = await describeResources(
70
+ { environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, endpoint: ENDPOINT },
71
+ http,
72
+ );
73
+
74
+ // Declared machine keyed by its entity name; orphan keyed by its live name.
75
+ expect(res.web.type).toBe("Fly::Machines::Machine");
76
+ expect(res.web.ownership).toBe("owned");
77
+ expect(res.web.physicalId).toBe("m-web");
78
+ expect(res.web.status).toBe("started");
79
+ expect((res.web.attributes as { config?: unknown }).config).toEqual({
80
+ image: "nginx:1",
81
+ metadata: { "managed-by": "chant" },
82
+ });
83
+
84
+ expect(res.legacy.ownership).toBe("foreign");
85
+ expect(res.legacy.type).toBe("Fly::Machines::Machine");
86
+
87
+ // App surfaced as owned (app-boundary: it carries an owned machine).
88
+ expect(res.app.type).toBe("Fly::Machines::App");
89
+ expect(res.app.ownership).toBe("owned");
90
+ });
91
+
92
+ test("the owned filter drops foreign entries", async () => {
93
+ const http = fakeHttp([
94
+ liveMachine("web", "nginx:1", { id: "m-web" }),
95
+ liveMachine("legacy", "redis:1", { id: "m-legacy", owned: false }),
96
+ ]);
97
+ const res = await describeResources(
98
+ { environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, owned: true, endpoint: ENDPOINT },
99
+ http,
100
+ );
101
+ expect(res.web?.ownership).toBe("owned");
102
+ expect(res.legacy).toBeUndefined(); // foreign machine filtered out
103
+ });
104
+
105
+ test("terminal machines (destroyed/destroying) are not reported live", async () => {
106
+ const http = fakeHttp([
107
+ liveMachine("web", "nginx:1"),
108
+ liveMachine("gone", "nginx:1", { state: "destroyed" }),
109
+ ]);
110
+ const res = await describeResources(
111
+ { environment: "prod", buildOutput: PLAN, entityNames: ["app", "web"], entities: ENTS, endpoint: ENDPOINT },
112
+ http,
113
+ );
114
+ expect(res.web).toBeDefined();
115
+ expect(res.gone).toBeUndefined();
116
+ });
117
+
118
+ test("empty build output returns nothing", async () => {
119
+ const res = await describeResources(
120
+ { environment: "prod", buildOutput: "", entityNames: [], entities: new Map(), endpoint: ENDPOINT },
121
+ fakeHttp([]),
122
+ );
123
+ expect(res).toEqual({});
124
+ });
125
+ });
126
+
127
+ describe("flyPlan: declared-vs-live change set (create/noop/delete/adopt)", () => {
128
+ test("classifies each action; an unmarked machine is never a delete", async () => {
129
+ // Declared: app, web (live+equal → noop), web2 (not live → create).
130
+ const plan = JSON.stringify({
131
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP, org_slug: "personal" } },
132
+ web: {
133
+ endpoint: `/v1/apps/${APP}/machines`,
134
+ method: "POST",
135
+ body: { name: "web", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
136
+ },
137
+ web2: {
138
+ endpoint: `/v1/apps/${APP}/machines`,
139
+ method: "POST",
140
+ body: { name: "web2", config: { image: "nginx:1", metadata: { ...OWNED_META } } },
141
+ },
142
+ });
143
+ const ents = entities([
144
+ ["app", "Fly::Machines::App"],
145
+ ["web", "Fly::Machines::Machine"],
146
+ ["web2", "Fly::Machines::Machine"],
147
+ ]);
148
+ const http = fakeHttp([
149
+ liveMachine("web", "nginx:1"), // declared + owned + config equal → noop
150
+ liveMachine("orphan", "nginx:1"), // owned + undeclared → delete
151
+ liveMachine("legacy", "redis:1", { owned: false }), // unmarked + undeclared → adopt
152
+ ]);
153
+
154
+ const { changeSet } = await flyPlan(
155
+ { environment: "prod", buildOutput: plan, entityNames: [...ents.keys()], entities: ents, endpoint: ENDPOINT },
156
+ http,
157
+ );
158
+ const action = (name: string) => changeSet.entries.find((e) => e.name === name)?.action;
159
+
160
+ expect(action("web")).toBe("noop");
161
+ expect(action("web2")).toBe("create");
162
+ expect(action("orphan")).toBe("delete");
163
+ expect(action("legacy")).toBe("adopt"); // unmarked → adopt, never delete
164
+ // No entry, anywhere, deletes an unmarked machine.
165
+ expect(changeSet.entries.filter((e) => e.action === "delete").map((e) => e.name)).toEqual(["orphan"]);
166
+ });
167
+
168
+ test("config drift on a declared machine → update (configEqual, matching apply)", async () => {
169
+ const plan = JSON.stringify({
170
+ app: { endpoint: "/v1/apps", method: "POST", body: { app_name: APP } },
171
+ web: {
172
+ endpoint: `/v1/apps/${APP}/machines`,
173
+ method: "POST",
174
+ body: { name: "web", config: { image: "nginx:2", metadata: { ...OWNED_META } } },
175
+ },
176
+ });
177
+ const ents = entities([
178
+ ["app", "Fly::Machines::App"],
179
+ ["web", "Fly::Machines::Machine"],
180
+ ]);
181
+ const http = fakeHttp([liveMachine("web", "nginx:1")]); // live image nginx:1 ≠ declared nginx:2
182
+ const { changeSet } = await flyPlan(
183
+ { environment: "prod", buildOutput: plan, entityNames: [...ents.keys()], entities: ents, endpoint: ENDPOINT },
184
+ http,
185
+ );
186
+ expect(changeSet.entries.find((e) => e.name === "web")?.action).toBe("update");
187
+ });
188
+ });
Binary file
package/src/detect.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Template detection for the fly lexicon.
3
+ *
4
+ * `chant import` parses a template file as JSON and asks each installed lexicon
5
+ * whether it recognizes the shape. fly's importable source is flaps JSON — the
6
+ * Machines API surface the #738 serializer emits and the applier POSTs. Three
7
+ * shapes are recognized:
8
+ *
9
+ * 1. The serializer plan: an object keyed by entity name whose values are
10
+ * `{ endpoint, method, body }` flaps requests (what #738 produces).
11
+ * 2. A machine, a machines listing, or an app-with-machines bundle: what
12
+ * `GET /v1/apps/{app}/machines` returns, or a single machine create body.
13
+ * 3. An app: a create body (`{ app_name }`) or the `GET /v1/apps/{app}` shape.
14
+ */
15
+
16
+ function isRecord(v: unknown): v is Record<string, unknown> {
17
+ return typeof v === "object" && v !== null && !Array.isArray(v);
18
+ }
19
+
20
+ /** A single flaps request as the serializer emits it. */
21
+ function isFlapsRequest(v: unknown): boolean {
22
+ if (!isRecord(v)) return false;
23
+ return typeof v.endpoint === "string" && v.endpoint.startsWith("/v1/apps") && "body" in v;
24
+ }
25
+
26
+ /** The serializer's whole output: entity name → flaps request. */
27
+ export function isSerializerPlan(v: unknown): boolean {
28
+ if (!isRecord(v)) return false;
29
+ const values = Object.values(v);
30
+ return values.length > 0 && values.every(isFlapsRequest);
31
+ }
32
+
33
+ /** A live or create-body machine: carries a `config` object, or the id+state+region GET shape. */
34
+ export function isMachineObject(v: unknown): boolean {
35
+ if (!isRecord(v)) return false;
36
+ if (isRecord(v.config)) return true;
37
+ return typeof v.id === "string" && typeof v.state === "string" && "region" in v;
38
+ }
39
+
40
+ /** An app: a create body (`app_name`) or the `GET /v1/apps/{app}` shape (`name` + machine_count/organization/status). */
41
+ export function isAppObject(v: unknown): boolean {
42
+ if (!isRecord(v)) return false;
43
+ if (typeof v.app_name === "string") return true;
44
+ return (
45
+ typeof v.name === "string" &&
46
+ ("machine_count" in v || "organization" in v || typeof v.status === "string")
47
+ );
48
+ }
49
+
50
+ export function detectTemplate(data: unknown): boolean {
51
+ if (isSerializerPlan(data)) return true;
52
+
53
+ if (Array.isArray(data)) {
54
+ return data.length > 0 && data.some(isMachineObject);
55
+ }
56
+
57
+ if (isRecord(data)) {
58
+ if (Array.isArray(data.machines)) return true;
59
+ if (isMachineObject(data)) return true;
60
+ if (isAppObject(data)) return true;
61
+ }
62
+
63
+ return false;
64
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Live export for the fly lexicon — implements `LexiconPlugin.exportResources()`
3
+ * so `chant import --from <fly-env>` regenerates existing Fly apps and machines
4
+ * as chant TypeScript.
5
+ *
6
+ * Reads live flaps state and hands it to the pure `./import/live-export`
7
+ * helpers, which strip server-written fields to the authored shape and map it to
8
+ * import IR via `FlyParser`. Endpoint + auth reuse the applier verbatim
9
+ * (`resolveEndpoint` / `defaultFlyHttp` → FLY_FLAPS_BASE_URL / FLY_API_TOKEN),
10
+ * and the machine listing reuses the applier's `listMachines`, so export reads
11
+ * the same target `flyApply` writes and `describeResources` observes.
12
+ *
13
+ * Which apps are exported: an app named by the selector, else `FLY_APP_NAME`,
14
+ * else every app in the org (`GET /v1/apps?org_slug=<FLY_ORG>`). All flaps I/O
15
+ * lives here; cleaning and IR-building are pure in `./import/live-export`.
16
+ */
17
+
18
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
19
+ import {
20
+ resolveEndpoint,
21
+ defaultFlyHttp,
22
+ listMachines,
23
+ type FlyHttp,
24
+ type ApplyCtx,
25
+ } from "./op/activities/fly-apply";
26
+ import { buildExportFromApp } from "./import/live-export";
27
+
28
+ const DEFAULT_ORG = "personal";
29
+
30
+ function parseJson(text: string): unknown {
31
+ try {
32
+ return JSON.parse(text);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function orgSlug(): string {
39
+ return process.env.FLY_ORG || process.env.FLY_ORG_SLUG || DEFAULT_ORG;
40
+ }
41
+
42
+ /** Resolve which apps to export: selector name → FLY_APP_NAME → org listing. */
43
+ async function resolveApps(
44
+ ctx: ApplyCtx,
45
+ selector: ResourceSelector | undefined,
46
+ http: FlyHttp,
47
+ signal?: AbortSignal,
48
+ ): Promise<string[]> {
49
+ if (selector?.name) return [selector.name];
50
+ if (process.env.FLY_APP_NAME) return [process.env.FLY_APP_NAME];
51
+
52
+ const res = await http(
53
+ "GET",
54
+ `${ctx.base}/v1/apps?org_slug=${encodeURIComponent(orgSlug())}`,
55
+ undefined,
56
+ undefined,
57
+ signal,
58
+ );
59
+ if (res.status >= 300) return [];
60
+ const body = parseJson(res.text);
61
+ const apps = Array.isArray(body)
62
+ ? body
63
+ : body && typeof body === "object" && Array.isArray((body as { apps?: unknown[] }).apps)
64
+ ? (body as { apps: unknown[] }).apps
65
+ : [];
66
+ return apps
67
+ .map((a) => (a && typeof a === "object" ? (a as { name?: unknown }).name : undefined))
68
+ .filter((n): n is string => typeof n === "string");
69
+ }
70
+
71
+ export async function exportResources(
72
+ options: {
73
+ environment: string;
74
+ selector?: ResourceSelector;
75
+ owned?: boolean;
76
+ verbatim?: boolean;
77
+ },
78
+ http: FlyHttp = defaultFlyHttp(),
79
+ signal?: AbortSignal,
80
+ ): Promise<ExportedTemplate> {
81
+ const ctx: ApplyCtx = { base: resolveEndpoint({}) };
82
+ const apps = await resolveApps(ctx, options.selector, http, signal);
83
+ if (apps.length === 0) return { resources: [], parameters: [] };
84
+
85
+ // The app-boundary log fires at most once per call, only under `owned`, when
86
+ // the marker-less app type is reached (mirrors describe-resources).
87
+ let loggedBoundary = false;
88
+ const onBoundaryInference = (): void => {
89
+ if (loggedBoundary) return;
90
+ loggedBoundary = true;
91
+ console.warn(
92
+ "[fly] apps carry no per-resource ownership marker — ownership is inferred at the app boundary (#741/#743); only apps with a chant-managed machine are exported under --owned",
93
+ );
94
+ };
95
+
96
+ const resources: ExportedTemplate["resources"] = [];
97
+ for (const app of apps) {
98
+ const appRes = await http(
99
+ "GET",
100
+ `${ctx.base}/v1/apps/${encodeURIComponent(app)}`,
101
+ undefined,
102
+ undefined,
103
+ signal,
104
+ );
105
+ const appBody = appRes.status === 200 ? parseJson(appRes.text) : undefined;
106
+ const appObj =
107
+ appBody && typeof appBody === "object" && !Array.isArray(appBody)
108
+ ? (appBody as Record<string, unknown>)
109
+ : { name: app };
110
+
111
+ const machines = await listMachines(ctx, app, http, signal);
112
+
113
+ const ir = buildExportFromApp(appObj, machines, {
114
+ verbatim: options.verbatim,
115
+ selector: options.selector,
116
+ owned: options.owned,
117
+ onBoundaryInference,
118
+ });
119
+ resources.push(...ir.resources);
120
+ }
121
+
122
+ return { resources, parameters: [] };
123
+ }
File without changes