@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/settings.mjs
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
|
-
// Where the
|
|
2
|
-
// the Dockerfile, else the env of the Kubernetes container, else <dir>/otel.env
|
|
1
|
+
// Where the UKNOWS settings go for 1 service, and the edits that put them there: the final stage of
|
|
2
|
+
// the Dockerfile, else the env of the Kubernetes container, else <dir>/otel.env when the repo has it or a repo
|
|
3
|
+
// file loads it (a systemd EnvironmentFile=, a Compose env_file:, --env-file or . ./otel.env). Otherwise no file:
|
|
4
|
+
// a required step prints the settings in the form of the platform. Never the key.
|
|
3
5
|
// Settings that would keep traces away from UKNOWS (another address, a traces-only override, a Kubernetes
|
|
4
6
|
// env that replaces the image value) become manual steps, never silent successes.
|
|
5
|
-
|
|
7
|
+
//
|
|
8
|
+
// The settings depend on the mode of the service (ctx.mode):
|
|
9
|
+
// standard the 5 settings, with OTEL_METRICS_EXPORTER=none and OTEL_LOGS_EXPORTER=otlp (traces and logs).
|
|
10
|
+
// channel the same 5 settings. The agent comes from the UKNOWS agent image (stacks/channel.mjs).
|
|
11
|
+
// otel the 3 shared settings. Never the none lines: the app keeps its metrics and logs. The traces-only
|
|
12
|
+
// names replace the endpoint and protocol when the code also sends metrics or logs over OTLP, and for
|
|
13
|
+
// Node.js, Python and Java, whose SDKs send metrics and logs over OTLP by default (unless the target
|
|
14
|
+
// turns both off with none).
|
|
15
|
+
//
|
|
16
|
+
// An endpoint value that an earlier setup wrote (a change in .uknows/setup.json added that line) is this tool's own,
|
|
17
|
+
// not another address to keep. A newer setup replaces it, for example the ingest address of 0.1.0.
|
|
18
|
+
// azure-monitor the 3 shared settings, and OTEL_SERVICE_NAME only when the app does not set it: it also sets
|
|
19
|
+
// the App Insights Cloud Role Name.
|
|
20
|
+
import { posix } from 'node:path'
|
|
21
|
+
import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, FUNCTIONS, INGEST_ENDPOINT, SECRET, SETTINGS, SETTINGS_ELSEWHERE, SETTINGS_FILE, SHARED_SETTINGS, STACKS,
|
|
22
|
+
TRACES_ONLY_PLAN, settingsSnippet } from './contract.mjs'
|
|
23
|
+
import { isDockerfile } from './detect.mjs'
|
|
6
24
|
import { Doc, indentOf } from './doc.mjs'
|
|
7
25
|
import { parseDockerfile, setEnv } from './dockerfile.mjs'
|
|
8
|
-
import {
|
|
26
|
+
import { BOM, sha256 } from './manifest.mjs'
|
|
27
|
+
import { list, stackLabel } from './ui.mjs'
|
|
9
28
|
|
|
10
29
|
const NAMES = SETTINGS.map((s) => s.name)
|
|
11
|
-
const [SERVICE, ENDPOINT] = NAMES
|
|
12
|
-
const
|
|
30
|
+
const [SERVICE, ENDPOINT, PROTOCOL] = NAMES
|
|
31
|
+
const TRACES_ENDPOINT = 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', TRACES_PROTOCOL = 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL'
|
|
32
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
33
|
+
/** The Collector note, with the link to that section: the stack tab of the docs page that has it folded (F-012-09). */
|
|
34
|
+
const COLLECTOR = `${STACKS.find((s) => s.id === 'otel').notes.find((n) => /Collector/.test(n))} See ${DOCS_URL}?method=manual&stack=otel#install-app.`
|
|
13
35
|
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
14
36
|
const blank = (line) => !line.trim() || /^\s*#/.test(line)
|
|
15
37
|
const quote = (value) => `"${value.replace(/["\\]/g, '\\$&')}"`
|
|
@@ -43,25 +65,75 @@ const overrideRules = (endpoint) => [
|
|
|
43
65
|
*/
|
|
44
66
|
export function settingsPlan(service, ctx, extra = []) {
|
|
45
67
|
const { ws } = ctx
|
|
68
|
+
const mode = ctx.mode ?? 'standard'
|
|
46
69
|
const ingest = ctx.endpoint ?? INGEST_ENDPOINT
|
|
47
|
-
const
|
|
48
|
-
const pairs = [...SETTINGS.map((s) => [s.name, s.name === SERVICE ? ctx.name : s.name === ENDPOINT ? ingest : s.value]),
|
|
49
|
-
...extra.filter((s) => s.name !== SECRET.name && !NAMES.includes(s.name)).map((s) => [s.name, String(s.value)])]
|
|
70
|
+
const envFile = at(service.dir, SETTINGS_FILE.path)
|
|
50
71
|
const target = (service.dockerfile && ws.read(service.dockerfile) !== null && dockerTarget(ws, service.dockerfile))
|
|
51
72
|
|| (service.k8s ?? []).map((k) => k8sTarget(ws, k)).find(Boolean)
|
|
52
|
-
||
|
|
53
|
-
const plan = { target: { kind: target.kind, file: target.file
|
|
73
|
+
|| envFileTarget(ws, ctx.repo ?? ws, service, envFile)
|
|
74
|
+
const plan = { target: target.kind === 'none' ? null : { kind: target.kind, file: target.file, ...(target.loader ? { loader: target.loader } : {}) },
|
|
75
|
+
edits: [], summary: [], manual: [], notes: [], pairs: [], tracesOnly: false }
|
|
76
|
+
if (target.loader) plan.notes.push(fill(SETTINGS_ELSEWHERE.loaded, { ...target.loader, path: envFile }))
|
|
77
|
+
const anchors = ctx.existing?.anchors ?? []
|
|
78
|
+
// otel mode: metrics or logs over OTLP in the code (or set in the target) keep the general names. Traces get their own.
|
|
79
|
+
// The SDKs of Node.js, Python and Java send metrics and logs over OTLP by default, unless the target turns both off.
|
|
80
|
+
const signal = anchors.find((a) => a.kind === 'otlp-signal')
|
|
81
|
+
const signals = ['OTEL_METRICS_EXPORTER', 'OTEL_LOGS_EXPORTER']
|
|
82
|
+
const otlpSet = signals.some((n) => /\botlp\b/.test(target.current.get(n) ?? ''))
|
|
83
|
+
const bothOff = signals.every((n) => (target.current.get(n) ?? '').trim().toLowerCase() === 'none')
|
|
84
|
+
const sdkDefault = SDK_SIGNALS.has(service.lang) && !bothOff
|
|
85
|
+
const tracesOnly = mode === 'otel' && Boolean(signal || otlpSet || sdkDefault)
|
|
86
|
+
// A value that an earlier setup wrote in this file: replace it, never keep it as another address.
|
|
87
|
+
const wrote = (name, current) => current !== null && current !== undefined && setupWrote(ctx.manifest, target.file, name, current, target.before)
|
|
88
|
+
const value = (s) => (s.name === SERVICE ? ctx.name : s.name === ENDPOINT ? (tracesOnly ? `${ingest}/v1/traces` : ingest) : s.value)
|
|
89
|
+
const rename = (name) => (!tracesOnly ? name : name === ENDPOINT ? TRACES_ENDPOINT : name === PROTOCOL ? TRACES_PROTOCOL : name)
|
|
90
|
+
let base = (mode === 'standard' || mode === 'channel' ? SETTINGS : SHARED_SETTINGS).map((s) => [rename(s.name), value(s)])
|
|
91
|
+
// azure-monitor: OTEL_SERVICE_NAME is also the App Insights Cloud Role Name. Set it only when the app does not, and
|
|
92
|
+
// say how to see the current role name and keep it.
|
|
93
|
+
if (mode === 'azure-monitor') {
|
|
94
|
+
const elsewhere = anchors.find((a) => a.kind === 'service-name' && a.file !== target.file)
|
|
95
|
+
const set = target.current.has(SERVICE) ? { file: target.file } : elsewhere
|
|
96
|
+
if (set) base = base.filter(([name]) => name !== SERVICE)
|
|
97
|
+
const R = AZURE_MONITOR.plan.roleName
|
|
98
|
+
plan.manual.push(set ? { title: R.title, required: false, lines: [fill(R.kept, { where: `${set.file}${set.line ? ` line ${set.line}` : ''}` })] }
|
|
99
|
+
: { title: R.title, required: false, lines: R.set.map((l) => fill(l, { name: ctx.name })),
|
|
100
|
+
snippets: [{ caption: 'App Insights Logs', language: 'kusto', code: R.query }] })
|
|
101
|
+
}
|
|
102
|
+
const ours = base.map(([name]) => name)
|
|
103
|
+
const endpointName = tracesOnly ? TRACES_ENDPOINT : ENDPOINT
|
|
104
|
+
const sameEndpoint = (v) => sameAs(ingest)(tracesOnly ? v.replace(/\/v1\/traces\/?$/, '') : v)
|
|
105
|
+
const pairs = [...base, ...extra.filter((s) => s.name !== SECRET.name && !NAMES.includes(s.name) && !ours.includes(s.name)).map((s) => [s.name, String(s.value)])]
|
|
106
|
+
plan.pairs = pairs
|
|
107
|
+
plan.tracesOnly = tracesOnly
|
|
54
108
|
|
|
55
109
|
const keyFiles = [...new Set([service.dockerfile, ...(service.k8s ?? []).map((k) => k.file),
|
|
56
110
|
at(service.dir, SETTINGS_FILE.path), at(service.dir, '.env.example')])].filter((f) => f && hasKey(ws.read(f)))
|
|
57
111
|
// A file that holds a key is never edited: the diff and the manifest hunks must not show it.
|
|
58
112
|
const keyInTarget = keyFiles.includes(target.file)
|
|
59
113
|
if (keyFiles.length) {
|
|
60
|
-
plan.manual.push({ title: 'Remove the key from your repo', lines: [...keyFiles.map((f) => `Remove ${SECRET.name} from ${f}.`),
|
|
114
|
+
plan.manual.push({ title: 'Remove the key from your repo', verify: true, lines: [...keyFiles.map((f) => `Remove ${SECRET.name} from ${f}.`),
|
|
61
115
|
'Get a new key. The old key stays in your git history.', SECRET.summary,
|
|
62
116
|
...(keyInTarget ? [`Then run npx @uknows/setup again. It adds the settings to ${target.file}.`] : [])] })
|
|
63
117
|
}
|
|
64
|
-
|
|
118
|
+
// The settings cannot go in: the service is not set up until the key is out of the file.
|
|
119
|
+
if (keyInTarget) return { ...plan, stop: true }
|
|
120
|
+
// An Azure Functions app (stacks/functions.mjs): the settings are app settings of the function app, never a file.
|
|
121
|
+
if (service.functions && (mode === 'standard' || mode === 'azure-monitor')) {
|
|
122
|
+
const fill = (text) => text.replace(/\{count\}/g, String(pairs.length))
|
|
123
|
+
return { ...plan, target: null, manual: [...plan.manual, { title: FUNCTIONS.settings.title, required: true, lines: FUNCTIONS.settings.lines.map(fill),
|
|
124
|
+
snippets: [settingsSnippet('azure-functions', pairs, ctx.name)] }] }
|
|
125
|
+
}
|
|
126
|
+
if (target.kind === 'none') {
|
|
127
|
+
// No file to put the settings in: the settings go where the app runs, in the form of each platform it runs on.
|
|
128
|
+
const hosts = [...new Set((ctx.detection?.hosts ?? []).map((h) => h.id).filter((id) => id !== 'github-actions'))]
|
|
129
|
+
const snippets = (hosts.length ? hosts : ['vm-docker']).map((id) => settingsSnippet(id, pairs, ctx.name))
|
|
130
|
+
// The same words as the start file advice of Node.js: this tool did not pick a Dockerfile, and these are the ones it saw.
|
|
131
|
+
const seen = unpicked(ctx)
|
|
132
|
+
const why = seen.length ? fill(SETTINGS_ELSEWHERE.notPicked, { dockerfiles: list(seen.slice(0, 5)) }) : SETTINGS_ELSEWHERE.why
|
|
133
|
+
plan.manual.push({ title: SETTINGS_ELSEWHERE.title, lines: [why, ...SETTINGS_ELSEWHERE.steps],
|
|
134
|
+
snippets: snippets.filter((x, i) => snippets.findIndex((y) => y.caption === x.caption) === i) })
|
|
135
|
+
return plan
|
|
136
|
+
}
|
|
65
137
|
if (target.flow) {
|
|
66
138
|
plan.manual.push({ title: `Add the UKNOWS settings to ${target.file}`,
|
|
67
139
|
lines: [`The env of ${target.where} is on 1 line. This tool does not change that format.`, 'Add these settings to the env of the container.'],
|
|
@@ -70,38 +142,79 @@ export function settingsPlan(service, ctx, extra = []) {
|
|
|
70
142
|
}
|
|
71
143
|
|
|
72
144
|
// Another address is kept. Then its protocol and exporters belong to that pipeline: keep them, add none.
|
|
73
|
-
|
|
74
|
-
const
|
|
145
|
+
// Traces only: the traces go to the general address now when the traces address is not set.
|
|
146
|
+
const general = tracesOnly && !target.current.has(TRACES_ENDPOINT)
|
|
147
|
+
const keptName = general ? ENDPOINT : endpointName
|
|
148
|
+
const endpoint = target.current.get(keptName)
|
|
149
|
+
const kept = endpoint !== undefined && !(endpoint !== null && (general ? sameAs(ingest)(endpoint) : sameEndpoint(endpoint))) && !wrote(keptName, endpoint)
|
|
150
|
+
if (tracesOnly && !kept) {
|
|
151
|
+
const why = signal || otlpSet ? fill(TRACES_ONLY_PLAN.code, { where: signal ? `${signal.file} line ${signal.line}` : target.where })
|
|
152
|
+
: fill(TRACES_ONLY_PLAN.sdk, { lang: stackLabel(service.lang) })
|
|
153
|
+
plan.notes.push(`${why} ${TRACES_ONLY_PLAN.set} ${TRACES_ONLY_PLAN.key}`)
|
|
154
|
+
// .NET AddOtlpExporter() does not read the traces-only settings: the code must pass the address on.
|
|
155
|
+
const add = anchors.find((a) => a.kind === 'add-otlp-exporter')
|
|
156
|
+
const passed = anchors.some((a) => a.kind === 'traces-endpoint') && anchors.some((a) => a.kind === 'traces-headers')
|
|
157
|
+
if (service.lang === 'dotnet' && add && !anchors.some((a) => a.kind === 'use-otlp-exporter') && !passed) {
|
|
158
|
+
plan.manual.push({ title: 'Pass the traces address on to AddOtlpExporter', verify: true, lines: [`${add.file} line ${add.line} adds an OTLP exporter with AddOtlpExporter().`,
|
|
159
|
+
DOTNET_TRACES_ONLY.note, 'Change the AddOtlpExporter() call of the traces as below. Then traces go to UKNOWS, and metrics and logs keep their address.'],
|
|
160
|
+
snippets: [{ caption: add.file, language: 'csharp', code: DOTNET_TRACES_ONLY.code }] })
|
|
161
|
+
}
|
|
162
|
+
// The key goes in the traces-only variable too (the note above), so the metrics and logs exporters never send it.
|
|
163
|
+
plan.manual.push({ title: TRACES_ONLY_PLAN.keyTitle, required: false, lines: [TRACES_ONLY_PLAN.key, TRACES_ONLY_PLAN.keyCheck] })
|
|
164
|
+
}
|
|
75
165
|
if (kept) {
|
|
76
166
|
plan.manual.push({ title: 'Check where this app sends data', lines: [
|
|
77
|
-
endpoint === null ? `${target.where} sets ${
|
|
167
|
+
endpoint === null ? `${target.where} sets ${keptName} from another source.` : `${target.where} sends data to ${hideUser(endpoint)}.`,
|
|
78
168
|
'This tool keeps that address and the other OpenTelemetry settings of the app.', COLLECTOR,
|
|
79
169
|
'To send data to UKNOWS directly, set these settings instead.'],
|
|
80
|
-
snippets: [{ caption: 'Settings for UKNOWS', language: 'text', code: pairs.filter(([name]) =>
|
|
170
|
+
snippets: [{ caption: 'Settings for UKNOWS', language: 'text', code: pairs.filter(([name]) => ours.includes(name) && name !== SERVICE)
|
|
81
171
|
.map(([name, value]) => `${name}=${value}`).join('\n') }] })
|
|
82
172
|
}
|
|
83
173
|
const set = []
|
|
84
174
|
for (const [name, value] of pairs) {
|
|
85
175
|
const current = target.current.get(name)
|
|
86
|
-
|
|
87
|
-
|
|
176
|
+
const stale = ours.includes(name) && name !== SERVICE && wrote(name, current)
|
|
177
|
+
if (current === undefined) { if (!kept || !ours.includes(name) || name === SERVICE) set.push([name, value]) }
|
|
178
|
+
else if (current === value || (name === endpointName && !stale)) continue
|
|
88
179
|
else if (current === null) plan.notes.push(`${target.where} sets ${name} from another source. This tool keeps it.`)
|
|
89
|
-
else if (!
|
|
180
|
+
else if ((!ours.includes(name) || kept) && !stale) plan.notes.push(`${target.where} already sets ${name}. This tool keeps that value.`)
|
|
90
181
|
else {
|
|
91
182
|
if (name !== SERVICE) plan.notes.push(`${target.where} sets ${name} to ${current}. This tool changes it to ${value}.`)
|
|
92
183
|
set.push([name, value])
|
|
93
184
|
}
|
|
94
185
|
}
|
|
95
|
-
plan.manual.push(...overrides(service, target, pairs, ingest))
|
|
186
|
+
plan.manual.push(...overrides(service, target, pairs, ingest, { ours, tracesOnly }))
|
|
96
187
|
if (!set.length) return plan
|
|
97
|
-
const added = set.every(([name]) => target.current.get(name) === undefined) &&
|
|
98
|
-
const extras = set.map(([name]) => name).filter((name) => !
|
|
99
|
-
plan.summary.push(added ? `Add the
|
|
188
|
+
const added = set.every(([name]) => target.current.get(name) === undefined) && ours.every((n) => set.some(([name]) => name === n))
|
|
189
|
+
const extras = set.map(([name]) => name).filter((name) => !ours.includes(name))
|
|
190
|
+
plan.summary.push(added ? `Add the ${ours.length} UKNOWS settings${extras.length ? ` and ${list(extras)}` : ''} to ${target.where}.`
|
|
100
191
|
: `Set ${list(set.map(([name]) => name))} in ${target.where}.`)
|
|
101
192
|
plan.edits.push({ file: target.file, before: target.before, after: target.write(set), why: added ? 'Add the UKNOWS settings.' : 'Set the UKNOWS settings.' })
|
|
102
193
|
return plan
|
|
103
194
|
}
|
|
104
195
|
|
|
196
|
+
/** Languages whose OpenTelemetry SDKs send metrics and logs over OTLP by default. */
|
|
197
|
+
const SDK_SIGNALS = new Set(['node', 'python', 'java'])
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True when an earlier setup run wrote name=value in this file: a change in .uknows/setup.json added that line (an
|
|
201
|
+
* ENV or env-file line, or a Kubernetes name: and value: pair), or created the file (text, as it is now) and it is
|
|
202
|
+
* unchanged since.
|
|
203
|
+
* @param {{changes: {file: string, created: boolean, afterSha256?: string, hunks: {after: string[]}[]}[]}|null} manifest
|
|
204
|
+
*/
|
|
205
|
+
export function setupWrote(manifest, file, name, value, text = null) {
|
|
206
|
+
if (!manifest || !file) return false
|
|
207
|
+
const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
208
|
+
const line = new RegExp(`(?:^|\\s)${esc(name)}=["']?${esc(value)}["']?(?:\\s|\\\\|$)`)
|
|
209
|
+
const yaml = new RegExp(`^\\s*-?\\s*name:\\s*["']?${esc(name)}["']?\\s*$`)
|
|
210
|
+
const yamlValue = new RegExp(`^\\s*value:\\s*["']?${esc(value)}["']?\\s*$`)
|
|
211
|
+
const lines = (list) => list.map((l) => l.replace(/\r?\n$/, ''))
|
|
212
|
+
const has = (list) => list.some((l, i) => line.test(l) || (yaml.test(l) && yamlValue.test(list[i + 1] ?? '')))
|
|
213
|
+
const same = (sha) => text !== null && [text, BOM + text].some((t) => sha256(Buffer.from(t, 'utf8')) === sha)
|
|
214
|
+
return manifest.changes.some((c) => c.file === file && (c.created ? same(c.afterSha256) && has(text.split(/\r?\n/))
|
|
215
|
+
: c.hunks.some((h) => has(lines(h.after)))))
|
|
216
|
+
}
|
|
217
|
+
|
|
105
218
|
/**
|
|
106
219
|
* True when the text sets OTEL_EXPORTER_OTLP_HEADERS to something that is not a placeholder (in any env format,
|
|
107
220
|
* Kubernetes flow style too), or holds x-uknows-key=<a real value> anywhere. Never returns the value.
|
|
@@ -128,7 +241,7 @@ export const redactKey = (line) => line.replace(KEY_HEADER, (m, value) => m.slic
|
|
|
128
241
|
* Manual steps for settings that keep traces from UKNOWS: an overrideRules() value where the settings go, and a
|
|
129
242
|
* Kubernetes container env that replaces the image values with something else.
|
|
130
243
|
*/
|
|
131
|
-
function overrides(service, target, pairs, endpoint) {
|
|
244
|
+
function overrides(service, target, pairs, endpoint, { ours = NAMES, tracesOnly = false } = {}) {
|
|
132
245
|
const sameEndpoint = sameAs(endpoint)
|
|
133
246
|
const sources = [{ where: target.where, file: target.file, env: target.current }]
|
|
134
247
|
if (target.kind !== 'k8s') {
|
|
@@ -139,18 +252,20 @@ function overrides(service, target, pairs, endpoint) {
|
|
|
139
252
|
const blocks = []
|
|
140
253
|
for (const { where, file, env, k8s } of sources) {
|
|
141
254
|
for (const [name, bad, fix] of overrideRules(endpoint)) {
|
|
255
|
+
// Traces-only settings that this tool sets itself are not overrides (another address is kept above).
|
|
256
|
+
if (tracesOnly && ours.includes(name) && !k8s) continue
|
|
142
257
|
if (!env.has(name) || (env.get(name) !== null && !bad(env.get(name)))) continue
|
|
143
258
|
const value = env.get(name)
|
|
144
259
|
const shown = value === null ? ' from another source' : /HEADERS/.test(name) ? '' : ` to ${hideUser(value)}`
|
|
145
|
-
blocks.push({ title: `Check ${name}`, lines: [`${where} sets ${name}${shown}.`,
|
|
260
|
+
blocks.push({ title: `Check ${name}`, verify: true, lines: [`${where} sets ${name}${shown}.`,
|
|
146
261
|
name === 'OTEL_SDK_DISABLED' ? 'That turns OpenTelemetry off.' : 'It wins over the UKNOWS settings, so traces would not reach UKNOWS.', fix] })
|
|
147
262
|
}
|
|
148
263
|
if (!k8s) continue
|
|
149
264
|
// The container env replaces the ENV of the image for the same names.
|
|
150
265
|
const differs = (name, value, current) => current === null || (current !== value && !(name === ENDPOINT && sameEndpoint(current)))
|
|
151
|
-
const differ = pairs.filter(([name, value]) =>
|
|
266
|
+
const differ = pairs.filter(([name, value]) => ours.includes(name) && env.has(name) && differs(name, value, env.get(name)))
|
|
152
267
|
if (differ.length) {
|
|
153
|
-
blocks.push({ title: `Check the UKNOWS settings in ${file}`, lines: [
|
|
268
|
+
blocks.push({ title: `Check the UKNOWS settings in ${file}`, verify: true, lines: [
|
|
154
269
|
`${where} sets ${list(differ.map(([name]) => name))}. The container env replaces the values in ${target.file}.`,
|
|
155
270
|
'Set them to the values below there, or remove them.'],
|
|
156
271
|
snippets: [{ caption: file, language: 'text', code: differ.map(([name, value]) => `${name}=${value}`).join('\n') }] })
|
|
@@ -167,6 +282,71 @@ function dockerTarget(ws, file) {
|
|
|
167
282
|
return { kind: 'dockerfile', file, where: file, before, current, write: (set) => setEnv(before, set) }
|
|
168
283
|
}
|
|
169
284
|
|
|
285
|
+
/** Repo files that can load otel.env when the app starts. */
|
|
286
|
+
const LOADER_FILE = /\.(?:service|ya?ml|sh|bash|ps1)$|(?:^|\/)(?:Makefile|Procfile|package\.json|justfile)$/
|
|
287
|
+
const COMPOSE_FILE = /(?:^|\/)(?:docker-)?compose(?:\.[^/]+)?\.ya?ml$/
|
|
288
|
+
/** [regex, files]: the group is the path of the env file. systemd EnvironmentFile=, --env-file of docker run or node, and . or source in a shell. */
|
|
289
|
+
const LOADERS = [
|
|
290
|
+
[/^[ \t]*EnvironmentFile[ \t]*=[ \t]*-?["']?([^\s"']*otel\.env)(?=["'\s]|$)/m, /\.service$/],
|
|
291
|
+
[/--env-file(?:[ \t]+|=)["']?([^\s"';&|)]*otel\.env)(?=["'\s;&|)]|$)/m, LOADER_FILE],
|
|
292
|
+
[/(?:^|[\s;&|(`])(?:\.|source)[ \t]+["']?([^\s"';&|)]*otel\.env)(?=["'\s;&|)]|$)/m, LOADER_FILE],
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* otel.env as the target when the repo has it or a repo file loads it. Else a target of kind none: there is no
|
|
297
|
+
* file that the app surely reads, so the settings become a required step.
|
|
298
|
+
*/
|
|
299
|
+
function envFileTarget(ws, view, service, file) {
|
|
300
|
+
if (ws.read(file) !== null) return envTarget(ws, file)
|
|
301
|
+
const loader = findLoader(view, service, file)
|
|
302
|
+
return loader ? { ...envTarget(ws, file), loader } : { kind: 'none', file: null, where: null, before: null, current: new Map() }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The first repo file that loads this otel.env: {file, line}. A relative path counts when it is the otel.env of the
|
|
307
|
+
* service from the folder of that file, the service folder or the repo root. A full path or a path with a variable
|
|
308
|
+
* names a file on the host, so it counts for every service.
|
|
309
|
+
*/
|
|
310
|
+
function findLoader(view, service, envFile) {
|
|
311
|
+
let files = []
|
|
312
|
+
try { files = view.files() } catch { /* unreadable */ }
|
|
313
|
+
const ours = (from, path) => /^[/~$]/.test(path) || path.includes('$')
|
|
314
|
+
|| [posix.dirname(from), service.dir, '.'].some((dir) => posix.normalize(at(dir, path.replace(/^\.\//, ''))) === envFile)
|
|
315
|
+
for (const from of files) {
|
|
316
|
+
if (from.startsWith('../') || !(LOADER_FILE.test(from) || COMPOSE_FILE.test(from))) continue
|
|
317
|
+
let text = null
|
|
318
|
+
try { text = (view.stat?.(from)?.size ?? 0) <= 262144 ? view.read(from) : null } catch { text = null }
|
|
319
|
+
if (!text?.includes(SETTINGS_FILE.path)) continue
|
|
320
|
+
const code = text.replace(/^[ \t]*#.*$/gm, '')
|
|
321
|
+
const hits = [...(COMPOSE_FILE.test(from) ? composeEnvFiles(code) : []),
|
|
322
|
+
...LOADERS.filter(([, re]) => re.test(from)).flatMap(([re]) => [...code.matchAll(new RegExp(re.source, 'gm'))]
|
|
323
|
+
.map((m) => ({ path: m[1], line: code.slice(0, m.index + m[0].lastIndexOf(m[1])).split('\n').length })))]
|
|
324
|
+
const hit = hits.find((h) => ours(from, h.path))
|
|
325
|
+
if (hit) return { file: from, line: hit.line }
|
|
326
|
+
}
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The env files of a Compose file: env_file: with 1 path, a [list], or list items below it (- path or - path: path). */
|
|
331
|
+
function composeEnvFiles(text) {
|
|
332
|
+
const lines = text.split(/\r?\n/), out = []
|
|
333
|
+
const paths = (value, line) => value.replace(/\s#.*$/, '').replace(/^\[|\]$/g, '').split(',')
|
|
334
|
+
.map((v) => v.trim().replace(/^-\s*/, '').replace(/^path\s*:\s*/, '').replace(/^["']|["']$/g, '')).filter((v) => v.endsWith(SETTINGS_FILE.path))
|
|
335
|
+
.forEach((path) => out.push({ path, line }))
|
|
336
|
+
lines.forEach((line, i) => {
|
|
337
|
+
const m = /^(\s*)env_file\s*:\s*(.*)$/.exec(line)
|
|
338
|
+
if (!m) return
|
|
339
|
+
if (m[2].trim()) return paths(m[2], i + 1)
|
|
340
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
341
|
+
if (!lines[j].trim()) continue
|
|
342
|
+
const indent = /^\s*/.exec(lines[j])[0].length
|
|
343
|
+
if (indent < m[1].length || (indent === m[1].length && !/^\s*-/.test(lines[j]))) break
|
|
344
|
+
paths(lines[j].trim(), j + 1)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
return out
|
|
348
|
+
}
|
|
349
|
+
|
|
170
350
|
function envTarget(ws, file) {
|
|
171
351
|
const before = ws.read(file)
|
|
172
352
|
const doc = new Doc(before ?? '')
|
|
@@ -278,3 +458,11 @@ function underContainers(doc, start) {
|
|
|
278
458
|
}
|
|
279
459
|
return false
|
|
280
460
|
}
|
|
461
|
+
|
|
462
|
+
/** The Dockerfiles of the repo that detection did not pick for any service: the same list for every step that names them. */
|
|
463
|
+
export function unpicked(ctx) {
|
|
464
|
+
let files = []
|
|
465
|
+
try { files = (ctx.repo ?? ctx.ws).files() } catch { /* unreadable */ }
|
|
466
|
+
const picked = new Set((ctx.detection?.services ?? []).map((s) => s.dockerfile).filter(Boolean))
|
|
467
|
+
return files.filter((f) => isDockerfile(f) && !picked.has(f) && !/(?:^|\/)node_modules\//.test(f))
|
|
468
|
+
}
|
package/lib/signin.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sign in from the terminal. When setup runs in a terminal and has no key, it asks UKNOWS for a sign-in link, opens
|
|
3
|
+
* the browser, and waits while the person signs in and picks the workspace and the environment. The key then comes
|
|
4
|
+
* to setup by itself: nobody creates, shows, copies or pastes it.
|
|
5
|
+
*
|
|
6
|
+
* 1. POST {app}/api/v1/cli/links {client, version, secretHash, app?}: 201 {linkId, userCode, verificationUrl,
|
|
7
|
+
* expiresIn, interval}. secretHash is the base64url SHA-256 of a device secret: 32 random bytes that only this
|
|
8
|
+
* process holds, in memory. So only this process can collect the key.
|
|
9
|
+
* 2. POST {app}/api/v1/cli/links/{linkId}/token {deviceSecret}, every interval seconds: 202 waiting, 200 the key,
|
|
10
|
+
* 403 cancelled in the browser, 410 expired, 429 slow down (Retry-After).
|
|
11
|
+
*
|
|
12
|
+
* A UKNOWS without these routes answers the links request with 404 or 405, or with 401 when its sign-in check runs
|
|
13
|
+
* before its routes (a test copy of UKNOWS older than this sign-in does). The links route itself needs no sign-in, so
|
|
14
|
+
* all 3 mean the same: this UKNOWS cannot make a sign-in link, and setup asks for the key instead. 5xx answers and
|
|
15
|
+
* network errors are failures: setup tries again, then says what went wrong.
|
|
16
|
+
*
|
|
17
|
+
* The key and the device secret never print and are never written anywhere. A pipeline, --yes, --json and a run
|
|
18
|
+
* without a terminal never start a sign-in (engine.mjs decides, before it calls signIn).
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
21
|
+
import { request } from './check.mjs'
|
|
22
|
+
import { CLI } from './contract.mjs'
|
|
23
|
+
import { VERSION } from './manifest.mjs'
|
|
24
|
+
import { list } from './ui.mjs'
|
|
25
|
+
|
|
26
|
+
export const LINKS_PATH = '/api/v1/cli/links'
|
|
27
|
+
export const CLIENT = '@uknows/setup'
|
|
28
|
+
/** The answers of a UKNOWS that has no links route: it cannot make a sign-in link, so the person pastes the key. */
|
|
29
|
+
export const NO_LINKS_STATUS = new Set([401, 404, 405])
|
|
30
|
+
/** Network errors and 5xx answers in a row before setup says that it cannot reach UKNOWS. */
|
|
31
|
+
const MAX_FAILURES = 5
|
|
32
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
33
|
+
|
|
34
|
+
/** The clock of a real run. Tests pass one whose time moves only when something sleeps. */
|
|
35
|
+
export const REAL_CLOCK = {
|
|
36
|
+
now: () => Date.now(),
|
|
37
|
+
sleep(ms) {
|
|
38
|
+
let timer
|
|
39
|
+
const done = new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, ms)) })
|
|
40
|
+
done.cancel = () => clearTimeout(timer)
|
|
41
|
+
return done
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** base64url of the SHA-256 of the device secret: what UKNOWS keeps, so only this process can collect the key. */
|
|
46
|
+
export const secretHash = (secret) => createHash('sha256').update(secret).digest('base64url')
|
|
47
|
+
|
|
48
|
+
/** The words of the sign-in. Exported for the tests and the README. */
|
|
49
|
+
export const SIGN_IN = {
|
|
50
|
+
opening: 'Sign in to UKNOWS to connect this app. Opening your browser...',
|
|
51
|
+
noBrowser: 'Sign in to UKNOWS to connect this app. Open this link in a browser:',
|
|
52
|
+
code: (code) => `Your code: ${code} (check the browser shows the same code)`,
|
|
53
|
+
keys: 'Press Enter to open the browser again, p to paste a key instead, or Ctrl+C to stop.',
|
|
54
|
+
waitingPlain: 'Waiting for you to sign in in the browser. The link works for {minutes}.',
|
|
55
|
+
waiting: 'Waiting for you to sign in... {left} left',
|
|
56
|
+
slow: ' (UKNOWS is slow to answer, still trying)',
|
|
57
|
+
signedIn: 'Signed in.',
|
|
58
|
+
denied: 'The sign-in was cancelled in the browser.',
|
|
59
|
+
expired: 'The sign-in link expired.',
|
|
60
|
+
noLinks: (host) => `${host} cannot make a sign-in link, so paste your key instead.`,
|
|
61
|
+
/** Sign-in is off: a test address is set (names: the settings that are set), but the app address of that UKNOWS is not. */
|
|
62
|
+
noApp: (names) => [`Setup cannot sign you in from this terminal: ${list(names)} ${names.length > 1 ? 'are' : 'is'} set, but ${CLI.appEnv} is not.`,
|
|
63
|
+
`To sign in, set ${CLI.appEnv} to the app address of your test copy of UKNOWS, for example ${CLI.appEnv}=https://app-test.example.com, and run setup again. For now, paste your key.`],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign in, and come back with the key. Returns one of:
|
|
68
|
+
* {approved: true, key, workspace: {id, name}, environment, environmentLabel, keyId, ingestEndpoint, siteUrl}
|
|
69
|
+
* {paste: true} the person chose to paste a key instead (p, or after a failed sign-in). pressed: p while waiting.
|
|
70
|
+
* {stopped: true} q after a failed sign-in, or the end of the input. interrupt: true for Ctrl+C.
|
|
71
|
+
* ctx: {base: the app address, app?: {name, repo, platform}, env, openUrl(url, env, say) => boolean, request?, clock?}
|
|
72
|
+
*/
|
|
73
|
+
export async function signIn(ui, ctx) {
|
|
74
|
+
for (let round = 0; ; round++) {
|
|
75
|
+
const made = await makeLink(ui, ctx)
|
|
76
|
+
let result = made
|
|
77
|
+
if (made.link) result = await waitForSignIn(ui, ctx, made.link, made.deviceSecret)
|
|
78
|
+
if (result.approved || result.paste || result.stopped) return result
|
|
79
|
+
ui.print(result.why)
|
|
80
|
+
const next = await whatNext(ui, result.again)
|
|
81
|
+
if (next !== 'again') return next === 'paste' ? { paste: true } : { stopped: true }
|
|
82
|
+
ui.print()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST /api/v1/cli/links. {link, deviceSecret}, or {why, again} when UKNOWS cannot be reached, or {paste} when this
|
|
88
|
+
* UKNOWS has no links route (401, 404 or 405).
|
|
89
|
+
*/
|
|
90
|
+
async function makeLink(ui, { base, app, request: send = request, clock = REAL_CLOCK }) {
|
|
91
|
+
const deviceSecret = randomBytes(32).toString('base64url')
|
|
92
|
+
const body = { client: CLIENT, version: VERSION, secretHash: secretHash(deviceSecret), ...(app ? { app: clean(app) } : {}) }
|
|
93
|
+
const url = new URL(`${base}${LINKS_PATH}`)
|
|
94
|
+
for (let failures = 0, tries = 0; tries < 20; tries++) {
|
|
95
|
+
const r = await post(send, url, body)
|
|
96
|
+
if (r.status === 201 || r.status === 200) {
|
|
97
|
+
const link = readLink(r.json)
|
|
98
|
+
return link ? { link, deviceSecret } : { why: `${url.hostname} sent a sign-in link that setup cannot read.`, again: 'try again' }
|
|
99
|
+
}
|
|
100
|
+
if (NO_LINKS_STATUS.has(r.status)) {
|
|
101
|
+
ui.print(SIGN_IN.noLinks(url.hostname))
|
|
102
|
+
return { paste: true }
|
|
103
|
+
}
|
|
104
|
+
if (r.status === 429) { await clock.sleep(retryAfter(r.headers, clock) ?? 5000); continue }
|
|
105
|
+
if (r.error || r.status >= 500) {
|
|
106
|
+
if (++failures >= 3) return { why: unreachable(url, r), again: 'try again' }
|
|
107
|
+
await clock.sleep(1000 * 2 ** (failures - 1))
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
return { why: `${url.hostname} did not make a sign-in link (status ${r.status}).`, again: 'try again' }
|
|
111
|
+
}
|
|
112
|
+
return { why: `${url.hostname} is busy. It asked setup to wait too many times.`, again: 'try again' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Show the link and the code, open the browser, and poll until UKNOWS answers. Enter opens the browser again, p goes to
|
|
117
|
+
* the paste question, Ctrl+C stops. Returns {approved, ...}, {paste}, {stopped}, or {why, again} (cancelled in the
|
|
118
|
+
* browser, expired, or no answer from UKNOWS).
|
|
119
|
+
*/
|
|
120
|
+
async function waitForSignIn(ui, ctx, link, deviceSecret) {
|
|
121
|
+
const { env, openUrl, request: send = request, clock = REAL_CLOCK } = ctx
|
|
122
|
+
const open = () => openUrl(link.verificationUrl, env, () => {})
|
|
123
|
+
const opened = open()
|
|
124
|
+
ui.print(opened ? SIGN_IN.opening : SIGN_IN.noBrowser)
|
|
125
|
+
ui.print(` ${ui.cyan(link.verificationUrl)}`)
|
|
126
|
+
ui.print(` ${SIGN_IN.code(ui.bold(link.userCode))}`)
|
|
127
|
+
ui.print()
|
|
128
|
+
const keys = ui.keys()
|
|
129
|
+
if (keys) {
|
|
130
|
+
ui.print(ui.dim(` ${SIGN_IN.keys}`))
|
|
131
|
+
ui.print()
|
|
132
|
+
} else ui.print(SIGN_IN.waitingPlain.replace('{minutes}', minutes(link.expiresIn)))
|
|
133
|
+
const expires = clock.now() + link.expiresIn * 1000
|
|
134
|
+
const url = new URL(`${new URL(ctx.base).origin}${LINKS_PATH}/${encodeURIComponent(link.linkId)}/token`)
|
|
135
|
+
let interval = link.interval * 1000, failures = 0, note = ''
|
|
136
|
+
let nextPoll = clock.now() + interval
|
|
137
|
+
const left = () => {
|
|
138
|
+
const s = Math.max(0, Math.ceil((expires - clock.now()) / 1000))
|
|
139
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
|
140
|
+
}
|
|
141
|
+
const spin = ui.progress(() => `${SIGN_IN.waiting.replace('{left}', left())}${note}`)
|
|
142
|
+
let keyWait = null
|
|
143
|
+
const end = (result, line = null) => { spin.end(line); keys?.close(); return result }
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const now = clock.now()
|
|
147
|
+
if (now >= expires) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
|
|
148
|
+
if (now >= nextPoll) {
|
|
149
|
+
const r = await post(send, url, { deviceSecret })
|
|
150
|
+
if (r.status === 200) {
|
|
151
|
+
const data = readToken(r.json)
|
|
152
|
+
if (data) return end({ approved: true, ...data }, `${ui.cyan(ui.marks.done)} ${SIGN_IN.signedIn}`)
|
|
153
|
+
return end({ why: `${url.hostname} sent an answer that setup cannot read.`, again: 'try again' })
|
|
154
|
+
}
|
|
155
|
+
if (r.status === 403) return end({ why: SIGN_IN.denied, again: 'sign in again' })
|
|
156
|
+
if (r.status === 410 || r.status === 404) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
|
|
157
|
+
if (r.status === 202) { failures = 0; note = ''; nextPoll = clock.now() + interval; continue }
|
|
158
|
+
if (r.status === 429) {
|
|
159
|
+
// Slow down: wait as long as UKNOWS asks, and poll less often from now on.
|
|
160
|
+
const wait = retryAfter(r.headers, clock)
|
|
161
|
+
interval = Math.min(Math.max(interval, wait ?? interval * 2), 60000)
|
|
162
|
+
nextPoll = clock.now() + Math.max(interval, wait ?? 0)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (r.error || r.status >= 500) {
|
|
166
|
+
if (++failures >= MAX_FAILURES) return end({ why: unreachable(url, r), again: 'try again' })
|
|
167
|
+
note = SIGN_IN.slow
|
|
168
|
+
nextPoll = clock.now() + Math.min(interval * 2 ** failures, 30000)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
return end({ why: `${url.hostname} answered the sign-in with status ${r.status}.`, again: 'try again' })
|
|
172
|
+
}
|
|
173
|
+
const sleep = clock.sleep(Math.min(nextPoll, expires) - now)
|
|
174
|
+
if (!keys) { await sleep; continue }
|
|
175
|
+
keyWait ??= keys.next()
|
|
176
|
+
const key = await Promise.race([sleep.then(() => undefined), keyWait])
|
|
177
|
+
sleep.cancel?.()
|
|
178
|
+
if (key === undefined) continue
|
|
179
|
+
keyWait = null
|
|
180
|
+
if (key === 'ctrl-c' || key === null) return end({ stopped: true, interrupt: true }, null)
|
|
181
|
+
if (key === 'p') {
|
|
182
|
+
// People often press Enter after p by habit. That Enter belongs to p, not to the paste question.
|
|
183
|
+
await Promise.race([keys.next(), new Promise((resolve) => setTimeout(resolve, 400))])
|
|
184
|
+
return end({ paste: true, pressed: true }, null)
|
|
185
|
+
}
|
|
186
|
+
if (key === 'enter' && !open()) ui.print(` ${ui.cyan(link.verificationUrl)}`)
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
spin.stop()
|
|
190
|
+
keys?.close()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** After a failed sign-in: again (Enter), paste (p) or stop (q, or the end of the input). */
|
|
195
|
+
async function whatNext(ui, again) {
|
|
196
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
197
|
+
const answer = await ui.line(`Press Enter to ${again}, type p to paste a key instead, or type q to stop:`)
|
|
198
|
+
if (answer === null) return 'stop'
|
|
199
|
+
const a = answer.toLowerCase()
|
|
200
|
+
if (a === '') return 'again'
|
|
201
|
+
if (a === 'p' || a === 'paste') return 'paste'
|
|
202
|
+
if (a === 'q' || a === 'quit' || a === 'stop') return 'stop'
|
|
203
|
+
ui.print('Press Enter, or type p or q.')
|
|
204
|
+
}
|
|
205
|
+
return 'stop'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The link of a 201 answer, checked and tidied, or null. */
|
|
209
|
+
function readLink(json) {
|
|
210
|
+
if (!json || typeof json !== 'object') return null
|
|
211
|
+
const { linkId, userCode, verificationUrl } = json
|
|
212
|
+
if (typeof linkId !== 'string' || !/^[A-Za-z0-9._~-]{1,200}$/.test(linkId)) return null
|
|
213
|
+
if (typeof userCode !== 'string' || !/^[A-Za-z0-9-]{4,20}$/.test(userCode)) return null
|
|
214
|
+
let url
|
|
215
|
+
try { url = new URL(String(verificationUrl)) } catch { return null }
|
|
216
|
+
const safe = url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
|
|
217
|
+
if (!safe || url.username || url.password) return null
|
|
218
|
+
const number = (value, fallback, min, max) => (Number.isFinite(value) && value > 0 ? Math.min(Math.max(value, min), max) : fallback)
|
|
219
|
+
return { linkId, userCode, verificationUrl: url.href, expiresIn: number(json.expiresIn, 600, 30, 3600), interval: number(json.interval, 3, 0.05, 60) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The key of a 200 answer, checked, or null. A key fits in a header: printable, no spaces, no commas. */
|
|
223
|
+
function readToken(json) {
|
|
224
|
+
if (!json || typeof json !== 'object' || typeof json.key !== 'string' || !/^[\x21-\x7e]{8,512}$/.test(json.key) || json.key.includes(',')) return null
|
|
225
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 200) : null)
|
|
226
|
+
const workspace = json.workspace && typeof json.workspace === 'object' ? { id: text(json.workspace.id), name: text(json.workspace.name) } : { id: null, name: null }
|
|
227
|
+
return { key: json.key, workspace, environment: text(json.environment), environmentLabel: text(json.environmentLabel),
|
|
228
|
+
keyId: text(json.keyId), ingestEndpoint: text(json.ingestEndpoint), siteUrl: text(json.siteUrl) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Retry-After in ms: seconds, or an HTTP date. null when missing. */
|
|
232
|
+
function retryAfter(headers = {}, clock = REAL_CLOCK) {
|
|
233
|
+
const raw = String(headers?.['retry-after'] ?? '').trim()
|
|
234
|
+
if (!raw) return null
|
|
235
|
+
if (/^\d+$/.test(raw)) return Math.min(Number(raw), 120) * 1000
|
|
236
|
+
const at = Date.parse(raw)
|
|
237
|
+
return Number.isFinite(at) ? Math.min(Math.max(at - clock.now(), 0), 120000) : null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unreachable(url, r) {
|
|
241
|
+
return r.error ? `Could not reach ${url.hostname} (${r.error.code || 'network error'}). Check your internet connection.`
|
|
242
|
+
: `${url.hostname} did not answer the sign-in (status ${r.status}).`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function post(send, url, body) {
|
|
246
|
+
const r = await send(url, 'POST', { 'content-type': 'application/json', accept: 'application/json' }, JSON.stringify(body), 10000)
|
|
247
|
+
let json = null
|
|
248
|
+
try { json = r.body ? JSON.parse(r.body) : null } catch { json = null }
|
|
249
|
+
return { ...r, json }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The app as UKNOWS shows it in the browser: only text fields that are set. */
|
|
253
|
+
function clean(app) {
|
|
254
|
+
const out = {}
|
|
255
|
+
for (const key of ['name', 'repo', 'platform']) if (typeof app[key] === 'string' && app[key].trim()) out[key] = app[key].trim().slice(0, 200)
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const minutes = (seconds) => (seconds % 60 === 0 ? `${seconds / 60} ${seconds === 60 ? 'minute' : 'minutes'}` : `${Math.round(seconds)} seconds`)
|