@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
package/lib/contract.mjs CHANGED
@@ -11,7 +11,24 @@
11
11
  */
12
12
 
13
13
  export * from './contract-docs.mjs'
14
- import { NODE_REGISTER, PING_URL } from './contract-docs.mjs'
14
+ import { GO_RELEASES, NODE_REGISTER, PING_URL } from './contract-docs.mjs'
15
+
16
+ /** The node options that load a register file: --require, or for ES modules the loader hook and --import. */
17
+ const nodeFlag = (esm, register = NODE_REGISTER) => (esm ? `--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ${register}`
18
+ : `--require ${register}`)
19
+
20
+ /**
21
+ * Known-good OpenTelemetry sets for Python, newest first. core: opentelemetry-sdk and the OTLP exporter. contrib:
22
+ * opentelemetry-distro and each opentelemetry-instrumentation-* package. minPython: the lowest Python of that
23
+ * release. protobuf: [lowest, below] that opentelemetry-proto of that release allows. Checked on PyPI on
24
+ * 2026-10-18 with tools/check-python-sets.mjs (not shipped). 1.27.0 is the last release for protobuf below 5:
25
+ * uv pip compile with protobuf<5 picks 1.27.0 and 0.48b0.
26
+ */
27
+ const PYTHON_SETS = [
28
+ { core: '1.45.1', contrib: '0.66b1', minPython: [3, 10], protobuf: ['5.0', '8.0'] },
29
+ { core: '1.41.1', contrib: '0.62b1', minPython: [3, 9], protobuf: ['5.0', '7.0'] },
30
+ { core: '1.27.0', contrib: '0.48b0', minPython: [3, 8], protobuf: ['3.19', '5.0'] },
31
+ ]
15
32
 
16
33
  export const PINS = {
17
34
  node: {
@@ -19,17 +36,39 @@ export const PINS = {
19
36
  packages: ['@opentelemetry/api', '@opentelemetry/auto-instrumentations-node'],
20
37
  /** ESM apps also need this one as a direct dependency (pnpm cannot resolve hook.mjs otherwise). */
21
38
  esmPackage: '@opentelemetry/instrumentation',
22
- requireFlag: `--require ${NODE_REGISTER}`,
23
- esmFlag: `--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ${NODE_REGISTER}`,
39
+ /** The upstream register line: for a start that this tool cannot give the start file, and in printed steps. */
40
+ requireFlag: nodeFlag(false),
41
+ esmFlag: nodeFlag(true),
42
+ /**
43
+ * The same options with the start file of this tool (install.ts NODE_REGISTER_FILE): its absolute path in an
44
+ * image, or ./uknows-otel.cjs in a start script. The ESM loader hook stays: it gives each request its route.
45
+ */
46
+ flag: nodeFlag,
47
+ /**
48
+ * The npm major version that each Node.js release comes with, from https://nodejs.org/dist/index.json on 18 Oct
49
+ * 2026: [lowest Node.js version, npm major], newest first. The official node images use that npm. npm on this
50
+ * computer writes package-lock.json, and npm ci in the image reads it, so a newer npm here stops the run.
51
+ */
52
+ npm: [[[24, 0], 11], [[20, 7], 10], [[20, 0], 9], [[19, 3], 9], [[19, 0], 8], [[18, 19], 10], [[18, 14], 9], [[18, 0], 8]],
24
53
  },
25
54
  python: {
26
- /** Same 2 packages as the docs install line, pinned like pip freeze would. */
27
- distro: 'opentelemetry-distro==0.66b1',
28
- exporter: 'opentelemetry-exporter-otlp-proto-http==1.45.1',
55
+ /** Same 2 packages as the docs install line, pinned like pip freeze would: the newest set. */
56
+ distro: `opentelemetry-distro==${PYTHON_SETS[0].contrib}`,
57
+ exporter: `opentelemetry-exporter-otlp-proto-http==${PYTHON_SETS[0].core}`,
29
58
  /** Version of each opentelemetry-instrumentation-* package that opentelemetry-bootstrap prints. */
30
- contrib: '0.66b1',
59
+ contrib: PYTHON_SETS[0].contrib,
31
60
  /** opentelemetry-distro 0.66b1 needs Python 3.10 or later. */
32
- minPython: [3, 10],
61
+ minPython: PYTHON_SETS[0].minPython,
62
+ /**
63
+ * requirements.txt and other files with no lock: exact pins from the first set that fits the direct limits of
64
+ * the file (protobuf, opentelemetry-*) and the Python of the image. The plan says why it is not the newest.
65
+ */
66
+ sets: PYTHON_SETS,
67
+ /**
68
+ * uv and Poetry projects: floors, not pins, so their resolver picks the newest release that the app allows.
69
+ * The floors are the oldest set and name a pre-release on purpose, so uv and Poetry allow the b releases.
70
+ */
71
+ floors: { core: PYTHON_SETS.at(-1).core, contrib: PYTHON_SETS.at(-1).contrib },
33
72
  },
34
73
  dotnet: {
35
74
  package: 'OpenTelemetry.AutoInstrumentation',
@@ -37,7 +76,30 @@ export const PINS = {
37
76
  /** 1.17.0 targets net8.0 and net462 only. */
38
77
  minNet: 8,
39
78
  /** The build fails when an app uses StackExchange.Redis [2.6.122, 4.0.0) without this package. */
40
- redis: { library: 'StackExchange.Redis', package: 'OpenTelemetry.Instrumentation.StackExchangeRedis', version: '1.19.0-beta.1' },
79
+ redis: { library: 'StackExchange.Redis', package: 'OpenTelemetry.Instrumentation.StackExchangeRedis', version: '1.19.0-beta.1',
80
+ /** The range of StackExchange.Redis that the build check of OpenTelemetry.AutoInstrumentation 1.17.0 asks for: [min, below). */
81
+ range: ['2.6.122', '4.0.0'],
82
+ /**
83
+ * Packages that bring StackExchange.Redis in. With no packages.lock.json or obj/project.assets.json, 1 of them
84
+ * in the project counts as StackExchange.Redis. With a lock or assets file, the resolved version decides.
85
+ */
86
+ carriers: ['NRedisStack', 'Microsoft.Extensions.Caching.StackExchangeRedis', 'Microsoft.AspNetCore.SignalR.StackExchangeRedis',
87
+ 'Microsoft.AspNetCore.DataProtection.StackExchangeRedis', 'Hangfire.Redis.StackExchange'] },
88
+ /**
89
+ * For apps with Azure Monitor and no lock or assets file: the OTLP exporter at the OpenTelemetry version that
90
+ * OpenTelemetry.AutoInstrumentation 1.17.0 uses. With a lock, the exporter takes the resolved OpenTelemetry version.
91
+ */
92
+ otlpExporter: { package: 'OpenTelemetry.Exporter.OpenTelemetryProtocol', version: '1.19.1' },
93
+ },
94
+ /**
95
+ * The OpenTelemetry SDK package of each language, and the lowest version of it that the packages of this tool
96
+ * need. A package that limits the SDK below this version stops the run (the wrapper stop). Checked on 2026-10-18:
97
+ * OpenTelemetry.AutoInstrumentation.Runtime.Managed 1.17.0 needs OpenTelemetry 1.19.1.
98
+ */
99
+ sdk: {
100
+ python: { package: 'opentelemetry-sdk', need: '1.45.1' },
101
+ dotnet: { package: 'OpenTelemetry', need: '1.19.1' },
102
+ node: { package: '@opentelemetry/sdk-trace-base', need: null },
41
103
  },
42
104
  go: {
43
105
  /**
@@ -45,6 +107,8 @@ export const PINS = {
45
107
  * builders fail (golang images set GOTOOLCHAIN=local). Checked upstream on 2026-10-12.
46
108
  */
47
109
  minGo: [1, 26],
110
+ /** Below minGo, the go get lines name the versions of install.ts GO_RELEASES. Below the oldest row, Go is too old. */
111
+ oldestGo: GO_RELEASES.at(-1).go.split('.').map(Number),
48
112
  },
49
113
  php: {
50
114
  /** The 6th setting for PHP. The docs PHP Dockerfile adds it with the 5. */
@@ -52,6 +116,17 @@ export const PINS = {
52
116
  },
53
117
  }
54
118
 
119
+ /** The agent channels (--channel), and the form of the registry setting: a host and a path, no scheme and no tag. */
120
+ export const AGENT_CHANNELS = ['stable', 'early']
121
+ /**
122
+ * The public store of the UKNOWS agent images, a host and a path under a uknows.my name: setup builds
123
+ * <registry>/agent-<lang>:<channel> from it, for example registry.uknows.my/uknows/agent-node:stable. Anyone can pull
124
+ * from it without signing in. UKNOWS_AGENT_REGISTRY overrides it. If it were empty, setup would use the agent channel
125
+ * only when UKNOWS_AGENT_REGISTRY is set, and the publish gate (tools/prepublish.mjs) would stop.
126
+ */
127
+ export const DEFAULT_AGENT_REGISTRY = 'registry.uknows.my/uknows'
128
+ export const AGENT_REGISTRY = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/
129
+
55
130
  export const CLI = {
56
131
  /** Records what setup changed so remove can undo it exactly. Never holds secrets. */
57
132
  manifest: '.uknows/setup.json',
@@ -59,8 +134,16 @@ export const CLI = {
59
134
  endpointEnv: 'UKNOWS_INGEST_ENDPOINT',
60
135
  /** Override for the site address (where check asks which workspace a key sends to), for a test environment. */
61
136
  siteEnv: 'UKNOWS_SITE_URL',
137
+ /** Override for the app address (where the user watches an app connect), for a test environment. */
138
+ appEnv: 'UKNOWS_APP_URL',
139
+ /** The registry and path of the UKNOWS agent images, for --channel. For example registry.example.com/uknows. */
140
+ agentRegistryEnv: 'UKNOWS_AGENT_REGISTRY',
62
141
  /** The route of the site that says which workspace and environment a key sends to. */
63
142
  pingPath: new URL(PING_URL).pathname,
64
143
  exit: { ok: 0, error: 1, usage: 2, manual: 3 },
65
- next: 'Next: commit, open a PR, deploy as usual.',
144
+ /**
145
+ * A run counts as CI when one of these is set and not empty. CI also must not be 0 or false.
146
+ * --allow-ci lets a bot or an AI agent that opens a pull request apply anyway.
147
+ */
148
+ ciEnv: ['CI', 'GITHUB_ACTIONS', 'TF_BUILD', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'JENKINS_URL', 'TEAMCITY_VERSION', 'BITBUCKET_BUILD_NUMBER', 'CODEBUILD_BUILD_ID'],
66
149
  }
package/lib/detect.mjs CHANGED
@@ -9,6 +9,8 @@ import { Doc, shellWords } from './doc.mjs'
9
9
  import { dockerStart, parseDockerfile } from './dockerfile.mjs'
10
10
  import { SETTINGS_FILE } from './contract.mjs'
11
11
  import { detectHosts } from './hosts.mjs'
12
+ import { pythonProjectName, uvWorkspace } from './members.mjs'
13
+ import { pyName, tomlArray, tomlKeys, tomlString, tomlTable } from './toml.mjs'
12
14
  import { createWorkspace, repoView } from './workspace.mjs'
13
15
 
14
16
  const MAX_BYTES = 1_000_000
@@ -16,8 +18,10 @@ const MAX_BYTES = 1_000_000
16
18
  const LANGS = ['python', 'java', 'dotnet', 'go', 'php', 'ruby', 'node']
17
19
  const LABEL = { node: 'Node.js', python: 'Python', dotnet: '.NET', java: 'Java', go: 'Go', php: 'PHP', ruby: 'Ruby' }
18
20
  const NODE_FW = { express: 'express', fastify: 'fastify', '@nestjs/core': 'nestjs', koa: 'koa', '@hapi/hapi': 'hapi',
19
- nitropack: 'nitro', nitro: 'nitro', nuxt: 'nuxt', next: 'next' }
20
- const PY_FW = ['fastapi', 'django', 'flask', 'starlette', 'falcon', 'tornado']
21
+ nitropack: 'nitro', nitro: 'nitro', nuxt: 'nuxt', next: 'next', '@azure/functions': 'azure-functions' }
22
+ const PY_FW = ['fastapi', 'django', 'flask', 'starlette', 'falcon', 'tornado', 'azure-functions']
23
+ /** A .NET project of the Azure Functions isolated worker: the worker package or the Functions version of the SDK. */
24
+ const DOTNET_FUNCTIONS = /<PackageReference\s+Include\s*=\s*["']Microsoft\.Azure\.Functions\.Worker["']|<AzureFunctionsVersion>/i
21
25
  const PHP_FW = { 'laravel/framework': 'laravel', 'symfony/framework-bundle': 'symfony', 'slim/slim': 'slim' }
22
26
  const TEXT_FW = {
23
27
  java: [['spring-boot', /spring-boot|org\.springframework\.boot/], ['quarkus', /io\.quarkus/], ['micronaut', /io\.micronaut/]],
@@ -32,6 +36,8 @@ const IMAGE = {
32
36
  php: /(?:^|\/)php(?:[:@]|$)/, ruby: /(?:^|\/)ruby(?:[:@]|$)/,
33
37
  }
34
38
  const DOCKERFILE = /^(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile)$/
39
+ /** Any file that docker build or podman build can take: Dockerfile, Dockerfile.*, *.Dockerfile, Containerfile. Dev variants too. */
40
+ export const isDockerfile = (path) => DOCKERFILE.test(posix.basename(path)) || /^Containerfile(?:\.[^/]+)?$/.test(posix.basename(path))
35
41
  /** Files that build or deploy images: workflows, compose files, scripts. A Dockerfile named in them with -f is the one that ships. */
36
42
  const DEPLOY_FILE = new RegExp(['^(?:\\.\\./)*\\.github/workflows/[^/]+\\.ya?ml$', '(?:^|/)(?:docker-)?compose[^/]*\\.ya?ml$', '(?:^|/)(?:Makefile|Jenkinsfile)$',
37
43
  '(?:^|/)[^/]+\\.(?:sh|ps1)$', '(?:^|/)(?:azure-pipelines|cloudbuild|buildspec|skaffold|\\.gitlab-ci|bitbucket-pipelines)\\.ya?ml$'].join('|'))
@@ -40,10 +46,14 @@ const DOCKERFILE_REF = /(?:^|\s)(?:-f|--file)(?:\s+|=)["']?([^\s"';&|)]+)|^\s*-?
40
46
  const STATIC_IMAGE = /(?:^|\/)(?:nginx|nginx-unprivileged|httpd|caddy|lighttpd|openresty)(?:[:@]|$)/
41
47
  const FRONTEND_DEP = /^(?:react|react-dom|vue|svelte|@angular\/core|vite|preact|solid-js|react-scripts|parcel|@vue\/cli-service)$/
42
48
  const DEV_SERVER = /^(?:vite|react-scripts|ng|vue-cli-service|parcel|webpack|webpack-dev-server|serve|http-server)$/
43
- const SERVER_FW = new Set(['express', 'fastify', 'nestjs', 'koa', 'hapi', 'nitro', 'nuxt', 'next'])
49
+ const SERVER_FW = new Set(['express', 'fastify', 'nestjs', 'koa', 'hapi', 'nitro', 'nuxt', 'next', 'azure-functions'])
44
50
  const RUNS_NODE = /^(?:node|nodejs|npm|npx|yarn|pnpm)$|\.[cm]?js$/
45
51
  const DEV_VARIANT = /(?:^|[.-])(?:dev|development|test|testing|local|debug|ci)(?:[.-]|$)/i
46
52
  const WORKLOAD = /^kind:\s*['"]?(?:Deployment|StatefulSet|DaemonSet|Pod|Job|CronJob)['"]?\s*(?:#.*)?$/m
53
+ /** Off-the-shelf JVM products that ship as an image, with no manifest in the repo: final image -> framework. */
54
+ const JVM_PRODUCTS = [[/(?:^|\/)keycloak\/keycloak(?:[:@]|$)/, 'keycloak']]
55
+ /** uv sync --package <name> in a RUN line: the image installs that workspace member. */
56
+ const UV_PACKAGE = /\buv\s+sync\b[^&|;\n]*?\s--package(?:\s+|=)["']?([A-Za-z0-9][A-Za-z0-9._-]*)/g
47
57
 
48
58
  /**
49
59
  * Find the services, stacks, start commands and hosts of the repo. Static reading only.
@@ -86,8 +96,27 @@ export function detect(root) {
86
96
  // 2. Dockerfiles, then 1 per candidate folder.
87
97
  const dockerfiles = files
88
98
  .filter((p) => DOCKERFILE.test(posix.basename(p)) && !DEV_VARIANT.test(posix.basename(p).replace(/dockerfile/i, '')))
89
- .map((path) => ({ path, info: parseDockerfile(read(path) ?? '') }))
99
+ .map((path) => ({ path, text: read(path) ?? '', info: parseDockerfile(read(path) ?? '') }))
90
100
  .filter((d) => d.info.instructions.some((x) => x.keyword === 'FROM'))
101
+ for (const d of dockerfiles) d.packages = [...new Set(d.info.instructions.filter((x) => x.keyword === 'RUN')
102
+ .flatMap((x) => [...(x.argv ? x.argv.join(' ') : x.args).matchAll(UV_PACKAGE)].map((m) => pyName(m[1]))))]
103
+ // A Dockerfile of a JVM product (Keycloak) with no manifest in its folder is a Java service of that product.
104
+ const products = new Map()
105
+ for (const d of dockerfiles) {
106
+ const product = JVM_PRODUCTS.find(([re]) => re.test(stageImages(d.info).at(-1) ?? ''))?.[1]
107
+ if (product && !candidates.has(dirOf(d.path))) { candidates.set(dirOf(d.path), new Map([['java', []]])); products.set(dirOf(d.path), { product, file: d.path }) }
108
+ }
109
+ // uv workspace members: name -> folder, and the folders that are members or the workspace root.
110
+ const members = new Map(), inWorkspace = new Set()
111
+ for (const [dir, langs] of candidates) {
112
+ if (!langs.has('python')) continue
113
+ const name = pythonProjectName(view, dir)
114
+ if (name && !members.has(name)) members.set(name, dir)
115
+ const workspace = uvWorkspace(view, dir)
116
+ if (workspace && [...workspace.members.values()].includes(dir) && dir !== workspace.root) inWorkspace.add(dir)
117
+ // A virtual workspace root (no [project]) only groups the members.
118
+ if (workspace && dir === workspace.root && !name) inWorkspace.add(dir)
119
+ }
91
120
  const dirs = [...candidates.keys()]
92
121
  // Folders that look like services before any Dockerfile is assigned: a framework or a start. Libraries do not count.
93
122
  const likely = dirs.filter((dir) => [...candidates.get(dir)].some(([lang, paths]) => frameworks(lang, paths, read).length)
@@ -101,7 +130,7 @@ export function detect(root) {
101
130
  const build = (dir, langs) => {
102
131
  const service = { dir, lang: null, frameworks: [], manifests: [], name: '', nameFrom: 'folder', dockerfile: null, start: null,
103
132
  runtimeVersion: null, k8s: [], problems: [], evidence: [] }
104
- const docker = assignDockerfile(dir, { dirs, pool, refs: () => (refs ??= dockerfileRefs(view)) }, dockerfiles, service)
133
+ const docker = assignDockerfile(dir, { dirs, pool, refs: () => (refs ??= dockerfileRefs(view)), members, manifests: [...langs.values()].flat() }, dockerfiles, service)
105
134
  service.dockerfile = docker?.path ?? null
106
135
  const found = new Map([...langs].map(([lang, paths]) => [lang, { paths: paths.sort(byCodePoint), fw: frameworks(lang, paths, read) }]))
107
136
  service.lang = chooseLang(found, docker, service)
@@ -109,10 +138,22 @@ export function detect(root) {
109
138
  const { paths, fw } = found.get(service.lang)
110
139
  service.manifests = paths
111
140
  service.frameworks = [...new Set(fw.map((f) => f.id))].sort(byCodePoint)
141
+ const product = products.get(dir)
142
+ if (product && service.lang === 'java') {
143
+ service.frameworks = [product.product]
144
+ service.evidence.push({ file: product.file, what: `${product.product} image` })
145
+ }
112
146
  for (const path of paths) service.evidence.push({ file: path, what: `${LABEL[service.lang]} manifest` })
113
147
  for (const f of fw) service.evidence.push({ file: f.file, what: `${f.dep} in dependencies` })
114
148
  }
115
- const start = findStart(service, docker, read)
149
+ // An Azure Functions app: the Functions framework of its stack and host.json in its folder. The Functions host
150
+ // starts it, so it has no start command of its own, and a Dockerfile builds a Functions image only (not handled).
151
+ const hostJson = join(dir, 'host.json')
152
+ if (service.frameworks.includes('azure-functions') && !docker && read(hostJson) !== null) {
153
+ service.functions = { hostJson }
154
+ service.evidence.push({ file: hostJson, what: 'host.json of an Azure Functions app' })
155
+ }
156
+ const start = service.functions ? { start: null, declared: true } : findStart(service, docker, read)
116
157
  service.start = start.start
117
158
  if (start.start) service.evidence.push({ file: start.start.file, line: start.start.line, what: 'start command' })
118
159
  else if (service.lang && !start.declared) service.problems.push({ code: 'start-unknown', message: `Found no start command for ${where(dir)}.` })
@@ -125,11 +166,19 @@ export function detect(root) {
125
166
  return service
126
167
  }
127
168
  let services = dirs.map((dir) => build(dir, candidates.get(dir)))
128
- const qualified = services.filter((s) => s.frameworks.length || s.declared || s.dockerfile)
169
+ // A uv workspace member with no Dockerfile and no start is a library of the other members, even with a framework.
170
+ // When no member has a Dockerfile or a start at all, the members with a framework stay services, as before.
171
+ const serviceLike = (s) => s.frameworks.length || s.declared || s.dockerfile
172
+ const library = (s) => inWorkspace.has(s.dir) && !s.declared && !s.dockerfile && !s.problems.some((p) => p.code === 'dockerfile-ambiguous')
173
+ let qualified = services.filter((s) => serviceLike(s) && !library(s))
174
+ if (!qualified.length) qualified = services.filter((s) => serviceLike(s) && (s.dir !== '.' || !inWorkspace.has(s.dir)))
129
175
  if (qualified.length) {
130
176
  // Say which folders are left out and why, so a Go net/http service without a Dockerfile is never skipped silently.
131
177
  for (const s of services.filter((x) => x.lang && !qualified.includes(x))) {
132
- problems.push({ code: 'skipped', message: `${where(s.dir)} has ${LABEL[s.lang]} files but no web framework, Dockerfile or start command, so this tool skips it. If it is a service, run npx @uknows/setup --path ${s.dir}, or follow the manual steps.` })
178
+ problems.push({ code: 'skipped', message: library(s) && qualified.every((q) => !library(q))
179
+ ? (s.dir === '.' || !pythonProjectName(view, s.dir) ? `${where(s.dir)} is the root of a uv workspace and has no app of its own, so this tool skips it.`
180
+ : `${where(s.dir)} is a uv workspace member with no Dockerfile or start command. This tool treats it as a library and skips it.`)
181
+ : `${where(s.dir)} has ${LABEL[s.lang]} files but no web framework, Dockerfile or start command, so this tool skips it. If it is a service, run npx @uknows/setup --path ${s.dir}, or follow the manual steps.` })
133
182
  }
134
183
  services = qualified
135
184
  }
@@ -180,7 +229,7 @@ function manifestLang(path, read) {
180
229
  if (name === 'package.json') return 'node'
181
230
  if (name === 'pyproject.toml' || name === 'Pipfile' || /^requirements.*\.txt$/.test(name) || /(?:^|\/)requirements\/[^/]+\.txt$/.test(path)) return 'python'
182
231
  if (name === 'go.mod') return 'go'
183
- if (name.endsWith('.csproj')) return /Microsoft\.NET\.Sdk\.Web/.test(read(path) ?? '') ? 'dotnet' : null
232
+ if (name.endsWith('.csproj')) { const body = read(path) ?? ''; return /Microsoft\.NET\.Sdk\.Web/.test(body) || DOTNET_FUNCTIONS.test(body) ? 'dotnet' : null }
184
233
  if (name === 'pom.xml' || name === 'build.gradle' || name === 'build.gradle.kts') return 'java'
185
234
  if (name === 'composer.json') return 'php'
186
235
  if (name === 'Gemfile') return 'ruby'
@@ -202,7 +251,10 @@ function frameworks(lang, paths, read) {
202
251
  for (const dep of new Set(pythonDeps(file, body, read))) if (PY_FW.includes(dep)) add(dep, dep)
203
252
  } else if (lang === 'php') {
204
253
  for (const dep of Object.keys(object(json(body)?.require))) if (PHP_FW[dep]) add(PHP_FW[dep], dep)
205
- } else if (lang === 'dotnet') add('aspnetcore', 'Microsoft.NET.Sdk.Web')
254
+ } else if (lang === 'dotnet') {
255
+ if (DOTNET_FUNCTIONS.test(body)) add('azure-functions', 'Microsoft.Azure.Functions.Worker')
256
+ else add('aspnetcore', 'Microsoft.NET.Sdk.Web')
257
+ }
206
258
  else for (const [id, re] of TEXT_FW[lang] ?? []) if (re.test(body.replace(/<!--[\s\S]*?-->|^\s*(?:#|\/\/).*$/gm, ''))) add(id, id)
207
259
  }
208
260
  return out
@@ -235,7 +287,7 @@ function pythonDeps(path, body, read, depth = 0) {
235
287
  * The Dockerfile that builds the service in dir, by the 4 rules in order. Adds dockerfile-ambiguous when it cannot tell.
236
288
  * dirs: every folder with a manifest. pool: the folders that look like services. refs: Dockerfile names that deploy files pass to -f.
237
289
  */
238
- function assignDockerfile(dir, { dirs, pool, refs }, dockerfiles, service) {
290
+ function assignDockerfile(dir, { dirs, pool, refs, members, manifests = [] }, dockerfiles, service) {
239
291
  const pick = (d, why) => { service.evidence.push({ file: d.path, what: `Dockerfile for this service: ${why}` }); return d }
240
292
  const ambiguous = (list, same = false) => {
241
293
  const names = list.map((d) => d.path).join(', ')
@@ -244,6 +296,16 @@ function assignDockerfile(dir, { dirs, pool, refs }, dockerfiles, service) {
244
296
  : 'Use --path to pick a folder, or keep 1 Dockerfile.'}` })
245
297
  return null
246
298
  }
299
+ // uv sync --package <name>: the Dockerfile builds the workspace member with that name, wherever the Dockerfile is.
300
+ const name = [...members].find(([, d]) => d === dir)?.[0]
301
+ const installs = name ? dockerfiles.filter((d) => d.packages.includes(name)) : []
302
+ if (installs.length === 1) return pick(installs[0], `it runs uv sync --package ${name}`)
303
+ if (installs.length > 1) {
304
+ const named = installs.filter((d) => refs().has(posix.basename(d.path)))
305
+ return named.length === 1 ? pick(named[0], `${refs().get(posix.basename(named[0].path))} builds it with -f`) : ambiguous(installs, true)
306
+ }
307
+ // A Dockerfile that installs another member is not the Dockerfile of the folder it sits in.
308
+ dockerfiles = dockerfiles.filter((d) => !d.packages.some((p) => members.has(p) && members.get(p) !== dir))
247
309
  const own = dockerfiles.filter((d) => dirOf(d.path) === dir)
248
310
  if (own.length === 1) return pick(own[0], 'it is in the service folder')
249
311
  if (own.length > 1) {
@@ -260,12 +322,26 @@ function assignDockerfile(dir, { dirs, pool, refs }, dockerfiles, service) {
260
322
  }
261
323
  break
262
324
  }
325
+ // Dockerfiles in a folder of their own (docker/), built from the repo root as monorepos do.
326
+ const free = dockerfiles.filter((d) => !dirs.includes(dirOf(d.path)))
327
+ const byRef = (list) => list.filter((d) => refs().has(posix.basename(d.path)))
263
328
  if (dir !== '.') {
264
- const free = dockerfiles.filter((d) => !dirs.includes(dirOf(d.path)))
265
- const copying = free.filter((d) => copySources(d)
329
+ const copying = free.filter((d) => copySources(d.info)
266
330
  .some((src) => inside(dir, src) || inside(dir, posix.normalize(join(dirOf(d.path), src)))))
267
331
  if (copying.length === 1) return pick(copying[0], 'it copies the service folder')
268
332
  if (copying.length > 1) return ambiguous(copying)
333
+ // It copies a folder above the service (csharp/src/ for csharp/src/EdgeProxy) and names a manifest of the service,
334
+ // or no other service is in that folder.
335
+ const parent = free.filter((d) => copySources(d.info).some((src) => src !== '.' && dir.startsWith(`${src.replace(/\/+$/, '')}/`)
336
+ && (manifests.some((m) => d.text.includes(m)) || pool.filter((x) => x !== dir && inside(src, x)).length === 0)))
337
+ if (parent.length === 1) return pick(parent[0], `it copies a folder above the service folder and builds ${manifests.find((m) => parent[0].text.includes(m)) ?? 'it'}`)
338
+ if (parent.length > 1) return byRef(parent).length === 1 ? pick(byRef(parent)[0], `${refs().get(posix.basename(byRef(parent)[0].path))} builds it with -f`) : ambiguous(parent, true)
339
+ } else if (manifests.length) {
340
+ // The root service: a Dockerfile elsewhere that copies a manifest from the repo root (COPY pyproject.toml uv.lock ./).
341
+ const names = manifests.filter((m) => !m.includes('/'))
342
+ const root = free.filter((d) => copySources(d.info).some((src) => names.includes(src.replace(/^\.\//, ''))))
343
+ if (root.length === 1) return pick(root[0], `it copies ${names.find((m) => copySources(root[0].info).includes(m)) ?? 'the manifest'} from the repo root`)
344
+ if (root.length > 1) return byRef(root).length === 1 ? pick(byRef(root)[0], `${refs().get(posix.basename(byRef(root)[0].path))} builds it with -f`) : ambiguous(root, true)
269
345
  }
270
346
  // The only Dockerfile, unless it sits in the folder of another manifest: then it builds that one.
271
347
  if (dockerfiles.length === 1 && !dirs.includes(dirOf(dockerfiles[0].path)) && (pool.length === 0 || (pool.length === 1 && pool[0] === dir))) {
@@ -274,10 +350,14 @@ function assignDockerfile(dir, { dirs, pool, refs }, dockerfiles, service) {
274
350
  return null
275
351
  }
276
352
 
277
- /** Source paths of the COPY and ADD instructions that read the build context (not --from). */
278
- function copySources(dockerfile) {
353
+ /**
354
+ * Source paths of the COPY and ADD instructions that read the build context (not --from), relative to the context.
355
+ * @param {import('./dockerfile.mjs').DockerfileInfo} info
356
+ * @returns {string[]}
357
+ */
358
+ export function copySources(info) {
279
359
  const out = []
280
- for (const x of dockerfile.info.instructions) {
360
+ for (const x of info.instructions) {
281
361
  if ((x.keyword !== 'COPY' && x.keyword !== 'ADD') || /(?:^|\s)--from=/.test(x.args)) continue
282
362
  const rest = x.args.replace(/^(?:--\S+\s+)*/, '')
283
363
  let words = rest.split(/\s+/).filter(Boolean)
@@ -512,34 +592,6 @@ function scalar(item, key) {
512
592
  return /^(['"])(.*)\1$/.exec(value)?.[2] ?? (value || null)
513
593
  }
514
594
 
515
- // --- small TOML reader (tables, strings, string arrays, keys) -----------------
516
-
517
- function tomlTable(body, header) {
518
- const out = []
519
- let on = false
520
- for (const line of body.split(/\r?\n/)) {
521
- const h = /^\s*\[([^\][]+)\]\s*(?:#.*)?$/.exec(line)
522
- if (h) { on = h[1].trim() === header; continue }
523
- if (/^\s*\[\[/.test(line)) { on = false; continue }
524
- if (on) out.push(line)
525
- }
526
- return out.join('\n')
527
- }
528
- const tomlString = (table, key) => new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^"'\\n]*)\\1`, 'm').exec(table)?.[2] ?? null
529
- const tomlKeys = (table) => table.split('\n').map((l) => /^\s*"?([A-Za-z0-9_.-]+)"?\s*=/.exec(l)?.[1]).filter(Boolean)
530
- function tomlArray(table, key) {
531
- const m = new RegExp(`^\\s*${key}\\s*=\\s*\\[`, 'm').exec(table)
532
- if (!m) return []
533
- const out = []
534
- let quote = '', value = ''
535
- for (const c of table.slice(m.index + m[0].length)) {
536
- if (quote) { if (c === quote) { out.push(value); quote = ''; value = '' } else value += c }
537
- else if (c === '"' || c === "'") quote = c
538
- else if (c === ']') break
539
- }
540
- return out
541
- }
542
-
543
595
  // --- helpers ------------------------------------------------------------------
544
596
 
545
597
  /**
@@ -128,6 +128,29 @@ function parents(stages, i) {
128
128
  return out
129
129
  }
130
130
 
131
+ /**
132
+ * The image that the final stage runs on. FROM base AS final (the Visual Studio layout) names an earlier stage, so
133
+ * follow the stage names to the image of the first one: mcr.microsoft.com/dotnet/aspnet:8.0-alpine, not base.
134
+ * @param {DockerfileInfo} info
135
+ * @returns {string|null}
136
+ */
137
+ export function finalImage(info) {
138
+ const { stages } = info
139
+ if (!stages.length) return info.image ?? null
140
+ return parents(stages, stages.length - 1).at(-1)?.image ?? stages.at(-1).image
141
+ }
142
+
143
+ /**
144
+ * The final stage and the earlier stages it builds on (FROM <stage>), final stage first. Their RUN lines are in the image.
145
+ * @param {DockerfileInfo} info
146
+ * @returns {Stage[]}
147
+ */
148
+ export function finalStages(info) {
149
+ const { stages } = info
150
+ if (!stages.length) return [{ image: '', name: null, instructions: info.instructions }]
151
+ return [stages.at(-1), ...parents(stages, stages.length - 1)]
152
+ }
153
+
131
154
  /**
132
155
  * The stages whose files reach the final image: the final stage, the stages it
133
156
  * builds on, and the stages they COPY --from, again and again.
@@ -296,6 +319,66 @@ export function prefixStart(text, words) {
296
319
  return doc.toString()
297
320
  }
298
321
 
322
+ /**
323
+ * Put words at position `at` of the start command, counted as dockerStart counts it (ENTRYPOINT, then CMD), for a
324
+ * runner such as uv run --no-sync uvicorn: the words go after the runner and its options. at 0 is prefixStart.
325
+ * Only a 1-line exec form, or a 1-line shell form without quotes. Returns the text unchanged when the words are
326
+ * already there, and null when it cannot be changed safely.
327
+ * @param {string} text
328
+ * @param {number} at
329
+ * @param {string[]} words
330
+ * @returns {string|null}
331
+ */
332
+ export function insertStart(text, at, words) {
333
+ if (at === 0) return prefixStart(text, words)
334
+ const { entrypoint, cmd } = parseDockerfile(text)
335
+ const parts = entrypoint && !entrypoint.argv ? [entrypoint] : [entrypoint, cmd].filter(Boolean)
336
+ const doc = new Doc(text)
337
+ let offset = 0
338
+ for (const [n, part] of parts.entries()) {
339
+ const shell = !part.argv && !/["']/.test(part.args)
340
+ const argv = part.argv ?? (shell && parts.length === 1 ? shellWords(part.args.replace(/^exec\s+/, '')) : null)
341
+ if (!argv) return null
342
+ const k = at - offset
343
+ offset += argv.length
344
+ if (k > argv.length || (k === argv.length && n < parts.length - 1)) continue
345
+ if (words.every((w, j) => argv[k + j] === w)) return text
346
+ if (part.start !== part.end) return null
347
+ const line = doc.get(part.start)
348
+ const keywordEnd = /^\s*[A-Za-z]+\s*/.exec(line)[0].length
349
+ if (part.argv) {
350
+ const tokens = jsonStrings(line, keywordEnd)
351
+ if (tokens.length !== argv.length) return null
352
+ const sep = argv.length < 2 || /",\s+"/.test(part.args) ? ', ' : ','
353
+ const inserted = words.map((w) => JSON.stringify(w)).join(sep)
354
+ doc.set(part.start, k < tokens.length ? line.slice(0, tokens[k].start) + inserted + sep + line.slice(tokens[k].start)
355
+ : line.slice(0, tokens.at(-1).end) + sep + inserted + line.slice(tokens.at(-1).end))
356
+ } else {
357
+ const head = keywordEnd + (/^exec\s+/.exec(line.slice(keywordEnd))?.[0].length ?? 0)
358
+ const starts = [...line.slice(head).matchAll(/\S+/g)].map((m) => head + m.index)
359
+ if (starts.length !== argv.length) return null
360
+ const pos = k < starts.length ? starts[k] : line.trimEnd().length + 1
361
+ const insert = words.map(shellQuote).join(' ')
362
+ doc.set(part.start, k < starts.length ? `${line.slice(0, pos)}${insert} ${line.slice(pos)}` : `${line.trimEnd()} ${insert}${line.slice(line.trimEnd().length)}`)
363
+ }
364
+ return doc.toString()
365
+ }
366
+ return null
367
+ }
368
+
369
+ /** Start and end columns of the JSON strings in an exec-form line, from the '['. */
370
+ function jsonStrings(line, from) {
371
+ const out = []
372
+ for (let i = line.indexOf('[', from); i >= 0 && i < line.length; i++) {
373
+ if (line[i] === ']') break
374
+ if (line[i] !== '"') continue
375
+ const start = i
376
+ for (i++; i < line.length && line[i] !== '"'; i++) if (line[i] === '\\') i++
377
+ out.push({ start, end: i + 1 })
378
+ }
379
+ return out
380
+ }
381
+
299
382
  // --- helpers ---------------------------------------------------------------
300
383
 
301
384
  /** A value for an ENV token. escape is the escape character of the Dockerfile. raw keeps $ references. */
package/lib/endpoint.mjs CHANGED
@@ -4,9 +4,10 @@
4
4
  * workspace a key sends to, and where the docs links point. Both are the
5
5
  * addresses of this build (contract-docs.mjs), unless UKNOWS_INGEST_ENDPOINT
6
6
  * or UKNOWS_SITE_URL names another one, for example a test copy of UKNOWS.
7
+ * The app address (the link where an app connects) follows UKNOWS_APP_URL.
7
8
  * Setup and check use these same rules, so a test run writes the address it checks.
8
9
  */
9
- import { CLI, INGEST_ENDPOINT, SITE_URL } from './contract.mjs'
10
+ import { APP_URL, CLI, INGEST_ENDPOINT, SETUP_NEXT, SITE_URL, openLive } from './contract.mjs'
10
11
 
11
12
  const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
12
13
  const safe = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
@@ -23,16 +24,46 @@ export function ingestEndpoint(env = {}) {
23
24
  return safe(url) && !url.username && !url.password ? raw : null
24
25
  }
25
26
 
27
+ /** An origin (https, or http on this computer), no path, no user: the origin, else null. */
28
+ function origin(raw) {
29
+ let url
30
+ try { url = new URL(String(raw).trim().replace(/\/+$/, '')) } catch { return null }
31
+ return safe(url) && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash ? url.origin : null
32
+ }
33
+
26
34
  /**
27
35
  * The site address (origin only, no trailing slash), or null when the override is not https or has a path.
28
36
  * @param {Record<string, string|undefined>} [env]
29
37
  * @returns {string|null}
30
38
  */
31
39
  export function siteUrl(env = {}) {
32
- const raw = String(env[CLI.siteEnv] || SITE_URL).trim().replace(/\/+$/, '')
33
- let url
34
- try { url = new URL(raw) } catch { return null }
35
- return safe(url) && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash ? url.origin : null
40
+ return origin(env[CLI.siteEnv] || SITE_URL)
41
+ }
42
+
43
+ /** True when UKNOWS_APP_URL is not set, or is an origin as siteUrl wants it. */
44
+ export const validAppUrl = (env = {}) => !env[CLI.appEnv] || origin(env[CLI.appEnv]) !== null
45
+
46
+ /**
47
+ * The app address, where people sign in and watch an app connect: UKNOWS_APP_URL, else the one of this build. A run
48
+ * against a test copy (UKNOWS_SITE_URL or UKNOWS_INGEST_ENDPOINT) without UKNOWS_APP_URL does not know the app of that
49
+ * copy: null, so setup never sends the user to the production app.
50
+ * @param {Record<string, string|undefined>} [env]
51
+ * @returns {string|null}
52
+ */
53
+ export function appUrl(env = {}) {
54
+ if (env[CLI.appEnv]) return origin(env[CLI.appEnv])
55
+ return env[CLI.siteEnv] || env[CLI.endpointEnv] ? null : APP_URL
56
+ }
57
+
58
+ /**
59
+ * The last line when only the deploy is left, with the app address of this run (F-012-146): where to sign in, from
60
+ * UKNOWS_APP_URL, else from this build. A test copy without UKNOWS_APP_URL gets no address, never the production one.
61
+ * The same line goes in the "next" field of --json.
62
+ * @param {Record<string, string|undefined>} [env]
63
+ */
64
+ export function nextData(env = {}) {
65
+ const app = appUrl(env)
66
+ return SETUP_NEXT.data.replace('{open}', app ? openLive(app) : SETUP_NEXT.testCopy)
36
67
  }
37
68
 
38
69
  /**