@uknows/setup 0.0.0-stage → 0.1.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/lib/diff.mjs ADDED
@@ -0,0 +1,147 @@
1
+ /*
2
+ * Line diffs. Lines keep their endings, so a diff of CRLF, mixed or
3
+ * unterminated text is byte exact. The common prefix and suffix are cut off
4
+ * first, then a Myers O(ND) diff runs on the middle. When the middle is too
5
+ * different, it becomes 1 change, which is still correct, just less tidy.
6
+ */
7
+
8
+ const MAX_D = 1000
9
+
10
+ /** Lines with their endings. '' has no lines. */
11
+ export const splitLines = (text) => (text ? text.split(/(?<=\n)/) : [])
12
+
13
+ /** Changed regions between 2 line arrays: [{a, aEnd, b, bEnd}], 0-based, ends exclusive. */
14
+ export function regions(a, b) {
15
+ let pre = 0
16
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++
17
+ let suf = 0
18
+ while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++
19
+ const A = a.slice(pre, a.length - suf), B = b.slice(pre, b.length - suf)
20
+ const out = []
21
+ let i = 0, j = 0
22
+ for (const [mi, mj] of [...(matches(A, B) ?? []), [A.length, B.length]]) {
23
+ if (mi > i || mj > j) out.push({ a: pre + i, aEnd: pre + mi, b: pre + j, bEnd: pre + mj })
24
+ i = mi + 1
25
+ j = mj + 1
26
+ }
27
+ return out
28
+ }
29
+
30
+ /** Matched line pairs [i, j] in order (Myers), or null when more than MAX_D edits apart. */
31
+ function matches(A, B) {
32
+ const n = A.length, m = B.length, max = n + m, off = max + 1
33
+ if (!n || !m) return []
34
+ const v = new Int32Array(2 * max + 3)
35
+ const trace = []
36
+ for (let d = 0; d <= Math.min(max, MAX_D); d++) {
37
+ trace.push(v.slice(off - d - 1, off + d + 2))
38
+ for (let k = -d; k <= d; k += 2) {
39
+ let x = k === -d || (k !== d && v[off + k - 1] < v[off + k + 1]) ? v[off + k + 1] : v[off + k - 1] + 1
40
+ let y = x - k
41
+ while (x < n && y < m && A[x] === B[y]) { x++; y++ }
42
+ v[off + k] = x
43
+ if (x >= n && y >= m) return backtrack(trace, n, m)
44
+ }
45
+ }
46
+ return null
47
+ }
48
+
49
+ function backtrack(trace, n, m) {
50
+ const pairs = []
51
+ let x = n, y = m
52
+ for (let d = trace.length - 1; d >= 0; d--) {
53
+ const snap = trace[d], at = (k) => snap[k + d + 1]
54
+ const k = x - y
55
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1
56
+ const prevX = at(prevK), prevY = prevX - prevK
57
+ while (x > prevX && y > prevY) pairs.push([--x, --y])
58
+ x = prevX
59
+ y = prevY
60
+ }
61
+ return pairs.reverse()
62
+ }
63
+
64
+ /**
65
+ * Zero-context hunks for the manifest. `at` is the 0-based line in the
66
+ * after-text. before null means a new file.
67
+ */
68
+ export function hunks(before, after) {
69
+ const a = splitLines(before ?? ''), b = splitLines(after)
70
+ return regions(a, b).map((r) => ({ at: r.b, before: a.slice(r.a, r.aEnd), after: b.slice(r.b, r.bEnd) }))
71
+ }
72
+
73
+ /** Undo hunks by position. Returns the lines, or null when a hunk does not sit where it should. */
74
+ export function reverseAt(lines, list) {
75
+ const out = [...lines]
76
+ for (const h of [...list].sort((p, q) => q.at - p.at)) {
77
+ if (!sameAt(out, h.at, h.after)) return null
78
+ out.splice(h.at, h.after.length, ...h.before)
79
+ }
80
+ return out
81
+ }
82
+
83
+ /**
84
+ * Undo hunks by content: each hunk's added lines must appear exactly once.
85
+ * Returns the lines, or null when a hunk is missing, ambiguous or overlaps another.
86
+ */
87
+ export function reverseByContent(lines, list) {
88
+ const found = []
89
+ for (const h of list) {
90
+ if (!h.after.length) return null
91
+ const spots = []
92
+ for (let i = 0; i + h.after.length <= lines.length; i++) if (sameAt(lines, i, h.after)) spots.push(i)
93
+ if (spots.length !== 1) return null
94
+ found.push({ at: spots[0], h })
95
+ }
96
+ found.sort((p, q) => q.at - p.at)
97
+ for (let i = 1; i < found.length; i++) if (found[i].at + found[i].h.after.length > found[i - 1].at) return null
98
+ const out = [...lines]
99
+ for (const { at, h } of found) out.splice(at, h.after.length, ...h.before)
100
+ return out
101
+ }
102
+
103
+ function sameAt(lines, at, want) {
104
+ if (at < 0 || at + want.length > lines.length) return false
105
+ return want.every((line, i) => lines[at + i] === line)
106
+ }
107
+
108
+ /** Lines added and removed. */
109
+ export function countLines(before, after) {
110
+ let added = 0, removed = 0
111
+ for (const r of regions(splitLines(before ?? ''), splitLines(after ?? ''))) {
112
+ added += r.bEnd - r.b
113
+ removed += r.aEnd - r.a
114
+ }
115
+ return { added, removed }
116
+ }
117
+
118
+ /** A unified diff for display. before null = new file. '' when nothing changed. */
119
+ export function unified(file, before, after, context = 3) {
120
+ const a = splitLines(before ?? ''), b = splitLines(after ?? '')
121
+ const rs = regions(a, b)
122
+ if (!rs.length) return ''
123
+ const out = [`--- ${before === null ? '/dev/null' : `a/${file}`}`, `+++ b/${file}`]
124
+ const line = (sign, text) => {
125
+ out.push(sign + text.replace(/\r?\n$/, ''))
126
+ if (!text.endsWith('\n')) out.push('\')
127
+ }
128
+ for (let g = 0; g < rs.length;) {
129
+ let h = g
130
+ while (h + 1 < rs.length && rs[h + 1].a - rs[h].aEnd <= 2 * context) h++
131
+ const aS = Math.max(0, rs[g].a - context), aE = Math.min(a.length, rs[h].aEnd + context)
132
+ const bS = rs[g].b - (rs[g].a - aS), bE = rs[h].bEnd + (aE - rs[h].aEnd)
133
+ out.push(`@@ -${span(aS, aE - aS)} +${span(bS, bE - bS)} @@`)
134
+ let i = aS
135
+ for (let r = g; r <= h; r++) {
136
+ for (; i < rs[r].a; i++) line(' ', a[i])
137
+ for (let x = rs[r].a; x < rs[r].aEnd; x++) line('-', a[x])
138
+ for (let y = rs[r].b; y < rs[r].bEnd; y++) line('+', b[y])
139
+ i = rs[r].aEnd
140
+ }
141
+ for (; i < aE; i++) line(' ', a[i])
142
+ g = h + 1
143
+ }
144
+ return out.join('\n')
145
+ }
146
+
147
+ const span = (start, length) => (length === 1 ? `${start + 1}` : `${length ? start + 1 : start},${length}`)
package/lib/doc.mjs ADDED
@@ -0,0 +1,98 @@
1
+ /*
2
+ * A text file as lines. Each line keeps its own line ending, so an edit
3
+ * changes only the lines it touches: CRLF, LF, mixed endings and a missing
4
+ * final newline all survive byte for byte. New lines use the dominant ending.
5
+ */
6
+
7
+ export class Doc {
8
+ /** @param {string} text */
9
+ constructor(text = '') {
10
+ const parts = text === '' ? [] : text.split(/(?<=\n)/)
11
+ /** @type {string[]} line content without the ending */
12
+ this.lines = []
13
+ /** @type {string[]} '\n', '\r\n' or '' (last line without a newline) */
14
+ this.ends = []
15
+ for (const part of parts) {
16
+ const end = part.endsWith('\r\n') ? '\r\n' : part.endsWith('\n') ? '\n' : ''
17
+ this.lines.push(part.slice(0, part.length - end.length))
18
+ this.ends.push(end)
19
+ }
20
+ const crlf = this.ends.filter((e) => e === '\r\n').length
21
+ const lf = this.ends.filter((e) => e === '\n').length
22
+ /** The dominant line ending. */
23
+ this.eol = crlf > lf ? '\r\n' : '\n'
24
+ }
25
+
26
+ get length() { return this.lines.length }
27
+
28
+ /** @param {number} i */
29
+ get(i) { return this.lines[i] }
30
+
31
+ /** Replace the content of line i. Keeps its line ending. */
32
+ set(i, content) {
33
+ if (i < 0 || i >= this.lines.length) throw new RangeError(`no line ${i}`)
34
+ this.lines[i] = content
35
+ }
36
+
37
+ /** Insert lines before index i. i === length appends. */
38
+ insert(i, ...contents) {
39
+ if (i < 0 || i > this.lines.length) throw new RangeError(`no line ${i}`)
40
+ if (!contents.length) return
41
+ const last = this.lines.length - 1
42
+ if (i === this.lines.length && last >= 0 && this.ends[last] === '') {
43
+ // Appending after a final line without a newline: keep the file without a final newline.
44
+ this.ends[last] = this.eol
45
+ this.lines.push(...contents)
46
+ this.ends.push(...contents.map((_, k) => (k === contents.length - 1 ? '' : this.eol)))
47
+ return
48
+ }
49
+ this.lines.splice(i, 0, ...contents)
50
+ this.ends.splice(i, 0, ...contents.map(() => this.eol))
51
+ }
52
+
53
+ /** Remove count lines from index i. */
54
+ remove(i, count = 1) {
55
+ this.lines.splice(i, count)
56
+ this.ends.splice(i, count)
57
+ }
58
+
59
+ /** First index >= from whose line matches. test is a RegExp or a function. */
60
+ find(test, from = 0) {
61
+ for (let i = from; i < this.lines.length; i++) {
62
+ if (typeof test === 'function' ? test(this.lines[i], i) : test.test(this.lines[i])) return i
63
+ }
64
+ return -1
65
+ }
66
+
67
+ toString() {
68
+ let out = ''
69
+ for (let i = 0; i < this.lines.length; i++) out += this.lines[i] + this.ends[i]
70
+ return out
71
+ }
72
+ }
73
+
74
+ /** Indentation (leading spaces or tabs) of a line. */
75
+ export const indentOf = (line) => /^[ \t]*/.exec(line)[0]
76
+
77
+ /**
78
+ * Split a command into words when it is a plain literal command. Returns null
79
+ * when it has shell syntax this tool must not guess at: variables,
80
+ * substitution, globs, redirects, pipes, lists, a leading NAME=value, an
81
+ * unclosed quote or a newline. Quotes are removed.
82
+ * @param {string} text
83
+ * @returns {string[]|null}
84
+ */
85
+ export function shellWords(text) {
86
+ if (typeof text !== 'string' || !text.trim() || /[\r\n\0$`;&|<>*?{}\\]/.test(text)) return null
87
+ const words = []
88
+ let word = '', quote = '', started = false
89
+ for (const c of text.trim()) {
90
+ if (quote) { if (c === quote) quote = ''; else word += c; started = true }
91
+ else if (c === '"' || c === "'") { quote = c; started = true }
92
+ else if (/\s/.test(c)) { if (started) { words.push(word); word = ''; started = false } }
93
+ else { word += c; started = true }
94
+ }
95
+ if (quote) return null
96
+ if (started) words.push(word)
97
+ return words.length && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) ? words : null
98
+ }
@@ -0,0 +1,372 @@
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 stages whose files reach the final image: the final stage, the stages it
133
+ * builds on, and the stages they COPY --from, again and again.
134
+ * @param {DockerfileInfo} info
135
+ * @returns {Stage[]}
136
+ */
137
+ export function reachingStages(info) {
138
+ const { stages } = info
139
+ if (!stages.length) return [{ image: '', name: null, instructions: info.instructions }]
140
+ const index = (ref) => (/^\d+$/.test(ref) ? Number(ref) : stages.findIndex((s) => s.name?.toLowerCase() === ref.toLowerCase()))
141
+ const seen = new Set(), queue = [stages.length - 1]
142
+ while (queue.length) {
143
+ const i = queue.pop()
144
+ if (i < 0 || i >= stages.length || seen.has(i)) continue
145
+ seen.add(i)
146
+ queue.push(...parents(stages, i).map((s) => stages.indexOf(s)))
147
+ for (const x of stages[i].instructions) {
148
+ const from = (x.keyword === 'COPY' || x.keyword === 'ADD') && /(?:^|\s)--from=["']?([^\s"']+)/.exec(x.args)
149
+ if (from) queue.push(index(from[1]))
150
+ }
151
+ }
152
+ return [...seen].sort((a, b) => a - b).map((i) => stages[i])
153
+ }
154
+
155
+ /**
156
+ * The start command of the final image: ENTRYPOINT then CMD. A shell-form
157
+ * ENTRYPOINT ignores CMD, as Docker does.
158
+ * @returns {{line: number, form: 'exec'|'shell', raw: string, argv: string[]|null}|null} line is 1-based
159
+ */
160
+ export function dockerStart(info) {
161
+ const { entrypoint, cmd } = info
162
+ if (!entrypoint && !cmd) return null
163
+ const parts = entrypoint && !entrypoint.argv ? [entrypoint] : [entrypoint, cmd].filter(Boolean)
164
+ const exec = parts.every((p) => p.argv)
165
+ const argv = exec ? parts.flatMap((p) => p.argv) : parts.length === 1 ? shellWords(parts[0].args) : null
166
+ return { line: (entrypoint ?? cmd).start + 1, form: exec ? 'exec' : 'shell', raw: parts.map((p) => p.args).join(' '), argv }
167
+ }
168
+
169
+ /**
170
+ * ENV lines in the docs format: 'ENV A=1 \' then ' B=2'. The line
171
+ * continuation uses the escape character of the Dockerfile.
172
+ * @param {[string, string][]} pairs
173
+ * @param {{quote?: 'auto'|'always', escape?: string, raw?: boolean}} [options] 'always' quotes every value, as the docs
174
+ * do for NODE_OPTIONS. raw keeps $ references, so Docker expands them.
175
+ * @returns {string[]}
176
+ */
177
+ export function formatEnv(pairs, { quote = 'auto', escape = '\\', raw = false } = {}) {
178
+ const items = pairs.map(([name, value]) => `${name}=${quoteValue(value, quote === 'always', escape, raw)}`)
179
+ return items.map((item, k) => `${k === 0 ? 'ENV ' : ' '}${item}${k < items.length - 1 ? ` ${escape}` : ''}`)
180
+ }
181
+
182
+ /**
183
+ * Set ENV values in the final stage. A name that the final stage sets with
184
+ * another value is changed in place. Missing names, and names that only an
185
+ * earlier stage sets, go in 1 new ENV block (formatEnv) before the start
186
+ * instruction. Equal values change nothing.
187
+ * @param {string} text
188
+ * @param {[string, string][]} pairs
189
+ * @param {{quote?: 'auto'|'always'}} [options]
190
+ */
191
+ export function setEnv(text, pairs, options = {}) {
192
+ let out = text
193
+ const missing = []
194
+ const { escape } = parseDockerfile(text)
195
+ for (const [name, value] of pairs) {
196
+ const info = parseDockerfile(out)
197
+ const entry = info.env.get(name)
198
+ if (!entry) { if (info.inherited.get(name)?.value !== value) missing.push([name, value]); continue }
199
+ if (entry.value === value) continue
200
+ const doc = new Doc(out)
201
+ const token = `${name}=${quoteValue(value, entry.quoted || options.quote === 'always', escape)}`
202
+ if (entry.legacy) {
203
+ const { start, end } = entry.instruction
204
+ doc.remove(start + 1, end - start)
205
+ doc.set(start, `${indentOf(doc.get(start))}ENV ${token}`)
206
+ } else {
207
+ const line = doc.get(entry.line)
208
+ doc.set(entry.line, line.slice(0, entry.col) + token + line.slice(entry.endCol))
209
+ }
210
+ out = doc.toString()
211
+ }
212
+ return missing.length ? insertBeforeStart(out, formatEnv(missing, { ...options, escape })) : out
213
+ }
214
+
215
+ /**
216
+ * Add an option to a list setting such as NODE_OPTIONS or JAVA_TOOL_OPTIONS.
217
+ * Lines that set it already stay as they are: build steps run with them, and
218
+ * their value can use $ references. A new ENV line goes after the last RUN,
219
+ * just before the start. When the final stage or an earlier stage sets a
220
+ * value, the new line keeps it with ${NAME}. Unchanged when has(value).
221
+ * @param {string} text
222
+ * @param {string} name
223
+ * @param {string} option
224
+ * @param {(value: string) => boolean} [has]
225
+ */
226
+ export function addOption(text, name, option, has = (value) => value.includes(option)) {
227
+ const info = parseDockerfile(text)
228
+ const current = info.env.get(name) ?? info.inherited.get(name)
229
+ if (current && has(current.value)) return text
230
+ const value = current ? `\${${name}} ${option}` : option
231
+ return insertBeforeStart(text, formatEnv([[name, value]], { quote: 'always', escape: info.escape, raw: true }))
232
+ }
233
+
234
+ /**
235
+ * Insert lines in the final stage, just before the first CMD or ENTRYPOINT
236
+ * after its last RUN (above any comment lines attached to it). Without one,
237
+ * after the last instruction. So no RUN of the final stage sees the lines.
238
+ * Lines get the indentation of the line they go before.
239
+ * @param {string} text
240
+ * @param {string[]} lines
241
+ */
242
+ export function insertBeforeStart(text, lines) {
243
+ const info = parseDockerfile(text)
244
+ const doc = new Doc(text)
245
+ const lastRun = info.final.findLastIndex((x) => x.keyword === 'RUN')
246
+ const start = info.final.find((x, k) => k > lastRun && (x.keyword === 'CMD' || x.keyword === 'ENTRYPOINT'))
247
+ let at, indent = ''
248
+ if (start) {
249
+ at = start.start
250
+ indent = indentOf(doc.get(at))
251
+ while (at > 0 && /^\s*#/.test(doc.get(at - 1)) && at - 1 > (info.final[0]?.end ?? -1)) at--
252
+ } else {
253
+ at = info.instructions.length ? info.instructions.at(-1).end + 1 : doc.length
254
+ }
255
+ doc.insert(at, ...lines.map((line) => indent + line))
256
+ return doc.toString()
257
+ }
258
+
259
+ /**
260
+ * Put words in front of the start command of the final stage: the
261
+ * ENTRYPOINT when there is one, else the CMD. Exec form gets JSON strings
262
+ * in the same comma style. Shell form gets plain words after the keyword
263
+ * (or after 'exec'). Returns the text unchanged when the words are already
264
+ * there, and null when there is no start or it cannot be changed safely
265
+ * (shell lists, pipes, a leading NAME=value, or '[' not on the first line).
266
+ * @param {string} text
267
+ * @param {string[]} words
268
+ * @returns {string|null}
269
+ */
270
+ export function prefixStart(text, words) {
271
+ const info = parseDockerfile(text)
272
+ const target = info.entrypoint ?? info.cmd
273
+ if (!target) return null
274
+ const doc = new Doc(text)
275
+ const line = doc.get(target.start)
276
+ const keywordEnd = /^\s*[A-Za-z]+\s*/.exec(line)[0].length
277
+ if (target.argv) {
278
+ if (words.every((w, k) => target.argv[k] === w)) return text
279
+ if (line[keywordEnd] !== '[') return null
280
+ // Keep the comma style of the array. With fewer than 2 items there is none: use the docs style.
281
+ const sep = target.argv.length < 2 || /",\s+"/.test(target.args) ? ', ' : ','
282
+ let pos = keywordEnd + 1
283
+ while (line[pos] === ' ') pos++
284
+ const inserted = words.map((w) => JSON.stringify(w)).join(sep) + (target.argv.length ? sep : '')
285
+ doc.set(target.start, line.slice(0, pos) + inserted + line.slice(pos))
286
+ return doc.toString()
287
+ }
288
+ const execWord = /^exec\s+/.exec(target.args)?.[0] ?? ''
289
+ const command = target.args.slice(execWord.length)
290
+ if (command.startsWith(words.join(' ') + ' ')) return text
291
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(command) || /[;|]|&&|&\s*$/.test(command)) return null
292
+ const head = /^exec\s+/.exec(line.slice(keywordEnd))
293
+ if (execWord && !head) return null
294
+ const pos = keywordEnd + (head ? head[0].length : 0)
295
+ doc.set(target.start, line.slice(0, pos) + words.map(shellQuote).join(' ') + ' ' + line.slice(pos))
296
+ return doc.toString()
297
+ }
298
+
299
+ // --- helpers ---------------------------------------------------------------
300
+
301
+ /** A value for an ENV token. escape is the escape character of the Dockerfile. raw keeps $ references. */
302
+ function quoteValue(value, always, escape = '\\', raw = false) {
303
+ if (!always && value !== '' && /^[A-Za-z0-9_.,:/@%+=-]+$/.test(value)) return value
304
+ const special = new Set(['"', escape, ...(raw ? [] : ['$'])])
305
+ return `"${[...value].map((c) => (special.has(c) ? escape + c : c)).join('')}"`
306
+ }
307
+
308
+ function shellQuote(word) {
309
+ return /^[A-Za-z0-9_.,:/@%+=-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`
310
+ }
311
+
312
+ /** NAME=value tokens of 1 ENV instruction, with their positions. */
313
+ function envEntries(doc, instruction, escape) {
314
+ const entries = []
315
+ const tokens = []
316
+ for (let n = instruction.start; n <= instruction.end; n++) {
317
+ let line = doc.get(n)
318
+ if (n > instruction.start && (/^\s*#/.test(line) || !line.trim())) continue
319
+ let from = 0
320
+ if (n === instruction.start) from = /^\s*ENV\s*/i.exec(line)[0].length
321
+ let to = line.length
322
+ const trimmed = line.trimEnd()
323
+ if (n < instruction.end && trimmed.endsWith(escape)) to = trimmed.length - 1
324
+ for (const t of tokenize(line, from, to, escape)) tokens.push({ ...t, line: n })
325
+ }
326
+ if (!tokens.length) return entries
327
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]*=/.test(tokens[0].text)) {
328
+ // Legacy form: ENV NAME the rest is the value
329
+ const name = tokens[0].text
330
+ const value = instruction.args.slice(instruction.args.indexOf(name) + name.length).trim()
331
+ return [{ name, value: unquote(value, escape), instruction, line: tokens[0].line, col: tokens[0].start, endCol: tokens[0].end, quoted: false, legacy: true }]
332
+ }
333
+ for (const t of tokens) {
334
+ const eq = t.text.indexOf('=')
335
+ if (eq < 1) continue
336
+ const raw = t.text.slice(eq + 1)
337
+ entries.push({ name: t.text.slice(0, eq), value: unquote(raw, escape), instruction, line: t.line, col: t.start, endCol: t.end,
338
+ quoted: raw.startsWith('"') || raw.startsWith("'"), legacy: false })
339
+ }
340
+ return entries
341
+ }
342
+
343
+ /** Whitespace-separated tokens of line[from, to), respecting quotes and the escape character. */
344
+ function tokenize(line, from, to, escape) {
345
+ const out = []
346
+ let i = from
347
+ while (i < to) {
348
+ while (i < to && /\s/.test(line[i])) i++
349
+ if (i >= to) break
350
+ const start = i
351
+ let quote = ''
352
+ while (i < to && (quote || !/\s/.test(line[i]))) {
353
+ const c = line[i]
354
+ if (c === escape && quote !== "'") i += 2
355
+ else { if (quote && c === quote) quote = ''; else if (!quote && (c === '"' || c === "'")) quote = c; i++ }
356
+ }
357
+ out.push({ text: line.slice(start, Math.min(i, to)), start, end: Math.min(i, to) })
358
+ }
359
+ return out
360
+ }
361
+
362
+ function unquote(raw, escape) {
363
+ let out = '', quote = ''
364
+ for (let i = 0; i < raw.length; i++) {
365
+ const c = raw[i]
366
+ if (c === escape && quote !== "'" && i + 1 < raw.length) out += raw[++i]
367
+ else if (quote && c === quote) quote = ''
368
+ else if (!quote && (c === '"' || c === "'")) quote = c
369
+ else out += c
370
+ }
371
+ return out
372
+ }
@@ -0,0 +1,50 @@
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
+ * Setup and check use these same rules, so a test run writes the address it checks.
8
+ */
9
+ import { CLI, INGEST_ENDPOINT, SITE_URL } from './contract.mjs'
10
+
11
+ const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
12
+ const safe = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
13
+
14
+ /**
15
+ * The ingest address without a trailing slash, or null when the override is not https (http on this computer only).
16
+ * @param {Record<string, string|undefined>} [env]
17
+ * @returns {string|null}
18
+ */
19
+ export function ingestEndpoint(env = {}) {
20
+ const raw = String(env[CLI.endpointEnv] || INGEST_ENDPOINT).trim().replace(/\/+$/, '')
21
+ let url
22
+ try { url = new URL(`${raw}/v1/traces`) } catch { return null }
23
+ return safe(url) && !url.username && !url.password ? raw : null
24
+ }
25
+
26
+ /**
27
+ * The site address (origin only, no trailing slash), or null when the override is not https or has a path.
28
+ * @param {Record<string, string|undefined>} [env]
29
+ * @returns {string|null}
30
+ */
31
+ export function siteUrl(env = {}) {
32
+ const raw = String(env[CLI.siteEnv] || SITE_URL).trim().replace(/\/+$/, '')
33
+ let url
34
+ try { url = new URL(raw) } catch { return null }
35
+ return safe(url) && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash ? url.origin : null
36
+ }
37
+
38
+ /**
39
+ * True when check may ask the site about the key: the site and the ingest address belong to the same UKNOWS.
40
+ * A test ingest address with the production site would ask the wrong UKNOWS, which does not know the test key.
41
+ * @param {Record<string, string|undefined>} [env]
42
+ */
43
+ export function sameUknows(env = {}) {
44
+ return Boolean(env[CLI.siteEnv]) || !env[CLI.endpointEnv]
45
+ }
46
+
47
+ /** The docs page of the site this run uses. */
48
+ export const docsUrl = (env = {}) => `${siteUrl(env) ?? SITE_URL}/docs`
49
+ /** The llms.txt of the site this run uses. */
50
+ export const llmsUrl = (env = {}) => `${siteUrl(env) ?? SITE_URL}/llms.txt`