@uknows/setup 0.0.0-stage → 0.1.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.
@@ -0,0 +1,168 @@
1
+ // OpenTelemetry and other APM agents already in 1 service, from its manifests and Dockerfile.
2
+ // Sentry counts only when its tracing is on, so for Sentry this also reads the source files.
3
+ import { TROUBLESHOOTING } from './contract.mjs'
4
+
5
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
6
+
7
+ /** Manifest name -> ecosystem. */
8
+ const ECOSYSTEM = [
9
+ [/^package\.json$/, 'node'], [/^(requirements[\w.-]*\.txt|pyproject\.toml|Pipfile)$/, 'python'], [/\.csproj$/, 'dotnet'],
10
+ [/^(pom\.xml|build\.gradle(\.kts)?)$/, 'java'], [/^go\.mod$/, 'go'], [/^composer\.json$/, 'php'], [/^Gemfile$/, 'ruby'],
11
+ ]
12
+
13
+ /** Per ecosystem: OpenTelemetry at SDK level (never the API package alone), other APM agents, and Sentry. */
14
+ const RULES = {
15
+ node: { otel: /^(@opentelemetry\/(sdk-node|sdk-trace-.+|auto-instrumentations-node|exporter-trace-otlp-.+)|@vercel\/otel)$/,
16
+ apm: { Datadog: /^dd-trace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm-node$/,
17
+ 'Azure Monitor': /^(applicationinsights|@azure\/monitor-opentelemetry.*)$/, Dynatrace: /^@dynatrace\//, Instana: /^@instana\/collector$/ },
18
+ sentry: /^@sentry\// },
19
+ python: { otel: /^opentelemetry-(sdk|distro|instrumentation.*)$/,
20
+ apm: { Datadog: /^ddtrace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm$/,
21
+ 'Azure Monitor': /^(azure-monitor-opentelemetry.*|opencensus-ext-azure)$/ },
22
+ sentry: /^sentry-sdk$/ },
23
+ dotnet: { otel: /^OpenTelemetry(\.Extensions\.Hosting|\.AutoInstrumentation|\.Exporter\..+)?$/i,
24
+ apm: { Datadog: /^Datadog\.Trace/i, 'New Relic': /^NewRelic\./i, 'Elastic APM': /^Elastic\.Apm/i,
25
+ 'Azure Monitor': /^(Microsoft\.ApplicationInsights|Azure\.Monitor\.OpenTelemetry)/i },
26
+ sentry: /^Sentry(\.|$)/ },
27
+ java: { otel: /^io\.opentelemetry[\w.]*:opentelemetry-[\w-]*(sdk|starter|exporter|javaagent)/,
28
+ apm: { Datadog: /^com\.datadoghq:dd-/, 'New Relic': /^com\.newrelic\./, 'Elastic APM': /^co\.elastic\.apm:/,
29
+ 'Azure Monitor': /^(com\.microsoft\.azure:applicationinsights-|com\.azure:azure-monitor-opentelemetry)/ },
30
+ sentry: /^io\.sentry:/ },
31
+ go: { otel: /^go\.opentelemetry\.io\/otel\/sdk(\/|$)/,
32
+ apm: { Datadog: /dd-trace-go/, 'New Relic': /^github\.com\/newrelic\/go-agent/, 'Elastic APM': /^go\.elastic\.co\/apm/ },
33
+ sentry: /^github\.com\/getsentry\/sentry-go/ },
34
+ php: { otel: /^open-telemetry\/sdk$/,
35
+ apm: { Datadog: /^datadog\//, 'New Relic': /newrelic/, 'Elastic APM': /^elastic\/apm-agent/ }, sentry: /^sentry\// },
36
+ ruby: { otel: /^opentelemetry-sdk$/,
37
+ apm: { Datadog: /^(ddtrace|datadog)$/, 'New Relic': /^newrelic_rpm$/, 'Elastic APM': /^elastic-apm$/,
38
+ 'Scout APM': /^scout_apm$/, Skylight: /^skylight$/ },
39
+ sentry: /^sentry-(ruby|rails|raven)$/ },
40
+ }
41
+
42
+ /** Agents that a Dockerfile loads (comment lines are ignored). */
43
+ const DOCKER_APM = { Datadog: /\bdd-trace\b|\bddtrace(-run)?\b|dd-java-agent|datadog-dotnet-apm|datadog-setup\.php/,
44
+ 'New Relic': /\bnewrelic(-admin|\.jar|-php5?)?\b/i, 'Elastic APM': /elastic-apm-agent|elastic-apm-node|\belasticapm\b/i,
45
+ 'Azure Monitor': /applicationinsights-agent/i, Dynatrace: /\bdynatrace\b|\boneagent\b/i, Instana: /\binstana\b/i }
46
+ const DOCKER_OTEL = /-javaagent:\S*opentelemetry/
47
+ const TRACING = /tracesSampleRate|traces_sample_rate|TracesSampleRate|enableTracing|traces-sample-rate|tracesSampler|traces_sampler/
48
+ const SOURCE = /\.(m?[jt]sx?|c[jt]s|py|cs|rb|go|php|java|kt|properties|ya?ml|json|exs?)$/
49
+ const LOCKS = /(^|\/)(package-lock|npm-shrinkwrap|composer\.lock|packages\.lock)\.json$/
50
+
51
+ /**
52
+ * @param {import('./types.mjs').Service} service
53
+ * @param {import('./workspace.mjs').Workspace} ws
54
+ * @returns {import('./types.mjs').Existing}
55
+ */
56
+ export function findExisting(service, ws) {
57
+ const otel = [], apm = new Map(), sentry = []
58
+ const add = (vendor, evidence) => {
59
+ const list = apm.get(vendor) ?? apm.set(vendor, []).get(vendor)
60
+ if (!list.some((e) => e.file === evidence.file && e.what === evidence.what)) list.push(evidence)
61
+ }
62
+ const manifests = new Set(service.manifests)
63
+ for (const name of ws.list(service.dir)) if (ECOSYSTEM.some(([re]) => re.test(name))) manifests.add(at(service.dir, name))
64
+ for (const file of [...manifests].sort()) {
65
+ const text = ws.read(file)
66
+ const eco = ECOSYSTEM.find(([re]) => re.test(file.split('/').pop()))?.[1]
67
+ if (text === null || !eco) continue
68
+ const rules = RULES[eco]
69
+ for (const { name, line } of dependencies(file, text)) {
70
+ const evidence = { file, line, what: name }
71
+ if (rules.otel.test(name)) otel.push(evidence)
72
+ for (const [vendor, re] of Object.entries(rules.apm)) if (re.test(name)) add(vendor, evidence)
73
+ if (rules.sentry.test(name)) sentry.push(evidence)
74
+ }
75
+ }
76
+ const dockerfile = (service.dockerfile && ws.read(service.dockerfile)) || ''
77
+ dockerfile.split('\n').forEach((text, i) => {
78
+ const found = (re) => (/^\s*#/.test(text) || !re.test(text) ? null : { file: service.dockerfile, line: i + 1, what: re.exec(text)[0] })
79
+ for (const [vendor, re] of Object.entries(DOCKER_APM)) if (found(re)) add(vendor, found(re))
80
+ if (found(DOCKER_OTEL)) otel.push(found(DOCKER_OTEL))
81
+ })
82
+ if (sentry.length) {
83
+ const hit = tracingIn(service, ws)
84
+ if (hit) for (const evidence of [...sentry, hit]) add('Sentry', evidence)
85
+ }
86
+ return { otel, apm: [...apm].map(([vendor, evidence]) => ({ vendor, evidence })) }
87
+ }
88
+
89
+ /**
90
+ * Why the run stops for a service with another APM agent, and the options.
91
+ * @param {import('./types.mjs').Service} service
92
+ * @param {import('./types.mjs').Apm[]} apm
93
+ * @returns {import('./types.mjs').Block}
94
+ */
95
+ export function explainApm(service, apm) {
96
+ const vendors = [...new Set(apm.map((a) => a.vendor))]
97
+ const names = vendors.length > 1 ? `${vendors.slice(0, -1).join(', ')} and ${vendors.at(-1)}` : vendors[0]
98
+ const steps = TROUBLESHOOTING.find((t) => t.id === 'other-apm')?.steps ?? []
99
+ return {
100
+ title: `${service.dir === '.' ? 'This app' : service.dir} already uses ${names}`,
101
+ lines: [...apm.flatMap((a) => a.evidence.map((e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`)),
102
+ ...steps, 'Then run npx @uknows/setup again.'],
103
+ }
104
+ }
105
+
106
+ /** Package names a manifest lists, with 1-based lines. */
107
+ export function dependencies(file, text) {
108
+ const base = file.split('/').pop()
109
+ const lines = text.split('\n')
110
+ const lineOf = (needle) => lines.findIndex((l) => l.includes(needle)) + 1 || undefined
111
+ const out = []
112
+ const push = (name, line) => { if (name && !out.some((d) => d.name === name)) out.push({ name, line }) }
113
+ if (base === 'package.json' || base === 'composer.json') {
114
+ let json
115
+ try { json = JSON.parse(text) } catch { return out }
116
+ const groups = base === 'package.json' ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] : ['require', 'require-dev']
117
+ for (const group of groups) {
118
+ for (const name of Object.keys(json?.[group] ?? {})) push(name, lineOf(`${JSON.stringify(name)}`))
119
+ }
120
+ } else if (/\.csproj$/.test(base)) {
121
+ for (const m of text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"/gi)) push(m[1], lineOf(m[0]))
122
+ } else if (base === 'pom.xml') {
123
+ for (const m of text.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
124
+ const group = /<groupId>\s*([^<\s]+)/.exec(m[1])?.[1], artifact = /<artifactId>\s*([^<\s]+)/.exec(m[1])?.[1]
125
+ if (group && artifact) push(`${group}:${artifact}`, lineOf(`<artifactId>${artifact}`))
126
+ }
127
+ } else if (/^build\.gradle/.test(base)) {
128
+ for (const m of text.matchAll(/['"]([\w.-]+):([\w.-]+)(?::[^'"]*)?['"]/g)) push(`${m[1]}:${m[2]}`, lineOf(m[0]))
129
+ } else if (base === 'go.mod') {
130
+ lines.forEach((l, i) => { const m = /^\s*(?:require\s+)?([\w.-]+\.[\w-]+\/\S+)\s+v\d/.exec(l); if (m) push(m[1], i + 1) })
131
+ } else if (base === 'Gemfile') {
132
+ lines.forEach((l, i) => { const m = /^\s*gem\s*\(?\s*["']([^"']+)["']/.exec(l); if (m) push(m[1], i + 1) })
133
+ } else if (/\.txt$/.test(base)) {
134
+ lines.forEach((l, i) => { const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(l.replace(/#.*/, '')); if (m) push(pyName(m[1]), i + 1) })
135
+ } else {
136
+ // pyproject.toml and Pipfile: dependency arrays and dependency tables.
137
+ let table = '', array = false
138
+ lines.forEach((raw, i) => {
139
+ const l = raw.replace(/\s#.*$/, '')
140
+ const closes = () => l.replace(/"[^"]*"|'[^']*'/g, '').includes(']')
141
+ const quoted = () => { for (const m of l.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)[^"']*["']/g)) push(pyName(m[1]), i + 1) }
142
+ if (array) { quoted(); array = !closes(); return }
143
+ const header = /^\s*\[+([^\]]+)\]/.exec(l)
144
+ if (header) { table = header[1]; return }
145
+ const kv = /^\s*["']?([A-Za-z0-9_.-]+)["']?\s*=\s*(.*)$/.exec(l)
146
+ if (!kv) return
147
+ if (kv[2].startsWith('[')) {
148
+ if (/dependencies|requires/.test(kv[1]) || /dependencies|dependency-groups/.test(table)) { quoted(); array = !closes() }
149
+ } else if (/dependencies|packages/.test(table) && kv[1] !== 'python') push(pyName(kv[1]), i + 1)
150
+ })
151
+ }
152
+ return out
153
+ }
154
+
155
+ const pyName = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
156
+
157
+ /** The first source line that turns Sentry tracing on, as evidence. */
158
+ function tracingIn(service, ws) {
159
+ let count = 0
160
+ for (const file of ws.files(service.dir)) {
161
+ if (!SOURCE.test(file) || LOCKS.test(file) || ++count > 5000) continue
162
+ if ((ws.stat(file)?.size ?? 0) > 1_000_000) continue
163
+ const text = ws.read(file) ?? ''
164
+ const m = TRACING.exec(text)
165
+ if (m) return { file, line: text.slice(0, m.index).split('\n').length, what: `Sentry tracing (${m[0]})` }
166
+ }
167
+ return null
168
+ }
package/lib/git.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /*
2
+ * The dirty-tree check. Setup refuses to edit a repo with uncommitted
3
+ * changes (untracked files count), so the user can review exactly what this
4
+ * tool changed with git diff. Paths that an earlier setup run changed are
5
+ * ignored, so running setup again before a commit works.
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+ import { readdirSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ /**
12
+ * @param {string} root absolute path of the folder setup works in
13
+ * @param {Set<string>} ignore repo-relative paths to leave out
14
+ * @returns {{git: boolean, repo: boolean, paths: string[]}} paths are relative to root, '/' separated
15
+ */
16
+ export function gitStatus(root, ignore = new Set()) {
17
+ const run = (...args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
18
+ const prefix = run('rev-parse', '--show-prefix')
19
+ if (prefix.error) return { git: false, repo: false, paths: [] }
20
+ if (prefix.status !== 0) return { git: true, repo: false, paths: [] }
21
+ const status = run('status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', '.')
22
+ if (status.status !== 0) return { git: true, repo: false, paths: [] }
23
+ const base = prefix.stdout.trim()
24
+ const tokens = status.stdout.split('\0')
25
+ const paths = []
26
+ for (let i = 0; i < tokens.length; i++) {
27
+ const entry = tokens[i]
28
+ if (entry.length < 4) continue
29
+ if (entry[0] === 'R' || entry[0] === 'C') i++ // the next token is the old path
30
+ const path = entry.slice(3).startsWith(base) ? entry.slice(3 + base.length) : entry.slice(3)
31
+ if (ignore.has(path) || (path.endsWith('/') && onlyIgnored(root, path.slice(0, -1), ignore))) continue
32
+ paths.push(path)
33
+ }
34
+ return { git: true, repo: true, paths }
35
+ }
36
+
37
+ /** True when every file under an untracked folder is in ignore. */
38
+ function onlyIgnored(root, dir, ignore, budget = { left: 2000 }) {
39
+ let entries
40
+ try { entries = readdirSync(join(root, ...dir.split('/')), { withFileTypes: true }) } catch { return false }
41
+ for (const entry of entries) {
42
+ if (--budget.left < 0) return false
43
+ const path = `${dir}/${entry.name}`
44
+ if (entry.isDirectory() ? !onlyIgnored(root, path, ignore, budget) : !ignore.has(path)) return false
45
+ }
46
+ return true
47
+ }
package/lib/hosts.mjs ADDED
@@ -0,0 +1,145 @@
1
+ /*
2
+ * Where the app runs, read from deploy files in the repo, and the "set your
3
+ * key once" steps for those hosts. Source cues and precedence come from
4
+ * instrumentation/agents/setup-cli/lib/hosting.mjs. Environment variables of
5
+ * this computer are never used: the CLI runs on a laptop, not on the host.
6
+ */
7
+ import { DOCS_URL, HOSTS, SETTINGS_FILE, SETTINGS_FILE_LOADERS } from './contract.mjs'
8
+
9
+ const MAX_BYTES = 262144
10
+ const RUNTIME = ['azure-container-apps', 'azure-app-service', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
11
+ /** hosting.mjs adapter ids -> docs host ids. */
12
+ const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'aws-ecs': 'aws-ecs',
13
+ kubernetes: 'kubernetes', 'alibaba-ack': 'kubernetes',
14
+ 'alibaba-sae': 'alibaba-sae', 'alibaba-ecs': 'vm-docker', vm: 'vm-docker', docker: 'vm-docker' }
15
+
16
+ const DOCKERFILE = /(?:^|\/)(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile)$/
17
+ const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:\.[^/]+)?\.ya?ml$/
18
+ /** Compose files for local work, by file name (compose.dev.yaml) or folder (local/compose.yaml). They are not deploy targets. */
19
+ const COMPOSE_DEV = /(?:^|[./-])(?:dev|development|local|test|tests|ci|override|sandbox|example|examples)(?:\.ya?ml$|\/)/
20
+ const YAML = /\.ya?ml$/
21
+ const SCRIPT = /\.(?:ya?ml|sh|ps1)$/
22
+ const TF = /\.tf$/
23
+ const API = /^[ \t]*apiVersion[ \t]*:/m
24
+ const WORKFLOW = /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/
25
+ const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|docker push|aliyun|aws ecs|helm upgrade/i
26
+ /** An ECS task definition file: a JSON file with containerDefinitions. */
27
+ const TASK_DEF = /(?:^|\/)[^/]*task[-_.]?def(?:inition)?[^/]*\.json$/i
28
+
29
+ /** [adapter, evidence text, path test or null for any file, regexes that must all match]. The first regex gives the line. */
30
+ const RULES = [
31
+ ['docker', 'Dockerfile builds an image', DOCKERFILE, /^[ \t]*FROM[ \t]+\S+/im],
32
+ ['docker', 'Docker Compose services', COMPOSE, /^services[ \t]*:/m],
33
+ ['kubernetes', 'Kubernetes workload', YAML, /^[ \t]*kind[ \t]*:[ \t]*['"]?(?:Deployment|StatefulSet|DaemonSet|Pod|Job|CronJob)['"]?[ \t]*$/m, API],
34
+ ['kubernetes', 'Helm chart', /(?:^|\/)Chart\.ya?ml$/, API, /^[ \t]*name[ \t]*:/m],
35
+ ['kubernetes', 'Deploy step runs kubectl or helm', SCRIPT, /\bkubectl[ \t]+(?:apply|set[ \t]+image|rollout)\b|\bhelm[ \t]+(?:upgrade|install)\b/],
36
+ ['vm', 'systemd service', /\.service$/, /^[ \t]*ExecStart[ \t]*=/m, /^[ \t]*\[Service\][ \t]*$/m],
37
+ ['vm', 'cloud-init config', null, /^#cloud-config\b/m, /^[ \t]*(?:runcmd|bootcmd|write_files)[ \t]*:/m],
38
+ ['azure-aca', 'Azure Container App in Terraform', TF, /\bresource\s+"azurerm_container_app"\s+"[^"\r\n]+"/],
39
+ ['azure-aca', 'Azure Container App in Bicep or ARM', null, /Microsoft\.App\/containerApps(?:@|["'\s])/],
40
+ ['azure-aca', 'Azure Container Apps deploy action', null, /azure\/container-apps-deploy-action@/],
41
+ ['azure-aca', 'azure.yaml deploys to Container Apps', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?containerapp\b/m],
42
+ ['azure-aca', 'Deploy step runs az containerapp', SCRIPT, /\baz[ \t]+containerapp[ \t]+(?:create|update|up|revision)\b/],
43
+ ['azure-app-service', 'Azure App Service in Terraform', TF, /\bresource\s+"azurerm_(?:linux_web_app|windows_web_app|app_service)"\s+"[^"\r\n]+"/],
44
+ ['azure-app-service', 'Azure App Service in Bicep or ARM', null, /Microsoft\.Web\/sites(?:@|["'\s])/],
45
+ ['azure-app-service', 'Azure App Service deploy action', null, /azure\/webapps-deploy@/],
46
+ ['azure-app-service', 'azure.yaml deploys to App Service', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?appservice\b/m],
47
+ ['azure-app-service', 'Deploy step runs az webapp', SCRIPT, /\baz[ \t]+webapp[ \t]+(?:create|up|deploy|config[ \t]+(?:container|appsettings))\b/],
48
+ ['aws-ecs', 'AWS ECS in Terraform', TF, /\bresource\s+"aws_ecs_(?:service|task_definition)"\s+"[^"\r\n]+"/],
49
+ ['aws-ecs', 'AWS ECS in CloudFormation or CDK output', null, /\bAWS::ECS::(?:TaskDefinition|Service)\b/],
50
+ ['aws-ecs', 'ECS task definition', TASK_DEF, /"containerDefinitions"\s*:/],
51
+ ['aws-ecs', 'ECS deploy action', null, /aws-actions\/amazon-ecs-deploy-task-definition@/],
52
+ ['aws-ecs', 'Deploy step runs aws ecs', SCRIPT, /\baws[ \t]+ecs[ \t]+(?:update-service|create-service|register-task-definition|deploy)\b/],
53
+ ['aws-ecs', 'AWS Copilot service manifest', /(?:^|\/)copilot\/[^/]+\/manifest\.ya?ml$/, /^[ \t]*type[ \t]*:[ \t]*['"]?(?:Load Balanced Web Service|Backend Service|Worker Service)/m],
54
+ ['alibaba-ecs', 'Alibaba ECS instance in Terraform', TF, /\bresource\s+"alicloud_instance"\s+"[^"\r\n]+"/],
55
+ ['alibaba-ack', 'Alibaba ACK cluster in Terraform', TF, /\bresource\s+"alicloud_cs_(?:managed_kubernetes|kubernetes)"\s+"[^"\r\n]+"/],
56
+ ['alibaba-ack', 'Alibaba Cloud settings in a Kubernetes file', YAML, /(?:alibabacloud\.com|aliyun\.com)\//, API],
57
+ ['alibaba-sae', 'Alibaba SAE application in Terraform', TF, /\bresource\s+"alicloud_sae_application"/],
58
+ ['alibaba-sae', 'Alibaba SAE address', null, /\bsae\.(?:[a-z0-9-]+\.)?aliyuncs\.com\b/],
59
+ ['alibaba-sae', 'Deploy step runs aliyun sae', SCRIPT, /\baliyun[ \t]+sae\b/],
60
+ ]
61
+
62
+ const deployFile = (path) => DOCKERFILE.test(path) || /\.(?:ya?ml|tf|bicep|service|sh|ps1)$/.test(path) || TASK_DEF.test(path)
63
+ || /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice)\.json$/.test(path)
64
+
65
+ /**
66
+ * Where the app runs, from deploy files in the repo.
67
+ * @param {Pick<import('./workspace.mjs').Workspace, 'files'|'read'|'stat'>} ws Paths may start with '../' (files above a --path folder).
68
+ * @returns {import('./types.mjs').Host[]}
69
+ */
70
+ export function detectHosts(ws) {
71
+ /** @type {Map<string, {evidence: import('./types.mjs').Evidence[], buildOnly: boolean}>} */
72
+ const seen = new Map()
73
+ const cue = (adapter, file, line, what) => {
74
+ if (!seen.has(adapter)) seen.set(adapter, { evidence: [], buildOnly: true })
75
+ const entry = seen.get(adapter)
76
+ if (!entry.evidence.some((e) => e.file === file && e.what === what)) entry.evidence.push({ file, line, what })
77
+ if (what !== RULES[0][1]) entry.buildOnly = false
78
+ }
79
+ for (const file of ws.files()) {
80
+ if (!deployFile(file) || /(?:^|\/)pnpm-lock\.ya?ml$/.test(file)) continue
81
+ let body = null
82
+ try { body = (ws.stat(file)?.size ?? 0) <= MAX_BYTES ? ws.read(file) : null } catch { body = null }
83
+ if (!body) continue
84
+ // Comment lines never count, so a commented-out resource is not a host. Line numbers stay the same.
85
+ const code = body.replace(/^[ \t]*(?:#(?!cloud-config\b)|\/\/).*$/gm, '')
86
+ for (const [adapter, what, path, ...need] of RULES) {
87
+ if ((path && !path.test(file)) || (adapter === 'docker' && COMPOSE.test(file) && COMPOSE_DEV.test(file))) continue
88
+ if (need.every((re) => re.test(code))) cue(adapter, file, lineOf(code, need[0]), what)
89
+ }
90
+ if (WORKFLOW.test(file) && /^jobs[ \t]*:/m.test(code) && DEPLOY_CUE.test(code)) {
91
+ cue('github-actions', file, lineOf(code, DEPLOY_CUE), 'GitHub Actions workflow that deploys')
92
+ }
93
+ }
94
+ // A Dockerfile is a build, not a host of its own: it does not compete with a real deploy target.
95
+ const runtime = [...seen.keys()].filter((id) => id !== 'github-actions')
96
+ if (seen.get('docker')?.buildOnly && runtime.some((id) => id !== 'docker')) seen.delete('docker')
97
+ const hosts = []
98
+ for (const id of RUNTIME) {
99
+ const evidence = [...seen].filter(([adapter]) => DOCS_ID[adapter] === id).flatMap(([, entry]) => entry.evidence)
100
+ if (evidence.length) hosts.push({ id, evidence: evidence.sort(byStrength) })
101
+ }
102
+ if (!hosts.length) hosts.push({ id: 'vm-docker', evidence: [] })
103
+ if (seen.has('github-actions')) hosts.push({ id: 'github-actions', evidence: seen.get('github-actions').evidence })
104
+ return hosts
105
+ }
106
+
107
+ /**
108
+ * The 'set your key once' steps for the detected hosts, from contract HOSTS.
109
+ * envFile: true when a service got otel.env, so the steps also say how to load it.
110
+ * Fields are read defensively: the docs file keeps changing.
111
+ * @param {import('./types.mjs').Host[]} hosts
112
+ * @param {{envFile?: boolean}} [options]
113
+ * @returns {import('./types.mjs').Block[]}
114
+ */
115
+ export function keySteps(hosts, options = {}) {
116
+ const ids = [...new Set((Array.isArray(hosts) ? hosts : []).map((h) => h?.id))]
117
+ const blocks = []
118
+ for (const id of ids) {
119
+ const host = (Array.isArray(HOSTS) ? HOSTS : []).find((h) => h?.id === id)
120
+ if (!host) continue
121
+ const lines = [...strings(host.steps), ...strings(host.notes)]
122
+ if (typeof host.stricter?.title === 'string') lines.push(`${host.stricter.title}. See ${DOCS_URL}.`)
123
+ blocks.push({ title: `Set your key once: ${host.label ?? id}`, lines, snippets: snippets(host.snippets) })
124
+ }
125
+ if (options.envFile) {
126
+ const loaders = (Array.isArray(SETTINGS_FILE_LOADERS) ? SETTINGS_FILE_LOADERS : [])
127
+ .filter((l) => l && (l.id !== 'alibaba-sae' || ids.includes('alibaba-sae')))
128
+ blocks.push({ title: `Load ${SETTINGS_FILE?.path ?? 'otel.env'} when your app starts`,
129
+ lines: loaders.filter((l) => l.summary).map((l) => `${l.label ?? l.id}: ${l.summary}`),
130
+ snippets: snippets(loaders.map((l) => l.snippet)) })
131
+ }
132
+ return blocks
133
+ }
134
+
135
+ /** Deploy files before deploy steps (RULES order), then by path and line. */
136
+ const rank = (e) => RULES.findIndex((rule) => rule[1] === e.what)
137
+ const byStrength = (a, b) => rank(a) - rank(b) || (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)
138
+ const strings = (list) => (Array.isArray(list) ? list.filter((s) => typeof s === 'string') : [])
139
+ const snippets = (list) => (Array.isArray(list) ? list : [])
140
+ .filter((s) => s && typeof s.code === 'string')
141
+ .map((s) => ({ caption: String(s.caption ?? ''), language: String(s.language ?? 'text'), code: s.code }))
142
+ const lineOf = (body, re) => {
143
+ const m = new RegExp(re.source, re.flags.replace('g', '')).exec(body)
144
+ return m ? body.slice(0, m.index).split('\n').length : 1
145
+ }
@@ -0,0 +1,112 @@
1
+ /*
2
+ * .uknows/setup.json: what setup changed, so remove can undo it exactly.
3
+ * It keeps hashes and the changed lines only, never whole files, so it
4
+ * cannot copy a secret that is already in the repo.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { CLI, PINS } from './contract.mjs'
10
+ import { hunks, reverseAt, reverseByContent, splitLines } from './diff.mjs'
11
+ import { checkPath } from './workspace.mjs'
12
+
13
+ export const TOOL = '@uknows/setup'
14
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
15
+ export const BOM = '\uFEFF'
16
+ const NOTE = 'Written by npx @uknows/setup. To undo these changes, run npx @uknows/setup remove. This file has no secrets.'
17
+ const STACKS = ['node', 'python', 'dotnet', 'java', 'go', 'php', 'ruby', 'otel']
18
+ const UNDO = { npm: 'uninstall', pnpm: 'remove', yarn: 'remove' }
19
+ /** 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
+ const PACKAGES = new Set([...PINS.node.packages, PINS.node.esmPackage])
22
+ const SHA = /^[0-9a-f]{64}$/
23
+
24
+ /** @param {Buffer|null} bytes */
25
+ export const sha256 = (bytes) => (bytes === null ? null : createHash('sha256').update(bytes).digest('hex'))
26
+
27
+ /**
28
+ * The manifest of the repo at root, or null. Throws when it is not valid.
29
+ * The file comes from the repo, so it is checked before remove trusts it:
30
+ * paths stay inside the repo and undo commands only remove packages.
31
+ */
32
+ export function readManifest(root) {
33
+ const file = join(root, ...CLI.manifest.split('/'))
34
+ if (!existsSync(file)) return null
35
+ let data = null
36
+ try { data = JSON.parse(readFileSync(file, 'utf8')) } catch { /* reported below */ }
37
+ if (!valid(data)) throw new Error(`${CLI.manifest} is not valid. Fix it or delete it.`)
38
+ return data
39
+ }
40
+
41
+ function valid(m) {
42
+ try {
43
+ if (m?.schema !== 1 || ![m.services, m.changes, m.packages].every(Array.isArray)) return false
44
+ for (const s of m.services) { checkPath(s.dir); if (!STACKS.includes(s.stack)) return false }
45
+ for (const c of m.changes) {
46
+ checkPath(c.file)
47
+ if (!c.hunks.every((h) => Number.isInteger(h.at) && [...h.before, ...h.after].every((l) => typeof l === 'string'))) return false
48
+ }
49
+ for (const p of m.packages) {
50
+ checkPath(p.cwd)
51
+ p.touches.forEach(checkPath)
52
+ // Only the exact packages that setup added, never other flags (-g, --prefix) or other packages.
53
+ const [cmd, verb, ...rest] = p.undo
54
+ const names = rest.filter((arg) => !UNDO_FLAGS.has(arg))
55
+ if (UNDO[cmd] !== verb || p.cmd !== cmd || !Array.isArray(p.added) || !names.length || new Set(names).size !== names.length
56
+ || !names.every((name) => PACKAGES.has(name)) || names.length !== p.added.length || !p.added.every((name) => names.includes(name))) return false
57
+ // before: the sha256 of each file the package manager touched, as it was before that run. Optional.
58
+ for (const [file, sha] of Object.entries(p.before ?? {})) { checkPath(file); if (sha !== null && !SHA.test(sha)) return false }
59
+ }
60
+ return true
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export const manifestText = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`
67
+
68
+ /**
69
+ * The record of 1 text edit. Hashes cover the bytes on disk, BOM included.
70
+ * Hunks are on the text without BOM.
71
+ */
72
+ export function changeEntry({ file, before, after, bom }, beforeBytes, afterBytes) {
73
+ return { file, created: before === null, bom, beforeSha256: sha256(beforeBytes), afterSha256: sha256(afterBytes),
74
+ hunks: before === null ? [] : hunks(before, after) }
75
+ }
76
+
77
+ /** Add 1 run to a manifest (or start one). Services are merged by dir. */
78
+ export function mergeManifest(old, { services, changes, packages }) {
79
+ const kept = (old?.services ?? []).filter((s) => !services.some((n) => n.dir === s.dir))
80
+ return { schema: 1, tool: TOOL, version: VERSION, note: NOTE,
81
+ 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] }
83
+ }
84
+
85
+ /** Paths the dirty-tree check ignores: the manifest and every path it lists. */
86
+ export function manifestPaths(manifest) {
87
+ const paths = new Set([CLI.manifest])
88
+ for (const c of manifest?.changes ?? []) paths.add(c.file)
89
+ for (const p of manifest?.packages ?? []) for (const t of p.touches ?? []) paths.add(t)
90
+ return paths
91
+ }
92
+
93
+ /**
94
+ * How to undo 1 change, given the bytes on disk now (null = missing).
95
+ * Returns {bytes} to write, {bytes: null} to delete, or {reason} to skip.
96
+ */
97
+ export function undoChange(entry, current) {
98
+ if (current === null) return { reason: 'The file is gone.' }
99
+ const sha = sha256(current)
100
+ if (entry.created) return sha === entry.afterSha256 ? { bytes: null } : { reason: 'The file changed after setup.' }
101
+ let text = current.toString('utf8')
102
+ const bom = text.startsWith(BOM)
103
+ if (bom) text = text.slice(1)
104
+ const lines = splitLines(text)
105
+ const pack = (out) => Buffer.from((bom ? BOM : '') + out.join(''), 'utf8')
106
+ if (sha === entry.afterSha256) {
107
+ const out = reverseAt(lines, entry.hunks)
108
+ if (out && sha256(pack(out)) === entry.beforeSha256) return { bytes: pack(out) }
109
+ }
110
+ const out = reverseByContent(lines, entry.hunks)
111
+ return out ? { bytes: pack(out) } : { reason: 'The lines that setup added changed, so they cannot be found exactly once.' }
112
+ }