@uknows/setup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
@@ -0,0 +1,269 @@
1
+ /*
2
+ * The first-party code that 1 service builds with, and the local package files it uses. Static reading only.
3
+ * Python: uv workspace members ({ workspace = true } in [tool.uv.sources]) and path sources, Poetry path dependencies.
4
+ * Node.js: file:, link: and workspace: dependencies, and the packages of an npm, pnpm or yarn workspace.
5
+ * .NET: ProjectReference items.
6
+ * Local packages: wheels, sdists, .tgz and .nupkg files the manifests name, and local NuGet sources.
7
+ */
8
+ import { posix } from 'node:path'
9
+ import { upFrom } from './workspace.mjs'
10
+ import { pyName, tomlArray, tomlEntries, tomlInline, tomlString, tomlTable } from './toml.mjs'
11
+
12
+ /**
13
+ * @typedef {Object} Member A first-party package folder that the service depends on.
14
+ * @property {string} dir
15
+ * @property {string|null} name Its package name ([project].name, package.json name or the csproj name).
16
+ * @property {string[]} manifests Its manifest files.
17
+ * @property {string} file The manifest that names it.
18
+ * @property {number} [line]
19
+ */
20
+ /**
21
+ * @typedef {Object} LocalPackage A package that comes from a file or folder in the repo, not from a registry.
22
+ * @property {string} name The package name read from the file name, or the source folder.
23
+ * @property {string} path Repo path of the file or folder.
24
+ * @property {'wheel'|'sdist'|'tgz'|'nupkg'|'source'|'folder'} kind
25
+ * @property {string} file The file that names it.
26
+ * @property {number} [line]
27
+ */
28
+
29
+ const MAX_MEMBERS = 50
30
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
31
+ const dirOf = (path) => posix.dirname(path)
32
+ const norm = (path) => posix.normalize(path).replace(/\/+$/, '') || '.'
33
+ const read = (view, path) => { try { return view.read(path) } catch { return null } }
34
+ const isUrl = (text) => /^[a-z][a-z0-9+.-]*:\/\//i.test(text)
35
+
36
+ /**
37
+ * @param {import('./types.mjs').Service} service
38
+ * @param {import('./workspace.mjs').RepoView} view
39
+ * @returns {{members: Member[], local: LocalPackage[]}}
40
+ */
41
+ export function findMembers(service, view) {
42
+ const members = [], local = [], seen = new Set([service.dir])
43
+ const addLocal = (entry) => { if (!local.some((l) => l.path === entry.path && l.file === entry.file)) local.push(entry) }
44
+ const queue = [service.dir]
45
+ const lang = service.lang
46
+ while (queue.length && members.length < MAX_MEMBERS) {
47
+ const dir = queue.shift()
48
+ const found = lang === 'python' ? python(dir, view) : lang === 'node' ? node(dir, view) : lang === 'dotnet' ? dotnet(dir, service, view) : { members: [], local: [] }
49
+ found.local.forEach(addLocal)
50
+ for (const m of found.members) {
51
+ if (seen.has(m.dir)) continue
52
+ seen.add(m.dir)
53
+ members.push(m)
54
+ queue.push(m.dir)
55
+ }
56
+ }
57
+ return { members, local }
58
+ }
59
+
60
+ /** The package name of the pyproject.toml in dir: [project].name, else [tool.poetry].name. */
61
+ export function pythonProjectName(view, dir) {
62
+ const text = read(view, join(dir, 'pyproject.toml'))
63
+ const name = text && (tomlString(tomlTable(text, 'project'), 'name') ?? tomlString(tomlTable(text, 'tool.poetry'), 'name'))
64
+ return name ? pyName(name) : null
65
+ }
66
+
67
+ /** Workspaces found per view and root folder, so the members of 1 workspace walk the repo files once. */
68
+ const WORKSPACES = new WeakMap()
69
+
70
+ /** The nearest folder with a pyproject.toml that has [tool.uv.workspace], and its members: name -> dir. */
71
+ export function uvWorkspace(view, dir) {
72
+ const cache = WORKSPACES.get(view) ?? WORKSPACES.set(view, new Map()).get(view)
73
+ for (const d of upFrom(dir, view.top ?? '.')) {
74
+ if (cache.has(d)) { if (cache.get(d)) return cache.get(d); continue }
75
+ const found = uvWorkspaceAt(view, d)
76
+ cache.set(d, found)
77
+ if (found) return found
78
+ }
79
+ return null
80
+ }
81
+
82
+ function uvWorkspaceAt(view, d) {
83
+ const text = read(view, join(d, 'pyproject.toml'))
84
+ if (!text || !/^\s*\[tool\.uv\.workspace\]/m.test(text)) return null
85
+ const table = tomlTable(text, 'tool.uv.workspace')
86
+ const include = tomlArray(table, 'members').map((g) => globRe(d, g)), exclude = tomlArray(table, 'exclude').map((g) => globRe(d, g))
87
+ const byName = new Map()
88
+ const add = (memberDir) => { const name = pythonProjectName(view, memberDir); if (name && !byName.has(name)) byName.set(name, memberDir) }
89
+ add(d)
90
+ for (const file of view.files().filter((f) => posix.basename(f) === 'pyproject.toml')) {
91
+ const memberDir = dirOf(file)
92
+ if (include.some((re) => re.test(memberDir)) && !exclude.some((re) => re.test(memberDir))) add(memberDir)
93
+ }
94
+ return { root: d, members: byName }
95
+ }
96
+
97
+ function python(dir, view) {
98
+ const out = { members: [], local: [] }
99
+ const file = join(dir, 'pyproject.toml'), text = read(view, file)
100
+ const workspace = () => (workspaceCache ??= uvWorkspace(view, dir))
101
+ let workspaceCache
102
+ const pathSource = (from, path, line, key) => {
103
+ const target = norm(posix.join(dirOf(from), path))
104
+ const kind = /\.whl$/i.test(target) ? 'wheel' : /\.(?:tar\.gz|zip)$/i.test(target) ? 'sdist' : null
105
+ if (kind) out.local.push({ name: packageName(posix.basename(target), kind) ?? key, path: target, kind, file: from, line })
106
+ else if (read(view, join(target, 'pyproject.toml')) !== null || read(view, join(target, 'setup.py')) !== null) {
107
+ out.members.push({ dir: target, name: pythonProjectName(view, target) ?? pyName(key), manifests: manifestsIn(view, target, 'python'), file: from, line })
108
+ } else out.local.push({ name: pyName(key), path: target, kind: 'folder', file: from, line })
109
+ }
110
+ if (text !== null) {
111
+ for (const { key, value, line } of tomlEntries(text, 'tool.uv.sources')) {
112
+ const source = tomlInline(value)
113
+ if (!source) continue
114
+ if (source.workspace === true) {
115
+ const memberDir = workspace()?.members.get(pyName(key))
116
+ if (memberDir && memberDir !== dir) out.members.push({ dir: memberDir, name: pyName(key), manifests: manifestsIn(view, memberDir, 'python'), file, line })
117
+ } else if (typeof source.path === 'string') pathSource(file, source.path, line, key)
118
+ }
119
+ const headers = [...text.matchAll(/^\s*\[(tool\.poetry(?:\.group\.[\w-]+)?\.(?:dev-)?dependencies)\]/gm)].map((m) => m[1])
120
+ for (const header of headers) {
121
+ for (const { key, value, line } of tomlEntries(text, header)) {
122
+ const source = tomlInline(value)
123
+ if (typeof source?.path === 'string') pathSource(file, source.path, line, key)
124
+ }
125
+ }
126
+ }
127
+ // requirements files: -e ./shared, ./vendor/x.whl, name @ file:///...
128
+ for (const name of view.list(dir).filter((n) => /^requirements[\w.-]*\.txt$/.test(n))) {
129
+ const req = join(dir, name)
130
+ ;(read(view, req) ?? '').split(/\r?\n/).forEach((raw, i) => {
131
+ const l = raw.replace(/(?:^|\s)#.*$/, '').trim()
132
+ const m = /^(?:-e\s+|--editable[=\s]+)?(?:[A-Za-z0-9._-]+\s*@\s*)?(?:file:(?:\/\/)?)?((?:\.{1,2}\/|[\w.-]+\/)[^\s;]*|[^\s;]+\.(?:whl|tar\.gz|zip))$/.exec(l)
133
+ if (m && !isUrl(m[1]) && !m[1].startsWith('/')) pathSource(req, m[1], i + 1, posix.basename(m[1]))
134
+ })
135
+ }
136
+ return out
137
+ }
138
+
139
+ function node(dir, view) {
140
+ const out = { members: [], local: [] }
141
+ const file = join(dir, 'package.json')
142
+ const text = read(view, file)
143
+ let pkg = null
144
+ try { pkg = JSON.parse(text ?? '') } catch { return out }
145
+ const lines = (text ?? '').split('\n')
146
+ const lineOf = (needle) => lines.findIndex((l) => l.includes(needle)) + 1 || undefined
147
+ let workspace
148
+ for (const group of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
149
+ for (const [name, spec] of Object.entries(pkg?.[group] ?? {})) {
150
+ if (typeof spec !== 'string') continue
151
+ const line = lineOf(JSON.stringify(name))
152
+ const path = /^(?:file|link|portal):(.+)$/.exec(spec)?.[1] ?? (/\.tgz$/.test(spec) && !isUrl(spec) ? spec : null)
153
+ if (path) {
154
+ const target = norm(posix.join(dir, path))
155
+ if (/\.tgz$/.test(target)) out.local.push({ name, path: target, kind: 'tgz', file, line })
156
+ else if (read(view, join(target, 'package.json')) !== null) out.members.push({ dir: target, name, manifests: [join(target, 'package.json')], file, line })
157
+ else out.local.push({ name, path: target, kind: 'folder', file, line })
158
+ continue
159
+ }
160
+ workspace ??= nodeWorkspace(view, dir)
161
+ const memberDir = workspace?.get(name)
162
+ if (memberDir && memberDir !== dir && (spec.startsWith('workspace:') || !isUrl(spec))) {
163
+ out.members.push({ dir: memberDir, name, manifests: [join(memberDir, 'package.json')], file, line })
164
+ }
165
+ }
166
+ }
167
+ return out
168
+ }
169
+
170
+ /** Packages of the npm, yarn or pnpm workspace around dir: name -> dir. null when there is none. */
171
+ function nodeWorkspace(view, dir) {
172
+ for (const d of upFrom(dir, view.top ?? '.')) {
173
+ let globs = null
174
+ try {
175
+ const pkg = JSON.parse(read(view, join(d, 'package.json')) ?? '')
176
+ globs = Array.isArray(pkg?.workspaces) ? pkg.workspaces : Array.isArray(pkg?.workspaces?.packages) ? pkg.workspaces.packages : null
177
+ } catch { /* not JSON */ }
178
+ const pnpm = read(view, join(d, 'pnpm-workspace.yaml'))
179
+ if (!globs && pnpm) globs = [...pnpm.matchAll(/^\s*-\s*['"]?([^'"#\s]+)['"]?/gm)].map((m) => m[1]).filter((g) => !g.startsWith('!'))
180
+ if (!globs) continue
181
+ const res = globs.filter((g) => typeof g === 'string').map((g) => globRe(d, g))
182
+ const byName = new Map()
183
+ for (const f of view.files().filter((x) => posix.basename(x) === 'package.json')) {
184
+ if (!res.some((re) => re.test(dirOf(f)))) continue
185
+ try { const name = JSON.parse(read(view, f) ?? '')?.name; if (typeof name === 'string' && !byName.has(name)) byName.set(name, dirOf(f)) } catch { /* skip */ }
186
+ }
187
+ return byName
188
+ }
189
+ return null
190
+ }
191
+
192
+ function dotnet(dir, service, view) {
193
+ const out = { members: [], local: [] }
194
+ const projects = dir === service.dir ? service.manifests.filter((m) => m.endsWith('.csproj')) : view.list(dir).filter((n) => n.endsWith('.csproj')).map((n) => join(dir, n))
195
+ for (const csproj of projects) {
196
+ const text = read(view, csproj) ?? ''
197
+ const lines = text.split('\n')
198
+ for (const m of text.matchAll(/<ProjectReference\s[^>]*?Include\s*=\s*"([^"]+)"/gi)) {
199
+ const target = norm(posix.join(dirOf(csproj), m[1].replace(/\\/g, '/')))
200
+ if (read(view, target) === null) continue
201
+ out.members.push({ dir: dirOf(target), name: posix.basename(target).replace(/\.csproj$/i, ''), manifests: [target], file: csproj,
202
+ line: lines.findIndex((l) => l.includes(m[0])) + 1 || undefined })
203
+ }
204
+ if (dir === service.dir) out.local.push(...nugetSources(view, csproj))
205
+ }
206
+ return out
207
+ }
208
+
209
+ /** Local NuGet sources for 1 project: NuGet.Config files up the tree and RestoreAdditionalProjectSources, with the .nupkg files in them. */
210
+ function nugetSources(view, csproj) {
211
+ const out = []
212
+ const add = (from, value, line) => {
213
+ for (const part of value.split(';').map((p) => p.trim()).filter(Boolean)) {
214
+ if (isUrl(part) || /^[A-Za-z]:/.test(part) || part.startsWith('/')) continue
215
+ const path = norm(posix.join(dirOf(from), part.replace(/\$\(MSBuild(?:ThisFileDirectory|ProjectDirectory)\)/g, '').replace(/\\/g, '/')))
216
+ if (path.startsWith('..') && !(view.top ?? '.').startsWith('..')) continue
217
+ out.push({ name: posix.basename(path), path, kind: 'source', file: from, line })
218
+ for (const name of safeList(view, path).filter((n) => /\.nupkg$/i.test(n))) {
219
+ out.push({ name: packageName(name, 'nupkg'), path: join(path, name), kind: 'nupkg', file: from, line })
220
+ }
221
+ }
222
+ }
223
+ for (const d of upFrom(dirOf(csproj), view.top ?? '.')) {
224
+ for (const name of safeList(view, d).filter((n) => /^nuget\.config$/i.test(n) || n === 'Directory.Build.props')) {
225
+ const file = join(d, name), text = read(view, file) ?? ''
226
+ let sources = false
227
+ text.split('\n').forEach((l, i) => {
228
+ if (/<packageSources\b/i.test(l)) sources = !/\/>\s*$/.test(l)
229
+ const source = /<add\s[^>]*?value\s*=\s*"([^"]+)"/i.exec(l)
230
+ if (sources && /nuget\.config$/i.test(name) && source) add(file, source[1], i + 1)
231
+ if (/<\/packageSources>/i.test(l)) sources = false
232
+ const restore = /<Restore(?:Additional)?(?:Project)?Sources>([^<]+)</i.exec(l)
233
+ if (restore) add(file, restore[1], i + 1)
234
+ })
235
+ }
236
+ }
237
+ ;(read(view, csproj) ?? '').split('\n').forEach((l, i) => {
238
+ const restore = /<Restore(?:Additional)?(?:Project)?Sources>([^<]+)</i.exec(l)
239
+ if (restore) add(csproj, restore[1], i + 1)
240
+ })
241
+ return out
242
+ }
243
+
244
+ function safeList(view, dir) {
245
+ try { return view.list ? view.list(dir) : [] } catch { return [] }
246
+ }
247
+
248
+ /** The manifests of a member folder. */
249
+ function manifestsIn(view, dir, lang) {
250
+ const names = safeList(view, dir)
251
+ if (lang === 'python') return names.filter((n) => n === 'pyproject.toml' || /^requirements[\w.-]*\.txt$/.test(n)).map((n) => join(dir, n)).sort()
252
+ return []
253
+ }
254
+
255
+ /** A folder glob of a workspace (packages/*, app) as a test for repo folders. */
256
+ function globRe(base, glob) {
257
+ const path = norm(posix.join(base, glob.replace(/\/+$/, '')))
258
+ const re = path.split('/').map((part) => (part === '**' ? '.*' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')))
259
+ return new RegExp(`^${re.join('/')}$`)
260
+ }
261
+
262
+ /** The package name in a wheel, sdist, .tgz or .nupkg file name. */
263
+ export function packageName(file, kind) {
264
+ if (kind === 'wheel') return pyName(file.split('-')[0])
265
+ if (kind === 'sdist') return pyName(file.replace(/-\d[^-]*\.(?:tar\.gz|zip)$/i, ''))
266
+ if (kind === 'tgz') return file.replace(/-\d[\w.+-]*\.tgz$/, '').replace(/\.tgz$/, '')
267
+ if (kind === 'nupkg') return file.replace(/\.\d+(?:\.\d+)*(?:-[\w.-]+)?\.nupkg$/i, '').replace(/\.nupkg$/i, '')
268
+ return file
269
+ }
package/lib/plan.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /*
2
+ * The plan of a run with 1 Yes, as the person reads it: what setup found, the files it changes (with how many lines),
3
+ * where the key goes, the deploy file edits, the branch and the pull request, and the link to watch the app connect.
4
+ * Then 1 question, and a tidy list of the steps as they run. Text only: the engine gathers the facts and does the work.
5
+ */
6
+ import { CLI } from './contract.mjs'
7
+ import { countLines, splitLines } from './diff.mjs'
8
+ import { list, plural, wrap } from './ui.mjs'
9
+
10
+ /** The question of a run with 1 Yes. */
11
+ export const ONE_YES = 'Do all of this? (Y/n)'
12
+
13
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'uv.lock', 'poetry.lock', 'pdm.lock', 'packages.lock.json'])
14
+
15
+ /** "8 lines added", "1 line removed", "2 lines added, 1 removed". */
16
+ function linesWord(added, removed) {
17
+ if (!removed) return `${plural(added, 'line')} added`
18
+ if (!added) return `${plural(removed, 'line')} removed`
19
+ return `${plural(added, 'line')} added, ${removed} removed`
20
+ }
21
+
22
+ /**
23
+ * The files of the plan, as [file, what changes]: the edits with their line counts, the files that a package manager
24
+ * writes, and the record. manifest: the record exists already.
25
+ */
26
+ export function fileRows(changes, commands, { manifest = false } = {}) {
27
+ const rows = new Map()
28
+ for (const c of changes) {
29
+ if (c.before === null) rows.set(c.file, `new file, ${plural(splitLines(c.after).length, 'line')}`)
30
+ else { const { added, removed } = countLines(c.before, c.after); rows.set(c.file, linesWord(added, removed)) }
31
+ }
32
+ for (const c of commands) {
33
+ for (const file of c.touches) {
34
+ const what = c.kind === 'lock' ? `${c.cmd} ${c.args[0]} updates it` : LOCKFILES.has(file.split('/').pop()) ? `${c.cmd} updates it`
35
+ : `${c.cmd} adds ${plural(c.added?.length ?? 0, 'package')}`
36
+ rows.set(file, rows.has(file) ? `${rows.get(file)}, and ${what}` : what)
37
+ }
38
+ }
39
+ rows.set(CLI.manifest, `${manifest ? 'updated' : 'new file'}, so npx @uknows/setup remove can undo this`)
40
+ return [...rows]
41
+ }
42
+
43
+ const pad = (text, width) => `${text}${' '.repeat(Math.max(2, width - text.length))}`
44
+
45
+ /**
46
+ * Label and value rows, the values in 1 column, long values wrapped under their column. The column fits a label of 38
47
+ * characters, such as .github/workflows/uknows-updates.yml, so the rows of a plan stay in line.
48
+ */
49
+ function rows(ui, items, indent) {
50
+ if (!items.length) return
51
+ const width = Math.min(Math.max(...items.map(([label]) => label.length)) + 2, 40)
52
+ const room = Math.max(24, ui.columns() - indent.length - width)
53
+ for (const [label, value] of items) {
54
+ wrap(value, room).forEach((line, i) => ui.print(`${indent}${i ? ' '.repeat(width) : ui.dim(pad(label, width))}${line}`))
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Print the plan. plan: {
60
+ * app: [label, value] rows (App, Runs on, Sends to), nameNote,
61
+ * steps: [{title, rows: [[label, value]], notes: [text]}],
62
+ * then: text (what the person does after), byHand: [titles], good: [notes]
63
+ * }
64
+ */
65
+ export function printPlan(ui, plan) {
66
+ ui.print(ui.bold('Here is the plan'))
67
+ rows(ui, plan.app, ' ')
68
+ if (plan.nameNote) ui.say(ui.dim(plan.nameNote), ' ')
69
+ plan.steps.forEach((step, i) => {
70
+ ui.print()
71
+ ui.say(ui.bold(`${i + 1}. ${step.title}`), ' ', ' ')
72
+ rows(ui, step.rows ?? [], ' ')
73
+ for (const note of step.notes ?? []) ui.say(note, ' ')
74
+ })
75
+ if (plan.byHand?.length) {
76
+ ui.print()
77
+ ui.say(`${ui.bold('Left for you')} ${ui.dim(`(${plan.byHand.length === 1 ? 'the step prints' : 'the steps print'} at the end)`)}`, ' ')
78
+ for (const title of plan.byHand) ui.say(title, ' ', ' ')
79
+ }
80
+ if (plan.good?.length) {
81
+ ui.print()
82
+ ui.say(ui.bold('Good to know'), ' ')
83
+ for (const note of plan.good) ui.say(note, ' ')
84
+ }
85
+ if (plan.then) {
86
+ ui.print()
87
+ ui.say(plan.then, ' ')
88
+ }
89
+ ui.print()
90
+ }
91
+
92
+ /**
93
+ * The 1 question. d shows every line that changes, then asks again. Enter or y: true. n or the end of the input: false.
94
+ * diff(): the lines to show for d.
95
+ */
96
+ export async function askOnce(ui, diff) {
97
+ ui.print(ui.dim('Type d to see every line that changes.'))
98
+ for (let tries = 0; tries < 5; tries++) {
99
+ const answer = await ui.line(ONE_YES)
100
+ if (answer === null) return false
101
+ const a = answer.toLowerCase()
102
+ if (a === '' || a === 'y' || a === 'yes') return true
103
+ if (a === 'n' || a === 'no') return false
104
+ if (a === 'd') {
105
+ ui.print()
106
+ for (const line of diff()) ui.print(line)
107
+ ui.print()
108
+ continue
109
+ }
110
+ ui.print('Type y, n or d.')
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * 1 step of the list: a spinner with doing while run() works, then 1 line with done or failed. run() returns
117
+ * {ok, line, more}: more are lines under it (what to do next). A thrown error counts as failed.
118
+ */
119
+ export async function step(ui, doing, run) {
120
+ const p = ui.progress(doing, ' ')
121
+ let r
122
+ try {
123
+ r = await run()
124
+ } catch (error) {
125
+ r = { ok: false, line: `${doing}: ${error.message}` }
126
+ }
127
+ p.end(` ${r.ok ? ui.cyan(ui.marks.done) : ui.red(ui.marks.failed)} ${r.line}`)
128
+ for (const line of r.more ?? []) ui.say(line, ' ', ' ')
129
+ return r
130
+ }