@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.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/members.mjs
ADDED
|
@@ -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
|
+
}
|