@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,398 @@
1
+ // How setup keeps the UKNOWS agent and its OpenTelemetry packages up to date. On by default, so nobody has to do it:
2
+ //
3
+ // 1. The agent channel, for a Java, .NET or Node.js service that builds from a Dockerfile, when the store of the UKNOWS
4
+ // agent images is known (contract DEFAULT_AGENT_REGISTRY, or UKNOWS_AGENT_REGISTRY). Each build copies the newest
5
+ // stable agent (stacks/channel.mjs), so the repo needs no update bot for it. The engine picks this before it plans.
6
+ // 2. Renovate, for the services on the package route, when the repo already uses Renovate: a config file that Renovate
7
+ // reads, or a "renovate" key in package.json.
8
+ // 3. Otherwise Dependabot and the workflow that merges its pull requests, when the repo is on GitHub. On GitLab, Azure
9
+ // DevOps, Bitbucket or another host with no Renovate, setup adds renovate.json and says that automatic updates need
10
+ // Renovate there, and how to add it in 1 step. With no origin remote, it adds nothing and says how to choose.
11
+ //
12
+ // --update-bot renovate, dependabot or none, and --channel, are explicit choices that skip this order. Choosing an
13
+ // update method never stops setup and never changes its exit code. The plan and the pull request say which method was
14
+ // chosen and why. Azure Functions apps get no update bot: their hooks stay on the versions that UKNOWS tested.
15
+ //
16
+ // Renovate (renovate.json at the top of the repo): only UKNOWS and OpenTelemetry packages, in 1 pull request, 3 days
17
+ // after each release (minimumReleaseAge, the cooldown), merged by Renovate when the checks pass, where the branch rules
18
+ // let it. It updates the whole group in 1 resolve, so the OpenTelemetry packages of Poetry, uv, pip, npm and NuGet
19
+ // projects move together (Dependabot moves 1 package at a time, and on Poetry it finds "No update possible"). Its merges
20
+ // are made by the Renovate app, so they start the deploy workflows like any push. For uv projects, a new version inside
21
+ // the range of pyproject.toml refreshes uv.lock (rangeStrategy update-lockfile). It needs the Renovate app (or a Renovate
22
+ // run) on the repo.
23
+ //
24
+ // Dependabot (GitHub only) adds:
25
+ // .github/dependabot.yml 1 entry per service folder: only OpenTelemetry packages, grouped in 1 pull
26
+ // request, checked every day
27
+ // .github/workflows/uknows-updates.yml merges those Dependabot pull requests when the checks that the branch rules
28
+ // ask for pass (GitHub auto-merge), then starts the deploy workflows
29
+ // Dependabot's "daily" runs Monday to Friday only, so a release on a Friday would wait until Monday. A cron schedule runs
30
+ // every day. Dependabot allows at most 1 run in 24 hours (measured: a cronjob of "30 1,13 * * *" is refused), so an
31
+ // update is proposed at most 24 hours after a release.
32
+ // The job has no checkout, so every gh command names the repo (gh workflow run reads it from git otherwise: measured).
33
+ // A merge made with the workflow's own token starts no other workflow (GitHub's rule), except workflow_dispatch. So the
34
+ // workflow waits for the merge and then starts the deploy workflows that it found here: those with workflow_dispatch
35
+ // that also run on a push to the default branch. Where the branch rules ask for a review, the workflow stops at
36
+ // "waits for a review" and runs again when the review is in.
37
+ // An update is never merged on a branch without a required check: gh merges a pull request with no unmet rule at once,
38
+ // even while its build still runs.
39
+ import { posix } from 'node:path'
40
+
41
+ /** What Dependabot calls the package manager of a service, and the names of the OpenTelemetry packages there. */
42
+ export function ecosystemOf(service, view) {
43
+ const at = (name) => (service.dir === '.' ? name : `${service.dir}/${name}`)
44
+ if (service.lang === 'node') return { ecosystem: 'npm', patterns: ['@opentelemetry/*'] }
45
+ if (service.lang === 'python') {
46
+ const uv = view.exists(at('uv.lock')) || view.exists('uv.lock')
47
+ return { ecosystem: uv ? 'uv' : 'pip', patterns: ['opentelemetry-*'] }
48
+ }
49
+ if (service.lang === 'dotnet') return { ecosystem: 'nuget', patterns: ['OpenTelemetry', 'OpenTelemetry.*'] }
50
+ return null
51
+ }
52
+
53
+ const WORKFLOW = '.github/workflows/uknows-updates.yml'
54
+ const CONFIG = '.github/dependabot.yml'
55
+ /** The commit of dependabot/fetch-metadata v3.1.0 (20 Apr 2026). */
56
+ const FETCH_METADATA = 'dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0'
57
+ /** Every day at 01:30 UTC. */
58
+ export const CRON = '30 1 * * *'
59
+
60
+ /** What --update-bot takes: an update bot, or none for no automatic updates. Without it, setup picks (see above). */
61
+ export const UPDATE_BOTS = ['renovate', 'dependabot', 'none']
62
+ const RENOVATE = 'renovate.json'
63
+ /**
64
+ * Files where a repo keeps its own Renovate config, in the order Renovate reads them (docs.renovatebot.com, configuration
65
+ * options, "Locations for configuration filenames"), before package.json with a "renovate" key.
66
+ */
67
+ export const RENOVATE_FILES = ['renovate.json', 'renovate.jsonc', 'renovate.json5', '.github/renovate.json', '.github/renovate.jsonc',
68
+ '.github/renovate.json5', '.gitlab/renovate.json', '.gitlab/renovate.jsonc', '.gitlab/renovate.json5', '.renovaterc', '.renovaterc.json',
69
+ '.renovaterc.jsonc', '.renovaterc.json5']
70
+
71
+ /** The Renovate config of the repo: the first file that Renovate would read, or package.json with a "renovate" key, or null. */
72
+ export function renovateConfigOf(read) {
73
+ const file = RENOVATE_FILES.find((f) => read(f) !== null)
74
+ if (file) return file
75
+ try {
76
+ const pkg = JSON.parse(read('package.json') ?? 'null')
77
+ return pkg && typeof pkg === 'object' && 'renovate' in pkg ? 'package.json' : null
78
+ } catch { return null }
79
+ }
80
+
81
+ /**
82
+ * The text of a Renovate config, to look for a rule of OpenTelemetry in it. In package.json only the "renovate" key
83
+ * counts: the dependencies there name the OpenTelemetry packages after setup, and that is no Renovate rule.
84
+ */
85
+ function renovateTextOf(file, text) {
86
+ if (file !== 'package.json') return text ?? ''
87
+ try { return JSON.stringify(JSON.parse(text ?? 'null')?.renovate ?? null) } catch { return '' }
88
+ }
89
+
90
+ /** The names of the git hosts, as the plan says them. */
91
+ const HOST_NAMES = { github: 'GitHub', gitlab: 'GitLab', 'azure-devops': 'Azure DevOps', bitbucket: 'Bitbucket', other: 'your git host' }
92
+
93
+ /** Where to add Renovate in 1 step, for a host with no Renovate (Mend Renovate Community Cloud is free). */
94
+ const RENOVATE_APP = {
95
+ gitlab: 'Install the free Mend Renovate app for the group of this repository at https://developer.mend.io.',
96
+ 'azure-devops': 'Install the free Mend Renovate app for this repository at https://developer.mend.io.',
97
+ bitbucket: 'Install the free Mend Renovate app for this repository at https://developer.mend.io.',
98
+ other: 'Run Renovate for this repository, as https://docs.renovatebot.com describes for your git host.',
99
+ }
100
+ /** The cooldown: an update is proposed when the release is 3 days old (owner decision, 22 Oct 2026). */
101
+ export const MINIMUM_RELEASE_AGE = '3 days'
102
+ /** The UKNOWS packages, beside the OpenTelemetry ones. */
103
+ const UKNOWS_PATTERNS = ['@uknows/**']
104
+
105
+ /** renovate.json: every other package stays as it is; UKNOWS and OpenTelemetry in 1 group that merges itself. */
106
+ export function renovateConfig(patterns) {
107
+ const names = [...new Set([...patterns.map((p) => p.replace(/^@opentelemetry\/\*$/, '@opentelemetry/**')), ...UKNOWS_PATTERNS])]
108
+ return `${JSON.stringify({
109
+ $schema: 'https://docs.renovatebot.com/renovate-schema.json',
110
+ description: 'Added by npx @uknows/setup: UKNOWS and OpenTelemetry updates only, in 1 pull request, 3 days after each release. Renovate merges it when your checks pass, where your branch rules let it.',
111
+ dependencyDashboard: false,
112
+ packageRules: [
113
+ { description: 'Other packages: this config proposes nothing for them.', matchPackageNames: ['*'], enabled: false },
114
+ { description: 'UKNOWS and OpenTelemetry: 1 pull request, 3 days after each release, merged when the checks pass.',
115
+ matchPackageNames: names, enabled: true, groupName: 'OpenTelemetry', minimumReleaseAge: MINIMUM_RELEASE_AGE,
116
+ automerge: true, platformAutomerge: false },
117
+ // The OpenTelemetry instrumentation packages of Python have beta versions only (0.66b1). Renovate skips beta
118
+ // versions by default, so it moved the core packages alone, and pip could not install the mix (bench, 23 Oct
119
+ // 2026). A package whose current version is a beta takes the next beta.
120
+ { description: 'OpenTelemetry for Python: a package on a beta version (such as 0.65b0) takes the next beta, with the others.',
121
+ matchDatasources: ['pypi'], matchPackageNames: names, matchCurrentVersion: '/b[0-9]+$/', ignoreUnstable: false },
122
+ // uv projects: Renovate's pep621 manager defaults to "replace", which leaves the >= ranges that setup writes in
123
+ // pyproject.toml alone and never refreshes uv.lock, so a uv project got no update at all (Renovate runner, 25 Oct
124
+ // 2026: none with the default, all 13 packages with update-lockfile). update-lockfile refreshes uv.lock for a version
125
+ // inside the range, and replaces the range for a version outside it. Only for pep621: npm and Poetry refresh their
126
+ // lock files this way by default, pip requirements hold exact versions, and with update-lockfile Renovate finds no
127
+ // NuGet update at all (local Renovate 44.176.3 lookups, 25 Oct 2026).
128
+ { description: 'OpenTelemetry for uv: a new version inside the range of pyproject.toml refreshes uv.lock.',
129
+ matchManagers: ['pep621'], matchPackageNames: names, rangeStrategy: 'update-lockfile' },
130
+ ],
131
+ }, null, 2)}\n`
132
+ }
133
+
134
+ export function dependabotEntries(services, view) {
135
+ const entries = []
136
+ for (const s of services) {
137
+ const e = ecosystemOf(s, view)
138
+ if (!e) continue
139
+ const directory = s.dir === '.' ? '/' : `/${posix.normalize(s.dir)}`
140
+ if (entries.some((x) => x.ecosystem === e.ecosystem && x.directory === directory)) continue
141
+ entries.push({ ...e, directory })
142
+ }
143
+ return entries
144
+ }
145
+
146
+ export function dependabotYaml(entries) {
147
+ return [
148
+ '# Added by npx @uknows/setup: OpenTelemetry package updates, in 1 pull request per folder, checked every day.',
149
+ '# .github/workflows/uknows-updates.yml merges them when the checks pass, then starts the deploy.',
150
+ 'version: 2',
151
+ 'updates:',
152
+ ...entries.flatMap((e) => [
153
+ ` - package-ecosystem: ${e.ecosystem}`,
154
+ ` directory: "${e.directory}"`,
155
+ ' schedule:',
156
+ ' interval: cron',
157
+ ` cronjob: "${CRON}"`,
158
+ ' allow:',
159
+ ...e.patterns.map((p) => ` - dependency-name: "${p}"`),
160
+ ' groups:',
161
+ ' opentelemetry:',
162
+ ' patterns: ["*"]',
163
+ ]),
164
+ '',
165
+ ].join('\n')
166
+ }
167
+
168
+ /** The deploy workflows to start after a merge: workflow_dispatch, and a push to the default branch (or any push). */
169
+ export function deployWorkflows(view) {
170
+ const out = []
171
+ for (const file of view.files().filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f))) {
172
+ if (file === WORKFLOW) continue
173
+ const text = view.read(file) ?? ''
174
+ if (!/^\s*workflow_dispatch\s*:/m.test(text) || !/^\s*push\s*:/m.test(text)) continue
175
+ out.push(posix.basename(file))
176
+ }
177
+ return out
178
+ }
179
+
180
+ export function updatesWorkflow(deploy, patterns) {
181
+ const cases = [...new Set(patterns)].join('|')
182
+ return `name: UKNOWS updates
183
+
184
+ # Added by npx @uknows/setup. Merges the Dependabot pull requests that update only OpenTelemetry packages when
185
+ # the checks that your branch rules ask for pass (GitHub auto-merge), then starts the deploy. Other pull requests are left
186
+ # alone. Needs "Allow auto-merge" in the repository settings, and a branch rule that requires your build check: without
187
+ # a required check, nothing is merged. Where your rules ask for a review, the update waits for it.
188
+
189
+ on:
190
+ pull_request:
191
+ pull_request_review:
192
+ types: [submitted]
193
+
194
+ permissions:
195
+ contents: write
196
+ pull-requests: write
197
+ actions: write
198
+
199
+ concurrency:
200
+ group: uknows-updates-\${{ github.event.pull_request.number }}
201
+ cancel-in-progress: true
202
+
203
+ jobs:
204
+ merge:
205
+ if: github.event.pull_request.user.login == 'dependabot[bot]'
206
+ runs-on: ubuntu-latest
207
+ timeout-minutes: 60
208
+ env:
209
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
210
+ PR: \${{ github.event.pull_request.html_url }}
211
+ BASE: \${{ github.event.pull_request.base.ref }}
212
+ REPO: \${{ github.repository }}
213
+ DEPLOY_WORKFLOWS: "${deploy.join(' ')}"
214
+ steps:
215
+ - id: meta
216
+ uses: ${FETCH_METADATA}
217
+
218
+ - name: Check that the pull request updates only OpenTelemetry packages
219
+ id: only
220
+ env:
221
+ NAMES: \${{ steps.meta.outputs.dependency-names }}
222
+ run: |
223
+ ok=true
224
+ for name in $(printf '%s' "$NAMES" | tr ',' ' '); do
225
+ case "$name" in ${cases}) ;; *) ok=false; echo "Not an OpenTelemetry package: $name" ;; esac
226
+ done
227
+ echo "ok=$ok" >> "$GITHUB_OUTPUT"
228
+
229
+ - name: Check that the branch asks for a passing check
230
+ id: rule
231
+ if: steps.only.outputs.ok == 'true'
232
+ run: |
233
+ classic=$(gh api "repos/$REPO/branches/$BASE" --jq '.protection.required_status_checks.contexts // [] | length' || echo 0)
234
+ rules=$(gh api "repos/$REPO/rules/branches/$BASE" --jq '[.[] | select(.type == "required_status_checks")] | length' || echo 0)
235
+ echo "Required checks on $BASE: \${classic:-0} from branch protection, \${rules:-0} from rulesets."
236
+ if [ "\${classic:-0}" -gt 0 ] || [ "\${rules:-0}" -gt 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; exit 0; fi
237
+ echo "::warning::$BASE has no required check, so this update is not merged by itself. Add a branch rule that requires your build check."
238
+ echo "ok=false" >> "$GITHUB_OUTPUT"
239
+
240
+ - name: Merge when the checks pass, then deploy
241
+ if: steps.only.outputs.ok == 'true' && steps.rule.outputs.ok == 'true'
242
+ run: |
243
+ now() { gh pr view "$PR" --json state,reviewDecision,autoMergeRequest --jq '[.state, (if (.reviewDecision // "") == "" then "NONE" else .reviewDecision end), (if .autoMergeRequest then "AUTO" else "MANUAL" end)] | join(" ")'; }
244
+ read -r state review auto <<< "$(now)"
245
+ if [ "$state" = OPEN ] && [ "$auto" = MANUAL ]; then
246
+ gh pr merge --auto --squash "$PR" || { echo "::error::Could not turn on auto-merge. Turn on Allow auto-merge in the repository settings."; exit 1; }
247
+ fi
248
+ for i in $(seq 1 110); do
249
+ read -r state review auto <<< "$(now)"
250
+ [ "$state" = OPEN ] || break
251
+ if [ "$review" = REVIEW_REQUIRED ] || [ "$review" = CHANGES_REQUESTED ]; then
252
+ echo "::notice::The update waits for a review: $PR"; exit 0
253
+ fi
254
+ sleep 30
255
+ done
256
+ if [ "$state" != MERGED ]; then echo "The pull request is $state. Nothing to deploy now."; exit 0; fi
257
+ [ -n "$DEPLOY_WORKFLOWS" ] || { echo "Merged. No deploy workflow with workflow_dispatch was found: the next push deploys."; exit 0; }
258
+ for wf in $DEPLOY_WORKFLOWS; do gh workflow run "$wf" --repo "$REPO" --ref "$BASE" && echo "Merged, and started $wf."; done
259
+ `
260
+ }
261
+
262
+ /**
263
+ * How this run keeps the agent up to date. Returns {edits, summary, manual, notes, methods, pr, homes, channelLine}:
264
+ * methods is the list of {method, why, services} for the plan and the JSON report, pr the sentences for the pull
265
+ * request. homes names the services that the lines are about: channelLine about homes.channel, every other line about
266
+ * homes.packages, so a plan shows each line with the service it is about.
267
+ * bot --update-bot: 'renovate', 'dependabot' or 'none'. null: setup picks (the order at the top of this file).
268
+ * recorded the bot that .uknows/setup.json records from an earlier run: a rerun keeps it, unless --update-bot chose.
269
+ * host where the origin remote is (git.mjs gitHost): 'github', 'gitlab', 'azure-devops', 'bitbucket', 'other' or null.
270
+ * Never throws: an update method is never a reason to stop setup.
271
+ * @param {{s: object, stack: string, mode: string, name?: string, channel?: {name: string}}[]} plans
272
+ */
273
+ export function updatesPlan(plans, { ws, repo, bot = null, recorded = null, host = null }) {
274
+ const out = { edits: [], summary: [], manual: [], notes: [], methods: [], pr: [], bot: null, homes: { channel: [], packages: [] }, channelLine: null }
275
+ try {
276
+ return planUpdates(plans, { ws, repo, bot, recorded, host }, out)
277
+ } catch (error) {
278
+ out.notes.push(`Setup could not add automatic updates (${error.message}). Your app is set up. To add them, run npx @uknows/setup --update-bot renovate or --update-bot dependabot.`)
279
+ return out
280
+ }
281
+ }
282
+
283
+ const listNames = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names[0] ?? '')
284
+
285
+ function planUpdates(plans, { ws, repo, bot, recorded, host }, out) {
286
+ const chosen = bot ? `you chose --update-bot ${bot}` : recorded ? 'setup chose it before (.uknows/setup.json)' : null
287
+ bot ??= recorded
288
+ if (bot === 'none') {
289
+ out.summary.push(`Keep the agent up to date: off, because ${chosen}. To get new versions, run npx @uknows/setup again.`)
290
+ out.methods.push({ method: 'none', why: chosen, services: plans.map((p) => p.name ?? p.s.name) })
291
+ out.homes.packages = out.methods[0].services
292
+ out.bot = 'none'
293
+ return out
294
+ }
295
+ // 1. The agent channel: each build copies the newest agent of its channel.
296
+ const channel = plans.filter((p) => p.mode === 'channel')
297
+ if (channel.length) {
298
+ const names = channel.map((p) => p.name ?? p.s.name)
299
+ const which = [...new Set(channel.map((p) => p.channel?.name ?? 'stable'))].join(' and ')
300
+ const why = bot === null && channel.every((p) => p.channelDefault) ? `${listNames(names)} ${names.length > 1 ? 'build' : 'builds'} from a Dockerfile` : 'you chose --channel'
301
+ out.channelLine = `Keep the agent up to date: the UKNOWS agent channel (${which}) for ${listNames(names)}, because ${why}. Each build copies the newest ${which} agent, so no update pull request is needed.`
302
+ out.summary.push(out.channelLine)
303
+ out.homes.channel = names
304
+ out.methods.push({ method: 'channel', why, services: names })
305
+ out.pr.push(`Keeps the UKNOWS agent up to date with the agent channel (${which}): each build of ${listNames(names)} copies the newest ${which} agent, because ${why}. No update pull request is needed.`)
306
+ }
307
+ // 2 and 3. The services on the package route. Azure Functions apps keep the versions that UKNOWS tested.
308
+ const packaged = plans.filter((p) => p.mode === 'standard' && !p.s.functions)
309
+ const entries = dependabotEntries(packaged.map((p) => p.s), repo)
310
+ if (!entries.length) return out
311
+ const names = packaged.filter((p) => ecosystemOf(p.s, repo)).map((p) => p.name ?? p.s.name)
312
+ out.homes.packages = names
313
+ const own = renovateConfigOf((f) => ws.read(f))
314
+ let method, why
315
+ if (bot === 'renovate' || bot === 'dependabot') { method = bot; why = chosen }
316
+ else if (own) { method = 'renovate'; why = `this repo already uses Renovate (${own})` }
317
+ else if (host === 'github') { method = 'dependabot'; why = 'this repo is on GitHub and does not use Renovate' }
318
+ else if (host) { method = 'renovate-app'; why = `automatic updates on ${HOST_NAMES[host] ?? HOST_NAMES.other} need Renovate, and this repo does not use it yet` }
319
+ else {
320
+ out.summary.push(`Keep the agent up to date: not added for ${listNames(names)}, because this repo has no origin remote, so setup cannot tell where it is hosted.`)
321
+ out.notes.push('To add automatic updates: on GitHub, run npx @uknows/setup --update-bot dependabot. Anywhere else, run npx @uknows/setup --update-bot renovate and add Renovate to the repository.')
322
+ out.methods.push({ method: 'not-added', why: 'this repo has no origin remote', services: names })
323
+ return out
324
+ }
325
+ const label = method === 'dependabot' ? 'Dependabot' : 'Renovate'
326
+ out.summary.push(`Keep the agent up to date: ${label} for ${listNames(names)}, because ${why}.`)
327
+ out.methods.push({ method, why, services: names })
328
+ out.bot = method === 'dependabot' ? 'dependabot' : 'renovate'
329
+ if (method === 'dependabot') {
330
+ dependabotPlan(entries, { ws, repo }, out)
331
+ out.pr.push(`Keeps the OpenTelemetry packages up to date with Dependabot, because ${why}. Dependabot proposes OpenTelemetry updates every day. Each update merges when your build check passes, then deploys. Where your branch rules ask for a review, it waits for that review.`)
332
+ return out
333
+ }
334
+ renovatePlan(entries, { ws, repo, own, app: method === 'renovate' && !own }, out)
335
+ if (method === 'renovate-app') {
336
+ out.manual.push({ title: `Add Renovate in 1 step: automatic updates on ${HOST_NAMES[host] ?? HOST_NAMES.other} need it`, required: false,
337
+ lines: [RENOVATE_APP[host] ?? RENOVATE_APP.other, `${RENOVATE} is in this change, so Renovate opens no onboarding pull request and proposes only the UKNOWS and OpenTelemetry updates.`] })
338
+ out.pr.push(`Keeps the UKNOWS and OpenTelemetry packages up to date with Renovate, because ${why}. ${RENOVATE_APP[host] ?? RENOVATE_APP.other} Renovate then proposes these updates in 1 pull request, 3 days after each release, and merges it when your build check passes.`)
339
+ return out
340
+ }
341
+ out.pr.push(`Keeps the UKNOWS and OpenTelemetry packages up to date with Renovate, because ${why}. Renovate proposes these updates in 1 pull request, 3 days after each release, and merges it when your build check passes. Where your branch rules ask for a review, it waits for that review.`)
342
+ return out
343
+ }
344
+
345
+ /** Dependabot: .github/dependabot.yml and the workflow that merges its pull requests, then deploys. */
346
+ function dependabotPlan(entries, { ws, repo }, out) {
347
+ if ((repo.top ?? '.') !== '.') {
348
+ out.manual.push({ title: 'Add the update bot at the top of your repo', required: false, lines: [
349
+ `This run is in a folder of the repo, and the update bot files go in .github at its top. Run npx @uknows/setup --update-bot dependabot in the repo root, or add ${CONFIG} with these lines.`],
350
+ snippets: [{ caption: CONFIG, language: 'yaml', code: dependabotYaml(entries) }] })
351
+ return out
352
+ }
353
+ const current = ws.read(CONFIG)
354
+ if (current === null) {
355
+ out.edits.push({ file: CONFIG, before: null, after: dependabotYaml(entries), why: 'Propose OpenTelemetry package updates every day.' })
356
+ out.summary.push(`Add ${CONFIG}: Dependabot proposes OpenTelemetry package updates every day (${entries.map((e) => `${e.ecosystem} in ${e.directory}`).join(', ')}).`)
357
+ } else if (!/uknows|opentelemetry/i.test(current)) {
358
+ out.manual.push({ title: `Add the OpenTelemetry updates to ${CONFIG}`, required: false, lines: [
359
+ `${CONFIG} is there already. This tool does not change it. Add these entries to its updates list.`],
360
+ snippets: [{ caption: CONFIG, language: 'yaml', code: dependabotYaml(entries).split('\n').filter((l) => /^ /.test(l)).join('\n') }] })
361
+ }
362
+ const deploy = deployWorkflows(repo)
363
+ if (ws.read(WORKFLOW) === null) {
364
+ out.edits.push({ file: WORKFLOW, before: null, after: updatesWorkflow(deploy, entries.flatMap((e) => e.patterns)),
365
+ why: 'Merge the OpenTelemetry update pull requests when the checks pass, then deploy.' })
366
+ out.summary.push(`Add ${WORKFLOW}: it merges those pull requests when the checks pass, then starts ${deploy.length ? deploy.join(', ') : 'nothing (no deploy workflow with workflow_dispatch was found)'}.`)
367
+ }
368
+ out.manual.push({ title: 'Let the update pull requests merge themselves', required: false, lines: [
369
+ 'In the repository settings, turn on Allow auto-merge.',
370
+ 'Add a branch rule for your default branch that requires your build check to pass. Then an update merges only when your build passes.',
371
+ 'Where your rules ask for a review, an update waits for that review.'] })
372
+ return out
373
+ }
374
+
375
+ /** Renovate: renovate.json at the top of the repo, or the rules to add to the Renovate config that the repo has (own). */
376
+ function renovatePlan(entries, { ws, repo, own = null, app = true }, out) {
377
+ const config = renovateConfig(entries.flatMap((e) => e.patterns))
378
+ if ((repo.top ?? '.') !== '.') {
379
+ out.manual.push({ title: 'Add the update bot at the top of your repo', required: false, lines: [
380
+ `This run is in a folder of the repo, and renovate.json goes at its top. Run npx @uknows/setup --update-bot renovate in the repo root, or add ${RENOVATE} with these lines.`],
381
+ snippets: [{ caption: RENOVATE, language: 'json', code: config }] })
382
+ return out
383
+ }
384
+ if (own === null) {
385
+ out.edits.push({ file: RENOVATE, before: null, after: config, why: 'Propose UKNOWS and OpenTelemetry updates, 3 days after each release.' })
386
+ out.summary.push(`Add ${RENOVATE}: Renovate proposes UKNOWS and OpenTelemetry updates in 1 pull request, 3 days after each release, and merges it when your checks pass (${entries.map((e) => `${e.ecosystem} in ${e.directory}`).join(', ')}).`)
387
+ } else if (!/opentelemetry/i.test(renovateTextOf(own, ws.read(own)))) {
388
+ const rules = JSON.parse(config).packageRules.slice(1)
389
+ out.manual.push({ title: `Add the OpenTelemetry updates to ${own}`, required: false, lines: [
390
+ `${own} has your Renovate config already. This tool does not change it. Add this rule to its packageRules.`],
391
+ snippets: [{ caption: own, language: 'json', code: JSON.stringify(rules, null, 2) }] })
392
+ }
393
+ out.manual.push({ title: 'Let the update pull requests merge themselves', required: false, lines: [
394
+ ...(app ? ['Install the Renovate app on this repository, or run Renovate for it. With renovate.json in the repo, the app opens no onboarding pull request.'] : []),
395
+ 'Add a branch rule for your default branch that requires your build check to pass. Renovate merges an update only when its checks pass.',
396
+ 'Where your rules ask for a review, an update waits for that review.'] })
397
+ return out
398
+ }
@@ -0,0 +1,115 @@
1
+ /*
2
+ * Version numbers and version limits, as Python (PEP 440), Poetry and NuGet write them. Only what this tool needs:
3
+ * releases with an a, b or rc pre-release, the operators == != < <= > >= ~= and ==1.2.*, Poetry ^ and ~, and NuGet
4
+ * ranges such as [1.17.0, 1.19.0). Local and dev parts are ignored.
5
+ */
6
+
7
+ /** [release numbers, pre-release rank (a 0, b 1, rc 2, final 3), pre-release number]. */
8
+ function parse(version) {
9
+ const m = /^\s*v?(\d+(?:\.\d+)*)(?:[-_.]?(a|alpha|b|beta|rc|c|pre|preview)[-_.]?(\d*))?/i.exec(String(version ?? ''))
10
+ if (!m) return null
11
+ const tag = (m[2] ?? '').toLowerCase()
12
+ const rank = !tag ? 3 : /^a/.test(tag) ? 0 : /^b/.test(tag) ? 1 : 2
13
+ return { release: m[1].split('.').map(Number), rank, n: Number(m[3] || 0) }
14
+ }
15
+
16
+ /** -1, 0 or 1. Missing release parts count as 0, so 1.2 equals 1.2.0. A version that does not parse sorts first. */
17
+ export function compareVersion(a, b) {
18
+ const x = parse(a), y = parse(b)
19
+ if (!x || !y) return x ? 1 : y ? -1 : 0
20
+ for (let i = 0; i < Math.max(x.release.length, y.release.length); i++) {
21
+ const d = (x.release[i] ?? 0) - (y.release[i] ?? 0)
22
+ if (d) return d < 0 ? -1 : 1
23
+ }
24
+ if (x.rank !== y.rank) return x.rank < y.rank ? -1 : 1
25
+ return x.n === y.n ? 0 : x.n < y.n ? -1 : 1
26
+ }
27
+
28
+ /** The next release after a prefix: 1.4 -> 1.5, 2 -> 3. */
29
+ const bump = (parts) => [...parts.slice(0, -1), parts.at(-1) + 1].join('.')
30
+
31
+ /**
32
+ * The clauses of a version limit, as [{op, version}]. Python: '>=1.2,<2', '==1.2.*', '~=1.4.2'. Poetry: '^1.2',
33
+ * '~1.2', '1.2' (exact), '*' (any). NuGet: '[1.17.0, 1.19.0)', '1.2.3' (that version or later). Wildcards and the
34
+ * compatible release become 2 clauses. Unknown text gives no clauses.
35
+ * @param {string|null} spec
36
+ * @param {'python'|'poetry'|'nuget'} [style]
37
+ * @returns {{op: string, version: string}[]}
38
+ */
39
+ export function clauses(spec, style = 'python') {
40
+ const text = String(spec ?? '').trim()
41
+ if (!text || text === '*') return []
42
+ if (style === 'nuget') {
43
+ const range = /^([[(])\s*([^,\])]*?)\s*(?:,\s*([^\])]*?)\s*)?([\])])$/.exec(text)
44
+ if (!range) return parse(text) ? [{ op: '>=', version: text }] : []
45
+ if (range[3] === undefined) return range[2] ? [{ op: '==', version: range[2] }] : []
46
+ const out = []
47
+ if (range[2]) out.push({ op: range[1] === '[' ? '>=' : '>', version: range[2] })
48
+ if (range[3]) out.push({ op: range[4] === ']' ? '<=' : '<', version: range[3] })
49
+ return out
50
+ }
51
+ const out = []
52
+ for (const raw of text.split(/\s*,\s*|\s+(?=[<>=!~^])/)) {
53
+ const part = raw.trim()
54
+ if (!part || part === '*') continue
55
+ const m = /^(===|==|!=|~=|<=|>=|<|>|\^|~|=)?\s*v?([0-9][0-9A-Za-z.*+_-]*)$/.exec(part)
56
+ if (!m) continue
57
+ let op = m[1] ?? (style === 'poetry' ? '==' : '==')
58
+ const version = m[2]
59
+ if (op === '=' || op === '===') op = '=='
60
+ const release = version.replace(/\.\*$/, '').split('.').map((p) => Number.parseInt(p, 10))
61
+ if (op === '==' && version.endsWith('.*')) {
62
+ out.push({ op: '>=', version: release.join('.') }, { op: '<', version: bump(release) })
63
+ } else if (op === '~=') {
64
+ out.push({ op: '>=', version }, { op: '<', version: bump(release.length > 1 ? release.slice(0, -1) : release) })
65
+ } else if (op === '^') {
66
+ const first = release.findIndex((n) => n !== 0)
67
+ const keep = first < 0 ? release.length : first + 1
68
+ out.push({ op: '>=', version }, { op: '<', version: bump(release.slice(0, Math.min(keep, release.length))) })
69
+ } else if (op === '~') {
70
+ out.push({ op: '>=', version }, { op: '<', version: bump(release.length > 1 ? release.slice(0, 2) : release) })
71
+ } else out.push({ op, version })
72
+ }
73
+ return out
74
+ }
75
+
76
+ /** True when the version meets every clause of the limit. No clauses: any version. */
77
+ export function satisfies(version, spec, style = 'python') {
78
+ return clauses(spec, style).every(({ op, version: v }) => {
79
+ const c = compareVersion(version, v)
80
+ return op === '==' ? c === 0 : op === '!=' ? c !== 0 : op === '<' ? c < 0 : op === '<=' ? c <= 0 : op === '>' ? c > 0 : c >= 0
81
+ })
82
+ }
83
+
84
+ /**
85
+ * True when some version meets the limit and lies in [lowest, below). For a protobuf limit against the protobuf
86
+ * range of 1 OpenTelemetry release. != clauses are ignored.
87
+ */
88
+ export function overlaps(spec, [lowest, below], style = 'python') {
89
+ let lo = { v: lowest, incl: true }, hi = { v: below, incl: false }
90
+ for (const { op, version } of clauses(spec, style)) {
91
+ if (op === '==') { lo = tighterLow(lo, { v: version, incl: true }); hi = tighterHigh(hi, { v: version, incl: true }) }
92
+ else if (op === '>=' || op === '>') lo = tighterLow(lo, { v: version, incl: op === '>=' })
93
+ else if (op === '<=' || op === '<') hi = tighterHigh(hi, { v: version, incl: op === '<=' })
94
+ }
95
+ const c = compareVersion(lo.v, hi.v)
96
+ return c < 0 || (c === 0 && lo.incl && hi.incl)
97
+ }
98
+
99
+ function tighterLow(a, b) {
100
+ const c = compareVersion(a.v, b.v)
101
+ return c > 0 ? a : c < 0 ? b : { v: a.v, incl: a.incl && b.incl }
102
+ }
103
+
104
+ function tighterHigh(a, b) {
105
+ const c = compareVersion(a.v, b.v)
106
+ return c < 0 ? a : c > 0 ? b : { v: a.v, incl: a.incl && b.incl }
107
+ }
108
+
109
+ /** The lowest version a limit allows, as [major, minor], for requires-python = ">=3.12" or python = "^3.11". null when open. */
110
+ export function lowestRelease(spec, style = 'python') {
111
+ const low = clauses(spec, style).filter((c) => c.op === '>=' || c.op === '>' || c.op === '==' || c.op === '~=')
112
+ .map((c) => c.version).sort(compareVersion).at(-1)
113
+ const parts = low ? parse(low)?.release : null
114
+ return parts ? [parts[0], parts[1] ?? 0] : null
115
+ }
package/lib/workspace.mjs CHANGED
@@ -144,6 +144,7 @@ export const createWorkspace = (root) => new Workspace(root)
144
144
  * @property {(path: string) => string|null} read
145
145
  * @property {(path: string) => boolean} exists
146
146
  * @property {(path: string) => import('node:fs').Stats|null} stat
147
+ * @property {(dir?: string) => string[]} list Names in 1 folder, as Workspace.list. A folder above the root reads as '../x'.
147
148
  * @property {string} top The repo top, relative to the root: '.', '..' or '../..'.
148
149
  */
149
150
 
@@ -162,7 +163,8 @@ export function repoView(ws) {
162
163
  if (existsSync(join(dir, '.git'))) { top = dir; break }
163
164
  if (dirname(dir) === dir) break
164
165
  }
165
- const view = { files: () => ws.files(), read: (path) => ws.read(path), exists: (path) => ws.exists(path), stat: (path) => ws.stat(path), top: '.' }
166
+ const view = { files: () => ws.files(), read: (path) => ws.read(path), exists: (path) => ws.exists(path), stat: (path) => ws.stat(path),
167
+ list: (dir = '.') => ws.list(dir), top: '.' }
166
168
  if (!top || top === ws.root) return view
167
169
  const outer = createWorkspace(top)
168
170
  const prefix = relative(top, ws.root).split(sep).join('/')
@@ -174,6 +176,7 @@ export function repoView(ws) {
174
176
  read: (path) => (above(path) ? outer.read(toTop(path)) : ws.read(path)),
175
177
  exists: (path) => (above(path) ? outer.exists(toTop(path)) : ws.exists(path)),
176
178
  stat: (path) => (above(path) ? outer.stat(toTop(path)) : ws.stat(path)),
179
+ list: (dir = '.') => (dir === '..' || above(dir) ? outer.list(toTop(dir)) : ws.list(dir)),
177
180
  top: prefix.split('/').map(() => '..').join('/'),
178
181
  }
179
182
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@uknows/setup",
3
- "version": "0.1.0",
4
- "description": "Add standard OpenTelemetry to your app in 1 command, so it sends traces to UKNOWS. Shows the change, asks once, can undo.",
3
+ "version": "0.2.0",
4
+ "description": "Add standard OpenTelemetry to your app in 1 command, so it sends traces and logs to UKNOWS. Shows the change, asks first, can undo.",
5
5
  "keywords": [
6
6
  "uknows",
7
7
  "opentelemetry",
@@ -12,12 +12,7 @@
12
12
  "setup",
13
13
  "cli"
14
14
  ],
15
- "homepage": "https://uknows.defacloud.my/docs",
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/MohammediYunus/uknows.git",
19
- "directory": "packages/uknows-setup"
20
- },
15
+ "homepage": "https://uknows.my/docs",
21
16
  "license": "Apache-2.0",
22
17
  "author": "UKNOWS",
23
18
  "type": "module",