@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/locks.mjs
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The packages that 1 service installs, read from its lockfiles: name -> {version, via, file, line}. It reads,
|
|
3
|
+
* in this order: uv.lock, poetry.lock, pinned requirements files, packages.lock.json (every target framework),
|
|
4
|
+
* obj/project.assets.json, and package-lock.json v2 and v3. It reads text only and runs nothing.
|
|
5
|
+
*
|
|
6
|
+
* direct: the service or 1 of its first-party members lists the package. via: for the other packages, the direct
|
|
7
|
+
* dependencies they arrive through. requires: what the package needs, with the version limit when the lock has it.
|
|
8
|
+
*/
|
|
9
|
+
import { posix } from 'node:path'
|
|
10
|
+
import { upFrom } from './workspace.mjs'
|
|
11
|
+
import { pythonProjectName } from './members.mjs'
|
|
12
|
+
import { pyName, tomlArray, tomlEntries, tomlInline, tomlTable } from './toml.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} Installed
|
|
16
|
+
* @property {string} name As the lockfile writes it.
|
|
17
|
+
* @property {string|null} version
|
|
18
|
+
* @property {string} file The lockfile.
|
|
19
|
+
* @property {number} [line] 1-based line of its entry.
|
|
20
|
+
* @property {boolean} direct
|
|
21
|
+
* @property {string[]} via Keys of the direct dependencies it arrives through. Empty when it is direct.
|
|
22
|
+
* @property {{name: string, spec: string|null, line?: number}[]} requires
|
|
23
|
+
* @property {string|null} source registry, editable, virtual, directory, path, git, url, project, link or file. null when unknown.
|
|
24
|
+
*/
|
|
25
|
+
/** @typedef {{packages: Map<string, Installed>, files: string[]}} InstalledSet Keys are packageKey() names. */
|
|
26
|
+
|
|
27
|
+
const MAX_LOCK = 16_000_000
|
|
28
|
+
const MAX_VISITS = 50_000
|
|
29
|
+
const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
|
|
30
|
+
const dirOf = (path) => posix.dirname(path)
|
|
31
|
+
const read = (view, path) => {
|
|
32
|
+
try { return (view.stat?.(path)?.size ?? 0) > MAX_LOCK ? null : view.read(path) } catch { return null }
|
|
33
|
+
}
|
|
34
|
+
const json = (text) => { try { const v = JSON.parse(text ?? ''); return v && typeof v === 'object' && !Array.isArray(v) ? v : null } catch { return null } }
|
|
35
|
+
const entry = (name, version, file, line, source = null) => ({ name, version, file, line, direct: false, via: [], requires: [], source })
|
|
36
|
+
|
|
37
|
+
/** The key of a package name: PEP 503 for Python, lower case for NuGet, exact for npm. */
|
|
38
|
+
export const packageKey = (lang, name) => (lang === 'python' ? pyName(name) : lang === 'dotnet' ? String(name).toLowerCase() : String(name))
|
|
39
|
+
|
|
40
|
+
/** An empty set, for services that have no lockfile. */
|
|
41
|
+
export const noPackages = () => ({ packages: new Map(), files: [] })
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {import('./types.mjs').Service} service
|
|
45
|
+
* @param {import('./workspace.mjs').RepoView} view
|
|
46
|
+
* @param {import('./members.mjs').Member[]} [members]
|
|
47
|
+
* @returns {InstalledSet}
|
|
48
|
+
*/
|
|
49
|
+
export function installedPackages(service, view, members = []) {
|
|
50
|
+
const out = noPackages()
|
|
51
|
+
const merge = (set) => {
|
|
52
|
+
if (!set) return
|
|
53
|
+
out.files.push(set.file)
|
|
54
|
+
for (const [key, item] of set.packages) if (!out.packages.has(key)) out.packages.set(key, item)
|
|
55
|
+
}
|
|
56
|
+
const dirs = [service.dir, ...members.map((m) => m.dir)]
|
|
57
|
+
if (service.lang === 'python') {
|
|
58
|
+
const roots = [pythonProjectName(view, service.dir), ...members.map((m) => m.name && pyName(m.name))].filter(Boolean)
|
|
59
|
+
const uv = nearest(view, service.dir, 'uv.lock')
|
|
60
|
+
if (uv) merge(uvLock(uv, read(view, uv), roots))
|
|
61
|
+
const poetry = nearest(view, service.dir, 'poetry.lock')
|
|
62
|
+
if (poetry) merge(poetryLock(poetry, read(view, poetry), pythonDirectNames(view, dirs)))
|
|
63
|
+
for (const dir of dirs) {
|
|
64
|
+
for (const name of list(view, dir).filter((n) => /^requirements[\w.-]*\.txt$/.test(n))) merge(requirementPins(join(dir, name), read(view, join(dir, name))))
|
|
65
|
+
}
|
|
66
|
+
} else if (service.lang === 'dotnet') {
|
|
67
|
+
for (const csproj of [...service.manifests, ...members.flatMap((m) => m.manifests)].filter((m) => m.endsWith('.csproj'))) {
|
|
68
|
+
const lock = join(dirOf(csproj), 'packages.lock.json')
|
|
69
|
+
merge(nugetLock(lock, read(view, lock)))
|
|
70
|
+
const assets = join(dirOf(csproj), 'obj/project.assets.json')
|
|
71
|
+
merge(nugetAssets(assets, read(view, assets)))
|
|
72
|
+
}
|
|
73
|
+
} else if (service.lang === 'node') {
|
|
74
|
+
const lock = nearest(view, service.dir, 'package-lock.json') ?? nearest(view, service.dir, 'npm-shrinkwrap.json')
|
|
75
|
+
if (lock) {
|
|
76
|
+
const base = dirOf(lock)
|
|
77
|
+
const rel = (dir) => (dir === base ? '' : base === '.' ? (dir === '.' ? '' : dir) : posix.relative(base, dir))
|
|
78
|
+
merge(npmLock(lock, read(view, lock), [rel(service.dir), ...members.map((m) => rel(m.dir))]))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The nearest file with this name, from dir up to the top of the git repo. */
|
|
85
|
+
export function nearest(view, dir, name) {
|
|
86
|
+
for (const d of upFrom(dir, view.top ?? '.')) if (read(view, join(d, name)) !== null) return join(d, name)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function list(view, dir) {
|
|
91
|
+
try { return view.list ? view.list(dir) : [] } catch { return [] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Set direct and via from a graph. graph: key -> dependency keys. firstParty: the service and its members. Their
|
|
96
|
+
* dependencies are direct, and the walk goes through them without a via.
|
|
97
|
+
*/
|
|
98
|
+
function walk(packages, graph, firstParty) {
|
|
99
|
+
const queue = [...firstParty].filter((k) => graph.has(k)).map((k) => [k, null])
|
|
100
|
+
const seen = new Set()
|
|
101
|
+
for (let visits = 0; queue.length && visits < MAX_VISITS; visits++) {
|
|
102
|
+
const [key, via] = queue.shift()
|
|
103
|
+
const tag = `${key}\u0000${via ?? ''}`
|
|
104
|
+
if (seen.has(tag)) continue
|
|
105
|
+
seen.add(tag)
|
|
106
|
+
for (const dep of graph.get(key) ?? []) {
|
|
107
|
+
const item = packages.get(dep)
|
|
108
|
+
if (!item) continue
|
|
109
|
+
if (firstParty.has(dep)) { queue.push([dep, null]); continue }
|
|
110
|
+
if (via === null) item.direct = true
|
|
111
|
+
else if (!item.via.includes(via)) item.via.push(via)
|
|
112
|
+
queue.push([dep, via ?? dep])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const item of packages.values()) if (item.direct) item.via = []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Python ----------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/** uv.lock: the [[package]] blocks, their dependencies, and [package.metadata] requires-dist (local packages have it). */
|
|
121
|
+
export function uvLock(file, text, roots) {
|
|
122
|
+
if (typeof text !== 'string') return null
|
|
123
|
+
const blocks = []
|
|
124
|
+
let b = null, section = '', array = null
|
|
125
|
+
const items = (line, i) => [...line.matchAll(/\{\s*name\s*=\s*"([^"]+)"([^}]*)\}/g)].map((m) => ({ name: m[1], rest: m[2], line: i + 1 }))
|
|
126
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
127
|
+
const line = raw.trim()
|
|
128
|
+
if (line === '[[package]]') { b = { name: null, version: null, source: null, deps: [], requires: [], line: i + 1 }; blocks.push(b); section = 'package'; array = null; return }
|
|
129
|
+
if (/^\[\[/.test(line)) { section = line; array = null; return }
|
|
130
|
+
const header = /^\[([^\]]+)\]$/.exec(line)
|
|
131
|
+
if (header) { section = header[1]; array = null; return }
|
|
132
|
+
if (!b || !section.startsWith('package')) return
|
|
133
|
+
if (array) {
|
|
134
|
+
array.push(...items(line, i))
|
|
135
|
+
if (/^\]/.test(line)) array = null
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
const kv = /^([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(line)
|
|
139
|
+
if (!kv) return
|
|
140
|
+
const open = (target) => { target.push(...items(kv[2], i)); if (/^\[/.test(kv[2]) && !/\]\s*$/.test(kv[2])) array = target }
|
|
141
|
+
if (section === 'package') {
|
|
142
|
+
if (kv[1] === 'name') { b.name = /^"([^"]+)"/.exec(kv[2])?.[1] ?? null; b.line = i + 1 }
|
|
143
|
+
else if (kv[1] === 'version') b.version = /^"([^"]+)"/.exec(kv[2])?.[1] ?? null
|
|
144
|
+
else if (kv[1] === 'source') b.source = Object.keys(tomlInline(kv[2]) ?? {})[0] ?? null
|
|
145
|
+
else if (kv[1] === 'dependencies') open(b.deps)
|
|
146
|
+
} else if (section === 'package.metadata' && kv[1] === 'requires-dist') open(b.requires)
|
|
147
|
+
})
|
|
148
|
+
const packages = new Map(), graph = new Map()
|
|
149
|
+
for (const block of blocks.filter((x) => x.name)) {
|
|
150
|
+
const key = pyName(block.name)
|
|
151
|
+
if (packages.has(key)) continue
|
|
152
|
+
const item = entry(block.name, block.version, file, block.line, block.source)
|
|
153
|
+
const specs = new Map(block.requires.map((r) => [pyName(r.name), { spec: /specifier\s*=\s*"([^"]*)"/.exec(r.rest)?.[1] ?? null, line: r.line }]))
|
|
154
|
+
const deps = [...new Set(block.deps.map((d) => pyName(d.name)))]
|
|
155
|
+
item.requires = deps.map((k) => ({ name: k, spec: specs.get(k)?.spec ?? null, line: specs.get(k)?.line ?? block.deps.find((d) => pyName(d.name) === k)?.line }))
|
|
156
|
+
for (const [k, s] of specs) if (!deps.includes(k)) item.requires.push({ name: k, spec: s.spec, line: s.line })
|
|
157
|
+
packages.set(key, item)
|
|
158
|
+
graph.set(key, deps)
|
|
159
|
+
}
|
|
160
|
+
const local = new Set(['editable', 'virtual', 'directory'])
|
|
161
|
+
walk(packages, graph, new Set(roots.filter((k) => local.has(packages.get(k)?.source ?? ''))))
|
|
162
|
+
return { file, packages }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** poetry.lock: [[package]] blocks and their [package.dependencies]. Optional dependencies are left out. */
|
|
166
|
+
export function poetryLock(file, text, direct) {
|
|
167
|
+
if (typeof text !== 'string') return null
|
|
168
|
+
const packages = new Map(), graph = new Map()
|
|
169
|
+
const lines = text.split(/\r?\n/)
|
|
170
|
+
const starts = lines.map((l, i) => (l.trim() === '[[package]]' ? i : -1)).filter((i) => i >= 0)
|
|
171
|
+
starts.forEach((start, n) => {
|
|
172
|
+
const body = lines.slice(start + 1, starts[n + 1] ?? lines.length).join('\n')
|
|
173
|
+
const name = /^name\s*=\s*"([^"]+)"/m.exec(body)?.[1]
|
|
174
|
+
if (!name || packages.has(pyName(name))) return
|
|
175
|
+
const nameLine = start + 1 + body.split('\n').findIndex((l) => /^name\s*=/.test(l)) + 1
|
|
176
|
+
const source = /^type\s*=\s*"([^"]+)"/m.exec(tomlTable(`[x]\n${body}`, 'package.source'))?.[1] ?? 'registry'
|
|
177
|
+
const item = entry(name, /^version\s*=\s*"([^"]+)"/m.exec(body)?.[1] ?? null, file, nameLine, source)
|
|
178
|
+
const offset = start + 1
|
|
179
|
+
for (const dep of tomlEntries(`[x]\n${body}`, 'package.dependencies')) {
|
|
180
|
+
const inline = tomlInline(dep.value)
|
|
181
|
+
if (inline?.optional === true) continue
|
|
182
|
+
const spec = inline ? (typeof inline.version === 'string' ? inline.version : null) : /^"([^"]*)"/.exec(dep.value)?.[1] ?? null
|
|
183
|
+
item.requires.push({ name: pyName(dep.key), spec, line: offset + dep.line - 1 })
|
|
184
|
+
}
|
|
185
|
+
packages.set(pyName(name), item)
|
|
186
|
+
graph.set(pyName(name), item.requires.map((r) => r.name))
|
|
187
|
+
})
|
|
188
|
+
// The project itself is not in poetry.lock: its direct dependencies start the walk.
|
|
189
|
+
graph.set('\u0000root', direct.filter((k) => packages.has(k)))
|
|
190
|
+
walk(packages, graph, new Set(['\u0000root']))
|
|
191
|
+
return { file, packages }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Direct dependency names of these Python folders: [project].dependencies and [tool.poetry.dependencies]. */
|
|
195
|
+
function pythonDirectNames(view, dirs) {
|
|
196
|
+
const out = new Set()
|
|
197
|
+
for (const dir of dirs) {
|
|
198
|
+
const text = read(view, join(dir, 'pyproject.toml'))
|
|
199
|
+
if (!text) continue
|
|
200
|
+
for (const req of tomlArray(tomlTable(text, 'project'), 'dependencies')) { const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(req); if (m) out.add(pyName(m[1])) }
|
|
201
|
+
for (const { key } of tomlEntries(text, 'tool.poetry.dependencies')) if (key !== 'python') out.add(pyName(key))
|
|
202
|
+
}
|
|
203
|
+
return [...out]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Exact pins (name==1.2.3) in a requirements file. They are direct. */
|
|
207
|
+
export function requirementPins(file, text) {
|
|
208
|
+
if (typeof text !== 'string') return null
|
|
209
|
+
const packages = new Map()
|
|
210
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
211
|
+
const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([A-Za-z0-9.+!-]+)\s*(?:[;#\\].*)?$/.exec(raw)
|
|
212
|
+
if (!m || packages.has(pyName(m[1]))) return
|
|
213
|
+
packages.set(pyName(m[1]), { ...entry(m[1], m[2], file, i + 1, 'registry'), direct: true })
|
|
214
|
+
})
|
|
215
|
+
return packages.size ? { file, packages } : null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- .NET ------------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/** First line of each "key": { in a JSON file. */
|
|
221
|
+
function jsonLines(text) {
|
|
222
|
+
const lines = new Map()
|
|
223
|
+
text.split(/\r?\n/).forEach((l, i) => { const m = /^\s*"([^"]+)"\s*:\s*\{/.exec(l); if (m && !lines.has(m[1])) lines.set(m[1], i + 1) })
|
|
224
|
+
return lines
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** packages.lock.json: every target framework, Direct, Transitive, Project and CentralTransitive. */
|
|
228
|
+
export function nugetLock(file, text) {
|
|
229
|
+
const data = json(text)
|
|
230
|
+
if (!data?.dependencies) return null
|
|
231
|
+
const lines = jsonLines(text), textLines = text.split(/\r?\n/)
|
|
232
|
+
const packages = new Map(), graph = new Map(), firstParty = new Set(['\u0000root'])
|
|
233
|
+
const roots = []
|
|
234
|
+
for (const deps of Object.values(data.dependencies)) {
|
|
235
|
+
for (const [name, info] of Object.entries(deps ?? {})) {
|
|
236
|
+
const key = name.toLowerCase()
|
|
237
|
+
if (info?.type === 'Project') { firstParty.add(key); roots.push(key) } else if (info?.type === 'Direct') roots.push(key)
|
|
238
|
+
if (!packages.has(key)) {
|
|
239
|
+
const item = entry(name, info?.resolved ?? null, file, lines.get(name), info?.type === 'Project' ? 'project' : 'registry')
|
|
240
|
+
packages.set(key, item)
|
|
241
|
+
graph.set(key, [])
|
|
242
|
+
}
|
|
243
|
+
const item = packages.get(key)
|
|
244
|
+
for (const [dep, spec] of Object.entries(info?.dependencies ?? {})) {
|
|
245
|
+
const dk = dep.toLowerCase()
|
|
246
|
+
if (!graph.get(key).includes(dk)) graph.get(key).push(dk)
|
|
247
|
+
if (!item.requires.some((r) => r.name.toLowerCase() === dk)) {
|
|
248
|
+
const needle = `"${dep}": ${JSON.stringify(spec)}`
|
|
249
|
+
item.requires.push({ name: dep, spec: String(spec), line: textLines.findIndex((l) => l.includes(needle)) + 1 || undefined })
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
graph.set('\u0000root', [...new Set(roots)])
|
|
255
|
+
walk(packages, graph, firstParty)
|
|
256
|
+
return { file, packages }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** obj/project.assets.json: targets per framework, and the direct dependencies of the project. */
|
|
260
|
+
export function nugetAssets(file, text) {
|
|
261
|
+
const data = json(text)
|
|
262
|
+
if (!data?.targets) return null
|
|
263
|
+
const lines = jsonLines(text)
|
|
264
|
+
const packages = new Map(), graph = new Map(), firstParty = new Set(['\u0000root'])
|
|
265
|
+
for (const libs of Object.values(data.targets)) {
|
|
266
|
+
for (const [id, info] of Object.entries(libs ?? {})) {
|
|
267
|
+
const [name, version] = id.split('/')
|
|
268
|
+
const key = name.toLowerCase()
|
|
269
|
+
if (info?.type === 'project') firstParty.add(key)
|
|
270
|
+
if (!packages.has(key)) { packages.set(key, entry(name, version ?? null, file, lines.get(id), info?.type === 'project' ? 'project' : 'registry')); graph.set(key, []) }
|
|
271
|
+
for (const [dep, spec] of Object.entries(info?.dependencies ?? {})) {
|
|
272
|
+
const dk = dep.toLowerCase()
|
|
273
|
+
if (!graph.get(key).includes(dk)) graph.get(key).push(dk)
|
|
274
|
+
if (!packages.get(key).requires.some((r) => r.name.toLowerCase() === dk)) packages.get(key).requires.push({ name: dep, spec: String(spec) })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const direct = Object.values(data.project?.frameworks ?? {}).flatMap((f) => Object.keys(f?.dependencies ?? {})).map((n) => n.toLowerCase())
|
|
279
|
+
graph.set('\u0000root', [...new Set([...direct, ...[...firstParty].filter((k) => k !== '\u0000root')])])
|
|
280
|
+
walk(packages, graph, firstParty)
|
|
281
|
+
return { file, packages }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Node.js ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/** package-lock.json v2 and v3 (the packages map), with a v1 fallback (nested dependencies). roots: folders relative to the lock. */
|
|
287
|
+
export function npmLock(file, text, roots) {
|
|
288
|
+
const data = json(text)
|
|
289
|
+
if (!data) return null
|
|
290
|
+
const lines = jsonLines(text)
|
|
291
|
+
const packages = new Map()
|
|
292
|
+
const depsOf = (info) => Object.keys({ ...(info?.dependencies ?? {}), ...(info?.optionalDependencies ?? {}), ...(info?.peerDependencies ?? {}) })
|
|
293
|
+
const specsOf = (info) => Object.entries({ ...(info?.peerDependencies ?? {}), ...(info?.optionalDependencies ?? {}), ...(info?.dependencies ?? {}) })
|
|
294
|
+
.map(([name, spec]) => ({ name, spec: String(spec) }))
|
|
295
|
+
if (data.packages && typeof data.packages === 'object') {
|
|
296
|
+
const pk = data.packages
|
|
297
|
+
const MARK = 'node_modules/'
|
|
298
|
+
const isPkg = (key) => key.includes(MARK)
|
|
299
|
+
const nameOf = (key) => key.slice(key.lastIndexOf(MARK) + MARK.length)
|
|
300
|
+
const depth = (key) => key.split(MARK).length
|
|
301
|
+
const real = (key) => (pk[key]?.link && typeof pk[key].resolved === 'string' && pk[pk[key].resolved] ? pk[key].resolved : key)
|
|
302
|
+
const parent = (key) => { const at = key.lastIndexOf(`/${MARK}`); return at >= 0 ? key.slice(0, at) : '' }
|
|
303
|
+
const resolve = (from, name) => {
|
|
304
|
+
for (let base = from; ; base = parent(base)) {
|
|
305
|
+
const key = `${base ? `${base}/` : ''}${MARK}${name}`
|
|
306
|
+
if (pk[key]) return real(key)
|
|
307
|
+
if (!base) return null
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 1 entry per name: the copy nearest the root.
|
|
311
|
+
for (const [key, info] of Object.entries(pk)) {
|
|
312
|
+
if (!isPkg(key) || info?.link) continue
|
|
313
|
+
const name = nameOf(key), old = packages.get(name)
|
|
314
|
+
if (old && old._depth <= depth(key)) continue
|
|
315
|
+
const item = entry(name, info?.version ?? null, file, lines.get(key), typeof info?.resolved === 'string' && info.resolved.startsWith('file:') ? 'file' : 'registry')
|
|
316
|
+
item.requires = specsOf(info)
|
|
317
|
+
packages.set(name, Object.assign(item, { _depth: depth(key) }))
|
|
318
|
+
}
|
|
319
|
+
// Walk the folders. Workspace folders (no node_modules in the key) are first-party.
|
|
320
|
+
const queue = roots.filter((k) => pk[k]).map((k) => [k, null])
|
|
321
|
+
const seen = new Set()
|
|
322
|
+
for (let visits = 0; queue.length && visits < MAX_VISITS; visits++) {
|
|
323
|
+
const [key, via] = queue.shift()
|
|
324
|
+
if (seen.has(`${key}\u0000${via ?? ''}`)) continue
|
|
325
|
+
seen.add(`${key}\u0000${via ?? ''}`)
|
|
326
|
+
for (const dep of depsOf(pk[key])) {
|
|
327
|
+
const target = resolve(key, dep)
|
|
328
|
+
if (target === null) continue
|
|
329
|
+
if (!isPkg(target)) { queue.push([target, null]); continue }
|
|
330
|
+
const item = packages.get(nameOf(target))
|
|
331
|
+
if (!item) continue
|
|
332
|
+
if (via === null) item.direct = true
|
|
333
|
+
else if (!item.via.includes(via)) item.via.push(via)
|
|
334
|
+
queue.push([target, via ?? nameOf(target)])
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const item of packages.values()) { delete item._depth; if (item.direct) item.via = [] }
|
|
338
|
+
return { file, packages }
|
|
339
|
+
}
|
|
340
|
+
// lockfileVersion 1: nested dependencies with requires. Only the versions and requirements are read.
|
|
341
|
+
const walkV1 = (deps, level) => {
|
|
342
|
+
for (const [name, info] of Object.entries(deps ?? {})) {
|
|
343
|
+
if (!packages.has(name)) {
|
|
344
|
+
const item = entry(name, info?.version ?? null, file, lines.get(name), 'registry')
|
|
345
|
+
item.requires = Object.entries(info?.requires ?? {}).map(([dep, spec]) => ({ name: dep, spec: String(spec) }))
|
|
346
|
+
packages.set(name, item)
|
|
347
|
+
}
|
|
348
|
+
if (level < 20) walkV1(info?.dependencies, level + 1)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
walkV1(data.dependencies, 0)
|
|
352
|
+
return { file, packages }
|
|
353
|
+
}
|
package/lib/manifest.mjs
CHANGED
|
@@ -6,19 +6,23 @@
|
|
|
6
6
|
import { createHash } from 'node:crypto'
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
|
-
import { CLI, PINS } from './contract.mjs'
|
|
9
|
+
import { AGENT_CHANNELS, AGENT_REGISTRY, CLI, PINS } from './contract.mjs'
|
|
10
10
|
import { hunks, reverseAt, reverseByContent, splitLines } from './diff.mjs'
|
|
11
11
|
import { checkPath } from './workspace.mjs'
|
|
12
|
+
import { HOOKS } from './hooks/pins.mjs'
|
|
12
13
|
|
|
13
14
|
export const TOOL = '@uknows/setup'
|
|
14
15
|
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
15
16
|
export const BOM = '\uFEFF'
|
|
16
17
|
const NOTE = 'Written by npx @uknows/setup. To undo these changes, run npx @uknows/setup remove. This file has no secrets.'
|
|
17
18
|
const STACKS = ['node', 'python', 'dotnet', 'java', 'go', 'php', 'ruby', 'otel']
|
|
19
|
+
/** services[].mode, written since 0.2.0. A 0.1.0 manifest has none. channel: the agent channel (--channel). */
|
|
20
|
+
const MODES = ['standard', 'otel', 'azure-monitor', 'channel']
|
|
18
21
|
const UNDO = { npm: 'uninstall', pnpm: 'remove', yarn: 'remove' }
|
|
19
22
|
/** The only flags an undo command may have: the ones setup itself writes. Everything else must be a package setup adds. */
|
|
20
|
-
const UNDO_FLAGS = new Set(['-w', '--no-package-lock', '--package-lock-only', '--lockfile-only', '--ignore-scripts', '--mode=skip-build'])
|
|
21
|
-
|
|
23
|
+
const UNDO_FLAGS = new Set(['-w', '--no-package-lock', '--package-lock-only', '--lockfile-only', '--ignore-scripts', '--mode=skip-build', '--no-audit', '--no-fund'])
|
|
24
|
+
// The packages of the Node.js stack, and of the UKNOWS hooks for Azure Functions (stacks/functions-hooks.mjs).
|
|
25
|
+
const PACKAGES = new Set([...PINS.node.packages, PINS.node.esmPackage, ...HOOKS.node.packages.map(([name]) => name)])
|
|
22
26
|
const SHA = /^[0-9a-f]{64}$/
|
|
23
27
|
|
|
24
28
|
/** @param {Buffer|null} bytes */
|
|
@@ -41,7 +45,16 @@ export function readManifest(root) {
|
|
|
41
45
|
function valid(m) {
|
|
42
46
|
try {
|
|
43
47
|
if (m?.schema !== 1 || ![m.services, m.changes, m.packages].every(Array.isArray)) return false
|
|
44
|
-
|
|
48
|
+
if (m.updates !== undefined && !RECORDED_BOTS.includes(m.updates)) return false
|
|
49
|
+
for (const s of m.services) {
|
|
50
|
+
checkPath(s.dir)
|
|
51
|
+
if (!STACKS.includes(s.stack) || (s.mode !== undefined && !MODES.includes(s.mode))) return false
|
|
52
|
+
// steps: the titles of the required steps in the repo that were left when setup ran (since 0.2.0). Optional.
|
|
53
|
+
if (s.steps !== undefined && !(Array.isArray(s.steps) && s.steps.every((t) => typeof t === 'string'))) return false
|
|
54
|
+
// channel: {name, registry} of a service in channel mode, and only there. A rerun reads them from here.
|
|
55
|
+
if ((s.mode === 'channel') !== (s.channel !== undefined)) return false
|
|
56
|
+
if (s.channel !== undefined && !(AGENT_CHANNELS.includes(s.channel?.name) && typeof s.channel?.registry === 'string' && AGENT_REGISTRY.test(s.channel.registry))) return false
|
|
57
|
+
}
|
|
45
58
|
for (const c of m.changes) {
|
|
46
59
|
checkPath(c.file)
|
|
47
60
|
if (!c.hunks.every((h) => Number.isInteger(h.at) && [...h.before, ...h.after].every((l) => typeof l === 'string'))) return false
|
|
@@ -74,12 +87,19 @@ export function changeEntry({ file, before, after, bom }, beforeBytes, afterByte
|
|
|
74
87
|
hunks: before === null ? [] : hunks(before, after) }
|
|
75
88
|
}
|
|
76
89
|
|
|
77
|
-
/**
|
|
78
|
-
export
|
|
90
|
+
/** The update bots that a manifest can record (updates: since 0.2.0, optional): what setup chose for the package route. */
|
|
91
|
+
export const RECORDED_BOTS = ['renovate', 'dependabot', 'none']
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Add 1 run to a manifest (or start one). Services are merged by dir. updates: the update bot of this run, kept by a
|
|
95
|
+
* rerun (updates.mjs). Without one, the manifest keeps what it had.
|
|
96
|
+
*/
|
|
97
|
+
export function mergeManifest(old, { services, changes, packages, updates }) {
|
|
79
98
|
const kept = (old?.services ?? []).filter((s) => !services.some((n) => n.dir === s.dir))
|
|
99
|
+
const bot = RECORDED_BOTS.includes(updates) ? updates : RECORDED_BOTS.includes(old?.updates) ? old.updates : null
|
|
80
100
|
return { schema: 1, tool: TOOL, version: VERSION, note: NOTE,
|
|
81
101
|
services: [...kept, ...services].sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)),
|
|
82
|
-
changes: [...(old?.changes ?? []), ...changes], packages: [...(old?.packages ?? []), ...packages] }
|
|
102
|
+
changes: [...(old?.changes ?? []), ...changes], packages: [...(old?.packages ?? []), ...packages], ...(bot ? { updates: bot } : {}) }
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
/** Paths the dirty-tree check ignores: the manifest and every path it lists. */
|