@uknows/setup 0.1.0 → 0.2.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 (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
package/lib/hosts.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  /*
2
2
  * Where the app runs, read from deploy files in the repo, and the "set your
3
- * key once" steps for those hosts. Source cues and precedence come from
4
- * instrumentation/agents/setup-cli/lib/hosting.mjs. Environment variables of
5
- * this computer are never used: the CLI runs on a laptop, not on the host.
3
+ * key once" steps for those hosts. Environment variables of this computer
4
+ * are never used: the CLI runs on a laptop, not on the host.
6
5
  */
7
- import { DOCS_URL, HOSTS, SETTINGS_FILE, SETTINGS_FILE_LOADERS } from './contract.mjs'
6
+ import { APP_SERVICE_SLOTS, DEPLOY_CHECKS, DOCS_URL, EXAMPLE_NAMES, EXAMPLE_SERVICE, FUNCTIONS, GITHUB_JOBS, HOSTS, SECRET, SETTINGS, SETTINGS_FILE, SETTINGS_FILE_LOADERS,
7
+ acaKeyScript, appServiceKeyScript, appServiceSlotScript, functionsKeyScript, githubKeyStep, githubSecretCommands } from './contract.mjs'
8
8
 
9
9
  const MAX_BYTES = 262144
10
- const RUNTIME = ['azure-container-apps', 'azure-app-service', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
11
- /** hosting.mjs adapter ids -> docs host ids. */
12
- const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'aws-ecs': 'aws-ecs',
10
+ const RUNTIME = ['azure-container-apps', 'azure-app-service', 'azure-functions', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
11
+ /** Adapter ids -> docs host ids. */
12
+ const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'azure-functions': 'azure-functions', 'aws-ecs': 'aws-ecs',
13
13
  kubernetes: 'kubernetes', 'alibaba-ack': 'kubernetes',
14
14
  'alibaba-sae': 'alibaba-sae', 'alibaba-ecs': 'vm-docker', vm: 'vm-docker', docker: 'vm-docker' }
15
15
 
@@ -22,7 +22,7 @@ const SCRIPT = /\.(?:ya?ml|sh|ps1)$/
22
22
  const TF = /\.tf$/
23
23
  const API = /^[ \t]*apiVersion[ \t]*:/m
24
24
  const WORKFLOW = /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/
25
- const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|docker push|aliyun|aws ecs|helm upgrade/i
25
+ const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|functionapp|docker push|aliyun|aws ecs|helm upgrade/i
26
26
  /** An ECS task definition file: a JSON file with containerDefinitions. */
27
27
  const TASK_DEF = /(?:^|\/)[^/]*task[-_.]?def(?:inition)?[^/]*\.json$/i
28
28
 
@@ -45,6 +45,14 @@ const RULES = [
45
45
  ['azure-app-service', 'Azure App Service deploy action', null, /azure\/webapps-deploy@/],
46
46
  ['azure-app-service', 'azure.yaml deploys to App Service', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?appservice\b/m],
47
47
  ['azure-app-service', 'Deploy step runs az webapp', SCRIPT, /\baz[ \t]+webapp[ \t]+(?:create|up|deploy|config[ \t]+(?:container|appsettings))\b/],
48
+ // Azure Functions: not a host of the docs page yet (FUNCTIONS in the contract). host.json with version 2.0 is the
49
+ // project file of a Functions app.
50
+ ['azure-functions', 'Azure Functions in Terraform', TF, /\bresource\s+"azurerm_(?:linux_|windows_)?function_app(?:_flex_consumption)?"\s+"[^"\r\n]+"/],
51
+ ['azure-functions', 'Azure Functions in Bicep or ARM', null, /\bkind['"]?\s*:\s*['"]functionapp/i, /Microsoft\.Web\/sites(?:@|["'\s])/],
52
+ ['azure-functions', 'Azure Functions deploy action', null, /\bAzure\/functions-action@/i],
53
+ ['azure-functions', 'Deploy step runs az functionapp', SCRIPT, /\baz[ \t]+functionapp[ \t]+(?:create|deployment|config|update|deploy)\b/],
54
+ ['azure-functions', 'azure.yaml deploys to Azure Functions', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?function\b/m],
55
+ ['azure-functions', 'host.json of an Azure Functions app', /(?:^|\/)host\.json$/, /"version"\s*:\s*"2\.0"/],
48
56
  ['aws-ecs', 'AWS ECS in Terraform', TF, /\bresource\s+"aws_ecs_(?:service|task_definition)"\s+"[^"\r\n]+"/],
49
57
  ['aws-ecs', 'AWS ECS in CloudFormation or CDK output', null, /\bAWS::ECS::(?:TaskDefinition|Service)\b/],
50
58
  ['aws-ecs', 'ECS task definition', TASK_DEF, /"containerDefinitions"\s*:/],
@@ -59,8 +67,31 @@ const RULES = [
59
67
  ['alibaba-sae', 'Deploy step runs aliyun sae', SCRIPT, /\baliyun[ \t]+sae\b/],
60
68
  ]
61
69
 
70
+ /**
71
+ * The checks of install.ts HOSTS[].checks, and the deploy files that select them: [check id, host id, the evidence
72
+ * (RULES text) of the files to read, the line that selects the check, the snippet languages to print]. A check
73
+ * with no evidence is printed for every repo with that host. The texts come from install.ts.
74
+ */
75
+ const CHECKS = [
76
+ ['replace-env-vars', 'azure-container-apps', ['Deploy step runs az containerapp'], /--replace-env-vars\b/],
77
+ ['deploy-action-env', 'azure-container-apps', ['Azure Container Apps deploy action'], /^[ \t]*environmentVariables[ \t]*:/m],
78
+ ['deploy-action-yaml', 'azure-container-apps', ['Azure Container Apps deploy action'], /^[ \t]*yamlConfigPath[ \t]*:/m],
79
+ ['template', 'azure-container-apps', ['Azure Container App in Bicep or ARM'], /Microsoft\.App\/containerApps(?:@|["'\s])/, ['bicep']],
80
+ ['template', 'azure-container-apps', ['Azure Container App in Terraform'], /\bresource\s+"azurerm_container_app"/, ['hcl']],
81
+ // lifecycle ignore_changes on the env or the secrets: terraform apply keeps what az sets, so the template check does not apply to that file.
82
+ // The list on 1 line (with [0] indexes in it), or over several lines up to its closing ] line.
83
+ ['template-ignored', 'azure-container-apps', ['Azure Container App in Terraform'],
84
+ /\bignore_changes\s*=\s*\[(?:[^\n\]]*(?:\[[^\n\]]*\][^\n\]]*)*\b(?:env|secret)\b|[ \t]*\n(?:(?!\n\s*\])[\s\S]){0,400}?\b(?:env|secret)\b)/],
85
+ ['secret-first', 'azure-container-apps', null],
86
+ ['slot', 'azure-app-service', ['Azure App Service deploy action'], /^[ \t]*slot-name[ \t]*:[ \t]*\S/m],
87
+ ['slot', 'azure-app-service', ['Deploy step runs az webapp'], /\baz[ \t]+webapp\b[^\n]*(?:\\\n[^\n]*)*?[ \t]--slot(?:[ \t=]|$)/m],
88
+ ['task-definition', 'aws-ecs', ['ECS task definition'], /"containerDefinitions"\s*:/],
89
+ ['optional', 'kubernetes', null],
90
+ ['guard', 'github-actions', null],
91
+ ]
92
+
62
93
  const deployFile = (path) => DOCKERFILE.test(path) || /\.(?:ya?ml|tf|bicep|service|sh|ps1)$/.test(path) || TASK_DEF.test(path)
63
- || /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice)\.json$/.test(path)
94
+ || /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice|host)\.json$/.test(path)
64
95
 
65
96
  /**
66
97
  * Where the app runs, from deploy files in the repo.
@@ -105,22 +136,51 @@ export function detectHosts(ws) {
105
136
  }
106
137
 
107
138
  /**
108
- * The 'set your key once' steps for the detected hosts, from contract HOSTS.
109
- * envFile: true when a service got otel.env, so the steps also say how to load it.
139
+ * The 'set your key once' steps for the detected hosts, from contract HOSTS. After the steps of each host, the
140
+ * checks for the deploy files of the repo (HOSTS[].checks), with the file and line that selected each one.
110
141
  * Fields are read defensively: the docs file keeps changing.
142
+ *
143
+ * options (all optional):
144
+ * envFile a service got otel.env and no repo file loads it, so the steps also say how to load it.
145
+ * otelEnv a service keeps its settings in otel.env. Without it, the VM and Docker steps leave out otel.env.
146
+ * docker every service starts from a Dockerfile: the VM and Docker steps are the key file and docker run only.
147
+ * view reads the evidence files. Without it, only the checks that need no file are printed, and no names.
148
+ * names deployNames(): the app names, resource groups, slots and GitHub jobs that the deploy files hold.
149
+ * repo the GitHub repo, as owner/name, for the gh commands.
150
+ * service the name of the 1 service of this run, for the container name in the YAML, Bicep and Terraform examples.
151
+ * settings the settings of this run, as [name, value] pairs, so "the 5 settings" and the Kubernetes snippets fit.
152
+ * startLine true when the stack of the run needs its start line on the platform (Python and .NET).
153
+ * secretName the variable that holds the key: OTEL_EXPORTER_OTLP_TRACES_HEADERS for apps that keep their own metrics and logs.
111
154
  * @param {import('./types.mjs').Host[]} hosts
112
- * @param {{envFile?: boolean}} [options]
113
155
  * @returns {import('./types.mjs').Block[]}
114
156
  */
115
157
  export function keySteps(hosts, options = {}) {
116
- const ids = [...new Set((Array.isArray(hosts) ? hosts : []).map((h) => h?.id))]
158
+ const list = (Array.isArray(hosts) ? hosts : []).filter((h) => h && typeof h === 'object')
159
+ const ids = [...new Set(list.map((h) => h.id))]
160
+ const names = options.names ?? emptyNames()
161
+ const count = options.settings?.length ?? SETTINGS.length
117
162
  const blocks = []
163
+ const runtimes = ids.filter((id) => id !== 'github-actions')
118
164
  for (const id of ids) {
165
+ if (id === 'azure-functions') {
166
+ const fn = scriptNames(names.functions ?? { apps: [], groups: [] })
167
+ blocks.push({ title: FUNCTIONS.key.title, lines: [...FUNCTIONS.key.lines], snippets: [{ caption: 'Set the key on your function apps', language: 'bash', code: functionsKeyScript(fn) }] })
168
+ continue
169
+ }
170
+ // A function app keeps its app settings when new code is deployed: the key needs no step in the workflow.
171
+ if (id === 'github-actions' && runtimes.length && runtimes.every((r) => r === 'azure-functions')) continue
119
172
  const host = (Array.isArray(HOSTS) ? HOSTS : []).find((h) => h?.id === id)
120
173
  if (!host) continue
121
- const lines = [...strings(host.steps), ...strings(host.notes)]
174
+ let lines = [...strings(host.steps), ...strings(host.notes)]
175
+ let code = snippets(host.snippets)
122
176
  if (typeof host.stricter?.title === 'string') lines.push(`${host.stricter.title}. See ${DOCS_URL}.`)
123
- blocks.push({ title: `Set your key once: ${host.label ?? id}`, lines, snippets: snippets(host.snippets) })
177
+ const fitted = fit(id, lines, code, { ...options, names })
178
+ lines = fitted.lines.map((line) => line.replace(/\bthe 5 settings\b/g, `the ${count} settings`).replace(/\bThe 5 settings\b/g, `The ${count} settings`))
179
+ code = fitted.snippets.map((x) => ({ ...x, caption: x.caption.replace(/\b5 settings\b/g, `${count} settings`) }))
180
+ blocks.push({ title: `Set your key once: ${host.label ?? id}`, lines, snippets: code })
181
+ const checks = hostChecks(host, list.filter((h) => h.id === id).flatMap((h) => h.evidence ?? []), options.view)
182
+ if (checks.lines.length) blocks.push({ title: `${DEPLOY_CHECKS.title}: ${host.label ?? id}`, ...checks })
183
+ if (id === 'azure-app-service') blocks.push(...slotBlocks(names))
124
184
  }
125
185
  if (options.envFile) {
126
186
  const loaders = (Array.isArray(SETTINGS_FILE_LOADERS) ? SETTINGS_FILE_LOADERS : [])
@@ -129,9 +189,454 @@ export function keySteps(hosts, options = {}) {
129
189
  lines: loaders.filter((l) => l.summary).map((l) => `${l.label ?? l.id}: ${l.summary}`),
130
190
  snippets: snippets(loaders.map((l) => l.snippet)) })
131
191
  }
132
- return blocks
192
+ // Apps that keep their own OTLP metrics and logs get the key in the traces-only variable (settings.mjs).
193
+ const secret = options.secretName ?? SECRET.name
194
+ if (secret === SECRET.name) return blocks
195
+ const rename = (text) => text.split(SECRET.name).join(secret)
196
+ return blocks.map((b) => ({ ...b, title: rename(b.title), lines: b.lines.map(rename), snippets: (b.snippets ?? []).map((x) => ({ ...x, caption: rename(x.caption), code: rename(x.code) })) }))
197
+ }
198
+
199
+ /** The steps and snippets of 1 host, fitted to this repo: its names, its settings and how its apps start. */
200
+ function fit(id, lines, code, options) {
201
+ const { names, repo, service, settings } = options
202
+ const container = (text) => (service ? text.split(EXAMPLE_SERVICE).join(service) : text)
203
+ if (id === 'azure-container-apps') {
204
+ const aca = scriptNames(names.aca)
205
+ code = code.map((x, i) => (i === 0 ? { ...x, code: acaKeyScript(aca) } : { ...x, code: container(x.code) }))
206
+ if (names.aca.apps.length) lines = lines.map((l) => (/^Set SUBSCRIPTION, RESOURCE_GROUP and APPS/.test(l) ? `${l} The script lists the apps that your deploy files name.` : l))
207
+ } else if (id === 'azure-app-service') {
208
+ const web = scriptNames(names.webapp)
209
+ code = code.map((x, i) => (i === 0 ? { ...x, code: appServiceKeyScript(web) } : x))
210
+ // The start line goes on App Service only for a stack that has one (Python, .NET).
211
+ lines = lines.filter((l) => options.startLine !== false || !/^For Python and \.NET, put the start line/.test(l))
212
+ if (names.webapp.slots.length) lines = lines.filter((l) => !/^If you swap a deployment slot/.test(l))
213
+ } else if (id === 'github-actions') return github(lines, code, options)
214
+ else if (id === 'kubernetes' && settings?.length) {
215
+ // The settings of this run, not the 5 of the page: an app with OpenTelemetry keeps its metrics and logs.
216
+ code = code.map((x) => (/settings in your deployment/.test(x.caption) ? { ...x, code: k8sEnv(x.code, settings, 10) }
217
+ : /^Helm/.test(x.caption) ? { ...x, code: helmEnv(x.code, settings) } : { ...x, code: container(x.code) }))
218
+ } else if (id === 'vm-docker') {
219
+ // otel.env only when a service keeps its settings there. systemd only when a service does not start from a Dockerfile.
220
+ const settingsFile = (t) => t.includes(SETTINGS_FILE.path)
221
+ const systemd = (t) => /systemd|EnvironmentFile|daemon-reload/.test(t)
222
+ lines = lines.filter((l) => (options.otelEnv || !settingsFile(l)) && (!options.docker || !systemd(l)))
223
+ code = code.filter((x) => !options.docker || !systemd(`${x.caption}\n${x.code}`))
224
+ .map((x) => ({ ...x, code: container(options.otelEnv ? x.code : x.code.split('\n').filter((l, i, all) => !settingsFile(l)
225
+ && !(settingsFile(all[i + 1] ?? '') && /^#/.test(l))).join('\n')) }))
226
+ } else code = code.map((x) => ({ ...x, code: container(x.code) }))
227
+ if (repo) lines = lines.map((l) => l.split(EXAMPLE_NAMES.repo).join(repo))
228
+ return { lines, snippets: code }
229
+ }
230
+
231
+ /** The GitHub Actions steps with the environments of the deploy jobs, the repo and the app names of this repo. */
232
+ function github(lines, code, { names, repo }) {
233
+ const jobs = names.jobs ?? []
234
+ const where = (j) => `${j.file} line ${j.line}`
235
+ // A job with environment: ${{ inputs.X }} deploys to the values that the deploy files give X.
236
+ const dynamic = (j) => Boolean(j.environment?.includes('$'))
237
+ const envs = [...new Set(jobs.flatMap((j) => (!j.environment ? [] : dynamic(j) ? (j.environments ?? []).map((e) => e.name) : [j.environment])))]
238
+ const none = jobs.filter((j) => !j.environment)
239
+ const slug = repo ?? EXAMPLE_NAMES.repo
240
+ const aca = names.aca.apps[0]?.name, group = names.aca.groups[0]?.name
241
+ const found = jobs.map((j) => (dynamic(j) ? dynamicJob(j, where(j))
242
+ : (j.environment ? GITHUB_JOBS.found : GITHUB_JOBS.none).replace('{where}', where(j)).replace('{job}', j.job).replace('{environment}', j.environment)))
243
+ const noEnvNote = lines.find((l) => /^A job can read an environment secret only/.test(l))
244
+ // Every deploy job has no environment: line, so the key is a repository secret and no environment name applies.
245
+ // A job whose environment is a ${{ }} value still reads an environment secret.
246
+ const repoSecretOnly = jobs.length > 0 && none.length === jobs.length
247
+ lines = lines.flatMap((l) => {
248
+ if (l === noEnvNote) return !jobs.length || none.length > 0 ? [l.split(EXAMPLE_NAMES.repo).join(slug)] : []
249
+ // "Replace <repo> with your repo, and <environment> with the environment: name of your deploy job. Never rename ...":
250
+ // only the names that are still examples.
251
+ const replace = /^Replace (\S+ with your repo), and (\S+ with the environment: name of your deploy job)\. (.+)$/.exec(l)
252
+ if (replace) {
253
+ const ask = [repo ? null : replace[1], envs.length || repoSecretOnly ? null : replace[2]].filter(Boolean)
254
+ const head = ask.length ? `Replace ${ask.join(', and ')}.` : null
255
+ return repoSecretOnly ? [head].filter(Boolean) : [[head, replace[3]].filter(Boolean).join(' ')]
256
+ }
257
+ // "Each environment has its own key. gh secret set UKNOWS_KEY --env <environment> sets the key for <environment> only. For staging, ...":
258
+ // the environments of the deploy jobs. No line when no job has one.
259
+ const each = /^(Each environment has its own key\. gh secret set UKNOWS_KEY --env )(\S+)( sets the key for )\2( only\.)( For (\S+), run it again with --env \6 and the \6 key\.)$/.exec(l)
260
+ if (each) {
261
+ if (repoSecretOnly) return []
262
+ if (!envs.length) return [l]
263
+ const [first, second] = envs
264
+ return [`${each[1]}${first}${each[3]}${first}${each[4]}${second ? each[5].split(each[6]).join(second) : ''}`]
265
+ }
266
+ // "In the example, replace <resource group> and <app> with your resource group and app.": only the names still examples.
267
+ const example = /^In the example, replace (\S+) and (\S+) with your (resource group) and (app)\.$/.exec(l)
268
+ if (example) {
269
+ if (aca && group) return []
270
+ if (aca || group) return [`In the example, replace ${aca ? example[1] : example[2]} with your ${aca ? example[3] : example[4]}.`]
271
+ return [l]
272
+ }
273
+ // A repository secret holds 1 key: it is run once, not once for each environment.
274
+ if (repoSecretOnly && /^Run the command once for each GitHub environment that your deploy jobs use\. Paste the key of that environment when it asks\./.test(l)) {
275
+ return [l.replace(/^.*?when it asks\./, 'Run the command once. Paste the key of the UKNOWS environment that this repo deploys to when it asks.')]
276
+ }
277
+ if (repo && /--repos /.test(l)) return [l.replace(/--repos \S+ /, `--repos ${repo.split('/')[1]} `)]
278
+ // The key step names 1 app. Deploy files that name more get 1 step for each app (review 2 of the installer, F2).
279
+ const apps = [...new Set(names.aca.apps.map((a) => a.name))]
280
+ if (/^Add the step below to each deploy job/.test(l) && apps.length > 1) {
281
+ return [l, `Your deploy files name ${apps.length} container apps, ${apps.slice(0, -1).join(', ')} and ${apps.at(-1)}. The step below sets the key on ${aca}. Add 1 step for each app, with its own APP and RESOURCE_GROUP.`]
282
+ }
283
+ return [l.split(EXAMPLE_NAMES.repo).join(slug)]
284
+ })
285
+ const secret = [envs.length ? githubSecretCommands(envs, slug) : !none.length ? githubSecretCommands(undefined, slug) : null,
286
+ none.length ? `# For a deploy job with no environment: line, a repository secret:\ngh secret set UKNOWS_KEY --repo ${slug}` : null].filter(Boolean).join('\n')
287
+ // The job of the key step: the first job with an environment: line, as it is written (staging, or ${{ inputs.environment }}).
288
+ const first = jobs.find((j) => j.environment)
289
+ code = code.map((x, i) => (i === 0 ? { ...x, code: secret }
290
+ : i === 1 ? { ...x, code: githubKeyStep({ environment: first ? first.environment : none.length ? null : undefined, resourceGroup: group, app: aca }) }
291
+ : { ...x, code: repo ? x.code.split(EXAMPLE_NAMES.org).join(repo.split('/')[0]) : x.code }))
292
+ return { lines: [...found, ...lines], snippets: code }
293
+ }
294
+
295
+ /** What a deploy job with environment: ${{ inputs.X }} deploys to: the workflows that call it and the choices of its own input. */
296
+ function dynamicJob(job, at) {
297
+ const head = `${at}: the deploy job ${job.job} has environment: ${job.environment}.`
298
+ const from = job.environments ?? []
299
+ if (!from.length) return `${head} This tool cannot tell which environments that is. Set the key for each environment that this job deploys to.`
300
+ const said = [...new Set(from.map((e) => e.where))].map((w) => {
301
+ const here = from.filter((e) => e.where === w)
302
+ const list = here.map((e) => e.name)
303
+ const or = list.length > 1 ? `${list.slice(0, -1).join(', ')} or ${list.at(-1)}` : list[0]
304
+ return here[0].how === 'call' ? `${w} sets it to ${or}.` : `${w} lets you pick ${or}.`
305
+ })
306
+ return `${head} ${said.join(' ')} Set the key for each of these environments.`
307
+ }
308
+
309
+ /**
310
+ * The App Service slot block: both slots get the key as a slot setting, with 1 script each. A slot that only a workflow
311
+ * input or variable holds keeps an example SLOT that the script says to replace.
312
+ */
313
+ function slotBlocks(names) {
314
+ const slot = names.webapp.slots[0]
315
+ if (!slot) return []
316
+ const app = names.webapp.apps[0]
317
+ const base = scriptNames(names.webapp, true)
318
+ const unknown = slot.name === null
319
+ const values = { app: app?.name ?? 'your web app', slot: unknown ? slot.raw : slot.name, where: slot.where }
320
+ const fill = (t) => t.replace(/\{(\w+)\}/g, (m, k) => (k in values ? values[k] : m))
321
+ return [{ title: fill(APP_SERVICE_SLOTS.title), lines: APP_SERVICE_SLOTS.lines.map(fill), snippets: [
322
+ { caption: 'The production slot', language: 'bash', code: appServiceSlotScript({ ...base, slot: undefined }) },
323
+ { caption: unknown ? 'The deployment slot' : `The ${slot.name} slot`, language: 'bash',
324
+ code: appServiceSlotScript({ ...base, slot: values.slot, ...(unknown ? { slotUnknown: true } : {}), from: { ...base.from, slot: slot.where } }) },
325
+ ] }]
326
+ }
327
+
328
+ /** The ScriptNames for the builders of install.ts: the names found, or none, so the examples stay with their comment. */
329
+ function scriptNames(found, one = false) {
330
+ const apps = found.apps.map((a) => a.name)
331
+ const group = found.groups.length === 1 || new Set(found.groups.map((g) => g.name)).size === 1 ? found.groups[0] : null
332
+ const out = { from: {} }
333
+ if (apps.length) { out.apps = apps.join(' '); out.app = apps[0]; out.from.apps = joinAnd([...new Set(found.apps.map((a) => a.where))]); out.from.app = found.apps[0].where }
334
+ if (group) { out.resourceGroup = group.name; out.from.resourceGroup = group.where }
335
+ if (one) delete out.apps
336
+ return out
337
+ }
338
+
339
+ const emptyNames = () => ({ aca: { apps: [], groups: [] }, webapp: { apps: [], groups: [], slots: [] }, functions: { apps: [], groups: [] }, jobs: [], code: [] })
340
+
341
+ /** env list items for a Kubernetes container: the YAML of the docs snippet, with the settings of this run. */
342
+ function k8sEnv(text, settings, indent) {
343
+ const head = text.split('\n').slice(0, text.split('\n').findIndex((l) => /^\s*env:\s*$/.test(l)) + 1)
344
+ const pad = ' '.repeat(indent + 2)
345
+ return [...head, ...settings.flatMap(([name, value]) => [`${' '.repeat(indent)} - name: ${name}`, `${pad} value: ${value}`])].join('\n')
346
+ }
347
+
348
+ function helmEnv(text, settings) {
349
+ const rows = text.split('\n')
350
+ const at = rows.findIndex((l) => /^env:\s*$/.test(l)), end = rows.findIndex((l, i) => i > at && /^\S/.test(l))
351
+ return [...rows.slice(0, at + 1), ...settings.flatMap(([name, value]) => [` - name: ${name}`, ` value: ${value}`]), ...rows.slice(end)].join('\n')
133
352
  }
134
353
 
354
+ /**
355
+ * The names that the deploy files of the repo hold, for the key steps: the container apps and web apps that an az
356
+ * command, a deploy action or a workflow variable names, their resource groups, the App Service slots, the deploy jobs
357
+ * of each GitHub workflow with their environment: line, and the App Service code deploys (deploys of a package, not
358
+ * an image) with their startup command. A value that only a workflow variable or secret holds (${{ vars.X }}) is unknown.
359
+ * A slot is null for the production slot; a slot that only an input or variable holds is kept as slotUnknown (code)
360
+ * or { name: null, raw } (webapp.slots). A job with environment: ${{ inputs.X }} gets environments: the values that the
361
+ * workflows calling it pass as X, and the options of its own workflow_dispatch choice input X.
362
+ * @param {Pick<import('./workspace.mjs').RepoView, 'read'|'stat'>} view
363
+ * @param {import('./types.mjs').Host[]} hosts
364
+ */
365
+ export function deployNames(view, hosts) {
366
+ const out = emptyNames()
367
+ const add = (list, name, where) => { if (name && !list.some((x) => x.name === name)) list.push({ name, where }) }
368
+ const files = [...new Set((hosts ?? []).flatMap((h) => (h.evidence ?? []).map((e) => e.file)))].filter((f) => /\.(?:ya?ml|sh|ps1)$/.test(f))
369
+ const rowsOf = (file) => {
370
+ let body = null
371
+ try { body = (view.stat?.(file)?.size ?? 0) <= MAX_BYTES ? view.read(file) : null } catch { body = null }
372
+ return body ? body.split(/\r?\n/).map((l) => (/^\s*#/.test(l) ? '' : l)) : null
373
+ }
374
+ // The calls of reusable workflows, from every workflow of the repo: they give the environment: ${{ inputs.X }} of a job.
375
+ const workflows = new Map()
376
+ let listed = []
377
+ try { listed = view.files?.() ?? [] } catch { listed = [] }
378
+ for (const file of [...files, ...listed].filter((f) => WORKFLOW.test(f))) if (!workflows.has(file)) workflows.set(file, rowsOf(file))
379
+ const calls = [...workflows].flatMap(([file, rows]) => (rows ? workflowCalls(file, rows) : []))
380
+ for (const file of files) {
381
+ const rows = workflows.get(file) ?? rowsOf(file)
382
+ if (!rows) continue
383
+ const vars = new Map()
384
+ for (const l of rows) {
385
+ const m = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*$/.exec(l) ?? /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=(\S+)\s*$/.exec(l)
386
+ if (m && !vars.has(m[1])) vars.set(m[1], m[2].replace(/\s+#.*$/, ''))
387
+ }
388
+ const value = (raw) => {
389
+ let v = String(raw ?? '').trim().replace(/^["']|["']$/g, '')
390
+ for (let hop = 0; hop < 3; hop++) {
391
+ const ref = /^\$\{\{\s*env\.([A-Za-z_]\w*)\s*\}\}$/.exec(v) ?? /^\$\{?([A-Za-z_]\w*)\}?$/.exec(v)
392
+ if (!ref) break
393
+ v = String(vars.get(ref[1]) ?? '').replace(/^["']|["']$/g, '')
394
+ }
395
+ return /^[A-Za-z0-9][\w.-]{0,79}$/.test(v) ? v : null
396
+ }
397
+ // The slot of an App Service command or deploy step. The production slot is no slot: none, an empty one, or one named
398
+ // production (the portal template writes slot-name: 'Production'). unknown: the text of a slot that only a workflow
399
+ // input or variable holds.
400
+ const slotOf = (raw) => {
401
+ const text = String(raw ?? '').trim().replace(/^["']|["']$/g, '').trim()
402
+ if (!text) return { slot: null, unknown: null }
403
+ const v = value(text)
404
+ if (v) return { slot: v.toLowerCase() === 'production' ? null : v, unknown: null }
405
+ return { slot: null, unknown: text }
406
+ }
407
+ const addSlot = (raw, where) => {
408
+ const { slot, unknown } = slotOf(raw)
409
+ if (slot) add(out.webapp.slots, slot, where)
410
+ else if (unknown && !out.webapp.slots.some((s) => s.raw === unknown)) out.webapp.slots.push({ name: null, raw: unknown, where })
411
+ }
412
+ const code = (entry, raw) => {
413
+ const { slot, unknown } = slotOf(raw)
414
+ out.code.push({ ...entry, slot, ...(unknown ? { slotUnknown: unknown } : {}) })
415
+ }
416
+ // Logical lines: a command over several lines (\ at the end) counts as 1, at the line where it starts.
417
+ const logical = []
418
+ for (let i = 0; i < rows.length; i++) {
419
+ let text = rows[i], j = i
420
+ while (/\\\s*$/.test(text) && j + 1 < rows.length) text = text.replace(/\\\s*$/, ' ') + rows[++j]
421
+ logical.push({ text, line: i + 1 })
422
+ i = j
423
+ }
424
+ const opt = (text, re) => re.exec(text)?.[1] ?? null
425
+ for (const { text, line } of logical) {
426
+ const where = `${file} line ${line}`
427
+ const az = /\baz\s+(containerapp|webapp|functionapp)\b(.*)$/.exec(text)
428
+ if (az) {
429
+ const target = az[1] === 'containerapp' ? out.aca : az[1] === 'functionapp' ? out.functions : out.webapp
430
+ add(target.apps, value(opt(az[2], /(?:^|\s)(?:--name|-n)[\s=]+("[^"]*"|'[^']*'|\S+)/)), where)
431
+ add(target.groups, value(opt(az[2], /(?:^|\s)(?:--resource-group|-g)[\s=]+("[^"]*"|'[^']*'|\S+)/)), where)
432
+ // The --slot of the command as written. slotOf() makes it null for the production slot.
433
+ const slot = az[1] === 'webapp' ? opt(az[2], /(?:^|\s)--slot[\s=]+("[^"]*"|'[^']*'|\S+)/) : null
434
+ if (az[1] === 'webapp') addSlot(slot, where)
435
+ const startup = /(?:^|\s)--startup-file[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(az[2])
436
+ if (az[1] === 'webapp' && startup) code({ kind: 'startup', startup: startup[1].replace(/^["']|["']$/g, ''), where }, slot)
437
+ if (az[1] === 'webapp' && (/\bdeploy\b.*--src-(?:path|url)\b|\bdeployment\s+source\s+config-zip\b|\bup\b/.test(az[2]))) code({ kind: 'deploy', where }, slot)
438
+ }
439
+ const key = /^\s*(?:-\s+)?([\w-]+)\s*:\s*(.+?)\s*$/.exec(text)
440
+ if (key) {
441
+ if (key[1] === 'containerAppName') add(out.aca.apps, value(key[2]), where)
442
+ if (key[1] === 'resourceGroup') add(out.aca.groups, value(key[2]), where)
443
+ if (key[1] === 'app-name') add(out.webapp.apps, value(key[2]), where)
444
+ if (key[1] === 'slot-name') addSlot(key[2], where)
445
+ }
446
+ }
447
+ // azure/webapps-deploy with package: and no images: deploys code, not an image.
448
+ rows.forEach((l, i) => {
449
+ const uses = /^(\s*)(?:-\s+)?uses\s*:\s*azure\/webapps-deploy@/.exec(l)
450
+ if (!uses) return
451
+ const step = stepRows(rows, i)
452
+ const slot = step.map((r) => /^\s*slot-name\s*:\s*(.+?)\s*$/.exec(r)?.[1]).find(Boolean) ?? null
453
+ if (step.some((r) => /^\s*package\s*:/.test(r)) && !step.some((r) => /^\s*images\s*:/.test(r))) code({ kind: 'deploy', where: `${file} line ${i + 1}` }, slot)
454
+ })
455
+ if (WORKFLOW.test(file)) out.jobs.push(...deployJobs(file, rows))
456
+ }
457
+ // Known slots first, then the slots that only a workflow input or variable holds.
458
+ out.webapp.slots.sort((a, b) => (a.name === null) - (b.name === null))
459
+ // A job that only builds and pushes the image is not where the key goes, when another job deploys.
460
+ if (out.jobs.some((j) => !j.push)) out.jobs = out.jobs.filter((j) => !j.push)
461
+ // environment: ${{ inputs.X }}: the values that the workflows that call this one pass as X, and the options of a
462
+ // workflow_dispatch choice input X of the same workflow.
463
+ out.jobs = out.jobs.map(({ push, input, ...job }) => {
464
+ if (!input) return job
465
+ const own = job.file.replace(/^(?:\.\.\/)*/, '')
466
+ const environments = []
467
+ const put = (name, where, how) => { if (plainName(name) && !environments.some((e) => e.name === name && e.where === where)) environments.push({ name, where, how }) }
468
+ for (const call of calls.filter((c) => c.path === own)) {
469
+ const given = call.values.get(input)
470
+ if (given) put(given.value, `${call.file} line ${given.line}`, 'call')
471
+ }
472
+ for (const option of choices(workflows.get(job.file) ?? [], input)) put(option.name, `${job.file} line ${option.line}`, 'pick')
473
+ return { ...job, environments }
474
+ })
475
+ return out
476
+ }
477
+
478
+ /** The rows of the workflow step that row i is in: from its - line to the next - at the same indent or less. */
479
+ function stepRows(rows, i) {
480
+ let start = i
481
+ while (start > 0 && !/^\s*-\s/.test(rows[start])) start--
482
+ const indent = /^\s*/.exec(rows[start])[0].length
483
+ let end = start + 1
484
+ while (end < rows.length && (!rows[end].trim() || /^\s*/.exec(rows[end])[0].length > indent)) end++
485
+ return rows.slice(start, end)
486
+ }
487
+
488
+ const indentOf = (line) => /^\s*/.exec(line)[0].length
489
+ /** A ${{ ... }} expression. It names other jobs and steps, so it is never a deploy cue. */
490
+ const EXPRESSION = /\$\{\{[^}]*\}\}/g
491
+ /** A job id, a step name or a step id that says deploy: deploy-prod, Deploy the new image. Not deployment, nor deploy tests or a deploy summary. */
492
+ const NAME_CUE = /(?:^|[^a-z])(?:re)?deploy(?:s|ing)?(?![a-z])(?![\s._-]*(?:tests?|summary|report|status|notes?)\b)/i
493
+ /** az commands that only read an app: they deploy nothing. */
494
+ const READ_ONLY_AZ = /\baz\s+(?:containerapp|webapp)\s+(?:(?:revision|replica|secret|env|ingress|logs?|config(?:\s+appsettings)?|deployment\s+slot)\s+)?(?:show|list|tail|browse)\b/g
495
+ /** The input of an environment: ${{ inputs.X }} line. */
496
+ const INPUT_REF = /^\$\{\{\s*(?:github\.event\.)?inputs\.([A-Za-z_][\w-]*)\s*\}\}$/
497
+ const plainName = (text) => /^[A-Za-z0-9][\w.-]{0,79}$/.test(text)
498
+ const unquote = (text) => String(text ?? '').replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '')
499
+
500
+ /** The jobs of a workflow: id, 1-based line, the rows of the job, and the indent of its keys. */
501
+ function jobBlocks(rows) {
502
+ const at = rows.findIndex((l) => /^jobs\s*:\s*$/.test(l))
503
+ if (at < 0) return []
504
+ let end = at + 1
505
+ while (end < rows.length && !/^\S/.test(rows[end] || ' ')) end++
506
+ const heads = []
507
+ for (let i = at + 1; i < end; i++) {
508
+ const m = /^( {1,8})([\w-]+)\s*:\s*$/.exec(rows[i])
509
+ if (m && (!heads.length || m[1].length === heads[0].indent)) heads.push({ job: m[2], line: i + 1, indent: m[1].length })
510
+ }
511
+ return heads.map((h, k) => {
512
+ const body = rows.slice(h.line, k + 1 < heads.length ? heads[k + 1].line - 1 : end)
513
+ const first = body.find((l) => l.trim())
514
+ return { ...h, body, pad: first ? indentOf(first) : h.indent + 2 }
515
+ })
516
+ }
517
+
518
+ /**
519
+ * 1 key of a job, such as steps: or environment:. head: its line, i: its index in the job rows, rows: the lines under it
520
+ * (a list may sit at the same indent as its key). null when the job has no such key.
521
+ */
522
+ function jobKey(block, name) {
523
+ const i = block.body.findIndex((l) => indentOf(l) === block.pad && new RegExp(`^\\s*${name}\\s*:`).test(l))
524
+ if (i < 0) return null
525
+ let j = i + 1
526
+ while (j < block.body.length && (!block.body[j].trim() || indentOf(block.body[j]) > block.pad || (indentOf(block.body[j]) === block.pad && /^\s*-/.test(block.body[j])))) j++
527
+ return { i, head: block.body[i], rows: block.body.slice(i + 1, j) }
528
+ }
529
+
530
+ /**
531
+ * The jobs of 1 workflow that deploy, with their environment: value (null when none). A job deploys when its id says so
532
+ * (deploy-prod) or its steps do: a deploy command or action, or a step named Deploy. Echo lines, ${{ }} expressions and
533
+ * az commands that only read do not count, so a summary job that prints "Deployment Summary" deploys nothing.
534
+ * push: the only cue is docker push, which builds the image (deployNames drops such a job when another job deploys).
535
+ * input: the X of environment: ${{ inputs.X }}.
536
+ */
537
+ function deployJobs(file, rows) {
538
+ const jobs = []
539
+ for (const block of jobBlocks(rows)) {
540
+ // A call of a reusable workflow (job-level uses:) has no steps. The jobs of the workflow it calls deploy.
541
+ if (jobKey(block, 'uses')) continue
542
+ const steps = (jobKey(block, 'steps')?.rows ?? []).map((l) => l.replace(EXPRESSION, ''))
543
+ .filter((l) => !/^\s*(?:-\s+)?(?:run\s*:\s*)?(?:echo|printf)\b/.test(l))
544
+ const named = (l) => /^\s*(?:-\s+)?(?:name|id)\s*:\s*(.*)$/.exec(l)?.[1]
545
+ const cue = (l) => (named(l) !== undefined ? NAME_CUE.test(named(l)) : DEPLOY_CUE.test(l.replace(READ_ONLY_AZ, '').replace(/\bdocker\s+push\b/gi, '')))
546
+ const strong = NAME_CUE.test(block.job) || steps.some(cue)
547
+ if (!strong && !steps.some((l) => named(l) === undefined && /\bdocker\s+push\b/i.test(l))) continue
548
+ const env = jobKey(block, 'environment')
549
+ let environment = env ? unquote(env.head.replace(/^\s*environment\s*:\s*/, '')) : null
550
+ if (environment === '') environment = unquote(env.rows.map((r) => /^\s*name\s*:\s*(.+?)\s*$/.exec(r)?.[1]).find(Boolean) ?? '') || null
551
+ const input = environment ? INPUT_REF.exec(environment)?.[1] : undefined
552
+ jobs.push({ file, job: block.job, line: block.line, environment, ...(strong ? {} : { push: true }), ...(input ? { input } : {}) })
553
+ }
554
+ return jobs
555
+ }
556
+
557
+ /** The jobs of 1 workflow that call a reusable workflow of this repo (uses: ./.github/workflows/x.yml), with their with: values. */
558
+ function workflowCalls(file, rows) {
559
+ return jobBlocks(rows).flatMap((block) => {
560
+ const uses = jobKey(block, 'uses')
561
+ const path = uses && /^\s*uses\s*:\s*["']?\.\/(\S+?\.ya?ml)["']?\s*$/.exec(uses.head)?.[1]
562
+ if (!path) return []
563
+ const given = jobKey(block, 'with')
564
+ const values = new Map()
565
+ given?.rows.forEach((r, k) => {
566
+ const m = /^\s*([\w-]+)\s*:\s*(.+?)\s*$/.exec(r)
567
+ if (m && !values.has(m[1])) values.set(m[1], { value: unquote(m[2]), line: block.line + given.i + k + 2 })
568
+ })
569
+ return [{ file, path, values }]
570
+ })
571
+ }
572
+
573
+ /** The options of a workflow_dispatch choice input of 1 workflow, with the line of each: [{name, line}]. */
574
+ function choices(rows, input) {
575
+ const at = rows.findIndex((l) => /^\s*workflow_dispatch\s*:\s*$/.test(l))
576
+ if (at < 0) return []
577
+ const base = indentOf(rows[at])
578
+ let end = at + 1
579
+ while (end < rows.length && (!rows[end].trim() || indentOf(rows[end]) > base)) end++
580
+ const inputs = rows.slice(at + 1, end).findIndex((l) => /^\s*inputs\s*:\s*$/.test(l))
581
+ if (inputs < 0) return []
582
+ const from = at + 1 + inputs
583
+ const head = rows.slice(from + 1, end).findIndex((l) => new RegExp(`^\\s*${input}\\s*:\\s*$`).test(l) && indentOf(l) > indentOf(rows[from]))
584
+ if (head < 0) return []
585
+ const i = from + 1 + head, pad = indentOf(rows[i])
586
+ let stop = i + 1
587
+ while (stop < end && (!rows[stop].trim() || indentOf(rows[stop]) > pad)) stop++
588
+ const block = rows.slice(i + 1, stop)
589
+ if (!block.some((l) => /^\s*type\s*:\s*["']?choice["']?\s*$/.test(l))) return []
590
+ const k = block.findIndex((l) => /^\s*options\s*:/.test(l))
591
+ if (k < 0) return []
592
+ const line = i + 1 + k + 1
593
+ const inline = /^\s*options\s*:\s*\[(.*)\]\s*$/.exec(block[k])
594
+ const names = inline ? inline[1].split(',').map(unquote)
595
+ : block.slice(k + 1).filter((l) => l.trim()).map((l) => /^\s*-\s*(.+?)\s*$/.exec(l)?.[1]).filter((v, n, all) => v !== undefined && all.slice(0, n).every((x) => x !== undefined)).map(unquote)
596
+ return names.filter(plainName).map((name) => ({ name, line }))
597
+ }
598
+
599
+ /**
600
+ * The checks of 1 host that the repo needs: each check of CHECKS whose deploy files the evidence names and whose
601
+ * line is in them, and each check that needs no file. 1 line per check, with the files and lines it found. A file
602
+ * with template-ignored (Terraform ignore_changes on the env or secrets) does not get the template check.
603
+ */
604
+ function hostChecks(host, evidence, view) {
605
+ const texts = new Map((Array.isArray(host.checks) ? host.checks : []).filter((c) => typeof c?.text === 'string').map((c) => [c.id, c]))
606
+ const found = new Map(), languages = new Map()
607
+ for (const [id, hostId, what, re, langs = []] of CHECKS) {
608
+ if (hostId !== host.id || !texts.has(id)) continue
609
+ if (!what) { if (!found.has(id)) found.set(id, []); continue }
610
+ for (const file of [...new Set(evidence.filter((e) => what.includes(e.what)).map((e) => e.file))]) {
611
+ let body = null
612
+ try { body = view?.read(file) ?? null } catch { body = null }
613
+ const code = body?.replace(/^[ \t]*(?:#|\/\/).*$/gm, '')
614
+ if (!code || !re.test(code)) continue
615
+ // The line where the match ends, so a command over several lines points at its option.
616
+ const m = new RegExp(re.source, re.flags.replace('g', '')).exec(code)
617
+ const end = m.index + m[0].trimEnd().length
618
+ found.set(id, [...(found.get(id) ?? []), { file, at: `${file} line ${code.slice(0, end).split('\n').length}`, langs }])
619
+ }
620
+ }
621
+ const ignored = new Set((found.get('template-ignored') ?? []).map((x) => x.file))
622
+ if (ignored.size && found.has('template')) {
623
+ const left = found.get('template').filter((x) => !ignored.has(x.file))
624
+ if (left.length) found.set('template', left); else found.delete('template')
625
+ }
626
+ for (const [id, hits] of found) languages.set(id, hits.flatMap((x) => x.langs))
627
+ const lines = [], out = []
628
+ for (const [id, check] of texts) {
629
+ if (!found.has(id)) continue
630
+ const where = found.get(id).map((x) => x.at)
631
+ lines.push(where.length ? `${check.text} ${DEPLOY_CHECKS.found.replace('{where}', joinAnd([...new Set(where)]))}` : check.text)
632
+ const langs = languages.get(id) ?? []
633
+ out.push(...snippets(check.snippets).filter((x) => !langs.length || langs.includes(x.language)))
634
+ }
635
+ return { lines, snippets: out }
636
+ }
637
+
638
+ const joinAnd = (items) => (items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items.at(-1)}` : items.join(''))
639
+
135
640
  /** Deploy files before deploy steps (RULES order), then by path and line. */
136
641
  const rank = (e) => RULES.findIndex((rule) => rule[1] === e.what)
137
642
  const byStrength = (a, b) => rank(a) - rank(b) || (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)