@uknows/setup 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/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
|
-
|
|
23
|
-
|
|
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:
|
|
28
|
-
exporter:
|
|
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:
|
|
59
|
+
contrib: PYTHON_SETS[0].contrib,
|
|
31
60
|
/** opentelemetry-distro 0.66b1 needs Python 3.10 or later. */
|
|
32
|
-
minPython: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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')
|
|
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
|
|
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
|
-
/**
|
|
278
|
-
|
|
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
|
|
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
|
/**
|
package/lib/dockerfile.mjs
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
/**
|