@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/engine.mjs
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The setup and remove flows. Everything that reads the repo, runs a
|
|
3
|
+
* program or asks the user comes in through deps, so tests can use fakes.
|
|
4
|
+
* Each flow returns {code, report}: the exit code and the --json report.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn as spawnChild } from 'node:child_process'
|
|
7
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { dirname, sep } from 'node:path'
|
|
9
|
+
import { runCheck } from './check.mjs'
|
|
10
|
+
import { CLI, EXAMPLE_SERVICE, INGEST_ENDPOINT, SECRET } from './contract.mjs'
|
|
11
|
+
import { detect } from './detect.mjs'
|
|
12
|
+
import { docsUrl, llmsUrl } from './endpoint.mjs'
|
|
13
|
+
import { countLines, unified } from './diff.mjs'
|
|
14
|
+
import { shellWords } from './doc.mjs'
|
|
15
|
+
import { explainApm, findExisting } from './existing.mjs'
|
|
16
|
+
import { gitStatus } from './git.mjs'
|
|
17
|
+
import { keySteps } from './hosts.mjs'
|
|
18
|
+
import { BOM, TOOL, VERSION, changeEntry, manifestPaths, manifestText, mergeManifest, readManifest, sha256, undoChange } from './manifest.mjs'
|
|
19
|
+
import { hasKey, redactKey, settingsPlan } from './settings.mjs'
|
|
20
|
+
import { APPLIERS } from './stacks/index.mjs'
|
|
21
|
+
import { UsageError, frameworkLabel, hostLabel, list, plural, stackLabel, unique } from './ui.mjs'
|
|
22
|
+
import { createWorkspace, repoView } from './workspace.mjs'
|
|
23
|
+
|
|
24
|
+
const DEFAULTS = { detect, findExisting, explainApm, settingsPlan, keySteps, appliers: APPLIERS, spawn: runCommand, gitStatus, createWorkspace, runCheck }
|
|
25
|
+
/** The language menu, in this order, then 'Something else'. */
|
|
26
|
+
const MENU = ['node', 'python', 'java', 'dotnet', 'go', 'php', 'ruby', 'otel']
|
|
27
|
+
const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'])
|
|
28
|
+
const DIFF_LIMIT = 200
|
|
29
|
+
/** On Windows, package managers run through a shell (.cmd files), so every argument must be plain. */
|
|
30
|
+
const SAFE_ARG = /^[@A-Za-z0-9._/=:~+-]+$/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean}} opts
|
|
34
|
+
* @param {Partial<typeof DEFAULTS> & {ui: ReturnType<typeof import('./ui.mjs').createUI>}} deps
|
|
35
|
+
*/
|
|
36
|
+
export async function runSetup(opts, deps) {
|
|
37
|
+
const d = { ...DEFAULTS, ...deps }
|
|
38
|
+
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'setup', status: 'failed', exitCode: 1,
|
|
39
|
+
services: [], files: [], hosts: [], keySteps: [], check: null, problems: [], next: CLI.next }
|
|
40
|
+
return guard(d.ui, report, () => setup(opts, d, report))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {{root: string, yes?: boolean, json?: boolean, force?: boolean}} opts */
|
|
44
|
+
export async function runRemove(opts, deps) {
|
|
45
|
+
const d = { ...DEFAULTS, ...deps }
|
|
46
|
+
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: [] }
|
|
47
|
+
return guard(d.ui, report, () => remove(opts, d, report))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function guard(ui, report, flow) {
|
|
51
|
+
let result
|
|
52
|
+
try {
|
|
53
|
+
result = await flow()
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof UsageError) {
|
|
56
|
+
ui.error(error.message)
|
|
57
|
+
ui.error('Run uknows-setup --help.')
|
|
58
|
+
result = ['usage-error', 2]
|
|
59
|
+
} else {
|
|
60
|
+
ui.error(`Something went wrong: ${error.message}`)
|
|
61
|
+
report.problems.push({ code: 'error', message: error.message })
|
|
62
|
+
result = ['failed', 1]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
report.status = result[0]
|
|
66
|
+
report.exitCode = result[1]
|
|
67
|
+
return { code: result[1], report }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function setup(opts, d, report) {
|
|
71
|
+
const { ui } = d, root = opts.root
|
|
72
|
+
// --yes never asks anything, so a script in a terminal cannot hang on a question.
|
|
73
|
+
const ask = ui.interactive && !opts.yes
|
|
74
|
+
const manifest = readManifest(root)
|
|
75
|
+
const detection = d.detect(root)
|
|
76
|
+
const ws = d.createWorkspace(root)
|
|
77
|
+
const repo = repoView(ws)
|
|
78
|
+
const known = (s) => manifest?.services.find((m) => m.dir === s.dir)?.stack
|
|
79
|
+
report.hosts = detection.hosts
|
|
80
|
+
report.problems.push(...detection.problems)
|
|
81
|
+
let services = detection.services.map((s) => ({ ...s }))
|
|
82
|
+
ui.print('UKNOWS setup')
|
|
83
|
+
ui.print()
|
|
84
|
+
const endpoint = opts.endpoint ?? INGEST_ENDPOINT
|
|
85
|
+
const env = opts.env ?? {}
|
|
86
|
+
const DOCS_URL = docsUrl(env), LLMS_URL = llmsUrl(env)
|
|
87
|
+
if (endpoint !== INGEST_ENDPOINT) ui.print(`Note: the settings use the test ingest address ${endpoint} from ${CLI.endpointEnv}.`)
|
|
88
|
+
for (const p of detection.problems.filter((x) => x.code === 'frontend' || x.code === 'skipped')) ui.print(`Note: ${p.message}`)
|
|
89
|
+
|
|
90
|
+
if (services.length > 1) {
|
|
91
|
+
ui.print(`Found ${services.length} services:`)
|
|
92
|
+
for (const s of services) ui.print(` ${s.dir}: ${describe(s)}`)
|
|
93
|
+
ui.print()
|
|
94
|
+
if (!opts.all) {
|
|
95
|
+
if (!ask) {
|
|
96
|
+
report.services = services.map((s) => serviceReport(s))
|
|
97
|
+
ui.print(`Pick the services to set up. Use --all to set up all of them, or --path <folder> to set up 1. Or run this in a terminal${opts.yes ? ' without --yes' : ''} to pick from a list.`)
|
|
98
|
+
return ['needs-selection', 2]
|
|
99
|
+
}
|
|
100
|
+
const pick = await ui.choose('Which service do you want to set up?', [...services.map((s) => `${s.dir}: ${describe(s)}`), 'All of them'])
|
|
101
|
+
if (pick < services.length) services = [services[pick]]
|
|
102
|
+
ui.print()
|
|
103
|
+
}
|
|
104
|
+
} else if (services[0].lang) {
|
|
105
|
+
ui.print(`Found 1 service: ${describe(services[0], true)}`)
|
|
106
|
+
ui.print()
|
|
107
|
+
}
|
|
108
|
+
if (opts.serviceName && services.length > 1) throw new UsageError('Use --service-name with 1 service only. Use --path to pick that service.')
|
|
109
|
+
|
|
110
|
+
// A browser app gets no packages and no settings: OpenTelemetry for Node.js runs in a server.
|
|
111
|
+
const frontend = (s) => s.problems.some((x) => x.code === 'frontend')
|
|
112
|
+
if (services.some(frontend)) {
|
|
113
|
+
for (const s of services.filter(frontend)) {
|
|
114
|
+
ui.print(`${s.dir === '.' ? 'This folder' : s.dir}: ${s.problems.find((x) => x.code === 'frontend').message} This tool changes nothing here.`)
|
|
115
|
+
}
|
|
116
|
+
services = services.filter((s) => !frontend(s))
|
|
117
|
+
if (!services.length) {
|
|
118
|
+
report.services = detection.services.map((x) => serviceReport(x))
|
|
119
|
+
ui.print(`Run this tool in the folder of your server app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
120
|
+
return ['not-found', 3]
|
|
121
|
+
}
|
|
122
|
+
ui.print()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A folder with nothing recognized: ask which stack it uses, or stop.
|
|
126
|
+
const chosen = new Map()
|
|
127
|
+
for (const s of services.filter((x) => !x.lang && !known(x))) {
|
|
128
|
+
if (!ask) {
|
|
129
|
+
ui.print(`No app found in ${where(s.dir)}.`)
|
|
130
|
+
ui.print(`Run this tool in the folder of your app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
131
|
+
report.services = services.map((x) => serviceReport(x))
|
|
132
|
+
return ['not-found', 3]
|
|
133
|
+
}
|
|
134
|
+
const pick = await ui.choose(`No app found in ${where(s.dir)}. What does your app use?`, [...MENU.map(stackLabel), 'Something else'])
|
|
135
|
+
if (pick === MENU.length) {
|
|
136
|
+
ui.print(`To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
137
|
+
ui.print(`AI coding tools can read the same steps at ${LLMS_URL}`)
|
|
138
|
+
report.services = services.map((x) => serviceReport(x))
|
|
139
|
+
return ['not-found', 3]
|
|
140
|
+
}
|
|
141
|
+
ui.print()
|
|
142
|
+
chosen.set(s.dir, MENU[pick])
|
|
143
|
+
if (MENU[pick] !== 'otel') s.lang = MENU[pick]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Another APM agent in any service stops the whole run before anything is planned.
|
|
147
|
+
const items = services.map((s) => ({ s, name: opts.serviceName || s.name, existing: d.findExisting(s, ws) }))
|
|
148
|
+
report.services = items.map((it) => serviceReport(it.s, { name: it.name, otel: it.existing.otel, apm: it.existing.apm }))
|
|
149
|
+
const blocked = items.filter((it) => it.existing.apm.length)
|
|
150
|
+
if (blocked.length) {
|
|
151
|
+
for (const it of blocked) ui.block(d.explainApm(it.s, it.existing.apm))
|
|
152
|
+
ui.print('Nothing changed.')
|
|
153
|
+
return ['refused', 1]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Plan each service. All edits go into 1 workspace, so each step reads the edits before it.
|
|
157
|
+
const plans = []
|
|
158
|
+
for (const it of items) {
|
|
159
|
+
const { s } = it
|
|
160
|
+
const stack = known(s) ?? chosen.get(s.dir) ?? (it.existing.otel.length ? 'otel' : s.lang)
|
|
161
|
+
const applier = Object.prototype.hasOwnProperty.call(d.appliers, stack) ? d.appliers[stack] : null
|
|
162
|
+
if (!applier) throw new Error(`This tool has no steps for ${stack}.`)
|
|
163
|
+
report.problems.push(...s.problems)
|
|
164
|
+
const ambiguous = s.problems.find((x) => x.code === 'dockerfile-ambiguous')
|
|
165
|
+
if (ambiguous) {
|
|
166
|
+
// Editing the wrong Dockerfile changes nothing that ships: say so, and change nothing for this service.
|
|
167
|
+
const manual = [{ title: 'Pick the Dockerfile you deploy', lines: [ambiguous.message, 'Then run npx @uknows/setup again.'] }]
|
|
168
|
+
plans.push({ ...it, stack, plan: { summary: [], edits: [], commands: [], extraSettings: [], manual, notes: [] },
|
|
169
|
+
settings: { target: null, edits: [], summary: [], manual: [], notes: [] }, manual, notes: [] })
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if ((stack === 'node' || stack === 'python') && !s.start && !s.dockerfile && ask) {
|
|
173
|
+
const raw = await ui.ask('What command starts this app now? For example: node server.js. Press Enter to skip.')
|
|
174
|
+
if (raw) s.start = { kind: 'answer', file: '', line: 0, form: 'shell', raw, argv: shellWords(raw) }
|
|
175
|
+
}
|
|
176
|
+
const ctx = { ws, repo, name: it.name, detection, endpoint }
|
|
177
|
+
const plan = applier.plan(s, ctx)
|
|
178
|
+
for (const edit of plan.edits) ws.stage(edit)
|
|
179
|
+
const settings = plan.stop ? { target: null, edits: [], summary: [], manual: [], notes: [] } : d.settingsPlan(s, ctx, plan.extraSettings ?? [])
|
|
180
|
+
for (const edit of settings.edits) ws.stage(edit)
|
|
181
|
+
plans.push({ ...it, stack, plan, settings, manual: [...plan.manual, ...settings.manual].map(tidy),
|
|
182
|
+
notes: unique([...plan.notes, ...settings.notes].filter(Boolean)) })
|
|
183
|
+
}
|
|
184
|
+
report.services = plans.map((p) => serviceReport(p.s, { stack: p.stack, name: p.name, settings: p.settings.target ?? null,
|
|
185
|
+
summary: steps(p), commands: p.plan.commands.map((c) => ({ cwd: c.cwd, argv: [c.cmd, ...c.args] })),
|
|
186
|
+
manual: p.manual, notes: p.notes, otel: p.existing.otel }))
|
|
187
|
+
report.keySteps = d.keySteps(detection.hosts, { envFile: plans.some((p) => p.settings.target?.kind === 'env-file') })
|
|
188
|
+
// The docs snippets use an example service name. With 1 service, show its real name.
|
|
189
|
+
if (plans.length === 1) {
|
|
190
|
+
for (const snippet of report.keySteps.flatMap((b) => b.snippets ?? [])) snippet.code = snippet.code.split(EXAMPLE_SERVICE).join(plans[0].name)
|
|
191
|
+
}
|
|
192
|
+
const changes = ws.changes()
|
|
193
|
+
// A file that holds a key is never changed, so the key cannot reach the diff or the manifest.
|
|
194
|
+
const keyed = changes.filter((c) => hasKey(c.before) || hasKey(c.after)).map((c) => c.file)
|
|
195
|
+
if (keyed.length) {
|
|
196
|
+
ui.block({ title: 'Remove the key from your repo', lines: [
|
|
197
|
+
...keyed.map((file) => `${file} sets ${SECRET.name} to a key. This tool does not change a file that holds a key.`),
|
|
198
|
+
`Remove ${SECRET.name} from ${keyed.length === 1 ? 'that file' : 'those files'}. ${SECRET.summary}`,
|
|
199
|
+
'Get a new key. The old key stays in your git history.', 'Then run npx @uknows/setup again.'] })
|
|
200
|
+
ui.print('Nothing changed.')
|
|
201
|
+
report.problems.push({ code: 'key-in-repo', message: `${plural(keyed.length, 'file')} that this tool would change ${keyed.length === 1 ? 'holds' : 'hold'} a key.` })
|
|
202
|
+
return ['refused', 1]
|
|
203
|
+
}
|
|
204
|
+
const commands = plans.flatMap((p) => p.plan.commands)
|
|
205
|
+
const manual = plans.flatMap((p) => p.manual)
|
|
206
|
+
const exit = manual.length ? 3 : 0
|
|
207
|
+
|
|
208
|
+
if (!changes.length && !commands.length) {
|
|
209
|
+
ui.print(manual.length ? 'Nothing for this tool to change. The steps below are yours.' : 'Already set up. Nothing to change.')
|
|
210
|
+
printManual(ui, manual)
|
|
211
|
+
return ['nothing-to-change', exit]
|
|
212
|
+
}
|
|
213
|
+
printPlan(ui, plans, detection, changes, commands, opts)
|
|
214
|
+
if (opts.dryRun || (opts.json && !opts.yes)) {
|
|
215
|
+
printManual(ui, manual)
|
|
216
|
+
ui.print('This was a dry run. Nothing changed.')
|
|
217
|
+
return ['planned', exit]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!opts.force) {
|
|
221
|
+
const git = d.gitStatus(root, manifestPaths(manifest))
|
|
222
|
+
if (!git.repo) {
|
|
223
|
+
const message = git.git ? 'This folder is not in a git repo. Keep a copy, so you can review what this tool changes.'
|
|
224
|
+
: 'git is not installed, so this tool cannot check for changes that are not committed.'
|
|
225
|
+
ui.print(`Note: ${message}`)
|
|
226
|
+
report.problems.push({ code: 'no-git', message })
|
|
227
|
+
} else if (git.paths.length) {
|
|
228
|
+
ui.error('This repo has changes that are not committed:')
|
|
229
|
+
for (const path of git.paths.slice(0, 10)) ui.error(` ${path}`)
|
|
230
|
+
if (git.paths.length > 10) ui.error(` and ${git.paths.length - 10} more`)
|
|
231
|
+
ui.error('Commit or stash your changes first, so you can review what this tool changes. Or run again with --force.')
|
|
232
|
+
report.problems.push({ code: 'dirty-tree', message: `${plural(git.paths.length, 'path')} in the repo are not committed.` })
|
|
233
|
+
return ['refused', 1]
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!opts.yes) {
|
|
237
|
+
if (!ui.interactive) {
|
|
238
|
+
ui.print('Run again with --yes to apply.')
|
|
239
|
+
return ['planned', 2]
|
|
240
|
+
}
|
|
241
|
+
if (!(await ui.confirm('Apply? (Y/n)'))) {
|
|
242
|
+
ui.print('Nothing changed.')
|
|
243
|
+
return ['planned', 0]
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const record = plans.map((p) => ({ dir: p.s.dir, stack: p.stack, name: p.name }))
|
|
248
|
+
const saved = await apply({ root, ws, changes, commands, manifest, services: record, d })
|
|
249
|
+
if (!saved) return ['failed', 1]
|
|
250
|
+
report.files = printResult(ui, ws, saved, changes, commands)
|
|
251
|
+
printManual(ui, manual, false)
|
|
252
|
+
for (const block of report.keySteps) ui.block(block)
|
|
253
|
+
ui.print('Never put the key in a file in your repo.')
|
|
254
|
+
report.check = ask ? await checkKey(ui, d, env) : null
|
|
255
|
+
if (!report.check) ui.print(`To check your key, set ${SECRET.name} in this terminal, then run ${checkCommand(env)}.`)
|
|
256
|
+
ui.print(CLI.next)
|
|
257
|
+
return ['applied', exit]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Write the edits, run the package managers, write the manifest. On any
|
|
262
|
+
* failure, put every file back and return null. Returns the original bytes
|
|
263
|
+
* of every path it touched.
|
|
264
|
+
*/
|
|
265
|
+
async function apply({ root, ws, changes, commands, manifest, services, d }) {
|
|
266
|
+
const { ui } = d
|
|
267
|
+
// Ctrl-C stops the package manager. Stay alive, so the failed run is rolled back below.
|
|
268
|
+
const hold = () => {}
|
|
269
|
+
process.on('SIGINT', hold)
|
|
270
|
+
/** @type {Map<string, Buffer|null>} */ const saved = new Map()
|
|
271
|
+
const made = []
|
|
272
|
+
const save = (file) => { if (!saved.has(file)) saved.set(file, readBytes(ws.abs(file))) }
|
|
273
|
+
const undo = () => {
|
|
274
|
+
for (const [file, bytes] of saved) {
|
|
275
|
+
try { if (bytes === null) removeFile(ws.abs(file)); else writeAtomic(ws.abs(file), bytes) } catch { /* best effort */ }
|
|
276
|
+
}
|
|
277
|
+
for (const dir of made.reverse()) try { rmdirSync(dir) } catch { /* not empty */ }
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
for (const c of changes) {
|
|
281
|
+
save(c.file)
|
|
282
|
+
if (ws.disk(c.file) !== c.before) {
|
|
283
|
+
ui.error(`${c.file} changed while this ran. Nothing changed. Run again.`)
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
const bytes = saved.get(c.file)
|
|
287
|
+
if (bytes && !bytes.equals(Buffer.from((c.bom ? BOM : '') + c.before, 'utf8'))) {
|
|
288
|
+
ui.error(`${c.file} is not UTF-8 text, so this tool cannot edit it safely. Nothing changed.`)
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
for (const file of [...changes.map((c) => c.file), CLI.manifest]) inside(ws, file)
|
|
293
|
+
const written = new Map()
|
|
294
|
+
for (const c of changes) {
|
|
295
|
+
const abs = ws.abs(c.file), bytes = Buffer.from((c.bom ? BOM : '') + c.after, 'utf8')
|
|
296
|
+
makeDirs(dirname(abs), made)
|
|
297
|
+
writeAtomic(abs, bytes)
|
|
298
|
+
written.set(c.file, bytes)
|
|
299
|
+
}
|
|
300
|
+
for (const command of commands) for (const file of command.touches) save(file)
|
|
301
|
+
for (const command of commands) {
|
|
302
|
+
const result = await d.spawn(command.cmd, command.args, { cwd: ws.abs(command.cwd) })
|
|
303
|
+
if (result.code !== 0) {
|
|
304
|
+
undo()
|
|
305
|
+
ui.error(`${command.cmd} failed${result.error ? `: ${result.error}` : ''}. Nothing changed.`)
|
|
306
|
+
ui.error(` ${[command.cmd, ...command.args].join(' ')}${command.cwd === '.' ? '' : ` (in ${command.cwd})`}`)
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const entries = changes.map((c) => changeEntry(c, saved.get(c.file), written.get(c.file)))
|
|
311
|
+
const packages = commands.map((c) => ({ cwd: c.cwd, cmd: c.cmd, added: c.added, undo: [c.undo.cmd, ...c.undo.args], touches: c.touches,
|
|
312
|
+
before: Object.fromEntries(c.touches.map((file) => [file, sha256(saved.get(file))])) }))
|
|
313
|
+
save(CLI.manifest)
|
|
314
|
+
const file = ws.abs(CLI.manifest)
|
|
315
|
+
makeDirs(dirname(file), made)
|
|
316
|
+
writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages }))))
|
|
317
|
+
return saved
|
|
318
|
+
} catch (error) {
|
|
319
|
+
undo()
|
|
320
|
+
ui.error(`Could not apply the changes: ${error.message}. Nothing changed.`)
|
|
321
|
+
return null
|
|
322
|
+
} finally {
|
|
323
|
+
process.off('SIGINT', hold)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function remove(opts, d, report) {
|
|
328
|
+
const { ui } = d, root = opts.root
|
|
329
|
+
const manifest = readManifest(root)
|
|
330
|
+
if (!manifest) {
|
|
331
|
+
ui.print(`Nothing to remove. This folder has no ${CLI.manifest}.`)
|
|
332
|
+
return ['nothing-to-remove', 0]
|
|
333
|
+
}
|
|
334
|
+
const ws = d.createWorkspace(root)
|
|
335
|
+
const packages = [...manifest.packages].reverse(), changes = [...manifest.changes].reverse()
|
|
336
|
+
for (const file of [...changes.map((c) => c.file), CLI.manifest]) inside(ws, file)
|
|
337
|
+
const runLine = (p) => `Run ${p.undo.join(' ')}${p.cwd === '.' ? '' : ` in ${p.cwd}`}.`
|
|
338
|
+
ui.print('UKNOWS remove')
|
|
339
|
+
ui.print()
|
|
340
|
+
ui.print('This undoes what npx @uknows/setup changed:')
|
|
341
|
+
for (const p of packages) ui.print(` ${runLine(p)}`)
|
|
342
|
+
for (const file of unique(changes.map((c) => c.file))) {
|
|
343
|
+
ui.print(` ${changes.some((c) => c.file === file && c.created) ? 'Delete' : 'Restore'} ${file}.`)
|
|
344
|
+
}
|
|
345
|
+
ui.print(` Delete ${CLI.manifest}.`)
|
|
346
|
+
if (opts.json && !opts.yes) return ['planned', 0]
|
|
347
|
+
if (!opts.yes) {
|
|
348
|
+
if (!ui.interactive) {
|
|
349
|
+
ui.print('Run again with --yes to remove.')
|
|
350
|
+
return ['planned', 2]
|
|
351
|
+
}
|
|
352
|
+
if (!(await ui.confirm('Remove? (Y/n)'))) {
|
|
353
|
+
ui.print('Nothing changed.')
|
|
354
|
+
return ['planned', 0]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const left = { packages: [], changes: [] }
|
|
359
|
+
for (const p of packages) {
|
|
360
|
+
const [cmd, ...args] = p.undo
|
|
361
|
+
const result = await d.spawn(cmd, args, { cwd: ws.abs(p.cwd) })
|
|
362
|
+
report.commands.push({ cwd: p.cwd, argv: p.undo, ok: result.code === 0 })
|
|
363
|
+
if (result.code !== 0) left.packages.unshift(p)
|
|
364
|
+
}
|
|
365
|
+
for (const c of changes) {
|
|
366
|
+
const abs = ws.abs(c.file)
|
|
367
|
+
const result = undoChange(c, readBytes(abs))
|
|
368
|
+
if (result.reason) {
|
|
369
|
+
report.files.push({ file: c.file, result: 'skipped', reason: result.reason })
|
|
370
|
+
left.changes.unshift(c)
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
if (result.bytes === null) removeFile(abs)
|
|
374
|
+
else writeAtomic(abs, result.bytes)
|
|
375
|
+
report.files.push({ file: c.file, result: result.bytes === null ? 'deleted' : 'restored' })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Keep a record of what is left, so remove can try again. --force forgets it.
|
|
379
|
+
const rest = left.packages.length + left.changes.length
|
|
380
|
+
const file = ws.abs(CLI.manifest)
|
|
381
|
+
if (rest && !opts.force) writeAtomic(file, Buffer.from(manifestText({ ...manifest, ...left })))
|
|
382
|
+
else {
|
|
383
|
+
removeFile(file)
|
|
384
|
+
try { rmdirSync(dirname(file)) } catch { /* not empty */ }
|
|
385
|
+
}
|
|
386
|
+
ui.print()
|
|
387
|
+
if (rest) {
|
|
388
|
+
ui.print('Undo these by hand:')
|
|
389
|
+
for (const p of left.packages) ui.print(` ${runLine(p)} It failed here.`)
|
|
390
|
+
for (const f of report.files.filter((x) => x.result === 'skipped')) ui.print(` ${f.file}: ${f.reason}`)
|
|
391
|
+
if (!opts.force) ui.print(`${CLI.manifest} still lists them. Fix them and run remove again, or run remove --force to forget them.`)
|
|
392
|
+
return ['partial', 3]
|
|
393
|
+
}
|
|
394
|
+
ui.print('Removed what setup added.')
|
|
395
|
+
// Package managers write package.json and lockfiles their own way, so say when one is not byte for byte as before.
|
|
396
|
+
const original = new Map()
|
|
397
|
+
for (const p of manifest.packages) for (const [file, sha] of Object.entries(p.before ?? {})) if (!original.has(file)) original.set(file, sha)
|
|
398
|
+
const differ = [...original].filter(([file, sha]) => sha256(readBytes(ws.abs(file))) !== sha).map(([file]) => file)
|
|
399
|
+
if (differ.length) {
|
|
400
|
+
for (const file of differ) report.files.push({ file, result: 'differs', reason: 'The package manager did not write it back byte for byte.' })
|
|
401
|
+
ui.print(`Your package manager did not write ${differ.join(', ')} back byte for byte. Check the difference with git diff.`)
|
|
402
|
+
}
|
|
403
|
+
return ['removed', 0]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The check command as the reader should type it, with the test addresses of this run. */
|
|
407
|
+
function checkCommand(env) {
|
|
408
|
+
const vars = [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
|
|
409
|
+
return [...vars, 'npx @uknows/setup check'].join(' ')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Check your key, right after setup: the key from this terminal, or one pasted now. The pasted key is never
|
|
414
|
+
* written anywhere and never shows on the screen. Returns the --json check entry, or null when skipped.
|
|
415
|
+
*/
|
|
416
|
+
async function checkKey(ui, d, env) {
|
|
417
|
+
ui.print()
|
|
418
|
+
ui.print('Check your key')
|
|
419
|
+
let headers = null
|
|
420
|
+
if (env[SECRET.name]?.trim()) {
|
|
421
|
+
if (await ui.confirm(`Check the key in ${SECRET.name} from this terminal now? (Y/n)`)) headers = env[SECRET.name]
|
|
422
|
+
} else {
|
|
423
|
+
const key = (await ui.secret('Paste your UKNOWS key to check it now. It does not show on the screen. Press Enter to skip:')).trim()
|
|
424
|
+
// Accept the key as copied, also with the header name or quotes around it.
|
|
425
|
+
const bare = key.replace(/^["']|["']$/g, '').replace(new RegExp(`^${SECRET.header}\\s*[=:]\\s*`, 'i'), '').trim()
|
|
426
|
+
if (bare) headers = `${SECRET.header}=${bare}`
|
|
427
|
+
}
|
|
428
|
+
if (!headers) return null
|
|
429
|
+
const result = await d.runCheck({ env, headers })
|
|
430
|
+
for (const line of result.lines) (result.ok ? ui.print : ui.error)(` ${line}`)
|
|
431
|
+
ui.print()
|
|
432
|
+
const status = result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
|
|
433
|
+
return { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function printPlan(ui, plans, detection, changes, commands, opts) {
|
|
437
|
+
ui.print('Runs on:')
|
|
438
|
+
for (const h of detection.hosts) {
|
|
439
|
+
const files = unique(h.evidence.map((e) => e.file))
|
|
440
|
+
ui.print(` ${hostLabel(h.id)}. ${files.length ? `Found in ${files.join(', ')}.` : 'No deploy files found, so the steps are the general ones.'}`)
|
|
441
|
+
}
|
|
442
|
+
for (const p of plans) {
|
|
443
|
+
const from = opts.serviceName ? 'from --service-name' : p.s.nameFrom === 'existing' ? 'already set in the repo'
|
|
444
|
+
: p.s.nameFrom === 'folder' ? 'from the folder name' : `from ${p.s.nameFrom}`
|
|
445
|
+
ui.print()
|
|
446
|
+
if (plans.length === 1) {
|
|
447
|
+
ui.print(`Service name: ${p.name} (${from}).${opts.serviceName ? '' : ' Change it with --service-name.'}`)
|
|
448
|
+
ui.print(`Plan for ${p.name}:`)
|
|
449
|
+
} else ui.print(`Plan for ${p.name} in ${p.s.dir} (name ${from}):`)
|
|
450
|
+
steps(p).forEach((step, i) => ui.print(` ${i + 1}. ${step}`))
|
|
451
|
+
if (p.manual.length) ui.print(` After this, you do ${plural(p.manual.length, 'step')} by hand: ${p.manual.map((b) => b.title).join('. ')}.`)
|
|
452
|
+
const chosen = p.s.evidence?.find((e) => e.what.startsWith('Dockerfile for this service: '))
|
|
453
|
+
if (chosen) ui.print(` Dockerfile: ${chosen.file}, because ${chosen.what.slice('Dockerfile for this service: '.length)}.`)
|
|
454
|
+
for (const note of [...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message)]) ui.print(` Note: ${note}`)
|
|
455
|
+
}
|
|
456
|
+
ui.print()
|
|
457
|
+
const edited = changes.filter((c) => c.before !== null).map((c) => c.file)
|
|
458
|
+
const created = changes.filter((c) => c.before === null).map((c) => c.file)
|
|
459
|
+
const changed = unique([...edited, ...commands.flatMap((c) => c.touches)])
|
|
460
|
+
if (changed.length) ui.print(`Files to change: ${changed.join(', ')}`)
|
|
461
|
+
if (created.length) ui.print(`Files to add: ${created.join(', ')}`)
|
|
462
|
+
ui.print(`It also writes ${CLI.manifest}, so you can undo this with npx @uknows/setup remove.`)
|
|
463
|
+
// Exactly what changes, before anything does: the diff of every file edit, and what each package manager adds.
|
|
464
|
+
const out = [...changes.flatMap((c) => unified(c.file, c.before, c.after).split('\n').map(redactKey)),
|
|
465
|
+
...commands.map((c) => `${list(c.touches)}: ${c.cmd} adds ${list(c.added ?? [])}.`)]
|
|
466
|
+
if (out.length) {
|
|
467
|
+
ui.print()
|
|
468
|
+
ui.print('The changes:')
|
|
469
|
+
ui.print()
|
|
470
|
+
for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
|
|
471
|
+
if (out.length > DIFF_LIMIT) ui.print(`And ${plural(out.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
|
|
472
|
+
ui.print()
|
|
473
|
+
}
|
|
474
|
+
const tools = unique(commands.map((c) => c.cmd))
|
|
475
|
+
if (tools.length) {
|
|
476
|
+
ui.print(`This runs ${tools.join(' and ')} with install scripts turned off. ${commands.every((c) => c.lockOnly)
|
|
477
|
+
? `${tools.length === 1 ? 'It writes' : 'They write'} only ${list(unique(commands.flatMap((c) => c.touches)))}.`
|
|
478
|
+
: `${tools.length === 1 ? 'It downloads' : 'They download'} the packages.`}`)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Print the diff of every file this run changed. Returns the --json files list. */
|
|
483
|
+
function printResult(ui, ws, saved, changes, commands) {
|
|
484
|
+
const files = [], out = []
|
|
485
|
+
for (const [file, before] of saved) {
|
|
486
|
+
if (file === CLI.manifest) continue
|
|
487
|
+
const after = readBytes(ws.abs(file))
|
|
488
|
+
if (before === null ? after === null : after !== null && before.equals(after)) continue
|
|
489
|
+
const a = before === null ? null : text(before), b = after === null ? '' : text(after)
|
|
490
|
+
const change = changes.find((c) => c.file === file), command = commands.find((c) => c.touches.includes(file))
|
|
491
|
+
const why = unique([...(change?.why ?? []), ...(command ? [command.why] : [])])
|
|
492
|
+
if (LOCKFILES.has(file.split('/').pop())) {
|
|
493
|
+
files.push({ file, created: before === null, added: null, removed: null, why })
|
|
494
|
+
out.push(`${file}: updated by ${command?.cmd ?? 'the package manager'}.`)
|
|
495
|
+
} else if (change && change.after === b) {
|
|
496
|
+
// The plan showed this diff already.
|
|
497
|
+
const counts = countLines(a, b)
|
|
498
|
+
files.push({ file, created: before === null, ...counts, why })
|
|
499
|
+
out.push(`${file}: ${before === null ? 'added' : 'changed'} as shown above (+${counts.added} -${counts.removed}).`)
|
|
500
|
+
} else {
|
|
501
|
+
files.push({ file, created: before === null, ...countLines(a, b), why })
|
|
502
|
+
out.push(...unified(file, a, b).split('\n').map(redactKey))
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
ui.print()
|
|
506
|
+
ui.print(`Done. Changed ${plural(files.length, 'file')}:`)
|
|
507
|
+
ui.print()
|
|
508
|
+
for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
|
|
509
|
+
if (out.length > DIFF_LIMIT) ui.print('Run git diff to see the rest.')
|
|
510
|
+
ui.print()
|
|
511
|
+
ui.print(`To undo this, run npx @uknows/setup remove. ${CLI.manifest} lists what changed. It has no secrets.`)
|
|
512
|
+
ui.print()
|
|
513
|
+
return files
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function printManual(ui, manual, gap = true) {
|
|
517
|
+
if (!manual.length) return
|
|
518
|
+
if (gap) ui.print()
|
|
519
|
+
ui.print(`You need to do ${plural(manual.length, 'more step')}:`)
|
|
520
|
+
ui.print()
|
|
521
|
+
for (const block of manual) ui.block(block)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const where = (dir) => (dir === '.' ? 'this folder' : dir)
|
|
525
|
+
/** Drop empty lines from a block, so a docs note that is gone never prints as 'undefined'. */
|
|
526
|
+
const tidy = (block) => ({ ...block, lines: block.lines.filter((line) => typeof line === 'string' && line) })
|
|
527
|
+
const steps = (p) => unique([...p.plan.commands.map((c) => c.why), ...p.plan.summary, ...p.settings.summary])
|
|
528
|
+
const text = (bytes) => { const t = bytes.toString('utf8'); return t.startsWith(BOM) ? t.slice(1) : t }
|
|
529
|
+
|
|
530
|
+
function describe(s, withDir = false) {
|
|
531
|
+
const frameworks = s.frameworks.length ? ` (${s.frameworks.map(frameworkLabel).join(', ')})` : ''
|
|
532
|
+
const start = !s.start ? 'Its start command is not clear.'
|
|
533
|
+
: s.start.kind === 'package.json' ? `It starts from the start script in ${s.start.file}.`
|
|
534
|
+
: `It starts from ${s.start.file} line ${s.start.line}.`
|
|
535
|
+
return `${stackLabel(s.lang)}${frameworks}${withDir ? ` in ${where(s.dir)}` : ''}. ${start}`
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** A service in the --json report. start never holds the command text. */
|
|
539
|
+
function serviceReport(s, extra = {}) {
|
|
540
|
+
return { dir: s.dir, lang: s.lang, stack: null, name: s.name, frameworks: s.frameworks,
|
|
541
|
+
start: s.start ? { kind: s.start.kind, file: s.start.file, line: s.start.line } : null, settings: null,
|
|
542
|
+
summary: [], commands: [], manual: [], notes: [], otel: [], apm: [], ...extra }
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Run a package manager. Its output goes to stderr, so --json stdout stays clean. */
|
|
546
|
+
export function runCommand(cmd, args, { cwd }) {
|
|
547
|
+
return new Promise((resolve) => {
|
|
548
|
+
const shell = process.platform === 'win32'
|
|
549
|
+
if (shell && ![cmd, ...args].every((arg) => SAFE_ARG.test(arg))) return resolve({ code: null, error: 'an argument has characters this tool does not pass to a shell' })
|
|
550
|
+
let settled = false
|
|
551
|
+
const finish = (result) => { if (!settled) { settled = true; resolve(result) } }
|
|
552
|
+
try {
|
|
553
|
+
const child = spawnChild(cmd, args, { cwd, stdio: ['ignore', 2, 2], shell })
|
|
554
|
+
child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? `${cmd} was not found` : error.message }))
|
|
555
|
+
child.on('close', (code) => finish({ code, error: null }))
|
|
556
|
+
} catch (error) {
|
|
557
|
+
finish({ code: null, error: error.message })
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Bytes of a regular file, or null when it is missing, a link or not a file. */
|
|
563
|
+
function readBytes(abs) {
|
|
564
|
+
try {
|
|
565
|
+
return lstatSync(abs).isFile() ? readFileSync(abs) : null
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
|
|
568
|
+
throw error
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Write through a temp file in the same folder, then rename. Keeps the file mode. */
|
|
573
|
+
function writeAtomic(abs, bytes) {
|
|
574
|
+
const temp = `${abs}.uknows-${process.pid}.tmp`
|
|
575
|
+
let mode = null
|
|
576
|
+
try { mode = statSync(abs).mode & 0o7777 } catch { /* a new file */ }
|
|
577
|
+
try {
|
|
578
|
+
writeFileSync(temp, bytes)
|
|
579
|
+
if (mode !== null) chmodSync(temp, mode)
|
|
580
|
+
renameSync(temp, abs)
|
|
581
|
+
} catch (error) {
|
|
582
|
+
try { unlinkSync(temp) } catch { /* not written */ }
|
|
583
|
+
throw error
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Throw unless the real folder of a repo path is inside the repo, so a linked folder cannot lead a write out of it. */
|
|
588
|
+
function inside(ws, file) {
|
|
589
|
+
let dir = dirname(ws.abs(file))
|
|
590
|
+
while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir)
|
|
591
|
+
const base = realpathSync(ws.root), real = realpathSync(dir)
|
|
592
|
+
if (real !== base && !real.startsWith(base.endsWith(sep) ? base : base + sep)) throw new Error(`${file} is in a folder outside the repo`)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function removeFile(abs) {
|
|
596
|
+
try { unlinkSync(abs) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Create a folder and its missing parents. Records the new folders, top first, for rollback. */
|
|
600
|
+
function makeDirs(dir, made) {
|
|
601
|
+
const missing = []
|
|
602
|
+
for (let d = dir; !existsSync(d); d = dirname(d)) missing.unshift(d)
|
|
603
|
+
if (missing.length) {
|
|
604
|
+
mkdirSync(dir, { recursive: true })
|
|
605
|
+
made.push(...missing)
|
|
606
|
+
}
|
|
607
|
+
}
|