@uknows/setup 0.0.0-stage → 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 (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. package/package.json +37 -4
@@ -0,0 +1,57 @@
1
+ // Java: copy the pinned OpenTelemetry Java agent into the final image with the docs COPY --from line
2
+ // (the image digest pins the jar) and load it with JAVA_TOOL_OPTIONS. Build files are never touched.
3
+ // Both lines go after the last RUN, so build steps (mvn, gradle) never load the agent.
4
+ import { JAVA_AGENT, STACKS } from '../contract.mjs'
5
+ import { addOption, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
6
+ import { envOverrides } from './k8s.mjs'
7
+
8
+ const DOCS = STACKS.find((s) => s.id === 'java')
9
+ const COPY_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('COPY --from='))
10
+ // 0.1.0 wrote an ADD line with --checksum and --chmod, which need BuildKit. Earlier /docs gave --chmod=644.
11
+ // A Dockerfile with one of those exact lines gets the COPY line instead.
12
+ const ADD_LINE = `ADD --checksum=sha256:${JAVA_AGENT.sha256} --chmod=755 ${JAVA_AGENT.url} /otel/${JAVA_AGENT.file}`
13
+ const OLD_LINES = [ADD_LINE, ADD_LINE.replace('--chmod=755', '--chmod=644')]
14
+ const AGENT_OPTION = /JAVA_TOOL_OPTIONS="([^"]+)"/.exec(DOCS.dockerfile)[1]
15
+ const AGENT_PATH = AGENT_OPTION.replace(/^-javaagent:/, '')
16
+ const note = (re) => DOCS.notes.find((n) => re.test(n))
17
+
18
+ /** @type {import('../types.mjs').Applier} */
19
+ export const java = {
20
+ id: 'java',
21
+ plan(service, ctx) {
22
+ const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
23
+ const text = service.dockerfile && ctx.ws.read(service.dockerfile)
24
+ const info = text ? parseDockerfile(text) : null
25
+ const image = info?.image ?? ''
26
+ if (!text || /windows|nanoserver|servercore/i.test(image)) {
27
+ plan.manual.push({ title: `Add the OpenTelemetry Java agent ${JAVA_AGENT.version}`, lines: [
28
+ text ? `The final image ${image} is a Windows image. This tool adds the agent to Linux images only.`
29
+ : 'This app does not build from a Dockerfile, so this tool cannot add the agent.',
30
+ 'Download the agent and check its checksum with the commands below.', note(/macOS/),
31
+ `Start the JVM with ${AGENT_OPTION.replace(AGENT_PATH, `<path to ${JAVA_AGENT.file}>`)}, or set it in JAVA_TOOL_OPTIONS.`,
32
+ note(/Put -javaagent before -jar/)],
33
+ snippets: [{ caption: 'Download and check the agent', language: 'bash', code: DOCS.install },
34
+ { caption: 'Start', language: 'bash', code: DOCS.start }] })
35
+ return plan
36
+ }
37
+ const has = (value) => value.includes(AGENT_OPTION)
38
+ plan.manual.push(...envOverrides(service, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has))
39
+ const fixed = OLD_LINES.reduce((out, line) => out.split(line).join(COPY_LINE), text)
40
+ const added = [JAVA_AGENT.url, JAVA_AGENT.image, AGENT_PATH].some((s) => fixed.includes(s)) ? fixed : insertBeforeStart(fixed, [COPY_LINE])
41
+ const after = addOption(added, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has)
42
+ if (after === text) return plan
43
+ if (after === fixed) {
44
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Copy the OpenTelemetry Java agent from the pinned image.' })
45
+ plan.summary.push(`Replace the ADD line of the agent in ${service.dockerfile} with a COPY line from the pinned OpenTelemetry image. It needs no BuildKit.`)
46
+ return plan
47
+ }
48
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the OpenTelemetry Java agent and load it when the JVM starts.' })
49
+ plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}, from the image pinned by its digest.`,
50
+ `Load the agent when the JVM starts (JAVA_TOOL_OPTIONS in ${service.dockerfile}).`)
51
+ plan.notes.push(note(/digest pins/))
52
+ if (info.env.has('JAVA_TOOL_OPTIONS') || info.inherited.has('JAVA_TOOL_OPTIONS')) {
53
+ plan.notes.push(`${service.dockerfile} already sets JAVA_TOOL_OPTIONS. A new ENV line before the start keeps that value and adds the agent.`)
54
+ }
55
+ return plan
56
+ },
57
+ }
@@ -0,0 +1,31 @@
1
+ // Kubernetes containers that replace what this tool sets in the image: an env value, or the start command.
2
+ // The image edit stays (it works with docker run). These steps say what to change in the manifest too.
3
+
4
+ /**
5
+ * Manual steps for containers whose env sets `name`, which replaces the image value that loads OpenTelemetry.
6
+ * @param {import('../types.mjs').Service} service
7
+ * @param {string} name e.g. NODE_OPTIONS
8
+ * @param {string} option what the value needs, e.g. --require ...
9
+ * @param {(value: string) => boolean} has true when a value already has it
10
+ * @returns {import('../types.mjs').Block[]}
11
+ */
12
+ export function envOverrides(service, name, option, has) {
13
+ return (service.k8s ?? []).filter((k) => k.env && Object.prototype.hasOwnProperty.call(k.env, name) && !(k.env[name] !== null && has(k.env[name])))
14
+ .map((k) => ({ title: `Add the option to ${name} in ${k.file}`, lines: [
15
+ `${k.file} sets ${name} for the container ${k.container}. That value replaces the one in the image, so OpenTelemetry would not load.`,
16
+ `Add ${option} to the end of that value.`] }))
17
+ }
18
+
19
+ /**
20
+ * Manual steps for containers whose command (or args, for a CMD) replaces the image start that this tool changed.
21
+ * @param {import('../types.mjs').Service} service
22
+ * @param {'ENTRYPOINT'|'CMD'} instruction the instruction that got the words
23
+ * @param {string} words e.g. opentelemetry-instrument
24
+ * @returns {import('../types.mjs').Block[]}
25
+ */
26
+ export function startOverrides(service, instruction, words) {
27
+ return (service.k8s ?? []).filter((k) => k.command || (k.args && instruction === 'CMD')).map((k) => ({
28
+ title: `Start the app with ${words} in ${k.file}`, lines: [
29
+ `${k.file} sets ${k.command ? 'command' : 'args'} for the container ${k.container}. That replaces the ${instruction} of the image.`,
30
+ `Put ${words} in front of the command there.`] }))
31
+ }
@@ -0,0 +1,358 @@
1
+ /*
2
+ * Node.js: add @opentelemetry/api and @opentelemetry/auto-instrumentations-node
3
+ * with the package manager of the repo, then load OpenTelemetry when node
4
+ * starts: NODE_OPTIONS in the Dockerfile, or the option right after `node` in
5
+ * scripts.start.
6
+ *
7
+ * It loads the start file uknows-otel.cjs (install.ts NODE_REGISTER_FILE), which
8
+ * this tool adds next to package.json: it stops the app cleanly on SIGTERM after
9
+ * the last data. In an image, a COPY line in the final stage puts it in the
10
+ * WORKDIR. When the build context, the WORKDIR or .dockerignore makes that
11
+ * unsure, it loads the upstream register line instead and says why.
12
+ *
13
+ * Safety rule: node crashes at start when the register module is missing, so
14
+ * the start changes only when the packages surely reach the running app.
15
+ * Package managers run with install scripts turned off, so no code of the
16
+ * project or its dependencies runs.
17
+ */
18
+ import { buildContext, finalWorkdir, ignoredBy } from '../context.mjs'
19
+ import { unpicked } from '../settings.mjs'
20
+ import { Doc, shellWords } from '../doc.mjs'
21
+ import { addOption, dockerStart, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
22
+ import { NODE_REGISTER, NODE_REGISTER_FILE, NODE_START_FILE, PINS, RESOLVE, STACKS } from '../contract.mjs'
23
+ import { list } from '../ui.mjs'
24
+ import { upFrom } from '../workspace.mjs'
25
+ import { envOverrides } from './k8s.mjs'
26
+
27
+ const STACK = STACKS.find((s) => s.id === 'node')
28
+ const START_FILE = NODE_REGISTER_FILE.path
29
+ const START_TEXT = `${NODE_REGISTER_FILE.content}\n`
30
+ /** The docs option for the start file. Its steps go in the advice when this tool cannot add the file. */
31
+ const SIGTERM = STACK.options.find((o) => o.title.startsWith('Stops cleanly on SIGTERM'))
32
+ /** A NODE_OPTIONS value or a start script that loads OpenTelemetry already: the upstream register or the start file. */
33
+ const loads = (value) => value.includes(NODE_REGISTER) || value.includes(START_FILE)
34
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
35
+ const LOCKS = [['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
36
+ ['bun.lockb', 'bun'], ['bun.lock', 'bun']]
37
+ const DEFAULT_LOCK = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }
38
+ const RUNS_NODE = /^(node|nodejs|npm|npx|yarn|pnpm)$|\.[cm]?js$/
39
+ const BUNDLER = /\b(esbuild|webpack|rollup|tsup|ncc)\b/
40
+ const VALUE_FLAGS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '--env-file', '-C', '--conditions'])
41
+
42
+ /** @type {import('../types.mjs').Applier} */
43
+ export const node = {
44
+ id: 'node',
45
+ plan(service, ctx) {
46
+ const { ws } = ctx
47
+ const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
48
+ const pkgFile = join(service.dir, 'package.json')
49
+ const pkgText = read(ws, pkgFile)
50
+ let pkg = null
51
+ try { pkg = JSON.parse(pkgText) } catch { /* handled below */ }
52
+ if (pkgText === null) {
53
+ out.manual.push({ title: 'Add OpenTelemetry to your Node.js app', lines: [`This tool did not find ${pkgFile}.`,
54
+ 'Add the packages with your package manager. Then start node with the option below.'],
55
+ snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: STACK.start }] })
56
+ return out
57
+ }
58
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) {
59
+ out.manual.push({ title: `Fix ${pkgFile}`, lines: [`This tool cannot read ${pkgFile} as JSON.`, 'Fix the file. Then run npx @uknows/setup again.'] })
60
+ return out
61
+ }
62
+ // Too old for the packages: with --import or --require of the register module, node would not start.
63
+ const v = /^(\d+)(?:\.(\d+))?/.exec(service.runtimeVersion ?? '')
64
+ const [major, minor] = v ? [Number(v[1]), v[2] === undefined ? 99 : Number(v[2])] : [99, 99]
65
+ if (major < 18 || major === 19 || (major === 18 && minor < 19) || (major === 20 && minor < 6)) {
66
+ out.manual.push({ title: 'Use Node.js 18.19 or later', lines: [`This app uses Node.js ${service.runtimeVersion}.`,
67
+ 'The OpenTelemetry packages need Node.js 18.19 or later on version 18, or 20.6 or later.',
68
+ 'Update the Node.js version of the app. Then run npx @uknows/setup again.'] })
69
+ return out
70
+ }
71
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
72
+ const info = docker !== null ? parseDockerfile(docker) : null
73
+ const start = info ? dockerStart(info) : null
74
+ const script = typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null
75
+ const entry = entryOf(info ? start?.argv : service.start?.argv ?? shellWords(script ?? ''), script)
76
+ const esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
77
+ const flag = PINS.node.flag(esm)
78
+
79
+ const guided = guidedBlock(service, ws, pkg, info, entry, flag)
80
+ if (guided) { out.manual.push(guided); return out }
81
+ // The docs settings for Node.js: only the resource detectors that ask no cloud metadata address (NETWORK.node).
82
+ for (const line of STACK.extraSettings?.lines ?? []) {
83
+ const at = line.indexOf('=')
84
+ out.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
85
+ }
86
+ const pm = packageManager(service.dir, pkg, ctx.repo ?? ws)
87
+ if (pm.outside) {
88
+ out.manual.push({ title: 'Run setup from the repo root', lines: [
89
+ `${service.dir === '.' ? 'This folder' : service.dir} is part of a workspace. Its ${base(pm.outside)} is in ${dirOf(pm.outside)}, `
90
+ + 'outside the folder this tool works in, so this tool cannot update it.',
91
+ 'Run npx @uknows/setup in the repo root without --path. Then pick this service from the list, or use --all.'] })
92
+ out.stop = true
93
+ return out
94
+ }
95
+ const wanted = [...PINS.node.packages, ...(esm ? [PINS.node.esmPackage] : [])]
96
+ if (pm.cmd === 'bun' || pm.cmd === 'other' || pm.pnp) {
97
+ out.manual.push({ title: 'Add OpenTelemetry with your package manager',
98
+ lines: [pm.pnp ? 'This repo uses Yarn Plug\'n\'Play, so node cannot load the packages with --require alone.'
99
+ : 'This tool adds packages with npm, pnpm or yarn only.', 'Add the packages below as normal dependencies.',
100
+ pm.pnp ? 'Start the app with yarn node and the option below.' : 'Start node with the option below.'],
101
+ snippets: [{ caption: 'Packages', language: 'text', code: wanted.join('\n') },
102
+ { caption: 'Start command', language: 'bash', code: `${pm.pnp ? 'yarn ' : ''}${startLine(flag, entry)}` }] })
103
+ return out
104
+ }
105
+ const added = wanted.filter((name) => !pkg.dependencies?.[name])
106
+ // npm here writes the lock that npm ci in the image reads: a newer npm than the image has stops the run.
107
+ const npmBlock = added.length && pm.cmd === 'npm' && pm.lock && info ? npmGuard(service, info, ctx.tools?.npm) : null
108
+ if (npmBlock) {
109
+ out.manual.push(npmBlock)
110
+ out.stop = true
111
+ return out
112
+ }
113
+ if (added.length) {
114
+ const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(service.dir, DEFAULT_LOCK[pm.cmd]))
115
+ // No install scripts, no audit and no funding lines (they are about the app, not this change). npm with a
116
+ // lockfile and pnpm write only package.json and the lockfile.
117
+ const flags = pm.cmd === 'npm' ? ['--ignore-scripts', '--no-audit', '--no-fund', pm.lock ? '--package-lock-only' : '--no-package-lock']
118
+ : pm.cmd === 'pnpm' ? ['--lockfile-only', ...(ws.exists(join(service.dir, 'pnpm-workspace.yaml')) ? ['-w'] : [])]
119
+ : [pm.berry ? '--mode=skip-build' : '--ignore-scripts']
120
+ const why = `Add ${list(added)} with ${pm.cmd}.`
121
+ const lockOnly = pm.cmd === 'pnpm' || (pm.cmd === 'npm' && Boolean(pm.lock))
122
+ out.commands.push({ cwd: service.dir, cmd: pm.cmd, why, added, lockOnly,
123
+ args: pm.cmd === 'npm' ? ['install', '--save', ...flags, ...added] : ['add', ...flags, ...added],
124
+ undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags, ...added] },
125
+ touches: [pkgFile, ...(lock ? [lock] : [])] })
126
+ out.summary.push(why)
127
+ if (lockOnly) out.notes.push(`${pm.cmd} updates only package.json and ${base(lock)} here, so node_modules on this computer does not have the new packages yet. Run ${pm.cmd} install before you start the app on this computer. Your build installs them from ${base(lock)}.`)
128
+ }
129
+
130
+ if (info) {
131
+ const runsNode = start && (start.argv ?? [start.raw]).flatMap((w) => w.split(/\s+/)).some((w) => RUNS_NODE.test(base(w.replace(/^["']|["']$/g, ''))))
132
+ if (!runsNode) out.manual.push(startBlock(flag, entry, `the start command in ${service.dockerfile}`))
133
+ else {
134
+ const current = info.env.get('NODE_OPTIONS') ?? info.inherited.get('NODE_OPTIONS')
135
+ const done = Boolean(current && loads(current.value))
136
+ const place = done ? null : placeStartFile(service, ctx.repo ?? ws, info, ctx)
137
+ const option = place?.path ? PINS.node.flag(esm, place.path) : flag
138
+ let after = docker
139
+ if (place?.path) {
140
+ addStartFile(out, ws, place.file)
141
+ const copied = info.final.some((x) => (x.keyword === 'COPY' || x.keyword === 'ADD') && x.args.includes(START_FILE))
142
+ if (!copied) after = insertBeforeStart(after, [place.copy])
143
+ } else if (place) {
144
+ out.manual.push({ title: SIGTERM.title, required: false, lines: [place.why, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
145
+ }
146
+ after = addOption(after, 'NODE_OPTIONS', option, loads)
147
+ if (after !== docker) {
148
+ out.edits.push({ file: service.dockerfile, before: docker, after, why: place?.path ? `Load OpenTelemetry when node starts. ${NODE_START_FILE.copy}` : 'Load OpenTelemetry when node starts.' })
149
+ out.summary.push(`Load OpenTelemetry when node starts (NODE_OPTIONS in ${service.dockerfile}).`)
150
+ if (current) {
151
+ out.notes.push(`${service.dockerfile} already sets NODE_OPTIONS. A new ENV line before the start keeps that value and adds the option.`)
152
+ }
153
+ }
154
+ out.manual.push(...envOverrides(service, 'NODE_OPTIONS', option, loads))
155
+ }
156
+ } else if (script !== null && service.start?.kind !== 'procfile' && shellWords(script)?.[0] === 'node') {
157
+ if (!loads(script)) {
158
+ // npm start runs the script in the package folder, so ./uknows-otel.cjs is the file next to package.json.
159
+ // The start file is only for a start that surely has it: no Dockerfile that could build this app without it, not ignored by git.
160
+ const unsure = scriptStartUnsure(service, ctx)
161
+ const after = editStartScript(pkgText, unsure ? flag : PINS.node.flag(esm, `./${START_FILE}`))
162
+ if (after === null) out.manual.push(startBlock(flag, entry, `scripts.start in ${pkgFile}`))
163
+ else {
164
+ if (unsure) out.manual.push({ title: SIGTERM.title, required: false, lines: [unsure, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
165
+ else addStartFile(out, ws, join(service.dir, START_FILE))
166
+ out.edits.push({ file: pkgFile, before: pkgText, after, why: 'Load OpenTelemetry when node starts.' })
167
+ out.summary.push(`Load OpenTelemetry when node starts (start script in ${pkgFile}).`)
168
+ }
169
+ }
170
+ } else {
171
+ out.manual.push(startBlock(flag, entry, script !== null ? `scripts.start in ${pkgFile}` : null))
172
+ }
173
+ return out
174
+ },
175
+ }
176
+
177
+ /**
178
+ * Where the start file goes in the image: {file, path, copy}, or {why} when this tool is not sure. The file sits
179
+ * next to package.json. The COPY line takes it from the build context into the WORKDIR of the final stage.
180
+ */
181
+ function placeStartFile(service, view, info, ctx = {}) {
182
+ const file = join(service.dir, START_FILE)
183
+ const values = { file, dockerfile: service.dockerfile }
184
+ const workdir = finalWorkdir(info)
185
+ if (!workdir) return { why: fill(NODE_START_FILE.noWorkdir, values) }
186
+ const context = buildContext(view, service.dockerfile, info, service.dir)
187
+ if (context === null) return { why: fill(NODE_START_FILE.noContext, values) }
188
+ if (context !== '.' && !file.startsWith(`${context}/`)) return { why: fill(NODE_START_FILE.outside, values) }
189
+ const source = context === '.' ? file : file.slice(context.length + 1)
190
+ const ignore = ignoredBy(view, context, service.dockerfile, source)
191
+ if (ignore) return { why: fill(NODE_START_FILE.ignored, { ...values, ignore }) }
192
+ // The pipeline builds from a checkout: a file that git ignores never reaches it, and the COPY line fails there.
193
+ if (!read(view, file) && ctx.ignored?.(file)) return { why: fill(NODE_START_FILE.gitignored, values) }
194
+ const dir = workdir === '/' ? '' : workdir
195
+ return { file, path: `${dir}/${START_FILE}`, copy: `COPY ${source} ${dir}/` }
196
+ }
197
+
198
+ /**
199
+ * Why a start script must not load the start file, or null when it can: git ignores the file, so a checkout lacks it,
200
+ * or the repo has a Dockerfile that this tool did not pick for any service (an image built from it would not copy the
201
+ * file, and node stops at start when a --require file is missing). The upstream register line has no such file.
202
+ */
203
+ function scriptStartUnsure(service, ctx) {
204
+ const view = ctx.repo ?? ctx.ws
205
+ const file = join(service.dir, START_FILE)
206
+ if (!read(view, file) && ctx.ignored?.(file)) return fill(NODE_START_FILE.gitignored, { file })
207
+ const others = unpicked(ctx)
208
+ return others.length ? fill(NODE_START_FILE.dockerfiles, { file, dockerfiles: list(others.slice(0, 5)) }) : null
209
+ }
210
+
211
+ /** Add the start file when it is not there. A file that is there stays as it is: it can be the user's own copy. */
212
+ function addStartFile(out, ws, file) {
213
+ const current = read(ws, file)
214
+ if (current === null) {
215
+ out.edits.push({ file, before: null, after: START_TEXT, why: fill(NODE_START_FILE.add, { file }) })
216
+ out.summary.push(fill(NODE_START_FILE.add, { file }))
217
+ } else if (current !== START_TEXT) out.notes.push(fill(NODE_START_FILE.keep, { file }))
218
+ }
219
+
220
+ /**
221
+ * A required step when npm on this computer is newer (by major version) than the npm of the image that runs npm ci:
222
+ * the npm of a RUN npm install -g npm@X line, else the npm that the node image of that stage comes with (PINS.node.npm).
223
+ * null when the versions fit or are not known.
224
+ */
225
+ export function npmGuard(service, info, have) {
226
+ const haveMajor = Number(/^(\d+)\./.exec(have ?? '')?.[1] ?? NaN)
227
+ if (!Number.isFinite(haveMajor)) return null
228
+ const runs = (stage) => stage.instructions.filter((x) => x.keyword === 'RUN')
229
+ const text = (x) => (x.argv ? x.argv.join(' ') : x.args)
230
+ let image = null, line = null, need = null
231
+ for (const stage of info.stages) {
232
+ for (const x of runs(stage)) {
233
+ const pin = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@(\d+)/.exec(text(x))
234
+ if (pin) { need = Number(pin[1]); image = `npm@${pin[1]}`; line = x.start + 1 }
235
+ }
236
+ }
237
+ if (need === null) {
238
+ const stages = info.stages, byName = new Map(stages.filter((st) => st.name).map((st) => [st.name.toLowerCase(), st]))
239
+ const stage = stages.find((st) => runs(st).some((x) => /\bnpm\s+(?:ci|install|i)\b/.test(text(x)))) ?? stages.at(-1)
240
+ if (!stage) return null
241
+ let from = stage.image
242
+ for (let hop = 0; hop < 5 && byName.has(from.toLowerCase()); hop++) from = byName.get(from.toLowerCase()).image
243
+ const m = /(?:^|\/)node:(\d+)(?:\.(\d+))?/.exec(from)
244
+ if (!m) return null
245
+ const v = [Number(m[1]), m[2] === undefined ? 99 : Number(m[2])]
246
+ need = PINS.node.npm.find(([[a, b]]) => v[0] > a || (v[0] === a && v[1] >= b))?.[1] ?? null
247
+ image = from
248
+ line = (info.instructions.find((x) => x.keyword === 'FROM' && x.args.split(/\s+/).includes(stage.image))?.start ?? 0) + 1
249
+ }
250
+ if (need === null || haveMajor <= need) return null
251
+ const values = { file: service.dockerfile, line, image, need, have }
252
+ const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
253
+ return { title: fill(RESOLVE.npm.title), lines: RESOLVE.npm.lines.map(fill) }
254
+ }
255
+
256
+ /** Cases where loading the register module cannot work: guided only, no packages. */
257
+ function guidedBlock(service, ws, pkg, info, entry, flag) {
258
+ if (service.frameworks.includes('next')) {
259
+ return { title: 'Next.js needs its own OpenTelemetry setup', lines: ['Next.js builds its server code, so the --require option cannot load OpenTelemetry.',
260
+ 'Follow the OpenTelemetry guide in the Next.js docs. It adds an instrumentation.ts file.', 'Keep the 5 UKNOWS settings. That setup reads them.'] }
261
+ }
262
+ if (info && (service.frameworks.includes('nitro') || service.frameworks.includes('nuxt'))) {
263
+ const install = info.final.some((x) => x.keyword === 'RUN' && /\b(npm (ci|install|i)|pnpm (install|i)|yarn)\b/.test(x.args))
264
+ const copies = info.final.filter((x) => x.keyword === 'COPY' || x.keyword === 'ADD')
265
+ if (!install && !copies.some((x) => /node_modules/.test(x.args) || !/\.output/.test(x.args))) {
266
+ return { title: 'Put node_modules in the final image', verify: true, lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
267
+ 'Install the packages in the final stage, or copy node_modules into it.', 'Then run npx @uknows/setup again.'] }
268
+ }
269
+ }
270
+ const bundler = BUNDLER.exec(typeof pkg.scripts?.build === 'string' ? pkg.scripts.build : '')
271
+ if (bundler && !(entry && ws.exists(join(service.dir, entry.replace(/^\.\//, ''))))) {
272
+ return { title: 'Load OpenTelemetry in a bundled app', lines: [`The build script bundles the app with ${bundler[1]}. OpenTelemetry cannot change bundled packages.`,
273
+ 'Add the packages below as normal dependencies. In the bundler config, mark them and the libraries to trace as external.',
274
+ 'Start node with the option below.'],
275
+ snippets: [{ caption: 'Install', language: 'bash', code: STACK.install }, { caption: 'Start command', language: 'bash', code: startLine(flag, entry) }] }
276
+ }
277
+ return null
278
+ }
279
+
280
+ /**
281
+ * The package manager from the packageManager field, else from the lockfile of the service folder or of
282
+ * the workspace root above it (a parent folder whose package.json has workspaces, or with pnpm-workspace.yaml).
283
+ * The search goes on above the --path folder, up to the top of the git repo (view.top). A workspace root or
284
+ * its lockfile up there is `outside`: this tool cannot update it.
285
+ */
286
+ export function packageManager(dir, pkg, view) {
287
+ const field = typeof pkg.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@(\d+)/.exec(pkg.packageManager) : null
288
+ let cmd = field ? field[1] : typeof pkg.packageManager === 'string' ? 'other' : null
289
+ let lock = null, outside = null
290
+ for (const d of upFrom(dir, view.top ?? '.')) {
291
+ const pnpmRoot = view.exists(join(d, 'pnpm-workspace.yaml'))
292
+ const marker = pnpmRoot || /"workspaces"\s*:/.test(read(view, join(d, 'package.json')) ?? '')
293
+ const hit = (d === dir || marker) && LOCKS.find(([file, owner]) => (!cmd || cmd === owner) && read(view, join(d, file)) !== null)
294
+ if ((d === '..' || d.startsWith('../')) && (marker || hit)) { outside = join(d, hit ? hit[0] : pnpmRoot ? 'pnpm-workspace.yaml' : 'package.json'); break }
295
+ if (hit) { lock = join(d, hit[0]); cmd = hit[1]; break }
296
+ }
297
+ cmd ??= 'npm'
298
+ const home = lock ? dirOf(lock) : dir
299
+ const rc = read(view, join(home, '.yarnrc.yml'))
300
+ const berry = cmd === 'yarn' && (field ? Number(field[2]) >= 2 : rc !== null || /^__metadata:/m.test(read(view, lock ?? '') ?? ''))
301
+ const pnp = cmd === 'yarn' && (view.exists(join(home, '.pnp.cjs')) || view.exists(join(home, '.pnp.js'))
302
+ || (berry && !/^\s*nodeLinker\s*:\s*["']?(node-modules|pnpm)/m.test(rc ?? '')))
303
+ return { cmd, lock, pnp, berry, outside }
304
+ }
305
+
306
+ /** The JS file that node runs, from a start argv. `npm start` and friends read scripts.start. */
307
+ export function entryOf(argv, script) {
308
+ let words = argv ?? null
309
+ if (words && /^(npm|yarn|pnpm)$/.test(base(words[0])) && (words[1] === 'start' || (words[1] === 'run' && words[2] === 'start'))) words = shellWords(script ?? '')
310
+ if (!words?.length) return null
311
+ if (/\.[cm]?[jt]s$/.test(words[0])) return words[0]
312
+ if (base(words[0]) !== 'node') return null
313
+ for (let i = 1; i < words.length; i++) {
314
+ if (!words[i].startsWith('-')) return words[i]
315
+ if (VALUE_FLAGS.has(words[i])) i++
316
+ }
317
+ return null
318
+ }
319
+
320
+ /** Insert the option right after `node` in scripts.start. Changes only that JSON string. null when unsure. */
321
+ function editStartScript(text, flag) {
322
+ const doc = new Doc(text)
323
+ const from = doc.find(/"scripts"\s*:/)
324
+ const i = from < 0 ? -1 : doc.find(/"start"\s*:\s*"/, from)
325
+ if (i < 0) return null
326
+ const line = doc.get(i)
327
+ const m = /("start"\s*:\s*)("(?:[^"\\]|\\.)*")/.exec(line)
328
+ if (!m) return null
329
+ const value = JSON.parse(m[2]).replace(/^(\s*node)(?=\s|$)/, `$1 ${flag}`)
330
+ doc.set(i, line.slice(0, m.index + m[1].length) + JSON.stringify(value) + line.slice(m.index + m[0].length))
331
+ const after = doc.toString()
332
+ try {
333
+ const expected = JSON.parse(text)
334
+ expected.scripts.start = value
335
+ return JSON.stringify(JSON.parse(after)) === JSON.stringify(expected) ? after : null
336
+ } catch { return null }
337
+ }
338
+
339
+ function startLine(flag, entry) {
340
+ return STACK.start.replace(PINS.node.requireFlag, flag).replace(/server\.js$/, entry ?? 'server.js')
341
+ }
342
+
343
+ function startBlock(flag, entry, where) {
344
+ return { title: 'Load OpenTelemetry when node starts',
345
+ lines: [where ? `This tool cannot change ${where} safely.` : 'This tool did not find the command that starts the app.',
346
+ 'Add the option below to the node command that starts the app. Or set NODE_OPTIONS to it where the app runs.',
347
+ ...STACK.notes.filter((n) => !entry && /server\.js/.test(n))],
348
+ snippets: [{ caption: 'Start command', language: 'bash', code: startLine(flag, entry) },
349
+ { caption: 'Or set NODE_OPTIONS', language: 'bash', code: `NODE_OPTIONS="${flag}"` }] }
350
+ }
351
+
352
+ const base = (word) => (word ?? '').split('/').pop()
353
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
354
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
355
+
356
+ function read(ws, path) {
357
+ try { return path ? ws.read(path) : null } catch { return null }
358
+ }
@@ -0,0 +1,63 @@
1
+ // The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 3 shared
2
+ // settings (no none lines, so the metrics and logs of the app keep going where they go now).
3
+ import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, INGEST_ENDPOINT, OTHER_EXPORTERS_NOTE, STACKS, TRACES_ONLY_NOTE, TRACES_ONLY_PLAN } from '../contract.mjs'
4
+
5
+ const STACK = STACKS.find((s) => s.id === 'otel')
6
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
7
+
8
+ /**
9
+ * The notes of the docs page, and after its .NET note the 2 notes that only setup prints: the pages name UKNOWS only,
10
+ * and these name products that setup finds in a repo.
11
+ */
12
+ export const OTEL_NOTES = STACK.notes.flatMap((n) => (n === DOTNET_TRACES_ONLY.note ? [n, AZURE_MONITOR.otelNote, OTHER_EXPORTERS_NOTE] : [n]))
13
+
14
+ /** The notes that name languages or features. A run keeps the parts that fit what it found. */
15
+ const NOTE = {
16
+ /** "For Python, use ... For Go, use ...": 1 sentence for each language. */
17
+ exporter: OTEL_NOTES.find((n) => /^For Python, use /.test(n)),
18
+ collector: OTEL_NOTES.find((n) => /Collector/.test(n)),
19
+ azure: AZURE_MONITOR.otelNote,
20
+ }
21
+ /** The label that each sentence of NOTE.exporter starts with. */
22
+ const EXPORTER_LANG = { python: 'Python', go: 'Go' }
23
+ /** The SDKs that send metrics and logs over OTLP by default. settings.mjs uses the traces-only settings for them. */
24
+ const SDK_SIGNALS = new Set(['node', 'python', 'java'])
25
+
26
+ /**
27
+ * The notes of the docs page for this run. With no language known (the menu pick "I already use OpenTelemetry"),
28
+ * every note stays. Else only the notes for this language and for what the repo uses:
29
+ * - the exporter sentence of this language only (Python, Go),
30
+ * - the traces-only note only when settings.mjs did not already say what this run does with traces only,
31
+ * and in a form for this language,
32
+ * - the .NET note only for .NET, and only when this run does not use the traces-only settings,
33
+ * - the Azure Monitor note only when the code calls Azure Monitor,
34
+ * - the other-exporters note never: existing.mjs gives it as advice when the repo has another exporter.
35
+ * @param {string[]} notes
36
+ * @param {{lang?: string|null, existing?: Partial<import('../types.mjs').Existing>, tracesOnly?: boolean, endpoint?: string, docs?: string}} found
37
+ * endpoint and docs: the ingest address and the docs page of this run.
38
+ * @returns {string[]}
39
+ */
40
+ export function otelNotes(notes, { lang = null, existing = {}, tracesOnly = false, endpoint = INGEST_ENDPOINT, docs = DOCS_URL } = {}) {
41
+ if (!lang) return notes
42
+ const anchors = existing.anchors ?? []
43
+ return notes.flatMap((note) => {
44
+ if (note === NOTE.exporter) {
45
+ const label = EXPORTER_LANG[lang]
46
+ return note.split(/(?<=\.) (?=For )/).filter((sentence) => label && sentence.startsWith(`For ${label}, `))
47
+ }
48
+ if (note === NOTE.collector) return [`${note} See ${docs}?method=manual&stack=otel#install-app.`]
49
+ if (note === TRACES_ONLY_NOTE) return tracesOnly || SDK_SIGNALS.has(lang) ? [] : [fill(TRACES_ONLY_PLAN.none, { endpoint })]
50
+ if (note === DOTNET_TRACES_ONLY.note) return lang === 'dotnet' && !tracesOnly ? [note] : []
51
+ if (note === NOTE.azure) return anchors.some((a) => a.kind === 'azure-call' || a.kind === 'appinsights-call') ? [note] : []
52
+ if (note === OTHER_EXPORTERS_NOTE) return []
53
+ return [note]
54
+ })
55
+ }
56
+
57
+ /** @type {import('../types.mjs').Applier} */
58
+ export const otel = {
59
+ id: 'otel',
60
+ plan() {
61
+ return { summary: [STACK.summary], edits: [], commands: [], extraSettings: [], manual: [], notes: [...OTEL_NOTES] }
62
+ },
63
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ * What `opentelemetry-bootstrap -a requirements` prints, without a Python
3
+ * environment. Source: bootstrap_gen.py of opentelemetry-python-contrib
4
+ * v0.66b1. Upstream checks the installed packages; this tool reads the
5
+ * requirement names instead, plus a few packages that the common frameworks
6
+ * always pull in (IMPLIED).
7
+ *
8
+ * Left out on purpose: openai-v2 and vertexai (not pinned to 0.66b1),
9
+ * psutil -> system-metrics (metrics are off) and the upstream quirk that maps
10
+ * pydantic to tortoiseorm. The default logging package comes only with the UKNOWS
11
+ * log bridge (logs: true). On its own, in 0.66b1, it adds an OpenTelemetry
12
+ * LoggingHandler to the root logger, also with OTEL_LOGS_EXPORTER=none. Then Python
13
+ * never uses its last-resort handler, so the warnings and errors of loggers with no
14
+ * handler no longer reach stderr. With the bridge, OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION
15
+ * =false keeps it off the root logger and the package gives only its LoggingHandler.
16
+ */
17
+ import { compareVersion } from '../versions.mjs'
18
+
19
+ /** Always printed. */
20
+ export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'sqlite3', 'threading', 'urllib', 'wsgi']
21
+
22
+ /** [normalized library name, instrumentation suffixes], in upstream order. */
23
+ export const LIBRARIES = [
24
+ ['aio-pika', ['aio-pika']], ['aiohttp', ['aiohttp-client', 'aiohttp-server']], ['aiokafka', ['aiokafka']],
25
+ ['aiopg', ['aiopg']], ['asgiref', ['asgi']], ['asyncclick', ['asyncclick']], ['asyncpg', ['asyncpg']],
26
+ ['boto3', ['boto3sqs']], ['botocore', ['botocore']], ['aiobotocore', ['botocore']],
27
+ ['cassandra-driver', ['cassandra']], ['scylla-driver', ['cassandra']], ['celery', ['celery']], ['click', ['click']],
28
+ ['confluent-kafka', ['confluent-kafka']], ['django', ['django']], ['falcon', ['falcon']], ['fastapi', ['fastapi']],
29
+ ['flask', ['flask']], ['grpcio', ['grpc']], ['httpx', ['httpx']], ['httpx2', ['httpx']], ['jinja2', ['jinja2']],
30
+ ['kafka-python', ['kafka-python']], ['mysql-connector-python', ['mysql']], ['mysqlclient', ['mysqlclient']],
31
+ ['pika', ['pika']], ['psycopg', ['psycopg']], ['psycopg2', ['psycopg2']], ['psycopg2-binary', ['psycopg2']],
32
+ ['pymemcache', ['pymemcache']], ['pymongo', ['pymongo']], ['pymssql', ['pymssql']], ['pymysql', ['pymysql']],
33
+ ['pyramid', ['pyramid']], ['redis', ['redis']], ['remoulade', ['remoulade']], ['requests', ['requests']],
34
+ ['sqlalchemy', ['sqlalchemy']], ['starlette', ['starlette']], ['structlog', ['structlog']], ['tornado', ['tornado']],
35
+ ['tortoise-orm', ['tortoiseorm']], ['urllib3', ['urllib3']],
36
+ ]
37
+
38
+ /**
39
+ * The first contrib release of each instrumentation package on PyPI (tools/check-python-sets.mjs). A set older than
40
+ * this leaves the package out, because it does not exist there. Packages missing here are older than every set.
41
+ */
42
+ export const SINCE = { exceptions: '0.64b0', structlog: '0.65b0', asyncclick: '0.53b0', pymssql: '0.51b0', click: '0.50b0',
43
+ aiokafka: '0.49b0', threading: '0.46b0', psycopg: '0.45b0', asyncio: '0.44b0', 'aiohttp-server': '0.42b0', cassandra: '0.41b0',
44
+ mysqlclient: '0.40b0', tortoiseorm: '0.36b0', 'aio-pika': '0.32b0', boto3sqs: '0.32b0', 'confluent-kafka': '0.32b0',
45
+ remoulade: '0.32b0', 'kafka-python': '0.28b0', pika: '0.25b0', httpx: '0.23b0', urllib3: '0.20b0', urllib: '0.17b0' }
46
+
47
+ /** Packages that a framework always installs, so upstream would see them too. */
48
+ export const IMPLIED = { fastapi: ['starlette'], django: ['asgiref'], flask: ['jinja2'], requests: ['urllib3'], boto3: ['botocore'] }
49
+
50
+ /** PEP 503 name: lower case, runs of - _ . become -. */
51
+ export const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
52
+
53
+ /**
54
+ * The instrumentation packages for these requirement names: the defaults,
55
+ * then 1 per matching library in table order, without repeats. Packages that
56
+ * are newer than version (SINCE) are left out.
57
+ * @param {Iterable<string>} names normalized requirement names
58
+ * @param {string} version the contrib release of the set, e.g. '0.66b1'
59
+ * @param {{floor?: string|null, logs?: boolean}} [options] floor: write '>=' limits from this release (or the first
60
+ * release of the package, when it is newer) instead of '==' pins, for uv and Poetry projects. logs: add the logging
61
+ * package, for the UKNOWS log bridge
62
+ * @returns {string[]} e.g. ['opentelemetry-instrumentation-asyncio==0.66b1', ...]
63
+ */
64
+ export function bootstrap(names, version, { floor = null, logs = false } = {}) {
65
+ const have = new Set(names)
66
+ for (const name of [...have]) for (const extra of IMPLIED[name] ?? []) have.add(extra)
67
+ const suffixes = [...DEFAULTS, ...(logs ? ['logging'] : [])]
68
+ for (const [library, list] of LIBRARIES) if (have.has(library)) suffixes.push(...list)
69
+ return [...new Set(suffixes)].filter((x) => !SINCE[x] || compareVersion(SINCE[x], version) <= 0)
70
+ .map((x) => `opentelemetry-instrumentation-${x}${floor ? `>=${[floor, SINCE[x] ?? floor].sort(compareVersion).at(-1)}` : `==${version}`}`)
71
+ }