@intentius/chant-lexicon-render 0.53.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 (168) hide show
  1. package/README.md +89 -0
  2. package/dist/catalog.d.ts +100 -0
  3. package/dist/catalog.d.ts.map +1 -0
  4. package/dist/codegen/docs-cli.d.ts +3 -0
  5. package/dist/codegen/docs-cli.d.ts.map +1 -0
  6. package/dist/codegen/docs.d.ts +21 -0
  7. package/dist/codegen/docs.d.ts.map +1 -0
  8. package/dist/codegen/generate-cli.d.ts +6 -0
  9. package/dist/codegen/generate-cli.d.ts.map +1 -0
  10. package/dist/codegen/generate-lexicon.d.ts +18 -0
  11. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  12. package/dist/codegen/generate-typescript.d.ts +12 -0
  13. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +15 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/naming.d.ts +14 -0
  17. package/dist/codegen/naming.d.ts.map +1 -0
  18. package/dist/codegen/package.d.ts +17 -0
  19. package/dist/codegen/package.d.ts.map +1 -0
  20. package/dist/composites/render-deploy.d.ts +88 -0
  21. package/dist/composites/render-deploy.d.ts.map +1 -0
  22. package/dist/coverage.d.ts +15 -0
  23. package/dist/coverage.d.ts.map +1 -0
  24. package/dist/describe-resources.d.ts +60 -0
  25. package/dist/describe-resources.d.ts.map +1 -0
  26. package/dist/detect.d.ts +12 -0
  27. package/dist/detect.d.ts.map +1 -0
  28. package/dist/generated/index.d.ts +38 -0
  29. package/dist/generated/index.d.ts.map +1 -0
  30. package/dist/generated/runtime.d.ts +5 -0
  31. package/dist/generated/runtime.d.ts.map +1 -0
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/integrity.json +18 -0
  35. package/dist/lint/audit-catalog.d.ts +13 -0
  36. package/dist/lint/audit-catalog.d.ts.map +1 -0
  37. package/dist/lint/post-synth/index.d.ts +3 -0
  38. package/dist/lint/post-synth/index.d.ts.map +1 -0
  39. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.d.ts +12 -0
  40. package/dist/lint/post-synth/ren010-native-runtime-requires-commands.d.ts.map +1 -0
  41. package/dist/lint/post-synth/ren011-service-requires-source.d.ts +12 -0
  42. package/dist/lint/post-synth/ren011-service-requires-source.d.ts.map +1 -0
  43. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.d.ts +11 -0
  44. package/dist/lint/post-synth/ren012-free-plan-cannot-scale.d.ts.map +1 -0
  45. package/dist/lint/post-synth/render-helpers.d.ts +19 -0
  46. package/dist/lint/post-synth/render-helpers.d.ts.map +1 -0
  47. package/dist/lint/rules/index.d.ts +7 -0
  48. package/dist/lint/rules/index.d.ts.map +1 -0
  49. package/dist/lint/rules/no-secret-literals.d.ts +13 -0
  50. package/dist/lint/rules/no-secret-literals.d.ts.map +1 -0
  51. package/dist/lint/rules/valid-cron-schedule.d.ts +12 -0
  52. package/dist/lint/rules/valid-cron-schedule.d.ts.map +1 -0
  53. package/dist/lint/rules/valid-region.d.ts +16 -0
  54. package/dist/lint/rules/valid-region.d.ts.map +1 -0
  55. package/dist/lsp/completions.d.ts +8 -0
  56. package/dist/lsp/completions.d.ts.map +1 -0
  57. package/dist/lsp/hover.d.ts +6 -0
  58. package/dist/lsp/hover.d.ts.map +1 -0
  59. package/dist/manifest.json +6 -0
  60. package/dist/meta.json +304 -0
  61. package/dist/okf/index.md +29 -0
  62. package/dist/okf/rules/REN001.md +11 -0
  63. package/dist/okf/rules/REN002.md +15 -0
  64. package/dist/okf/rules/REN003.md +15 -0
  65. package/dist/okf/rules/REN010.md +11 -0
  66. package/dist/okf/rules/REN011.md +11 -0
  67. package/dist/okf/rules/REN012.md +11 -0
  68. package/dist/okf/types/BackgroundWorker.md +24 -0
  69. package/dist/okf/types/CronJob.md +28 -0
  70. package/dist/okf/types/CustomDomain.md +14 -0
  71. package/dist/okf/types/Disk.md +16 -0
  72. package/dist/okf/types/EnvGroup.md +22 -0
  73. package/dist/okf/types/Environment.md +17 -0
  74. package/dist/okf/types/KeyValue.md +20 -0
  75. package/dist/okf/types/Postgres.md +29 -0
  76. package/dist/okf/types/PrivateService.md +24 -0
  77. package/dist/okf/types/Project.md +15 -0
  78. package/dist/okf/types/RegistryCredential.md +17 -0
  79. package/dist/okf/types/StaticSite.md +24 -0
  80. package/dist/okf/types/WebService.md +24 -0
  81. package/dist/okf/types/Webhook.md +17 -0
  82. package/dist/op/activities/fake-render.d.ts +52 -0
  83. package/dist/op/activities/fake-render.d.ts.map +1 -0
  84. package/dist/op/activities/index.d.ts +10 -0
  85. package/dist/op/activities/index.d.ts.map +1 -0
  86. package/dist/op/activities/render-apply.d.ts +229 -0
  87. package/dist/op/activities/render-apply.d.ts.map +1 -0
  88. package/dist/ownership.d.ts +32 -0
  89. package/dist/ownership.d.ts.map +1 -0
  90. package/dist/package-cli.d.ts +3 -0
  91. package/dist/package-cli.d.ts.map +1 -0
  92. package/dist/plugin.d.ts +8 -0
  93. package/dist/plugin.d.ts.map +1 -0
  94. package/dist/pseudo.d.ts +21 -0
  95. package/dist/pseudo.d.ts.map +1 -0
  96. package/dist/rules/no-secret-literals.ts +73 -0
  97. package/dist/rules/ren010-native-runtime-requires-commands.ts +47 -0
  98. package/dist/rules/ren011-service-requires-source.ts +50 -0
  99. package/dist/rules/ren012-free-plan-cannot-scale.ts +43 -0
  100. package/dist/rules/render-helpers.ts +38 -0
  101. package/dist/rules/valid-cron-schedule.ts +51 -0
  102. package/dist/rules/valid-region.ts +49 -0
  103. package/dist/serializer.d.ts +99 -0
  104. package/dist/serializer.d.ts.map +1 -0
  105. package/dist/skills/chant-render-patterns.md +126 -0
  106. package/dist/skills/chant-render.md +112 -0
  107. package/dist/spec/fetch.d.ts +21 -0
  108. package/dist/spec/fetch.d.ts.map +1 -0
  109. package/dist/spec/parse.d.ts +120 -0
  110. package/dist/spec/parse.d.ts.map +1 -0
  111. package/dist/types/index.d.ts +573 -0
  112. package/dist/validate-cli.d.ts +3 -0
  113. package/dist/validate-cli.d.ts.map +1 -0
  114. package/dist/validate.d.ts +15 -0
  115. package/dist/validate.d.ts.map +1 -0
  116. package/package.json +73 -0
  117. package/src/catalog.ts +286 -0
  118. package/src/codegen/docs-cli.ts +7 -0
  119. package/src/codegen/docs.ts +120 -0
  120. package/src/codegen/generate-cli.ts +36 -0
  121. package/src/codegen/generate-lexicon.ts +52 -0
  122. package/src/codegen/generate-typescript.ts +70 -0
  123. package/src/codegen/generate.ts +106 -0
  124. package/src/codegen/naming.ts +55 -0
  125. package/src/codegen/package.ts +30 -0
  126. package/src/composites/render-deploy.ts +133 -0
  127. package/src/coverage.ts +49 -0
  128. package/src/describe-resources.test.ts +143 -0
  129. package/src/describe-resources.ts +366 -0
  130. package/src/detect.ts +29 -0
  131. package/src/generated/index.d.ts +573 -0
  132. package/src/generated/index.ts +43 -0
  133. package/src/generated/lexicon-render.json +304 -0
  134. package/src/generated/runtime.ts +4 -0
  135. package/src/index.ts +30 -0
  136. package/src/lint/audit-catalog.ts +47 -0
  137. package/src/lint/post-synth/index.ts +11 -0
  138. package/src/lint/post-synth/post-synth.test.ts +151 -0
  139. package/src/lint/post-synth/ren010-native-runtime-requires-commands.ts +47 -0
  140. package/src/lint/post-synth/ren011-service-requires-source.ts +50 -0
  141. package/src/lint/post-synth/ren012-free-plan-cannot-scale.ts +43 -0
  142. package/src/lint/post-synth/render-helpers.ts +38 -0
  143. package/src/lint/rules/index.ts +11 -0
  144. package/src/lint/rules/no-secret-literals.ts +73 -0
  145. package/src/lint/rules/rules.test.ts +73 -0
  146. package/src/lint/rules/valid-cron-schedule.ts +51 -0
  147. package/src/lint/rules/valid-region.ts +49 -0
  148. package/src/lsp/completions.test.ts +28 -0
  149. package/src/lsp/completions.ts +22 -0
  150. package/src/lsp/hover.test.ts +32 -0
  151. package/src/lsp/hover.ts +50 -0
  152. package/src/op/activities/fake-render.ts +275 -0
  153. package/src/op/activities/index.ts +51 -0
  154. package/src/op/activities/render-apply.test.ts +403 -0
  155. package/src/op/activities/render-apply.ts +1042 -0
  156. package/src/ownership.ts +44 -0
  157. package/src/package-cli.ts +23 -0
  158. package/src/plugin.test.ts +54 -0
  159. package/src/plugin.ts +258 -0
  160. package/src/pseudo.ts +26 -0
  161. package/src/serializer.test.ts +221 -0
  162. package/src/serializer.ts +286 -0
  163. package/src/skills/chant-render-patterns.md +126 -0
  164. package/src/skills/chant-render.md +112 -0
  165. package/src/spec/fetch.ts +33 -0
  166. package/src/spec/parse.ts +721 -0
  167. package/src/validate-cli.ts +5 -0
  168. package/src/validate.ts +47 -0
@@ -0,0 +1,403 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { Declarable } from "@intentius/chant";
6
+ import { resolveAttrRefs } from "@intentius/chant/discovery/resolve";
7
+ import { serializeRender } from "../../serializer";
8
+ import { CATALOG, ENTITY_TYPES } from "../../catalog";
9
+ import {
10
+ WebService,
11
+ WebServiceDetails,
12
+ NativeEnvironmentDetails,
13
+ Postgres,
14
+ EnvGroup,
15
+ Project,
16
+ ProjectEnvironment,
17
+ Environment,
18
+ Disk,
19
+ CustomDomain,
20
+ BackgroundWorker,
21
+ BackgroundWorkerDetails,
22
+ } from "../../generated/index";
23
+ import { FakeRender } from "./fake-render";
24
+ import {
25
+ renderApply,
26
+ renderApplyDetailed,
27
+ renderDelete,
28
+ orderPlan,
29
+ diffForPatch,
30
+ envVarsDiffer,
31
+ isChantOwned,
32
+ inStack,
33
+ planOwnership,
34
+ resolveEndpoint,
35
+ resolveOwner,
36
+ waitForDeploy,
37
+ type RenderHttp,
38
+ } from "./render-apply";
39
+
40
+ function stack(...entries: Array<[string, unknown]>): Map<string, Declarable> {
41
+ return new Map(entries as Array<[string, Declarable]>);
42
+ }
43
+
44
+ /** Serialize a stack to a plan file and return its path. */
45
+ function planFile(entities: Map<string, Declarable>, ownership?: { stack: string; env?: string }): string {
46
+ resolveAttrRefs(entities);
47
+ const dir = mkdtempSync(join(tmpdir(), "render-plan-"));
48
+ const path = join(dir, "render.json");
49
+ writeFileSync(path, serializeRender(entities, undefined, ownership ? { ownership } : undefined));
50
+ return path;
51
+ }
52
+
53
+ const web = () =>
54
+ new WebService({
55
+ name: "web",
56
+ repo: "https://github.com/render-examples/express-hello-world",
57
+ serviceDetails: new WebServiceDetails({
58
+ runtime: "node",
59
+ plan: "starter",
60
+ envSpecificDetails: new NativeEnvironmentDetails({ buildCommand: "npm ci", startCommand: "npm start" }),
61
+ }),
62
+ envVars: [{ key: "LOG_LEVEL", value: "info" }],
63
+ });
64
+
65
+ describe("render-apply pure helpers", () => {
66
+ afterEach(() => {
67
+ delete process.env.RENDER_API_BASE_URL;
68
+ delete process.env.RENDER_OWNER_ID;
69
+ });
70
+
71
+ it("resolveEndpoint: arg > env > default, trailing slash stripped", () => {
72
+ expect(resolveEndpoint({}, {})).toBe("https://api.render.com/v1");
73
+ expect(resolveEndpoint({}, { RENDER_API_BASE_URL: "http://localhost:9999/v1/" })).toBe("http://localhost:9999/v1");
74
+ expect(resolveEndpoint({ endpoint: "http://x/" }, { RENDER_API_BASE_URL: "http://y" })).toBe("http://x");
75
+ });
76
+
77
+ it("orderPlan: catalog order first, then references", () => {
78
+ const db = new Postgres({ name: "db", plan: "free", version: "16" });
79
+ const project = new Project({ name: "p", environments: [] });
80
+ const env = new Environment({ name: "prod", projectId: project });
81
+ const w = new WebService({
82
+ name: "web",
83
+ environmentId: env,
84
+ serviceDetails: new WebServiceDetails({ runtime: "docker" }),
85
+ envVars: [{ key: "DATABASE_URL", value: db.internalConnectionString }],
86
+ });
87
+ const entities = stack(["web", w], ["disk", new Disk({ name: "d", sizeGB: 1, mountPath: "/d", serviceId: w })], ["db", db], ["env", env], ["project", project]);
88
+ resolveAttrRefs(entities);
89
+ const plan = JSON.parse(serializeRender(entities));
90
+ expect(orderPlan(plan).map(([n]) => n)).toEqual(["project", "env", "db", "web", "disk"]);
91
+ });
92
+
93
+ it("orderPlan: a dangling reference is an error", () => {
94
+ expect(() =>
95
+ orderPlan({
96
+ d: { kind: "Disk", entityType: ENTITY_TYPES.disk, endpoint: "/disks", method: "POST", name: "d", body: { serviceId: { $ref: "nope" } } },
97
+ }),
98
+ ).toThrow(/references "nope"/);
99
+ });
100
+
101
+ it("diffForPatch: only declared patchable fields that differ; nested subset compare", () => {
102
+ const entry = CATALOG[ENTITY_TYPES.webService];
103
+ const live = { name: "web", ownerId: "tea-1", branch: "main", serviceDetails: { runtime: "node", plan: "starter", numInstances: 1, region: "oregon" } };
104
+ expect(diffForPatch(entry, { name: "web", ownerId: "tea-2", branch: "main", serviceDetails: { runtime: "node", plan: "starter" } }, live)).toBeUndefined();
105
+ expect(diffForPatch(entry, { branch: "dev" }, live)).toEqual({ branch: "dev" });
106
+ expect(diffForPatch(entry, { serviceDetails: { plan: "standard" } }, live)).toEqual({ serviceDetails: { plan: "standard" } });
107
+ });
108
+
109
+ it("envVarsDiffer: generated values match anything; key sets and values compared", () => {
110
+ expect(envVarsDiffer([{ key: "A", value: "1" }], { A: "1" })).toBe(false);
111
+ expect(envVarsDiffer([{ key: "A", value: "2" }], { A: "1" })).toBe(true);
112
+ expect(envVarsDiffer([{ key: "A", generateValue: true }], { A: "whatever" })).toBe(false);
113
+ expect(envVarsDiffer([{ key: "A", value: "1" }, { key: "B", value: "2" }], { A: "1" })).toBe(true);
114
+ });
115
+
116
+ it("ownership: marker detection and stack scoping", () => {
117
+ expect(isChantOwned({ CHANT_MANAGED_BY: "chant" })).toBe(true);
118
+ expect(isChantOwned({ CHANT_MANAGED_BY: "terraform" })).toBe(false);
119
+ expect(isChantOwned({})).toBe(false);
120
+ expect(inStack({ stack: "a" }, undefined)).toBe(true);
121
+ expect(inStack({ stack: "a" }, { stack: "" })).toBe(true);
122
+ expect(inStack({ stack: "a" }, { stack: "b" })).toBe(false);
123
+ expect(inStack({ stack: "a", env: "prod" }, { stack: "a", env: "dev" })).toBe(false);
124
+ expect(inStack(undefined, { stack: "a" })).toBe(false);
125
+ const plan = JSON.parse(serializeRender(stack(["web", web()]), undefined, { ownership: { stack: "shop", env: "prod" } }));
126
+ expect(planOwnership(plan)).toEqual({ stack: "shop", env: "prod" });
127
+ });
128
+
129
+ it("resolveOwner: arg, env, sole visible owner, else error", async () => {
130
+ const fake = new FakeRender();
131
+ const ctx = { base: "http://fake/v1" };
132
+ expect(await resolveOwner(ctx, { ownerId: "tea-x" }, fake.http(), undefined, {})).toBe("tea-x");
133
+ expect(await resolveOwner({ base: "http://fake/v1" }, {}, fake.http(), undefined, { RENDER_OWNER_ID: "tea-env" })).toBe("tea-env");
134
+ expect(await resolveOwner({ base: "http://fake/v1" }, {}, fake.http(), undefined, {})).toBe("tea-1");
135
+ fake.owners.push({ id: "tea-2", name: "Other", email: "", type: "team" });
136
+ await expect(resolveOwner({ base: "http://fake/v1" }, {}, fake.http(), undefined, {})).rejects.toThrow(/2 workspaces/);
137
+ });
138
+
139
+ it("waitForDeploy: polls to live, throws on failure", async () => {
140
+ const fake = new FakeRender();
141
+ const svc = fake.seed("/services", { name: "s", type: "web_service", ownerId: "tea-1" });
142
+ const id = svc.id as string;
143
+ fake.deploys.set(id, [{ id: "dep-1", status: "build_in_progress" }]);
144
+ fake.deployPolls = 2;
145
+ let t = 0;
146
+ const status = await waitForDeploy({ base: "http://fake/v1" }, id, "dep-1", { intervalMs: 0 }, fake.http(), undefined, () => (t += 1000));
147
+ expect(status).toBe("live");
148
+
149
+ fake.deploys.set(id, [{ id: "dep-2", status: "build_failed" }]);
150
+ await expect(waitForDeploy({ base: "http://fake/v1" }, id, "dep-2", { intervalMs: 0 }, fake.http())).rejects.toThrow(/build_failed/);
151
+ });
152
+ });
153
+
154
+ describe("renderApply against the fake API", () => {
155
+ afterEach(() => {
156
+ delete process.env.RENDER_OWNER_ID;
157
+ });
158
+
159
+ it("creates a full stack in dependency order, resolving refs, attrs, and the owner", async () => {
160
+ const fake = new FakeRender();
161
+ const db = new Postgres({ name: "db", plan: "free", version: "16" });
162
+ const project = new Project({ name: "shop", environments: [new ProjectEnvironment({ name: "prod" })] });
163
+ const env = new Environment({ name: "prod", projectId: project });
164
+ const w = new WebService({
165
+ name: "web",
166
+ environmentId: env,
167
+ serviceDetails: new WebServiceDetails({ runtime: "docker" }),
168
+ envVars: [{ key: "DATABASE_URL", value: db.internalConnectionString }, { key: "SECRET", generateValue: true }],
169
+ });
170
+ const group = new EnvGroup({ name: "shared", envVars: [{ key: "A", value: "1" }], serviceIds: [w] });
171
+ const path = planFile(
172
+ stack(
173
+ ["group", group],
174
+ ["domain", new CustomDomain({ name: "example.com", serviceId: w })],
175
+ ["disk", new Disk({ name: "data", sizeGB: 5, mountPath: "/data", serviceId: w })],
176
+ ["web", w],
177
+ ["db", db],
178
+ ["env", env],
179
+ ["project", project],
180
+ ),
181
+ { stack: "shop" },
182
+ );
183
+
184
+ const result = await renderApply({ planPath: path, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
185
+
186
+ expect(result.apply).toBe("v1");
187
+ expect(result.applied.map((a) => `${a.kind}/${a.name}:${a.action}`)).toEqual([
188
+ "Project/shop:created",
189
+ "Environment/prod:created",
190
+ "Postgres/db:created",
191
+ "WebService/web:created",
192
+ "EnvGroup/shared:created",
193
+ "CustomDomain/example.com:created",
194
+ "Disk/data:created",
195
+ ]);
196
+
197
+ // Owner resolved from the sole visible workspace.
198
+ const svc = fake.service("web")!;
199
+ expect(svc.ownerId).toBe("tea-1");
200
+ // Environment got the project's live id; the service got the environment's.
201
+ const envRec = [...fake.collections["/environments"].items.values()][0];
202
+ const prjRec = [...fake.collections["/projects"].items.values()][0];
203
+ expect(envRec.projectId).toBe(prjRec.id);
204
+ expect(svc.environmentId).toBe(envRec.id);
205
+ // The connection string was read from /connection-info; the marker was stamped.
206
+ expect(fake.serviceEnv.get(svc.id as string)).toEqual([
207
+ { key: "DATABASE_URL", value: "internal://db" },
208
+ { key: "SECRET", value: "gen-SECRET" },
209
+ { key: "CHANT_MANAGED_BY", value: "chant" },
210
+ { key: "CHANT_STACK", value: "shop" },
211
+ ]);
212
+ // Disk and domain hang off the live service id.
213
+ const disk = [...fake.collections["/disks"].items.values()][0];
214
+ expect(disk.serviceId).toBe(svc.id);
215
+ expect(fake.domains.get(svc.id as string)?.[0].name).toBe("example.com");
216
+ // Env group linked to the service.
217
+ const grp = [...fake.collections["/env-groups"].items.values()][0];
218
+ expect((grp.serviceLinks as Array<{ id: string }>).map((l) => l.id)).toEqual([svc.id]);
219
+ // The created service's deploy was awaited.
220
+ expect(fake.calls.some((c) => c.method === "GET" && /\/deploys\//.test(c.path))).toBe(true);
221
+ });
222
+
223
+ it("is idempotent: a second apply is all unchanged, and a changed field is a PATCH", async () => {
224
+ const fake = new FakeRender();
225
+ process.env.RENDER_OWNER_ID = "tea-1";
226
+ const first = planFile(stack(["web", web()]));
227
+ await renderApply({ planPath: first, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
228
+ fake.calls.length = 0;
229
+
230
+ const again = await renderApply({ planPath: first, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
231
+ expect(again.applied).toEqual([{ kind: "WebService", name: "web", action: "unchanged", physicalId: fake.service("web")!.id }]);
232
+ expect(fake.calls.filter((c) => c.method !== "GET")).toEqual([]);
233
+
234
+ const changed = planFile(
235
+ stack([
236
+ "web",
237
+ new WebService({
238
+ name: "web",
239
+ repo: "https://github.com/render-examples/express-hello-world",
240
+ branch: "release",
241
+ serviceDetails: new WebServiceDetails({ runtime: "node", plan: "standard" }),
242
+ envVars: [{ key: "LOG_LEVEL", value: "debug" }],
243
+ }),
244
+ ]),
245
+ );
246
+ const updated = await renderApply({ planPath: changed, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
247
+ expect(updated.applied[0].action).toBe("updated");
248
+ const svc = fake.service("web")!;
249
+ expect(svc.branch).toBe("release");
250
+ expect((svc.serviceDetails as { plan: string }).plan).toBe("standard");
251
+ expect(fake.serviceEnv.get(svc.id as string)).toEqual([
252
+ { key: "LOG_LEVEL", value: "debug" },
253
+ { key: "CHANT_MANAGED_BY", value: "chant" },
254
+ ]);
255
+ const patchCall = fake.calls.find((c) => c.method === "PATCH");
256
+ expect(patchCall?.body).toEqual({ branch: "release", serviceDetails: { runtime: "node", plan: "standard" } });
257
+ });
258
+
259
+ it("keeps a generated env var's live value on update instead of regenerating it", async () => {
260
+ const fake = new FakeRender();
261
+ process.env.RENDER_OWNER_ID = "tea-1";
262
+ const mk = (extra: string) =>
263
+ new WebService({
264
+ name: "web",
265
+ serviceDetails: new WebServiceDetails({ runtime: "docker" }),
266
+ envVars: [{ key: "SECRET", generateValue: true }, { key: "X", value: extra }],
267
+ });
268
+ await renderApply({ planPath: planFile(stack(["web", mk("1")])), endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
269
+ const id = fake.service("web")!.id as string;
270
+ fake.serviceEnv.set(id, [{ key: "SECRET", value: "s3cr3t" }, { key: "X", value: "1" }, { key: "CHANT_MANAGED_BY", value: "chant" }]);
271
+ await renderApply({ planPath: planFile(stack(["web", mk("2")])), endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
272
+ expect(fake.serviceEnv.get(id)).toEqual([
273
+ { key: "SECRET", value: "s3cr3t" },
274
+ { key: "X", value: "2" },
275
+ { key: "CHANT_MANAGED_BY", value: "chant" },
276
+ ]);
277
+ });
278
+
279
+ it("prunes only chant-owned services of this stack, never foreign or other-stack ones", async () => {
280
+ const fake = new FakeRender();
281
+ process.env.RENDER_OWNER_ID = "tea-1";
282
+ fake.seed("/services", { name: "foreign", type: "web_service", ownerId: "tea-1", envVars: [{ key: "A", value: "1" }] });
283
+ fake.seed("/services", { name: "other-stack", type: "web_service", ownerId: "tea-1", envVars: [{ key: "CHANT_MANAGED_BY", value: "chant" }, { key: "CHANT_STACK", value: "other" }] });
284
+ fake.seed("/services", { name: "stale", type: "background_worker", ownerId: "tea-1", envVars: [{ key: "CHANT_MANAGED_BY", value: "chant" }, { key: "CHANT_STACK", value: "shop" }] });
285
+ fake.seed("/env-groups", { name: "stale-group", ownerId: "tea-1", envVars: [{ key: "CHANT_MANAGED_BY", value: "chant" }, { key: "CHANT_STACK", value: "shop" }] });
286
+ fake.seed("/env-groups", { name: "foreign-group", ownerId: "tea-1", envVars: [{ key: "B", value: "2" }] });
287
+
288
+ const path = planFile(stack(["web", web()]), { stack: "shop" });
289
+ const result = await renderApply({ planPath: path, endpoint: "http://fake/v1", prune: true, wait: { intervalMs: 0 } }, undefined, fake.http());
290
+
291
+ expect(result.pruned?.map((p) => `${p.kind}/${p.name}`)).toEqual(["BackgroundWorker/stale", "EnvGroup/stale-group"]);
292
+ expect(fake.service("foreign")).toBeDefined();
293
+ expect(fake.service("other-stack")).toBeDefined();
294
+ expect(fake.service("stale")).toBeUndefined();
295
+ expect([...fake.collections["/env-groups"].items.values()].map((g) => g.name)).toEqual(["foreign-group"]);
296
+ });
297
+
298
+ it("prunes undeclared disks and custom domains under an owned declared service, never under a foreign one", async () => {
299
+ const fake = new FakeRender();
300
+ process.env.RENDER_OWNER_ID = "tea-1";
301
+ // First apply: web + two disks + two domains.
302
+ const w1 = web();
303
+ const first = planFile(
304
+ stack(
305
+ ["web", w1],
306
+ ["data", new Disk({ name: "data", sizeGB: 1, mountPath: "/data", serviceId: w1 })],
307
+ ["old", new Disk({ name: "old", sizeGB: 1, mountPath: "/old", serviceId: w1 })],
308
+ ["apex", new CustomDomain({ name: "example.com", serviceId: w1 })],
309
+ ["www", new CustomDomain({ name: "www.example.com", serviceId: w1 })],
310
+ ),
311
+ );
312
+ await renderApply({ planPath: first, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
313
+ // A foreign service with a disk and a domain of its own.
314
+ const foreign = fake.seed("/services", { name: "theirs", type: "web_service", ownerId: "tea-1", envVars: [] });
315
+ fake.seed("/disks", { name: "theirs-data", sizeGB: 1, mountPath: "/d", serviceId: foreign.id });
316
+ fake.domains.set(foreign.id as string, [{ id: "cd-theirs", name: "theirs.example.com" }]);
317
+
318
+ // Second apply drops `old` and `www`.
319
+ const w2 = web();
320
+ const second = planFile(
321
+ stack(
322
+ ["web", w2],
323
+ ["data", new Disk({ name: "data", sizeGB: 1, mountPath: "/data", serviceId: w2 })],
324
+ ["apex", new CustomDomain({ name: "example.com", serviceId: w2 })],
325
+ ),
326
+ );
327
+ const result = await renderApply({ planPath: second, endpoint: "http://fake/v1", prune: true, wait: { intervalMs: 0 } }, undefined, fake.http());
328
+ expect(result.pruned?.map((p) => `${p.kind}/${p.name}`).sort()).toEqual(["CustomDomain/www.example.com", "Disk/old"]);
329
+ const webId = fake.service("web")!.id as string;
330
+ expect([...fake.collections["/disks"].items.values()].map((d) => d.name).sort()).toEqual(["data", "theirs-data"]);
331
+ expect(fake.domains.get(webId)?.map((d) => d.name)).toEqual(["example.com"]);
332
+ expect(fake.domains.get(foreign.id as string)?.map((d) => d.name)).toEqual(["theirs.example.com"]);
333
+ });
334
+
335
+ it("does not prune without the flag", async () => {
336
+ const fake = new FakeRender();
337
+ process.env.RENDER_OWNER_ID = "tea-1";
338
+ fake.seed("/services", { name: "stale", type: "web_service", ownerId: "tea-1", envVars: [{ key: "CHANT_MANAGED_BY", value: "chant" }] });
339
+ const result = await renderApply({ planPath: planFile(stack(["web", web()])), endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
340
+ expect(result.pruned).toBeUndefined();
341
+ expect(fake.service("stale")).toBeDefined();
342
+ });
343
+
344
+ it("matches services by name AND type, so a same-named worker is not mistaken for the web service", async () => {
345
+ const fake = new FakeRender();
346
+ process.env.RENDER_OWNER_ID = "tea-1";
347
+ fake.seed("/services", {
348
+ name: "app",
349
+ type: "background_worker",
350
+ ownerId: "tea-1",
351
+ serviceDetails: { runtime: "docker" },
352
+ envVars: [{ key: "CHANT_MANAGED_BY", value: "chant" }],
353
+ });
354
+ const path = planFile(
355
+ stack(
356
+ ["w", new WebService({ name: "app", serviceDetails: new WebServiceDetails({ runtime: "docker" }) })],
357
+ ["bg", new BackgroundWorker({ name: "app", serviceDetails: new BackgroundWorkerDetails({ runtime: "docker" }) })],
358
+ ),
359
+ );
360
+ const result = await renderApply({ planPath: path, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
361
+ expect(result.applied.map((a) => `${a.kind}:${a.action}`)).toEqual(["WebService:created", "BackgroundWorker:unchanged"]);
362
+ });
363
+
364
+ it("renderDelete removes what the plan names in reverse order and reports already-gone", async () => {
365
+ const fake = new FakeRender();
366
+ process.env.RENDER_OWNER_ID = "tea-1";
367
+ const w = web();
368
+ const entities = stack(["web", w], ["disk", new Disk({ name: "data", sizeGB: 1, mountPath: "/data", serviceId: w })], ["domain", new CustomDomain({ name: "example.com", serviceId: w })]);
369
+ const path = planFile(entities);
370
+ await renderApply({ planPath: path, endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
371
+ fake.calls.length = 0;
372
+
373
+ const result = await renderDelete({ planPath: path, endpoint: "http://fake/v1" }, undefined, fake.http());
374
+ expect(result.pruned?.map((p) => `${p.kind}/${p.name}:${p.deleted}`)).toEqual([
375
+ "CustomDomain/example.com:true",
376
+ "Disk/data:true",
377
+ "WebService/web:true",
378
+ ]);
379
+ expect(fake.calls.filter((c) => c.method === "DELETE").map((c) => c.path.replace(/-\d+/g, ""))).toEqual([
380
+ "/services/srv/custom-domains/cd",
381
+ "/disks/dsk",
382
+ "/services/srv",
383
+ ]);
384
+
385
+ const again = await renderDelete({ planPath: path, endpoint: "http://fake/v1" }, undefined, fake.http());
386
+ expect(again.pruned?.every((p) => p.deleted === false)).toBe(true);
387
+ });
388
+
389
+ it("surfaces an API error with status and body", async () => {
390
+ const http: RenderHttp = async (method) =>
391
+ method === "GET" ? { status: 200, text: "[]" } : { status: 402, text: '{"message":"payment required"}' };
392
+ process.env.RENDER_OWNER_ID = "tea-1";
393
+ const path = planFile(stack(["web", web()]));
394
+ await expect(renderApply({ planPath: path, endpoint: "http://fake/v1" }, undefined, http)).rejects.toThrow(/POST \/services failed \(HTTP 402\).*payment required/);
395
+ });
396
+
397
+ it("renderApplyDetailed exposes entity names alongside the envelope", async () => {
398
+ const fake = new FakeRender();
399
+ process.env.RENDER_OWNER_ID = "tea-1";
400
+ const out = await renderApplyDetailed({ planPath: planFile(stack(["site", web()])), endpoint: "http://fake/v1", wait: { intervalMs: 0 } }, undefined, fake.http());
401
+ expect(out.applied[0]).toMatchObject({ entity: "site", kind: "WebService", name: "web", action: "created" });
402
+ });
403
+ });