@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
@@ -0,0 +1,1299 @@
1
+ /*
2
+ * The last steps of a setup run, done for the user in a terminal. Each one has its own question and runs only on a yes:
3
+ * 1. store the key on the platform (az for Container Apps and App Service, gh for GitHub secrets, kubectl for a
4
+ * Kubernetes secret), for the environment that the key check named;
5
+ * 2. add the key to a deploy file that would drop it on the next deploy;
6
+ * 3. create a branch, commit the files that setup changed, push it and open a pull request.
7
+ * Never in a pipeline, never with --yes, --json or --dry-run, never without a terminal (automates()).
8
+ *
9
+ * The key never goes in the arguments of a program, and never in a file: every program reads it from stdin. az reads
10
+ * the value of an argument @- from stdin, gh secret set reads stdin, and kubectl apply reads the Secret from stdin.
11
+ * Every az command names the subscription where the app was found: setup never uses the default subscription.
12
+ * Programs run through cli(cmd, args, {cwd, env, input}) (deps.cli), so tests use fakes.
13
+ */
14
+ import { spawn } from 'node:child_process'
15
+ import { accessSync, constants, readFileSync } from 'node:fs'
16
+ import { delimiter, join } from 'node:path'
17
+ import { ciVariable } from './ci.mjs'
18
+ import { RESOURCE_ATTRIBUTES, applyChanges, setEnvChange, updateCommandAt } from './cloudid.mjs'
19
+ import { ACA_SECRET_REF, CLI, FUNCTIONS, HOSTS, SECRET } from './contract.mjs'
20
+ import { HOOK_FILES } from './hooks/pins.mjs'
21
+ import { unified } from './diff.mjs'
22
+ import { Doc } from './doc.mjs'
23
+ import { appUrl } from './endpoint.mjs'
24
+ import { githubSlug } from './git.mjs'
25
+ import { hasKey } from './settings.mjs'
26
+ import { list, plural, unique } from './ui.mjs'
27
+
28
+ /** The secret of a container app that holds the key, from the env entry of the docs (secretref:uknows-otlp-headers). */
29
+ export const ACA_SECRET = ACA_SECRET_REF.slice(ACA_SECRET_REF.indexOf('secretref:') + 'secretref:'.length)
30
+ /** The Kubernetes secret and the GitHub secret of the docs steps. */
31
+ export const K8S_SECRET = 'uknows'
32
+ export const GITHUB_SECRET = 'UKNOWS_KEY'
33
+ /** The pull request: its title, the commit message, and the branch name (uknows-setup-2 and so on when taken). */
34
+ export const PULL_REQUEST = { title: 'Add UKNOWS monitoring', branch: 'uknows-setup' }
35
+ /** The page of the app that shows an app connect. The product serves it at exactly this path. */
36
+ export const CONNECT_PATH = '/live/connect'
37
+ const OUTPUT_LIMIT = 1 << 20
38
+ /** For every program: no questions, no colors, and az never installs an extension by itself. */
39
+ const QUIET = { AZURE_EXTENSION_USE_DYNAMIC_INSTALL: 'no', AZURE_CORE_ONLY_SHOW_ERRORS: 'true', AZURE_CORE_NO_COLOR: 'true',
40
+ GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0', NO_COLOR: '1' }
41
+ const AZURE = {
42
+ aca: { type: 'Microsoft.App/containerApps', what: 'container app', effect: 'It restarts the app.' },
43
+ webapp: { type: 'Microsoft.Web/sites', what: 'web app', effect: 'It restarts the app.' },
44
+ }
45
+ const WORDS = [['production', 'prod', 'prd', 'live'], ['staging', 'stage', 'stg'], ['development', 'dev'], ['test', 'testing', 'qa', 'uat']]
46
+
47
+ /** True when this run may do the last steps: a terminal that asks, and not --yes, --json, --dry-run or a pipeline. */
48
+ export function automates(opts, ui, env = {}) {
49
+ return Boolean(ui.interactive && !opts.yes && !opts.json && !opts.dryRun && !ciVariable(env))
50
+ }
51
+
52
+ /**
53
+ * Run a program and keep its output, which never goes to the screen. input: text for stdin (else stdin is closed).
54
+ * Resolves {code, stdout, stderr, error}: error is 'missing' when the program is not installed.
55
+ */
56
+ export function runProgram(cmd, args, { cwd, env = process.env, input = null, timeoutMs = 300000 } = {}) {
57
+ return new Promise((resolve) => {
58
+ let stdout = '', stderr = '', settled = false, timer = null, child
59
+ const finish = (result) => { if (!settled) { settled = true; clearTimeout(timer); resolve({ stdout, stderr, ...result }) } }
60
+ try {
61
+ child = spawn(cmd, args, { cwd, env: { ...env, ...QUIET }, stdio: [input === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true })
62
+ } catch (error) {
63
+ finish({ code: null, error: error.code === 'ENOENT' ? 'missing' : error.message })
64
+ return
65
+ }
66
+ child.stdout.on('data', (chunk) => { if (stdout.length < OUTPUT_LIMIT) stdout += chunk })
67
+ child.stderr.on('data', (chunk) => { if (stderr.length < OUTPUT_LIMIT) stderr += chunk })
68
+ child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? 'missing' : error.message }))
69
+ child.on('close', (code) => finish({ code, error: null }))
70
+ if (input !== null) { child.stdin.on('error', () => {}); child.stdin.end(input) }
71
+ timer = setTimeout(() => { child.kill(); finish({ code: null, error: 'it took too long' }) }, timeoutMs)
72
+ })
73
+ }
74
+
75
+ /**
76
+ * The page where the user watches the app connect, for 1 service and the environment of the key check. base: the app
77
+ * address (appUrl). null when the run does not know the app (a test copy of UKNOWS without UKNOWS_APP_URL).
78
+ */
79
+ export function connectUrl(service, environment = null, base = appUrl()) {
80
+ if (!base) return null
81
+ const env = String(environment ?? '').trim().toLowerCase().replace(/\s+/g, '-')
82
+ return `${base}${CONNECT_PATH}?app=${encodeURIComponent(service ?? '')}${env ? `&env=${encodeURIComponent(env)}` : ''}`
83
+ }
84
+
85
+ /**
86
+ * The end of the last line: the link to the page where the apps connect, or what to do without one. environment: the
87
+ * one of the key check. Without it (a skipped check), the link names the app only, and the page watches every
88
+ * environment of the workspace, so the line says so (F-012-145).
89
+ */
90
+ export function watchLine(urls, environment = null) {
91
+ const known = urls.filter(Boolean)
92
+ if (!known.length) return 'Then watch your app connect in your test copy of UKNOWS. To get the link, set UKNOWS_APP_URL to the address of its app.'
93
+ const apps = known.length > 1 ? 'apps' : 'app'
94
+ return String(environment ?? '').trim() ? `Then watch your ${apps} connect: ${list(known)}` : `Then watch your ${apps} connect. The page watches every environment: ${list(known)}`
95
+ }
96
+
97
+ /**
98
+ * True when this computer can show a browser to the person at the terminal: macOS and Windows, except in an ssh
99
+ * session (the browser would open on the other computer), and Linux with a display and xdg-open.
100
+ */
101
+ export function canOpenBrowser(env = process.env, platform = process.platform) {
102
+ if (platform !== 'linux' && (env.SSH_CONNECTION || env.SSH_TTY)) return false
103
+ if (platform === 'win32') return true
104
+ if (platform !== 'darwin' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
105
+ return onPath(platform === 'darwin' ? 'open' : 'xdg-open', env)
106
+ }
107
+
108
+ /** True when an executable named cmd is in a folder of PATH. */
109
+ function onPath(cmd, env) {
110
+ const path = env.PATH ?? env.Path ?? ''
111
+ return path.split(delimiter).filter(Boolean).some((dir) => {
112
+ try { accessSync(join(dir, cmd), constants.X_OK); return true } catch { return false }
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Open a link in the browser. say() runs just before, so the screen says that the browser opens. Without a browser,
118
+ * nothing happens and nothing prints. Never fails. Returns true when it opened the link.
119
+ */
120
+ export function openBrowser(url, env = process.env, say = () => {}) {
121
+ if (!canOpenBrowser(env)) return false
122
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
123
+ say()
124
+ try {
125
+ const child = spawn(cmd, args, { env, stdio: 'ignore', detached: true, windowsHide: true })
126
+ child.on('error', () => {})
127
+ child.unref()
128
+ } catch { /* no browser */ }
129
+ return true
130
+ }
131
+
132
+ // --- where the key goes -------------------------------------------------------------
133
+
134
+ /** The names that mean 1 environment: Production is also prod, Staging also stg. Lower case. */
135
+ export function envWords(environment) {
136
+ const name = String(environment ?? '').trim().toLowerCase()
137
+ if (!name) return []
138
+ return WORDS.find((words) => words.includes(name)) ?? [name]
139
+ }
140
+ const tokens = (name) => String(name).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
141
+
142
+ /**
143
+ * The 1 app that gets the key of this environment, from the names in the deploy files: the only one, else the ones
144
+ * whose name says the environment (ca-web-staging), else the ones whose name holds the service name. More or none: [].
145
+ */
146
+ export function pickApp(names, environment = null, service = null) {
147
+ let pool = unique(names)
148
+ if (pool.length > 1 && environment) {
149
+ const words = envWords(environment)
150
+ const hit = pool.filter((n) => tokens(n).some((t) => words.includes(t)))
151
+ if (hit.length) pool = hit
152
+ }
153
+ if (pool.length > 1 && service) {
154
+ const hit = pool.filter((n) => n === service || tokens(n).join('-').includes(service))
155
+ if (hit.length) pool = hit
156
+ }
157
+ return pool
158
+ }
159
+
160
+ /**
161
+ * The GitHub secret for the key of this environment: the environment secret of the deploy job of that environment, or
162
+ * the repository secret when no deploy job has an environment: line. {env} (null: repository secret) or {why}.
163
+ */
164
+ export function githubSecret(jobs = [], environment = null) {
165
+ const dynamic = (j) => Boolean(j.environment?.includes('$'))
166
+ const envs = unique(jobs.flatMap((j) => (!j.environment ? [] : dynamic(j) ? (j.environments ?? []).map((e) => e.name) : [j.environment])))
167
+ const unknown = jobs.some((j) => dynamic(j) && !(j.environments ?? []).length)
168
+ if (!jobs.length) return { why: 'Setup found no deploy job in your workflows, so it does not store a GitHub secret.' }
169
+ if (!envs.length && !unknown) return { env: null }
170
+ if (!envs.length) return { why: 'Your deploy job picks its GitHub environment when it runs, so setup cannot tell which environment gets this key.' }
171
+ if (!environment) return { why: 'UKNOWS did not say which environment this key is for, so setup cannot pick the GitHub environment.' }
172
+ const words = envWords(environment)
173
+ const match = envs.filter((e) => words.includes(e.toLowerCase()))
174
+ if (match.length === 1) return { env: match[0], partial: jobs.some((j) => !j.environment) }
175
+ return { why: `Your deploy jobs use the GitHub environments ${list(envs)}. ${match.length ? 'More than 1' : 'None'} of them is ${environment}, so setup cannot tell where this key goes.` }
176
+ }
177
+
178
+ /**
179
+ * What setup can store the key on, for each platform of the repo that has a key script: host id -> target. Only from
180
+ * the repo, nothing runs. plans: [{name, s}] with s.k8s, for the Kubernetes workloads. repo: owner/name on GitHub.
181
+ */
182
+ export function keyTargets({ hosts = [], names, view, plans = [], repo = null }) {
183
+ const ids = new Set(hosts.map((h) => h.id))
184
+ const out = new Map()
185
+ if (ids.has('azure-container-apps')) out.set('azure-container-apps', { kind: 'aca', apps: names.aca.apps, groups: names.aca.groups })
186
+ if (ids.has('azure-app-service')) out.set('azure-app-service', { kind: 'webapp', apps: names.webapp.apps, groups: names.webapp.groups, slots: names.webapp.slots })
187
+ if (ids.has('kubernetes')) out.set('kubernetes', { kind: 'k8s', workloads: workloads(view, plans) })
188
+ if (ids.has('github-actions')) out.set('github-actions', { kind: 'github', repo, jobs: names.jobs ?? [], readsKey: readsKey(view) })
189
+ return out
190
+ }
191
+
192
+ /** True when a workflow of the repo reads the UKNOWS_KEY secret already. */
193
+ function readsKey(view) {
194
+ let files = []
195
+ try { files = view.files() } catch { files = [] }
196
+ return files.filter((f) => /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).some((f) => {
197
+ try { return new RegExp(`\\bsecrets\\.${GITHUB_SECRET}\\b`).test(view.read(f) ?? '') } catch { return false }
198
+ })
199
+ }
200
+
201
+ /** The workloads (kind, name, namespace) of the Kubernetes containers of the services of this run. */
202
+ function workloads(view, plans) {
203
+ const out = []
204
+ for (const p of plans) {
205
+ for (const k of p.s?.k8s ?? []) {
206
+ let text = null
207
+ try { text = view.read(k.file) } catch { text = null }
208
+ const w = text === null ? null : workloadOf(text, k.doc)
209
+ if (w?.kind && w.name && !out.some((x) => x.kind === w.kind && x.name === w.name && x.namespace === w.namespace)) out.push({ ...w, file: k.file })
210
+ }
211
+ }
212
+ return out
213
+ }
214
+
215
+ /** kind, metadata.name and metadata.namespace of document doc (0-based, split on --- lines) of a YAML file. */
216
+ export function workloadOf(text, doc = 0) {
217
+ const lines = docLines(new Doc(text), doc).map((i) => text.split(/\r?\n/)[i])
218
+ const kind = lines.map((l) => /^kind:\s*['"]?([A-Za-z]+)['"]?\s*(?:#.*)?$/.exec(l)?.[1]).find(Boolean) ?? null
219
+ const at = lines.findIndex((l) => /^metadata:\s*(?:#.*)?$/.test(l))
220
+ let name = null, namespace = null
221
+ if (at >= 0) {
222
+ const rows = []
223
+ for (let i = at + 1; i < lines.length && (!lines[i].trim() || /^\s/.test(lines[i])); i++) if (lines[i].trim() && !/^\s*#/.test(lines[i])) rows.push(lines[i])
224
+ const pad = rows.length ? /^\s*/.exec(rows[0])[0].length : 0
225
+ const value = (key) => {
226
+ const row = rows.find((r) => /^\s*/.exec(r)[0].length === pad && new RegExp(`^\\s*${key}:`).test(r))
227
+ const v = row?.replace(new RegExp(`^\\s*${key}:\\s*`), '').replace(/\s+#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2')
228
+ return v && /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(v) ? v : null
229
+ }
230
+ name = value('name')
231
+ namespace = value('namespace')
232
+ }
233
+ return { kind, name, namespace }
234
+ }
235
+
236
+ /** The line indexes of document doc of a YAML file, split on --- lines. */
237
+ function docLines(d, doc) {
238
+ const out = []
239
+ let n = 0
240
+ for (let i = 0; i < d.length; i++) {
241
+ if (/^---/.test(d.get(i))) { n++; continue }
242
+ if (n === doc) out.push(i)
243
+ }
244
+ return out
245
+ }
246
+
247
+ // --- storing the key ----------------------------------------------------------------
248
+
249
+ const why = (text) => ({ stored: false, why: text })
250
+ const parse = (text) => { try { return JSON.parse(text) } catch { return null } }
251
+ /** The first line of an error, without the key (also in base64, as a Kubernetes secret holds it) and without the ERROR: prefix of az. */
252
+ const firstLine = (text, key) => {
253
+ const line = String(text ?? '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? 'no reason given'
254
+ const forms = key ? [Buffer.from(`${SECRET.header}=${key}`).toString('base64'), key] : []
255
+ return forms.reduce((out, form) => out.split(form).join(SECRET.placeholder), line).replace(/^ERROR:\s*/, '').slice(0, 300)
256
+ }
257
+ const keyWord = (environment) => (environment ? `this ${environment} key` : 'this key')
258
+ const failed = (r, key) => (r.error ? r.error : firstLine(r.stderr || r.stdout, key))
259
+ /** An error that the same command gives again: a usage error. Then setup never says to try again. */
260
+ const REPEATS = /\busage error\b|\bis required\b|unrecognized arguments/i
261
+ /** A store that failed: why, and whether npx @uknows/setup key can work on a second try. */
262
+ const failure = (prefix, r, key) => { const text = failed(r, key); return { stored: false, failed: true, why: `${prefix}: ${text}`, again: !REPEATS.test(text) } }
263
+
264
+ /**
265
+ * Azure Container Apps and App Service, read only: find the 1 app that gets this key, in the subscriptions that the user
266
+ * can see. {found} or {stored: false, why}. say(line): where the line about the lookup goes.
267
+ */
268
+ async function findAzure(cli, t, ctx, say = () => {}) {
269
+ const A = AZURE[t.kind]
270
+ if (t.kind === 'webapp' && t.slots?.length) return why('Your deploy uses a deployment slot, so set the key on each slot with the steps below.')
271
+ if (!t.apps.length) return why(`Your deploy files do not name the ${A.what}, so setup cannot tell where the key goes.`)
272
+ // The deploy files do not say which app runs which service, so 1 store could reach only 1 of them.
273
+ if ((ctx.services?.length ?? 0) > 1) return why(`You set up ${ctx.services.length} services, so setup cannot tell which ${A.what} runs each one. Set the key on each app with the steps below.`)
274
+ const apps = pickApp(t.apps.map((a) => a.name), ctx.environment, ctx.service)
275
+ if (apps.length !== 1) return why(`Your deploy files name ${t.apps.length} ${A.what}s, ${list(t.apps.map((a) => a.name))}, so setup cannot tell which one gets this key.`)
276
+ if (process.platform === 'win32') return why('On Windows, setup does not run az. Use the steps below in WSL or Azure Cloud Shell.')
277
+ const app = apps[0]
278
+ const groups = unique(t.groups.map((g) => g.name.toLowerCase()))
279
+ const group = groups.length === 1 ? groups[0] : null
280
+ const az = azure(cli, ctx)
281
+ const accounts = await az(['account', 'list', '--query', "[?state=='Enabled'].{id:id, name:name}", '-o', 'json'])
282
+ if (accounts.error === 'missing') return why('az is not installed, so setup cannot store the key. Install the Azure CLI and sign in with az login. Then run npx @uknows/setup key, or use the steps below.')
283
+ const subs = (accounts.code === 0 ? parse(accounts.stdout) : null)?.filter?.((s) => typeof s?.id === 'string' && s.id) ?? []
284
+ if (!subs.length) return why('az is not signed in, so setup cannot store the key. Sign in with az login. Then run npx @uknows/setup key, or use the steps below.')
285
+ say(`Looking for ${app} in the ${plural(subs.length, 'Azure subscription')} that you can see.`)
286
+ // Read only, 4 subscriptions at a time, in the order az lists them.
287
+ const answers = await inTurn(subs, 4, (sub) => az(['resource', 'list', '--subscription', sub.id, '--name', app, '--resource-type', A.type,
288
+ '--query', '[].{id:id, name:name, resourceGroup:resourceGroup}', '-o', 'json'], { timeoutMs: 60000 }))
289
+ const found = []
290
+ subs.forEach((sub, i) => {
291
+ const rows = answers[i].code === 0 ? parse(answers[i].stdout) : null
292
+ for (const row of Array.isArray(rows) ? rows : []) {
293
+ if (typeof row?.name !== 'string' || row.name.toLowerCase() !== app.toLowerCase() || typeof row.resourceGroup !== 'string') continue
294
+ if (group && row.resourceGroup.toLowerCase() !== group) continue
295
+ found.push({ sub, group: row.resourceGroup, name: row.name })
296
+ }
297
+ })
298
+ const named = group ? ` in the resource group ${t.groups[0].name}` : ''
299
+ if (!found.length) return why(`Setup did not find the ${A.what} ${app}${named} in your Azure subscriptions, so it does not store the key.`)
300
+ if (found.length > 1) {
301
+ return why(`Setup found ${app} in ${found.length} places, ${list(found.map((f) => `${f.group} in ${f.sub.name ?? f.sub.id}`))}, so it cannot tell which one gets this key.`)
302
+ }
303
+ const { sub, group: rg, name } = found[0]
304
+ const at = ['--subscription', sub.id, '-g', rg, '-n', name]
305
+ if (t.kind === 'aca') {
306
+ // az containerapp update sets the env entry on the only container. With more, it needs --container-name.
307
+ const containers = listed(await az(['containerapp', 'show', ...at, '--query', 'properties.template.containers[].name', '-o', 'json']))?.filter((c) => typeof c === 'string')
308
+ if (containers?.length > 1) {
309
+ return why(`${name} has ${containers.length} containers, ${list(containers)}, so setup cannot tell which one runs your app. Use the steps below, and add --container-name to the update command.`)
310
+ }
311
+ }
312
+ const mark = t.kind === 'aca' ? ACA_SECRET : ctx.secretName
313
+ const have = await azureNames(az, t.kind, at)
314
+ return { found: { kind: t.kind, name, rg, sub, at, mark, replaces: Boolean(have?.includes(mark)) } }
315
+ }
316
+
317
+ const azure = (cli, ctx) => (args, more = {}) => cli('az', [...args, '--only-show-errors'], { cwd: ctx.root, env: ctx.env, ...more })
318
+ const listed = (r, field = null) => { const v = r.code === 0 ? parse(r.stdout) : null; return Array.isArray(v) ? v.map((x) => (field ? x?.[field] : x)) : null }
319
+ /** The names of the secrets of a container app, or of the settings of a web app. Never their values. */
320
+ const azureNames = async (az, kind, at) => listed(await (kind === 'aca' ? az(['containerapp', 'secret', 'list', ...at, '--query', '[].name', '-o', 'json'])
321
+ : az(['webapp', 'config', 'appsettings', 'list', ...at, '--query', '[].name', '-o', 'json'])))
322
+
323
+ /** Set the key on the app that findAzure found, then check that it is there. */
324
+ async function putAzure(cli, f, ctx) {
325
+ const az = azure(cli, ctx)
326
+ let r
327
+ if (f.kind === 'aca') {
328
+ // az reads the value of an argument @- from stdin, so the key is in no argument and in no file.
329
+ r = await az(['containerapp', 'secret', 'set', ...f.at, '-o', 'none', '--secrets', '@-'], { input: `${ACA_SECRET}=${SECRET.header}=${ctx.key}` })
330
+ if (r.code === 0) r = await az(['containerapp', 'update', ...f.at, '-o', 'none', '--set-env-vars', `${ctx.secretName}=secretref:${ACA_SECRET}`])
331
+ } else {
332
+ r = await az(['webapp', 'config', 'appsettings', 'set', ...f.at, '-o', 'none', '--settings', '@-'],
333
+ { input: JSON.stringify([{ name: ctx.secretName, value: `${SECRET.header}=${ctx.key}`, slotSetting: false }]) })
334
+ }
335
+ if (r.code !== 0) return failure(`Could not store the key on ${f.name}`, r, ctx.key)
336
+ if (!(await azureNames(az, f.kind, f.at))?.includes(f.mark)) {
337
+ return { stored: false, failed: true, why: `Setup set the key, but cannot find ${f.kind === 'aca' ? `the secret ${ACA_SECRET}` : `the setting ${f.mark}`} on ${f.name}. Check the app in the Azure portal.` }
338
+ }
339
+ return { stored: true, where: f.name, done: `Stored ${keyWord(ctx.environment).replace(/^this /, 'the ')} on ${f.name} in ${f.rg}.` }
340
+ }
341
+
342
+ const azureQuestion = (f, ctx) => `Store ${keyWord(ctx.environment)} on ${f.name} in ${f.rg} (subscription ${f.sub.name ?? f.sub.id}, ${f.sub.id.slice(0, 8)}...)? ${AZURE[f.kind].effect}${f.replaces ? ' It replaces the key that the app has now.' : ''} (y/N)`
343
+
344
+ /** Azure Container Apps and App Service: find the app, ask, then set the key on it. */
345
+ async function storeAzure(ui, cli, t, ctx) {
346
+ const f = await findAzure(cli, t, ctx, ui.print)
347
+ if (!f.found) return f
348
+ if (!(await ui.confirm(azureQuestion(f.found, ctx), false))) return { stored: false, declined: true }
349
+ ui.print(`Storing the key on ${f.found.name}. This can take a minute.`)
350
+ return putAzure(cli, f.found, ctx)
351
+ }
352
+
353
+ /** GitHub Actions, read only: the environment secret of the deploy job of this environment, or the repository secret. */
354
+ async function findGithub(cli, t, ctx) {
355
+ if (!t.repo) return why('The origin remote of this repo is not on github.com, so setup cannot store the key as a GitHub secret.')
356
+ const target = githubSecret(t.jobs, ctx.environment)
357
+ if (target.why) return why(target.why)
358
+ const gh = github(cli, ctx)
359
+ const auth = await gh(['auth', 'status', '--hostname', 'github.com'])
360
+ if (auth.error === 'missing') return why('gh is not installed, so setup cannot store the key as a GitHub secret. Install the GitHub CLI and sign in with gh auth login. Then run npx @uknows/setup key, or use the steps below.')
361
+ if (auth.code !== 0) return why('gh is not signed in, so setup cannot store the key. Sign in with gh auth login. Then run npx @uknows/setup key, or use the steps below.')
362
+ const where = target.env ? ['--env', target.env] : []
363
+ if (target.env) {
364
+ const env = await gh(['api', `repos/${t.repo}/environments/${encodeURIComponent(target.env)}`, '--silent'])
365
+ if (env.code !== 0) {
366
+ return why(/\b404\b|not found/i.test(`${env.stderr}${env.stdout}`)
367
+ ? `The GitHub environment ${target.env} does not exist yet in ${t.repo}. Create it in the settings of the repo, under Environments. Then run npx @uknows/setup key, or use the steps below.`
368
+ : `Setup could not read the environments of ${t.repo}: ${failed(env)}`)
369
+ }
370
+ }
371
+ const have = await githubNames(gh, t.repo, where)
372
+ return { found: { kind: 'github', repo: t.repo, env: target.env, where, partial: target.partial, replaces: Boolean(have?.includes(GITHUB_SECRET)) } }
373
+ }
374
+
375
+ const github = (cli, ctx) => (args, more = {}) => cli('gh', args, { cwd: ctx.root, env: ctx.env, timeoutMs: 60000, ...more })
376
+ /** The names of the secrets, never their values: --json name, or the first column on a gh without --json. */
377
+ async function githubNames(gh, repo, where) {
378
+ const json = await gh(['secret', 'list', '--repo', repo, ...where, '--json', 'name'])
379
+ const v = json.code === 0 ? parse(json.stdout) : null
380
+ if (Array.isArray(v)) return v.map((x) => x?.name)
381
+ const plain = await gh(['secret', 'list', '--repo', repo, ...where])
382
+ return plain.code === 0 ? plain.stdout.split(/\r?\n/).map((l) => l.split(/\s+/)[0]).filter(Boolean) : null
383
+ }
384
+
385
+ /** Set the GitHub secret that findGithub found, then check that it is there. */
386
+ async function putGithub(cli, f, ctx) {
387
+ const gh = github(cli, ctx)
388
+ // gh reads the value from stdin. The secret holds the key only, as the docs steps say.
389
+ const r = await gh(['secret', 'set', GITHUB_SECRET, '--repo', f.repo, ...f.where], { input: ctx.key })
390
+ if (r.code !== 0) return failure('Could not store the GitHub secret', r, ctx.key)
391
+ if (!(await githubNames(gh, f.repo, f.where))?.includes(GITHUB_SECRET)) {
392
+ return { stored: false, failed: true, why: `Setup set the secret, but cannot find ${GITHUB_SECRET} in ${f.repo}. Check the secrets in the settings of the repo.` }
393
+ }
394
+ return { stored: true, partial: f.partial, done: f.env ? `Stored the GitHub secret ${GITHUB_SECRET} of the environment ${f.env}.` : `Stored the repository secret ${GITHUB_SECRET}.` }
395
+ }
396
+
397
+ const githubQuestion = (f, ctx) => {
398
+ const replaces = f.replaces ? ' It replaces the secret that is there now.' : ''
399
+ return f.env
400
+ ? `Store ${keyWord(ctx.environment)} as the secret ${GITHUB_SECRET} of the GitHub environment ${f.env} in ${f.repo}? The deploy jobs of that environment can read it.${replaces} (y/N)`
401
+ : `Store ${keyWord(ctx.environment)} as the repository secret ${GITHUB_SECRET} of ${f.repo}? Every workflow of the repo can read it.${replaces} (y/N)`
402
+ }
403
+
404
+ /** GitHub Actions: find where the secret goes, ask, then set it. */
405
+ async function storeGithub(ui, cli, t, ctx) {
406
+ const f = await findGithub(cli, t, ctx)
407
+ if (!f.found) return f
408
+ if (!(await ui.confirm(githubQuestion(f.found, ctx), false))) return { stored: false, declined: true }
409
+ return putGithub(cli, f.found, ctx)
410
+ }
411
+
412
+ /** Kubernetes, read only: the 1 kubectl context that has the workload, and the namespace of the secret uknows. */
413
+ async function findK8s(cli, t, ctx, say = () => {}) {
414
+ if (!t.workloads.length) return why('Setup did not find the Kubernetes workload of this app, so it does not store the key.')
415
+ const spaces = unique(t.workloads.map((w) => w.namespace))
416
+ if (spaces.includes(null)) return why('Your Kubernetes files do not name a namespace, so setup cannot tell where the secret goes.')
417
+ if (spaces.length > 1) return why(`Your Kubernetes files use ${spaces.length} namespaces, ${list(spaces)}, so setup cannot tell which one gets this key.`)
418
+ const ns = spaces[0], w = t.workloads[0]
419
+ const k = kube(cli, ctx)
420
+ const contexts = await k(['config', 'get-contexts', '-o', 'name'])
421
+ if (contexts.error === 'missing') return why('kubectl is not installed, so setup cannot store the key. Then run npx @uknows/setup key, or use the steps below.')
422
+ const all = contexts.code === 0 ? unique(contexts.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)) : []
423
+ if (!all.length) return why('kubectl has no cluster set up, so setup cannot store the key. Set up kubectl for your cluster. Then run npx @uknows/setup key, or use the steps below.')
424
+ const resource = `${w.kind.toLowerCase()}/${w.name}`
425
+ say(`Looking for ${w.kind} ${w.name} in the namespace ${ns}, in the ${plural(all.length, 'kubectl context')} on this computer.`)
426
+ const found = []
427
+ for (const c of all) if ((await k(['--context', c, '-n', ns, 'get', resource, '-o', 'name', '--request-timeout=10s'])).code === 0) found.push(c)
428
+ if (!found.length) return why(`Setup did not find ${w.kind} ${w.name} in the namespace ${ns} in your kubectl contexts, so it does not store the key.`)
429
+ if (found.length > 1) return why(`Setup found ${w.kind} ${w.name} in ${found.length} kubectl contexts, ${list(found)}, so it cannot tell which one gets this key.`)
430
+ const at = ['--context', found[0], '-n', ns]
431
+ const had = (await k([...at, 'get', 'secret', K8S_SECRET, '-o', 'name', '--request-timeout=10s'])).code === 0
432
+ return { found: { kind: 'k8s', context: found[0], ns, at, replaces: had } }
433
+ }
434
+
435
+ // A cluster that wants a new sign-in (a device code) would wait for it: every kubectl call has a time limit.
436
+ const kube = (cli, ctx) => (args, more = {}) => cli('kubectl', args, { cwd: ctx.root, env: ctx.env, timeoutMs: 60000, ...more })
437
+
438
+ /** Apply the secret uknows that findK8s found, then check that it is there. */
439
+ async function putK8s(cli, f, ctx) {
440
+ const k = kube(cli, ctx)
441
+ // The Secret goes to kubectl apply on stdin, so the key is in no argument and in no file.
442
+ const r = await k([...f.at, 'apply', '-f', '-'], { input: secretYaml(K8S_SECRET, f.ns, ctx.secretName, `${SECRET.header}=${ctx.key}`) })
443
+ if (r.code !== 0) return failure('Could not store the key', r, ctx.key)
444
+ if ((await k([...f.at, 'get', 'secret', K8S_SECRET, '-o', 'name', '--request-timeout=10s'])).code !== 0) {
445
+ return { stored: false, failed: true, why: `Setup applied the secret, but cannot find ${K8S_SECRET} in the namespace ${f.ns}.` }
446
+ }
447
+ return { stored: true, where: `the namespace ${f.ns}`, done: `Stored ${keyWord(ctx.environment).replace(/^this /, 'the ')} as the secret ${K8S_SECRET} in the namespace ${f.ns}.` }
448
+ }
449
+
450
+ const k8sQuestion = (f, ctx) => `Store ${keyWord(ctx.environment)} as the secret ${K8S_SECRET} in the namespace ${f.ns} (kubectl context ${f.context})? Pods that start after this get the key.${f.replaces ? ' It replaces the key in that secret.' : ''} (y/N)`
451
+
452
+ /** Kubernetes: find the context, ask, then apply the secret. */
453
+ async function storeK8s(ui, cli, t, ctx) {
454
+ const f = await findK8s(cli, t, ctx, ui.print)
455
+ if (!f.found) return f
456
+ if (!(await ui.confirm(k8sQuestion(f.found, ctx), false))) return { stored: false, declined: true }
457
+ return putK8s(cli, f.found, ctx)
458
+ }
459
+
460
+ /** A Kubernetes Secret with 1 value: the Secret that kubectl create secret generic makes. */
461
+ export function secretYaml(name, namespace, key, value) {
462
+ return ['apiVersion: v1', 'kind: Secret', 'metadata:', ` name: ${name}`, ` namespace: ${namespace}`, 'type: Opaque', 'data:',
463
+ ` ${key}: ${Buffer.from(value, 'utf8').toString('base64')}`, ''].join('\n')
464
+ }
465
+
466
+ /** Read only lookups, and the writes after a yes, for each kind of place that can hold the key. */
467
+ export const FIND = { aca: findAzure, webapp: findAzure, github: findGithub, k8s: findK8s }
468
+ export const PUT = { aca: putAzure, webapp: putAzure, github: putGithub, k8s: putK8s }
469
+
470
+ /**
471
+ * Where a store puts the key, for the plan: a title, the rows (label, value) and 1 line on what happens. Only from what
472
+ * FIND found, so the plan names the exact app, subscription, repository or cluster.
473
+ */
474
+ export function storeRows(f) {
475
+ if (f.kind === 'aca' || f.kind === 'webapp') {
476
+ // A dry run looks up nothing: it names what the deploy files name, and says what a real run looks up.
477
+ const sub = f.dry ? 'the one that has this app. A real run looks it up.' : `${f.sub.name ?? f.sub.id} (${f.sub.id.slice(0, 8)}...)`
478
+ return { title: `Store your key on your ${AZURE[f.kind].what}`, rows: [['App', f.name], ['Resource group', f.rg], ['Subscription', sub]],
479
+ note: `${AZURE[f.kind].effect.replace('It restarts the app.', 'The app restarts with the key.')}${f.replaces ? ' It replaces the key that the app has now.' : ''}` }
480
+ }
481
+ if (f.kind === 'github') {
482
+ return { title: 'Store your key as a GitHub secret', rows: [['Secret', GITHUB_SECRET], ['Repository', f.repo], ...(f.env ? [['Environment', f.env]] : [])],
483
+ note: `${f.env ? 'The deploy jobs of that environment can read it.' : 'Every workflow of the repo can read it.'}${f.replaces ? ' It replaces the secret that is there now.' : ''}` }
484
+ }
485
+ return { title: 'Store your key as a Kubernetes secret', rows: [['Secret', K8S_SECRET], ['Namespace', f.ns], ['kubectl context', f.context]],
486
+ note: `Pods that start after this get the key.${f.replaces ? ' It replaces the key in that secret.' : ''}` }
487
+ }
488
+
489
+ /** The name that a progress line uses for a store: the app, the GitHub secret or the Kubernetes secret. */
490
+ export const storeName = (f) => (f.kind === 'github' ? `the GitHub secret ${GITHUB_SECRET}` : f.kind === 'k8s' ? `the secret ${K8S_SECRET} in ${f.ns}` : f.name)
491
+
492
+ const STORE = { aca: storeAzure, webapp: storeAzure, github: storeGithub, k8s: storeK8s }
493
+
494
+ /** fn for each item, at most n at a time. The results keep the order of the items. */
495
+ async function inTurn(items, n, fn) {
496
+ const out = new Array(items.length)
497
+ let next = 0
498
+ const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]) } }
499
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker))
500
+ return out
501
+ }
502
+ const keyTitle = (title) => HOSTS.find((h) => title === `Set your key once: ${h.label}`)
503
+
504
+ /**
505
+ * The key steps in a terminal. For each "Set your key once" block whose platform setup can store the key on: ask, and
506
+ * store it. The block prints when the key is not stored. GitHub Actions needs no secret when setup stored the key on
507
+ * the app itself and no workflow reads the secret. Returns {stored, left, skipped, direct, github}: what is done, the
508
+ * titles of the blocks that are left, the hosts whose block is not needed, where the key went on the platform itself,
509
+ * and the GitHub Actions block when its secret is stored but no workflow step reads it yet.
510
+ * ctx: {key, environment, env, root, secretName, service, services}: service is the 1 service of the run, services every
511
+ * service of the run. key null: the key was not checked, so nothing is stored.
512
+ */
513
+ export async function keyStepsInTerminal(ui, cli, blocks, targets, ctx) {
514
+ const out = { stored: [], left: [], skipped: [], direct: null }
515
+ const keyBlocks = blocks.filter((b) => keyTitle(b.title))
516
+ if (!keyBlocks.length) return out
517
+ if (!ctx.key && keyBlocks.some((b) => targets.has(keyTitle(b.title).id))) {
518
+ ui.print('Setup stores your key only after UKNOWS accepts it. To store it later, run npx @uknows/setup key. Or use the steps below.')
519
+ ui.print()
520
+ } else if (ctx.key && keyBlocks.some((b) => targets.has(keyTitle(b.title).id))) {
521
+ ui.print('Store your key')
522
+ }
523
+ for (const block of keyBlocks) {
524
+ const host = keyTitle(block.title), t = targets.get(host.id)
525
+ if (host.id === 'github-actions' && out.direct && !t?.readsKey) {
526
+ ui.print(`GitHub Actions needs no ${GITHUB_SECRET} secret: setup stored the key on ${out.direct}.`)
527
+ ui.print()
528
+ out.skipped.push(host.id)
529
+ continue
530
+ }
531
+ if (!t || !ctx.key) { ui.block(block); out.left.push(block.title); continue }
532
+ const r = await STORE[t.kind](ui, cli, t, ctx)
533
+ if (r.stored) {
534
+ ui.print('Stored.')
535
+ ui.print()
536
+ out.stored.push({ host: host.id, done: r.done })
537
+ if (t.kind !== 'github') out.direct = r.where
538
+ // A GitHub secret reaches the app only through a step of the deploy job. Without one, that step is left: the deploy
539
+ // file question can add it (githubStep), else it prints after the deploy files (githubStepBlock).
540
+ if (t.kind === 'github' && r.partial) { ui.block(block); out.left.push(block.title) } else if (t.kind === 'github' && !t.readsKey) out.github = block
541
+ continue
542
+ }
543
+ if (r.why) (r.failed ? ui.error : ui.print)(r.why)
544
+ if (r.failed) ui.print(r.again ? 'Use the steps below, or run npx @uknows/setup key to try again.' : 'Use the steps below.')
545
+ if (r.why || r.failed) ui.print()
546
+ ui.block(block)
547
+ out.left.push(block.title)
548
+ }
549
+ return out
550
+ }
551
+
552
+ /**
553
+ * The key steps of a run with 1 Yes, read only, before the plan. For each "Set your key once" block: a store that setup
554
+ * can do ({block, host, t, found}), or the block left for the person with why not. GitHub Actions needs no secret when the
555
+ * key goes on the app itself and no workflow reads the secret (skipped), as in keyStepsInTerminal. ctx as there.
556
+ * Returns {stores, left: [{block, host, why}], skipped: [{block, host}]}. why null: setup has no way to store it there.
557
+ */
558
+ export async function planKeyStores(cli, blocks, targets, ctx, say = () => {}) {
559
+ const out = { stores: [], left: [], skipped: [] }
560
+ let direct = false
561
+ for (const block of blocks.filter((b) => keyTitle(b.title))) {
562
+ const host = keyTitle(block.title), t = targets.get(host.id)
563
+ if (host.id === 'github-actions' && direct && !t?.readsKey) { out.skipped.push({ block, host: host.id }); continue }
564
+ if (!t || !ctx.key) { out.left.push({ block, host: host.id, why: t ? 'no-key' : null }); continue }
565
+ const f = await FIND[t.kind](cli, t, ctx, say)
566
+ if (!f.found) { out.left.push({ block, host: host.id, why: f.why }); continue }
567
+ out.stores.push({ block, host: host.id, t, found: f.found })
568
+ if (t.kind !== 'github') direct = true
569
+ }
570
+ return out
571
+ }
572
+
573
+ /** The step of the GitHub Actions block that is left after the secret is stored: add the key step to the deploy job. */
574
+ export function githubStepBlock(block) {
575
+ return { title: 'Add the key step to your deploy workflow', lines: block.lines.filter((l) => /^Add the step below|^Your deploy files name \d+ container apps|^The app must load the secret|^On other platforms/.test(l)),
576
+ snippets: (block.snippets ?? []).filter((x) => /- name: Set the UKNOWS key/.test(x.code)) }
577
+ }
578
+
579
+ /** The lines of the key step of the GitHub Actions block, from its - name: line to its az line, without comments. */
580
+ export function githubStepLines(block) {
581
+ const code = (block?.snippets ?? []).find((x) => /- name: Set the UKNOWS key/.test(x.code))?.code
582
+ if (!code) return null
583
+ const rows = code.split('\n')
584
+ const from = rows.findIndex((l) => /^- name: Set the UKNOWS key/.test(l)), to = rows.findIndex((l) => /\baz containerapp secret set\b/.test(l))
585
+ return from >= 0 && to > from ? rows.slice(from, to + 1).map((l) => l.replace(/\s+#[^'"]*$/, '')) : null
586
+ }
587
+
588
+ // --- deploy files -------------------------------------------------------------------
589
+
590
+ /**
591
+ * The edits that make each deploy keep the key, 1 per file, from the files on disk now. Only the safe cases:
592
+ * --replace-env-vars of az containerapp update: the env entry goes after the last pair of the list.
593
+ * environmentVariables of the Container Apps deploy action, on 1 line: the env entry goes at its end.
594
+ * a container of a plain Kubernetes file with no envFrom: envFrom with the secret uknows, optional.
595
+ * Anything else stays a printed step. A file that holds a key, or above the setup folder, is never changed.
596
+ * Each edit: {file, before, after, lines (1-based, in before), checks (the deploy check ids it does), about}.
597
+ * plans: [{s}] with s.k8s. secretName: the variable that holds the key.
598
+ */
599
+ export function deployEdits(view, hosts, plans, secretName, { githubStep = null } = {}) {
600
+ const files = new Map()
601
+ const evidence = (id, what) => unique(hosts.filter((h) => h.id === id).flatMap((h) => h.evidence ?? []).filter((e) => e.what === what).map((e) => e.file))
602
+ const add = (file, check, apply) => {
603
+ if (file.startsWith('../')) return
604
+ if (!files.has(file)) {
605
+ let text = null
606
+ try { text = view.read(file) } catch { text = null }
607
+ if (text === null || hasKey(text)) return
608
+ files.set(file, { file, before: text, after: text, lines: [], checks: [], about: [] })
609
+ }
610
+ const entry = files.get(file)
611
+ const result = apply(entry.after)
612
+ if (!result) return
613
+ entry.after = result.after
614
+ entry.lines.push(...result.lines)
615
+ entry.checks.push(check)
616
+ entry.about.push(`${file} ${result.lines.length > 1 ? 'lines' : 'line'} ${list(result.lines.map(String))} ${result.about}`)
617
+ }
618
+ const pair = `${secretName}=secretref:${ACA_SECRET}`
619
+ for (const file of evidence('azure-container-apps', 'Deploy step runs az containerapp')) add(file, 'replace-env-vars', (text) => replaceEnvEdit(text, pair, secretName))
620
+ for (const file of evidence('azure-container-apps', 'Azure Container Apps deploy action')) add(file, 'deploy-action-env', (text) => actionEnvEdit(text, pair, secretName))
621
+ // The key is in a GitHub secret now: the deploy job sets it on the container app, and its deploy command loads it.
622
+ if (githubStep && hosts.some((h) => h.id === 'azure-container-apps')) {
623
+ for (const file of evidence('github-actions', 'GitHub Actions workflow that deploys')) add(file, 'github-step', (text) => githubStepEdit(text, githubStep, pair, secretName))
624
+ }
625
+ if (hosts.some((h) => h.id === 'kubernetes')) {
626
+ for (const p of plans) for (const k of p.s?.k8s ?? []) add(k.file, 'env-from', (text) => envFromEdit(text, k.doc, k.container))
627
+ }
628
+ return [...files.values()].filter((e) => e.after !== e.before).map((e) => ({ ...e, lines: unique(e.lines).sort((a, b) => a - b),
629
+ question: `Add the key to ${e.file} ${e.lines.length > 1 ? 'lines' : 'line'} ${list(unique(e.lines).sort((a, b) => a - b).map(String))}? (y/N)` }))
630
+ }
631
+
632
+ const setsVar = (name) => new RegExp(`(?:^|[\\s"'])${name}=`)
633
+
634
+ /** --replace-env-vars of each az containerapp update in text: the pair after the last value. null when nothing to add. */
635
+ export function replaceEnvEdit(text, pair, name) {
636
+ const d = new Doc(text)
637
+ const lines = []
638
+ for (let i = 0; i < d.length; i++) {
639
+ const line = d.get(i)
640
+ if (/^\s*#/.test(line)) continue
641
+ const flag = /(^|\s)--replace-env-vars(?=\s|$)/.exec(line)
642
+ if (!flag) continue
643
+ let start = i
644
+ while (start > 0 && /\\\s*$/.test(d.get(start - 1))) start--
645
+ let end = i
646
+ while (end + 1 < d.length && /\\\s*$/.test(d.get(end))) end++
647
+ const command = Array.from({ length: end - start + 1 }, (_, k) => d.get(start + k)).join('\n')
648
+ if (!/\baz\s+containerapp\s+update\b/.test(command) || setsVar(name).test(command)) continue
649
+ // The values after the flag, up to the next option, the end of the command, a comment or a shell operator.
650
+ let row = i, col = flag.index + flag[0].length, last = null
651
+ for (;;) {
652
+ const rest = d.get(row).slice(col)
653
+ const re = /("[^"]*"|'[^']*'|[^\s"']+)/g
654
+ let m, stop = false, more = false
655
+ while ((m = re.exec(rest))) {
656
+ const token = m[1]
657
+ if (token === '\\' && !rest.slice(m.index + 1).trim()) { more = true; break }
658
+ if (/^(?:-|#|&&|\|\||;|\|)/.test(token)) { stop = true; break }
659
+ if (!/=/.test(token)) { stop = true; break }
660
+ last = { row, col: col + m.index + token.length }
661
+ }
662
+ if (stop || !more || row + 1 >= d.length) break
663
+ row++
664
+ col = 0
665
+ }
666
+ if (!last) continue
667
+ const target = d.get(last.row)
668
+ d.set(last.row, `${target.slice(0, last.col)} ${pair}${target.slice(last.col)}`)
669
+ lines.push(i + 1)
670
+ }
671
+ return lines.length ? { after: d.toString(), lines,
672
+ about: `runs az containerapp update with --replace-env-vars. That replaces every environment variable of the app, so each deploy would remove the key.` } : null
673
+ }
674
+
675
+ /**
676
+ * The key step of the docs before each workflow step that runs az containerapp update, in a job that does not read the
677
+ * UKNOWS_KEY secret yet, and the env entry that loads the key on that command (unless it has --replace-env-vars, whose
678
+ * edit adds it). null when nothing to add, and when a command names another app than the APP of the key step: that
679
+ * step would set the key on 1 app only, so it stays a printed step.
680
+ */
681
+ export function githubStepEdit(text, stepLines, pair, name) {
682
+ const d = new Doc(text)
683
+ const indent = (l) => /^\s*/.exec(l)[0].length
684
+ const unquote = (v) => v?.replace(/^(["'])(.*)\1$/, '$2') ?? null
685
+ const app = unquote(stepLines.map((l) => /^\s+APP:\s*(\S+)\s*$/.exec(l)?.[1]).find(Boolean))
686
+ const spots = []
687
+ for (let i = 0; i < d.length; i++) {
688
+ if (/^\s*#/.test(d.get(i)) || !/\baz\s+containerapp\s+update\b/.test(d.get(i))) continue
689
+ let step = i
690
+ while (step >= 0 && !(/^\s*-\s+\S/.test(d.get(step)) && indent(d.get(step)) < indent(d.get(i)))) step--
691
+ if (step < 0) return null
692
+ // The job of the step: up to the line with less indent than its steps: key.
693
+ let top = step
694
+ while (top > 0 && !/^\s*steps\s*:/.test(d.get(top))) top--
695
+ let job = top
696
+ while (job > 0 && !(d.get(job).trim() && indent(d.get(job)) < indent(d.get(top)))) job--
697
+ let end = top + 1
698
+ while (end < d.length && (!d.get(end).trim() || indent(d.get(end)) > indent(d.get(job)))) end++
699
+ const body = Array.from({ length: end - job }, (_, k) => d.get(job + k)).join('\n')
700
+ if (new RegExp(`\\bsecrets\\.${GITHUB_SECRET}\\b`).test(body) || /- name: Set the UKNOWS key/.test(body)) continue
701
+ let last = i
702
+ while (last + 1 < d.length && /\\\s*$/.test(d.get(last))) last++
703
+ const command = Array.from({ length: last - i + 1 }, (_, k) => d.get(i + k)).join('\n')
704
+ // A literal app name that is not the APP of the key step. A $VARIABLE is the deploy's own: the names came from it.
705
+ const named = unquote(/(?:^|\s)(?:-n|--name)[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(command)?.[1])
706
+ if (app && named && !named.includes('$') && named !== app) return null
707
+ // The subscription of the key step: the one of the deploy command, else the one that azure/login signs in to.
708
+ const subscription = /--subscription[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(command)?.[1]?.replace(/^(["'])(.*)\1$/, '$2')
709
+ ?? /^\s*subscription-id\s*:\s*(.+?)\s*$/m.exec(body)?.[1]?.replace(/^(["'])(.*)\1$/, '$2') ?? null
710
+ spots.push({ step, i, subscription, first: !spots.some((x) => x.step === step),
711
+ env: !setsVar(name).test(command) && !/(?:^|\s)--replace-env-vars(?=\s|$)/.test(command) })
712
+ }
713
+ if (!spots.length) return null
714
+ for (const spot of [...spots].reverse()) {
715
+ if (spot.env) {
716
+ // On the --set-env-vars list of the command if it has one: az keeps only the last --set-env-vars list.
717
+ const command = updateCommandAt(d, spot.i)
718
+ if (!command) return null
719
+ applyChanges(d, [setEnvChange(command, pair)])
720
+ }
721
+ if (!spot.first) continue
722
+ const pad = ' '.repeat(indent(d.get(spot.step)))
723
+ const lines = stepLines.map((l) => (spot.subscription && /^\s+SUBSCRIPTION:/.test(l) ? `${/^\s*/.exec(l)[0]}SUBSCRIPTION: ${spot.subscription}` : l))
724
+ d.insert(spot.step, ...lines.map((l) => `${pad}${l}`), '')
725
+ }
726
+ // Lines in the text before the edit: the step that the key step goes before.
727
+ return { after: d.toString(), lines: unique(spots.map((s) => s.step + 1)),
728
+ about: 'deploys with az containerapp update. This adds the step that sets the key from the UKNOWS_KEY secret before it, and loads the key in the deploy command. Your GitHub secret then reaches the app on each deploy.' }
729
+ }
730
+
731
+ /** environmentVariables of the Container Apps deploy action, on 1 line: the pair at its end. null when nothing to add. */
732
+ export function actionEnvEdit(text, pair, name) {
733
+ const d = new Doc(text)
734
+ const lines = []
735
+ for (let i = 0; i < d.length; i++) {
736
+ const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(d.get(i))
737
+ if (!m || !inActionStep(d, i)) continue
738
+ const value = m[2]
739
+ if (!value || /^[|>]/.test(value) || setsVar(name).test(value)) continue
740
+ const quoted = /^(["'])(.*)\1$/.exec(value)
741
+ if (!quoted && /["']/.test(value)) continue
742
+ const next = quoted ? `${quoted[1]}${quoted[2]} ${pair}${quoted[1]}` : `${value} ${pair}`
743
+ d.set(i, `${m[1]}${next}${m[3]}`)
744
+ lines.push(i + 1)
745
+ }
746
+ return lines.length ? { after: d.toString(), lines,
747
+ about: 'gives the Container Apps deploy action its environmentVariables. The action replaces every environment variable, so each deploy would remove the key.' } : null
748
+ }
749
+
750
+ /** True when line i is in a workflow step that uses azure/container-apps-deploy-action. */
751
+ function inActionStep(d, i) {
752
+ const indent = (l) => /^\s*/.exec(l)[0].length
753
+ let start = i
754
+ while (start >= 0 && !/^\s*-\s/.test(d.get(start))) start--
755
+ if (start < 0) return false
756
+ const pad = indent(d.get(start))
757
+ for (let j = start; j < d.length && (j === start || !d.get(j).trim() || indent(d.get(j)) > pad); j++) {
758
+ if (/^\s*(?:-\s+)?uses\s*:\s*['"]?azure\/container-apps-deploy-action@/.test(d.get(j))) return true
759
+ }
760
+ return false
761
+ }
762
+
763
+ /** The rows of 1 container of document doc of a YAML file, with top: the indent of its keys. null when not found. */
764
+ function containerItem(d, doc, container) {
765
+ const rows = docLines(d, doc).filter((i) => d.get(i).trim() && !/^\s*#/.test(d.get(i)))
766
+ .map((i) => ({ i, indent: /^\s*/.exec(d.get(i))[0].length, text: d.get(i).trim() }))
767
+ for (let r = 0; r < rows.length; r++) {
768
+ if (!/^containers:\s*(?:#.*)?$/.test(rows[r].text)) continue
769
+ for (const item of items(rows, r)) {
770
+ const name = item.find((x) => x.indent === item.top && /^name:/.test(x.text))?.text.replace(/^name:\s*/, '').replace(/\s+#.*$/, '').replace(/^(['"])(.*)\1$/, '$2')
771
+ if (name === container) return item
772
+ }
773
+ }
774
+ return null
775
+ }
776
+
777
+ /** True when the container loads the secret uknows with envFrom. */
778
+ function loadsSecret(item) {
779
+ const from = item.findIndex((x) => x.indent === item.top && /^envFrom:/.test(x.text))
780
+ if (from < 0) return false
781
+ for (let k = from + 1; k < item.length && item[k].indent > item.top; k++) if (new RegExp(`^name:\\s*['"]?${K8S_SECRET}['"]?\\s*(?:#.*)?$`).test(item[k].text)) return true
782
+ return false
783
+ }
784
+
785
+ /** True when every Kubernetes container of the services loads the secret uknows (after the deploy file step). */
786
+ export function k8sLoadsKey(view, plans) {
787
+ for (const p of plans) {
788
+ for (const k of p.s?.k8s ?? []) {
789
+ let text = null
790
+ try { text = view.read(k.file) } catch { text = null }
791
+ const item = text === null ? null : containerItem(new Doc(text), k.doc, k.container)
792
+ if (!item || !loadsSecret(item)) return false
793
+ }
794
+ }
795
+ return true
796
+ }
797
+
798
+ /** envFrom with the secret uknows for 1 container of document doc. null when it has envFrom already, or is not found. */
799
+ export function envFromEdit(text, doc, container) {
800
+ const d = new Doc(text)
801
+ const item = containerItem(d, doc, container)
802
+ if (!item || item.some((x) => x.indent === item.top && /^envFrom:/.test(x.text))) return null
803
+ const pad = ' '.repeat(item.top)
804
+ d.insert(item[item.length - 1].i + 1, `${pad}envFrom:`, `${pad} - secretRef:`, `${pad} name: ${K8S_SECRET}`, `${pad} optional: true`)
805
+ const line = item.find((x) => x.indent === item.top && /^name:/.test(x.text))?.i ?? item[0].i
806
+ return { after: d.toString(), lines: [line + 1],
807
+ about: `runs the container ${container}, which does not load the secret ${K8S_SECRET} yet. This adds envFrom, so new pods get the key.` }
808
+ }
809
+
810
+ /** The items of the block list under rows[at], each with its rows and top: the indent of its keys. */
811
+ function items(rows, at) {
812
+ const out = []
813
+ let dash = -1
814
+ for (let r = at + 1; r < rows.length; r++) {
815
+ const { indent, text } = rows[r]
816
+ const isDash = /^-(?:\s|$)/.test(text)
817
+ if (dash < 0) { if (!isDash || indent < rows[at].indent) break; dash = indent }
818
+ if (indent < dash || (indent === dash && !isDash)) break
819
+ if (indent === dash) {
820
+ const rest = text.slice(1), trimmed = rest.trimStart()
821
+ const item = trimmed ? [{ i: rows[r].i, indent: indent + 1 + rest.length - trimmed.length, text: trimmed }] : []
822
+ out.push(Object.assign(item, { top: item[0]?.indent ?? -1 }))
823
+ } else {
824
+ const item = out[out.length - 1]
825
+ if (item.top < 0) item.top = indent
826
+ item.push(rows[r])
827
+ }
828
+ }
829
+ return out.filter((item) => item.length)
830
+ }
831
+
832
+ /**
833
+ * Ask for each deploy file edit, with what it changes and why, and apply it with apply(edit) on a yes. Returns the
834
+ * edits that were applied.
835
+ */
836
+ export async function deployFilesInTerminal(ui, edits, apply) {
837
+ const done = []
838
+ for (const edit of edits) {
839
+ for (const about of edit.about) ui.print(about)
840
+ for (const line of unified(edit.file, edit.before, edit.after).split('\n')) ui.print(line)
841
+ if (!(await ui.confirm(edit.question, false))) { ui.print(); continue }
842
+ try {
843
+ apply(edit)
844
+ done.push(edit)
845
+ ui.print(`Changed ${edit.file}. To undo it, run npx @uknows/setup remove.`)
846
+ } catch (error) {
847
+ ui.error(`Could not change ${edit.file}: ${error.message}. It is as it was.`)
848
+ }
849
+ ui.print()
850
+ }
851
+ return done
852
+ }
853
+
854
+ /**
855
+ * The deploy check lines that are done: the ones that a deploy file edit did for every file that the line names, the
856
+ * secret-first line when the key is on the container app, and the GitHub guard when its block is not needed.
857
+ */
858
+ export function openChecks(blocks, { edits = [], stored = [], skipped = [], secretName = SECRET.name }) {
859
+ const text = (host, id) => {
860
+ const t = HOSTS.find((h) => h.id === host)?.checks?.find((c) => c.id === id)?.text
861
+ return t ? t.split(SECRET.name).join(secretName) : null
862
+ }
863
+ const doneBy = new Map([['replace-env-vars', 'azure-container-apps'], ['deploy-action-env', 'azure-container-apps']])
864
+ return blocks.flatMap((block) => {
865
+ if (keyTitle(block.title)) return []
866
+ const host = HOSTS.find((h) => block.title.endsWith(`: ${h.label}`) && block.title.startsWith('Check your deploy files'))
867
+ if (!host) return [block]
868
+ if (skipped.includes(host.id)) return []
869
+ // The key step that the GitHub step edit added sets the secret before the deploy, and fails the job without it.
870
+ const step = edits.some((e) => e.checks.includes('github-step'))
871
+ const lines = block.lines.filter((line) => {
872
+ if (host.id === 'azure-container-apps' && (stored.includes('azure-container-apps') || step) && line === text(host.id, 'secret-first')) return false
873
+ if (host.id === 'github-actions' && step && line === text(host.id, 'guard')) return false
874
+ for (const [id, hostId] of doneBy) {
875
+ const t = text(hostId, id)
876
+ if (host.id !== hostId || !t || !line.startsWith(t)) continue
877
+ const files = [...line.slice(t.length).matchAll(/([^\s,]+) line \d+/g)].map((m) => m[1])
878
+ if (files.length && files.every((f) => edits.some((e) => e.file === f && e.checks.includes(id)))) return false
879
+ }
880
+ return true
881
+ })
882
+ return lines.length ? [{ ...block, lines }] : []
883
+ })
884
+ }
885
+
886
+ // --- the pull request ---------------------------------------------------------------
887
+
888
+ /** The paths in the porcelain -z output of git status, relative to the folder whose prefix is given. */
889
+ function statusPaths(out, prefix) {
890
+ const tokens = out.split('\0'), paths = []
891
+ for (let i = 0; i < tokens.length; i++) {
892
+ const entry = tokens[i]
893
+ if (entry.length < 4) continue
894
+ if (entry[0] === 'R' || entry[0] === 'C') i++
895
+ const path = entry.slice(3)
896
+ paths.push(path.startsWith(prefix) ? path.slice(prefix.length) : path)
897
+ }
898
+ return paths
899
+ }
900
+
901
+ /** ssh never asks for a passphrase: the terminal belongs to the questions of setup. A command of the user stays. */
902
+ async function sshEnv(git, env) {
903
+ return env.GIT_SSH_COMMAND || (await git(['config', '--get', 'core.sshCommand'])).code === 0 ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
904
+ }
905
+
906
+ /** The name after this one: uknows-setup, uknows-setup-2, uknows-setup-3 and so on. */
907
+ const nextBranch = (name) => (name === PULL_REQUEST.branch ? `${name}-2` : name.replace(/-(\d+)$/, (m, n) => `-${Number(n) + 1}`))
908
+
909
+ /** A commit subject as the question shows it: 1 line, no control characters, at most 72 characters. */
910
+ const subject = (text) => {
911
+ const line = String(text).replace(/[\u0000-\u001f\u007f]/g, '').trim()
912
+ return line.length > 72 ? `${line.slice(0, 71)}...` : line
913
+ }
914
+
915
+ /**
916
+ * What the pull request step needs to know, before its question. git: (args, more) => result, in the repo.
917
+ * from: the branch checked out (null: none). remote: the origin address. slug: owner/name on GitHub.
918
+ * base: the branch of origin that the pull request goes to: the branch checked out when origin has it, else the
919
+ * default branch of origin (origin/HEAD). null when origin has neither: then setup does not push.
920
+ * ahead: {count, subjects}, the commits on HEAD that origin/<base> does not have. A push takes them along.
921
+ * branch: the first free name, uknows-setup, then uknows-setup-2 and so on.
922
+ * network: also ask origin which branches it has (git ls-remote), so a branch that a teammate pushed is never taken.
923
+ * The dry run passes false and reads only this computer.
924
+ */
925
+ export async function prFacts(git, { env = {}, network = false } = {}) {
926
+ const head = await git(['rev-parse', '--abbrev-ref', 'HEAD'])
927
+ if (head.code !== 0) return null
928
+ const from = head.stdout.trim()
929
+ if (from === 'HEAD') return { from: null }
930
+ const exists = async (ref) => (await git(['rev-parse', '--verify', '--quiet', ref])).code === 0
931
+ const origin = await git(['config', '--get', 'remote.origin.url'])
932
+ const remote = origin.code === 0 ? origin.stdout.trim() || null : null
933
+ let base = null
934
+ if (remote) {
935
+ if (await exists(`refs/remotes/origin/${from}`)) base = from
936
+ else {
937
+ const def = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
938
+ const name = def.code === 0 && def.stdout.trim().startsWith('origin/') ? def.stdout.trim().slice('origin/'.length) : null
939
+ if (name && (await exists(`refs/remotes/origin/${name}`))) base = name
940
+ }
941
+ }
942
+ const ahead = { count: 0, subjects: [] }
943
+ if (base) {
944
+ const range = `refs/remotes/origin/${base}..HEAD`
945
+ ahead.count = Number((await git(['rev-list', '--count', range])).stdout.trim()) || 0
946
+ if (ahead.count) ahead.subjects = (await git(['log', '-n', '3', '--format=%s', range])).stdout.split('\n').map(subject).filter(Boolean)
947
+ }
948
+ const ssh = network && remote ? await sshEnv(git, env) : {}
949
+ const taken = new Set()
950
+ if (network && remote) {
951
+ // The heads of origin, where the push goes. Read only. Without an answer, the local names are all setup knows.
952
+ const push = await git(['remote', 'get-url', '--push', 'origin'])
953
+ const heads = await git(['ls-remote', '--heads', push.code === 0 && push.stdout.trim() ? push.stdout.trim() : 'origin'], { timeoutMs: 30000, env: { ...env, ...ssh } })
954
+ if (heads.code === 0) for (const m of heads.stdout.matchAll(/\trefs\/heads\/(\S+)/g)) taken.add(m[1])
955
+ }
956
+ const free = async (name) => !taken.has(name) && !(await exists(`refs/heads/${name}`)) && !(await exists(`refs/remotes/origin/${name}`))
957
+ let branch = PULL_REQUEST.branch
958
+ for (let n = 0; n < 100 && !(await free(branch)); n++) branch = nextBranch(branch)
959
+ return { from, remote, slug: githubSlug(remote), base, ahead, branch, ssh }
960
+ }
961
+
962
+ /**
963
+ * The files of the commit, as the questions name them: the files of the app that setup changed, and its record
964
+ * (.uknows/setup.json), so the count matches the "Changed N files" line of the run. earlier: a rerun names the files
965
+ * that an earlier run changed.
966
+ */
967
+ export function commitFiles(files, earlier = false) {
968
+ const n = files.filter((f) => f !== CLI.manifest).length
969
+ const changed = `the ${plural(n, 'file')} setup changed${earlier ? ' earlier' : ''}`
970
+ if (!files.includes(CLI.manifest)) return changed
971
+ return n ? `${changed} and its record, ${CLI.manifest}` : `the record of setup, ${CLI.manifest}`
972
+ }
973
+
974
+ /** The paths that git shows as changed and not committed, of the paths given, relative to the folder of git. */
975
+ async function changedPaths(git, paths) {
976
+ const prefix = (await git(['rev-parse', '--show-prefix'])).stdout.trim()
977
+ const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...paths])
978
+ return status.code === 0 ? unique(statusPaths(status.stdout, prefix)).sort() : []
979
+ }
980
+
981
+ /**
982
+ * The files of setup that are not committed yet: paths is the manifest and its paths. [] when git cannot tell. A rerun
983
+ * with nothing to change reads it, to say that setup has nothing more to change but its files are not committed yet.
984
+ */
985
+ export function notCommitted(cli, { root, env, paths }) {
986
+ return changedPaths((args) => cli('git', ['-C', root, ...args], { cwd: root, env }), paths)
987
+ }
988
+
989
+ /**
990
+ * The question of the pull request step. files: the paths of the commit. pr: setup opens the pull request (gh is
991
+ * signed in, origin is on GitHub and has a base). Commits of the user that origin does not have go along on a push:
992
+ * the question names them, and no is the default then.
993
+ */
994
+ export function prQuestion({ branch, files, pr, base = null, ahead = { count: 0, subjects: [] } }) {
995
+ const what = pr ? `Create branch ${branch}, commit ${commitFiles(files)}, and open a pull request?` : `Create branch ${branch} and commit ${commitFiles(files)}?`
996
+ if (!ahead.count) return `${what} (Y/n)`
997
+ const names = ahead.subjects.map((s) => `"${s}"`)
998
+ const shown = ahead.count > names.length ? `${names.join(', ')} and ${ahead.count - names.length} more` : list(names)
999
+ return `${what} ${pr ? 'The pull request also gets' : 'The branch also has'} ${plural(ahead.count, 'commit')} of yours that ${ahead.count === 1 ? 'is' : 'are'} not on origin/${base} yet: ${shown}. (y/N)`
1000
+ }
1001
+
1002
+ /** Why a git push failed: the line of origin or git that says so, and whether the branch name is taken on origin. */
1003
+ export function pushFailure(stderr) {
1004
+ const lines = String(stderr ?? '').split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, ' ')).filter(Boolean)
1005
+ const rejected = lines.find((l) => /^! \[(?:remote )?rejected\]/.test(l))
1006
+ const told = lines.map((l) => /^remote:\s*(.+)$/.exec(l)?.[1]?.trim()).find((l) => l && !/^error: failed to push/i.test(l))
1007
+ const other = lines.find((l) => !/^(?:To |hint:|error: failed to push some refs)/.test(l))
1008
+ const reason = (told ?? rejected ?? other ?? 'no reason given').replace(/^(?:fatal|error):\s*/, '').slice(0, 300)
1009
+ return { reason, taken: Boolean(rejected && /\((?:fetch first|non-fast-forward|already exists)\)/.test(rejected)) }
1010
+ }
1011
+
1012
+ /**
1013
+ * The pull request step, read only: the facts of prFacts (with the network: the branches of origin) and whether gh
1014
+ * can open the pull request. null: not a git repo. {from: null}: no branch checked out.
1015
+ */
1016
+ export async function prPlan(cli, { root, env, gh = true }) {
1017
+ const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
1018
+ const facts = await prFacts(git, { env, network: true })
1019
+ if (!facts || !facts.from) return facts
1020
+ return gh ? { ...facts, gh: await ghReady(cli, facts, { root, env }) } : facts
1021
+ }
1022
+
1023
+ /** True when gh is signed in and origin is on GitHub with a base, so setup can push and open the pull request. */
1024
+ const ghReady = async (cli, facts, { root, env }) => (facts.slug && facts.base ? (await cli('gh', ['auth', 'status', '--hostname', 'github.com'], { cwd: root, env })).code === 0 : false)
1025
+
1026
+ /**
1027
+ * The pull request step. With a yes: a new branch, 1 commit of exactly the files that setup changed (never git add -A),
1028
+ * then git push and gh pr create when gh is signed in and origin is on GitHub with a base for the pull request. Without
1029
+ * gh, a remote or a base: the commit, then the push command and where to open the pull request. Never a force push.
1030
+ * paths: every path that setup changed (the manifest and its paths), relative to root. left: the titles of the steps
1031
+ * that are left, for the checklist of the pull request. environment: the one of the key check, for the link. rerun: a run
1032
+ * with nothing to change, after a no to this question earlier: it says first that the files are not committed yet.
1033
+ * key: what the pull request says about the key (see prBody). Returns {state: 'pr'|'pushed'|'committed'|'declined'|'none',
1034
+ * branch, url}.
1035
+ */
1036
+ export async function pullRequest(ui, cli, { root, env, paths, left = [], done = [], services = [], url, environment = null, rerun = false, key = null, logs = true, cloudId = false, updates = [] }) {
1037
+ const facts = await prPlan(cli, { root, env, gh: false })
1038
+ if (!facts) return { state: 'none' }
1039
+ if (!facts.from) {
1040
+ ui.print('This repo has no branch checked out, so setup does not commit. Commit the change and open a pull request yourself.')
1041
+ ui.print()
1042
+ return { state: 'none' }
1043
+ }
1044
+ const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
1045
+ const files = await changedPaths(git, paths)
1046
+ if (!files.length) return { state: 'none' }
1047
+ facts.gh = await ghReady(cli, facts, { root, env })
1048
+ if (rerun) ui.print()
1049
+ ui.print('Open a pull request')
1050
+ if (rerun) {
1051
+ const earlier = commitFiles(files, true)
1052
+ ui.print(`${earlier[0].toUpperCase()}${earlier.slice(1)}${files.includes(CLI.manifest) ? ',' : ''} ${files.length === 1 ? 'is' : 'are'} not committed yet.`)
1053
+ }
1054
+ if (!(await ui.confirm(prQuestion({ branch: facts.branch, files, pr: facts.gh, base: facts.base, ahead: facts.ahead }), !facts.ahead.count))) {
1055
+ ui.print('Nothing committed.')
1056
+ ui.print()
1057
+ return { state: 'declined' }
1058
+ }
1059
+ const r = await prRun(cli, facts, { root, env, files, body: () => prBody({ services, files, left, done, url, environment, key, logs, cloudId, updates }) }, ui.print, ui.error)
1060
+ ui.print()
1061
+ return r
1062
+ }
1063
+
1064
+ /**
1065
+ * Create the branch, commit, push and open the pull request, as pullRequest does after its yes. say(line) and
1066
+ * warn(line) get every line it prints. files: the paths of the commit. body(): the text of the pull request.
1067
+ * noPush: why setup only commits, though gh could push (a run with 1 Yes does not push commits of the user).
1068
+ * Returns {state: 'pr'|'pushed'|'committed'|'declined', branch, url, why}.
1069
+ */
1070
+ export async function prRun(cli, facts, { root, env, files, body, noPush = null }, say, warn) {
1071
+ const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
1072
+ const { from, remote, slug, base, branch, ssh } = facts
1073
+ const named = commitFiles(files)
1074
+ let r = await git(['switch', '-c', branch])
1075
+ if (r.code !== 0) r = await git(['checkout', '-b', branch])
1076
+ if (r.code !== 0) { const why = `Could not create the branch ${branch}: ${failed(r)}`; warn(why); return { state: 'declined', why } }
1077
+ r = await git(['add', '--', ...files])
1078
+ if (r.code === 0) r = await git(['commit', '-q', '-m', PULL_REQUEST.title, '-m', 'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.', '--', ...files])
1079
+ if (r.code !== 0) {
1080
+ const why = `Could not commit: ${failed(r)}`
1081
+ warn(why)
1082
+ await git(['switch', from])
1083
+ await git(['branch', '-d', branch])
1084
+ say('Commit the change yourself, then open a pull request.')
1085
+ return { state: 'declined', why }
1086
+ }
1087
+ say(`Created the branch ${branch} with 1 commit, ${PULL_REQUEST.title}. It has ${named}.`)
1088
+ const newPr = slug ? `https://github.com/${slug}/pull/new/${branch}` : null
1089
+ const here = `You are on the branch ${branch} now.`
1090
+ if (!facts.gh || noPush) {
1091
+ if (noPush) say(noPush)
1092
+ else if (remote && !base) say('Setup cannot tell which branch of origin the pull request goes to, so it does not push.')
1093
+ say(remote ? `Push it with: git push -u origin ${branch}` : `This repo has no remote named origin. Push the branch ${branch} to your git host.`)
1094
+ say(newPr ? `Then open the pull request at ${newPr}` : 'Then open a pull request there.')
1095
+ say(here)
1096
+ return { state: 'committed', branch }
1097
+ }
1098
+ // On GitHub the commit goes up through the GitHub API, so GitHub signs it, and a branch rule that requires signed
1099
+ // commits passes with no key on this computer (owner decision, 22 Oct 2026). The local branch then points at that
1100
+ // commit: the same files, so nothing changes in the folder. When the API does not take it, setup pushes as before.
1101
+ const signed = await apiCommit(git, cli, { root, env, slug, branch })
1102
+ if (signed.ok) {
1103
+ say(`Pushed ${branch} to origin as 1 commit that GitHub signed.`)
1104
+ if (!signed.local) say(`Your local branch ${branch} still has the commit made on this computer. Run git fetch origin and git reset --soft origin/${branch} to match it.`)
1105
+ r = { code: 0 }
1106
+ } else {
1107
+ if (signed.why) say(`GitHub did not take the commit through its API (${signed.why}), so setup pushes it.`)
1108
+ // If the push fails, setup prints the push command (ssh never asks for a passphrase here, see sshEnv).
1109
+ r = await git(['push', '-u', 'origin', branch], { timeoutMs: 180000, env: { ...env, ...ssh } })
1110
+ }
1111
+ if (r.code !== 0) {
1112
+ const why = pushFailure(r.error ?? r.stderr)
1113
+ warn(`Could not push the branch: ${why.reason}`)
1114
+ if (why.taken) {
1115
+ // A teammate pushed this name after setup looked: push under the next name.
1116
+ const next = nextBranch(branch)
1117
+ say(`The branch ${branch} is on origin already. Rename yours with: git branch -m ${branch} ${next}`)
1118
+ say(`Then push it with: git push -u origin ${next}`)
1119
+ say(`Then open the pull request at https://github.com/${slug}/pull/new/${next}`)
1120
+ } else {
1121
+ say(`Fix that, then push it with: git push -u origin ${branch}`)
1122
+ say(`Then open the pull request at ${newPr}`)
1123
+ }
1124
+ say(here)
1125
+ return { state: 'committed', branch, why: `Could not push the branch: ${why.reason}` }
1126
+ }
1127
+ if (!signed.ok) say(`Pushed ${branch} to origin.`)
1128
+ r = await cli('gh', ['pr', 'create', '--repo', slug, '--base', base, '--head', branch, '--title', PULL_REQUEST.title, '--body-file', '-'],
1129
+ { cwd: root, env, input: body(), timeoutMs: 120000 })
1130
+ const link = /https:\/\/\S+/.exec(r.stdout ?? '')?.[0]
1131
+ if (r.code !== 0 || !link) {
1132
+ const why = `Could not open the pull request: ${failed(r)}`
1133
+ warn(why)
1134
+ say(`Open it at ${newPr}`)
1135
+ say(here)
1136
+ return { state: 'pushed', branch, why }
1137
+ }
1138
+ say(`Opened the pull request: ${link}`)
1139
+ say(here)
1140
+ return { state: 'pr', branch, url: link }
1141
+ }
1142
+
1143
+ const COMMIT_MUTATION = 'mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }'
1144
+
1145
+ /**
1146
+ * The commit of the pull request through the GitHub API (createCommitOnBranch), which GitHub signs. The local commit is
1147
+ * HEAD and its parent is on origin. Only plain files, whose bytes on disk are the bytes of the commit (no line-ending
1148
+ * filter), can go this way: the API writes every file as a plain file. Returns {ok, local} or {ok: false, why}.
1149
+ */
1150
+ async function apiCommit(git, cli, { root, env, slug, branch }) {
1151
+ const gh = (args, more = {}) => cli('gh', args, { cwd: root, env, timeoutMs: 120000, ...more })
1152
+ const rev = async (ref) => { const r = await git(['rev-parse', '--verify', '-q', ref]); return r.code === 0 ? r.stdout.trim() : null }
1153
+ const base = await rev('HEAD~1'), head = await rev('HEAD')
1154
+ if (!base || !head) return { ok: false }
1155
+ const raw = await git(['diff', '--raw', '--no-abbrev', '--no-renames', '-z', base, head])
1156
+ if (raw.code !== 0) return { ok: false }
1157
+ const parts = raw.stdout.split('\0').filter((x) => x !== '')
1158
+ const additions = [], deletions = []
1159
+ for (let i = 0; i + 1 < parts.length; i += 2) {
1160
+ const [src, dst, , blob, status] = parts[i].replace(/^:/, '').split(' ')
1161
+ const path = parts[i + 1]
1162
+ if (status === 'D') { deletions.push({ path }); continue }
1163
+ if (dst !== '100644' || !['100644', '000000'].includes(src)) return { ok: false, why: `${path} is not a plain file` }
1164
+ const disk = await git(['hash-object', '--no-filters', '--', path])
1165
+ if (disk.code !== 0 || disk.stdout.trim() !== blob) return { ok: false, why: `${path} changes when git writes it` }
1166
+ additions.push({ path, contents: readFileSync(join(root, path)).toString('base64') })
1167
+ }
1168
+ if (!additions.length && !deletions.length) return { ok: false }
1169
+ let r = await gh(['api', '-X', 'POST', `repos/${slug}/git/refs`, '-f', `ref=refs/heads/${branch}`, '-f', `sha=${base}`])
1170
+ if (r.code !== 0) return { ok: false, why: failed(r) }
1171
+ const input = { branch: { repositoryNameWithOwner: slug, branchName: branch }, expectedHeadOid: base,
1172
+ message: { headline: PULL_REQUEST.title, body: 'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.' },
1173
+ fileChanges: { additions, deletions } }
1174
+ r = await gh(['api', 'graphql', '--input', '-'], { input: JSON.stringify({ query: COMMIT_MUTATION, variables: { input } }) })
1175
+ const oid = parse(r.stdout ?? '')?.data?.createCommitOnBranch?.commit?.oid
1176
+ if (r.code !== 0 || !oid) {
1177
+ await gh(['api', '-X', 'DELETE', `repos/${slug}/git/refs/heads/${branch}`])
1178
+ return { ok: false, why: failed(r) }
1179
+ }
1180
+ // The local branch takes the signed commit. Its tree is the tree of the local commit, so only HEAD moves.
1181
+ r = await git(['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { timeoutMs: 180000 })
1182
+ const same = r.code === 0 && (await rev(`refs/remotes/origin/${branch}^{tree}`)) === (await rev('HEAD^{tree}'))
1183
+ if (same) r = await git(['reset', '-q', '--soft', `refs/remotes/origin/${branch}`])
1184
+ if (same && r.code === 0) await git(['branch', '-q', `--set-upstream-to=origin/${branch}`])
1185
+ return { ok: true, oid, local: same && r.code === 0 }
1186
+ }
1187
+
1188
+ /** The paths that git shows as changed and not committed, of the paths given (the files of setup). */
1189
+ export function changedFiles(cli, { root, env, paths }) {
1190
+ return changedPaths((args) => cli('git', ['-C', root, ...args], { cwd: root, env }), paths)
1191
+ }
1192
+
1193
+ /**
1194
+ * What the last line and the pull request say about the key when it may not be stored. 'left': this run did not store
1195
+ * it (a skipped key check, a no to the store question, a store that failed), and the key steps printed. A deploy without
1196
+ * the key sends nothing, so the app never connects (F-012-153). 'unknown': a rerun, which does not ask for the key.
1197
+ * .uknows/setup.json lists only the files that setup changed, so the rerun cannot tell if an earlier run stored the key.
1198
+ */
1199
+ export const KEY_NOTE = {
1200
+ left: {
1201
+ line: 'Your key is not stored yet. Store it before you merge and deploy: run npx @uknows/setup key, or do the key steps above.',
1202
+ pr: 'Your key is not stored yet. Store it before you merge and deploy: run npx @uknows/setup key in the repo, or do the key steps below.',
1203
+ },
1204
+ unknown: {
1205
+ line: 'Setup cannot tell if your key is stored. If it is not stored yet, run npx @uknows/setup key before you merge and deploy.',
1206
+ pr: `Setup cannot tell from ${CLI.manifest} if your key is stored. If it is not stored yet, run npx @uknows/setup key in the repo before you merge and deploy.`,
1207
+ },
1208
+ }
1209
+
1210
+ /**
1211
+ * The text of the pull request: what it does, the files, and the checklist of what is left. Never the key. key: null when
1212
+ * the key is stored or not asked for, else 'left' or 'unknown' (KEY_NOTE): a line on the key comes first in Left to do.
1213
+ * updates: how the agent stays up to date and why (updates.mjs), 1 paragraph each. Without them (an older record), the
1214
+ * text says what the update bot files in the change do.
1215
+ */
1216
+ export function prBody({ services = [], files = [], left = [], done = [], url, environment = null, key = null, logs = true, cloudId = false, updates = [] }) {
1217
+ const names = services.length ? list(services) : 'this app'
1218
+ const dependabot = files.some((f) => /(^|\/)\.github\/workflows\/uknows-updates\.yml$/.test(f))
1219
+ const renovate = files.includes('renovate.json')
1220
+ const hookFiles = files.filter((f) => HOOK_FILES.includes(f.split('/').pop()))
1221
+ return [
1222
+ `Adds OpenTelemetry to ${names}, so ${services.length > 1 ? 'they send' : 'it sends'} ${logs ? 'traces and logs' : 'traces'} to UKNOWS.`,
1223
+ '',
1224
+ ...updates.flatMap((line) => [line, '']),
1225
+ ...(!updates.length && renovate ? ['Adds an update bot too. Renovate proposes the UKNOWS and OpenTelemetry updates in 1 pull request, 3 days after each release, and merges it when your build check passes. Where your branch rules ask for a review, it waits for that review.', ''] : []),
1226
+ ...(!updates.length && dependabot ? ['Adds an update bot too. Dependabot proposes OpenTelemetry updates every day. Each update merges when your build check passes, then deploys. Where your branch rules ask for a review, it waits for that review.', ''] : []),
1227
+ // host.json changes only for Azure Functions (stacks/functions.mjs), the hook files only for its worker hooks.
1228
+ ...(files.some((f) => /(^|\/)host\.json$/.test(f)) ? [FUNCTIONS.pr, ''] : []),
1229
+ ...(hookFiles.length ? [FUNCTIONS.hooks.pr.replace('{files}', hookFiles.join(', ')), '',
1230
+ ...(hookFiles.some((f) => f.endsWith('.py')) ? [FUNCTIONS.hooks.prPython, ''] : [])] : []),
1231
+ ...(cloudId ? [`Your deploy now also sets ${RESOURCE_ATTRIBUTES} with the Azure resource id of the app, so UKNOWS knows which app in Azure the data comes from. Attributes that your app sets there already stay as they are.`, ''] : []),
1232
+ 'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.',
1233
+ '',
1234
+ '## Files',
1235
+ ...files.map((f) => `- ${f}`),
1236
+ '',
1237
+ '## Left to do',
1238
+ ...(KEY_NOTE[key] ? [KEY_NOTE[key].pr, ''] : []),
1239
+ ...left.map((title) => `- [ ] ${title}`),
1240
+ '- [ ] Merge this pull request and deploy as usual.',
1241
+ url ? `- [ ] ${watchLine([url], environment).replace(/^Then w/, 'W')}` : '- [ ] Watch your app connect in UKNOWS.',
1242
+ '',
1243
+ ...(done.length ? ['## Done', ...done.map((d) => `- [x] ${d}`), ''] : []),
1244
+ 'To see the steps again, run npx @uknows/setup --dry-run in the repo.',
1245
+ '',
1246
+ ].join('\n')
1247
+ }
1248
+
1249
+ /**
1250
+ * The last line: what is left, then the link to the page where the app connects (environment: of the key check). key:
1251
+ * null when the key is stored or not asked for, else 'left' or 'unknown' (KEY_NOTE): the line says it before the merge.
1252
+ */
1253
+ export function lastLine({ state, required = 0, urls = [], environment = null, key = null }) {
1254
+ const action = state === 'pr' ? 'Merge the pull request and deploy as usual.'
1255
+ : state === 'pushed' ? 'Open the pull request, merge it and deploy as usual.'
1256
+ : state === 'committed' ? 'Push the branch, open a pull request, merge it and deploy as usual.'
1257
+ : 'Commit, open a pull request, merge it and deploy as usual.'
1258
+ const steps = required ? `Do the ${plural(required, 'step')} above first. ` : ''
1259
+ const stored = KEY_NOTE[key] ? `${KEY_NOTE[key].line} ` : ''
1260
+ return `${steps}${stored}${action} ${watchLine(urls, environment)}`
1261
+ }
1262
+
1263
+ /**
1264
+ * The questions that a run in a terminal asks after Apply, for the dry run. From the repo and this computer only: no
1265
+ * cloud CLI and no network. Each is {question, after}: after is the question that comes after a no to it. A store
1266
+ * question only where 1 app is known and the run has 1 service (storeAzure). The GitHub secret is a question of its
1267
+ * own when no store on the platform is planned or a workflow reads it already, else it comes after a no.
1268
+ * pr: prFacts of the repo without the network (null or no branch: no pull request question). files: the paths that a
1269
+ * real run commits. services: how many services the run sets up. Returns {questions, hosts}: hosts are the platforms
1270
+ * whose key step a question does, so the dry run can print those steps after the questions, as the steps by hand.
1271
+ */
1272
+ export function plannedQuestions({ targets, edits = [], files = [], pr = null, services = 1 }) {
1273
+ const stores = []
1274
+ for (const [host, t] of targets) {
1275
+ if (t.kind === 'aca' || t.kind === 'webapp') {
1276
+ const apps = pickApp(t.apps.map((a) => a.name))
1277
+ if (apps.length !== 1 || services > 1 || (t.kind === 'webapp' && t.slots?.length)) continue
1278
+ const groups = unique(t.groups.map((g) => g.name))
1279
+ stores.push({ host, question: `Store this key on ${apps[0]}${groups.length === 1 ? ` in ${groups[0]}` : ''}? ${AZURE[t.kind].effect} (y/N)` })
1280
+ } else if (t.kind === 'k8s') {
1281
+ const spaces = unique(t.workloads.map((w) => w.namespace))
1282
+ if (spaces.length === 1 && spaces[0]) stores.push({ host, question: `Store this key as the secret ${K8S_SECRET} in the namespace ${spaces[0]}? Pods that start after this get the key. (y/N)` })
1283
+ }
1284
+ }
1285
+ const out = [...stores]
1286
+ const gh = [...targets.values()].find((t) => t.kind === 'github' && t.repo)
1287
+ const hosts = stores.map((q) => q.host)
1288
+ if (gh) {
1289
+ const s = githubSecret(gh.jobs)
1290
+ const question = s.why ? null : s.env === null ? `Store this key as the repository secret ${GITHUB_SECRET} of ${gh.repo}? Every workflow of the repo can read it. (y/N)`
1291
+ : `Store this key as the secret ${GITHUB_SECRET} of its GitHub environment in ${gh.repo}? (y/N)`
1292
+ if (question && (gh.readsKey || !stores.length)) out.push({ question })
1293
+ else if (question) stores.at(-1).after = question
1294
+ if (question) hosts.push('github-actions')
1295
+ }
1296
+ for (const e of edits) out.push({ question: e.question })
1297
+ if (pr?.from && files.length) out.push({ question: prQuestion({ branch: pr.branch, files, pr: Boolean(pr.slug && pr.base), base: pr.base, ahead: pr.ahead }) })
1298
+ return { questions: out, hosts: unique(hosts) }
1299
+ }