grada-run 0.0.2 → 0.32.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 (231) hide show
  1. package/.github/workflows/deploy-docs.yml +37 -0
  2. package/.github/workflows/iac-validation.yml +303 -0
  3. package/.github/workflows/publish.yml +68 -0
  4. package/.github/workflows/sync-bedrock-models.yml +57 -0
  5. package/.github/workflows/test.yml +43 -0
  6. package/.muserules +31 -0
  7. package/LICENSE +21 -0
  8. package/README.md +193 -3
  9. package/apps/docs/.astro/collections/docs.schema.json +644 -0
  10. package/apps/docs/.astro/content-assets.mjs +4 -0
  11. package/apps/docs/.astro/content-modules.mjs +4 -0
  12. package/apps/docs/.astro/content.d.ts +179 -0
  13. package/apps/docs/.astro/data-store.json +1 -0
  14. package/apps/docs/.astro/dev.json +14 -0
  15. package/apps/docs/.astro/settings.json +5 -0
  16. package/apps/docs/.astro/types.d.ts +2 -0
  17. package/apps/docs/astro.config.mjs +97 -0
  18. package/apps/docs/package.json +17 -0
  19. package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
  20. package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
  21. package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
  22. package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
  23. package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
  24. package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
  25. package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
  26. package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
  27. package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
  28. package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
  29. package/apps/docs/src/content/docs/cli/add.md +84 -0
  30. package/apps/docs/src/content/docs/cli/apply.md +32 -0
  31. package/apps/docs/src/content/docs/cli/db.md +200 -0
  32. package/apps/docs/src/content/docs/cli/destroy.md +31 -0
  33. package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
  34. package/apps/docs/src/content/docs/cli/doctor.md +28 -0
  35. package/apps/docs/src/content/docs/cli/domain.md +57 -0
  36. package/apps/docs/src/content/docs/cli/drift.md +40 -0
  37. package/apps/docs/src/content/docs/cli/eject.md +29 -0
  38. package/apps/docs/src/content/docs/cli/exec.md +49 -0
  39. package/apps/docs/src/content/docs/cli/gc.md +37 -0
  40. package/apps/docs/src/content/docs/cli/init.md +72 -0
  41. package/apps/docs/src/content/docs/cli/logs.md +39 -0
  42. package/apps/docs/src/content/docs/cli/rollback.md +51 -0
  43. package/apps/docs/src/content/docs/cli/secrets.md +73 -0
  44. package/apps/docs/src/content/docs/cli/sleep.md +53 -0
  45. package/apps/docs/src/content/docs/cli/status.md +34 -0
  46. package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
  47. package/apps/docs/src/content/docs/guides/architecture.md +87 -0
  48. package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
  49. package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
  50. package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
  51. package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
  52. package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
  53. package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
  54. package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
  55. package/apps/docs/src/content/docs/guides/examples.md +50 -0
  56. package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
  57. package/apps/docs/src/content/docs/guides/headless.md +75 -0
  58. package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
  59. package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
  60. package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
  61. package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
  62. package/apps/docs/src/content/docs/index.mdx +103 -0
  63. package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
  64. package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
  65. package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
  66. package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
  67. package/apps/docs/src/content/docs/roadmap.md +97 -0
  68. package/apps/docs/src/content/docs/testing-strategy.md +32 -0
  69. package/apps/docs/src/content.config.ts +7 -0
  70. package/apps/docs/src/custom.css +14 -0
  71. package/apps/docs/tsconfig.json +6 -0
  72. package/bin/cli.js +140 -0
  73. package/package.json +105 -7
  74. package/scripts/sync-bedrock-models.js +22 -0
  75. package/scripts/test-iac.js +261 -0
  76. package/specs/add-redis-sqs-bedrock.md +128 -0
  77. package/specs/add-storage-dynamodb.md +106 -0
  78. package/specs/bedrock-model-catalog.md +131 -0
  79. package/specs/ci-pipeline.md +17 -0
  80. package/specs/cost-transparency.md +115 -0
  81. package/specs/custom-domains-and-ses.md +153 -0
  82. package/specs/database-suite-expansion.md +151 -0
  83. package/specs/db-connect.md +69 -0
  84. package/specs/db-lifecycle-migrations.md +159 -0
  85. package/specs/dependency-aware-init.md +176 -0
  86. package/specs/deploy-stack-to-grada-run-rebrand.md +45 -0
  87. package/specs/deployment-safety.md +170 -0
  88. package/specs/diagnose.md +16 -0
  89. package/specs/docs-hub.md +16 -0
  90. package/specs/dx-polish.md +46 -0
  91. package/specs/exec.md +25 -0
  92. package/specs/finops-cron-drift.md +161 -0
  93. package/specs/gc.md +26 -0
  94. package/specs/integration-suite.md +16 -0
  95. package/specs/logs.md +32 -0
  96. package/specs/rollback-live-polling.md +40 -0
  97. package/specs/secrets-pull-audit.md +51 -0
  98. package/specs/serverless-lambda-target.md +133 -0
  99. package/specs/status.md +31 -0
  100. package/specs/telemetry-and-spawn-hardening.md +69 -0
  101. package/specs/telemetry-hardening.md +35 -0
  102. package/src/commands/add.js +1111 -0
  103. package/src/commands/apply.js +214 -0
  104. package/src/commands/db/backup.js +229 -0
  105. package/src/commands/db/connect.js +304 -0
  106. package/src/commands/db/enable-vector.js +344 -0
  107. package/src/commands/db/import.js +604 -0
  108. package/src/commands/db/migrate.js +477 -0
  109. package/src/commands/db/restore.js +361 -0
  110. package/src/commands/db.js +87 -0
  111. package/src/commands/destroy.js +217 -0
  112. package/src/commands/diagnose.js +460 -0
  113. package/src/commands/doctor.js +109 -0
  114. package/src/commands/domain.js +685 -0
  115. package/src/commands/drift.js +243 -0
  116. package/src/commands/eject.js +127 -0
  117. package/src/commands/exec.js +222 -0
  118. package/src/commands/gc.js +250 -0
  119. package/src/commands/init.js +649 -0
  120. package/src/commands/logs.js +256 -0
  121. package/src/commands/rollback.js +323 -0
  122. package/src/commands/secrets.js +485 -0
  123. package/src/commands/sleep.js +347 -0
  124. package/src/commands/status.js +309 -0
  125. package/src/commands/sync-ai.js +115 -0
  126. package/src/commands/wake.js +337 -0
  127. package/src/core/parser.js +126 -0
  128. package/src/core/telemetry.js +244 -0
  129. package/src/data/bedrock-models.json +896 -0
  130. package/src/utils/addons.js +126 -0
  131. package/src/utils/ai-rules.js +59 -0
  132. package/src/utils/args.js +91 -0
  133. package/src/utils/aws.js +178 -0
  134. package/src/utils/backup.js +69 -0
  135. package/src/utils/bedrock-catalog.js +511 -0
  136. package/src/utils/capabilities.js +500 -0
  137. package/src/utils/command.js +65 -0
  138. package/src/utils/db-tunnel.js +164 -0
  139. package/src/utils/detector.js +298 -0
  140. package/src/utils/dockerCompose.js +65 -0
  141. package/src/utils/domains.js +73 -0
  142. package/src/utils/ecs-runner.js +289 -0
  143. package/src/utils/ecs.js +92 -0
  144. package/src/utils/frameworks.js +55 -0
  145. package/src/utils/generator.js +527 -0
  146. package/src/utils/hcl.js +426 -0
  147. package/src/utils/lambda-ecr.js +185 -0
  148. package/src/utils/prompts.js +278 -0
  149. package/src/utils/rds.js +131 -0
  150. package/src/utils/resolvers.js +174 -0
  151. package/src/utils/sleep-state.js +140 -0
  152. package/src/utils/sleep-targets.js +139 -0
  153. package/src/utils/system.js +42 -0
  154. package/src/utils/terraform.js +70 -0
  155. package/src/utils/visualizer.js +381 -0
  156. package/src/utils/warnings.js +49 -0
  157. package/templates/README.md +150 -0
  158. package/templates/docker/django.Dockerfile +40 -0
  159. package/templates/docker/go.Dockerfile +23 -0
  160. package/templates/docker/nestjs.Dockerfile +33 -0
  161. package/templates/docker/nextjs.Dockerfile +55 -0
  162. package/templates/docker/node.Dockerfile +24 -0
  163. package/templates/docker/nuxt.Dockerfile +47 -0
  164. package/templates/docker/python.Dockerfile +38 -0
  165. package/templates/docker/rails.Dockerfile +59 -0
  166. package/templates/docker/static.Dockerfile +32 -0
  167. package/templates/docker/svelte.Dockerfile +52 -0
  168. package/templates/github/deploy-lambda.yml +120 -0
  169. package/templates/github/deploy.yml +138 -0
  170. package/templates/github/drift.yml +112 -0
  171. package/templates/github/preview-lambda.yml +86 -0
  172. package/templates/github/preview.yml +69 -0
  173. package/templates/github/teardown.yml +43 -0
  174. package/templates/terraform/addons/bedrock.tf +34 -0
  175. package/templates/terraform/addons/cron-lambda.tf +78 -0
  176. package/templates/terraform/addons/cron.tf +101 -0
  177. package/templates/terraform/addons/dynamodb.tf +73 -0
  178. package/templates/terraform/addons/redis.tf +64 -0
  179. package/templates/terraform/addons/s3.tf +143 -0
  180. package/templates/terraform/addons/ses.tf +73 -0
  181. package/templates/terraform/addons/sqs.tf +67 -0
  182. package/templates/terraform/backend.tf +22 -0
  183. package/templates/terraform/cloudfront-lambda.tf +80 -0
  184. package/templates/terraform/cloudfront.tf +80 -0
  185. package/templates/terraform/database-aurora-postgresql.tf +92 -0
  186. package/templates/terraform/database-mysql.tf +72 -0
  187. package/templates/terraform/database.tf +71 -0
  188. package/templates/terraform/main-lambda.tf +229 -0
  189. package/templates/terraform/main.tf +296 -0
  190. package/templates/terraform/network.tf +95 -0
  191. package/templates/terraform/oidc.tf +64 -0
  192. package/templates/terraform/secrets.tf +31 -0
  193. package/templates/terraform/worker.tf +69 -0
  194. package/tests/__snapshots__/generator.test.js.snap +9633 -0
  195. package/tests/add.test.js +2037 -0
  196. package/tests/ai.test.js +94 -0
  197. package/tests/apply.test.js +488 -0
  198. package/tests/args.test.js +86 -0
  199. package/tests/aws.test.js +244 -0
  200. package/tests/capabilities.test.js +307 -0
  201. package/tests/cli.test.js +29 -0
  202. package/tests/command.test.js +100 -0
  203. package/tests/commands-import.test.js +74 -0
  204. package/tests/db.test.js +2704 -0
  205. package/tests/destroy.test.js +391 -0
  206. package/tests/detector.test.js +79 -0
  207. package/tests/diagnose.test.js +779 -0
  208. package/tests/doctor.test.js +202 -0
  209. package/tests/domain.test.js +899 -0
  210. package/tests/drift.test.js +243 -0
  211. package/tests/ecs.test.js +130 -0
  212. package/tests/eject.test.js +65 -0
  213. package/tests/exec.test.js +380 -0
  214. package/tests/gc.test.js +496 -0
  215. package/tests/generator.test.js +794 -0
  216. package/tests/headless.test.js +562 -0
  217. package/tests/lambda-ecr.test.js +185 -0
  218. package/tests/logs.test.js +447 -0
  219. package/tests/parser.test.js +160 -0
  220. package/tests/rds.test.js +244 -0
  221. package/tests/resolvers.test.js +282 -0
  222. package/tests/rollback.test.js +692 -0
  223. package/tests/secrets.test.js +752 -0
  224. package/tests/sleep-wake.test.js +1016 -0
  225. package/tests/status.test.js +370 -0
  226. package/tests/system.test.js +70 -0
  227. package/tests/telemetry.test.js +520 -0
  228. package/tests/terraform.test.js +84 -0
  229. package/tests/visualizer.test.js +496 -0
  230. package/vitest.config.js +9 -0
  231. package/index.js +0 -2
@@ -0,0 +1,426 @@
1
+ // Shared HCL block-manipulation toolkit for CLI commands that patch
2
+ // generated Terraform files (container env injection, worker lifecycle,
3
+ // snapshot pins, CloudFront domain wiring).
4
+ //
5
+ // All helpers are pure string functions. Every scanner is aware of
6
+ // double-quoted string literals (with backslash escapes) and `#` / `//`
7
+ // line comments plus `/* */` block comments, so brackets and identifiers
8
+ // inside them never disturb matching. Returned bounds are indices into the
9
+ // string that was passed in; `null` means "not found" and callers must
10
+ // leave the content unchanged.
11
+ const OPEN_TO_CLOSE = { '{': '}', '[': ']', '(': ')' };
12
+
13
+ function isIdentChar(ch) {
14
+ return ch !== undefined && /[A-Za-z0-9_-]/.test(ch);
15
+ }
16
+
17
+ // Advances `index` past whitespace and comments. Returns the first index
18
+ // at or after `index` holding meaningful content.
19
+ function skipTrivia(content, index) {
20
+ let i = index;
21
+ for (;;) {
22
+ while (i < content.length && /\s/.test(content[i])) i++;
23
+ if (content.startsWith('//', i) || content[i] === '#') {
24
+ const end = content.indexOf('\n', i);
25
+ i = end === -1 ? content.length : end + 1;
26
+ continue;
27
+ }
28
+ if (content.startsWith('/*', i)) {
29
+ const end = content.indexOf('*/', i + 2);
30
+ i = end === -1 ? content.length : end + 2;
31
+ continue;
32
+ }
33
+ return i;
34
+ }
35
+ }
36
+
37
+ // Skips one double-quoted string literal starting at the opening quote.
38
+ // Returns the index just past the closing quote (or content.length when
39
+ // unterminated).
40
+ function skipString(content, index) {
41
+ let i = index + 1;
42
+ while (i < content.length) {
43
+ if (content[i] === '\\') {
44
+ i += 2;
45
+ continue;
46
+ }
47
+ if (content[i] === '"') return i + 1;
48
+ i++;
49
+ }
50
+ return i;
51
+ }
52
+
53
+ // Finds the balanced close for the bracket at `openIdx`. Returns
54
+ // `{ openIdx, closeIdx }` or null when the opener is missing or the
55
+ // bracket is unterminated.
56
+ function scanBalanced(content, openIdx, openCh) {
57
+ const text = String(content ?? '');
58
+ if (text[openIdx] !== openCh) return null;
59
+ const closeCh = OPEN_TO_CLOSE[openCh];
60
+ let depth = 0;
61
+ let i = openIdx;
62
+ while (i < text.length) {
63
+ const ch = text[i];
64
+ if (ch === '"') {
65
+ i = skipString(text, i);
66
+ continue;
67
+ }
68
+ if (ch === '#' || text.startsWith('//', i)) {
69
+ const end = text.indexOf('\n', i);
70
+ i = end === -1 ? text.length : end + 1;
71
+ continue;
72
+ }
73
+ if (text.startsWith('/*', i)) {
74
+ const end = text.indexOf('*/', i + 2);
75
+ i = end === -1 ? text.length : end + 2;
76
+ continue;
77
+ }
78
+ if (ch === openCh) {
79
+ depth++;
80
+ } else if (ch === closeCh) {
81
+ depth--;
82
+ if (depth === 0) return { openIdx, closeIdx: i };
83
+ }
84
+ i++;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function escapeRegExp(raw) {
90
+ return String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ }
92
+
93
+ // Locates `resource "<type>" "<name>" { ... }`. Returns
94
+ // `{ headerIdx, openIdx, closeIdx }` or null when the resource or its
95
+ // braces cannot be found.
96
+ export function findResourceBlock(content, resourceType, resourceName) {
97
+ const text = String(content ?? '');
98
+ const pattern = new RegExp(
99
+ `resource\\s+"${escapeRegExp(resourceType)}"\\s+"${escapeRegExp(resourceName)}"`
100
+ );
101
+ const match = pattern.exec(text);
102
+ if (!match) return null;
103
+ const headerIdx = match.index;
104
+ const openIdx = text.indexOf('{', headerIdx + match[0].length);
105
+ if (openIdx === -1) return null;
106
+ const bounds = scanBalanced(text, openIdx, '{');
107
+ if (!bounds) return null;
108
+ return { headerIdx, openIdx, closeIdx: bounds.closeIdx };
109
+ }
110
+
111
+ // Locates a nested `blockName { ... }` (with optional quoted labels)
112
+ // at the top level of a block slice. Returns
113
+ // `{ startIdx, openIdx, closeIdx }` (startIdx is the keyword) or null.
114
+ export function findNestedBlock(blockContent, blockName) {
115
+ const text = String(blockContent ?? '');
116
+ const name = String(blockName ?? '');
117
+ if (!name) return null;
118
+ let depth = 0;
119
+ let i = 0;
120
+ while (i < text.length) {
121
+ const ch = text[i];
122
+ if (ch === '"') {
123
+ i = skipString(text, i);
124
+ continue;
125
+ }
126
+ if (ch === '#' || text.startsWith('//', i)) {
127
+ const end = text.indexOf('\n', i);
128
+ i = end === -1 ? text.length : end + 1;
129
+ continue;
130
+ }
131
+ if (text.startsWith('/*', i)) {
132
+ const end = text.indexOf('*/', i + 2);
133
+ i = end === -1 ? text.length : end + 2;
134
+ continue;
135
+ }
136
+ if (ch === '{') {
137
+ depth++;
138
+ i++;
139
+ continue;
140
+ }
141
+ if (ch === '}') {
142
+ depth = Math.max(0, depth - 1);
143
+ i++;
144
+ continue;
145
+ }
146
+ if (depth === 1 && /[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
147
+ let end = i + 1;
148
+ while (end < text.length && isIdentChar(text[end])) end++;
149
+ if (text.slice(i, end) === name) {
150
+ let j = skipTrivia(text, end);
151
+ // Skip optional quoted labels (`block "label" {`).
152
+ while (text[j] === '"') j = skipTrivia(text, skipString(text, j));
153
+ if (text[j] === '{') {
154
+ const bounds = scanBalanced(text, j, '{');
155
+ if (!bounds) return null;
156
+ return { startIdx: i, openIdx: bounds.openIdx, closeIdx: bounds.closeIdx };
157
+ }
158
+ }
159
+ i = end;
160
+ continue;
161
+ }
162
+ i++;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // Splices `replacement` over `bounds` (`{ startIdx, openIdx, closeIdx }`
168
+ // from the finders above; startIdx falls back to openIdx).
169
+ export function replaceBlock(content, bounds, replacement) {
170
+ const text = String(content ?? '');
171
+ if (!bounds || typeof bounds.closeIdx !== 'number') return text;
172
+ const start = typeof bounds.startIdx === 'number' ? bounds.startIdx : bounds.openIdx;
173
+ if (typeof start !== 'number') return text;
174
+ return text.slice(0, start) + String(replacement ?? '') + text.slice(bounds.closeIdx + 1);
175
+ }
176
+
177
+ // Finds a top-level `key = <value>` attribute inside a block slice.
178
+ // Returns `{ lineStart, keyStart, valueStart, valueEnd }` (valueEnd is
179
+ // exclusive, before the trailing newline or closing brace) or null.
180
+ function findAttributeBounds(blockContent, key) {
181
+ const text = String(blockContent ?? '');
182
+ const name = String(key ?? '');
183
+ if (!name) return null;
184
+ let depth = 0;
185
+ let i = 0;
186
+ while (i < text.length) {
187
+ const ch = text[i];
188
+ if (ch === '"') {
189
+ i = skipString(text, i);
190
+ continue;
191
+ }
192
+ if (ch === '#' || text.startsWith('//', i)) {
193
+ const end = text.indexOf('\n', i);
194
+ i = end === -1 ? text.length : end + 1;
195
+ continue;
196
+ }
197
+ if (text.startsWith('/*', i)) {
198
+ const end = text.indexOf('*/', i + 2);
199
+ i = end === -1 ? text.length : end + 2;
200
+ continue;
201
+ }
202
+ if (ch === '{' || ch === '[' || ch === '(') {
203
+ depth++;
204
+ i++;
205
+ continue;
206
+ }
207
+ if (ch === '}' || ch === ']' || ch === ')') {
208
+ depth = Math.max(0, depth - 1);
209
+ i++;
210
+ continue;
211
+ }
212
+ if (depth === 1 && /[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
213
+ let end = i + 1;
214
+ while (end < text.length && isIdentChar(text[end])) end++;
215
+ if (text.slice(i, end) === name) {
216
+ const after = skipTrivia(text, end);
217
+ if (text[after] === '=') {
218
+ const valueStart = skipTrivia(text, after + 1);
219
+ const valueEnd = scanAttributeValueEnd(text, valueStart);
220
+ if (valueEnd === null) return null;
221
+ const lineStart = text.lastIndexOf('\n', i) + 1;
222
+ return { lineStart, keyStart: i, valueStart, valueEnd };
223
+ }
224
+ }
225
+ i = end;
226
+ continue;
227
+ }
228
+ i++;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ // Finds the end of an attribute value: the newline (or closing brace)
234
+ // reached while no brackets are open. Values may span lines.
235
+ function scanAttributeValueEnd(text, valueStart) {
236
+ let depth = 0;
237
+ let i = valueStart;
238
+ while (i < text.length) {
239
+ const ch = text[i];
240
+ if (ch === '"') {
241
+ i = skipString(text, i);
242
+ continue;
243
+ }
244
+ if (ch === '#' || text.startsWith('//', i)) {
245
+ // A `#` comment ends the value only outside brackets; inside
246
+ // (e.g. a heredoc-free list) it is skipped like content.
247
+ if (depth === 0) return i;
248
+ const end = text.indexOf('\n', i);
249
+ i = end === -1 ? text.length : end + 1;
250
+ continue;
251
+ }
252
+ if (text.startsWith('/*', i)) {
253
+ const end = text.indexOf('*/', i + 2);
254
+ i = end === -1 ? text.length : end + 2;
255
+ continue;
256
+ }
257
+ if (ch === '{' || ch === '[' || ch === '(') {
258
+ depth++;
259
+ i++;
260
+ continue;
261
+ }
262
+ if (ch === '}' || ch === ']' || ch === ')') {
263
+ if (depth === 0) return i;
264
+ depth--;
265
+ i++;
266
+ continue;
267
+ }
268
+ if (ch === '\n' && depth === 0) return i;
269
+ i++;
270
+ }
271
+ return i;
272
+ }
273
+
274
+ // Returns the raw (trimmed) value of a top-level `key = <value>`
275
+ // attribute, or null when the attribute is absent.
276
+ export function getAttributeValue(blockContent, key) {
277
+ const bounds = findAttributeBounds(blockContent, key);
278
+ if (!bounds) return null;
279
+ return String(blockContent).slice(bounds.valueStart, bounds.valueEnd).trim();
280
+ }
281
+
282
+ // Replaces (or inserts) a top-level `key = <valueExpr>` attribute.
283
+ // Insertions reuse the block's existing attribute indent. Returns the
284
+ // content unchanged when the value already matches.
285
+ export function upsertAttribute(blockContent, key, valueExpr) {
286
+ const text = String(blockContent ?? '');
287
+ const name = String(key ?? '');
288
+ const value = String(valueExpr ?? '');
289
+ const bounds = findAttributeBounds(text, name);
290
+ if (bounds) {
291
+ const current = text.slice(bounds.valueStart, bounds.valueEnd).trim();
292
+ if (current === value.trim()) return text;
293
+ const indent = text.slice(bounds.lineStart, bounds.keyStart);
294
+ return text.slice(0, bounds.lineStart) + `${indent}${name} = ${value}` + text.slice(bounds.valueEnd);
295
+ }
296
+ const closeIdx = text.lastIndexOf('}');
297
+ if (closeIdx === -1) return text;
298
+ const indentMatch = text.match(/^( +)[A-Za-z_][A-Za-z0-9_-]*\s*=/m);
299
+ const indent = indentMatch ? indentMatch[1] : ' ';
300
+ const glue = closeIdx > 0 && text[closeIdx - 1] === '\n' ? '' : '\n';
301
+ return `${text.slice(0, closeIdx)}${glue}${indent}${name} = ${value}\n${text.slice(closeIdx)}`;
302
+ }
303
+
304
+ // Removes a top-level `key = <value>` attribute (including multi-line
305
+ // values) along with its line. Returns the content unchanged when absent.
306
+ export function removeAttribute(blockContent, key) {
307
+ const text = String(blockContent ?? '');
308
+ const bounds = findAttributeBounds(text, key);
309
+ if (!bounds) return text;
310
+ let end = bounds.valueEnd;
311
+ if (text[end] === '\n') end++;
312
+ return text.slice(0, bounds.lineStart) + text.slice(end);
313
+ }
314
+
315
+ // Locates the first `key = [...]` array assigned in the given content
316
+ // (used for `environment = [...]` inside task definitions, where the
317
+ // array lives inside nested jsonencode structures rather than at the
318
+ // top level of the resource block). Returns `{ openIdx, closeIdx }`
319
+ // for the brackets, or null.
320
+ export function findArrayBounds(content, key) {
321
+ const text = String(content ?? '');
322
+ const name = String(key ?? '');
323
+ if (!name) return null;
324
+ let i = 0;
325
+ while (i < text.length) {
326
+ const ch = text[i];
327
+ if (ch === '"') {
328
+ i = skipString(text, i);
329
+ continue;
330
+ }
331
+ if (ch === '#' || text.startsWith('//', i)) {
332
+ const end = text.indexOf('\n', i);
333
+ i = end === -1 ? text.length : end + 1;
334
+ continue;
335
+ }
336
+ if (text.startsWith('/*', i)) {
337
+ const end = text.indexOf('*/', i + 2);
338
+ i = end === -1 ? text.length : end + 2;
339
+ continue;
340
+ }
341
+ if (/[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
342
+ let end = i + 1;
343
+ while (end < text.length && isIdentChar(text[end])) end++;
344
+ if (text.slice(i, end) === name) {
345
+ const after = skipTrivia(text, end);
346
+ if (text[after] === '=') {
347
+ const bracket = skipTrivia(text, after + 1);
348
+ if (text[bracket] === '[') return scanBalanced(text, bracket, '[');
349
+ return null;
350
+ }
351
+ }
352
+ i = end;
353
+ continue;
354
+ }
355
+ i++;
356
+ }
357
+ return null;
358
+ }
359
+
360
+ // Locates the first `key = {...}` map assigned in the given content (used
361
+ // for `variables = {...}` inside Lambda `environment` blocks, mirroring
362
+ // findArrayBounds for ECS `environment = [...]` arrays). Returns
363
+ // `{ openIdx, closeIdx }` for the braces, or null.
364
+ export function findMapBounds(content, key) {
365
+ const text = String(content ?? '');
366
+ const name = String(key ?? '');
367
+ if (!name) return null;
368
+ let i = 0;
369
+ while (i < text.length) {
370
+ const ch = text[i];
371
+ if (ch === '"') {
372
+ i = skipString(text, i);
373
+ continue;
374
+ }
375
+ if (ch === '#' || text.startsWith('//', i)) {
376
+ const end = text.indexOf('\n', i);
377
+ i = end === -1 ? text.length : end + 1;
378
+ continue;
379
+ }
380
+ if (text.startsWith('/*', i)) {
381
+ const end = text.indexOf('*/', i + 2);
382
+ i = end === -1 ? text.length : end + 2;
383
+ continue;
384
+ }
385
+ if (/[A-Za-z_]/.test(ch) && (i === 0 || !isIdentChar(text[i - 1]))) {
386
+ let end = i + 1;
387
+ while (end < text.length && isIdentChar(text[end])) end++;
388
+ if (text.slice(i, end) === name) {
389
+ const after = skipTrivia(text, end);
390
+ if (text[after] === '=') {
391
+ const brace = skipTrivia(text, after + 1);
392
+ if (text[brace] === '{') return scanBalanced(text, brace, '{');
393
+ return null;
394
+ }
395
+ }
396
+ i = end;
397
+ continue;
398
+ }
399
+ i++;
400
+ }
401
+ return null;
402
+ }
403
+
404
+ // Finds the `{ ... }` object enclosing `index` by brace depth.
405
+ // Interpolation braces inside quoted values (`${...}`) net to zero, so
406
+ // they never disturb the count. Returns null when the index is not
407
+ // inside an object.
408
+ export function enclosingBraceBounds(text, index) {
409
+ const content = String(text ?? '');
410
+ let openIdx = -1;
411
+ let depth = 0;
412
+ for (let i = index - 1; i >= 0; i--) {
413
+ if (content[i] === '}') depth++;
414
+ else if (content[i] === '{') {
415
+ if (depth === 0) {
416
+ openIdx = i;
417
+ break;
418
+ }
419
+ depth--;
420
+ }
421
+ }
422
+ if (openIdx === -1) return null;
423
+ const bounds = scanBalanced(content, openIdx, '{');
424
+ if (!bounds) return null;
425
+ return { openIdx: bounds.openIdx, closeIdx: bounds.closeIdx };
426
+ }
@@ -0,0 +1,185 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import zlib from 'node:zlib';
6
+
7
+ // Day-0 ECR seeding for `--target lambda` projects. Lambda's CreateFunction
8
+ // API rejects images outside a private same-account ECR repository, so a
9
+ // fresh `terraform apply` would fail before the first CI push. `apply.js`
10
+ // seeds a minimal valid Docker schema-2 image under `:latest` instead —
11
+ // built entirely in memory (no Docker daemon) and pushed with the AWS CLI,
12
+ // which `doctor` already requires. All helpers shell out through an
13
+ // injectable spawnSync-shaped `run` (mirroring sleep-targets.js) and never
14
+ // throw for AWS-side failures: they return `{ ok, reason, detail }`.
15
+
16
+ export const PLACEHOLDER_TAG = 'latest';
17
+
18
+ function sha256Hex(buffer) {
19
+ return crypto.createHash('sha256').update(buffer).digest('hex');
20
+ }
21
+
22
+ function tailLines(output, count = 5) {
23
+ return String(output ?? '').split('\n').slice(-count).join('\n').trim();
24
+ }
25
+
26
+ function cliResult(res, operation) {
27
+ if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing', operation };
28
+ if (res?.status !== 0) {
29
+ const tail = tailLines(res?.stderr || res?.stdout);
30
+ return { ok: false, reason: 'aws-failed', operation, detail: tail ? `${operation}: ${tail}` : operation };
31
+ }
32
+ return { ok: true, stdout: res?.stdout ?? '' };
33
+ }
34
+
35
+ // Builds a minimal Docker schema-2 image (empty gzipped tar layer plus an
36
+ // amd64/linux config) with runtime-computed digests, so the manifest ECR
37
+ // receives is always self-consistent. Lambda only checks image existence
38
+ // and architecture at CreateFunction time; CI replaces this placeholder
39
+ // with the real application image on the first push.
40
+ export function buildPlaceholderImage() {
41
+ const emptyTar = Buffer.alloc(1024);
42
+ const layerBlob = zlib.gzipSync(emptyTar);
43
+ const layerDigest = `sha256:${sha256Hex(layerBlob)}`;
44
+ const config = {
45
+ architecture: 'amd64',
46
+ os: 'linux',
47
+ config: { Entrypoint: ['/bootstrap'] },
48
+ rootfs: { type: 'layers', diff_ids: [`sha256:${sha256Hex(emptyTar)}`] },
49
+ history: [{ created: '1970-01-01T00:00:00Z', comment: 'grada lambda placeholder' }],
50
+ };
51
+ const configBlob = Buffer.from(JSON.stringify(config));
52
+ const configDigest = `sha256:${sha256Hex(configBlob)}`;
53
+ const manifest = {
54
+ schemaVersion: 2,
55
+ mediaType: 'application/vnd.docker.distribution.manifest.v2+json',
56
+ config: {
57
+ mediaType: 'application/vnd.docker.container.image.v1+json',
58
+ size: configBlob.length,
59
+ digest: configDigest,
60
+ },
61
+ layers: [
62
+ {
63
+ mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip',
64
+ size: layerBlob.length,
65
+ digest: layerDigest,
66
+ },
67
+ ],
68
+ };
69
+ return {
70
+ manifest,
71
+ manifestJson: JSON.stringify(manifest),
72
+ configBlob,
73
+ configDigest,
74
+ layerBlob,
75
+ layerDigest,
76
+ };
77
+ }
78
+
79
+ // True when `repository:tag` already exists. A missing image resolves to
80
+ // `{ ok: true, exists: false }`; every other failure (missing CLI, missing
81
+ // repository, auth errors) resolves to `{ ok: false, reason }`.
82
+ export function ecrImageExists({ run, repository, tag = PLACEHOLDER_TAG, region }) {
83
+ const res = run('aws', [
84
+ 'ecr', 'describe-images',
85
+ '--repository-name', repository,
86
+ '--image-ids', `imageTag=${tag}`,
87
+ '--region', region,
88
+ '--output', 'json',
89
+ ], { encoding: 'utf8' });
90
+ if (res?.error?.code === 'ENOENT') return { ok: false, reason: 'cli-missing' };
91
+ if (res?.status !== 0) {
92
+ const detail = tailLines(res?.stderr || res?.stdout);
93
+ if (/ImageNotFound/i.test(detail)) return { ok: true, exists: false };
94
+ return { ok: false, reason: 'describe-failed', detail };
95
+ }
96
+ try {
97
+ const details = JSON.parse(res.stdout || '{}').imageDetails || [];
98
+ return { ok: true, exists: details.length > 0 };
99
+ } catch {
100
+ return { ok: false, reason: 'bad-response' };
101
+ }
102
+ }
103
+
104
+ function uploadBlob({ run, repository, region, blob, digest, tmpDir, label }) {
105
+ const blobPath = path.join(tmpDir, `${label}.blob`);
106
+ fs.writeFileSync(blobPath, blob);
107
+
108
+ const initiated = cliResult(run('aws', [
109
+ 'ecr', 'initiate-layer-upload',
110
+ '--repository-name', repository,
111
+ '--region', region,
112
+ '--output', 'json',
113
+ ], { encoding: 'utf8' }), 'initiate-layer-upload');
114
+ if (!initiated.ok) return initiated;
115
+ let uploadId;
116
+ try {
117
+ uploadId = JSON.parse(initiated.stdout || '{}').uploadId;
118
+ } catch {
119
+ return { ok: false, reason: 'bad-response' };
120
+ }
121
+ if (!uploadId) return { ok: false, reason: 'bad-response' };
122
+
123
+ const parted = cliResult(run('aws', [
124
+ 'ecr', 'upload-layer-part',
125
+ '--repository-name', repository,
126
+ '--upload-id', uploadId,
127
+ '--part-first-byte', '0',
128
+ '--part-last-byte', String(blob.length - 1),
129
+ // The blob parameter is --layer-part-blob (not --body); fileb://
130
+ // forces the CLI to read blob bytes from disk, while a bare path
131
+ // would be uploaded as literal content and fail the digest check
132
+ // at complete-layer-upload.
133
+ '--layer-part-blob', `fileb://${blobPath}`,
134
+ '--region', region,
135
+ '--output', 'json',
136
+ ], { encoding: 'utf8' }), 'upload-layer-part');
137
+ if (!parted.ok) return parted;
138
+
139
+ return cliResult(run('aws', [
140
+ 'ecr', 'complete-layer-upload',
141
+ '--repository-name', repository,
142
+ '--upload-id', uploadId,
143
+ '--layer-digests', digest,
144
+ '--region', region,
145
+ '--output', 'json',
146
+ ], { encoding: 'utf8' }), 'complete-layer-upload');
147
+ }
148
+
149
+ // Pushes the placeholder image under `repository:tag`. ECR requires every
150
+ // referenced blob to exist before `put-image`, so both the config and the
151
+ // layer are uploaded first.
152
+ export function seedPlaceholderImage({ run, repository, tag = PLACEHOLDER_TAG, region }) {
153
+ const image = buildPlaceholderImage();
154
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'grada-lambda-seed-'));
155
+ try {
156
+ for (const [label, blob, digest] of [
157
+ ['config', image.configBlob, image.configDigest],
158
+ ['layer', image.layerBlob, image.layerDigest],
159
+ ]) {
160
+ const uploaded = uploadBlob({ run, repository, region, blob, digest, tmpDir, label });
161
+ if (!uploaded.ok) return uploaded;
162
+ }
163
+ const put = cliResult(run('aws', [
164
+ 'ecr', 'put-image',
165
+ '--repository-name', repository,
166
+ '--image-tag', tag,
167
+ '--image-manifest', image.manifestJson,
168
+ '--region', region,
169
+ '--output', 'json',
170
+ ], { encoding: 'utf8' }), 'put-image');
171
+ if (!put.ok) return put;
172
+ return { ok: true, seeded: true };
173
+ } finally {
174
+ fs.rmSync(tmpDir, { recursive: true, force: true });
175
+ }
176
+ }
177
+
178
+ // Ensures `repository:tag` exists, seeding the placeholder only when the
179
+ // tag is absent (idempotent: reruns after a real CI push are no-ops).
180
+ export function ensureLambdaSeedImage({ run, repository, tag = PLACEHOLDER_TAG, region }) {
181
+ const checked = ecrImageExists({ run, repository, tag, region });
182
+ if (!checked.ok) return checked;
183
+ if (checked.exists) return { ok: true, seeded: false };
184
+ return seedPlaceholderImage({ run, repository, tag, region });
185
+ }