@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/check.mjs CHANGED
@@ -12,7 +12,7 @@ import { randomBytes } from 'node:crypto'
12
12
  import { readFileSync } from 'node:fs'
13
13
  import http from 'node:http'
14
14
  import https from 'node:https'
15
- import { BASH_STEP, CLI, KEY_CHECK, KEY_PROMPT, SECRET, TROUBLESHOOTING } from './contract.mjs'
15
+ import { CHECK_NETWORK, CLI, KEY_CHECK, SECRET, SETUP_NEXT, TROUBLESHOOTING, keyCheckLine } from './contract.mjs'
16
16
  import { ingestEndpoint, sameUknows, siteUrl } from './endpoint.mjs'
17
17
 
18
18
  const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
@@ -22,18 +22,66 @@ const ACCEPTED = 'UKNOWS accepted 1 test span.'
22
22
  /** What each refusal of the ping route means, from the docs list ("ingest_key_invalid" and so on). */
23
23
  const MEANING = new Map(KEY_CHECK.answers.map((a) => [/^[a-z_]+$/.test(a.answer) ? a.answer : null, a.meaning]).filter(([code]) => code))
24
24
 
25
+ /** The key variable of apps that keep their own metrics and logs. check reads it when OTEL_EXPORTER_OTLP_HEADERS is not set. */
26
+ const TRACES_HEADERS = 'OTEL_EXPORTER_OTLP_TRACES_HEADERS'
27
+
28
+ /** A timeout gets 1 more try, for this many times as long: UKNOWS can take about 16 seconds to answer after a quiet time. */
29
+ const RETRY_FACTOR = 3
30
+
25
31
  /**
26
- * Check the key in OTEL_EXPORTER_OTLP_HEADERS from env (or options.headers), then send 1 test span.
27
- * @param {{env?: Record<string, string|undefined>, headers?: string, timeoutMs?: number}} [options]
32
+ * A new key: one that UKNOWS made a moment ago. That is the key of the sign-in of this run, or a key that the site says
33
+ * is under 5 minutes old (createdAt in its answer). The site knows a new key at once. The data intake (the ingest
34
+ * address) learns new keys by itself, about every 30 seconds (the key sync of its gateway; measured 26 Oct 2026: about
35
+ * 30 seconds). So for a new key, a 401 means "not ready yet": the check asks again, every 5 seconds for the first 30
36
+ * seconds and then every 10 seconds, for up to 2 minutes, with 1 line that says so. A pasted or older key gets 1 try.
37
+ */
38
+ export const NEW_KEY = {
39
+ waitMs: 120000, fastMs: 30000, fastEveryMs: 5000, everyMs: 10000, youngMs: 300000,
40
+ /** The line while the check waits. On a terminal, the time since the wait began follows it, for example 0:35. */
41
+ waiting: 'UKNOWS is getting your new key ready on the data intake...',
42
+ /** The same in a pipe or a log, printed once. */
43
+ waitingPlain: 'UKNOWS is getting your new key ready on the data intake. This can take up to 2 minutes.',
44
+ ready: (took) => `Your new key is ready on the data intake. It took ${took}.`,
45
+ /** After 2 minutes. where: " for Your Company, environment Production" when the site named them, else "". */
46
+ late: (where, siteKnows) => (siteKnows
47
+ ? `UKNOWS made your new key${where}, but its data intake did not take the key within 2 minutes. This is on the UKNOWS side, not in your app.`
48
+ : 'UKNOWS did not get your new key ready within 2 minutes. This is on the UKNOWS side, not in your app.'),
49
+ /** The last line after that: of a run with 1 Yes, of the key command, and of a run that changed the files already. */
50
+ again: 'Run npx @uknows/setup again in a few minutes. Nothing changed.',
51
+ againKey: 'Run npx @uknows/setup key again in a few minutes. Nothing changed.',
52
+ againFiles: 'Next: in a few minutes, run npx @uknows/setup key. It signs you in again, checks the new key and stores it.',
53
+ }
54
+
55
+ /** Time as the person reads it: 35 seconds, 1 minute 5 seconds, 2 minutes. */
56
+ export function duration(ms) {
57
+ const s = Math.max(0, Math.round(ms / 1000)), m = Math.floor(s / 60), rest = s % 60
58
+ const part = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
59
+ return m ? `${part(m, 'minute')}${rest ? ` ${part(rest, 'second')}` : ''}` : part(rest, 'second')
60
+ }
61
+
62
+ /** The clock of a real check. Tests pass one whose time moves only when something sleeps. */
63
+ const SYSTEM_CLOCK = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))) }
64
+
65
+ /**
66
+ * Check the key in OTEL_EXPORTER_OTLP_HEADERS (or OTEL_EXPORTER_OTLP_TRACES_HEADERS) from env, or options.headers,
67
+ * then send 1 test span. serviceName: the name of the service that setup found, for the test span. A request with no
68
+ * answer in timeoutMs is tried once more, for up to 3 times as long. onRetry(line) gets 1 line that says so, before it.
69
+ * newKey: the key came from the sign-in of this run, so a 401 waits for it (NEW_KEY), with clock (now and sleep).
70
+ * onWait() is called once, when that wait begins. It returns end(line): line is NEW_KEY.ready when the key works after
71
+ * the wait, else null. A new key that still gets 401 after 2 minutes gives notReady: true and the NEW_KEY.late line.
72
+ * @param {{env?: Record<string, string|undefined>, headers?: string, timeoutMs?: number, serviceName?: string|null,
73
+ * onRetry?: (line: string) => void, newKey?: boolean, clock?: {now: () => number, sleep: (ms: number) => Promise<void>},
74
+ * onWait?: () => ((line: string|null) => void)}} [options]
28
75
  * @returns {Promise<import('./types.mjs').CheckResult>}
29
76
  */
30
77
  export async function runCheck(options = {}) {
31
- const { env = process.env, timeoutMs = 10000 } = options
32
- const raw = options.headers ?? env[SECRET.name]
78
+ const { env = process.env, timeoutMs = 10000, onRetry = () => {}, clock = SYSTEM_CLOCK, onWait = () => () => {} } = options
79
+ const ask = (url, method, headers, body) => requestWithRetry(url, method, headers, body, timeoutMs, onRetry)
80
+ const raw = options.headers ?? (env[SECRET.name]?.trim() ? env[SECRET.name] : env[TRACES_HEADERS])
33
81
  if (!raw || !raw.trim()) {
34
- return result(2, null, [`${SECRET.name} is not set in this terminal.`, BASH_STEP,
35
- 'Run these 3 lines. Paste your key when it asks. The key does not show on the screen.',
36
- ` ${KEY_PROMPT}`, ` export ${SECRET.name}="${SECRET.header}=$UKNOWS_KEY"`, ' npx @uknows/setup check'])
82
+ // 1 line, safe to paste at once: read takes the key from the next line, and nothing stays set in the terminal.
83
+ const vars = [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
84
+ return result(2, null, [`${SECRET.name} is not set in this terminal.`, SETUP_NEXT.skipped, ` ${keyCheckLine(vars)}`, CHECK_NETWORK.where])
37
85
  }
38
86
  const headers = parseHeaders(raw)
39
87
  // Hide each header value as written and as decoded. A part without '=' may be a bare key: hide it whole.
@@ -47,22 +95,51 @@ export async function runCheck(options = {}) {
47
95
  const site = siteUrl(env)
48
96
  if (!site) return result(1, null, [`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`])
49
97
 
50
- // 1. Which workspace and environment the key sends to.
51
98
  let ping = null
52
99
  const lines = [], after = []
100
+ // A new key waits while UKNOWS gets it ready (NEW_KEY): 1 wait for both steps, with 1 line.
101
+ const wait = waiter(clock, onWait)
102
+ let fresh = Boolean(options.newKey)
103
+ const late = (siteKnows) => {
104
+ wait.end(null)
105
+ const where = ping?.workspace ? ` for ${ping.workspace}${ping.environment ? `, environment ${ping.environment}` : ''}` : ''
106
+ return { ...result(1, 401, [NEW_KEY.late(where, siteKnows)]), ...named(ping), notReady: true }
107
+ }
108
+
109
+ // 1. Which workspace and environment the key sends to.
53
110
  if (sameUknows(env)) {
54
- const answer = await request(new URL(`${site}${CLI.pingPath}`), 'GET', { [SECRET.header]: headers[keyName] }, null, timeoutMs)
55
- ping = explainPing(answer, site, redact)
56
- if (ping.stop) return { ...result(1, answer.status ?? null, ping.lines), keyState: ping.keyState ?? null }
111
+ const pingUrl = new URL(`${site}${CLI.pingPath}`)
112
+ const askPing = () => ask(pingUrl, 'GET', { [SECRET.header]: headers[keyName] }, null)
113
+ let answer = await askPing()
114
+ // A key the site does not know yet. Revoked, paused and the other answers are no wait.
115
+ const unknown = (a) => a.status === 401 && parse(a.body)?.error === 'ingest_key_invalid'
116
+ while (fresh && unknown(answer) && await wait.more()) answer = await askPing()
117
+ if (fresh && unknown(answer)) return late(false)
118
+ ping = explainPing(answer, site, redact, clock.now())
119
+ if (ping.stop) {
120
+ wait.end(null)
121
+ return { ...result(1, answer.status ?? null, ping.lines), keyState: ping.keyState ?? null }
122
+ }
57
123
  lines.push(...ping.lines)
124
+ fresh ||= typeof ping.ageMs === 'number' && ping.ageMs < NEW_KEY.youngMs
58
125
  } else {
59
126
  after.push(`To also see which workspace the key sends to, set ${CLI.siteEnv} with ${CLI.endpointEnv}.`)
60
127
  }
61
128
 
62
- // 2. 1 test span to the ingest address.
129
+ // 2. 1 test span to the ingest address. A new key that the data intake does not know yet gets 401 there.
63
130
  const url = new URL(`${endpoint}/v1/traces`)
64
- const response = await request(url, 'POST', { ...headers, 'content-type': 'application/json' }, testSpan(env), timeoutMs)
65
- if (response.error) return { ...result(1, null, [...lines, ...explainNetworkError(response.error, url, timeoutMs, env).map(redact), ...after]), ...named(ping) }
131
+ const send = () => ask(url, 'POST', { ...headers, 'content-type': 'application/json' }, testSpan(env, options.serviceName))
132
+ let response = await send()
133
+ while (fresh && response.status === 401 && await wait.more()) response = await send()
134
+ if (fresh && response.status === 401) return late(true)
135
+ wait.end(wait.started && response.status >= 200 && response.status < 300 ? NEW_KEY.ready(duration(wait.elapsed())) : null)
136
+ if (response.error) {
137
+ // The site said the key works, but the upload to the ingest address failed: say both plainly, with the next step.
138
+ // After a second timeout, the advice names the longer wait.
139
+ const keyFine = Boolean(ping?.workspace) && !CERT.test(response.error?.code ?? '')
140
+ const waited = timedOut(response.error) ? timeoutMs * RETRY_FACTOR : timeoutMs
141
+ return { ...result(1, null, [...lines, ...(keyFine ? [CHECK_NETWORK.keyFine] : []), ...explainNetworkError(response.error, url, waited, env).map(redact), ...after]), ...named(ping) }
142
+ }
66
143
  const sent = explainResponse(response, redact)
67
144
  // Without the site's answer, the accepted span is what shows that the key works.
68
145
  const spanLines = sent.lines.map((line) => (line === ACCEPTED && !ping?.workspace ? `${ACCEPTED} Your key works.` : line))
@@ -74,8 +151,11 @@ function named(ping, sent = {}) {
74
151
  return { workspace: sent.workspace ?? ping?.workspace ?? null, environment: sent.environment ?? ping?.environment ?? null, keyState: ping?.keyState ?? null }
75
152
  }
76
153
 
77
- /** What the ping route said. stop: true when the key is refused, so no span is sent. */
78
- export function explainPing({ status, error, body }, site, redact = (x) => x) {
154
+ /**
155
+ * What the ping route said. stop: true when the key is refused, so no span is sent. ageMs: how old the key is, when
156
+ * the answer says when it was made (createdAt), measured at now. null when it does not say.
157
+ */
158
+ export function explainPing({ status, error, body }, site, redact = (x) => x, now = Date.now()) {
79
159
  const host = new URL(site).hostname
80
160
  if (error) return { stop: false, lines: [`Could not ask ${host} which workspace the key sends to (${error.code || 'network error'}). Sending the test span anyway.`] }
81
161
  const data = parse(body)
@@ -85,7 +165,8 @@ export function explainPing({ status, error, body }, site, redact = (x) => x) {
85
165
  const lines = [`Your key works. It sends data to ${workspace}${environment ? `, environment ${environment}` : ''}.`]
86
166
  if (data.keyState === 'retiring') lines.push(`A new key replaced this key. It stops working${data.retiresAt ? ` at ${clean(String(data.retiresAt), redact)}` : ' soon'}. Set the new key.`)
87
167
  if (data.status === 'paused') lines.push('Your workspace is paused.')
88
- return { stop: false, workspace, environment, keyState: clean(data.keyState, redact), lines }
168
+ const made = typeof data.createdAt === 'string' ? Date.parse(data.createdAt) : NaN
169
+ return { stop: false, workspace, environment, keyState: clean(data.keyState, redact), ageMs: Number.isFinite(made) ? Math.max(0, now - made) : null, lines }
89
170
  }
90
171
  const code = typeof data?.error === 'string' ? data.error : null
91
172
  if ((status === 401 || status === 403) && code) {
@@ -108,30 +189,45 @@ export function parseHeaders(value) {
108
189
  return out
109
190
  }
110
191
 
111
- /** What to print for a network error. Exported for tests: DNS and TLS failures are hard to cause offline. */
192
+ /**
193
+ * What to print for a network error: what failed, with the host and port, and what to do. The same words as the
194
+ * answers in "Check your key" (CHECK_NETWORK). Exported for tests: DNS and TLS failures are hard to cause offline.
195
+ */
112
196
  export function explainNetworkError(error, url, timeoutMs, env = {}) {
113
197
  const code = error?.code ?? ''
114
198
  const port = url.port || (url.protocol === 'https:' ? '443' : '80')
115
199
  const host = url.hostname
116
200
  const proxy = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => env[k])
117
201
  if (CERT.test(code)) return [`Cannot check the TLS certificate of ${host} (${code}).`, 'A proxy may be in the way. This tool does not use HTTPS_PROXY.']
202
+ if (/^ERR_INVALID_(?:CHAR|HTTP_TOKEN)/.test(code)) return [`${SECRET.name} has a character that is not allowed in a header.`]
118
203
  const seconds = Math.round(timeoutMs / 100) / 10
119
- const lines = [
120
- code === 'ENOTFOUND' || code === 'EAI_AGAIN' ? `Cannot find ${host}. Check DNS.`
121
- : code === 'ECONNREFUSED' ? `${host} refused the connection on port ${port}.`
122
- : code === 'UKNOWS_TIMEOUT' || code === 'ETIMEDOUT' ? `No answer in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}.`
123
- : code === 'ECONNRESET' || code === 'EPIPE' ? `The connection to ${host} closed before UKNOWS answered.`
124
- : code === 'EHOSTUNREACH' || code === 'ENETUNREACH' ? `Cannot reach ${host}. Check your network.`
125
- : /^ERR_INVALID_(?:CHAR|HTTP_TOKEN)/.test(code) ? `${SECRET.name} has a character that is not allowed in a header.`
126
- : `Cannot reach ${host} (${code || 'unknown error'}).`,
127
- ]
128
- if (proxy) lines.push('This tool does not use HTTPS_PROXY. Run the check on a network that can reach UKNOWS directly.')
204
+ const values = { host, port, seconds: seconds === 1 ? '1' : String(seconds), code: code || 'unknown error' }
205
+ const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
206
+ const dns = code === 'ENOTFOUND' || code === 'EAI_AGAIN'
207
+ const what = dns ? CHECK_NETWORK.dns : code === 'ECONNREFUSED' ? CHECK_NETWORK.refused
208
+ : code === 'UKNOWS_TIMEOUT' || code === 'ETIMEDOUT' ? CHECK_NETWORK.timeout.replace('{seconds} seconds', seconds === 1 ? '1 second' : '{seconds} seconds')
209
+ : code === 'ECONNRESET' || code === 'EPIPE' ? CHECK_NETWORK.reset
210
+ : code === 'EHOSTUNREACH' || code === 'ENETUNREACH' ? CHECK_NETWORK.unreachable : CHECK_NETWORK.other
211
+ const lines = [fill(what), ...(dns ? [] : [fill(CHECK_NETWORK.ask)]), CHECK_NETWORK.where]
212
+ if (proxy) lines.push(CHECK_NETWORK.proxy)
129
213
  return lines
130
214
  }
131
215
 
132
216
  // --- request and response ---------------------------------------------------------
133
217
 
134
- function request(url, method, headers, body, timeoutMs) {
218
+ const timedOut = (error) => error?.code === 'UKNOWS_TIMEOUT' || error?.code === 'ETIMEDOUT'
219
+ const secondsOf = (ms) => { const s = Math.round(ms / 100) / 10; return s === 1 ? '1 second' : `${s} seconds` }
220
+
221
+ /** 1 request. With no answer in timeoutMs, 1 more try for up to RETRY_FACTOR times as long, after 1 line to onRetry. */
222
+ async function requestWithRetry(url, method, headers, body, timeoutMs, onRetry) {
223
+ const first = await request(url, method, headers, body, timeoutMs)
224
+ if (!timedOut(first.error)) return first
225
+ onRetry(`No answer from ${url.hostname} in ${secondsOf(timeoutMs)}. Trying again, for up to ${secondsOf(timeoutMs * RETRY_FACTOR)}.`)
226
+ return request(url, method, headers, body, timeoutMs * RETRY_FACTOR)
227
+ }
228
+
229
+ /** 1 HTTP request with node:http or node:https. Resolves {status, headers, body} or {error}. Never rejects. */
230
+ export function request(url, method, headers, body, timeoutMs) {
135
231
  return new Promise((resolve) => {
136
232
  let done = false, timer = null, req
137
233
  const finish = (value) => { if (!done) { done = true; clearTimeout(timer); resolve(value) } }
@@ -165,7 +261,7 @@ function explainResponse({ status, headers, body }, redact) {
165
261
  }
166
262
  const steps = (id) => TROUBLESHOOTING.find((t) => t.id === id)?.steps ?? []
167
263
  if (status === 401) return result(1, status, [`UKNOWS did not accept the key (status 401).`, ...steps('unauthorized')])
168
- if (status === 403) return result(1, status, [`UKNOWS refused the key (status 403).`, ...steps('forbidden')])
264
+ if (status === 403) return result(1, status, [`UKNOWS refused the data (status 403). ${TROUBLESHOOTING.find((t) => t.id === 'forbidden')?.cause ?? ''}`.trim(), ...steps('forbidden')])
169
265
  const textual = !data && /json|text/i.test(String(headers['content-type'] ?? ''))
170
266
  const message = clean(typeof data?.message === 'string' ? data.message : textual ? body : null, redact)
171
267
  const lines = [`UKNOWS answered with status ${status}.`]
@@ -175,11 +271,11 @@ function explainResponse({ status, headers, body }, redact) {
175
271
  }
176
272
 
177
273
  /** 1 span in OTLP/JSON: hex ids, integer enums, int64 values as decimal strings. */
178
- function testSpan(env) {
274
+ function testSpan(env, serviceName = null) {
179
275
  const hex = (bytes) => { let id; do id = randomBytes(bytes).toString('hex'); while (/^0+$/.test(id)); return id }
180
276
  const start = BigInt(Date.now()) * 1000000n
181
277
  return JSON.stringify({ resourceSpans: [{
182
- resource: { attributes: [{ key: 'service.name', value: { stringValue: env.OTEL_SERVICE_NAME || 'uknows-setup-check' } }] },
278
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: env.OTEL_SERVICE_NAME || serviceName || 'uknows-setup-check' } }] },
183
279
  scopeSpans: [{ scope: { name: '@uknows/setup', version: VERSION }, spans: [{
184
280
  traceId: hex(16), spanId: hex(8), name: 'uknows setup check', kind: 1,
185
281
  startTimeUnixNano: String(start), endTimeUnixNano: String(start + 1000000n),
@@ -190,6 +286,26 @@ function testSpan(env) {
190
286
 
191
287
  // --- helpers ------------------------------------------------------------------------
192
288
 
289
+ /**
290
+ * The wait for a new key (NEW_KEY). more() starts it at the first call (onWait() shows its line), sleeps until the next
291
+ * try, and is false once 2 minutes have passed since the start. end(line) ends the line once, with line or nothing.
292
+ */
293
+ function waiter(clock, onWait) {
294
+ let started = null, finish = null
295
+ return {
296
+ get started() { return started !== null },
297
+ elapsed: () => (started === null ? 0 : clock.now() - started),
298
+ async more() {
299
+ if (started === null) { started = clock.now(); finish = onWait() ?? (() => {}) }
300
+ const waited = clock.now() - started
301
+ if (waited >= NEW_KEY.waitMs) return false
302
+ await clock.sleep(Math.min(waited < NEW_KEY.fastMs ? NEW_KEY.fastEveryMs : NEW_KEY.everyMs, NEW_KEY.waitMs - waited))
303
+ return true
304
+ },
305
+ end(line) { if (finish) { const done = finish; finish = null; done(line) } },
306
+ }
307
+ }
308
+
193
309
  function result(exit, status, lines) {
194
310
  return { ok: false, status, workspace: null, environment: null, lines, exit }
195
311
  }
package/lib/ci.mjs ADDED
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Does this run in a pipeline? Setup and remove change a repo once, on a
3
+ * computer, so in a pipeline they stop with exit 2 unless --allow-ci is set.
4
+ * The variables come from contract.mjs CLI.ciEnv.
5
+ */
6
+ import { CLI } from './contract.mjs'
7
+
8
+ /**
9
+ * The name of the first CI variable that is set, or null. CI counts when it is not empty, 0 or false.
10
+ * @param {Record<string, string|undefined>} [env]
11
+ * @returns {string|null}
12
+ */
13
+ export function ciVariable(env = {}) {
14
+ for (const name of CLI.ciEnv) {
15
+ const value = String(env[name] ?? '').trim()
16
+ if (!value) continue
17
+ if (name === 'CI' && /^(?:0|false)$/i.test(value)) continue
18
+ return name
19
+ }
20
+ return null
21
+ }
package/lib/cli.mjs CHANGED
@@ -5,59 +5,117 @@
5
5
  import { statSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
7
  import { runCheck } from './check.mjs'
8
- import { CLI, DOCS_URL, INGEST_ENDPOINT, SECRET, SITE_URL } from './contract.mjs'
8
+ import { CI_CD, CLI, DEFAULT_AGENT_REGISTRY, DOCS_URL, SECRET, SETUP_NEEDS } from './contract.mjs'
9
9
  import { toServiceName } from './detect.mjs'
10
- import { ingestEndpoint, siteUrl } from './endpoint.mjs'
11
- import { runRemove, runSetup } from './engine.mjs'
12
- import { TOOL, VERSION } from './manifest.mjs'
10
+ import { ingestEndpoint, siteUrl, validAppUrl } from './endpoint.mjs'
11
+ import { CHANNELS, validRegistry } from './stacks/channel.mjs'
12
+ import { UPDATE_BOTS } from './updates.mjs'
13
+ import { runKey, runRemove, runSetup } from './engine.mjs'
14
+ import { TOOL, VERSION, readManifest } from './manifest.mjs'
13
15
  import { UsageError, createUI } from './ui.mjs'
14
16
 
15
- const FLAGS = { '--dry-run': 'dryRun', '--yes': 'yes', '-y': 'yes', '--all': 'all', '--json': 'json', '--force': 'force',
16
- '--check': 'check', '--help': 'help', '-h': 'help', '--version': 'version' }
17
- const VALUES = { '--path': 'path', '--service-name': 'serviceName' }
18
- const COMMANDS = { setup: ['dryRun', 'yes', 'path', 'all', 'json', 'serviceName', 'force'], remove: ['yes', 'path', 'json', 'force'], check: ['json'] }
17
+ const FLAGS = { '--dry-run': 'dryRun', '--yes': 'yes', '-y': 'yes', '--all': 'all', '--json': 'json', '--force': 'force', '--updates': 'updates',
18
+ '--allow-ci': 'allowCi', '--check': 'check', '--help': 'help', '-h': 'help', '--version': 'version', '--step-by-step': 'stepByStep', '--paste-key': 'pasteKey',
19
+ '--functions-opentelemetry': 'functionsOpenTelemetry' }
20
+ const VALUES = { '--path': 'path', '--service-name': 'serviceName', '--channel': 'channel', '--update-bot': 'updateBot' }
21
+ const COMMANDS = { setup: ['dryRun', 'yes', 'path', 'all', 'json', 'serviceName', 'force', 'allowCi', 'stepByStep', 'pasteKey', 'channel', 'updates', 'updateBot', 'functionsOpenTelemetry'], key: ['path', 'pasteKey'],
22
+ remove: ['yes', 'path', 'json', 'force', 'allowCi'], check: ['json'] }
23
+ /** Flags that only mean something when setup asks questions in a terminal. */
24
+ const ASKS = { stepByStep: '--step-by-step', pasteKey: '--paste-key' }
19
25
 
20
- export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces to UKNOWS.
26
+ export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces and logs to UKNOWS.
21
27
 
22
28
  Usage:
23
29
  npx @uknows/setup [setup] [options] Find your app, show the plan, then apply it.
30
+ npx @uknows/setup key [options] Check and store the key of another environment, for example Production.
24
31
  npx @uknows/setup remove [options] Undo what setup changed.
25
32
  npx @uknows/setup check Send 1 test span with the key in ${SECRET.name}.
26
33
 
27
34
  Options:
28
35
  --dry-run Show the plan. Change nothing.
29
36
  --yes, -y Apply without asking. Never asks any question.
30
- --path <folder> The folder to set up. The default is this folder.
37
+ --step-by-step Ask before each step, instead of 1 question for the whole plan.
38
+ --paste-key Paste your key instead of signing in to UKNOWS in your browser.
39
+ --path <folder> The folder to set up. The default is this folder. key takes only this option and --paste-key.
31
40
  --all Set up every service folder. Each one gets its own OTEL_SERVICE_NAME.
32
41
  --service-name <name> The name of the service in UKNOWS.
42
+ --channel <name> Java, .NET and Node.js apps with a Dockerfile: copy the UKNOWS agent from the
43
+ agent image of a channel, stable or early, at each build. Then a new agent
44
+ reaches the app at its next build, with no change to your repo.
45
+ --update-bot <name> How the OpenTelemetry packages of your app stay up to date: renovate,
46
+ dependabot (GitHub only, with a workflow that merges the updates and starts
47
+ the deploy) or none. Without it, setup picks, as below.
48
+ --functions-opentelemetry
49
+ Azure Functions apps: turn on OpenTelemetry in host.json, instead of the UKNOWS hooks
50
+ that setup adds when the app sends to Application Insights or it cannot look.
33
51
  --json Print a JSON report. Without --yes, setup only plans.
34
52
  --force Run even when the repo has changes that are not committed.
53
+ --allow-ci Apply in a pipeline. Only for a bot or an AI agent that opens a pull request.
35
54
  --check The same as the check command.
36
55
  --help, -h Show this help.
37
56
  --version Show the version.
38
57
 
39
- Exit codes:
40
- 0 Done, or nothing to do.
41
- 1 Stopped or failed. Nothing changed.
42
- 2 Usage error, or you need to pick a service.
43
- 3 You need to do 1 or more steps by hand.
58
+ Setup keeps the UKNOWS agent up to date by itself, and the plan says how and why:
59
+ 1. Java, .NET and Node.js apps that build from a Dockerfile get the agent channel: each build
60
+ copies the newest stable agent, so no update pull request is needed.
61
+ 2. Other apps get Renovate when your repo uses Renovate already.
62
+ 3. Else Dependabot on GitHub. On GitLab, Azure DevOps or Bitbucket, setup adds renovate.json
63
+ and says how to add Renovate in 1 step.
64
+ --update-bot and --channel choose instead.
65
+
66
+ Run setup once on your computer, not in your pipeline. In a pipeline, setup and remove
67
+ change nothing and exit 2, or exit 0 when the repo is already set up. --dry-run, a --json plan,
68
+ check, --help and --version still work.
69
+ To check in a pipeline that a repo is set up ("status": "nothing-to-change", "exitCode": 0):
70
+ ${CI_CD.snippet.code}
71
+
72
+ In a terminal, setup opens your browser so you sign in to UKNOWS and pick the workspace and
73
+ the environment. Your key then comes to setup by itself. Setup checks it, then shows the whole
74
+ plan and asks 1 question: Do all of this? (Y/n). On a yes, it changes the files and also:
75
+ Stores the key on your platform with az, gh or kubectl, for the environment of the key.
76
+ Adds the key to a deploy file that would remove it on the next deploy.
77
+ Creates a branch, commits the files that setup changed, pushes it and opens a pull request.
78
+ To show this plan, it reads your Azure subscriptions, your GitHub repo or your kubectl
79
+ contexts. It changes nothing there until you say yes. With --step-by-step, it asks before each
80
+ of these steps instead, after the change to the files.
81
+ Then it opens the page where you watch your app connect. With --yes or --json, in a pipeline
82
+ or without a terminal, it does none of these and prints the steps for you.
83
+
84
+ Exit codes of setup and remove (they describe your repo):
85
+ 0 Done, already set up, or nothing to do. Only the key steps on your platform are left.
86
+ Merging the pull request and deploying are left too.
87
+ 1 Stopped or failed. Nothing changed. For example, no app found or another APM agent.
88
+ In a terminal, also when UKNOWS refuses the key. With --step-by-step, the changes to your repo stay.
89
+ 2 Needs your input. Nothing changed. For example, pick a service, or a pipeline without --allow-ci.
90
+ 3 Done, but you must do 1 or more steps in your repo before traces flow.
91
+ Ctrl+C while setup waits for your sign-in stops it with exit code 130.
92
+ check exits 0 when UKNOWS takes the test span, 1 when UKNOWS refuses the key or cannot be
93
+ reached, and 2 when ${SECRET.name} is not set.
94
+ key exits 0 when it stored the key or printed its steps, 1 when the folder is not set up or UKNOWS
95
+ refuses the key or cannot be reached, and 2 without a terminal or a key.
96
+
97
+ You need on this computer (setup updates the lock file of your app with its own tools):
98
+ ${SETUP_NEEDS.map((line) => ` ${line}`).join('\n')}
44
99
 
45
100
  Environment:
46
101
  ${SECRET.name}
47
102
  Your key, for check, as ${SECRET.value}. Set it only in your terminal.
48
- ${CLI.siteEnv}
49
- A test UKNOWS site, for check and the docs links. The default is ${SITE_URL}.
50
- ${CLI.endpointEnv}
51
- A test ingest address, for setup and check. The default is ${INGEST_ENDPOINT}.
103
+ ${CLI.agentRegistryEnv}
104
+ Another store of the UKNOWS agent images for the agent channel, as a registry and path, for example
105
+ registry.example.com/uknows.
52
106
 
53
- After setup, the tool asks for your key to check it. The key does not show on the screen.
54
- This tool never writes your key to a file.
107
+ In a terminal, the tool gets your key when you sign in, or uses the key in ${SECRET.name}.
108
+ With --paste-key, it asks you to paste it. The key does not show on the screen.
109
+ With --yes in a terminal, it checks the key in ${SECRET.name} if it is set.
110
+ This tool never writes your key to a file. To store it, az, gh and kubectl read it from
111
+ a pipe, so it is in no command line.
55
112
  Docs: ${DOCS_URL}
56
113
  `
57
114
 
58
- /** @returns {{command: 'setup'|'remove'|'check', dryRun: boolean, yes: boolean, all: boolean, json: boolean, force: boolean, help: boolean, version: boolean, path: string|null, serviceName: string|null}} */
115
+ /** @returns {{command: 'setup'|'key'|'remove'|'check', dryRun: boolean, yes: boolean, all: boolean, json: boolean, force: boolean, allowCi: boolean, help: boolean, version: boolean, path: string|null, serviceName: string|null, stepByStep: boolean, pasteKey: boolean, channel: string|null, updates: boolean, updateBot: string|null}} */
59
116
  export function parseArgs(argv) {
60
- const opts = { command: null, dryRun: false, yes: false, all: false, json: false, force: false, help: false, version: false, path: null, serviceName: null }
117
+ const opts = { command: null, dryRun: false, yes: false, all: false, json: false, force: false, allowCi: false, help: false, version: false, path: null, serviceName: null,
118
+ stepByStep: false, pasteKey: false, channel: null, updates: false, updateBot: null, functionsOpenTelemetry: false }
61
119
  const given = []
62
120
  let check = false
63
121
  for (let i = 0; i < argv.length; i++) {
@@ -87,17 +145,27 @@ export function parseArgs(argv) {
87
145
  } else throw new UsageError(`Unknown option: ${flag}.`)
88
146
  }
89
147
  if (check && opts.command && opts.command !== 'check') throw new UsageError(`--check does not work with ${opts.command}.`)
148
+ if (opts.channel !== null && !CHANNELS.includes(opts.channel)) throw new UsageError(`--channel is ${CHANNELS.join(' or ')}.`)
149
+ if (opts.updateBot !== null) {
150
+ if (!UPDATE_BOTS.includes(opts.updateBot)) throw new UsageError(`--update-bot is ${UPDATE_BOTS.slice(0, -1).join(', ')} or ${UPDATE_BOTS.at(-1)}.`)
151
+ opts.updates = true
152
+ }
90
153
  opts.command = check ? 'check' : opts.command ?? 'setup'
91
154
  for (const [key, flag] of given) {
92
155
  if (!COMMANDS[opts.command].includes(key)) throw new UsageError(`${flag} does not work with ${opts.command}.`)
93
156
  }
157
+ // --yes and --json never ask, and a dry run asks for no key: the flags that change the questions do not fit them.
158
+ for (const [key, flag] of Object.entries(ASKS)) {
159
+ const other = opts.yes ? '--yes' : opts.json ? '--json' : opts.dryRun && key === 'pasteKey' ? '--dry-run' : null
160
+ if (opts[key] && other) throw new UsageError(`${flag} does not work with ${other}.`)
161
+ }
94
162
  return opts
95
163
  }
96
164
 
97
165
  /**
98
166
  * Run the CLI. Returns the exit code.
99
167
  * @param {string[]} argv
100
- * @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, deps?: object}} [io]
168
+ * @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, terminal?: boolean, deps?: object}} [io]
101
169
  */
102
170
  export async function main(argv, io = {}) {
103
171
  const { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, deps = {} } = io
@@ -116,12 +184,25 @@ export async function main(argv, io = {}) {
116
184
  }
117
185
  if (args.help) { stdout.write(HELP); return 0 }
118
186
  if (args.version) { stdout.write(`${VERSION}\n`); return 0 }
119
- if (args.command === 'check') return check(args, { stdout, stderr, env }, deps.runCheck ?? runCheck)
187
+ if (args.command === 'check') return check(args, { stdout, stderr, env, cwd }, deps.runCheck ?? runCheck)
188
+ const run = { setup: runSetup, key: runKey, remove: runRemove }[args.command]
120
189
 
121
190
  // setup writes the same ingest address that check sends to.
122
191
  const endpoint = ingestEndpoint(env)
123
192
  if (!endpoint) return usage(`${CLI.endpointEnv} must be an https address.`)
124
193
  if (!siteUrl(env)) return usage(`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`)
194
+ if (!validAppUrl(env)) return usage(`${CLI.appEnv} must be an https address with no path, for example https://app.uknows.example.com.`)
195
+ // The agent channel: the store of the UKNOWS agent images, UKNOWS_AGENT_REGISTRY or the one of this release.
196
+ const given = String(env[CLI.agentRegistryEnv] ?? '').trim().replace(/\/+$/, '')
197
+ if (given && !validRegistry(given)) return usage(`${CLI.agentRegistryEnv} must be the registry and path of the UKNOWS agent images, for example registry.example.com/uknows.`)
198
+ const registry = given || (validRegistry(DEFAULT_AGENT_REGISTRY) ? DEFAULT_AGENT_REGISTRY : null)
199
+ let channel = null
200
+ if (args.channel) {
201
+ if (!registry) return usage(`--channel needs ${CLI.agentRegistryEnv}: the registry and path of the UKNOWS agent images, for example registry.example.com/uknows.`)
202
+ channel = { name: args.channel, registry }
203
+ }
204
+ // The default (updates.mjs, step 1): the stable channel for the apps that fit, unless --update-bot or --channel chose.
205
+ const defaultChannel = !args.channel && !args.updateBot && registry ? { name: 'stable', registry } : null
125
206
  const root = resolve(cwd, args.path ?? '.')
126
207
  let folder = false
127
208
  try { folder = statSync(root).isDirectory() } catch { /* missing */ }
@@ -129,9 +210,9 @@ export async function main(argv, io = {}) {
129
210
  if (args.serviceName !== null && (deps.toServiceName ?? toServiceName)(args.serviceName) !== args.serviceName) {
130
211
  return usage(`${args.serviceName} is not a valid service name. Use lowercase letters, digits and -, up to 63 characters.`)
131
212
  }
132
- const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive })
213
+ const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive, terminal: io.terminal, env })
133
214
  try {
134
- const { code, report } = await (args.command === 'remove' ? runRemove : runSetup)({ ...args, root, endpoint, env }, { ...deps, ui })
215
+ const { code, report } = await run({ ...args, channel, defaultChannel, root, endpoint, env }, { ...deps, ui })
135
216
  if (args.json) stdout.write(`${JSON.stringify(report, null, 2)}\n`)
136
217
  return code
137
218
  } finally {
@@ -139,8 +220,12 @@ export async function main(argv, io = {}) {
139
220
  }
140
221
  }
141
222
 
142
- async function check(args, { stdout, stderr, env }, run) {
143
- const result = await run({ env })
223
+ async function check(args, { stdout, stderr, env, cwd }, run) {
224
+ // The test span carries the service name that setup recorded here, so the workspace never lists a made-up app.
225
+ let serviceName = null
226
+ try { serviceName = readManifest(cwd)?.services?.[0]?.name ?? null } catch { /* not valid: no name */ }
227
+ // The retry line goes to stderr, so --json stays 1 JSON document on stdout.
228
+ const result = await run({ env, serviceName, onRetry: (line) => stderr.write(`${line}\n`) })
144
229
  const status = result.exit === 2 ? 'no-key' : result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
145
230
  const lines = redact(result.lines, env[SECRET.name])
146
231
  if (args.json) {