@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.
- package/LICENSE +190 -0
- package/README.md +209 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +327 -0
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +251 -0
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +2736 -0
- package/lib/contract.mjs +149 -0
- package/lib/detect.mjs +627 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +455 -0
- package/lib/endpoint.mjs +81 -0
- package/lib/engine.mjs +1753 -0
- package/lib/existing.mjs +562 -0
- package/lib/git.mjs +100 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +650 -0
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +132 -0
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +468 -0
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +472 -0
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +227 -0
- package/lib/stacks/index.mjs +18 -0
- package/lib/stacks/java.mjs +57 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +358 -0
- package/lib/stacks/otel.mjs +63 -0
- package/lib/stacks/python-bootstrap.mjs +71 -0
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +632 -0
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +208 -0
- package/lib/ui.mjs +225 -0
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +201 -0
- 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
|
+
}
|
package/lib/endpoint.mjs
ADDED
|
@@ -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`
|