@uknows/setup 0.0.0-stage → 0.1.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.
package/lib/engine.mjs ADDED
@@ -0,0 +1,607 @@
1
+ /*
2
+ * The setup and remove flows. Everything that reads the repo, runs a
3
+ * program or asks the user comes in through deps, so tests can use fakes.
4
+ * Each flow returns {code, report}: the exit code and the --json report.
5
+ */
6
+ import { spawn as spawnChild } from 'node:child_process'
7
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
8
+ import { dirname, sep } from 'node:path'
9
+ import { runCheck } from './check.mjs'
10
+ import { CLI, EXAMPLE_SERVICE, INGEST_ENDPOINT, SECRET } from './contract.mjs'
11
+ import { detect } from './detect.mjs'
12
+ import { docsUrl, llmsUrl } from './endpoint.mjs'
13
+ import { countLines, unified } from './diff.mjs'
14
+ import { shellWords } from './doc.mjs'
15
+ import { explainApm, findExisting } from './existing.mjs'
16
+ import { gitStatus } from './git.mjs'
17
+ import { keySteps } from './hosts.mjs'
18
+ import { BOM, TOOL, VERSION, changeEntry, manifestPaths, manifestText, mergeManifest, readManifest, sha256, undoChange } from './manifest.mjs'
19
+ import { hasKey, redactKey, settingsPlan } from './settings.mjs'
20
+ import { APPLIERS } from './stacks/index.mjs'
21
+ import { UsageError, frameworkLabel, hostLabel, list, plural, stackLabel, unique } from './ui.mjs'
22
+ import { createWorkspace, repoView } from './workspace.mjs'
23
+
24
+ const DEFAULTS = { detect, findExisting, explainApm, settingsPlan, keySteps, appliers: APPLIERS, spawn: runCommand, gitStatus, createWorkspace, runCheck }
25
+ /** The language menu, in this order, then 'Something else'. */
26
+ 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'])
28
+ const DIFF_LIMIT = 200
29
+ /** On Windows, package managers run through a shell (.cmd files), so every argument must be plain. */
30
+ const SAFE_ARG = /^[@A-Za-z0-9._/=:~+-]+$/
31
+
32
+ /**
33
+ * @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean}} opts
34
+ * @param {Partial<typeof DEFAULTS> & {ui: ReturnType<typeof import('./ui.mjs').createUI>}} deps
35
+ */
36
+ export async function runSetup(opts, deps) {
37
+ const d = { ...DEFAULTS, ...deps }
38
+ 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 }
40
+ return guard(d.ui, report, () => setup(opts, d, report))
41
+ }
42
+
43
+ /** @param {{root: string, yes?: boolean, json?: boolean, force?: boolean}} opts */
44
+ export async function runRemove(opts, deps) {
45
+ const d = { ...DEFAULTS, ...deps }
46
+ const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: [] }
47
+ return guard(d.ui, report, () => remove(opts, d, report))
48
+ }
49
+
50
+ async function guard(ui, report, flow) {
51
+ let result
52
+ try {
53
+ result = await flow()
54
+ } catch (error) {
55
+ if (error instanceof UsageError) {
56
+ ui.error(error.message)
57
+ ui.error('Run uknows-setup --help.')
58
+ result = ['usage-error', 2]
59
+ } else {
60
+ ui.error(`Something went wrong: ${error.message}`)
61
+ report.problems.push({ code: 'error', message: error.message })
62
+ result = ['failed', 1]
63
+ }
64
+ }
65
+ report.status = result[0]
66
+ report.exitCode = result[1]
67
+ return { code: result[1], report }
68
+ }
69
+
70
+ async function setup(opts, d, report) {
71
+ const { ui } = d, root = opts.root
72
+ // --yes never asks anything, so a script in a terminal cannot hang on a question.
73
+ const ask = ui.interactive && !opts.yes
74
+ const manifest = readManifest(root)
75
+ const detection = d.detect(root)
76
+ const ws = d.createWorkspace(root)
77
+ const repo = repoView(ws)
78
+ const known = (s) => manifest?.services.find((m) => m.dir === s.dir)?.stack
79
+ report.hosts = detection.hosts
80
+ report.problems.push(...detection.problems)
81
+ let services = detection.services.map((s) => ({ ...s }))
82
+ ui.print('UKNOWS setup')
83
+ ui.print()
84
+ const endpoint = opts.endpoint ?? INGEST_ENDPOINT
85
+ const env = opts.env ?? {}
86
+ const DOCS_URL = docsUrl(env), LLMS_URL = llmsUrl(env)
87
+ if (endpoint !== INGEST_ENDPOINT) ui.print(`Note: the settings use the test ingest address ${endpoint} from ${CLI.endpointEnv}.`)
88
+ for (const p of detection.problems.filter((x) => x.code === 'frontend' || x.code === 'skipped')) ui.print(`Note: ${p.message}`)
89
+
90
+ if (services.length > 1) {
91
+ ui.print(`Found ${services.length} services:`)
92
+ for (const s of services) ui.print(` ${s.dir}: ${describe(s)}`)
93
+ ui.print()
94
+ if (!opts.all) {
95
+ if (!ask) {
96
+ report.services = services.map((s) => serviceReport(s))
97
+ 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.`)
98
+ return ['needs-selection', 2]
99
+ }
100
+ const pick = await ui.choose('Which service do you want to set up?', [...services.map((s) => `${s.dir}: ${describe(s)}`), 'All of them'])
101
+ if (pick < services.length) services = [services[pick]]
102
+ ui.print()
103
+ }
104
+ } else if (services[0].lang) {
105
+ ui.print(`Found 1 service: ${describe(services[0], true)}`)
106
+ ui.print()
107
+ }
108
+ if (opts.serviceName && services.length > 1) throw new UsageError('Use --service-name with 1 service only. Use --path to pick that service.')
109
+
110
+ // A browser app gets no packages and no settings: OpenTelemetry for Node.js runs in a server.
111
+ const frontend = (s) => s.problems.some((x) => x.code === 'frontend')
112
+ if (services.some(frontend)) {
113
+ for (const s of services.filter(frontend)) {
114
+ ui.print(`${s.dir === '.' ? 'This folder' : s.dir}: ${s.problems.find((x) => x.code === 'frontend').message} This tool changes nothing here.`)
115
+ }
116
+ services = services.filter((s) => !frontend(s))
117
+ if (!services.length) {
118
+ report.services = detection.services.map((x) => serviceReport(x))
119
+ 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]
121
+ }
122
+ ui.print()
123
+ }
124
+
125
+ // A folder with nothing recognized: ask which stack it uses, or stop.
126
+ const chosen = new Map()
127
+ for (const s of services.filter((x) => !x.lang && !known(x))) {
128
+ if (!ask) {
129
+ ui.print(`No app found in ${where(s.dir)}.`)
130
+ 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
+ report.services = services.map((x) => serviceReport(x))
132
+ return ['not-found', 3]
133
+ }
134
+ const pick = await ui.choose(`No app found in ${where(s.dir)}. What does your app use?`, [...MENU.map(stackLabel), 'Something else'])
135
+ if (pick === MENU.length) {
136
+ ui.print(`To set up UKNOWS by hand, see ${DOCS_URL}`)
137
+ ui.print(`AI coding tools can read the same steps at ${LLMS_URL}`)
138
+ report.services = services.map((x) => serviceReport(x))
139
+ return ['not-found', 3]
140
+ }
141
+ ui.print()
142
+ chosen.set(s.dir, MENU[pick])
143
+ if (MENU[pick] !== 'otel') s.lang = MENU[pick]
144
+ }
145
+
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)
150
+ if (blocked.length) {
151
+ for (const it of blocked) ui.block(d.explainApm(it.s, it.existing.apm))
152
+ ui.print('Nothing changed.')
153
+ return ['refused', 1]
154
+ }
155
+
156
+ // Plan each service. All edits go into 1 workspace, so each step reads the edits before it.
157
+ const plans = []
158
+ for (const it of items) {
159
+ 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
162
+ if (!applier) throw new Error(`This tool has no steps for ${stack}.`)
163
+ report.problems.push(...s.problems)
164
+ const ambiguous = s.problems.find((x) => x.code === 'dockerfile-ambiguous')
165
+ if (ambiguous) {
166
+ // 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: [] })
170
+ continue
171
+ }
172
+ if ((stack === 'node' || stack === 'python') && !s.start && !s.dockerfile && ask) {
173
+ const raw = await ui.ask('What command starts this app now? For example: node server.js. Press Enter to skip.')
174
+ if (raw) s.start = { kind: 'answer', file: '', line: 0, form: 'shell', raw, argv: shellWords(raw) }
175
+ }
176
+ const ctx = { ws, repo, name: it.name, detection, endpoint }
177
+ const plan = applier.plan(s, ctx)
178
+ for (const edit of plan.edits) ws.stage(edit)
179
+ const settings = plan.stop ? { target: null, edits: [], summary: [], manual: [], notes: [] } : d.settingsPlan(s, ctx, plan.extraSettings ?? [])
180
+ 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)) })
183
+ }
184
+ report.services = plans.map((p) => serviceReport(p.s, { stack: p.stack, name: p.name, settings: p.settings.target ?? null,
185
+ summary: steps(p), commands: p.plan.commands.map((c) => ({ cwd: c.cwd, argv: [c.cmd, ...c.args] })),
186
+ 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
+ }
192
+ const changes = ws.changes()
193
+ // A file that holds a key is never changed, so the key cannot reach the diff or the manifest.
194
+ const keyed = changes.filter((c) => hasKey(c.before) || hasKey(c.after)).map((c) => c.file)
195
+ if (keyed.length) {
196
+ ui.block({ title: 'Remove the key from your repo', lines: [
197
+ ...keyed.map((file) => `${file} sets ${SECRET.name} to a key. This tool does not change a file that holds a key.`),
198
+ `Remove ${SECRET.name} from ${keyed.length === 1 ? 'that file' : 'those files'}. ${SECRET.summary}`,
199
+ 'Get a new key. The old key stays in your git history.', 'Then run npx @uknows/setup again.'] })
200
+ ui.print('Nothing changed.')
201
+ report.problems.push({ code: 'key-in-repo', message: `${plural(keyed.length, 'file')} that this tool would change ${keyed.length === 1 ? 'holds' : 'hold'} a key.` })
202
+ return ['refused', 1]
203
+ }
204
+ const commands = plans.flatMap((p) => p.plan.commands)
205
+ const manual = plans.flatMap((p) => p.manual)
206
+ const exit = manual.length ? 3 : 0
207
+
208
+ 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]
212
+ }
213
+ printPlan(ui, plans, detection, changes, commands, opts)
214
+ if (opts.dryRun || (opts.json && !opts.yes)) {
215
+ printManual(ui, manual)
216
+ ui.print('This was a dry run. Nothing changed.')
217
+ return ['planned', exit]
218
+ }
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
+ if (!opts.yes) {
237
+ if (!ui.interactive) {
238
+ ui.print('Run again with --yes to apply.')
239
+ return ['planned', 2]
240
+ }
241
+ if (!(await ui.confirm('Apply? (Y/n)'))) {
242
+ ui.print('Nothing changed.')
243
+ return ['planned', 0]
244
+ }
245
+ }
246
+
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 })
249
+ if (!saved) return ['failed', 1]
250
+ report.files = printResult(ui, ws, saved, changes, commands)
251
+ printManual(ui, manual, false)
252
+ for (const block of report.keySteps) ui.block(block)
253
+ 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]
258
+ }
259
+
260
+ /**
261
+ * Write the edits, run the package managers, write the manifest. On any
262
+ * failure, put every file back and return null. Returns the original bytes
263
+ * of every path it touched.
264
+ */
265
+ async function apply({ root, ws, changes, commands, manifest, services, d }) {
266
+ const { ui } = d
267
+ // Ctrl-C stops the package manager. Stay alive, so the failed run is rolled back below.
268
+ const hold = () => {}
269
+ process.on('SIGINT', hold)
270
+ /** @type {Map<string, Buffer|null>} */ const saved = new Map()
271
+ const made = []
272
+ const save = (file) => { if (!saved.has(file)) saved.set(file, readBytes(ws.abs(file))) }
273
+ const undo = () => {
274
+ for (const [file, bytes] of saved) {
275
+ try { if (bytes === null) removeFile(ws.abs(file)); else writeAtomic(ws.abs(file), bytes) } catch { /* best effort */ }
276
+ }
277
+ for (const dir of made.reverse()) try { rmdirSync(dir) } catch { /* not empty */ }
278
+ }
279
+ try {
280
+ for (const c of changes) {
281
+ save(c.file)
282
+ if (ws.disk(c.file) !== c.before) {
283
+ ui.error(`${c.file} changed while this ran. Nothing changed. Run again.`)
284
+ return null
285
+ }
286
+ const bytes = saved.get(c.file)
287
+ if (bytes && !bytes.equals(Buffer.from((c.bom ? BOM : '') + c.before, 'utf8'))) {
288
+ ui.error(`${c.file} is not UTF-8 text, so this tool cannot edit it safely. Nothing changed.`)
289
+ return null
290
+ }
291
+ }
292
+ for (const file of [...changes.map((c) => c.file), CLI.manifest]) inside(ws, file)
293
+ const written = new Map()
294
+ for (const c of changes) {
295
+ const abs = ws.abs(c.file), bytes = Buffer.from((c.bom ? BOM : '') + c.after, 'utf8')
296
+ makeDirs(dirname(abs), made)
297
+ writeAtomic(abs, bytes)
298
+ written.set(c.file, bytes)
299
+ }
300
+ for (const command of commands) for (const file of command.touches) save(file)
301
+ for (const command of commands) {
302
+ const result = await d.spawn(command.cmd, command.args, { cwd: ws.abs(command.cwd) })
303
+ if (result.code !== 0) {
304
+ 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})`}`)
307
+ return null
308
+ }
309
+ }
310
+ 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))])) }))
313
+ save(CLI.manifest)
314
+ const file = ws.abs(CLI.manifest)
315
+ makeDirs(dirname(file), made)
316
+ writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages }))))
317
+ return saved
318
+ } catch (error) {
319
+ undo()
320
+ ui.error(`Could not apply the changes: ${error.message}. Nothing changed.`)
321
+ return null
322
+ } finally {
323
+ process.off('SIGINT', hold)
324
+ }
325
+ }
326
+
327
+ async function remove(opts, d, report) {
328
+ const { ui } = d, root = opts.root
329
+ const manifest = readManifest(root)
330
+ if (!manifest) {
331
+ ui.print(`Nothing to remove. This folder has no ${CLI.manifest}.`)
332
+ return ['nothing-to-remove', 0]
333
+ }
334
+ const ws = d.createWorkspace(root)
335
+ const packages = [...manifest.packages].reverse(), changes = [...manifest.changes].reverse()
336
+ for (const file of [...changes.map((c) => c.file), CLI.manifest]) inside(ws, file)
337
+ const runLine = (p) => `Run ${p.undo.join(' ')}${p.cwd === '.' ? '' : ` in ${p.cwd}`}.`
338
+ ui.print('UKNOWS remove')
339
+ ui.print()
340
+ ui.print('This undoes what npx @uknows/setup changed:')
341
+ for (const p of packages) ui.print(` ${runLine(p)}`)
342
+ for (const file of unique(changes.map((c) => c.file))) {
343
+ ui.print(` ${changes.some((c) => c.file === file && c.created) ? 'Delete' : 'Restore'} ${file}.`)
344
+ }
345
+ ui.print(` Delete ${CLI.manifest}.`)
346
+ if (opts.json && !opts.yes) return ['planned', 0]
347
+ if (!opts.yes) {
348
+ if (!ui.interactive) {
349
+ ui.print('Run again with --yes to remove.')
350
+ return ['planned', 2]
351
+ }
352
+ if (!(await ui.confirm('Remove? (Y/n)'))) {
353
+ ui.print('Nothing changed.')
354
+ return ['planned', 0]
355
+ }
356
+ }
357
+
358
+ const left = { packages: [], changes: [] }
359
+ for (const p of packages) {
360
+ const [cmd, ...args] = p.undo
361
+ const result = await d.spawn(cmd, args, { cwd: ws.abs(p.cwd) })
362
+ report.commands.push({ cwd: p.cwd, argv: p.undo, ok: result.code === 0 })
363
+ if (result.code !== 0) left.packages.unshift(p)
364
+ }
365
+ for (const c of changes) {
366
+ const abs = ws.abs(c.file)
367
+ const result = undoChange(c, readBytes(abs))
368
+ if (result.reason) {
369
+ report.files.push({ file: c.file, result: 'skipped', reason: result.reason })
370
+ left.changes.unshift(c)
371
+ continue
372
+ }
373
+ if (result.bytes === null) removeFile(abs)
374
+ else writeAtomic(abs, result.bytes)
375
+ report.files.push({ file: c.file, result: result.bytes === null ? 'deleted' : 'restored' })
376
+ }
377
+
378
+ // Keep a record of what is left, so remove can try again. --force forgets it.
379
+ const rest = left.packages.length + left.changes.length
380
+ const file = ws.abs(CLI.manifest)
381
+ if (rest && !opts.force) writeAtomic(file, Buffer.from(manifestText({ ...manifest, ...left })))
382
+ else {
383
+ removeFile(file)
384
+ try { rmdirSync(dirname(file)) } catch { /* not empty */ }
385
+ }
386
+ ui.print()
387
+ if (rest) {
388
+ ui.print('Undo these by hand:')
389
+ for (const p of left.packages) ui.print(` ${runLine(p)} It failed here.`)
390
+ for (const f of report.files.filter((x) => x.result === 'skipped')) ui.print(` ${f.file}: ${f.reason}`)
391
+ if (!opts.force) ui.print(`${CLI.manifest} still lists them. Fix them and run remove again, or run remove --force to forget them.`)
392
+ return ['partial', 3]
393
+ }
394
+ ui.print('Removed what setup added.')
395
+ // Package managers write package.json and lockfiles their own way, so say when one is not byte for byte as before.
396
+ const original = new Map()
397
+ for (const p of manifest.packages) for (const [file, sha] of Object.entries(p.before ?? {})) if (!original.has(file)) original.set(file, sha)
398
+ const differ = [...original].filter(([file, sha]) => sha256(readBytes(ws.abs(file))) !== sha).map(([file]) => file)
399
+ if (differ.length) {
400
+ for (const file of differ) report.files.push({ file, result: 'differs', reason: 'The package manager did not write it back byte for byte.' })
401
+ ui.print(`Your package manager did not write ${differ.join(', ')} back byte for byte. Check the difference with git diff.`)
402
+ }
403
+ return ['removed', 0]
404
+ }
405
+
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(' ')
410
+ }
411
+
412
+ /**
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.
415
+ */
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}`
427
+ }
428
+ if (!headers) return null
429
+ const result = await d.runCheck({ env, headers })
430
+ for (const line of result.lines) (result.ok ? ui.print : ui.error)(` ${line}`)
431
+ ui.print()
432
+ const status = result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
433
+ return { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }
434
+ }
435
+
436
+ function printPlan(ui, plans, detection, changes, commands, opts) {
437
+ ui.print('Runs on:')
438
+ for (const h of detection.hosts) {
439
+ const files = unique(h.evidence.map((e) => e.file))
440
+ ui.print(` ${hostLabel(h.id)}. ${files.length ? `Found in ${files.join(', ')}.` : 'No deploy files found, so the steps are the general ones.'}`)
441
+ }
442
+ for (const p of plans) {
443
+ const from = opts.serviceName ? 'from --service-name' : p.s.nameFrom === 'existing' ? 'already set in the repo'
444
+ : p.s.nameFrom === 'folder' ? 'from the folder name' : `from ${p.s.nameFrom}`
445
+ ui.print()
446
+ if (plans.length === 1) {
447
+ ui.print(`Service name: ${p.name} (${from}).${opts.serviceName ? '' : ' Change it with --service-name.'}`)
448
+ ui.print(`Plan for ${p.name}:`)
449
+ } else ui.print(`Plan for ${p.name} in ${p.s.dir} (name ${from}):`)
450
+ 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('. ')}.`)
452
+ 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)}.`)
454
+ for (const note of [...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message)]) ui.print(` Note: ${note}`)
455
+ }
456
+ ui.print()
457
+ const edited = changes.filter((c) => c.before !== null).map((c) => c.file)
458
+ const created = changes.filter((c) => c.before === null).map((c) => c.file)
459
+ const changed = unique([...edited, ...commands.flatMap((c) => c.touches)])
460
+ if (changed.length) ui.print(`Files to change: ${changed.join(', ')}`)
461
+ if (created.length) ui.print(`Files to add: ${created.join(', ')}`)
462
+ ui.print(`It also writes ${CLI.manifest}, so you can undo this with npx @uknows/setup remove.`)
463
+ // 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 ?? [])}.`)]
466
+ if (out.length) {
467
+ ui.print()
468
+ ui.print('The changes:')
469
+ ui.print()
470
+ for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
471
+ if (out.length > DIFF_LIMIT) ui.print(`And ${plural(out.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
472
+ ui.print()
473
+ }
474
+ const tools = unique(commands.map((c) => c.cmd))
475
+ 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)))}.`
478
+ : `${tools.length === 1 ? 'It downloads' : 'They download'} the packages.`}`)
479
+ }
480
+ }
481
+
482
+ /** Print the diff of every file this run changed. Returns the --json files list. */
483
+ function printResult(ui, ws, saved, changes, commands) {
484
+ const files = [], out = []
485
+ for (const [file, before] of saved) {
486
+ if (file === CLI.manifest) continue
487
+ const after = readBytes(ws.abs(file))
488
+ if (before === null ? after === null : after !== null && before.equals(after)) continue
489
+ const a = before === null ? null : text(before), b = after === null ? '' : text(after)
490
+ const change = changes.find((c) => c.file === file), command = commands.find((c) => c.touches.includes(file))
491
+ const why = unique([...(change?.why ?? []), ...(command ? [command.why] : [])])
492
+ if (LOCKFILES.has(file.split('/').pop())) {
493
+ files.push({ file, created: before === null, added: null, removed: null, why })
494
+ out.push(`${file}: updated by ${command?.cmd ?? 'the package manager'}.`)
495
+ } else if (change && change.after === b) {
496
+ // The plan showed this diff already.
497
+ const counts = countLines(a, b)
498
+ files.push({ file, created: before === null, ...counts, why })
499
+ out.push(`${file}: ${before === null ? 'added' : 'changed'} as shown above (+${counts.added} -${counts.removed}).`)
500
+ } else {
501
+ files.push({ file, created: before === null, ...countLines(a, b), why })
502
+ out.push(...unified(file, a, b).split('\n').map(redactKey))
503
+ }
504
+ }
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
514
+ }
515
+
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)
522
+ }
523
+
524
+ 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])
528
+ const text = (bytes) => { const t = bytes.toString('utf8'); return t.startsWith(BOM) ? t.slice(1) : t }
529
+
530
+ function describe(s, withDir = false) {
531
+ 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}.`
535
+ return `${stackLabel(s.lang)}${frameworks}${withDir ? ` in ${where(s.dir)}` : ''}. ${start}`
536
+ }
537
+
538
+ /** A service in the --json report. start never holds the command text. */
539
+ function serviceReport(s, extra = {}) {
540
+ return { dir: s.dir, lang: s.lang, stack: null, name: s.name, frameworks: s.frameworks,
541
+ start: s.start ? { kind: s.start.kind, file: s.start.file, line: s.start.line } : null, settings: null,
542
+ summary: [], commands: [], manual: [], notes: [], otel: [], apm: [], ...extra }
543
+ }
544
+
545
+ /** Run a package manager. Its output goes to stderr, so --json stdout stays clean. */
546
+ export function runCommand(cmd, args, { cwd }) {
547
+ return new Promise((resolve) => {
548
+ const shell = process.platform === 'win32'
549
+ 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) } }
552
+ try {
553
+ const child = spawnChild(cmd, args, { cwd, stdio: ['ignore', 2, 2], shell })
554
+ child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? `${cmd} was not found` : error.message }))
555
+ child.on('close', (code) => finish({ code, error: null }))
556
+ } catch (error) {
557
+ finish({ code: null, error: error.message })
558
+ }
559
+ })
560
+ }
561
+
562
+ /** Bytes of a regular file, or null when it is missing, a link or not a file. */
563
+ function readBytes(abs) {
564
+ try {
565
+ return lstatSync(abs).isFile() ? readFileSync(abs) : null
566
+ } catch (error) {
567
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
568
+ throw error
569
+ }
570
+ }
571
+
572
+ /** Write through a temp file in the same folder, then rename. Keeps the file mode. */
573
+ function writeAtomic(abs, bytes) {
574
+ const temp = `${abs}.uknows-${process.pid}.tmp`
575
+ let mode = null
576
+ try { mode = statSync(abs).mode & 0o7777 } catch { /* a new file */ }
577
+ try {
578
+ writeFileSync(temp, bytes)
579
+ if (mode !== null) chmodSync(temp, mode)
580
+ renameSync(temp, abs)
581
+ } catch (error) {
582
+ try { unlinkSync(temp) } catch { /* not written */ }
583
+ throw error
584
+ }
585
+ }
586
+
587
+ /** Throw unless the real folder of a repo path is inside the repo, so a linked folder cannot lead a write out of it. */
588
+ function inside(ws, file) {
589
+ let dir = dirname(ws.abs(file))
590
+ while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir)
591
+ const base = realpathSync(ws.root), real = realpathSync(dir)
592
+ if (real !== base && !real.startsWith(base.endsWith(sep) ? base : base + sep)) throw new Error(`${file} is in a folder outside the repo`)
593
+ }
594
+
595
+ function removeFile(abs) {
596
+ try { unlinkSync(abs) } catch (error) { if (error.code !== 'ENOENT') throw error }
597
+ }
598
+
599
+ /** Create a folder and its missing parents. Records the new folders, top first, for rollback. */
600
+ function makeDirs(dir, made) {
601
+ const missing = []
602
+ for (let d = dir; !existsSync(d); d = dirname(d)) missing.unshift(d)
603
+ if (missing.length) {
604
+ mkdirSync(dir, { recursive: true })
605
+ made.push(...missing)
606
+ }
607
+ }