@aws/nx-plugin 1.1.1 → 1.3.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 (150) hide show
  1. package/README.md +31 -29
  2. package/generators.json +16 -0
  3. package/migrations.json +6 -1
  4. package/package.json +1 -1
  5. package/src/agentcore-harness/generator.d.ts +1 -1
  6. package/src/greengrass/component/common.d.ts +60 -0
  7. package/src/greengrass/component/common.js +191 -0
  8. package/src/greengrass/component/common.js.map +1 -0
  9. package/src/greengrass/component/dependencies.d.ts +101 -0
  10. package/src/greengrass/component/dependencies.js +37 -0
  11. package/src/greengrass/component/dependencies.js.map +1 -0
  12. package/src/greengrass/component/generator.d.ts +174 -0
  13. package/src/greengrass/component/generator.js +32 -0
  14. package/src/greengrass/component/generator.js.map +1 -0
  15. package/src/greengrass/component/metadata.d.ts +2 -0
  16. package/src/greengrass/component/metadata.js +14 -0
  17. package/src/greengrass/component/metadata.js.map +1 -0
  18. package/src/greengrass/component/python/__snapshots__/generator.spec.ts.snap +450 -0
  19. package/src/greengrass/component/python/files/component/main.py.template +60 -0
  20. package/src/greengrass/component/python/files/component/recipe.yaml.template +30 -0
  21. package/src/greengrass/component/python/files/gdk/gdk-config.json.template +13 -0
  22. package/src/greengrass/component/python/files/tests/test___componentSnakeCase__.py.template +37 -0
  23. package/src/greengrass/component/python/generator.d.ts +15 -0
  24. package/src/greengrass/component/python/generator.js +192 -0
  25. package/src/greengrass/component/python/generator.js.map +1 -0
  26. package/src/greengrass/component/schema.d.js +6 -0
  27. package/src/greengrass/component/schema.d.js.map +1 -0
  28. package/src/greengrass/component/schema.d.ts +18 -0
  29. package/src/greengrass/component/schema.json +82 -0
  30. package/src/greengrass/component/typescript/__snapshots__/generator.spec.ts.snap +275 -0
  31. package/src/greengrass/component/typescript/files/component/recipe.yaml.template +41 -0
  32. package/src/greengrass/component/typescript/files/entrypoint/main.ts.template +58 -0
  33. package/src/greengrass/component/typescript/files/gdk/gdk-config.json.template +13 -0
  34. package/src/greengrass/component/typescript/files/scripts/vendor-crt-addon.ts.template +115 -0
  35. package/src/greengrass/component/typescript/generator.d.ts +16 -0
  36. package/src/greengrass/component/typescript/generator.js +194 -0
  37. package/src/greengrass/component/typescript/generator.js.map +1 -0
  38. package/src/infra/app/generator.d.ts +1 -1
  39. package/src/init/generator.d.ts +1 -1
  40. package/src/internal/test-matrix/generator.js +48 -1
  41. package/src/internal/test-matrix/generator.js.map +1 -1
  42. package/src/license/dependency-check/pre-approved.js +2 -1
  43. package/src/license/dependency-check/pre-approved.js.map +1 -1
  44. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/metadata.json +3 -0
  45. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.d.ts +6 -0
  46. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.js +118 -0
  47. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.js.map +1 -0
  48. package/src/open-api/py-client/generator.d.ts +1 -1
  49. package/src/preset/__snapshots__/generator.spec.ts.snap +4 -0
  50. package/src/preset/generator.d.ts +1 -1
  51. package/src/py/agent/a2a-connection/generator.d.ts +1 -1
  52. package/src/py/agent/gateway-connection/generator.d.ts +1 -1
  53. package/src/py/agent/guardrails/__snapshots__/generator.spec.ts.snap +3813 -0
  54. package/src/py/agent/guardrails/files/langchain/guardrail.py.template +135 -0
  55. package/src/py/agent/guardrails/files/strands/guardrail.py.template +93 -0
  56. package/src/py/agent/guardrails/generator.d.ts +10 -0
  57. package/src/py/agent/guardrails/generator.js +175 -0
  58. package/src/py/agent/guardrails/generator.js.map +1 -0
  59. package/src/py/agent/guardrails/runtime-check/run_checks.py +250 -0
  60. package/src/py/agent/guardrails/schema.d.js +6 -0
  61. package/src/py/agent/guardrails/schema.d.js.map +1 -0
  62. package/src/py/agent/guardrails/schema.d.ts +9 -0
  63. package/src/py/agent/guardrails/schema.json +25 -0
  64. package/src/py/agent/mcp-connection/generator.d.ts +1 -1
  65. package/src/py/agent/react-connection/generator.d.ts +1 -1
  66. package/src/py/fast-api/react/generator.d.ts +1 -1
  67. package/src/py/project/generator.js +2 -0
  68. package/src/py/project/generator.js.map +1 -1
  69. package/src/sdk/greengrass.d.ts +6 -0
  70. package/src/sdk/greengrass.js +6 -0
  71. package/src/sdk/greengrass.js.map +1 -0
  72. package/src/sdk/py.d.ts +2 -0
  73. package/src/sdk/py.js +1 -0
  74. package/src/sdk/py.js.map +1 -1
  75. package/src/smithy/project/generator.d.ts +1 -1
  76. package/src/smithy/react-connection/generator.d.ts +1 -1
  77. package/src/terraform/project/generator.d.ts +1 -1
  78. package/src/trpc/react/generator.d.ts +1 -1
  79. package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
  80. package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
  81. package/src/ts/agent/guardrails/__snapshots__/generator.spec.ts.snap +54 -3
  82. package/src/ts/agent/guardrails/generator.js +2 -1
  83. package/src/ts/agent/guardrails/generator.js.map +1 -1
  84. package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
  85. package/src/ts/agent/react-connection/generator.d.ts +1 -1
  86. package/src/ts/astro-docs/generator.d.ts +1 -1
  87. package/src/ts/dcr-proxy/generator.d.ts +1 -1
  88. package/src/ts/dynamodb/generator.d.ts +1 -1
  89. package/src/ts/lambda-function/generator.d.ts +1 -1
  90. package/src/ts/lib/generator.d.ts +1 -1
  91. package/src/ts/nx-generator/generator.d.ts +1 -1
  92. package/src/ts/nx-migration/generator.d.ts +1 -1
  93. package/src/ts/nx-plugin/generator.d.ts +1 -1
  94. package/src/ts/rdb/generator.d.ts +1 -1
  95. package/src/ts/react-website/agui/generator.d.ts +1 -1
  96. package/src/utils/__snapshots__/shared-greengrass-scripts.spec.ts.snap +1279 -0
  97. package/src/utils/files/common/scripts/src/greengrass/build-artifact.ts.template +236 -0
  98. package/src/utils/files/common/scripts/src/greengrass/component-logs.ts.template +48 -0
  99. package/src/utils/files/common/scripts/src/greengrass/deploy-local.ts.template +497 -0
  100. package/src/utils/files/common/scripts/src/greengrass/recipe-utils.ts.template +356 -0
  101. package/src/utils/files/common/scripts/src/greengrass/zip-writer.ts.template +121 -0
  102. package/src/utils/files/terraform/src/core/runtime-config/read/read.tf.template +3 -3
  103. package/src/utils/files/terraform/src/metrics/metrics.tf.template +1 -1
  104. package/src/utils/greengrass/constants.d.ts +58 -0
  105. package/src/utils/greengrass/constants.js +63 -0
  106. package/src/utils/greengrass/constants.js.map +1 -0
  107. package/src/utils/greengrass/naming.d.ts +46 -0
  108. package/src/utils/greengrass/naming.js +67 -0
  109. package/src/utils/greengrass/naming.js.map +1 -0
  110. package/src/utils/greengrass/recipe.d.ts +143 -0
  111. package/src/utils/greengrass/recipe.js +119 -0
  112. package/src/utils/greengrass/recipe.js.map +1 -0
  113. package/src/utils/greengrass-constructs/files/cdk/app/greengrass-component/__componentDirName__.ts.template +42 -0
  114. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/artifact-bucket.ts.template +82 -0
  115. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/component-version.ts.template +370 -0
  116. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/deployment.ts.template +276 -0
  117. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/index.ts.template +5 -0
  118. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/next-version.ts.template +119 -0
  119. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/recipe.ts.template +90 -0
  120. package/src/utils/greengrass-constructs/files/next-version/__nextVersionFileName__.template +195 -0
  121. package/src/utils/greengrass-constructs/files/terraform/app/greengrass-component/__componentDirName__/__componentDirName__.tf.template +26 -0
  122. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/artifact-bucket/main.tf.template +204 -0
  123. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/component-version/main.tf.template +156 -0
  124. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/deployment/main.tf.template +186 -0
  125. package/src/utils/greengrass-constructs/greengrass-constructs.d.ts +57 -0
  126. package/src/utils/greengrass-constructs/greengrass-constructs.js +154 -0
  127. package/src/utils/greengrass-constructs/greengrass-constructs.js.map +1 -0
  128. package/src/utils/guardrail-constructs/files/terraform/app/guardrails/__nameKebabCase__/__nameKebabCase__-guardrail.tf.template +18 -1
  129. package/src/utils/guardrail-constructs/guardrail-constructs.d.ts +5 -0
  130. package/src/utils/guardrail-constructs/guardrail-constructs.js +8 -0
  131. package/src/utils/guardrail-constructs/guardrail-constructs.js.map +1 -1
  132. package/src/utils/guardrail-constructs/guardrail-wiring.d.ts +7 -0
  133. package/src/utils/guardrail-constructs/guardrail-wiring.js +6 -5
  134. package/src/utils/guardrail-constructs/guardrail-wiring.js.map +1 -1
  135. package/src/utils/nx.d.ts +15 -0
  136. package/src/utils/nx.js +32 -0
  137. package/src/utils/nx.js.map +1 -1
  138. package/src/utils/shared-greengrass-scripts.d.ts +21 -0
  139. package/src/utils/shared-greengrass-scripts.js +43 -0
  140. package/src/utils/shared-greengrass-scripts.js.map +1 -0
  141. package/src/utils/test/fake-aws/fake_aws.py +205 -0
  142. package/src/utils/test/python-dependencies.d.ts +6 -0
  143. package/src/utils/test/python-dependencies.js +19 -0
  144. package/src/utils/test/python-dependencies.js.map +1 -1
  145. package/src/utils/test/warm-python-cache.js +5 -1
  146. package/src/utils/test/warm-python-cache.js.map +1 -1
  147. package/src/utils/versions.d.ts +7 -0
  148. package/src/utils/versions.js +7 -0
  149. package/src/utils/versions.js.map +1 -1
  150. /package/src/{ts/agent/guardrails/files/agent → utils/guardrail-constructs/files/policy}/guardrail.json.template +0 -0
@@ -0,0 +1,1279 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/build-artifact.ts 1`] = `
4
+ "import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
5
+ import { join, relative } from 'node:path';
6
+ import yaml from 'js-yaml';
7
+ import { loadRecipe, validateManifestArchitectures, validateRecipe } from './recipe-utils.js';
8
+ import { writeZip, type ZipEntry } from './zip-writer.js';
9
+
10
+ /*
11
+ * Usage: build-artifact.ts <project-root> <component-dir> <dist> [bundle-dir] [--platforms=<arch>,<arch>]
12
+ *
13
+ * <project-root> path to the host project (e.g. a Python project root)
14
+ * <component-dir> the component's directory name under <project-root>/greengrass/,
15
+ * which also becomes the artifact zip's base name
16
+ * <dist> the component's dist root: reads <dist>/vendor (produced by
17
+ * the "-vendor" target this target depends on) and writes
18
+ * <dist>/greengrass-build/{recipes,artifacts}
19
+ * <bundle-dir> optional: a directory of already-bundled deployable code
20
+ * (e.g. a TypeScript rolldown bundle output). When given and
21
+ * it exists, its contents are zipped at the archive root
22
+ * INSTEAD of <project-root>/greengrass/<component-dir>/ and
23
+ * <dist>/vendor - a compiled bundle is the deployable
24
+ * content, so the TypeScript source that produced it must
25
+ * not also ship. <project-root>/greengrass/<component-dir>/package.json,
26
+ * when present, is included too (the minimal manifest an
27
+ * Install lifecycle step's \`npm install\` needs for a native
28
+ * dependency the bundle marked external).
29
+ * --platforms optional: a comma-separated list of manifest architecture
30
+ * tokens (e.g. "amd64,aarch64"). When given, <dist>/vendor/<arch>
31
+ * is read per architecture and one <component-dir>-<arch>.zip
32
+ * is written per architecture, and every recipe manifest
33
+ * declaring one of these architectures must pair with that
34
+ * architecture's own zip. Cannot be combined with
35
+ * <bundle-dir>. Without it, behaviour is exactly as before:
36
+ * flat <dist>/vendor, one <component-dir>.zip.
37
+ *
38
+ * Without <bundle-dir>, everything under <project-root>/greengrass/<component-dir>/
39
+ * is packaged except recipe.yaml and gdk-config.json, which are framework
40
+ * metadata, not deployed code, and __pycache__ and .lock, which are tool
41
+ * staging output.
42
+ */
43
+
44
+ const log = (msg: string): void => console.error(\`[build-artifact] \${msg}\`);
45
+
46
+ function usage(): never {
47
+ log(
48
+ 'Usage: build-artifact.ts <project-root> <component-dir> <dist> [bundle-dir] [--platforms=<arch>,<arch>]',
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ /**
54
+ * Every file under \`dir\`, recursively, excluding top-level names in \`exclude\`
55
+ * and \`__pycache__\` directories at any depth.
56
+ */
57
+ function collectFiles(dir: string, exclude: ReadonlySet<string>): string[] {
58
+ const results: string[] = [];
59
+ const walk = (current: string, isRoot: boolean): void => {
60
+ for (const entry of readdirSync(current)) {
61
+ if (entry === '__pycache__' || (isRoot && exclude.has(entry))) continue;
62
+ const fullPath = join(current, entry);
63
+ if (statSync(fullPath).isDirectory()) {
64
+ walk(fullPath, false);
65
+ } else {
66
+ results.push(fullPath);
67
+ }
68
+ }
69
+ };
70
+ walk(dir, true);
71
+ return results;
72
+ }
73
+
74
+ /** Zip entries for every file under \`dir\`, named relative to \`dir\` with forward slashes. */
75
+ function entriesFrom(dir: string, exclude: ReadonlySet<string>): ZipEntry[] {
76
+ return collectFiles(dir, exclude).map((filePath) => ({
77
+ name: relative(dir, filePath).split('\\\\').join('/'),
78
+ data: readFileSync(filePath),
79
+ }));
80
+ }
81
+
82
+ function main(): void {
83
+ const argv = process.argv.slice(2);
84
+ const PLATFORMS_FLAG = '--platforms=';
85
+ const platforms = argv
86
+ .filter((a) => a.startsWith(PLATFORMS_FLAG))
87
+ .flatMap((a) => a.slice(PLATFORMS_FLAG.length).split(','))
88
+ .map((a) => a.trim())
89
+ .filter((a) => a.length > 0);
90
+ const [projectRoot, componentDirName, dist, bundleDir] = argv.filter(
91
+ (a) => !a.startsWith(PLATFORMS_FLAG),
92
+ );
93
+ if (!projectRoot || !componentDirName || !dist) {
94
+ usage();
95
+ }
96
+
97
+ if (platforms.length > 0 && bundleDir) {
98
+ log(
99
+ '--platforms cannot be combined with a bundle-dir: per-architecture copies of one bundle would all hold identical bytes. Drop --platforms and serve both architectures from a single artifact with two manifests.',
100
+ );
101
+ process.exit(1);
102
+ return;
103
+ }
104
+
105
+ const componentDir = join(projectRoot, 'greengrass', componentDirName);
106
+ const recipePath = join(componentDir, 'recipe.yaml');
107
+ const baseNameByArchitecture = Object.fromEntries(
108
+ platforms.map((arch) => [arch, \`\${componentDirName}-\${arch}\`]),
109
+ );
110
+ const artifactBaseNames =
111
+ platforms.length === 0
112
+ ? [componentDirName]
113
+ : platforms.map((arch) => baseNameByArchitecture[arch]);
114
+
115
+ let componentName: string;
116
+ let componentVersion: string;
117
+ let recipe: ReturnType<typeof loadRecipe>;
118
+ try {
119
+ recipe = loadRecipe(recipePath);
120
+ ({ componentName, componentVersion } = validateRecipe(
121
+ recipe,
122
+ artifactBaseNames,
123
+ ));
124
+ // Only meaningful per architecture, so only checked when this build knows
125
+ // which architecture each zip is for.
126
+ if (platforms.length > 0) {
127
+ validateManifestArchitectures(recipe, baseNameByArchitecture);
128
+ }
129
+ } catch (e) {
130
+ log((e as Error).message);
131
+ process.exit(1);
132
+ return;
133
+ }
134
+
135
+ const vendorDir = join(dist, 'vendor');
136
+ // Recipe and artifact paths are named by version, so a rebuild after a
137
+ // version bump would otherwise accumulate stale siblings - and the
138
+ // GreengrassComponentVersion construct requires exactly one resolved
139
+ // recipe. Start from an empty greengrass-build every run.
140
+ rmSync(join(dist, 'greengrass-build'), { recursive: true, force: true });
141
+ const artifactDir = join(
142
+ dist,
143
+ 'greengrass-build',
144
+ 'artifacts',
145
+ componentName,
146
+ componentVersion,
147
+ );
148
+ const recipesDir = join(dist, 'greengrass-build', 'recipes');
149
+ mkdirSync(artifactDir, { recursive: true });
150
+ mkdirSync(recipesDir, { recursive: true });
151
+
152
+ if (platforms.length > 0) {
153
+ // Multi-architecture: one zip per architecture, each holding the
154
+ // component's own files plus that architecture's own vendored wheels.
155
+ for (const arch of platforms) {
156
+ const archVendorDir = join(vendorDir, arch);
157
+ if (!existsSync(archVendorDir)) {
158
+ log(
159
+ \`Missing vendored dependencies for "\${arch}" at "\${archVendorDir}" - run the component's "-vendor" target first (it writes one directory per architecture when the component targets more than one).\`,
160
+ );
161
+ process.exit(1);
162
+ return;
163
+ }
164
+ const entries = [
165
+ ...entriesFrom(
166
+ componentDir,
167
+ new Set(['recipe.yaml', 'gdk-config.json', '.lock']),
168
+ ),
169
+ ...entriesFrom(archVendorDir, new Set(['.lock'])),
170
+ ];
171
+ if (entries.length === 0) {
172
+ log(
173
+ \`Nothing to package for "\${arch}": "\${componentDir}" has no deployable files and "\${archVendorDir}" has no vendored dependencies\`,
174
+ );
175
+ process.exit(1);
176
+ return;
177
+ }
178
+ const zipPath = join(artifactDir, \`\${componentDirName}-\${arch}.zip\`);
179
+ writeZip(entries, zipPath);
180
+ log(\`wrote \${zipPath}\`);
181
+ }
182
+ } else {
183
+ let entries: ZipEntry[];
184
+ if (bundleDir && existsSync(bundleDir)) {
185
+ // A pre-bundled language (e.g. TypeScript via rolldown): the bundle
186
+ // output is the deployable content, so the source that produced it is
187
+ // never packaged.
188
+ entries = entriesFrom(bundleDir, new Set(['.lock']));
189
+ const packageJsonPath = join(componentDir, 'package.json');
190
+ if (existsSync(packageJsonPath)) {
191
+ // Ships alongside the bundle only when an Install lifecycle step needs
192
+ // it (e.g. \`npm install\` for a native dependency the bundle marked
193
+ // external) - absent for a fully self-contained bundle.
194
+ entries.push({
195
+ name: 'package.json',
196
+ data: readFileSync(packageJsonPath),
197
+ });
198
+ }
199
+ } else {
200
+ // Framework metadata that ships alongside the recipe but never inside
201
+ // the deployed artifact. A package.json here is the user's own, so it is
202
+ // packaged like any other source file - only bundle mode treats one as
203
+ // framework metadata.
204
+ entries = entriesFrom(
205
+ componentDir,
206
+ new Set(['recipe.yaml', 'gdk-config.json', '.lock']),
207
+ );
208
+ if (existsSync(vendorDir)) {
209
+ // requirements.txt is the "-vendor" target's own intermediate, not
210
+ // something the device needs.
211
+ entries.push(
212
+ ...entriesFrom(vendorDir, new Set(['requirements.txt', '.lock'])),
213
+ );
214
+ }
215
+ }
216
+ if (entries.length === 0) {
217
+ log(
218
+ \`Nothing to package: "\${bundleDir && existsSync(bundleDir) ? bundleDir : componentDir}" has no deployable files and "\${vendorDir}" does not exist\`,
219
+ );
220
+ process.exit(1);
221
+ return;
222
+ }
223
+
224
+ const zipPath = join(artifactDir, \`\${componentDirName}.zip\`);
225
+ writeZip(entries, zipPath);
226
+ log(\`wrote \${zipPath}\`);
227
+ }
228
+
229
+ // The BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION artifact URI placeholders
230
+ // are left exactly as authored - a later IaC step substitutes them.
231
+ const recipeOutPath = join(
232
+ recipesDir,
233
+ \`\${componentName}-\${componentVersion}.yaml\`,
234
+ );
235
+ writeFileSync(recipeOutPath, yaml.dump(recipe, { lineWidth: -1 }));
236
+ log(\`wrote \${recipeOutPath}\`);
237
+ }
238
+
239
+ main();
240
+ "
241
+ `;
242
+
243
+ exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/component-logs.ts 1`] = `
244
+ "import { spawn } from 'node:child_process';
245
+ import { join } from 'node:path';
246
+
247
+ /*
248
+ * Usage: component-logs.ts <component-name>
249
+ *
250
+ * <component-name> the recipe's ComponentName, e.g. "com.example.MyComponent"
251
+ *
252
+ * Tails <GREENGRASS_ROOT>/logs/<component-name>.log in follow mode. Ctrl-C
253
+ * (SIGINT) stops it - this target never exits on its own.
254
+ */
255
+
256
+ const log = (msg: string): void => console.error(\`[component-logs] \${msg}\`);
257
+
258
+ function usage(): never {
259
+ log('Usage: component-logs.ts <component-name>');
260
+ process.exit(1);
261
+ }
262
+
263
+ function main(): void {
264
+ const [componentName] = process.argv.slice(2);
265
+ if (!componentName) {
266
+ usage();
267
+ }
268
+
269
+ const greengrassRoot = process.env.GREENGRASS_ROOT || '/greengrass/v2';
270
+ const logPath = join(greengrassRoot, 'logs', \`\${componentName}.log\`);
271
+
272
+ log(\`Tailing \${logPath} - Ctrl-C to stop\`);
273
+ const tail = spawn('tail', ['-n', '200', '-f', logPath], {
274
+ stdio: 'inherit',
275
+ });
276
+
277
+ const stop = (): void => {
278
+ tail.kill();
279
+ process.exit(0);
280
+ };
281
+ process.on('SIGINT', stop);
282
+ process.on('SIGTERM', stop);
283
+
284
+ tail.on('exit', (code) => process.exit(code ?? 0));
285
+ tail.on('error', (err) => {
286
+ log(\`Failed to start tail: \${err.message}\`);
287
+ process.exit(1);
288
+ });
289
+ }
290
+
291
+ main();
292
+ "
293
+ `;
294
+
295
+ exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/deploy-local.ts 1`] = `
296
+ "import { type SpawnSyncReturns, spawnSync } from 'node:child_process';
297
+ import {
298
+ closeSync,
299
+ cpSync,
300
+ fstatSync,
301
+ mkdirSync,
302
+ openSync,
303
+ readSync,
304
+ readdirSync,
305
+ rmSync,
306
+ writeFileSync,
307
+ } from 'node:fs';
308
+ import { join } from 'node:path';
309
+ import yaml from 'js-yaml';
310
+ import {
311
+ isNextVersionSentinel,
312
+ readResolvedRecipe,
313
+ resolveLocalNextVersion,
314
+ } from './recipe-utils.js';
315
+
316
+ /*
317
+ * Usage: deploy-local.ts <dist>
318
+ *
319
+ * <dist> the component's dist root written by the "-artifact" target. Reads
320
+ * <dist>/greengrass-build/{recipes,artifacts}. A NEXT_* recipe stages
321
+ * the resolved recipe and artifact under greengrass-build/local/.
322
+ *
323
+ * Creates one local deployment via greengrass-cli, then polls the component's
324
+ * own lifecycle state until it settles. A deployment the nucleus reports as
325
+ * SUCCEEDED is not evidence the component runs, so this exits non-zero when the
326
+ * component ends up BROKEN or ERRORED, or never settles inside the timeout -
327
+ * printing the state it reached and the tail of its log.
328
+ *
329
+ * GREENGRASS_ROOT overrides the /greengrass/v2 nucleus root, and
330
+ * GREENGRASS_DEPLOY_TIMEOUT_SECONDS the 120 second poll bound.
331
+ *
332
+ * A NEXT_* ComponentVersion resolves on the device, not against the cloud
333
+ * registry: the next version above the highest of the installed version and
334
+ * the version directories the nucleus keeps under
335
+ * <GREENGRASS_ROOT>/packages/{artifacts,artifacts-unarchived}/<ComponentName>/.
336
+ *
337
+ * Re-deploying a concrete ComponentVersion already RUNNING on this device can
338
+ * read the outgoing instance's state, so bump that version in recipe.yaml to
339
+ * keep this check meaningful. A NEXT_* version resolves a new local version,
340
+ * so this warning does not apply to it.
341
+ */
342
+
343
+ const log = (msg: string): void => console.error(\`[deploy-local] \${msg}\`);
344
+
345
+ const POLL_INTERVAL_MS = 2000;
346
+ const DEFAULT_TIMEOUT_SECONDS = 120;
347
+
348
+ /**
349
+ * How many consecutive checks a running state must survive before this script
350
+ * calls the deployment good. The nucleus reports RUNNING the moment it starts
351
+ * the Run lifecycle, so a component that dies seconds later - the usual shape
352
+ * of a missing dependency or a failed IPC connect - is RUNNING at the first
353
+ * check and BROKEN by the third. One check is not evidence.
354
+ */
355
+ const REQUIRED_STABLE_CHECKS = 3;
356
+
357
+ const LOG_TAIL_LINES = 40;
358
+ /** Read from the END of the log file, so a large log cannot exhaust memory. */
359
+ const LOG_TAIL_BYTES = 64 * 1024;
360
+ /** Room for \`component list\` on a device carrying many components. */
361
+ const CLI_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
362
+
363
+ /**
364
+ * A component that started. FINISHED counts: a one-shot component that ran to
365
+ * completion is a success, and the nucleus never moves it to RUNNING. It is
366
+ * reported explicitly, because a generated component is not one-shot.
367
+ */
368
+ const SETTLED_RUNNING_STATES = ['RUNNING', 'FINISHED'];
369
+ /** BROKEN follows three failed restarts, ERRORED a single failed one. */
370
+ const SETTLED_FAILED_STATES = ['BROKEN', 'ERRORED'];
371
+
372
+ /**
373
+ * Log lines a component leaves when the device's \`sudo\` is sudo-rs, which
374
+ * accepts the nucleus's \`sudo -E\` and then discards the environment.
375
+ */
376
+ const SUDO_RS_LOG_SIGNATURES = [
377
+ 'AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT',
378
+ 'preserving the entire environment is not supported',
379
+ ];
380
+
381
+ interface ComponentStatus {
382
+ version?: string;
383
+ state?: string;
384
+ }
385
+
386
+ function usage(): never {
387
+ log('Usage: deploy-local.ts <dist>');
388
+ process.exit(1);
389
+ }
390
+
391
+ const sleep = (ms: number): Promise<void> =>
392
+ new Promise((resolve) => setTimeout(resolve, ms));
393
+
394
+ /** Seconds to wait for the component to settle, from the environment. */
395
+ const resolveTimeoutSeconds = (): number => {
396
+ const raw = process.env.GREENGRASS_DEPLOY_TIMEOUT_SECONDS;
397
+ if (raw === undefined || raw.trim() === '') {
398
+ return DEFAULT_TIMEOUT_SECONDS;
399
+ }
400
+ const parsed = Number(raw);
401
+ // Rejects Infinity (which would poll forever) and anything <= 0 (which would
402
+ // skip polling and report a timeout the deployment never had).
403
+ if (!Number.isFinite(parsed) || parsed <= 0) {
404
+ log(
405
+ \`Ignoring GREENGRASS_DEPLOY_TIMEOUT_SECONDS="\${raw}": it must be a \` +
406
+ \`finite number of seconds greater than 0. Waiting \` +
407
+ \`\${DEFAULT_TIMEOUT_SECONDS}s instead.\`,
408
+ );
409
+ return DEFAULT_TIMEOUT_SECONDS;
410
+ }
411
+ return parsed;
412
+ };
413
+
414
+ /**
415
+ * Runs greengrass-cli, bounded: a wedged nucleus must not outlive the caller's
416
+ * deadline, and its output must not overflow the default 1 MB pipe buffer.
417
+ */
418
+ const runCli = (
419
+ cli: string,
420
+ args: string[],
421
+ timeoutMs: number,
422
+ ): SpawnSyncReturns<string> =>
423
+ spawnSync(cli, args, {
424
+ encoding: 'utf-8',
425
+ timeout: Math.max(1000, Math.trunc(timeoutMs)),
426
+ maxBuffer: CLI_MAX_BUFFER_BYTES,
427
+ });
428
+
429
+ /**
430
+ * Pulls one component's Version and State out of \`greengrass-cli component
431
+ * list\` output. Deliberately tolerant of indentation and of extra fields: the
432
+ * CLI's report is human-facing text with no documented layout, so anything
433
+ * unrecognised is skipped rather than parsed. First value wins for each field,
434
+ * so a later \`State:\` line inside the same block's serialized Configuration
435
+ * cannot overwrite the component's own state.
436
+ */
437
+ const parseComponentStatus = (
438
+ output: string,
439
+ componentName: string,
440
+ ): ComponentStatus | undefined => {
441
+ let found: ComponentStatus | undefined;
442
+ let inTargetBlock = false;
443
+ for (const line of output.split('\\n')) {
444
+ const name = /^\\s*Component Name:\\s*(\\S+)\\s*$/.exec(line);
445
+ if (name) {
446
+ inTargetBlock = name[1] === componentName;
447
+ if (inTargetBlock) {
448
+ found = found ?? {};
449
+ }
450
+ continue;
451
+ }
452
+ if (!inTargetBlock || !found) {
453
+ continue;
454
+ }
455
+ const version = /^\\s*Version:\\s*(\\S+)\\s*$/.exec(line);
456
+ if (version && found.version === undefined) {
457
+ found.version = version[1];
458
+ continue;
459
+ }
460
+ const state = /^\\s*State:\\s*(\\S+)\\s*$/.exec(line);
461
+ if (state && found.state === undefined) {
462
+ found.state = state[1];
463
+ }
464
+ }
465
+ return found;
466
+ };
467
+
468
+ /** The nucleus package stores that keep one directory per component version. */
469
+ const LOCAL_VERSION_STORES = ['artifacts', 'artifacts-unarchived'];
470
+
471
+ /**
472
+ * Versions this device has held for the component: the directory names under
473
+ * <GREENGRASS_ROOT>/packages/{artifacts,artifacts-unarchived}/<ComponentName>/.
474
+ * The recipe store (packages/recipes) is not read - it is root-only and names
475
+ * its files by a hashed component name. The nucleus garbage-collects unused
476
+ * versions, so this history is best-effort; the caller always counts the
477
+ * installed version too. A store that is missing or unreadable is skipped with
478
+ * one log line.
479
+ */
480
+ const readLocalVersionHistory = (
481
+ greengrassRoot: string,
482
+ componentName: string,
483
+ ): string[] => {
484
+ const versions: string[] = [];
485
+ for (const store of LOCAL_VERSION_STORES) {
486
+ const dir = join(greengrassRoot, 'packages', store, componentName);
487
+ try {
488
+ versions.push(
489
+ ...readdirSync(dir, { withFileTypes: true })
490
+ .filter((entry) => entry.isDirectory())
491
+ .map((entry) => entry.name),
492
+ );
493
+ } catch (e) {
494
+ log(\`Skipping \${dir}: \${(e as Error).message}\`);
495
+ }
496
+ }
497
+ return versions;
498
+ };
499
+
500
+ /**
501
+ * Reads the last {@link LOG_TAIL_BYTES} of the component's log, returning
502
+ * undefined when it cannot be read.
503
+ */
504
+ const readComponentLogTail = (logPath: string): string | undefined => {
505
+ let fd: number | undefined;
506
+ try {
507
+ fd = openSync(logPath, 'r');
508
+ const { size } = fstatSync(fd);
509
+ const length = Math.min(size, LOG_TAIL_BYTES);
510
+ const buffer = Buffer.alloc(length);
511
+ readSync(fd, buffer, 0, length, size - length);
512
+ const text = buffer.toString('utf-8');
513
+ // A truncated window starts mid-line, and possibly mid-character.
514
+ return size > length ? text.slice(text.indexOf('\\n') + 1) : text;
515
+ } catch (e) {
516
+ log(
517
+ \`Cannot read \${logPath}: \${(e as Error).message}. Run the component's \` +
518
+ '"-logs" target on this device to tail it.',
519
+ );
520
+ return undefined;
521
+ } finally {
522
+ if (fd !== undefined) {
523
+ closeSync(fd);
524
+ }
525
+ }
526
+ };
527
+
528
+ /** Prints the last {@link LOG_TAIL_LINES} lines, plus any recognised remedy. */
529
+ const reportComponentLog = (
530
+ greengrassRoot: string,
531
+ componentName: string,
532
+ ): void => {
533
+ const logPath = join(greengrassRoot, 'logs', \`\${componentName}.log\`);
534
+ const content = readComponentLogTail(logPath);
535
+ if (content === undefined) {
536
+ return;
537
+ }
538
+ const lines = content.split('\\n').filter((line) => line.length > 0);
539
+ const tail = lines.slice(-LOG_TAIL_LINES);
540
+ log(\`Last \${tail.length} line(s) of \${logPath}:\`);
541
+ for (const line of tail) {
542
+ console.error(line);
543
+ }
544
+ if (SUDO_RS_LOG_SIGNATURES.some((signature) => content.includes(signature))) {
545
+ log(
546
+ 'This log matches sudo-rs discarding the component environment. The ' +
547
+ 'nucleus launches every component through "sudo -E" to pass ' +
548
+ 'AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT and SVCUID, and ' +
549
+ 'sudo-rs (the default sudo on Ubuntu 25.10 and later) accepts -E and ' +
550
+ 'then discards the environment, so an IPC component never receives ' +
551
+ 'them. Classic sudo is already installed - select it and restart the ' +
552
+ 'nucleus: sudo update-alternatives --set sudo /usr/bin/sudo.ws && ' +
553
+ 'sudo systemctl restart greengrass',
554
+ );
555
+ }
556
+ };
557
+
558
+ /** Reports a component that started, then exits 0. */
559
+ const exitStarted = (
560
+ componentName: string,
561
+ componentVersion: string,
562
+ state: string,
563
+ ): never => {
564
+ log(\`\${componentName}=\${componentVersion} is \${state}.\`);
565
+ if (state === 'FINISHED') {
566
+ log(
567
+ 'FINISHED means the Run lifecycle exited 0 rather than staying up. A ' +
568
+ 'generated component subscribes to IPC and runs until it is stopped, ' +
569
+ 'so unless you made this one deliberately one-shot, read its log with ' +
570
+ "the component's \\"-logs\\" target.",
571
+ );
572
+ }
573
+ process.exit(0);
574
+ };
575
+
576
+ async function main(): Promise<void> {
577
+ const [dist] = process.argv.slice(2);
578
+ if (!dist) {
579
+ usage();
580
+ }
581
+
582
+ const greengrassRoot = process.env.GREENGRASS_ROOT || '/greengrass/v2';
583
+ let recipeDir = join(dist, 'greengrass-build', 'recipes');
584
+ let artifactDir = join(dist, 'greengrass-build', 'artifacts');
585
+
586
+ let componentName: string;
587
+ let componentVersion: string;
588
+ let recipe: Record<string, unknown>;
589
+ try {
590
+ ({ componentName, componentVersion, recipe } = readResolvedRecipe(recipeDir));
591
+ } catch (e) {
592
+ log((e as Error).message);
593
+ process.exit(1);
594
+ return;
595
+ }
596
+
597
+ const timeoutSeconds = resolveTimeoutSeconds();
598
+ const timeoutMs = timeoutSeconds * 1000;
599
+ const cli = join(greengrassRoot, 'bin', 'greengrass-cli');
600
+
601
+ // Read BEFORE submitting. The state read afterwards can only be attributed
602
+ // to this deployment if the device was not already running this exact
603
+ // version - \`component list\` reports no instance identity to tell the
604
+ // incoming instance from the outgoing one.
605
+ const before = runCli(cli, ['component', 'list'], timeoutMs);
606
+ const installed =
607
+ !before.error && before.status === 0
608
+ ? parseComponentStatus(before.stdout ?? '', componentName)
609
+ : undefined;
610
+
611
+ if (isNextVersionSentinel(componentVersion)) {
612
+ const sentinel = componentVersion;
613
+ if (before.error || before.status !== 0) {
614
+ const detail = (before.error?.message ?? before.stderr ?? '').trim();
615
+ log(
616
+ \`Cannot resolve \${sentinel} locally because "\${cli} component list" failed\${detail ? \`: \${detail}\` : ''}. Local NEXT_VERSION resolution needs the installed component version.\`,
617
+ );
618
+ process.exit(1);
619
+ return;
620
+ }
621
+
622
+ const history = readLocalVersionHistory(greengrassRoot, componentName);
623
+ let resolved: string;
624
+ try {
625
+ resolved = resolveLocalNextVersion(installed?.version, history, sentinel);
626
+ } catch (e) {
627
+ log((e as Error).message);
628
+ process.exit(1);
629
+ return;
630
+ }
631
+
632
+ const localDir = join(dist, 'greengrass-build', 'local');
633
+ const localRecipeDir = join(localDir, 'recipes');
634
+ const localArtifactDir = join(
635
+ localDir,
636
+ 'artifacts',
637
+ componentName,
638
+ resolved,
639
+ );
640
+ const sourceArtifactDir = join(
641
+ artifactDir,
642
+ componentName,
643
+ componentVersion,
644
+ );
645
+ try {
646
+ rmSync(localDir, { recursive: true, force: true });
647
+ mkdirSync(localRecipeDir, { recursive: true });
648
+ mkdirSync(localArtifactDir, { recursive: true });
649
+ writeFileSync(
650
+ join(localRecipeDir, \`\${componentName}-\${resolved}.yaml\`),
651
+ yaml.dump(
652
+ { ...recipe, ComponentVersion: resolved },
653
+ { lineWidth: -1 },
654
+ ),
655
+ );
656
+ for (const artifact of readdirSync(sourceArtifactDir)) {
657
+ cpSync(
658
+ join(sourceArtifactDir, artifact),
659
+ join(localArtifactDir, artifact),
660
+ { recursive: true },
661
+ );
662
+ }
663
+ } catch (e) {
664
+ log(
665
+ \`Cannot stage \${sentinel} as \${resolved} for local deployment: \${(e as Error).message}\`,
666
+ );
667
+ process.exit(1);
668
+ return;
669
+ }
670
+
671
+ log(
672
+ \`Resolved \${sentinel} to \${resolved} (installed: \${installed?.version ?? 'none'}; package stores: \${history.length > 0 ? history.join(', ') : 'none'}).\`,
673
+ );
674
+ componentVersion = resolved;
675
+ recipeDir = localRecipeDir;
676
+ artifactDir = join(localDir, 'artifacts');
677
+ } else if (installed?.version === componentVersion) {
678
+ log(
679
+ \`This device already reports \${componentName}=\${componentVersion}. The \` +
680
+ 'state check below can then read the outgoing instance and pass while ' +
681
+ 'the incoming one fails - bump ComponentVersion in recipe.yaml before ' +
682
+ 're-deploying.',
683
+ );
684
+ }
685
+
686
+ const args = [
687
+ 'deployment',
688
+ 'create',
689
+ '--recipeDir',
690
+ recipeDir,
691
+ '--artifactDir',
692
+ artifactDir,
693
+ '--merge',
694
+ \`\${componentName}=\${componentVersion}\`,
695
+ ];
696
+
697
+ log(\`Deploying \${componentName}=\${componentVersion} via \${cli}\`);
698
+ const result = runCli(cli, args, timeoutMs);
699
+ process.stdout.write(result.stdout ?? '');
700
+ process.stderr.write(result.stderr ?? '');
701
+ if (result.error) {
702
+ log(
703
+ (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
704
+ ? \`"\${cli} deployment create" did not return within \${timeoutSeconds}s.\`
705
+ : \`Cannot run "\${cli}": \${result.error.message}. Run this target on \` +
706
+ 'the Greengrass core device, and set GREENGRASS_ROOT when the ' +
707
+ 'nucleus is not installed at /greengrass/v2.',
708
+ );
709
+ process.exit(1);
710
+ }
711
+ if (result.status !== 0) {
712
+ log(\`greengrass-cli exited with \${result.status ?? 'no status'}.\`);
713
+ process.exit(result.status ?? 1);
714
+ }
715
+
716
+ log(
717
+ \`Deployment submitted. Waiting up to \${timeoutSeconds}s for \` +
718
+ \`\${componentName} to hold \${SETTLED_RUNNING_STATES.join(' or ')} across \` +
719
+ \`\${REQUIRED_STABLE_CHECKS} checks.\`,
720
+ );
721
+
722
+ const deadline = Date.now() + timeoutMs;
723
+ let lastState: string | undefined;
724
+ let stableChecks = 0;
725
+ let listFailureReported = false;
726
+ while (Date.now() < deadline) {
727
+ const list = runCli(cli, ['component', 'list'], deadline - Date.now());
728
+ if (list.error || list.status !== 0) {
729
+ if (!listFailureReported) {
730
+ listFailureReported = true;
731
+ log(
732
+ \`"\${cli} component list" failed: \${(
733
+ list.error?.message ??
734
+ list.stderr ??
735
+ ''
736
+ ).trim()} - retrying until the timeout.\`,
737
+ );
738
+ }
739
+ } else {
740
+ const listed = parseComponentStatus(list.stdout ?? '', componentName);
741
+ // Only a report of the version just deployed counts. An entry still
742
+ // carrying the previous version means the nucleus has not applied the
743
+ // deployment yet.
744
+ if (listed?.version === componentVersion && listed.state) {
745
+ lastState = listed.state;
746
+ if (SETTLED_FAILED_STATES.includes(lastState)) {
747
+ log(
748
+ \`\${componentName}=\${componentVersion} is \${lastState} - the \` +
749
+ 'component did not start. The local deployment itself can still ' +
750
+ 'report success, so this state is the evidence that matters.',
751
+ );
752
+ reportComponentLog(greengrassRoot, componentName);
753
+ process.exit(1);
754
+ }
755
+ if (SETTLED_RUNNING_STATES.includes(lastState)) {
756
+ stableChecks += 1;
757
+ if (stableChecks >= REQUIRED_STABLE_CHECKS) {
758
+ exitStarted(componentName, componentVersion, lastState);
759
+ }
760
+ } else {
761
+ // STARTING, NEW, INSTALLED, STOPPING: on its way somewhere.
762
+ stableChecks = 0;
763
+ }
764
+ }
765
+ }
766
+ await sleep(POLL_INTERVAL_MS);
767
+ }
768
+
769
+ // The deadline caught a component that started and never went bad. That is a
770
+ // pass: only the stability window is unfinished, and waiting longer is the
771
+ // caller's choice to make.
772
+ if (lastState !== undefined && SETTLED_RUNNING_STATES.includes(lastState)) {
773
+ log(
774
+ \`The \${timeoutSeconds}s timeout ended after \${stableChecks} of \` +
775
+ \`\${REQUIRED_STABLE_CHECKS} stable checks. Raise \` +
776
+ 'GREENGRASS_DEPLOY_TIMEOUT_SECONDS to confirm the state holds.',
777
+ );
778
+ exitStarted(componentName, componentVersion, lastState);
779
+ }
780
+
781
+ log(
782
+ \`\${componentName}=\${componentVersion} did not settle within \` +
783
+ \`\${timeoutSeconds}s - last reported state \${
784
+ lastState ?? 'none (the component was not listed at this version)'
785
+ }. Raise GREENGRASS_DEPLOY_TIMEOUT_SECONDS if this component is slow to \` +
786
+ 'start.',
787
+ );
788
+ reportComponentLog(greengrassRoot, componentName);
789
+ process.exit(1);
790
+ }
791
+
792
+ void main();
793
+ "
794
+ `;
795
+
796
+ exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/recipe-utils.ts 1`] = `
797
+ "import { readFileSync, readdirSync } from 'node:fs';
798
+ import { join } from 'node:path';
799
+ import yaml from 'js-yaml';
800
+
801
+ /**
802
+ * A narrow view of a Greengrass recipe: only the keys this vended toolchain
803
+ * reads. Every other key - including unknown lifecycle keys - is preserved
804
+ * untouched wherever a recipe is re-serialized, because the recipe is
805
+ * user-owned and this toolchain does not model the full recipe schema.
806
+ */
807
+ export type GreengrassRecipe = Record<string, unknown>;
808
+
809
+ export interface ResolvedComponent {
810
+ readonly componentName: string;
811
+ readonly componentVersion: string;
812
+ readonly recipe: GreengrassRecipe;
813
+ }
814
+
815
+ const COMPONENT_NAME_PATTERN = /^[a-zA-Z0-9-_.]+$/;
816
+ const RESERVED_COMPONENT_NAME_PREFIX = 'aws.greengrass.';
817
+ const MAX_COMPONENT_NAME_LENGTH = 128;
818
+ // https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
819
+ const SEMVER_PATTERN =
820
+ /^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$/;
821
+ const STRICT_COMPONENT_VERSION_PATTERN =
822
+ /^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$/;
823
+ const DECOMPRESSED_PATH_TOKEN = '{artifacts:decompressedPath}';
824
+
825
+ export const NEXT_VERSION_SENTINELS = [
826
+ 'NEXT_PATCH',
827
+ 'NEXT_MINOR',
828
+ 'NEXT_MAJOR',
829
+ ] as const;
830
+
831
+ export type NextVersionSentinel = (typeof NEXT_VERSION_SENTINELS)[number];
832
+
833
+ /** Whether a value requests local deploy-time component version resolution. */
834
+ export function isNextVersionSentinel(
835
+ value: unknown,
836
+ ): value is NextVersionSentinel {
837
+ return (
838
+ typeof value === 'string' &&
839
+ NEXT_VERSION_SENTINELS.includes(value as NextVersionSentinel)
840
+ );
841
+ }
842
+
843
+ /**
844
+ * Resolves a version sentinel on a device: bumps the highest strict M.m.p
845
+ * version among the installed version and the candidate versions the caller
846
+ * collected from the nucleus's package stores, or returns 1.0.0 when there is
847
+ * none. A candidate that is not strict M.m.p is ignored. An installed version
848
+ * that is not strict M.m.p is an error, because the sentinel cannot bump it.
849
+ */
850
+ export function resolveLocalNextVersion(
851
+ installedVersion: string | undefined,
852
+ candidateVersions: readonly string[],
853
+ sentinel: NextVersionSentinel,
854
+ ): string {
855
+ if (
856
+ installedVersion !== undefined &&
857
+ !STRICT_COMPONENT_VERSION_PATTERN.test(installedVersion)
858
+ ) {
859
+ throw new Error(
860
+ \`Installed component version "\${installedVersion}" is not a strict M.m.p version, so \${sentinel} cannot resolve it.\`,
861
+ );
862
+ }
863
+
864
+ const highest = [...candidateVersions, installedVersion]
865
+ .filter(
866
+ (version): version is string =>
867
+ version !== undefined && STRICT_COMPONENT_VERSION_PATTERN.test(version),
868
+ )
869
+ .reduce<string | undefined>((currentHighest, candidate) => {
870
+ if (currentHighest === undefined) {
871
+ return candidate;
872
+ }
873
+ const current = STRICT_COMPONENT_VERSION_PATTERN.exec(currentHighest)!;
874
+ const next = STRICT_COMPONENT_VERSION_PATTERN.exec(candidate)!;
875
+ for (let index = 1; index <= 3; index++) {
876
+ const difference = BigInt(next[index]) - BigInt(current[index]);
877
+ if (difference !== 0n) {
878
+ return difference > 0n ? candidate : currentHighest;
879
+ }
880
+ }
881
+ return currentHighest;
882
+ }, undefined);
883
+ if (highest === undefined) {
884
+ return '1.0.0';
885
+ }
886
+
887
+ const [, major, minor, patch] =
888
+ STRICT_COMPONENT_VERSION_PATTERN.exec(highest)!;
889
+ switch (sentinel) {
890
+ case 'NEXT_PATCH':
891
+ return \`\${major}.\${minor}.\${(BigInt(patch) + 1n).toString()}\`;
892
+ case 'NEXT_MINOR':
893
+ return \`\${major}.\${(BigInt(minor) + 1n).toString()}.0\`;
894
+ case 'NEXT_MAJOR':
895
+ return \`\${(BigInt(major) + 1n).toString()}.0.0\`;
896
+ }
897
+ }
898
+
899
+ /** Reads and parses a recipe.yaml. Throws with a clear message on failure. */
900
+ export function loadRecipe(recipePath: string): GreengrassRecipe {
901
+ let raw: string;
902
+ try {
903
+ raw = readFileSync(recipePath, 'utf-8');
904
+ } catch {
905
+ throw new Error(\`Cannot read recipe at "\${recipePath}"\`);
906
+ }
907
+ const parsed = yaml.load(raw);
908
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
909
+ throw new Error(\`"\${recipePath}" is not a Greengrass recipe document\`);
910
+ }
911
+ return parsed as GreengrassRecipe;
912
+ }
913
+
914
+ /**
915
+ * Validates only the recipe fields this vended toolchain consumes:
916
+ * \`ComponentName\`, \`ComponentVersion\`, and, for every entry in
917
+ * \`artifactBaseNames\`, the \`{artifacts:decompressedPath}\` reference the
918
+ * local-deploy lifecycle script must carry so that a deployed component can
919
+ * find its own artifact once Greengrass decompresses it.
920
+ *
921
+ * Throws a single \`Error\` listing every violation when the recipe is invalid.
922
+ */
923
+ export function validateRecipe(
924
+ recipe: GreengrassRecipe,
925
+ artifactBaseNames: readonly string[],
926
+ ): ResolvedComponent {
927
+ const errors: string[] = [];
928
+
929
+ const componentName = recipe.ComponentName;
930
+ if (typeof componentName !== 'string' || componentName.length === 0) {
931
+ errors.push('ComponentName is required and must be a non-empty string');
932
+ } else {
933
+ if (componentName.length > MAX_COMPONENT_NAME_LENGTH) {
934
+ errors.push(
935
+ \`ComponentName must be at most \${MAX_COMPONENT_NAME_LENGTH} characters, got \${componentName.length}\`,
936
+ );
937
+ }
938
+ if (!COMPONENT_NAME_PATTERN.test(componentName)) {
939
+ errors.push(
940
+ 'ComponentName may only contain letters, numbers, "-", "_" and "."',
941
+ );
942
+ }
943
+ if (componentName.startsWith(RESERVED_COMPONENT_NAME_PREFIX)) {
944
+ errors.push(
945
+ \`ComponentName must not start with the reserved prefix "\${RESERVED_COMPONENT_NAME_PREFIX}"\`,
946
+ );
947
+ }
948
+ }
949
+
950
+ const componentVersion = recipe.ComponentVersion;
951
+ if (
952
+ typeof componentVersion !== 'string' ||
953
+ (!SEMVER_PATTERN.test(componentVersion) &&
954
+ !isNextVersionSentinel(componentVersion))
955
+ ) {
956
+ errors.push(
957
+ 'ComponentVersion is required and must be a valid semver version (e.g. "1.0.0") or one of NEXT_PATCH, NEXT_MINOR, NEXT_MAJOR',
958
+ );
959
+ }
960
+
961
+ for (const baseName of artifactBaseNames) {
962
+ if (!referencesDecompressedComponentDir(recipe, baseName)) {
963
+ errors.push(
964
+ \`No lifecycle script references "\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" - \` +
965
+ \`the Run script path must match the artifact zip base name ("\${baseName}.zip")\`,
966
+ );
967
+ }
968
+ if (!declaresComponentZipArtifact(recipe, baseName)) {
969
+ errors.push(
970
+ \`No manifest declares an artifact whose Uri ends in "/\${baseName}.zip" - \` +
971
+ 'Greengrass only unarchives a declared artifact, so ' +
972
+ \`"\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" would never resolve on the device\`,
973
+ );
974
+ }
975
+ }
976
+
977
+ if (errors.length > 0) {
978
+ throw new Error(
979
+ \`Invalid recipe:\\n\${errors.map((e) => \` - \${e}\`).join('\\n')}\`,
980
+ );
981
+ }
982
+
983
+ return {
984
+ componentName: componentName as string,
985
+ componentVersion: componentVersion as string,
986
+ recipe,
987
+ };
988
+ }
989
+
990
+ /**
991
+ * Recursively searches every string value for a decompressedPath reference to
992
+ * \`componentDirName\`, wherever the recipe places its Lifecycle block (top
993
+ * level or per-manifest).
994
+ */
995
+ function referencesDecompressedComponentDir(
996
+ value: unknown,
997
+ componentDirName: string,
998
+ ): boolean {
999
+ const needle = \`\${DECOMPRESSED_PATH_TOKEN}/\${componentDirName}/\`;
1000
+ if (typeof value === 'string') {
1001
+ return value.includes(needle);
1002
+ }
1003
+ if (Array.isArray(value)) {
1004
+ return value.some((entry) =>
1005
+ referencesDecompressedComponentDir(entry, componentDirName),
1006
+ );
1007
+ }
1008
+ if (value !== null && typeof value === 'object') {
1009
+ return Object.values(value).some((entry) =>
1010
+ referencesDecompressedComponentDir(entry, componentDirName),
1011
+ );
1012
+ }
1013
+ return false;
1014
+ }
1015
+
1016
+ /**
1017
+ * Whether any manifest declares an artifact whose Uri basename is
1018
+ * \`<componentDirName>.zip\`, which is the name the artifact this toolchain
1019
+ * writes actually carries.
1020
+ */
1021
+ function declaresComponentZipArtifact(
1022
+ recipe: GreengrassRecipe,
1023
+ componentDirName: string,
1024
+ ): boolean {
1025
+ const manifests = Array.isArray(recipe.Manifests) ? recipe.Manifests : [];
1026
+ return manifests.some((manifest) =>
1027
+ manifestDeclaresZipArtifact(manifest, componentDirName),
1028
+ );
1029
+ }
1030
+
1031
+ /**
1032
+ * Whether one manifest declares an artifact whose Uri basename is
1033
+ * \`<componentDirName>.zip\`. Case-insensitive on the key so a recipe authored
1034
+ * with \`URI\` (as some AWS samples spell it) still matches.
1035
+ */
1036
+ function manifestDeclaresZipArtifact(
1037
+ manifest: unknown,
1038
+ componentDirName: string,
1039
+ ): boolean {
1040
+ const suffix = \`/\${componentDirName}.zip\`;
1041
+ const artifacts = (manifest as { Artifacts?: unknown })?.Artifacts;
1042
+ if (!Array.isArray(artifacts)) {
1043
+ return false;
1044
+ }
1045
+ return artifacts.some((artifact) => {
1046
+ if (artifact === null || typeof artifact !== 'object') {
1047
+ return false;
1048
+ }
1049
+ return Object.entries(artifact as Record<string, unknown>).some(
1050
+ ([key, value]) =>
1051
+ key.toLowerCase() === 'uri' &&
1052
+ typeof value === 'string' &&
1053
+ value.endsWith(suffix),
1054
+ );
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * Multi-architecture only: checks that every manifest whose
1060
+ * \`Platform.architecture\` names an architecture this build vendored pairs with
1061
+ * THAT architecture's own artifact, both in its \`Artifacts\` and in the
1062
+ * lifecycle that unarchives it.
1063
+ *
1064
+ * {@link validateRecipe}'s checks are global - they prove every built zip is
1065
+ * declared and referenced somewhere in the recipe. A recipe that swaps two
1066
+ * manifests' artifacts and Run paths satisfies them, and a swap sends one
1067
+ * architecture's native wheels to the other, which fails only on the device.
1068
+ * A swap is one paste mistake away, because adding a second manifest to an
1069
+ * existing component is a hand edit of a user-owned recipe.
1070
+ *
1071
+ * A manifest with no \`Platform.architecture\`, or one naming an architecture
1072
+ * this build did not vendor, is left alone: a recipe may carry manifests this
1073
+ * toolchain does not build.
1074
+ */
1075
+ export function validateManifestArchitectures(
1076
+ recipe: GreengrassRecipe,
1077
+ baseNameByArchitecture: Readonly<Record<string, string>>,
1078
+ ): void {
1079
+ const errors: string[] = [];
1080
+ const manifests = Array.isArray(recipe.Manifests) ? recipe.Manifests : [];
1081
+ manifests.forEach((manifest, index) => {
1082
+ const architecture = (
1083
+ manifest as { Platform?: { architecture?: unknown } } | null
1084
+ )?.Platform?.architecture;
1085
+ if (typeof architecture !== 'string') {
1086
+ return;
1087
+ }
1088
+ const baseName = baseNameByArchitecture[architecture];
1089
+ if (!baseName) {
1090
+ return;
1091
+ }
1092
+ if (!manifestDeclaresZipArtifact(manifest, baseName)) {
1093
+ errors.push(
1094
+ \`Manifest \${index + 1} declares "architecture: \${architecture}", so its own artifact Uri must end in "/\${baseName}.zip" - \` +
1095
+ \`that is the zip this build writes for "\${architecture}". Manifests must not carry another architecture's artifact\`,
1096
+ );
1097
+ }
1098
+ const lifecycle =
1099
+ (manifest as { Lifecycle?: unknown }).Lifecycle ?? recipe.Lifecycle;
1100
+ if (!referencesDecompressedComponentDir(lifecycle, baseName)) {
1101
+ errors.push(
1102
+ \`Manifest \${index + 1} declares "architecture: \${architecture}", so its lifecycle must reference \` +
1103
+ \`"\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" - the directory that architecture's own artifact unarchives to\`,
1104
+ );
1105
+ }
1106
+ });
1107
+
1108
+ if (errors.length > 0) {
1109
+ throw new Error(
1110
+ \`Invalid recipe:\\n\${errors.map((e) => \` - \${e}\`).join('\\n')}\`,
1111
+ );
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * Reads the single resolved recipe a prior \`-artifact\` run wrote under a
1117
+ * \`greengrass-build/recipes\` directory, for scripts that only need to know
1118
+ * which component and version were last built.
1119
+ */
1120
+ export function readResolvedRecipe(recipesDir: string): ResolvedComponent {
1121
+ let files: string[];
1122
+ try {
1123
+ files = readdirSync(recipesDir).filter(
1124
+ (f) => f.endsWith('.yaml') || f.endsWith('.yml'),
1125
+ );
1126
+ } catch {
1127
+ throw new Error(
1128
+ \`No resolved recipes found at "\${recipesDir}" - run the component's "-artifact" target first\`,
1129
+ );
1130
+ }
1131
+ if (files.length !== 1) {
1132
+ throw new Error(
1133
+ \`Expected exactly one resolved recipe in "\${recipesDir}", found \${files.length}\`,
1134
+ );
1135
+ }
1136
+ const recipePath = join(recipesDir, files[0]);
1137
+ const recipe = loadRecipe(recipePath);
1138
+ const { ComponentName, ComponentVersion } = recipe;
1139
+ if (
1140
+ typeof ComponentName !== 'string' ||
1141
+ typeof ComponentVersion !== 'string'
1142
+ ) {
1143
+ throw new Error(
1144
+ \`"\${recipePath}" is missing ComponentName/ComponentVersion\`,
1145
+ );
1146
+ }
1147
+ return {
1148
+ componentName: ComponentName,
1149
+ componentVersion: ComponentVersion,
1150
+ recipe,
1151
+ };
1152
+ }
1153
+ "
1154
+ `;
1155
+
1156
+ exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/zip-writer.ts 1`] = `
1157
+ "import { writeFileSync } from 'node:fs';
1158
+ // crc32 from node:zlib requires Node >= 20.15 / 22.2.
1159
+ import { crc32, deflateRawSync } from 'node:zlib';
1160
+
1161
+ /** One file to place in the archive, at a forward-slash relative path. */
1162
+ export interface ZipEntry {
1163
+ readonly name: string;
1164
+ readonly data: Buffer;
1165
+ }
1166
+
1167
+ const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
1168
+ const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
1169
+ const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
1170
+ // 20 = version 2.0, the lowest version that documents DEFLATE support.
1171
+ const VERSION_NEEDED = 20;
1172
+ const DEFLATE_METHOD = 8;
1173
+ const STORED_METHOD = 0;
1174
+ const MAX_ENTRIES = 65_535;
1175
+
1176
+ /**
1177
+ * Fixed MS-DOS timestamp fields (1980-01-01 00:00:00, the DOS epoch).
1178
+ *
1179
+ * The archive must be byte-reproducible: its sha256 keys the artifact's S3
1180
+ * object and is substituted into the recipe, so a rebuild that produced
1181
+ * different bytes for identical content would make CloudFormation try to
1182
+ * replace an already-published, immutable component version and fail with
1183
+ * AlreadyExists. Stamping the build time would do exactly that on every
1184
+ * rebuild.
1185
+ */
1186
+ const DOS_EPOCH_TIME = 0;
1187
+ const DOS_EPOCH_DATE = (1 << 5) | 1;
1188
+
1189
+ /**
1190
+ * Writes \`entries\` to a plain ZIP archive at \`outPath\` with no external
1191
+ * dependency - only \`node:zlib\` for DEFLATE and CRC-32. Supports exactly what
1192
+ * Greengrass artifact packaging needs: a flat set of files, no encryption, no
1193
+ * zip64 (component artifacts are small). Cross-platform: no shell-out to a
1194
+ * system \`zip\` binary.
1195
+ */
1196
+ export function writeZip(entries: readonly ZipEntry[], outPath: string): void {
1197
+ if (entries.length > MAX_ENTRIES) {
1198
+ throw new RangeError(
1199
+ \`ZIP64 is not supported; this ZIP writer can store at most 65,535 entries, received \${entries.length}.\`,
1200
+ );
1201
+ }
1202
+
1203
+ const fileChunks: Buffer[] = [];
1204
+ const centralRecords: Buffer[] = [];
1205
+ let offset = 0;
1206
+ const time = DOS_EPOCH_TIME;
1207
+ const date = DOS_EPOCH_DATE;
1208
+
1209
+ // Sorted so directory iteration order cannot change the archive's bytes.
1210
+ const sortedEntries = [...entries].sort((a, b) =>
1211
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
1212
+ );
1213
+ for (const entry of sortedEntries) {
1214
+ const nameBuf = Buffer.from(entry.name, 'utf-8');
1215
+ const entryCrc32 = crc32(entry.data) >>> 0;
1216
+ const compressed = deflateRawSync(entry.data);
1217
+ const useDeflate = compressed.length < entry.data.length;
1218
+ const method = useDeflate ? DEFLATE_METHOD : STORED_METHOD;
1219
+ const payload = useDeflate ? compressed : entry.data;
1220
+
1221
+ const localHeader = Buffer.alloc(30);
1222
+ localHeader.writeUInt32LE(LOCAL_FILE_HEADER_SIGNATURE, 0);
1223
+ localHeader.writeUInt16LE(VERSION_NEEDED, 4);
1224
+ localHeader.writeUInt16LE(0, 6); // general purpose flags
1225
+ localHeader.writeUInt16LE(method, 8);
1226
+ localHeader.writeUInt16LE(time, 10);
1227
+ localHeader.writeUInt16LE(date, 12);
1228
+ localHeader.writeUInt32LE(entryCrc32, 14);
1229
+ localHeader.writeUInt32LE(payload.length, 18);
1230
+ localHeader.writeUInt32LE(entry.data.length, 22);
1231
+ localHeader.writeUInt16LE(nameBuf.length, 26);
1232
+ localHeader.writeUInt16LE(0, 28); // extra field length
1233
+
1234
+ fileChunks.push(localHeader, nameBuf, payload);
1235
+
1236
+ const centralHeader = Buffer.alloc(46);
1237
+ centralHeader.writeUInt32LE(CENTRAL_DIRECTORY_SIGNATURE, 0);
1238
+ centralHeader.writeUInt16LE(VERSION_NEEDED, 4); // version made by
1239
+ centralHeader.writeUInt16LE(VERSION_NEEDED, 6); // version needed to extract
1240
+ centralHeader.writeUInt16LE(0, 8); // general purpose flags
1241
+ centralHeader.writeUInt16LE(method, 10);
1242
+ centralHeader.writeUInt16LE(time, 12);
1243
+ centralHeader.writeUInt16LE(date, 14);
1244
+ centralHeader.writeUInt32LE(entryCrc32, 16);
1245
+ centralHeader.writeUInt32LE(payload.length, 20);
1246
+ centralHeader.writeUInt32LE(entry.data.length, 24);
1247
+ centralHeader.writeUInt16LE(nameBuf.length, 28);
1248
+ centralHeader.writeUInt16LE(0, 30); // extra field length
1249
+ centralHeader.writeUInt16LE(0, 32); // file comment length
1250
+ centralHeader.writeUInt16LE(0, 34); // disk number start
1251
+ centralHeader.writeUInt16LE(0, 36); // internal file attributes
1252
+ centralHeader.writeUInt32LE(0, 38); // external file attributes
1253
+ centralHeader.writeUInt32LE(offset, 42); // relative offset of local header
1254
+
1255
+ centralRecords.push(centralHeader, nameBuf);
1256
+
1257
+ offset += localHeader.length + nameBuf.length + payload.length;
1258
+ }
1259
+
1260
+ const centralDirectory = Buffer.concat(centralRecords);
1261
+ const centralDirectoryOffset = offset;
1262
+
1263
+ const endRecord = Buffer.alloc(22);
1264
+ endRecord.writeUInt32LE(END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
1265
+ endRecord.writeUInt16LE(0, 4); // disk number
1266
+ endRecord.writeUInt16LE(0, 6); // disk with central directory start
1267
+ endRecord.writeUInt16LE(entries.length, 8); // entries on this disk
1268
+ endRecord.writeUInt16LE(entries.length, 10); // total entries
1269
+ endRecord.writeUInt32LE(centralDirectory.length, 12);
1270
+ endRecord.writeUInt32LE(centralDirectoryOffset, 16);
1271
+ endRecord.writeUInt16LE(0, 20); // comment length
1272
+
1273
+ writeFileSync(
1274
+ outPath,
1275
+ Buffer.concat([...fileChunks, centralDirectory, endRecord]),
1276
+ );
1277
+ }
1278
+ "
1279
+ `;