@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/engine.mjs CHANGED
@@ -2,48 +2,229 @@
2
2
  * The setup and remove flows. Everything that reads the repo, runs a
3
3
  * program or asks the user comes in through deps, so tests can use fakes.
4
4
  * Each flow returns {code, report}: the exit code and the --json report.
5
+ *
6
+ * Exit codes describe the repo, never the key (README "Exit codes"):
7
+ * 0 applied, already set up, or nothing to do. Only the key steps on the platform are left. A repo whose manifest
8
+ * lists every service and that has nothing to change is set up (status nothing-to-change), also with steps left
9
+ * that the repo cannot show. With no manifest and only required steps, the status is needs-steps (exit 3).
10
+ * 1 stopped or failed, and nothing changed: dirty tree, key in a file, other APM, no app found, a failed command.
11
+ * 2 needs input, and nothing changed: usage, a service to pick, no terminal and no --yes, a pipeline without --allow-ci.
12
+ * 3 applied or planned, but required repo steps remain. Only blocks with required !== false count.
13
+ *
14
+ * Each service gets a mode before anything is planned (existing.mjs classify): standard, otel, azure-monitor
15
+ * or stop. With --channel (or a channel recorded in .uknows/setup.json), a standard Java, .NET or Node.js service
16
+ * with a Dockerfile gets the channel mode instead: the agent comes from the UKNOWS agent image (stacks/channel.mjs).
17
+ * A stop in any service stops the whole run. Lock refreshes (uv lock, poetry lock, dotnet restore
18
+ * --force-evaluate) are commands of kind 'lock': the manifest records the lockfile they rewrite as an ordinary text
19
+ * change, so remove gives back every byte without running the tool again. They run after the edits, and any failure
20
+ * puts every file back: a resolver that finds no solution, a lockfile format line that changed (keep), or a restore
21
+ * whose obj/project.assets.json needs a package the plan did not add (verify).
5
22
  */
6
- import { spawn as spawnChild } from 'node:child_process'
23
+ import { spawn as spawnChild, spawnSync } from 'node:child_process'
7
24
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
8
25
  import { dirname, sep } from 'node:path'
9
- import { runCheck } from './check.mjs'
10
- import { CLI, EXAMPLE_SERVICE, INGEST_ENDPOINT, SECRET } from './contract.mjs'
26
+ import { PUT, automates, changedFiles, connectUrl, deployEdits, deployFilesInTerminal, githubSecret, githubStepBlock, githubStepLines, k8sLoadsKey, keyStepsInTerminal,
27
+ keyTargets, lastLine, notCommitted, openBrowser, openChecks, pickApp, planKeyStores, plannedQuestions, prBody, prFacts, prPlan, prRun, pullRequest, runProgram,
28
+ storeName, storeRows, watchLine } from './automate.mjs'
29
+ import { NEW_KEY, parseHeaders, request, runCheck } from './check.mjs'
30
+ import { ciVariable } from './ci.mjs'
31
+ import { PREDICT, cloudIdFacts, cloudIdLookups, cloudIdPlan, cloudIdSpots } from './cloudid.mjs'
32
+ import { APP_INSIGHTS_SETTINGS, appInsightsSigns, functionAppSettingNames, hostJsonEdit } from './stacks/functions.mjs'
33
+ import { APP_SERVICE_CODE, CI_CD, CLI, FUNCTIONS, HOSTS, INGEST_ENDPOINT, RESOLVE, SECRET, SETUP_NEXT, SETUP_STATUS, STACKS, keyCheckLine, settingsSnippet } from './contract.mjs'
11
34
  import { detect } from './detect.mjs'
12
- import { docsUrl, llmsUrl } from './endpoint.mjs'
35
+ import { appUrl, docsUrl, llmsUrl, nextData } from './endpoint.mjs'
36
+ import { askOnce, fileRows, printPlan as printOnePlan, step } from './plan.mjs'
37
+ import { REAL_CLOCK, SIGN_IN, signIn } from './signin.mjs'
13
38
  import { countLines, unified } from './diff.mjs'
14
39
  import { shellWords } from './doc.mjs'
15
- import { explainApm, findExisting } from './existing.mjs'
16
- import { gitStatus } from './git.mjs'
17
- import { keySteps } from './hosts.mjs'
40
+ import { classify, existingNotes, explainApm, explainStop, findExisting } from './existing.mjs'
41
+ import { gitHost, gitIgnored, gitRemote, gitStatus } from './git.mjs'
42
+ import { deployNames, keySteps } from './hosts.mjs'
18
43
  import { BOM, TOOL, VERSION, changeEntry, manifestPaths, manifestText, mergeManifest, readManifest, sha256, undoChange } from './manifest.mjs'
19
44
  import { hasKey, redactKey, settingsPlan } from './settings.mjs'
45
+ import { lockedMode, verifyRestore, webProject } from './stacks/dotnet.mjs'
46
+ import { channelMisfit } from './stacks/channel.mjs'
47
+ import { updatesPlan } from './updates.mjs'
20
48
  import { APPLIERS } from './stacks/index.mjs'
49
+ import { otelNotes } from './stacks/otel.mjs'
21
50
  import { UsageError, frameworkLabel, hostLabel, list, plural, stackLabel, unique } from './ui.mjs'
22
51
  import { createWorkspace, repoView } from './workspace.mjs'
23
52
 
24
- const DEFAULTS = { detect, findExisting, explainApm, settingsPlan, keySteps, appliers: APPLIERS, spawn: runCommand, gitStatus, createWorkspace, runCheck }
53
+ const DEFAULTS = { detect, findExisting, classify, explainApm, explainStop, settingsPlan, keySteps, deployNames, appliers: APPLIERS, spawn: runCommand,
54
+ probe: probeTool, verify: verifyRestore, gitStatus, gitIgnored, gitRemote, gitHost, createWorkspace, runCheck, cli: runProgram, openUrl: openBrowser,
55
+ signIn, request, clock: REAL_CLOCK }
25
56
  /** The language menu, in this order, then 'Something else'. */
26
57
  const MENU = ['node', 'python', 'java', 'dotnet', 'go', 'php', 'ruby', 'otel']
27
- const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'])
58
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'uv.lock', 'poetry.lock', 'pdm.lock', 'packages.lock.json'])
59
+ /** Local tools the plan can need, probed once before planning: name -> files in the repo that need it. */
60
+ const TOOLS = { uv: /(?:^|\/)uv\.lock$/, poetry: /(?:^|\/)poetry\.lock$/ }
28
61
  const DIFF_LIMIT = 200
29
62
  /** On Windows, package managers run through a shell (.cmd files), so every argument must be plain. */
30
63
  const SAFE_ARG = /^[@A-Za-z0-9._/=:~+-]+$/
64
+ /** The output of a command that the engine keeps, to say why a resolver failed. */
65
+ const OUTPUT_LIMIT = 65536
31
66
 
32
67
  /**
33
- * @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean}} opts
68
+ * @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean,
69
+ * allowCi?: boolean, env?: Record<string, string|undefined>, endpoint?: string}} opts
34
70
  * @param {Partial<typeof DEFAULTS> & {ui: ReturnType<typeof import('./ui.mjs').createUI>}} deps
35
71
  */
36
72
  export async function runSetup(opts, deps) {
37
73
  const d = { ...DEFAULTS, ...deps }
38
74
  const report = { schema: 1, tool: TOOL, version: VERSION, command: 'setup', status: 'failed', exitCode: 1,
39
- services: [], files: [], hosts: [], keySteps: [], check: null, problems: [], next: CLI.next }
75
+ services: [], files: [], hosts: [], keySteps: [], check: null, problems: [], next: nextData(opts.env), ci: Boolean(ciVariable(opts.env)) }
40
76
  return guard(d.ui, report, () => setup(opts, d, report))
41
77
  }
42
78
 
43
- /** @param {{root: string, yes?: boolean, json?: boolean, force?: boolean}} opts */
79
+ /**
80
+ * npx @uknows/setup key: paste the key of another environment (for example Production), check it, and store it on the
81
+ * platform of an app that setup set up, as the key steps of a setup run do. Only in a terminal. It changes no file.
82
+ * Exit codes: 0 stored, or its steps printed; 1 not set up yet, a refused key or a key that could not be checked;
83
+ * 2 no terminal (a pipeline, --json, a pipe) or no key given.
84
+ * @param {{root: string, env?: Record<string, string|undefined>}} opts
85
+ */
86
+ export async function runKey(opts, deps) {
87
+ const d = { ...DEFAULTS, ...deps }
88
+ const report = { schema: 1, tool: TOOL, version: VERSION, command: 'key', status: 'failed', exitCode: 1, check: null, stored: [], problems: [],
89
+ ci: Boolean(ciVariable(opts.env)) }
90
+ return guard(d.ui, report, () => key(opts, d, report))
91
+ }
92
+
93
+ async function key(opts, d, report) {
94
+ const { ui } = d, root = opts.root, env = opts.env ?? {}
95
+ ui.print('UKNOWS key')
96
+ ui.print()
97
+ if (!automates({}, ui, env)) {
98
+ ui.print('Run npx @uknows/setup key in a terminal. It asks for your key, so it does not run in a pipeline or without a terminal. Nothing changed.')
99
+ return ['needs-terminal', 2]
100
+ }
101
+ const manifest = readManifest(root)
102
+ if (!manifest?.services.length) {
103
+ ui.print(`This folder is not set up yet: it has no ${CLI.manifest}. Run npx @uknows/setup first. It sets up your app and stores the key of your first environment.`)
104
+ return ['not-set-up', 1]
105
+ }
106
+ ui.print('This checks the key of 1 environment, for example Production, and stores it where your app runs. It changes no file in your repo.')
107
+ const detection = d.detect(root)
108
+ const repo = repoView(d.createWorkspace(root))
109
+ const names = d.deployNames(repo, detection.hosts)
110
+ const slug = d.gitRemote(root)
111
+ const plans = manifest.services.map((m) => ({ name: m.name, s: detection.services.find((s) => s.dir === m.dir) ?? { dir: m.dir, k8s: [] } }))
112
+ // Apps that keep their own metrics and logs got the traces-only settings: their key goes in the traces-only variable.
113
+ const tracesOnly = manifest.changes.some((c) => c.hunks.some((h) => h.after.some((l) => l.includes('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')))
114
+ || (c.created && (repo.read(c.file) ?? '').includes('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')))
115
+ const secretName = tracesOnly ? TRACES_HEADERS : SECRET.name
116
+ // The key of this terminal is often the one of the first environment: a no to it goes on to the paste question.
117
+ const app = { name: plans.map((p) => p.name).join(', '), repo: slug ?? undefined, platform: platformOf(detection.hosts) }
118
+ const checked = await checkKey(ui, d, env, false, plans[0].name, { paste: true, pasteKey: opts.pasteKey, app })
119
+ if (checked?.stopped && checked.interrupt) { ui.print('Stopped. Nothing changed.'); return ['cancelled', 130] }
120
+ report.check = checked?.entry ?? null
121
+ const line = keyCheckLine(checkVars(env))
122
+ if (!checked || checked.stopped) {
123
+ ui.print('No key given. Nothing changed.')
124
+ return ['no-key', 2]
125
+ }
126
+ if (checked.notReady) {
127
+ ui.print(NEW_KEY.againKey)
128
+ return ['key-refused', 1]
129
+ }
130
+ if (report.check.status !== 'reached') {
131
+ ui.print(report.check.status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network)
132
+ ui.print(` ${line}`)
133
+ ui.print('Then run npx @uknows/setup key again.')
134
+ return [report.check.status === 'rejected' ? 'key-refused' : 'network-error', 1]
135
+ }
136
+ const blocks = d.keySteps(detection.hosts, { view: repo, names, repo: slug, service: plans.length === 1 ? plans[0].name : undefined, secretName })
137
+ report.keySteps = blocks
138
+ const targets = keyTargets({ hosts: detection.hosts, names, view: repo, plans, repo: slug })
139
+ const environment = report.check.environment ?? null
140
+ const steps = await keyStepsInTerminal(ui, d.cli, blocks, targets, { key: bareKey(checked.headers), environment, env, root, secretName,
141
+ service: plans.length === 1 ? plans[0].name : null, services: plans.map((p) => p.name) })
142
+ report.stored = steps.stored.map((s) => s.done)
143
+ // The deploy files decide if the key stays: the checks that are open, the key step of a GitHub secret, and envFrom
144
+ // for a Kubernetes secret. key changes no file, so it prints them.
145
+ const rest = openChecks(blocks, { stored: steps.stored.map((s) => s.host), skipped: steps.skipped, secretName })
146
+ if (steps.github) rest.unshift(githubStepBlock(steps.github))
147
+ const k8s = steps.stored.some((s) => s.host === 'kubernetes') && !k8sLoadsKey(repo, plans)
148
+ if (k8s) rest.unshift(loadKeyBlock(blocks))
149
+ for (const block of rest) ui.block(block)
150
+ ui.print('Never put the key in a file in your repo.')
151
+ ui.print()
152
+ // A deploy file that drops the key (--replace-env-vars, environmentVariables) or a deployment without envFrom.
153
+ const drops = steps.github || k8s || deployEdits(repo, detection.hosts, plans, secretName).length > 0
154
+ const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
155
+ const next = !steps.stored.length ? 'Do the steps above, then deploy as usual.'
156
+ : drops ? 'Your deploy files do not keep the key yet. Do the steps above, then deploy as usual.' : 'Your app gets the key when it starts again or with your next deploy.'
157
+ ui.print(`${next} ${watchLine(urls, environment)}`)
158
+ if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
159
+ return [steps.stored.length ? 'stored' : 'steps', 0]
160
+ }
161
+
162
+ /**
163
+ * The app settings step of an Azure Functions app (settings.mjs), with the function app and resource group that the
164
+ * deploy files name. With more than 1 function app, the first one, as in the key script.
165
+ */
166
+ function functionsSettingsNames(block, p, names) {
167
+ if (block.title !== FUNCTIONS.settings.title) return block
168
+ const fn = names.functions ?? { apps: [], groups: [] }
169
+ const group = new Set(fn.groups.map((g) => g.name)).size === 1 ? fn.groups[0] : null
170
+ const scriptNames = { ...(fn.apps[0] ? { app: fn.apps[0].name } : {}), ...(group ? { resourceGroup: group.name } : {}),
171
+ from: { ...(fn.apps[0] ? { app: fn.apps[0].where } : {}), ...(group ? { resourceGroup: group.where } : {}) } }
172
+ return { ...block, snippets: [settingsSnippet('azure-functions', p.settings.pairs ?? [], p.name, scriptNames)] }
173
+ }
174
+
175
+ /**
176
+ * How setup turns on OpenTelemetry for an Azure Functions app (stacks/functions.mjs): {route, signs, unknown,
177
+ * predicted}. route 'host': the line in host.json, when setup knows that the app does not send to Application Insights
178
+ * (a run in a terminal reads the names of the app settings of each function app that the deploy files name, and no
179
+ * deploy file sets the connection string), or with --functions-opentelemetry. route 'hooks': the UKNOWS worker hooks,
180
+ * which keep Application Insights exactly as it is, when the app sends to it or setup cannot tell. A dry run in a
181
+ * terminal shows the plan of a real run that finds no Application Insights (predicted). host.json that has the line
182
+ * already needs no decision.
183
+ */
184
+ async function functionsDecision(s, it, { d, opts, ui, env, repo, detection, root }) {
185
+ if (!hostJsonEdit(repo.read(s.functions.hostJson)).after) return { route: 'host', signs: [] }
186
+ if (opts.functionsOpenTelemetry) return { route: 'host', signs: [] }
187
+ const signs = appInsightsSigns(s, repo, it.existing)
188
+ const deploys = signs.filter((x) => x.deploy)
189
+ if (automates(opts, ui, env)) {
190
+ const names = d.deployNames(repo, detection.hosts).functions ?? { apps: [], groups: [] }
191
+ const group = new Set(names.groups.map((g) => g.name)).size === 1 ? names.groups[0].name : null
192
+ const lookups = [...new Set(names.apps.map((a) => a.name))].map((app) => ({ app, group }))
193
+ const look = ui.progress('Looking at the app settings of your function app in Azure')
194
+ const facts = await functionAppSettingNames(d.cli, { root, env }, lookups)
195
+ look.end(null)
196
+ const platform = [...(facts ?? new Map())].flatMap(([app, list]) => (list ?? []).filter((n) => APP_INSIGHTS_SETTINGS.includes(n))
197
+ .map((n) => ({ file: `the app settings of ${app}`, what: n, deploy: false })))
198
+ const found = [...platform, ...deploys]
199
+ if (found.length) return { route: 'hooks', signs: found }
200
+ const known = Boolean(facts) && lookups.length > 0 && [...facts.values()].every((list) => Array.isArray(list))
201
+ return known ? { route: 'host', signs: [] } : { route: 'hooks', signs, unknown: true }
202
+ }
203
+ if (opts.dryRun && ui.interactive && !opts.json && !ciVariable(env) && !deploys.length) return { route: 'host', signs: [], predicted: true }
204
+ return deploys.length ? { route: 'hooks', signs: deploys } : { route: 'hooks', signs, unknown: true }
205
+ }
206
+
207
+ /** The platform of the app for the sign-in page: where it runs, not the pipeline that deploys it. */
208
+ const platformOf = (hosts) => hosts.find((h) => h.id !== 'github-actions')?.id ?? hosts[0]?.id
209
+
210
+ /** The step that loads a Kubernetes secret: envFrom in each deployment, from the snippets of the Kubernetes block. */
211
+ /**
212
+ * True when every service of the run sends its logs to UKNOWS: the standard settings and the agent channel set
213
+ * OTEL_LOGS_EXPORTER=otlp. An app that keeps its own OpenTelemetry or Azure Monitor keeps its logs where they go now.
214
+ */
215
+ const sendsLogs = (plans) => plans.length > 0 && plans.every((p) => p.mode === 'standard' || p.mode === 'channel')
216
+
217
+ function loadKeyBlock(blocks) {
218
+ const k8s = blocks.find((b) => b.title === `Set your key once: ${HOSTS.find((h) => h.id === 'kubernetes').label}`)
219
+ return { title: 'Load the key in your deployment', lines: ['Add envFrom to each deployment, so new pods get the key. Commit this change.'],
220
+ snippets: (k8s?.snippets ?? []).filter((x) => /envFrom/.test(x.code) && !/kubectl/.test(x.code)) }
221
+ }
222
+
223
+ /** @param {{root: string, yes?: boolean, json?: boolean, force?: boolean, allowCi?: boolean, env?: Record<string, string|undefined>}} opts */
44
224
  export async function runRemove(opts, deps) {
45
225
  const d = { ...DEFAULTS, ...deps }
46
- const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: [] }
226
+ const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: [],
227
+ ci: Boolean(ciVariable(opts.env)) }
47
228
  return guard(d.ui, report, () => remove(opts, d, report))
48
229
  }
49
230
 
@@ -91,7 +272,14 @@ async function setup(opts, d, report) {
91
272
  ui.print(`Found ${services.length} services:`)
92
273
  for (const s of services) ui.print(` ${s.dir}: ${describe(s)}`)
93
274
  ui.print()
94
- if (!opts.all) {
275
+ // A pipeline check (a plan with no terminal) of a repo that setup set up plans the services that it lists.
276
+ const listed = manifest ? services.filter((s) => manifest.services.some((m) => m.dir === s.dir)) : []
277
+ const planOnly = opts.dryRun || (opts.json && !opts.yes)
278
+ if (!opts.all && !ask && planOnly && listed.length) {
279
+ services = listed
280
+ ui.print(fillIn(SETUP_STATUS.recorded, { count: plural(listed.length, 'service'), manifest: CLI.manifest }))
281
+ ui.print()
282
+ } else if (!opts.all) {
95
283
  if (!ask) {
96
284
  report.services = services.map((s) => serviceReport(s))
97
285
  ui.print(`Pick the services to set up. Use --all to set up all of them, or --path <folder> to set up 1. Or run this in a terminal${opts.yes ? ' without --yes' : ''} to pick from a list.`)
@@ -115,9 +303,10 @@ async function setup(opts, d, report) {
115
303
  }
116
304
  services = services.filter((s) => !frontend(s))
117
305
  if (!services.length) {
306
+ // A frontend needs nothing from UKNOWS, so this is not a failure.
118
307
  report.services = detection.services.map((x) => serviceReport(x))
119
308
  ui.print(`Run this tool in the folder of your server app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
120
- return ['not-found', 3]
309
+ return ['nothing-to-change', 0]
121
310
  }
122
311
  ui.print()
123
312
  }
@@ -129,66 +318,149 @@ async function setup(opts, d, report) {
129
318
  ui.print(`No app found in ${where(s.dir)}.`)
130
319
  ui.print(`Run this tool in the folder of your app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
131
320
  report.services = services.map((x) => serviceReport(x))
132
- return ['not-found', 3]
321
+ return ['not-found', 1]
133
322
  }
134
323
  const pick = await ui.choose(`No app found in ${where(s.dir)}. What does your app use?`, [...MENU.map(stackLabel), 'Something else'])
135
324
  if (pick === MENU.length) {
136
325
  ui.print(`To set up UKNOWS by hand, see ${DOCS_URL}`)
137
326
  ui.print(`AI coding tools can read the same steps at ${LLMS_URL}`)
138
327
  report.services = services.map((x) => serviceReport(x))
139
- return ['not-found', 3]
328
+ return ['not-found', 1]
140
329
  }
141
330
  ui.print()
142
331
  chosen.set(s.dir, MENU[pick])
143
332
  if (MENU[pick] !== 'otel') s.lang = MENU[pick]
144
333
  }
145
334
 
146
- // Another APM agent in any service stops the whole run before anything is planned.
147
- const items = services.map((s) => ({ s, name: opts.serviceName || s.name, existing: d.findExisting(s, ws) }))
148
- report.services = items.map((it) => serviceReport(it.s, { name: it.name, otel: it.existing.otel, apm: it.existing.apm }))
149
- const blocked = items.filter((it) => it.existing.apm.length)
335
+ // A mode for each service before anything is planned. A stop in any service stops the whole run.
336
+ const items = services.map((s) => {
337
+ const existing = d.findExisting(s, repo)
338
+ const cls = d.classify(existing, { lang: s.lang, hosts: detection.hosts })
339
+ const recorded = manifest?.services.find((m) => m.dir === s.dir)
340
+ // A service that setup already did keeps its mode: its own packages must not turn it into an otel app.
341
+ let mode = cls.mode === 'stop' ? 'stop' : recorded ? recorded.mode ?? (recorded.stack === 'otel' ? 'otel' : 'standard') : s.mode ?? cls.mode
342
+ // The agent channel: asked for in this run, recorded by an earlier one, or the default (updates.mjs, step 1) for a
343
+ // service that setup did not set up before. Only for a standard service that fits; never for an Azure Functions app.
344
+ const fallback = !recorded && !s.functions ? opts.defaultChannel ?? null : null
345
+ const channel = (recorded?.mode === 'channel' ? recorded.channel : null) ?? opts.channel ?? fallback
346
+ const misfit = channel && mode === 'standard' ? channelMisfit(s) : null
347
+ if (channel && mode === 'standard' && !misfit) mode = 'channel'
348
+ return { s, name: opts.serviceName || s.name, existing, cls, mode, channel: mode === 'channel' ? channel : null,
349
+ channelDefault: mode === 'channel' && channel === fallback && !opts.channel,
350
+ channelNote: misfit && opts.channel ? `${misfit} So ${s.dir === '.' ? 'this service' : s.dir} gets the packages instead.` : null }
351
+ })
352
+ report.services = items.map((it) => serviceReport(it.s, { name: it.name, mode: it.mode, otel: it.existing.otel, apm: it.existing.apm ?? [] }))
353
+ const blocked = items.filter((it) => it.mode === 'stop')
150
354
  if (blocked.length) {
151
- for (const it of blocked) ui.block(d.explainApm(it.s, it.existing.apm))
355
+ for (const it of blocked) ui.block(it.cls.reason === 'apm' ? d.explainApm(it.s, it.existing.apm) : d.explainStop(it.s, it.existing, it.cls))
152
356
  ui.print('Nothing changed.')
153
357
  return ['refused', 1]
154
358
  }
359
+ const tools = probeTools(d, repo, items)
155
360
 
156
361
  // Plan each service. All edits go into 1 workspace, so each step reads the edits before it.
157
362
  const plans = []
158
363
  for (const it of items) {
159
364
  const { s } = it
160
- const stack = known(s) ?? chosen.get(s.dir) ?? (it.existing.otel.length ? 'otel' : s.lang)
161
- const applier = Object.prototype.hasOwnProperty.call(d.appliers, stack) ? d.appliers[stack] : null
365
+ const stack = known(s) ?? chosen.get(s.dir) ?? (it.mode === 'otel' ? 'otel' : s.lang)
366
+ // How the tool sets up this service: the classified mode, or otel for a stack picked as 'otel' from the menu.
367
+ const mode = stack === 'otel' ? 'otel' : it.mode
368
+ // An Azure Functions app (standard, or with App Insights in its code): the Functions host sends the data, or setup
369
+ // stands down (stacks/functions.mjs).
370
+ const fnApp = s.functions && (mode === 'standard' || mode === 'azure-monitor')
371
+ const id = fnApp ? 'functions' : mode === 'azure-monitor' ? 'azure' : mode === 'channel' ? 'channel' : stack
372
+ const applier = Object.prototype.hasOwnProperty.call(d.appliers, id) ? d.appliers[id] : null
162
373
  if (!applier) throw new Error(`This tool has no steps for ${stack}.`)
163
374
  report.problems.push(...s.problems)
164
375
  const ambiguous = s.problems.find((x) => x.code === 'dockerfile-ambiguous')
165
376
  if (ambiguous) {
166
377
  // Editing the wrong Dockerfile changes nothing that ships: say so, and change nothing for this service.
167
- const manual = [{ title: 'Pick the Dockerfile you deploy', lines: [ambiguous.message, 'Then run npx @uknows/setup again.'] }]
168
- plans.push({ ...it, stack, plan: { summary: [], edits: [], commands: [], extraSettings: [], manual, notes: [] },
169
- settings: { target: null, edits: [], summary: [], manual: [], notes: [] }, manual, notes: [] })
378
+ const manual = [{ title: 'Pick the Dockerfile you deploy', verify: true, lines: [ambiguous.message, 'Then run npx @uknows/setup again.'] }]
379
+ plans.push({ ...it, stack, mode, plan: { summary: [], edits: [], commands: [], extraSettings: [], manual: manual.map(tidy), notes: [] },
380
+ settings: { target: null, edits: [], summary: [], manual: [], notes: [] }, manual: manual.map(tidy), notes: [] })
170
381
  continue
171
382
  }
172
- if ((stack === 'node' || stack === 'python') && !s.start && !s.dockerfile && ask) {
383
+ // The Functions host starts a function app: no start command to ask for.
384
+ if ((stack === 'node' || stack === 'python') && !s.start && !s.dockerfile && !s.functions && ask) {
173
385
  const raw = await ui.ask('What command starts this app now? For example: node server.js. Press Enter to skip.')
174
386
  if (raw) s.start = { kind: 'answer', file: '', line: 0, form: 'shell', raw, argv: shellWords(raw) }
175
387
  }
176
- const ctx = { ws, repo, name: it.name, detection, endpoint }
388
+ const functions = fnApp ? await functionsDecision(s, it, { d, opts, ui, env, repo, detection, root }) : undefined
389
+ const ctx = { ws, repo, name: it.name, detection, endpoint, mode, channel: it.channel, existing: it.existing, installed: it.existing.installed, tools, manifest,
390
+ ignored: (file) => d.gitIgnored(root, file), functions }
177
391
  const plan = applier.plan(s, ctx)
178
392
  for (const edit of plan.edits) ws.stage(edit)
179
393
  const settings = plan.stop ? { target: null, edits: [], summary: [], manual: [], notes: [] } : d.settingsPlan(s, ctx, plan.extraSettings ?? [])
180
394
  for (const edit of settings.edits) ws.stage(edit)
181
- plans.push({ ...it, stack, plan, settings, manual: [...plan.manual, ...settings.manual].map(tidy),
182
- notes: unique([...plan.notes, ...settings.notes].filter(Boolean)) })
395
+ const extra = existingNotes(it.existing, { hosts: detection.hosts, mode })
396
+ // otel mode: only the notes of the docs page that fit the language and what the repo uses.
397
+ const own = mode === 'otel' ? otelNotes(plan.notes, { lang: s.lang, existing: it.existing, tracesOnly: settings.tracesOnly, endpoint, docs: DOCS_URL }) : plan.notes
398
+ plans.push({ ...it, stack, mode, plan, settings, functions, manual: [...plan.manual, ...settings.manual, ...extra.advice].map(tidy),
399
+ notes: unique([...own, ...settings.notes, ...extra.notes, it.channelNote].filter(Boolean)) })
400
+ }
401
+ // How the agent stays up to date (updates.mjs): on by default; --update-bot and --channel choose. Each line shows with
402
+ // the first service that it is about (the channel's with the services on the channel, the update bot's with the
403
+ // services on the package route), and the pull request says which method was chosen and why.
404
+ let updates = { pr: [], methods: [] }
405
+ if (plans.length) {
406
+ updates = updatesPlan(plans, { ws, repo, bot: opts.updateBot ?? null, recorded: manifest?.updates ?? null, host: d.gitHost(root) })
407
+ for (const edit of updates.edits) ws.stage(edit)
408
+ const home = (names) => plans.find((p) => names.includes(p.name)) ?? plans[0]
409
+ for (const line of updates.summary) home(line === updates.channelLine ? updates.homes.channel : updates.homes.packages).plan.summary.push(line)
410
+ home(updates.homes.packages).manual.push(...updates.manual.map(tidy))
411
+ home(updates.homes.packages).notes.push(...updates.notes)
412
+ }
413
+ // The cloud id of each container app, in the command that deploys it (cloudid.mjs). az only reads, and only in a run
414
+ // in a terminal that asks (automates): never with --yes, --json or --dry-run, never in a pipeline. The run with 1 Yes
415
+ // reads after the key check, with the other reads (runOnce); --step-by-step reads here, before Apply. A dry run in a
416
+ // terminal shows the plan of a real run, as if az showed nothing in the way. Other runs change only the deploy steps
417
+ // that need no look in Azure.
418
+ const spots = cloudIdSpots(repo, detection.hosts)
419
+ const lookups = cloudIdLookups(spots)
420
+ const terminalRun = automates(opts, ui, env)
421
+ const dockerfiles = plans.map((p) => p.s.dockerfile).filter(Boolean)
422
+ const cloudLater = terminalRun && !opts.stepByStep && lookups.length > 0 && plans.length > 0 ? { dockerfiles } : null
423
+ let azureFacts = opts.dryRun && !opts.json && !ciVariable(env) ? PREDICT : null
424
+ if (terminalRun && opts.stepByStep && lookups.length > 0 && plans.length > 0) {
425
+ const look = ui.progress('Looking at your container apps in Azure')
426
+ azureFacts = await cloudIdFacts(d.cli, { root, env }, lookups)
427
+ look.end(null)
183
428
  }
184
- report.services = plans.map((p) => serviceReport(p.s, { stack: p.stack, name: p.name, settings: p.settings.target ?? null,
429
+ const cloudIds = plans.length ? cloudIdPlan(repo, spots, azureFacts, { dockerfiles }) : { edits: [], summary: [] }
430
+ for (const edit of cloudIds.edits) ws.stage(edit)
431
+ // After the settings of the service: it is 1 more setting, set by the deploy.
432
+ if (plans.length) plans[0].settings.summary = [...plans[0].settings.summary, ...cloudIds.summary]
433
+ const cloudId = cloudIds.edits.length > 0
434
+ // App Service that deploys the code, not an image, never runs the Dockerfile: the start and the settings go on App Service.
435
+ const names = d.deployNames(repo, detection.hosts)
436
+ for (const p of plans) p.manual.push(...appServiceCode(p, names, repo).map(tidy))
437
+ for (const p of plans.filter((x) => x.s.functions)) p.manual = p.manual.map((b) => functionsSettingsNames(b, p, names))
438
+ report.services = plans.map((p) => serviceReport(p.s, { stack: p.stack, mode: p.mode, name: p.name, settings: p.settings.target ?? null,
185
439
  summary: steps(p), commands: p.plan.commands.map((c) => ({ cwd: c.cwd, argv: [c.cmd, ...c.args] })),
186
440
  manual: p.manual, notes: p.notes, otel: p.existing.otel }))
187
- report.keySteps = d.keySteps(detection.hosts, { envFile: plans.some((p) => p.settings.target?.kind === 'env-file') })
188
- // The docs snippets use an example service name. With 1 service, show its real name.
189
- if (plans.length === 1) {
190
- for (const snippet of report.keySteps.flatMap((b) => b.snippets ?? [])) snippet.code = snippet.code.split(EXAMPLE_SERVICE).join(plans[0].name)
191
- }
441
+ // The key steps of each host, fitted to this repo: the names in its deploy files, how its apps start, the settings of
442
+ // this run, and the key variable of apps that keep their own metrics and logs (traces only).
443
+ const one = plans.length === 1 ? plans[0] : null
444
+ const tracesOnly = plans.length > 0 && plans.every((p) => p.settings.tracesOnly)
445
+ if (!tracesOnly) for (const p of plans.filter((x) => x.settings.tracesOnly)) p.notes.push(`For ${p.name}, use ${TRACES_HEADERS} instead of ${SECRET.name} in the key steps below.`)
446
+ const slug = d.gitRemote(root)
447
+ const secretName = tracesOnly ? TRACES_HEADERS : SECRET.name
448
+ const keyBlocks = d.keySteps(detection.hosts, {
449
+ view: repo, names, repo: slug, service: one?.name,
450
+ // How to load otel.env only when no repo file loads it. otel.env and systemd only when a service needs them.
451
+ envFile: plans.some((p) => p.settings.target?.kind === 'env-file' && !p.settings.target.loader),
452
+ otelEnv: plans.some((p) => p.settings.target?.kind === 'env-file'),
453
+ docker: plans.length > 0 && plans.every((p) => p.settings.target?.kind === 'dockerfile'),
454
+ settings: one?.settings.pairs?.length ? one.settings.pairs : null,
455
+ startLine: plans.some((p) => p.mode === 'standard' && START_PREFIX[p.stack]),
456
+ secretName,
457
+ })
458
+ // A function app where OpenTelemetry stays off (functionsDecision) gets no key: the key would only restart the app.
459
+ const fnPlans = plans.filter((p) => p.s.functions)
460
+ const fnOff = fnPlans.length > 0 && fnPlans.every((p) => p.plan.stop)
461
+ report.keySteps = !fnOff ? keyBlocks : fnPlans.length === plans.length ? [] : keyBlocks.filter((b) => b.title !== FUNCTIONS.key.title)
462
+ // Where a run in a terminal can store the key itself (automate.mjs). Only read from the repo here.
463
+ const targets = keyTargets({ hosts: detection.hosts, names, view: repo, plans, repo: slug })
192
464
  const changes = ws.changes()
193
465
  // A file that holds a key is never changed, so the key cannot reach the diff or the manifest.
194
466
  const keyed = changes.filter((c) => hasKey(c.before) || hasKey(c.after)).map((c) => c.file)
@@ -203,36 +475,121 @@ async function setup(opts, d, report) {
203
475
  }
204
476
  const commands = plans.flatMap((p) => p.plan.commands)
205
477
  const manual = plans.flatMap((p) => p.manual)
206
- const exit = manual.length ? 3 : 0
478
+ const required = manual.filter((b) => b.required)
479
+ const exit = required.length ? 3 : 0
480
+ const planOnly = opts.dryRun || (opts.json && !opts.yes)
481
+ const app = { name: plans.map((p) => p.name).join(', '), repo: slug ?? undefined, platform: platformOf(detection.hosts) }
482
+ const KEEP = 'Keep the agent up to date: '
483
+ const keep = (updates.summary ?? []).filter((line) => line.startsWith(KEEP)).map((line) => line.slice(KEEP.length))
484
+ const last = { ask, opts, env, plans, required, root, hosts: detection.hosts, targets, secretName, app, cloudId, cloudLater, updates: updates.pr, bot: updates.bot, keep }
485
+ const record = plans.map((p) => ({ dir: p.s.dir, stack: p.stack, name: p.name, mode: p.mode, ...(p.channel ? { channel: p.channel } : {}),
486
+ ...(p.manual.some((b) => b.required && b.verify) ? { steps: p.manual.filter((b) => b.required && b.verify).map((b) => b.title) } : {}) }))
487
+ // In a terminal, the default is 1 Yes: sign in, show the whole plan, ask once, do it all (runOnce). --step-by-step
488
+ // keeps a question for each step. A dry run shows the same plan, unless it is --step-by-step or in a pipeline.
489
+ const once = automates(opts, ui, env) && !opts.stepByStep
490
+ const dryOnce = opts.dryRun && !opts.json && !opts.stepByStep && !ciVariable(env)
491
+ const facts = { ...last, ws, changes, commands, manifest, record, detection, view: repo, manual }
207
492
 
208
493
  if (!changes.length && !commands.length) {
209
- ui.print(manual.length ? 'Nothing for this tool to change. The steps below are yours.' : 'Already set up. Nothing to change.')
210
- printManual(ui, manual)
211
- return ['nothing-to-change', exit]
494
+ // Set up: the manifest lists every service. Steps that this tool can check in the repo and that are not done
495
+ // yet leave it at steps-left (exit 3). Steps that it cannot check (often done on the platform) do not.
496
+ const recorded = Boolean(manifest) && plans.every((p) => manifest.services.some((m) => m.dir === p.s.dir))
497
+ if (recorded) {
498
+ const left = required.filter((b) => b.verify)
499
+ if (left.length) {
500
+ ui.print(fillIn(SETUP_STATUS.stepsLeft, { count: plural(left.length, 'step'), verb: left.length === 1 ? 'is' : 'are', pronoun: left.length === 1 ? 'it' : 'them' }))
501
+ ui.print()
502
+ for (const block of left) ui.block(block)
503
+ printManual(ui, manual.filter((b) => !left.includes(b)), true, SETUP_STATUS.doneSteps)
504
+ return ['steps-left', 3]
505
+ }
506
+ // After a no to the pull request, the files of setup are not committed yet: a rerun in a terminal asks again.
507
+ // It reads git only. The key steps are not asked again: npx @uknows/setup key does those. Then the first line
508
+ // says that setup has nothing more to change, so the question to commit does not read as a contradiction.
509
+ const terminal = automates(opts, ui, env)
510
+ const paths = [...manifestPaths(manifest)]
511
+ const pending = terminal ? await notCommitted(d.cli, { root, env, paths }) : []
512
+ ui.print(pending.length ? RERUN_UNCOMMITTED : SETUP_STATUS.done)
513
+ printManual(ui, manual, true, SETUP_STATUS.doneSteps)
514
+ if (terminal) {
515
+ const urls = plans.map((p) => connectUrl(p.name, null, appUrl(env)))
516
+ // .uknows/setup.json does not say if the key is stored, so the pull request and the last line say so (key: 'unknown').
517
+ const pr = await pullRequest(ui, d.cli, { root, env, paths, left: required.map((b) => b.title),
518
+ services: plans.map((p) => p.name), url: urls[0], rerun: true, key: 'unknown', logs: sendsLogs(plans), updates: updates.pr })
519
+ if (pr.state !== 'none') {
520
+ report.next = lastLine({ state: pr.state, urls, key: 'unknown' })
521
+ ui.print(report.next)
522
+ }
523
+ }
524
+ return ['nothing-to-change', 0]
525
+ }
526
+ // Nothing for this tool to change. Show where the app runs, the steps, and the key steps, and write the record so
527
+ // the pipeline check and remove know this service.
528
+ const status = exit ? 'needs-steps' : 'nothing-to-change'
529
+ // No record for a service that this tool could not set up: a Dockerfile to pick, or a plan that stopped.
530
+ const stopped = plans.some((p) => p.plan.stop || p.settings.stop || p.s.problems.some((x) => x.code === 'dockerfile-ambiguous'))
531
+ ui.print(exit ? SETUP_STATUS.steps : SETUP_STATUS.done)
532
+ ui.print()
533
+ const plain = !(dryOnce && !stopped) && !(once && !stopped)
534
+ if (plain) {
535
+ printHosts(ui, detection)
536
+ printManual(ui, manual)
537
+ }
538
+ if (dryOnce && !stopped) {
539
+ await printDryOnce(ui, d, report, { ...facts, recordOnly: true })
540
+ return [status, exit]
541
+ }
542
+ if (opts.dryRun && !opts.json) await printQuestions(ui, d, report, { ...last, view: repo, files: [CLI.manifest], manifest, apply: false })
543
+ if (planOnly || stopped || (ciVariable(opts.env) && !opts.allowCi)) return [status, exit]
544
+ if (once) return runOnce(ui, d, report, { ...facts, recordOnly: true, status, exit, declined: [status, exit] })
545
+ ui.print()
546
+ ui.print(fillIn(SETUP_STATUS.record, { manifest: CLI.manifest }))
547
+ if (!opts.yes) {
548
+ if (!ui.interactive) { ui.print(`Run again with --yes to write ${CLI.manifest}.`); return [status, exit] }
549
+ if (!(await ui.confirm(`Write ${CLI.manifest}? (Y/n)`))) { ui.print('Nothing changed.'); return [status, exit] }
550
+ }
551
+ const saved = await apply({ root, ws, changes: [], commands: [], manifest, services: record, d })
552
+ if (!saved) return ['failed', 1]
553
+ ui.print(`Wrote ${CLI.manifest}. To undo it, run npx @uknows/setup remove.`)
554
+ // The steps printed above already (plain): finish does not print them again.
555
+ return (await finish(ui, d, report, { ...last, shown: plain })) ?? [status, exit]
556
+ }
557
+
558
+ // A run that applies checks the pipeline and the git tree first, before the long plan.
559
+ if (!planOnly) {
560
+ if (stopInCi(ui, report, opts, env)) return ['planned', 2]
561
+ if (!opts.force) {
562
+ const git = d.gitStatus(root, manifestPaths(manifest))
563
+ if (!git.repo) {
564
+ const message = git.git ? 'This folder is not in a git repo. Keep a copy, so you can review what this tool changes.'
565
+ : 'git is not installed, so this tool cannot check for changes that are not committed.'
566
+ ui.print(`Note: ${message}`)
567
+ report.problems.push({ code: 'no-git', message })
568
+ } else if (git.paths.length) {
569
+ ui.error('This repo has changes that are not committed:')
570
+ for (const path of git.paths.slice(0, 10)) ui.error(` ${path}`)
571
+ if (git.paths.length > 10) ui.error(` and ${git.paths.length - 10} more`)
572
+ ui.error('Commit or stash your changes first, so you can review what this tool changes. Or run again with --force.')
573
+ report.problems.push({ code: 'dirty-tree', message: `${plural(git.paths.length, 'path')} in the repo are not committed.` })
574
+ return ['refused', 1]
575
+ }
576
+ }
577
+ }
578
+ if (dryOnce) {
579
+ await printDryOnce(ui, d, report, facts)
580
+ return ['planned', exit]
212
581
  }
582
+ if (once) return runOnce(ui, d, report, { ...facts, status: 'applied', exit, declined: ['planned', 0] })
213
583
  printPlan(ui, plans, detection, changes, commands, opts)
214
- if (opts.dryRun || (opts.json && !opts.yes)) {
584
+ if (planOnly) {
215
585
  printManual(ui, manual)
586
+ if (opts.dryRun && !opts.json) {
587
+ const files = unique([...changes.map((c) => c.file), ...commands.flatMap((c) => c.touches), CLI.manifest])
588
+ await printQuestions(ui, d, report, { ...last, view: repo, files, manifest, apply: true })
589
+ }
216
590
  ui.print('This was a dry run. Nothing changed.')
217
591
  return ['planned', exit]
218
592
  }
219
-
220
- if (!opts.force) {
221
- const git = d.gitStatus(root, manifestPaths(manifest))
222
- if (!git.repo) {
223
- const message = git.git ? 'This folder is not in a git repo. Keep a copy, so you can review what this tool changes.'
224
- : 'git is not installed, so this tool cannot check for changes that are not committed.'
225
- ui.print(`Note: ${message}`)
226
- report.problems.push({ code: 'no-git', message })
227
- } else if (git.paths.length) {
228
- ui.error('This repo has changes that are not committed:')
229
- for (const path of git.paths.slice(0, 10)) ui.error(` ${path}`)
230
- if (git.paths.length > 10) ui.error(` and ${git.paths.length - 10} more`)
231
- ui.error('Commit or stash your changes first, so you can review what this tool changes. Or run again with --force.')
232
- report.problems.push({ code: 'dirty-tree', message: `${plural(git.paths.length, 'path')} in the repo are not committed.` })
233
- return ['refused', 1]
234
- }
235
- }
236
593
  if (!opts.yes) {
237
594
  if (!ui.interactive) {
238
595
  ui.print('Run again with --yes to apply.')
@@ -244,17 +601,565 @@ async function setup(opts, d, report) {
244
601
  }
245
602
  }
246
603
 
247
- const record = plans.map((p) => ({ dir: p.s.dir, stack: p.stack, name: p.name }))
248
- const saved = await apply({ root, ws, changes, commands, manifest, services: record, d })
604
+ const saved = await apply({ root, ws, changes, commands, manifest, services: record, updates: updates.bot, d })
249
605
  if (!saved) return ['failed', 1]
250
606
  report.files = printResult(ui, ws, saved, changes, commands)
251
- printManual(ui, manual, false)
607
+ return (await finish(ui, d, report, last)) ?? ['applied', exit]
608
+ }
609
+
610
+ /** The key variable of apps that keep their own OTLP metrics and logs: only the traces exporter reads it. */
611
+ const TRACES_HEADERS = 'OTEL_EXPORTER_OTLP_TRACES_HEADERS'
612
+ /** The start line that a stack needs on a platform that does not run the Dockerfile. */
613
+ const START_PREFIX = { python: 'opentelemetry-instrument', dotnet: 'sh ./instrument.sh' }
614
+
615
+ /**
616
+ * After the files: ask for the key and check it, then the steps in the repo, the key steps of each platform, and 1
617
+ * line on what to do next. The next line depends on the check and on the steps left. In a terminal (automates()),
618
+ * setup then asks to do the last steps itself (finishInTerminal). Returns [status, exit code] that replace the ones of
619
+ * the repo, or null: a terminal run whose key UKNOWS refuses ends with 1, and Ctrl+C during the sign-in with 130.
620
+ */
621
+ async function finish(ui, d, report, ctx) {
622
+ const { ask, opts, env, plans, required } = ctx
623
+ // --yes asks nothing. In a terminal with the key already set, it still checks that key.
624
+ const auto = !ask && opts.yes && ui.terminal && Boolean(env[SECRET.name]?.trim())
625
+ let checked = ask || auto ? await checkKey(ui, d, env, auto, plans[0]?.name ?? null, { pasteKey: opts.pasteKey, app: ctx.app }) : null
626
+ if (checked?.stopped && checked.interrupt) {
627
+ // Ctrl+C while setup waits for the sign-in: stop here. The files are changed already.
628
+ ui.print(`Stopped. The changes to your files stay. To undo them, run npx @uknows/setup remove.`)
629
+ return ['cancelled', 130]
630
+ }
631
+ if (checked?.stopped) checked = null
632
+ report.check = checked?.entry ?? null
633
+ if (automates(opts, ui, env)) {
634
+ const code = await finishInTerminal(ui, d, report, { ...ctx, headers: checked?.headers ?? null, notReady: Boolean(checked?.notReady) })
635
+ return code === null ? null : ['key-refused', code]
636
+ }
637
+ if (!ctx.shown) printManual(ui, plans.flatMap((p) => p.manual), true)
252
638
  for (const block of report.keySteps) ui.block(block)
253
639
  ui.print('Never put the key in a file in your repo.')
254
- report.check = ask ? await checkKey(ui, d, env) : null
255
- if (!report.check) ui.print(`To check your key, set ${SECRET.name} in this terminal, then run ${checkCommand(env)}.`)
256
- ui.print(CLI.next)
257
- return ['applied', exit]
640
+ ui.print()
641
+ const line = keyCheckLine(checkVars(env))
642
+ const status = report.check?.status
643
+ if (checked?.notReady) {
644
+ // A new key that UKNOWS did not get ready in 2 minutes: nothing to fix in the key, so no key advice.
645
+ ui.print(NEW_KEY.againFiles)
646
+ report.next = NEW_KEY.againFiles
647
+ return null
648
+ }
649
+ if (status === 'rejected' || status === 'network-error') {
650
+ ui.print(status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network)
651
+ ui.print(` ${line}`)
652
+ report.next = status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network
653
+ return null
654
+ }
655
+ if (!report.check) {
656
+ ui.print(SETUP_NEXT.skipped)
657
+ ui.print(` ${line}`)
658
+ ui.print()
659
+ }
660
+ report.next = required.length ? fillIn(SETUP_NEXT.steps, { count: plural(required.length, 'step') }) : nextData(env)
661
+ ui.print(report.next)
662
+ return null
663
+ }
664
+
665
+ /**
666
+ * The end of a run in a terminal: store the key on the platform, add the key to the deploy files, open the pull
667
+ * request, each after its own question (automate.mjs). What the user does not say yes to prints as today. A refused key
668
+ * stops here with exit 1: its steps print, and nothing is stored or committed.
669
+ */
670
+ async function finishInTerminal(ui, d, report, { env, plans, required, root, hosts, targets, secretName, headers, cloudId = false, shown = false, updates = [], notReady = false }) {
671
+ const line = keyCheckLine(checkVars(env))
672
+ const status = report.check?.status
673
+ if (!shown) printManual(ui, plans.flatMap((p) => p.manual), true)
674
+ if (status === 'rejected') {
675
+ for (const block of report.keySteps) ui.block(block)
676
+ ui.print('Never put the key in a file in your repo.')
677
+ ui.print()
678
+ // A new key that UKNOWS did not get ready in 2 minutes: nothing to fix in the key, so no key advice.
679
+ report.next = notReady ? NEW_KEY.againFiles : SETUP_NEXT.rejected
680
+ ui.print(report.next)
681
+ if (!notReady) ui.print(` ${line}`)
682
+ return 1
683
+ }
684
+ const environment = report.check?.environment ?? null
685
+ const steps = await keyStepsInTerminal(ui, d.cli, report.keySteps, targets, { key: status === 'reached' ? bareKey(headers) : null,
686
+ environment, env, root, secretName, service: plans.length === 1 ? plans[0].name : null, services: plans.map((p) => p.name) })
687
+ const view = () => repoView(d.createWorkspace(root))
688
+ const githubStep = steps.github ? githubStepLines(steps.github) : null
689
+ const applied = await deployFilesInTerminal(ui, deployEdits(view(), hosts, plans, secretName, { githubStep }), (edit) => applyDeployEdit(d.createWorkspace(root), edit))
690
+ const rest = openChecks(report.keySteps, { edits: applied, stored: steps.stored.map((s) => s.host), skipped: steps.skipped, secretName })
691
+ // The GitHub secret is stored: the step that reads it is left, unless the deploy file question added it.
692
+ if (steps.github && !applied.some((e) => e.checks.includes('github-step'))) rest.unshift(githubStepBlock(steps.github))
693
+ // A Kubernetes secret is only half: each deployment loads it with envFrom. Without that edit, the step stays.
694
+ if (steps.stored.some((s) => s.host === 'kubernetes') && !k8sLoadsKey(view(), plans)) rest.unshift(loadKeyBlock(report.keySteps))
695
+ for (const block of rest) ui.block(block)
696
+ ui.print('Never put the key in a file in your repo.')
697
+ ui.print()
698
+ const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
699
+ const left = unique([...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...steps.left, ...rest.map((b) => b.title)])
700
+ // Nothing stored and the key steps printed: a skipped key check, a no to the store question or a store that failed.
701
+ // The pull request and the last line then say to store the key before the merge (F-012-153).
702
+ const key = !steps.stored.length && steps.left.length ? 'left' : null
703
+ const pr = await pullRequest(ui, d.cli, { root, env, paths: [...manifestPaths(readManifest(root))], left, done: steps.stored.map((s) => s.done),
704
+ services: plans.map((p) => p.name), url: urls[0], environment, key, logs: sendsLogs(plans), cloudId, updates })
705
+ if (!report.check || status === 'network-error') {
706
+ ui.print(report.check ? SETUP_NEXT.network : SETUP_NEXT.skipped)
707
+ ui.print(` ${line}`)
708
+ ui.print()
709
+ }
710
+ report.next = lastLine({ state: pr.state, required: required.length, urls, environment, key })
711
+ ui.print(report.next)
712
+ if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
713
+ return null
714
+ }
715
+
716
+ /**
717
+ * A run in a terminal with 1 Yes (the default). Nothing changes until the Yes:
718
+ * 1. the key: from this terminal, a sign-in in the browser, or pasted (--paste-key, or p while waiting), then the check;
719
+ * 2. read only: where the key goes (az, gh, kubectl), the deploy files that would drop it, and git for the pull request;
720
+ * 3. the whole plan, and 1 question: Do all of this? (Y/n). d shows every line that changes;
721
+ * 4. every step, 1 line each with done or failed: the files, the key, the deploy files, the pull request;
722
+ * 5. what is left for the person, and the last line with the link to watch the app connect, which opens.
723
+ * A refused key stops before anything changes (exit 1). Ctrl+C while waiting for the sign-in: exit 130. A failed step
724
+ * after the files does not stop the others: its steps print at the end. Returns [status, exit code].
725
+ */
726
+ async function runOnce(ui, d, report, ctx) {
727
+ const { opts, env, plans, required, root, hosts, targets, secretName, ws, commands, manifest, record, recordOnly = false, app, cloudLater = null, updates = [] } = ctx
728
+ let { changes, cloudId = false } = ctx
729
+ const services = plans.map((p) => p.name)
730
+ const checked = await checkKey(ui, d, env, false, plans[0]?.name ?? null, { once: true, pasteKey: opts.pasteKey, app })
731
+ if (checked?.stopped) {
732
+ ui.print('Stopped. Nothing changed.')
733
+ return checked.interrupt ? ['cancelled', 130] : ['stopped', 1]
734
+ }
735
+ report.check = checked?.entry ?? null
736
+ const status = report.check?.status ?? null
737
+ if (status === 'rejected') {
738
+ if (checked.notReady) ui.print(NEW_KEY.again)
739
+ else if (checked.from === 'signin') ui.print('UKNOWS did not accept the new key. Run npx @uknows/setup again in a minute. Nothing changed.')
740
+ else {
741
+ ui.print(SETUP_NEXT.rejected)
742
+ ui.print(` ${keyCheckLine(checkVars(env))}`)
743
+ ui.print('Nothing changed.')
744
+ }
745
+ report.next = checked.notReady ? NEW_KEY.again : SETUP_NEXT.rejected
746
+ return ['key-refused', 1]
747
+ }
748
+ const mismatch = addressMismatch(checked?.link, opts.endpoint ?? INGEST_ENDPOINT)
749
+ if (mismatch) { ui.error(mismatch); return ['needs-input', 2] }
750
+ const environment = report.check?.environment ?? null
751
+ const keyCtx = { key: status === 'reached' ? bareKey(checked.headers) : null, environment, env, root, secretName,
752
+ service: plans.length === 1 ? plans[0].name : null, services }
753
+
754
+ // Read only: where the key goes, the deploy files, and git.
755
+ const look = ui.progress('Looking at where your app runs')
756
+ const stores = await planKeyStores(d.cli, report.keySteps, targets, keyCtx)
757
+ // The cloud id of a container app whose deploy command needs a look in Azure first (cloudid.mjs): read only.
758
+ if (cloudLater && !recordOnly) {
759
+ const view = repoView(ws)
760
+ const spots = cloudIdSpots(view, hosts)
761
+ const later = cloudIdPlan(view, spots, await cloudIdFacts(d.cli, { root, env }, cloudIdLookups(spots)), { dockerfiles: cloudLater.dockerfiles })
762
+ for (const edit of later.edits) ws.stage(edit)
763
+ if (later.edits.length) {
764
+ changes = ws.changes()
765
+ cloudId = true
766
+ if (plans.length) plans[0].settings.summary = [...plans[0].settings.summary, ...later.summary]
767
+ }
768
+ }
769
+ const ghStore = stores.stores.find((x) => x.t.kind === 'github')
770
+ const edits = deployEdits(repoView(ws), hosts, plans, secretName, { githubStep: ghStore && !ghStore.t.readsKey && !ghStore.found.partial ? githubStepLines(ghStore.block) : null })
771
+ const pr = await prPlan(d.cli, { root, env })
772
+ look.end(null)
773
+ const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
774
+ const sendsTo = report.check?.workspace ? `${report.check.workspace}${environment ? `, environment ${environment}` : ''}` : null
775
+ printOnePlan(ui, onePlan({ ...ctx, changes, sendsTo, stores, edits, pr, urls, keyStatus: status, dry: false, keySteps: report.keySteps }))
776
+ if (!(await askOnce(ui, () => diffLines(changes, commands, edits)))) {
777
+ ui.print('Nothing changed.')
778
+ return ctx.declined ?? ['planned', 0]
779
+ }
780
+ ui.print()
781
+
782
+ // 1. The files, and the record that remove reads.
783
+ const quiet = muted(ui)
784
+ let saved = null
785
+ const files = await step(ui, recordOnly ? `Writing ${CLI.manifest}` : 'Adding OpenTelemetry to your app', async () => {
786
+ saved = await apply({ root, ws, changes, commands, manifest, services: record, updates: ctx.bot, d: { ...d, ui: quiet, spawn: (cmd, args, o) => d.spawn(cmd, args, { ...o, quiet: true }) } })
787
+ if (!saved) return { ok: false, line: 'Could not change your files. Nothing changed.', more: quiet.lines }
788
+ report.files = resultFiles(ws, saved, changes, commands).files
789
+ return { ok: true, line: recordOnly ? `Wrote ${CLI.manifest}` : `Added OpenTelemetry to your app (${plural(report.files.length, 'file')} changed)` }
790
+ })
791
+ if (!files.ok) return ['failed', 1]
792
+
793
+ // 2. The key, where setup found it can go.
794
+ const done = { stored: [], left: [], skipped: stores.skipped.map((x) => x.host), github: null }
795
+ let directFailed = false
796
+ for (const x of stores.stores) {
797
+ let put = { stored: false }
798
+ await step(ui, `Storing your key on ${storeName(x.found)}`, async () => {
799
+ put = await PUT[x.found.kind](d.cli, x.found, keyCtx)
800
+ if (put.stored) return { ok: true, line: put.done.replace(/\.$/, '') }
801
+ return { ok: false, line: put.why, more: [put.again === false ? 'Do the key steps at the end.' : 'Do the key steps at the end, or run npx @uknows/setup key to try again.'] }
802
+ })
803
+ if (put.stored) {
804
+ done.stored.push({ host: x.host, done: put.done })
805
+ if (x.t.kind === 'github' && put.partial) done.left.push(x.block.title)
806
+ else if (x.t.kind === 'github' && !x.t.readsKey) done.github = x.block
807
+ } else {
808
+ done.left.push(x.block.title)
809
+ if (x.t.kind !== 'github') directFailed = true
810
+ }
811
+ }
812
+ // The key is not on the app after all: the GitHub Actions block is needed again.
813
+ if (directFailed) for (const x of stores.skipped) { done.left.push(x.block.title); done.skipped = done.skipped.filter((h) => h !== x.host) }
814
+
815
+ // 3. The deploy files, as planned. The key step of a GitHub secret only when that secret is stored now.
816
+ const view = () => repoView(d.createWorkspace(root))
817
+ const planned = new Set(edits.map((e) => e.file))
818
+ const applied = []
819
+ for (const edit of deployEdits(view(), hosts, plans, secretName, { githubStep: done.github ? githubStepLines(done.github) : null }).filter((e) => planned.has(e.file))) {
820
+ const where = `${edit.file} ${edit.lines.length > 1 ? 'lines' : 'line'} ${list(edit.lines.map(String))}`
821
+ await step(ui, `Adding the key to ${edit.file}`, async () => {
822
+ try {
823
+ applyDeployEdit(d.createWorkspace(root), edit)
824
+ applied.push(edit)
825
+ return { ok: true, line: `Added the key to ${where}` }
826
+ } catch (error) {
827
+ return { ok: false, line: `Could not change ${edit.file}: ${error.message}. It is as it was.` }
828
+ }
829
+ })
830
+ }
831
+
832
+ // What is left for the person: steps in the repo, key steps, and deploy file checks.
833
+ const rest = openChecks(report.keySteps, { edits: applied, stored: done.stored.map((x) => x.host), skipped: done.skipped, secretName })
834
+ if (done.github && !applied.some((e) => e.checks.includes('github-step'))) rest.unshift(githubStepBlock(done.github))
835
+ if (done.stored.some((x) => x.host === 'kubernetes') && !k8sLoadsKey(view(), plans)) rest.unshift(loadKeyBlock(report.keySteps))
836
+ const leftTitles = new Set([...stores.left.map((x) => x.block.title), ...done.left])
837
+ const keyLeft = report.keySteps.filter((b) => leftTitles.has(b.title))
838
+ const left = unique([...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...keyLeft.map((b) => b.title), ...rest.map((b) => b.title)])
839
+ const key = !done.stored.length && keyLeft.length ? 'left' : null
840
+
841
+ // 4. The branch and the pull request. Commits of the person that origin does not have never go along with 1 Yes.
842
+ let state = 'none'
843
+ if (pr?.from) {
844
+ const paths = await changedFiles(d.cli, { root, env, paths: [...manifestPaths(readManifest(root))] })
845
+ if (paths.length) {
846
+ const noPush = pr.ahead?.count ? aheadNote(pr) : null
847
+ const lines = []
848
+ const r = await step(ui, pr.gh && !noPush ? 'Opening a pull request' : 'Committing the change on a new branch', async () => {
849
+ const out = await prRun(d.cli, pr, { root, env, files: paths, noPush,
850
+ body: () => prBody({ services, files: paths, left, done: done.stored.map((x) => x.done), url: urls[0], environment, key, logs: sendsLogs(plans), cloudId, updates }) }, (l) => lines.push(l), (l) => lines.push(l))
851
+ state = out.state
852
+ const others = lines.filter((l) => l !== out.why && !l.startsWith('Created the branch') && !l.startsWith('Pushed ') && !l.startsWith('Opened the pull request'))
853
+ if (out.state === 'pr') return { ok: true, line: `Opened the pull request: ${out.url}`, more: others }
854
+ if (out.state === 'committed' && !out.why) return { ok: true, line: `Created the branch ${out.branch} with 1 commit`, more: others.filter((l) => l !== noPush) }
855
+ return { ok: false, line: out.why ?? 'Could not commit.', more: lines.filter((l) => l !== out.why) }
856
+ })
857
+ if (!r.ok && state === 'none') state = 'declined'
858
+ }
859
+ } else if (pr && !pr.from) {
860
+ ui.print(' This repo has no branch checked out, so setup did not commit. Commit the change and open a pull request yourself.')
861
+ }
862
+
863
+ ui.print()
864
+ printManual(ui, plans.flatMap((p) => p.manual), false)
865
+ for (const block of [...keyLeft, ...rest]) ui.block(block)
866
+ if (keyLeft.length || rest.length) { ui.print('Never put the key in a file in your repo.'); ui.print() }
867
+ if (status === 'network-error') {
868
+ ui.print(SETUP_NEXT.network)
869
+ ui.print(` ${keyCheckLine(checkVars(env))}`)
870
+ ui.print()
871
+ }
872
+ report.next = lastLine({ state, required: required.length, urls, environment, key })
873
+ ui.print(report.next)
874
+ if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
875
+ return [ctx.status, ctx.exit]
876
+ }
877
+
878
+ /** Why a run with 1 Yes commits but does not push: commits of the person that origin does not have would go along. */
879
+ function aheadNote(pr) {
880
+ const n = pr.ahead.count
881
+ const names = pr.ahead.subjects.map((x) => `"${x}"`)
882
+ const shown = n > names.length ? `${names.join(', ')} and ${n - names.length} more` : list(names)
883
+ return `Your branch has ${plural(n, 'commit')} that ${n === 1 ? 'is' : 'are'} not on origin/${pr.base} yet: ${shown}. A push would take ${n === 1 ? 'it' : 'them'} along, so setup only commits. Push when you are ready.`
884
+ }
885
+
886
+ /** A UI that keeps what it would print, for the steps whose lines go under their done or failed line. */
887
+ function muted(ui) {
888
+ const lines = []
889
+ return { ...ui, lines, print: (text = '') => { if (text) lines.push(text) }, error: (text) => lines.push(text), block: () => {} }
890
+ }
891
+
892
+ /**
893
+ * The plan of a run with 1 Yes, from what setup found: the app, the files, where the key goes, the deploy files, the
894
+ * pull request and the link. dry: a dry run, which signs in to nothing and looks up nothing outside this computer.
895
+ */
896
+ function onePlan({ opts, plans, detection, changes, commands, manifest, recordOnly = false, stores, edits, pr, urls, sendsTo, keyStatus, dry, keySteps, secretName, keep = [] }) {
897
+ const one = plans.length === 1 ? plans[0] : null
898
+ const app = one ? [['App', `${one.name}, ${describeShort(one.s)}`]] : [['Apps', plans.map((p) => `${p.name} (${p.s.dir})`).join(', ')]]
899
+ const from = one && !opts.serviceName ? (one.s.nameFrom === 'existing' ? 'is set in your repo already' : one.s.nameFrom === 'folder' ? 'comes from the folder name' : `comes from ${one.s.nameFrom}`) : null
900
+ // GitHub Actions is how the app deploys, not where it runs.
901
+ const runs = detection.hosts.filter((h) => h.id !== 'github-actions')
902
+ if (runs.length) app.push(['Runs on', list(unique(runs.map((h) => hostLabel(h.id))))])
903
+ if (detection.hosts.some((h) => h.id === 'github-actions')) app.push(['Deploys with', hostLabel('github-actions')])
904
+ app.push(['Sends to', dry ? 'the workspace and environment that you pick when you sign in' : sendsTo ?? 'not checked: setup has no key yet'])
905
+ const steps = []
906
+ const good = []
907
+ steps.push(recordOnly
908
+ ? { title: `Write ${CLI.manifest}`, rows: [], notes: [fillIn(SETUP_STATUS.record, { manifest: CLI.manifest })] }
909
+ : { title: plans.length > 1 ? 'Add OpenTelemetry to your apps' : 'Add OpenTelemetry to your app', rows: fileRows(changes, commands, { manifest: Boolean(manifest) }), notes: runNotes(commands) })
910
+ // How the agent stays up to date, and why (updates.mjs): the method, said once.
911
+ if (!recordOnly && keep.length) steps.push({ title: 'Keep the agent up to date', rows: [], notes: keep.map((line) => `${line[0].toUpperCase()}${line.slice(1)}`) })
912
+ for (const x of stores.stores) {
913
+ const r = storeRows(x.found)
914
+ steps.push({ title: r.title, rows: r.rows, notes: [r.note] })
915
+ }
916
+ // Left for the person: the steps in the repo, the key steps that setup cannot do here, and the deploy file checks that
917
+ // the planned edits and stores do not cover.
918
+ const checks = openChecks(keySteps, { edits, stored: stores.stores.map((x) => x.host), skipped: stores.skipped.map((x) => x.host), secretName })
919
+ const byHand = [...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...stores.left.map((x) => x.block.title), ...checks.map((b) => b.title)]
920
+ // The reasons say "the steps below" in a run with a question for each step. Here the steps come at the end.
921
+ const why = unique(stores.left.map((x) => x.why).filter((w) => w && w !== 'no-key')
922
+ .map((w) => w.replace(/use the steps below/g, 'do the key steps at the end').replace(/Use the steps below/g, 'Do the key steps at the end').replace(/with the steps below/g, 'with the key steps at the end')))
923
+ if (stores.left.some((x) => x.why === 'no-key')) {
924
+ good.push(keyStatus === 'network-error' ? 'Setup could not check your key, so it does not store it. The key steps print at the end.'
925
+ : dry ? 'Setup stores your key after you sign in.' : 'Setup has no key, so it does not store one. The key steps print at the end. To store it later, run npx @uknows/setup key.')
926
+ }
927
+ good.push(...why)
928
+ if (edits.length) {
929
+ steps.push({ title: 'Keep the key on each deploy', rows: [], notes: [...edits.flatMap((e) => e.about), `Setup adds the key ${edits.length > 1 ? 'to those files' : 'there'}.`] })
930
+ }
931
+ if (pr?.from) {
932
+ const into = pr.base && pr.slug ? [['Into', `${pr.base} of ${pr.slug}`]] : []
933
+ const branch = [['Branch', `${pr.branch}, with 1 commit of the files above`]]
934
+ if (pr.ahead?.count) steps.push({ title: 'Commit the change on a new branch', rows: branch, notes: [aheadNote(pr)] })
935
+ else if (dry && pr.slug && pr.base) steps.push({ title: 'Open a pull request', rows: [...branch, ...into], notes: ['If gh is not signed in, setup only commits and prints the push command.'] })
936
+ else if (pr.gh) steps.push({ title: 'Open a pull request', rows: [...branch, ...into], notes: [] })
937
+ else {
938
+ const reason = !pr.remote ? 'This repo has no remote named origin, so setup does not push.'
939
+ : !pr.base ? 'Setup cannot tell which branch of origin the pull request goes to, so it does not push.'
940
+ : !pr.slug ? 'origin is not on github.com, so setup does not open the pull request.'
941
+ : 'gh is not signed in, so setup does not push or open the pull request.'
942
+ steps.push({ title: 'Commit the change on a new branch', rows: branch, notes: [`${reason} The push command prints at the end.`] })
943
+ }
944
+ } else if (pr && !pr.from) good.push('This repo has no branch checked out, so setup does not commit. Commit the change and open a pull request yourself.')
945
+ for (const p of plans) {
946
+ const chosen = p.s.evidence?.find((e) => e.what.startsWith('Dockerfile for this service: '))
947
+ const kind = /dockerfile/i.test(chosen?.file.split('/').pop() ?? '') ? 'file' : 'Dockerfile'
948
+ // Only a Dockerfile that setup had to pick from others is worth a line.
949
+ const why = chosen?.what.slice('Dockerfile for this service: '.length)
950
+ if (chosen && why !== 'it is in the service folder') good.push(`Setup uses the ${kind} ${chosen.file}${plans.length > 1 ? ` for ${p.name}` : ''}, because ${why}.`)
951
+ good.push(...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message))
952
+ }
953
+ const link = urls.filter(Boolean)
954
+ const merge = pr?.from ? (steps.at(-1).title === 'Open a pull request' ? 'merge the pull request' : 'push the branch, open a pull request, merge it') : 'commit, open a pull request, merge it'
955
+ const then = link.length ? `Then you ${merge} and deploy as usual. Watch your ${link.length > 1 ? 'apps' : 'app'} connect at ${list(link)}`
956
+ : `Then you ${merge} and deploy as usual, and watch your app connect in your test copy of UKNOWS.`
957
+ return { app, nameNote: from ? `The name ${one.name} ${from}. To change it, use --service-name.` : null, steps, byHand: unique(byHand), good: unique(good), then }
958
+ }
959
+
960
+ /** A short description of a service: its stack and frameworks, and its folder when it is not this one. */
961
+ function describeShort(s) {
962
+ const frameworks = s.frameworks?.length ? ` (${s.frameworks.map(frameworkLabel).join(', ')})` : ''
963
+ return `${stackLabel(s.lang)}${frameworks}${s.dir && s.dir !== '.' ? ` in ${s.dir}` : ''}`
964
+ }
965
+
966
+ /**
967
+ * The dry run of a run with 1 Yes: the same plan, from this computer only (no sign-in, no cloud CLI, no network),
968
+ * then every line that changes and the steps that are left for the person.
969
+ */
970
+ async function printDryOnce(ui, d, report, ctx) {
971
+ const { env, plans, hosts, targets, secretName, ws, changes, commands, root, manifest, recordOnly = false } = ctx
972
+ const stores = dryStores(report.keySteps, targets, plans.length)
973
+ const ghStore = stores.stores.find((x) => x.t.kind === 'github')
974
+ const edits = deployEdits(repoView(ws), hosts, plans, secretName, { githubStep: ghStore && !ghStore.t.readsKey ? githubStepLines(ghStore.block) : null })
975
+ const pr = d.gitStatus(root, manifestPaths(manifest)).repo ? await prFacts((args, more = {}) => d.cli('git', ['-C', root, ...args], { cwd: root, env, ...more })) : null
976
+ const urls = plans.map((p) => connectUrl(p.name, null, appUrl(env)))
977
+ printOnePlan(ui, onePlan({ ...ctx, stores, edits, pr, urls, dry: true, keySteps: report.keySteps }))
978
+ ui.say('A real run in a terminal signs you in to UKNOWS in your browser first. Then it shows this plan with your workspace, and asks 1 question: Do all of this? (Y/n)')
979
+ const lines = diffLines(changes, commands, edits)
980
+ if (lines.some(Boolean)) {
981
+ ui.print()
982
+ ui.print('Every line that changes:')
983
+ ui.print()
984
+ for (const line of lines.slice(0, DIFF_LIMIT)) ui.print(line)
985
+ if (lines.length > DIFF_LIMIT) ui.print(`And ${plural(lines.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
986
+ }
987
+ printManual(ui, plans.flatMap((p) => p.manual))
988
+ // The key steps that the plan does not do, for the person. The ones it does are only for doing it by hand.
989
+ const byHand = report.keySteps.filter((b) => !stores.stores.some((x) => x.block.title === b.title) && !stores.skipped.some((x) => x.block.title === b.title))
990
+ if (byHand.length) {
991
+ ui.print()
992
+ ui.print(`You do ${byHand.length === 1 ? 'this step' : 'these steps'} yourself:`)
993
+ ui.print()
994
+ for (const block of byHand) ui.block(block)
995
+ } else ui.print()
996
+ ui.print('This was a dry run. Nothing changed.')
997
+ }
998
+
999
+ /**
1000
+ * The stores of a dry run, from the repo only, as plannedQuestions knows them: an Azure app when the deploy files name 1
1001
+ * app and the run has 1 service, a Kubernetes secret when the namespace is known, and the GitHub secret when nothing
1002
+ * stores on the platform or a workflow reads the secret. found holds what the plan can name without a lookup.
1003
+ */
1004
+ function dryStores(blocks, targets, services) {
1005
+ const out = { stores: [], left: [], skipped: [] }
1006
+ let direct = false
1007
+ for (const block of blocks.filter((b) => b.title.startsWith('Set your key once: '))) {
1008
+ const host = HOSTS.find((h) => block.title === `Set your key once: ${h.label}`)
1009
+ const t = host ? targets.get(host.id) : null
1010
+ if (host?.id === 'github-actions' && direct && !t?.readsKey) { out.skipped.push({ block, host: host.id }); continue }
1011
+ let found = null
1012
+ if (t && (t.kind === 'aca' || t.kind === 'webapp')) {
1013
+ const apps = pickApp(t.apps.map((a) => a.name))
1014
+ const groups = unique(t.groups.map((g) => g.name))
1015
+ if (apps.length === 1 && services === 1 && !(t.kind === 'webapp' && t.slots?.length)) {
1016
+ found = { kind: t.kind, name: apps[0], rg: groups.length === 1 ? groups[0] : 'the one that has this app', dry: true }
1017
+ }
1018
+ } else if (t?.kind === 'k8s') {
1019
+ const spaces = unique(t.workloads.map((w) => w.namespace))
1020
+ if (spaces.length === 1 && spaces[0]) found = { kind: 'k8s', ns: spaces[0], context: 'the one that has this app. A real run looks it up.', dry: true }
1021
+ } else if (t?.kind === 'github' && t.repo) {
1022
+ const g = githubSecret(t.jobs)
1023
+ if (!g.why) found = { kind: 'github', repo: t.repo, env: g.env === null ? null : 'the one of the environment that you pick when you sign in', dry: true }
1024
+ }
1025
+ if (!found) { out.left.push({ block, host: host?.id ?? null, why: null }); continue }
1026
+ out.stores.push({ block, host: host.id, t, found })
1027
+ if (t.kind !== 'github') direct = true
1028
+ }
1029
+ return out
1030
+ }
1031
+
1032
+ /** Said just before the page where the app connects opens in the browser. Nothing prints when no browser can open. */
1033
+ const OPENING = 'Opening it in your browser.'
1034
+
1035
+ /** A rerun with nothing to change whose files are not committed yet: setup itself has nothing more to change. */
1036
+ const RERUN_UNCOMMITTED = 'Already set up. Setup itself has nothing more to change.'
1037
+
1038
+ /** The key of x-uknows-key in the header text of a check, or null. */
1039
+ function bareKey(headers) {
1040
+ const pairs = parseHeaders(headers ?? '')
1041
+ const name = Object.keys(pairs).find((n) => n.toLowerCase() === SECRET.header)
1042
+ const key = name ? pairs[name].trim() : ''
1043
+ return key || null
1044
+ }
1045
+
1046
+ /**
1047
+ * Add the key to 1 deploy file, after a yes. The edit goes in the manifest like every other change, so remove gives back
1048
+ * every byte, and a rerun finds nothing to add.
1049
+ */
1050
+ function applyDeployEdit(ws, edit) {
1051
+ for (const file of [edit.file, CLI.manifest]) inside(ws, file)
1052
+ const abs = ws.abs(edit.file)
1053
+ const beforeBytes = readBytes(abs)
1054
+ if (beforeBytes === null || text(beforeBytes) !== edit.before) throw new Error('it changed while this ran')
1055
+ const bom = beforeBytes.toString('utf8').startsWith(BOM)
1056
+ if (!beforeBytes.equals(Buffer.from((bom ? BOM : '') + edit.before, 'utf8'))) throw new Error('it is not UTF-8 text')
1057
+ const manifest = readManifest(ws.root)
1058
+ if (!manifest) throw new Error(`${CLI.manifest} is missing`)
1059
+ const afterBytes = Buffer.from((bom ? BOM : '') + edit.after, 'utf8')
1060
+ const entry = changeEntry({ file: edit.file, before: edit.before, after: edit.after, bom }, beforeBytes, afterBytes)
1061
+ writeAtomic(abs, afterBytes)
1062
+ try {
1063
+ writeAtomic(ws.abs(CLI.manifest), Buffer.from(manifestText(mergeManifest(manifest, { services: [], changes: [entry], packages: [] }))))
1064
+ } catch (error) {
1065
+ writeAtomic(abs, beforeBytes)
1066
+ throw error
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * The dry run: the key steps and the questions that a run in a terminal asks after the plan. Not in a pipeline. It
1072
+ * reads git on this computer only: no cloud CLI and no network. Without a terminal, the full key steps come first. In
1073
+ * a terminal, the questions come first: the key steps that a question does follow them, under a heading that says
1074
+ * they are only for doing it by hand. Key steps that no question does stay before the questions, as steps to do.
1075
+ */
1076
+ async function printQuestions(ui, d, report, { env, hosts, plans, targets, secretName, view, files, manifest, root, apply }) {
1077
+ ui.print()
1078
+ if (ciVariable(env)) {
1079
+ for (const block of report.keySteps) ui.block(block)
1080
+ return
1081
+ }
1082
+ const edits = deployEdits(view, hosts, plans, secretName)
1083
+ const pr = d.gitStatus(root, manifestPaths(manifest)).repo ? await prFacts((args, more = {}) => d.cli('git', ['-C', root, ...args], { cwd: root, env, ...more })) : null
1084
+ const planned = plannedQuestions({ targets, edits, files: unique([...files, ...edits.map((e) => e.file)]), pr, services: plans.length })
1085
+ const questions = [{ question: apply ? 'Apply? (Y/n)' : `Write ${CLI.manifest}? (Y/n)` }, { question: KEY_QUESTION }, ...planned.questions]
1086
+ const asked = (block) => planned.hosts.some((id) => block.title.endsWith(`: ${HOSTS.find((h) => h.id === id)?.label}`))
1087
+ const byHand = ui.interactive ? report.keySteps.filter(asked) : []
1088
+ for (const block of report.keySteps.filter((b) => !byHand.includes(b))) ui.block(block)
1089
+ ui.print('In a terminal, a real run asks you:')
1090
+ questions.forEach((q, i) => {
1091
+ ui.print(` ${i + 1}. ${q.question}`)
1092
+ if (q.after) ui.print(` If you say no: ${q.after}`)
1093
+ })
1094
+ ui.print('Each question changes only what it says. When you answer no, setup prints that step for you to do.')
1095
+ ui.print()
1096
+ if (!byHand.length) return
1097
+ ui.print(BY_HAND)
1098
+ ui.print()
1099
+ for (const block of byHand) ui.block(block)
1100
+ }
1101
+
1102
+ /** The heading of the key steps that the questions of a real run do, in a dry run in a terminal. */
1103
+ const BY_HAND = 'These steps are only for doing it by hand. When you answer yes, the questions above do them for you:'
1104
+
1105
+ /**
1106
+ * The required step for an App Service code deploy (a zip or Oryx deploy, not an image): App Service never runs the
1107
+ * Dockerfile, so the start line and the settings that this tool put there must be set on App Service too. Only for a
1108
+ * service whose settings went into a Dockerfile.
1109
+ */
1110
+ function appServiceCode(p, names, view = null) {
1111
+ const deploy = names.code?.find((c) => c.kind === 'deploy')
1112
+ if (!deploy || p.settings.target?.kind !== 'dockerfile') return []
1113
+ const T = APP_SERVICE_CODE
1114
+ const stack = STACKS.find((x) => x.id === p.stack)
1115
+ const pairs = [...(p.settings.pairs ?? []), ...(p.mode === 'standard' ? (stack?.platformEnv ?? []).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]) : [])]
1116
+ const web = names.webapp ?? { apps: [], groups: [], slots: [] }
1117
+ const group = new Set(web.groups.map((g) => g.name)).size === 1 ? web.groups[0] : null
1118
+ // The code goes to a deployment slot (then a swap moves it into production): the settings and the startup command go on
1119
+ // that slot too, as the deploy of the repo does. Plain app settings move with the code on swap. A slot that only a
1120
+ // workflow input or variable holds (slotUnknown) keeps an example SLOT, which the user replaces.
1121
+ const slot = deploy.slot ?? null, unknown = deploy.slotUnknown ?? null
1122
+ const scriptNames = { ...(web.apps[0] ? { app: web.apps[0].name } : {}), ...(group ? { resourceGroup: group.name } : {}),
1123
+ ...(slot || unknown ? { slot: slot ?? unknown } : {}), ...(unknown ? { slotUnknown: true } : {}),
1124
+ from: { ...(web.apps[0] ? { app: web.apps[0].where } : {}), ...(group ? { resourceGroup: group.where } : {}), ...(slot || unknown ? { slot: deploy.where } : {}) } }
1125
+ const values = { where: deploy.where, dockerfile: p.settings.target.file, count: pairs.length }
1126
+ const lines = T.lines.map((l) => fillIn(l, values))
1127
+ if (unknown) lines.push(`${deploy.where} deploys to the slot ${unknown}. Set SLOT below to the name of that slot.`)
1128
+ const snippets = [settingsSnippet('azure-app-service', pairs, p.name, scriptNames)]
1129
+ const prefix = p.mode === 'standard' ? START_PREFIX[p.stack] : null
1130
+ if (!prefix) return [{ title: T.title, lines, snippets }]
1131
+ // The startup command of the slot that gets the code, else the first one.
1132
+ const starts = names.code.filter((c) => c.kind === 'startup')
1133
+ const startup = starts.find((c) => (c.slot ?? null) === slot && (c.slotUnknown ?? null) === unknown) ?? starts[0]
1134
+ if (startup?.startup.startsWith(prefix)) {
1135
+ // The deploy sets the startup command with the prefix already. The step is done when the deploy files also set
1136
+ // every setting. Else only the settings are left, and they may be set on the platform, so the repo cannot show them.
1137
+ return view && settingsInFiles(view, names, pairs) ? [] : [{ title: T.title, lines, snippets }]
1138
+ }
1139
+ const example = p.stack === 'python' ? stack.start.replace(/^opentelemetry-instrument\s+/, '') : `dotnet ${p.name}.dll`
1140
+ const command = startup ? `${prefix} ${startup.startup}` : `${prefix} ${example}`
1141
+ lines.push(startup ? fillIn(T.start, { startWhere: startup.where, startup: startup.startup, prefix }) : fillIn(T.startNone, { prefix }))
1142
+ // The same first lines as the settings snippet: the comment and the names, up to its az line.
1143
+ const head = settingsSnippet('azure-app-service', [], p.name, scriptNames).code.split('\n')
1144
+ snippets.push({ caption: 'The startup command of your web app', language: 'bash',
1145
+ code: [...head.slice(0, head.findIndex((l) => l.startsWith('az '))),
1146
+ `az webapp config set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP"${slot || unknown ? ' --slot "$SLOT"' : ''} -o none --startup-file "${command.replace(/"/g, '\\"')}"`].join('\n') })
1147
+ // A deploy file sets the startup command, so each deploy sets it again: the repo shows when the step is done (F-012-10).
1148
+ // With no startup command in the deploy files, it is set on the platform, and the repo cannot show it.
1149
+ return [{ title: T.title, ...(startup ? { verify: true } : {}), lines, snippets }]
1150
+ }
1151
+
1152
+ /**
1153
+ * The deploy files of the App Service code deploy set each setting: as NAME=value (az --settings), "name": "NAME" (the
1154
+ * JSON of azure/appservice-settings), or NAME: value and name: NAME (YAML). Comment lines do not count.
1155
+ */
1156
+ function settingsInFiles(view, names, pairs) {
1157
+ const files = [...new Set(names.code.map((c) => c.where.replace(/ line \d+$/, '')))]
1158
+ const body = files.map((f) => { try { return view.read(f) ?? '' } catch { return '' } }).join('\n').replace(/^[ \t]*(?:#|\/\/).*$/gm, '')
1159
+ return pairs.every(([name]) => {
1160
+ const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1161
+ return new RegExp(`(?:^|[\\s"'(,\\[])${n}=|"name"\\s*:\\s*"${n}"|^[ \\t]*(?:-[ \\t]+)?(?:name[ \\t]*:[ \\t]*["']?${n}["']?[ \\t]*$|${n}[ \\t]*:)`, 'm').test(body)
1162
+ })
258
1163
  }
259
1164
 
260
1165
  /**
@@ -262,7 +1167,7 @@ async function setup(opts, d, report) {
262
1167
  * failure, put every file back and return null. Returns the original bytes
263
1168
  * of every path it touched.
264
1169
  */
265
- async function apply({ root, ws, changes, commands, manifest, services, d }) {
1170
+ async function apply({ root, ws, changes, commands, manifest, services, updates = null, d }) {
266
1171
  const { ui } = d
267
1172
  // Ctrl-C stops the package manager. Stay alive, so the failed run is rolled back below.
268
1173
  const hold = () => {}
@@ -298,22 +1203,55 @@ async function apply({ root, ws, changes, commands, manifest, services, d }) {
298
1203
  written.set(c.file, bytes)
299
1204
  }
300
1205
  for (const command of commands) for (const file of command.touches) save(file)
1206
+ const locked = []
301
1207
  for (const command of commands) {
302
1208
  const result = await d.spawn(command.cmd, command.args, { cwd: ws.abs(command.cwd) })
303
1209
  if (result.code !== 0) {
304
1210
  undo()
305
- ui.error(`${command.cmd} failed${result.error ? `: ${result.error}` : ''}. Nothing changed.`)
306
- ui.error(` ${[command.cmd, ...command.args].join(' ')}${command.cwd === '.' ? '' : ` (in ${command.cwd})`}`)
1211
+ const failed = command.kind === 'lock' && !result.error ? resolverFailure(command, result.output ?? '') : null
1212
+ if (failed) {
1213
+ for (const line of [failed.title, ...failed.lines]) ui.error(line)
1214
+ } else {
1215
+ ui.error(`${command.cmd} failed${result.error ? `: ${result.error}` : ''}. Nothing changed.`)
1216
+ ui.error(` ${[command.cmd, ...command.args].join(' ')}${command.cwd === '.' ? '' : ` (in ${command.cwd})`}`)
1217
+ }
1218
+ return null
1219
+ }
1220
+ if (command.kind !== 'lock') continue
1221
+ // After a restore, the packages the build check sees must match the plan (dotnet.mjs verifyRestore).
1222
+ const problem = command.verify ? d.verify(command.verify, (file) => { const b = readBytes(ws.abs(file)); return b === null ? null : text(b) }) : null
1223
+ if (problem) {
1224
+ undo()
1225
+ for (const line of [problem.title, ...problem.lines]) ui.error(line)
307
1226
  return null
308
1227
  }
1228
+ // A lock refresh must keep the lockfile format (the uv in the image reads only its own version).
1229
+ for (const { file, pattern } of command.keep ?? []) {
1230
+ const re = new RegExp(pattern, 'm')
1231
+ const was = re.exec(text(saved.get(file) ?? Buffer.alloc(0)))?.[0] ?? null, now = re.exec(text(readBytes(ws.abs(file)) ?? Buffer.alloc(0)))?.[0] ?? null
1232
+ if (was !== now) {
1233
+ undo()
1234
+ ui.error(`${command.cmd} changed ${file} from "${was}" to "${now}". The ${command.cmd} in your image may not read it. Nothing changed.`)
1235
+ ui.error(`Install the ${command.cmd} version that your image uses. Then run npx @uknows/setup again.`)
1236
+ return null
1237
+ }
1238
+ }
1239
+ locked.push(command)
309
1240
  }
310
1241
  const entries = changes.map((c) => changeEntry(c, saved.get(c.file), written.get(c.file)))
311
- const packages = commands.map((c) => ({ cwd: c.cwd, cmd: c.cmd, added: c.added, undo: [c.undo.cmd, ...c.undo.args], touches: c.touches,
312
- before: Object.fromEntries(c.touches.map((file) => [file, sha256(saved.get(file))])) }))
1242
+ // Files that a lock refresh rewrote are ordinary text changes, so remove restores them byte for byte.
1243
+ for (const file of unique(locked.flatMap((c) => c.touches))) {
1244
+ const before = saved.get(file), after = readBytes(ws.abs(file))
1245
+ if (after === null || (before && before.equals(after))) continue
1246
+ entries.push(changeEntry({ file, before: before ? text(before) : null, after: text(after), bom: Boolean(before && text(before) !== before.toString('utf8')) },
1247
+ before, after))
1248
+ }
1249
+ const packages = commands.filter((c) => c.kind !== 'lock').map((c) => ({ cwd: c.cwd, cmd: c.cmd, added: c.added, undo: [c.undo.cmd, ...c.undo.args],
1250
+ touches: c.touches, before: Object.fromEntries(c.touches.map((file) => [file, sha256(saved.get(file))])) }))
313
1251
  save(CLI.manifest)
314
1252
  const file = ws.abs(CLI.manifest)
315
1253
  makeDirs(dirname(file), made)
316
- writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages }))))
1254
+ writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages, updates }))))
317
1255
  return saved
318
1256
  } catch (error) {
319
1257
  undo()
@@ -344,6 +1282,7 @@ async function remove(opts, d, report) {
344
1282
  }
345
1283
  ui.print(` Delete ${CLI.manifest}.`)
346
1284
  if (opts.json && !opts.yes) return ['planned', 0]
1285
+ if (stopInCi(ui, report, opts)) return ['planned', 2]
347
1286
  if (!opts.yes) {
348
1287
  if (!ui.interactive) {
349
1288
  ui.print('Run again with --yes to remove.')
@@ -403,42 +1342,120 @@ async function remove(opts, d, report) {
403
1342
  return ['removed', 0]
404
1343
  }
405
1344
 
406
- /** The check command as the reader should type it, with the test addresses of this run. */
407
- function checkCommand(env) {
408
- const vars = [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
409
- return [...vars, 'npx @uknows/setup check'].join(' ')
1345
+ /** The question that asks for the key. */
1346
+ const KEY_QUESTION = 'Paste your UKNOWS key to check it now. It does not show on the screen. Press Enter to skip:'
1347
+
1348
+ /** The test addresses of this run, as settings in front of the check command. */
1349
+ function checkVars(env) {
1350
+ return [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
410
1351
  }
411
1352
 
412
1353
  /**
413
- * Check your key, right after setup: the key from this terminal, or one pasted now. The pasted key is never
414
- * written anywhere and never shows on the screen. Returns the --json check entry, or null when skipped.
1354
+ * Check your key: the key from this terminal, a sign-in in the browser, or a key pasted now (--paste-key, or p while
1355
+ * setup waits for the sign-in). The key is never written anywhere and never shows on the screen. auto: --yes in a
1356
+ * terminal, so check the key in this terminal without a question. paste: after a no to the key of this terminal, get
1357
+ * another key (the key command, which is for another environment). once: a run with 1 Yes, before its plan: a key in
1358
+ * this terminal is used without a question, since the plan names its workspace and environment before the 1 Yes.
1359
+ * pasteKey: --paste-key. app: what the browser shows about this app while signing in.
1360
+ * Returns {entry, headers, from, link}: the --json check entry, the header text that was checked (in memory only, for
1361
+ * the key steps in a terminal), where the key came from ('terminal', 'signin' or 'paste') and the answer of the sign-in.
1362
+ * null when skipped. {stopped, interrupt} when the person stopped the sign-in (q, or Ctrl+C).
415
1363
  */
416
- async function checkKey(ui, d, env) {
417
- ui.print()
418
- ui.print('Check your key')
419
- let headers = null
420
- if (env[SECRET.name]?.trim()) {
421
- if (await ui.confirm(`Check the key in ${SECRET.name} from this terminal now? (Y/n)`)) headers = env[SECRET.name]
422
- } else {
423
- const key = (await ui.secret('Paste your UKNOWS key to check it now. It does not show on the screen. Press Enter to skip:')).trim()
424
- // Accept the key as copied, also with the header name or quotes around it.
425
- const bare = key.replace(/^["']|["']$/g, '').replace(new RegExp(`^${SECRET.header}\\s*[=:]\\s*`, 'i'), '').trim()
426
- if (bare) headers = `${SECRET.header}=${bare}`
1364
+ async function checkKey(ui, d, env, auto = false, serviceName = null, { paste = false, once = false, pasteKey = false, app = null } = {}) {
1365
+ if (!once) {
1366
+ ui.print()
1367
+ ui.print('Check your key')
1368
+ }
1369
+ let headers = null, from = null, link = null
1370
+ const inTerminal = Boolean(env[SECRET.name]?.trim())
1371
+ if (auto) {
1372
+ ui.print(`Checking the key in ${SECRET.name} from this terminal.`)
1373
+ headers = env[SECRET.name]
1374
+ from = 'terminal'
1375
+ } else if (inTerminal && once) {
1376
+ ui.print(`Using the key in ${SECRET.name} from this terminal.`)
1377
+ headers = env[SECRET.name]
1378
+ from = 'terminal'
1379
+ } else if (inTerminal && (await ui.confirm(`Check the key in ${SECRET.name} from this terminal now? (Y/n)`))) {
1380
+ headers = env[SECRET.name]
1381
+ from = 'terminal'
1382
+ } else if (!inTerminal || paste) {
1383
+ const got = await keyFromPerson(ui, d, env, { pasteKey, app })
1384
+ if (got.stopped) return got
1385
+ ;({ headers, from, link } = got)
427
1386
  }
428
1387
  if (!headers) return null
429
- const result = await d.runCheck({ env, headers })
1388
+ // The test span carries the name of the service of this run, so the workspace never lists a made-up app.
1389
+ // A timeout gets 1 more try, which can take up to 30 seconds: say so, so the wait is not a surprise.
1390
+ // The key of the sign-in is new: the data intake of UKNOWS learns it about 30 seconds later, so the check waits for
1391
+ // it, for up to 2 minutes, with 1 line (check.mjs NEW_KEY). A pasted key or the key of this terminal gets 1 try.
1392
+ const result = await d.runCheck({ env, headers, serviceName, onRetry: (line) => ui.print(` ${line}`),
1393
+ newKey: from === 'signin', clock: d.clock, onWait: () => newKeyLine(ui, d.clock) })
430
1394
  for (const line of result.lines) (result.ok ? ui.print : ui.error)(` ${line}`)
431
1395
  ui.print()
432
1396
  const status = result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
433
- return { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }
1397
+ return { entry: { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }, headers, from, link,
1398
+ notReady: Boolean(result.notReady) }
434
1399
  }
435
1400
 
436
- function printPlan(ui, plans, detection, changes, commands, opts) {
1401
+ /** The 1 line while a new key gets ready (check.mjs NEW_KEY): a spinner with the time on a terminal, else 1 plain line. */
1402
+ function newKeyLine(ui, clock) {
1403
+ const started = clock.now()
1404
+ if (!ui.tty) ui.print(` ${NEW_KEY.waitingPlain}`)
1405
+ const spin = ui.progress(() => `${NEW_KEY.waiting} ${clockText(clock.now() - started)}`, ' ')
1406
+ return (line) => spin.end(line === null ? null : ` ${line}`)
1407
+ }
1408
+ const clockText = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }
1409
+
1410
+ /**
1411
+ * A key from the person at the terminal: a sign-in in the browser, or the hidden paste question (--paste-key, p while
1412
+ * waiting, or a test copy of UKNOWS with no app address). {headers, from, link}, {headers: null} when skipped, or
1413
+ * {stopped, interrupt}.
1414
+ */
1415
+ async function keyFromPerson(ui, d, env, { pasteKey = false, app = null } = {}) {
1416
+ const base = appUrl(env)
1417
+ // A pipeline never starts a sign-in (only --allow-ci in a terminal gets here): it can only paste.
1418
+ let paste = pasteKey || Boolean(ciVariable(env))
1419
+ if (!paste && !base) {
1420
+ // A test address without the app of that UKNOWS: never sign in at the production app. Say which setting is missing.
1421
+ for (const line of SIGN_IN.noApp([CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]))) ui.print(line)
1422
+ paste = true
1423
+ }
1424
+ if (!paste) {
1425
+ const r = await d.signIn(ui, { base, app, env, openUrl: d.openUrl, request: d.request, clock: d.clock })
1426
+ if (r.stopped) return { stopped: true, interrupt: Boolean(r.interrupt) }
1427
+ if (r.approved) return { headers: `${SECRET.header}=${r.key}`, from: 'signin', link: r }
1428
+ paste = r.pressed ? 'pressed' : true
1429
+ }
1430
+ let key = (await ui.secret(KEY_QUESTION)).trim()
1431
+ // After p, an empty answer is more often a stray Enter than a choice to skip: ask once more.
1432
+ if (!key && paste === 'pressed') key = (await ui.secret('Nothing was pasted. Paste your key, or press Enter again to skip:')).trim()
1433
+ // Accept the key as copied, also with the header name or quotes around it.
1434
+ const bare = key.replace(/^["']|["']$/g, '').replace(new RegExp(`^${SECRET.header}\\s*[=:]\\s*`, 'i'), '').trim()
1435
+ return { headers: bare ? `${SECRET.header}=${bare}` : null, from: 'paste', link: null }
1436
+ }
1437
+
1438
+ /**
1439
+ * A sign-in answer whose ingest address is not the one that this run writes: the app would send to the wrong UKNOWS.
1440
+ * Only a test copy of UKNOWS can get here (UKNOWS_APP_URL without UKNOWS_INGEST_ENDPOINT). The text, or null.
1441
+ */
1442
+ function addressMismatch(link, endpoint) {
1443
+ const norm = (u) => { try { const x = new URL(String(u).trim()); return `${x.origin}${x.pathname.replace(/\/+$/, '')}`.toLowerCase() } catch { return null } }
1444
+ if (!link?.ingestEndpoint || !norm(link.ingestEndpoint) || norm(link.ingestEndpoint) === norm(endpoint)) return null
1445
+ return `UKNOWS sends the data of this key to ${link.ingestEndpoint}, but this run would write ${endpoint}. Set ${CLI.endpointEnv}=${link.ingestEndpoint} and run setup again. Nothing changed.`
1446
+ }
1447
+
1448
+ /** Where the app runs, from the deploy files. */
1449
+ function printHosts(ui, detection) {
437
1450
  ui.print('Runs on:')
438
1451
  for (const h of detection.hosts) {
439
1452
  const files = unique(h.evidence.map((e) => e.file))
440
1453
  ui.print(` ${hostLabel(h.id)}. ${files.length ? `Found in ${files.join(', ')}.` : 'No deploy files found, so the steps are the general ones.'}`)
441
1454
  }
1455
+ }
1456
+
1457
+ function printPlan(ui, plans, detection, changes, commands, opts) {
1458
+ printHosts(ui, detection)
442
1459
  for (const p of plans) {
443
1460
  const from = opts.serviceName ? 'from --service-name' : p.s.nameFrom === 'existing' ? 'already set in the repo'
444
1461
  : p.s.nameFrom === 'folder' ? 'from the folder name' : `from ${p.s.nameFrom}`
@@ -448,9 +1465,12 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
448
1465
  ui.print(`Plan for ${p.name}:`)
449
1466
  } else ui.print(`Plan for ${p.name} in ${p.s.dir} (name ${from}):`)
450
1467
  steps(p).forEach((step, i) => ui.print(` ${i + 1}. ${step}`))
451
- if (p.manual.length) ui.print(` After this, you do ${plural(p.manual.length, 'step')} by hand: ${p.manual.map((b) => b.title).join('. ')}.`)
1468
+ const required = p.manual.filter((b) => b.required)
1469
+ if (required.length) ui.print(` After this, you do ${plural(required.length, 'step')} by hand: ${required.map((b) => b.title).join('. ')}.`)
452
1470
  const chosen = p.s.evidence?.find((e) => e.what.startsWith('Dockerfile for this service: '))
453
- if (chosen) ui.print(` Dockerfile: ${chosen.file}, because ${chosen.what.slice('Dockerfile for this service: '.length)}.`)
1471
+ // "the Dockerfile Dockerfile" reads as a typo: a file whose name says Dockerfile is "the file".
1472
+ const kind = /dockerfile/i.test(chosen?.file.split('/').pop() ?? '') ? 'file' : 'Dockerfile'
1473
+ if (chosen) ui.print(` This tool uses the ${kind} ${chosen.file} for this service, because ${chosen.what.slice('Dockerfile for this service: '.length)}.`)
454
1474
  for (const note of [...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message)]) ui.print(` Note: ${note}`)
455
1475
  }
456
1476
  ui.print()
@@ -461,8 +1481,7 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
461
1481
  if (created.length) ui.print(`Files to add: ${created.join(', ')}`)
462
1482
  ui.print(`It also writes ${CLI.manifest}, so you can undo this with npx @uknows/setup remove.`)
463
1483
  // Exactly what changes, before anything does: the diff of every file edit, and what each package manager adds.
464
- const out = [...changes.flatMap((c) => unified(c.file, c.before, c.after).split('\n').map(redactKey)),
465
- ...commands.map((c) => `${list(c.touches)}: ${c.cmd} adds ${list(c.added ?? [])}.`)]
1484
+ const out = diffLines(changes, commands)
466
1485
  if (out.length) {
467
1486
  ui.print()
468
1487
  ui.print('The changes:')
@@ -471,16 +1490,55 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
471
1490
  if (out.length > DIFF_LIMIT) ui.print(`And ${plural(out.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
472
1491
  ui.print()
473
1492
  }
474
- const tools = unique(commands.map((c) => c.cmd))
1493
+ for (const line of runNotes(commands)) ui.print(line)
1494
+ }
1495
+
1496
+ /** Every line that changes: the diff of each file edit, and what each package manager adds or updates. */
1497
+ function diffLines(changes, commands, edits = []) {
1498
+ return [...changes.flatMap((c) => unified(c.file, c.before, c.after).split('\n').map(redactKey)),
1499
+ ...commands.map((c) => (c.kind === 'lock' ? `${list(c.touches)}: ${[c.cmd, ...c.args].join(' ')} updates it.` : `${list(c.touches)}: ${c.cmd} adds ${list(c.added ?? [])}.`)),
1500
+ ...edits.flatMap((e) => unified(e.file, e.before, e.after).split('\n').map(redactKey))]
1501
+ }
1502
+
1503
+ /** What the package managers and lock refreshes of the plan run, and what they write. */
1504
+ function runNotes(commands) {
1505
+ const out = []
1506
+ const installs = commands.filter((c) => c.kind !== 'lock'), locks = commands.filter((c) => c.kind === 'lock')
1507
+ const tools = unique(installs.map((c) => c.cmd))
475
1508
  if (tools.length) {
476
- ui.print(`This runs ${tools.join(' and ')} with install scripts turned off. ${commands.every((c) => c.lockOnly)
477
- ? `${tools.length === 1 ? 'It writes' : 'They write'} only ${list(unique(commands.flatMap((c) => c.touches)))}.`
1509
+ out.push(`This runs ${tools.join(' and ')} with install scripts turned off. ${installs.every((c) => c.lockOnly)
1510
+ ? `${tools.length === 1 ? 'It writes' : 'They write'} only ${list(unique(installs.flatMap((c) => c.touches)))}.`
478
1511
  : `${tools.length === 1 ? 'It downloads' : 'They download'} the packages.`}`)
479
1512
  }
1513
+ if (locks.length) {
1514
+ // dotnet restore also fills the NuGet folder and writes obj/ folders, which remove does not undo. uv and Poetry write the lock only.
1515
+ const restores = locks.filter((c) => c.cmd === 'dotnet'), others = locks.filter((c) => c.cmd !== 'dotnet')
1516
+ const files = (cs) => list(unique(cs.flatMap((c) => c.touches)))
1517
+ // The note on project build code names only the lock commands of this run.
1518
+ const tools = list(unique(locks.map((c) => `${c.cmd} ${c.args[0]}`)))
1519
+ out.push([`This runs ${list(unique(locks.map((c) => [c.cmd, ...c.args].join(' '))))}.`,
1520
+ ...(others.length ? [fillIn(RESOLVE.writes, { command: list(unique(others.map((c) => `${c.cmd} ${c.args[0]}`))), files: files(others) })] : []),
1521
+ ...(restores.length ? [fillIn(RESOLVE.restoreWrites, { files: files(restores) })] : []), fillIn(RESOLVE.buildCode, { commands: tools })].join(' '))
1522
+ }
1523
+ return out
480
1524
  }
481
1525
 
482
1526
  /** Print the diff of every file this run changed. Returns the --json files list. */
483
1527
  function printResult(ui, ws, saved, changes, commands) {
1528
+ const { files, out } = resultFiles(ws, saved, changes, commands)
1529
+ ui.print()
1530
+ ui.print(`Done. Changed ${plural(files.length, 'file')}:`)
1531
+ ui.print()
1532
+ for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
1533
+ if (out.length > DIFF_LIMIT) ui.print('Run git diff to see the rest.')
1534
+ ui.print()
1535
+ ui.print(`To undo this, run npx @uknows/setup remove. ${CLI.manifest} lists what changed. It has no secrets.`)
1536
+ ui.print()
1537
+ return files
1538
+ }
1539
+
1540
+ /** The --json files list of what this run changed, and the lines that say how (a diff when it differs from the plan). */
1541
+ function resultFiles(ws, saved, changes, commands) {
484
1542
  const files = [], out = []
485
1543
  for (const [file, before] of saved) {
486
1544
  if (file === CLI.manifest) continue
@@ -502,55 +1560,143 @@ function printResult(ui, ws, saved, changes, commands) {
502
1560
  out.push(...unified(file, a, b).split('\n').map(redactKey))
503
1561
  }
504
1562
  }
505
- ui.print()
506
- ui.print(`Done. Changed ${plural(files.length, 'file')}:`)
507
- ui.print()
508
- for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
509
- if (out.length > DIFF_LIMIT) ui.print('Run git diff to see the rest.')
510
- ui.print()
511
- ui.print(`To undo this, run npx @uknows/setup remove. ${CLI.manifest} lists what changed. It has no secrets.`)
512
- ui.print()
513
- return files
1563
+ return { files, out }
514
1564
  }
515
1565
 
516
- function printManual(ui, manual, gap = true) {
517
- if (!manual.length) return
518
- if (gap) ui.print()
519
- ui.print(`You need to do ${plural(manual.length, 'more step')}:`)
520
- ui.print()
521
- for (const block of manual) ui.block(block)
1566
+ /** Required steps first, then advice. Only the required steps count toward exit 3. title: another heading for the required steps. */
1567
+ function printManual(ui, manual, gap = true, title = null) {
1568
+ const required = manual.filter((b) => b.required), advice = manual.filter((b) => !b.required)
1569
+ for (const [blocks, heading] of [[required, title ?? `You need to do ${plural(required.length, 'more step')}:`], [advice, 'Advice:']]) {
1570
+ if (!blocks.length) continue
1571
+ if (gap) ui.print()
1572
+ gap = true
1573
+ ui.print(heading)
1574
+ ui.print()
1575
+ for (const block of blocks) ui.block(block)
1576
+ }
1577
+ }
1578
+
1579
+ /**
1580
+ * In a pipeline, setup and remove change nothing, unless --allow-ci. Prints what happened and 1 next step, with the
1581
+ * link to CI/CD and pipelines on /docs. True when it stopped.
1582
+ */
1583
+ function stopInCi(ui, report, opts, env = opts.env ?? {}) {
1584
+ const name = ciVariable(opts.env)
1585
+ if (!name || opts.allowCi) return false
1586
+ ui.block({ title: CI_CD.title, lines: [`${name} is set. ${CI_CD.stop}`, fillIn(CI_CD.next, { docs: `${docsUrl(env)}#${CI_CD.id}` }), CI_CD.allowCi] })
1587
+ report.problems.push({ code: 'ci', message: `${name} is set, so this run counts as a pipeline. ${CI_CD.allowCi}` })
1588
+ ui.print('Nothing changed.')
1589
+ return true
522
1590
  }
523
1591
 
524
1592
  const where = (dir) => (dir === '.' ? 'this folder' : dir)
525
- /** Drop empty lines from a block, so a docs note that is gone never prints as 'undefined'. */
526
- const tidy = (block) => ({ ...block, lines: block.lines.filter((line) => typeof line === 'string' && line) })
527
- const steps = (p) => unique([...p.plan.commands.map((c) => c.why), ...p.plan.summary, ...p.settings.summary])
1593
+ const fillIn = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
1594
+ /**
1595
+ * Drop empty lines from a block, so a docs note that is gone never prints as 'undefined'. A block is required
1596
+ * unless its producer says required: false (advice, such as a note on sampling).
1597
+ */
1598
+ const tidy = (block) => ({ ...block, lines: block.lines.filter((line) => typeof line === 'string' && line), required: block.required !== false })
1599
+ /** Plan sentences: package installs, then the edits, then lock refreshes (they run after the edits), then the settings. */
1600
+ const steps = (p) => unique([...p.plan.commands.filter((c) => c.kind !== 'lock').map((c) => c.why), ...p.plan.summary,
1601
+ ...p.plan.commands.filter((c) => c.kind === 'lock').map((c) => c.why), ...p.settings.summary])
528
1602
  const text = (bytes) => { const t = bytes.toString('utf8'); return t.startsWith(BOM) ? t.slice(1) : t }
529
1603
 
530
1604
  function describe(s, withDir = false) {
531
1605
  const frameworks = s.frameworks.length ? ` (${s.frameworks.map(frameworkLabel).join(', ')})` : ''
532
- const start = !s.start ? 'Its start command is not clear.'
533
- : s.start.kind === 'package.json' ? `It starts from the start script in ${s.start.file}.`
534
- : `It starts from ${s.start.file} line ${s.start.line}.`
1606
+ const start = s.functions ? 'The Azure Functions host starts it.' : !s.start ? 'Its start command is not clear.'
1607
+ : s.start.kind === 'package.json' ? `It starts with the start script in ${s.start.file}.`
1608
+ : s.start.kind === 'dockerfile' ? `It starts with the start command in ${s.start.file} line ${s.start.line}.`
1609
+ : `It starts with ${s.start.file} line ${s.start.line}.`
535
1610
  return `${stackLabel(s.lang)}${frameworks}${withDir ? ` in ${where(s.dir)}` : ''}. ${start}`
536
1611
  }
537
1612
 
538
1613
  /** A service in the --json report. start never holds the command text. */
539
1614
  function serviceReport(s, extra = {}) {
540
- return { dir: s.dir, lang: s.lang, stack: null, name: s.name, frameworks: s.frameworks,
1615
+ return { dir: s.dir, lang: s.lang, stack: null, mode: s.mode ?? null, name: s.name, frameworks: s.frameworks,
541
1616
  start: s.start ? { kind: s.start.kind, file: s.start.file, line: s.start.line } : null, settings: null,
542
1617
  summary: [], commands: [], manual: [], notes: [], otel: [], apm: [], ...extra }
543
1618
  }
544
1619
 
545
- /** Run a package manager. Its output goes to stderr, so --json stdout stays clean. */
546
- export function runCommand(cmd, args, { cwd }) {
1620
+ /**
1621
+ * Probe the local tools that the services can need, once, before planning: uv and Poetry for a Python service when
1622
+ * the repo has their lockfile, dotnet for a locked .NET restore, npm for a Node.js service. Returns name -> version,
1623
+ * or null when missing.
1624
+ */
1625
+ function probeTools(d, repo, items) {
1626
+ const tools = {}
1627
+ // The channel mode changes no packages, so it needs no package tool.
1628
+ const active = items.filter((it) => it.mode !== 'stop' && it.mode !== 'otel' && it.mode !== 'channel')
1629
+ if (active.some((it) => it.s.lang === 'python')) {
1630
+ let files = []
1631
+ try { files = repo.files() } catch { /* unreadable */ }
1632
+ for (const [name, re] of Object.entries(TOOLS)) if (files.some((f) => re.test(f))) tools[name] = d.probe(name)
1633
+ }
1634
+ // .NET with a locked restore: dotnet restore refreshes the lock, so it needs an SDK.
1635
+ if (active.some((it) => it.s.lang === 'dotnet' && lockedMode(it.s, repo, webProject(it.s, repo)))) tools.dotnet = d.probe('dotnet')
1636
+ // Node.js: npm here writes the package-lock.json that npm ci in the image reads (stacks/node.mjs npmGuard).
1637
+ if (active.some((it) => it.s.lang === 'node' && it.mode === 'standard')) tools.npm = d.probe('npm')
1638
+ return tools
1639
+ }
1640
+
1641
+ /**
1642
+ * The version of a local tool from `<cmd> --version`, or null when it is missing or fails. For dotnet, the newest
1643
+ * SDK from `dotnet --list-sdks` (the SDK, not the runtime, restores). Runs nothing else.
1644
+ */
1645
+ export function probeTool(cmd) {
1646
+ const args = cmd === 'dotnet' ? ['--list-sdks'] : ['--version']
1647
+ const run = (shell) => spawnSync(cmd, args, { encoding: 'utf8', timeout: 15000, shell, stdio: ['ignore', 'pipe', 'pipe'] })
1648
+ try {
1649
+ let r = run(false)
1650
+ if (r.error?.code === 'ENOENT' && process.platform === 'win32' && /^[\w.-]+$/.test(cmd)) r = run(true)
1651
+ if (r.status !== 0) return null
1652
+ if (cmd === 'dotnet') {
1653
+ const sdks = [...r.stdout.matchAll(/^(\d+\.\d+\.\d+[\w.-]*)\s+\[/gm)].map((m) => m[1])
1654
+ return sdks.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1) ?? null
1655
+ }
1656
+ return /(\d+\.\d+(?:\.\d+)?)/.exec(`${r.stdout} ${r.stderr}`)?.[1] ?? '0'
1657
+ } catch { return null }
1658
+ }
1659
+
1660
+ /**
1661
+ * Why a lock command failed, from its output: the resolver text (uv "No solution found", Poetry "version solving
1662
+ * failed", NuGet "error NU1xxx") and the package it names. null when the output has none of these.
1663
+ */
1664
+ export function resolverFailure(command, output) {
1665
+ const lines = output.split(/\r?\n/).map((l) => l.replace(/\u001b\[[0-9;]*m/g, '').trimEnd())
1666
+ const start = lines.findIndex((l) => /No solution found|version solving failed|SolverProblemError|Because .* depends on/.test(l))
1667
+ const nuget = lines.filter((l) => /\berror NU\d{4}\b/.test(l))
1668
+ if (start < 0 && !nuget.length) return null
1669
+ let text = nuget.length ? [...new Set(nuget.map((l) => l.trim()))].slice(0, 5) : []
1670
+ if (start >= 0) {
1671
+ const end = lines.findIndex((l, i) => i > start && !l.trim() && i - start > 1)
1672
+ text = lines.slice(start, end < 0 ? start + 14 : Math.min(end, start + 14)).map((l) => l.trim()).filter(Boolean)
1673
+ }
1674
+ const named = /Because (?:only )?(?:the following versions of )?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(text.join(' '))?.[1]
1675
+ ?? /Unable to find (?:a stable )?package '?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(text.join(' '))?.[1]
1676
+ ?? /\b(?:on|for) ([A-Za-z0-9][A-Za-z0-9._-]*) (?:\(|[<>=])/.exec(text.join(' '))?.[1] ?? 'a package'
1677
+ const values = { tool: command.cmd, command: [command.cmd, ...command.args].join(' '), package: named }
1678
+ const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
1679
+ return { title: fill(RESOLVE.failed.title), lines: [fill(RESOLVE.failed.lines[0]), ...text.map((l) => ` ${l}`), fill(RESOLVE.failed.lines[1])], package: named }
1680
+ }
1681
+
1682
+ /**
1683
+ * Run a package manager. Its output goes to stderr, so --json stdout stays clean, or nowhere with quiet (the list of
1684
+ * steps of a run with 1 Yes). The end of the output comes back too (output), so a failed resolver can say why.
1685
+ */
1686
+ export function runCommand(cmd, args, { cwd, quiet = false }) {
547
1687
  return new Promise((resolve) => {
548
1688
  const shell = process.platform === 'win32'
549
1689
  if (shell && ![cmd, ...args].every((arg) => SAFE_ARG.test(arg))) return resolve({ code: null, error: 'an argument has characters this tool does not pass to a shell' })
550
- let settled = false
551
- const finish = (result) => { if (!settled) { settled = true; resolve(result) } }
1690
+ let settled = false, output = ''
1691
+ const finish = (result) => { if (!settled) { settled = true; resolve({ ...result, output }) } }
1692
+ const keep = (chunk) => {
1693
+ if (!quiet) process.stderr.write(chunk)
1694
+ output = (output + chunk.toString('utf8')).slice(-OUTPUT_LIMIT)
1695
+ }
552
1696
  try {
553
- const child = spawnChild(cmd, args, { cwd, stdio: ['ignore', 2, 2], shell })
1697
+ const child = spawnChild(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell })
1698
+ child.stdout.on('data', keep)
1699
+ child.stderr.on('data', keep)
554
1700
  child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? `${cmd} was not found` : error.message }))
555
1701
  child.on('close', (code) => finish({ code, error: null }))
556
1702
  } catch (error) {