@uknows/setup 0.0.0-stage → 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 (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. package/package.json +37 -4
@@ -0,0 +1,455 @@
1
+ /*
2
+ * Read and edit Dockerfiles as text. Only the final stage matters: it is the
3
+ * image that runs. Edits keep every other line byte for byte (see doc.mjs).
4
+ * Handles line continuations, comment lines inside them, heredoc bodies and
5
+ * the # escape= directive. New lines go after the last RUN of the final
6
+ * stage, so build steps never see them.
7
+ */
8
+ import { Doc, indentOf, shellWords } from './doc.mjs'
9
+
10
+ /**
11
+ * @typedef {Object} Instruction
12
+ * @property {string} keyword Upper case, e.g. 'ENV'.
13
+ * @property {number} start 0-based index of its first line.
14
+ * @property {number} end 0-based index of its last line (continuations and heredoc bodies included).
15
+ * @property {string} args Logical argument text: continuations joined, inner comment lines removed.
16
+ * @property {string[]|null} argv The JSON array of an exec-form instruction, else null.
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} EnvEntry
21
+ * @property {string} value Unquoted value.
22
+ * @property {Instruction} instruction
23
+ * @property {number} line 0-based line of the NAME=value token.
24
+ * @property {number} col Token start column in that line.
25
+ * @property {number} endCol Token end column (exclusive).
26
+ * @property {boolean} quoted The value was written in quotes.
27
+ * @property {boolean} legacy Old 'ENV NAME value' form.
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} DockerfileInfo
32
+ * @property {Instruction[]} instructions All instructions, in order.
33
+ * @property {Instruction[]} final The final stage, starting with its FROM. All instructions when there is no FROM.
34
+ * @property {string|null} image Final FROM image, e.g. 'python:3.12-slim'.
35
+ * @property {string|null} stageName Final stage name from 'FROM ... AS name'.
36
+ * @property {string|null} workdir Last WORKDIR of the final stage.
37
+ * @property {Instruction|null} cmd Last CMD of the final stage.
38
+ * @property {Instruction|null} entrypoint Last ENTRYPOINT of the final stage.
39
+ * @property {Map<string, EnvEntry>} env ENV of the final stage. The last one wins.
40
+ * @property {Map<string, EnvEntry>} inherited ENV of the earlier stages that the final stage builds on (FROM <stage>).
41
+ * @property {Stage[]} stages Every stage, in order. Empty when there is no FROM.
42
+ * @property {string} escape '\\' or '`'.
43
+ * @property {boolean} syntax Has a '# syntax=' directive.
44
+ */
45
+
46
+ /** @typedef {{image: string, name: string|null, instructions: Instruction[]}} Stage */
47
+
48
+ const EXEC_FORM = new Set(['CMD', 'ENTRYPOINT', 'RUN', 'SHELL', 'HEALTHCHECK'])
49
+
50
+ /** @param {string} text @returns {DockerfileInfo} */
51
+ export function parseDockerfile(text) {
52
+ const doc = new Doc(text)
53
+ let escape = '\\', syntax = false, i = 0
54
+ // Parser directives: only at the very top.
55
+ for (; i < doc.length; i++) {
56
+ const m = /^\s*#\s*([a-zA-Z]+)\s*=\s*(\S+)\s*$/.exec(doc.get(i))
57
+ if (!m) break
58
+ const name = m[1].toLowerCase()
59
+ if (name === 'escape' && (m[2] === '`' || m[2] === '\\')) escape = m[2]
60
+ else if (name === 'syntax') syntax = true
61
+ else if (name !== 'check') break
62
+ }
63
+ const continues = (line) => line.trimEnd().endsWith(escape)
64
+ const comment = (line) => /^\s*#/.test(line)
65
+ const instructions = []
66
+ for (; i < doc.length; i++) {
67
+ const first = doc.get(i)
68
+ const m = /^\s*([A-Za-z]+)(?:\s+(.*))?$/.exec(first)
69
+ if (!m || comment(first)) continue
70
+ const start = i
71
+ let piece = m[2] ?? '', args = ''
72
+ while (continues(piece) && i + 1 < doc.length) {
73
+ args += piece.trimEnd().slice(0, -1)
74
+ i++
75
+ while (i < doc.length && (comment(doc.get(i)) || doc.get(i).trim() === '') && i + 1 < doc.length) i++
76
+ piece = doc.get(i)
77
+ }
78
+ args = (args + piece).trim()
79
+ const keyword = m[1].toUpperCase()
80
+ // Heredoc bodies (RUN <<EOF ... EOF) are not instructions.
81
+ if (['RUN', 'COPY', 'ADD'].includes(keyword)) {
82
+ for (const h of args.matchAll(/<<(-?)\s*(["']?)([A-Za-z_][A-Za-z0-9_]*)\2/g)) {
83
+ while (i + 1 < doc.length && doc.get(++i).trim() !== h[3]);
84
+ }
85
+ }
86
+ let argv = null
87
+ if (EXEC_FORM.has(keyword) && args.startsWith('[')) {
88
+ try {
89
+ const value = JSON.parse(args)
90
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) argv = value
91
+ } catch { /* shell form after all */ }
92
+ }
93
+ instructions.push({ keyword, start, end: i, args, argv })
94
+ }
95
+ const stages = []
96
+ for (const x of instructions) {
97
+ if (x.keyword === 'FROM') {
98
+ const words = x.args.split(/\s+/).filter((w) => !w.startsWith('--'))
99
+ stages.push({ image: words[0] ?? '', name: words[1]?.toLowerCase() === 'as' ? words[2] ?? null : null, instructions: [] })
100
+ }
101
+ stages.at(-1)?.instructions.push(x)
102
+ }
103
+ const final = stages.at(-1)?.instructions ?? instructions
104
+ const last = (k) => final.filter((x) => x.keyword === k).at(-1) ?? null
105
+ const envOf = (list) => {
106
+ const env = new Map()
107
+ for (const instruction of list.filter((x) => x.keyword === 'ENV')) {
108
+ for (const entry of envEntries(doc, instruction, escape)) env.set(entry.name, entry)
109
+ }
110
+ return env
111
+ }
112
+ const inherited = new Map()
113
+ for (const stage of parents(stages, stages.length - 1).reverse()) for (const [name, entry] of envOf(stage.instructions)) inherited.set(name, entry)
114
+ const workdir = last('WORKDIR')
115
+ return { instructions, final, stages, image: stages.at(-1)?.image ?? null, stageName: stages.at(-1)?.name ?? null,
116
+ workdir: workdir ? workdir.args : null, cmd: last('CMD'), entrypoint: last('ENTRYPOINT'), env: envOf(final), inherited, escape, syntax }
117
+ }
118
+
119
+ /** The earlier stages that stage i builds on (FROM <name>), nearest first. */
120
+ function parents(stages, i) {
121
+ const out = []
122
+ for (let k = i, hops = 0; k > 0 && hops < 20; hops++) {
123
+ const base = stages[k].image.toLowerCase()
124
+ k = stages.findLastIndex((s, j) => j < k && s.name?.toLowerCase() === base)
125
+ if (k < 0) break
126
+ out.push(stages[k])
127
+ }
128
+ return out
129
+ }
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
+
154
+ /**
155
+ * The stages whose files reach the final image: the final stage, the stages it
156
+ * builds on, and the stages they COPY --from, again and again.
157
+ * @param {DockerfileInfo} info
158
+ * @returns {Stage[]}
159
+ */
160
+ export function reachingStages(info) {
161
+ const { stages } = info
162
+ if (!stages.length) return [{ image: '', name: null, instructions: info.instructions }]
163
+ const index = (ref) => (/^\d+$/.test(ref) ? Number(ref) : stages.findIndex((s) => s.name?.toLowerCase() === ref.toLowerCase()))
164
+ const seen = new Set(), queue = [stages.length - 1]
165
+ while (queue.length) {
166
+ const i = queue.pop()
167
+ if (i < 0 || i >= stages.length || seen.has(i)) continue
168
+ seen.add(i)
169
+ queue.push(...parents(stages, i).map((s) => stages.indexOf(s)))
170
+ for (const x of stages[i].instructions) {
171
+ const from = (x.keyword === 'COPY' || x.keyword === 'ADD') && /(?:^|\s)--from=["']?([^\s"']+)/.exec(x.args)
172
+ if (from) queue.push(index(from[1]))
173
+ }
174
+ }
175
+ return [...seen].sort((a, b) => a - b).map((i) => stages[i])
176
+ }
177
+
178
+ /**
179
+ * The start command of the final image: ENTRYPOINT then CMD. A shell-form
180
+ * ENTRYPOINT ignores CMD, as Docker does.
181
+ * @returns {{line: number, form: 'exec'|'shell', raw: string, argv: string[]|null}|null} line is 1-based
182
+ */
183
+ export function dockerStart(info) {
184
+ const { entrypoint, cmd } = info
185
+ if (!entrypoint && !cmd) return null
186
+ const parts = entrypoint && !entrypoint.argv ? [entrypoint] : [entrypoint, cmd].filter(Boolean)
187
+ const exec = parts.every((p) => p.argv)
188
+ const argv = exec ? parts.flatMap((p) => p.argv) : parts.length === 1 ? shellWords(parts[0].args) : null
189
+ return { line: (entrypoint ?? cmd).start + 1, form: exec ? 'exec' : 'shell', raw: parts.map((p) => p.args).join(' '), argv }
190
+ }
191
+
192
+ /**
193
+ * ENV lines in the docs format: 'ENV A=1 \' then ' B=2'. The line
194
+ * continuation uses the escape character of the Dockerfile.
195
+ * @param {[string, string][]} pairs
196
+ * @param {{quote?: 'auto'|'always', escape?: string, raw?: boolean}} [options] 'always' quotes every value, as the docs
197
+ * do for NODE_OPTIONS. raw keeps $ references, so Docker expands them.
198
+ * @returns {string[]}
199
+ */
200
+ export function formatEnv(pairs, { quote = 'auto', escape = '\\', raw = false } = {}) {
201
+ const items = pairs.map(([name, value]) => `${name}=${quoteValue(value, quote === 'always', escape, raw)}`)
202
+ return items.map((item, k) => `${k === 0 ? 'ENV ' : ' '}${item}${k < items.length - 1 ? ` ${escape}` : ''}`)
203
+ }
204
+
205
+ /**
206
+ * Set ENV values in the final stage. A name that the final stage sets with
207
+ * another value is changed in place. Missing names, and names that only an
208
+ * earlier stage sets, go in 1 new ENV block (formatEnv) before the start
209
+ * instruction. Equal values change nothing.
210
+ * @param {string} text
211
+ * @param {[string, string][]} pairs
212
+ * @param {{quote?: 'auto'|'always'}} [options]
213
+ */
214
+ export function setEnv(text, pairs, options = {}) {
215
+ let out = text
216
+ const missing = []
217
+ const { escape } = parseDockerfile(text)
218
+ for (const [name, value] of pairs) {
219
+ const info = parseDockerfile(out)
220
+ const entry = info.env.get(name)
221
+ if (!entry) { if (info.inherited.get(name)?.value !== value) missing.push([name, value]); continue }
222
+ if (entry.value === value) continue
223
+ const doc = new Doc(out)
224
+ const token = `${name}=${quoteValue(value, entry.quoted || options.quote === 'always', escape)}`
225
+ if (entry.legacy) {
226
+ const { start, end } = entry.instruction
227
+ doc.remove(start + 1, end - start)
228
+ doc.set(start, `${indentOf(doc.get(start))}ENV ${token}`)
229
+ } else {
230
+ const line = doc.get(entry.line)
231
+ doc.set(entry.line, line.slice(0, entry.col) + token + line.slice(entry.endCol))
232
+ }
233
+ out = doc.toString()
234
+ }
235
+ return missing.length ? insertBeforeStart(out, formatEnv(missing, { ...options, escape })) : out
236
+ }
237
+
238
+ /**
239
+ * Add an option to a list setting such as NODE_OPTIONS or JAVA_TOOL_OPTIONS.
240
+ * Lines that set it already stay as they are: build steps run with them, and
241
+ * their value can use $ references. A new ENV line goes after the last RUN,
242
+ * just before the start. When the final stage or an earlier stage sets a
243
+ * value, the new line keeps it with ${NAME}. Unchanged when has(value).
244
+ * @param {string} text
245
+ * @param {string} name
246
+ * @param {string} option
247
+ * @param {(value: string) => boolean} [has]
248
+ */
249
+ export function addOption(text, name, option, has = (value) => value.includes(option)) {
250
+ const info = parseDockerfile(text)
251
+ const current = info.env.get(name) ?? info.inherited.get(name)
252
+ if (current && has(current.value)) return text
253
+ const value = current ? `\${${name}} ${option}` : option
254
+ return insertBeforeStart(text, formatEnv([[name, value]], { quote: 'always', escape: info.escape, raw: true }))
255
+ }
256
+
257
+ /**
258
+ * Insert lines in the final stage, just before the first CMD or ENTRYPOINT
259
+ * after its last RUN (above any comment lines attached to it). Without one,
260
+ * after the last instruction. So no RUN of the final stage sees the lines.
261
+ * Lines get the indentation of the line they go before.
262
+ * @param {string} text
263
+ * @param {string[]} lines
264
+ */
265
+ export function insertBeforeStart(text, lines) {
266
+ const info = parseDockerfile(text)
267
+ const doc = new Doc(text)
268
+ const lastRun = info.final.findLastIndex((x) => x.keyword === 'RUN')
269
+ const start = info.final.find((x, k) => k > lastRun && (x.keyword === 'CMD' || x.keyword === 'ENTRYPOINT'))
270
+ let at, indent = ''
271
+ if (start) {
272
+ at = start.start
273
+ indent = indentOf(doc.get(at))
274
+ while (at > 0 && /^\s*#/.test(doc.get(at - 1)) && at - 1 > (info.final[0]?.end ?? -1)) at--
275
+ } else {
276
+ at = info.instructions.length ? info.instructions.at(-1).end + 1 : doc.length
277
+ }
278
+ doc.insert(at, ...lines.map((line) => indent + line))
279
+ return doc.toString()
280
+ }
281
+
282
+ /**
283
+ * Put words in front of the start command of the final stage: the
284
+ * ENTRYPOINT when there is one, else the CMD. Exec form gets JSON strings
285
+ * in the same comma style. Shell form gets plain words after the keyword
286
+ * (or after 'exec'). Returns the text unchanged when the words are already
287
+ * there, and null when there is no start or it cannot be changed safely
288
+ * (shell lists, pipes, a leading NAME=value, or '[' not on the first line).
289
+ * @param {string} text
290
+ * @param {string[]} words
291
+ * @returns {string|null}
292
+ */
293
+ export function prefixStart(text, words) {
294
+ const info = parseDockerfile(text)
295
+ const target = info.entrypoint ?? info.cmd
296
+ if (!target) return null
297
+ const doc = new Doc(text)
298
+ const line = doc.get(target.start)
299
+ const keywordEnd = /^\s*[A-Za-z]+\s*/.exec(line)[0].length
300
+ if (target.argv) {
301
+ if (words.every((w, k) => target.argv[k] === w)) return text
302
+ if (line[keywordEnd] !== '[') return null
303
+ // Keep the comma style of the array. With fewer than 2 items there is none: use the docs style.
304
+ const sep = target.argv.length < 2 || /",\s+"/.test(target.args) ? ', ' : ','
305
+ let pos = keywordEnd + 1
306
+ while (line[pos] === ' ') pos++
307
+ const inserted = words.map((w) => JSON.stringify(w)).join(sep) + (target.argv.length ? sep : '')
308
+ doc.set(target.start, line.slice(0, pos) + inserted + line.slice(pos))
309
+ return doc.toString()
310
+ }
311
+ const execWord = /^exec\s+/.exec(target.args)?.[0] ?? ''
312
+ const command = target.args.slice(execWord.length)
313
+ if (command.startsWith(words.join(' ') + ' ')) return text
314
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(command) || /[;|]|&&|&\s*$/.test(command)) return null
315
+ const head = /^exec\s+/.exec(line.slice(keywordEnd))
316
+ if (execWord && !head) return null
317
+ const pos = keywordEnd + (head ? head[0].length : 0)
318
+ doc.set(target.start, line.slice(0, pos) + words.map(shellQuote).join(' ') + ' ' + line.slice(pos))
319
+ return doc.toString()
320
+ }
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
+
382
+ // --- helpers ---------------------------------------------------------------
383
+
384
+ /** A value for an ENV token. escape is the escape character of the Dockerfile. raw keeps $ references. */
385
+ function quoteValue(value, always, escape = '\\', raw = false) {
386
+ if (!always && value !== '' && /^[A-Za-z0-9_.,:/@%+=-]+$/.test(value)) return value
387
+ const special = new Set(['"', escape, ...(raw ? [] : ['$'])])
388
+ return `"${[...value].map((c) => (special.has(c) ? escape + c : c)).join('')}"`
389
+ }
390
+
391
+ function shellQuote(word) {
392
+ return /^[A-Za-z0-9_.,:/@%+=-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`
393
+ }
394
+
395
+ /** NAME=value tokens of 1 ENV instruction, with their positions. */
396
+ function envEntries(doc, instruction, escape) {
397
+ const entries = []
398
+ const tokens = []
399
+ for (let n = instruction.start; n <= instruction.end; n++) {
400
+ let line = doc.get(n)
401
+ if (n > instruction.start && (/^\s*#/.test(line) || !line.trim())) continue
402
+ let from = 0
403
+ if (n === instruction.start) from = /^\s*ENV\s*/i.exec(line)[0].length
404
+ let to = line.length
405
+ const trimmed = line.trimEnd()
406
+ if (n < instruction.end && trimmed.endsWith(escape)) to = trimmed.length - 1
407
+ for (const t of tokenize(line, from, to, escape)) tokens.push({ ...t, line: n })
408
+ }
409
+ if (!tokens.length) return entries
410
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*=/.test(tokens[0].text)) {
411
+ // Legacy form: ENV NAME the rest is the value
412
+ const name = tokens[0].text
413
+ const value = instruction.args.slice(instruction.args.indexOf(name) + name.length).trim()
414
+ return [{ name, value: unquote(value, escape), instruction, line: tokens[0].line, col: tokens[0].start, endCol: tokens[0].end, quoted: false, legacy: true }]
415
+ }
416
+ for (const t of tokens) {
417
+ const eq = t.text.indexOf('=')
418
+ if (eq < 1) continue
419
+ const raw = t.text.slice(eq + 1)
420
+ entries.push({ name: t.text.slice(0, eq), value: unquote(raw, escape), instruction, line: t.line, col: t.start, endCol: t.end,
421
+ quoted: raw.startsWith('"') || raw.startsWith("'"), legacy: false })
422
+ }
423
+ return entries
424
+ }
425
+
426
+ /** Whitespace-separated tokens of line[from, to), respecting quotes and the escape character. */
427
+ function tokenize(line, from, to, escape) {
428
+ const out = []
429
+ let i = from
430
+ while (i < to) {
431
+ while (i < to && /\s/.test(line[i])) i++
432
+ if (i >= to) break
433
+ const start = i
434
+ let quote = ''
435
+ while (i < to && (quote || !/\s/.test(line[i]))) {
436
+ const c = line[i]
437
+ if (c === escape && quote !== "'") i += 2
438
+ else { if (quote && c === quote) quote = ''; else if (!quote && (c === '"' || c === "'")) quote = c; i++ }
439
+ }
440
+ out.push({ text: line.slice(start, Math.min(i, to)), start, end: Math.min(i, to) })
441
+ }
442
+ return out
443
+ }
444
+
445
+ function unquote(raw, escape) {
446
+ let out = '', quote = ''
447
+ for (let i = 0; i < raw.length; i++) {
448
+ const c = raw[i]
449
+ if (c === escape && quote !== "'" && i + 1 < raw.length) out += raw[++i]
450
+ else if (quote && c === quote) quote = ''
451
+ else if (!quote && (c === '"' || c === "'")) quote = c
452
+ else out += c
453
+ }
454
+ return out
455
+ }
@@ -0,0 +1,81 @@
1
+ /*
2
+ * The addresses this run uses. The ingest address is what setup writes and
3
+ * check sends the test span to. The site address is where check asks which
4
+ * workspace a key sends to, and where the docs links point. Both are the
5
+ * addresses of this build (contract-docs.mjs), unless UKNOWS_INGEST_ENDPOINT
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.
8
+ * Setup and check use these same rules, so a test run writes the address it checks.
9
+ */
10
+ import { APP_URL, CLI, INGEST_ENDPOINT, SETUP_NEXT, SITE_URL, openLive } from './contract.mjs'
11
+
12
+ const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
13
+ const safe = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
14
+
15
+ /**
16
+ * The ingest address without a trailing slash, or null when the override is not https (http on this computer only).
17
+ * @param {Record<string, string|undefined>} [env]
18
+ * @returns {string|null}
19
+ */
20
+ export function ingestEndpoint(env = {}) {
21
+ const raw = String(env[CLI.endpointEnv] || INGEST_ENDPOINT).trim().replace(/\/+$/, '')
22
+ let url
23
+ try { url = new URL(`${raw}/v1/traces`) } catch { return null }
24
+ return safe(url) && !url.username && !url.password ? raw : null
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
+
34
+ /**
35
+ * The site address (origin only, no trailing slash), or null when the override is not https or has a path.
36
+ * @param {Record<string, string|undefined>} [env]
37
+ * @returns {string|null}
38
+ */
39
+ export function siteUrl(env = {}) {
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)
67
+ }
68
+
69
+ /**
70
+ * True when check may ask the site about the key: the site and the ingest address belong to the same UKNOWS.
71
+ * A test ingest address with the production site would ask the wrong UKNOWS, which does not know the test key.
72
+ * @param {Record<string, string|undefined>} [env]
73
+ */
74
+ export function sameUknows(env = {}) {
75
+ return Boolean(env[CLI.siteEnv]) || !env[CLI.endpointEnv]
76
+ }
77
+
78
+ /** The docs page of the site this run uses. */
79
+ export const docsUrl = (env = {}) => `${siteUrl(env) ?? SITE_URL}/docs`
80
+ /** The llms.txt of the site this run uses. */
81
+ export const llmsUrl = (env = {}) => `${siteUrl(env) ?? SITE_URL}/llms.txt`