@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/LICENSE +190 -0
- package/README.md +142 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/check.mjs +211 -0
- package/lib/cli.mjs +166 -0
- package/lib/contract-docs.mjs +1154 -0
- package/lib/contract.mjs +66 -0
- package/lib/detect.mjs +575 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +372 -0
- package/lib/endpoint.mjs +50 -0
- package/lib/engine.mjs +607 -0
- package/lib/existing.mjs +168 -0
- package/lib/git.mjs +47 -0
- package/lib/hosts.mjs +145 -0
- package/lib/manifest.mjs +112 -0
- package/lib/settings.mjs +280 -0
- package/lib/stacks/dotnet.mjs +165 -0
- package/lib/stacks/guided.mjs +104 -0
- package/lib/stacks/index.mjs +10 -0
- package/lib/stacks/java.mjs +56 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +235 -0
- package/lib/stacks/otel.mjs +11 -0
- package/lib/stacks/python-bootstrap.mjs +51 -0
- package/lib/stacks/python.mjs +434 -0
- package/lib/types.mjs +161 -0
- package/lib/ui.mjs +106 -0
- package/lib/workspace.mjs +198 -0
- package/package.json +42 -4
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
|
+
}
|
package/lib/endpoint.mjs
ADDED
|
@@ -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`
|