@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/ui.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Terminal input and output. Plain text, no color. Normal text goes to
|
|
3
|
+
* stdout and errors to stderr. With --json, normal text is left out, so
|
|
4
|
+
* stdout holds only the JSON report, and nothing ever prompts.
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from 'node:readline'
|
|
7
|
+
import { HOSTS, STACKS } from './contract.mjs'
|
|
8
|
+
|
|
9
|
+
/** A usage error: exit 2 with the message and a hint to run --help. */
|
|
10
|
+
export class UsageError extends Error {}
|
|
11
|
+
|
|
12
|
+
const FRAMEWORKS = { express: 'Express', fastify: 'Fastify', nestjs: 'NestJS', koa: 'Koa', hapi: 'Hapi', nitro: 'Nitro',
|
|
13
|
+
nuxt: 'Nuxt', next: 'Next.js', fastapi: 'FastAPI', django: 'Django', flask: 'Flask', starlette: 'Starlette',
|
|
14
|
+
falcon: 'Falcon', tornado: 'Tornado', aspnetcore: 'ASP.NET Core', 'spring-boot': 'Spring Boot', quarkus: 'Quarkus',
|
|
15
|
+
micronaut: 'Micronaut', echo: 'Echo', gin: 'Gin', chi: 'chi', laravel: 'Laravel', symfony: 'Symfony', slim: 'Slim',
|
|
16
|
+
rails: 'Rails', sinatra: 'Sinatra' }
|
|
17
|
+
|
|
18
|
+
export const stackLabel = (id) => STACKS.find((s) => s.id === id)?.label ?? 'an unknown stack'
|
|
19
|
+
export const hostLabel = (id) => HOSTS.find((h) => h.id === id)?.label ?? id
|
|
20
|
+
export const frameworkLabel = (id) => FRAMEWORKS[id] ?? id
|
|
21
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
22
|
+
export const unique = (list) => [...new Set(list)]
|
|
23
|
+
/** 'a', 'a and b', 'a, b and c'. */
|
|
24
|
+
export const list = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''))
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{stdin?: any, stdout?: any, stderr?: any, json?: boolean, interactive?: boolean}} [io]
|
|
28
|
+
* interactive defaults to: stdin and stdout are terminals and json is off.
|
|
29
|
+
*/
|
|
30
|
+
export function createUI({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, json = false, interactive } = {}) {
|
|
31
|
+
let rl = null, closed = false
|
|
32
|
+
const queue = [], waiting = []
|
|
33
|
+
const nextLine = () => {
|
|
34
|
+
if (!rl) {
|
|
35
|
+
rl = createInterface({ input: stdin, terminal: false })
|
|
36
|
+
rl.on('line', (line) => (waiting.length ? waiting.shift()(line) : queue.push(line)))
|
|
37
|
+
rl.on('close', () => { closed = true; while (waiting.length) waiting.shift()(null) })
|
|
38
|
+
}
|
|
39
|
+
if (queue.length) return Promise.resolve(queue.shift())
|
|
40
|
+
return closed ? Promise.resolve(null) : new Promise((resolve) => waiting.push(resolve))
|
|
41
|
+
}
|
|
42
|
+
const print = (text = '') => { if (!json) stdout.write(`${text}\n`) }
|
|
43
|
+
/** null when input ended. */
|
|
44
|
+
const prompt = (question) => { stdout.write(`${question} `); return nextLine() }
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
interactive: interactive ?? Boolean(stdin.isTTY && stdout.isTTY && !json),
|
|
48
|
+
print,
|
|
49
|
+
error: (text) => stderr.write(`${text}\n`),
|
|
50
|
+
/** @param {import('./types.mjs').Block} block */
|
|
51
|
+
block(block) {
|
|
52
|
+
print(block.title)
|
|
53
|
+
block.lines.forEach((line, i) => print(` ${i + 1}. ${line}`))
|
|
54
|
+
for (const s of block.snippets ?? []) {
|
|
55
|
+
print(` ${s.caption}:`)
|
|
56
|
+
for (const line of s.code.split('\n')) print(` ${line}`)
|
|
57
|
+
}
|
|
58
|
+
print()
|
|
59
|
+
},
|
|
60
|
+
async ask(question) { return ((await prompt(question)) ?? '').trim() },
|
|
61
|
+
/**
|
|
62
|
+
* Read 1 line without showing it, for a key. In a terminal, raw mode stops the echo. Backspace works,
|
|
63
|
+
* Ctrl-C and Ctrl-D skip. Without a terminal (tests, pipes) it reads a plain line. Never stores the answer.
|
|
64
|
+
*/
|
|
65
|
+
async secret(question) {
|
|
66
|
+
const tty = stdin.isTTY && typeof stdin.setRawMode === 'function'
|
|
67
|
+
if (tty) stdin.setRawMode(true)
|
|
68
|
+
let line
|
|
69
|
+
try { line = await prompt(question) } finally { if (tty) { stdin.setRawMode(false); stdout.write('\n') } }
|
|
70
|
+
let out = ''
|
|
71
|
+
for (const ch of line ?? '') {
|
|
72
|
+
if (ch === '\u0003' || ch === '\u0004') return ''
|
|
73
|
+
if (ch === '\u007f' || ch === '\b') out = out.slice(0, -1)
|
|
74
|
+
else if (ch === '\u0015') out = ''
|
|
75
|
+
else if (ch >= ' ') out += ch
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
},
|
|
79
|
+
/** Y/n. Empty means yes. Ended input means no. */
|
|
80
|
+
async confirm(question) {
|
|
81
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
82
|
+
const answer = await prompt(question)
|
|
83
|
+
if (answer === null) return false
|
|
84
|
+
const a = answer.trim().toLowerCase()
|
|
85
|
+
if (a === '' || a === 'y' || a === 'yes') return true
|
|
86
|
+
if (a === 'n' || a === 'no') return false
|
|
87
|
+
print('Type y or n.')
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
},
|
|
91
|
+
/** A numbered menu. Returns the 0-based index. 3 bad answers: usage error. */
|
|
92
|
+
async choose(title, options) {
|
|
93
|
+
print(title)
|
|
94
|
+
options.forEach((option, i) => print(` ${i + 1}. ${option}`))
|
|
95
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
96
|
+
const answer = await prompt(`Type a number from 1 to ${options.length}:`)
|
|
97
|
+
if (answer === null) break
|
|
98
|
+
const n = Number(answer.trim())
|
|
99
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return n - 1
|
|
100
|
+
print(`Type a number from 1 to ${options.length}.`)
|
|
101
|
+
}
|
|
102
|
+
throw new UsageError('No choice was made.')
|
|
103
|
+
},
|
|
104
|
+
close() { rl?.close() },
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A read view of the repo plus the edits staged in this run. Producers read
|
|
3
|
+
* through it, so a later producer sees the edits of an earlier one. Nothing
|
|
4
|
+
* here writes to disk: the engine writes ws.changes() on apply.
|
|
5
|
+
*
|
|
6
|
+
* Paths are repo-relative with '/' on every OS. '.' is the root.
|
|
7
|
+
*/
|
|
8
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join, posix, relative, resolve, sep } from 'node:path'
|
|
11
|
+
|
|
12
|
+
/** Folders that never hold a service or deploy file of the app. Dot folders are skipped too, except .github. */
|
|
13
|
+
export const SKIP_DIRS = new Set(['node_modules', 'vendor', 'venv', 'target', 'build', 'dist', 'out', 'bin', 'obj',
|
|
14
|
+
'__pycache__', 'coverage', 'test', 'tests', '__tests__', 'test_data', 'testdata', 'fixtures', 'examples', 'example', 'samples'])
|
|
15
|
+
const BOM = '\uFEFF'
|
|
16
|
+
const LIMIT = 20000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} Change The net change to 1 file after every staged edit.
|
|
20
|
+
* @property {string} file
|
|
21
|
+
* @property {string|null} before Disk text before this run, BOM removed. null = new file.
|
|
22
|
+
* @property {string} after Text to write, BOM removed.
|
|
23
|
+
* @property {boolean} bom The disk file starts with a UTF-8 BOM. The engine writes it back.
|
|
24
|
+
* @property {string[]} why The why of each edit, in order.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export class Workspace {
|
|
28
|
+
/** @param {string} root absolute or relative path of the repo root */
|
|
29
|
+
constructor(root) {
|
|
30
|
+
this.root = resolve(root)
|
|
31
|
+
/** @type {Map<string, string>} */ this.overlay = new Map()
|
|
32
|
+
/** @type {Map<string, string|null>} */ this.original = new Map()
|
|
33
|
+
/** @type {Map<string, string[]>} */ this.whys = new Map()
|
|
34
|
+
/** True when files() stopped at its limit. */ this.truncated = false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Absolute native path. */
|
|
38
|
+
abs(path) {
|
|
39
|
+
checkPath(path)
|
|
40
|
+
return path === '.' ? this.root : join(this.root, ...path.split('/'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
stat(path) {
|
|
44
|
+
try { return lstatSync(this.abs(path)) } catch { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
exists(path) { return this.overlay.has(path) || this.stat(path) !== null }
|
|
48
|
+
|
|
49
|
+
/** A real folder (not a link). */
|
|
50
|
+
isDir(path) { return Boolean(this.stat(path)?.isDirectory()) }
|
|
51
|
+
|
|
52
|
+
/** Current text: staged edits first, else disk. BOM removed. null when missing, a link or not a file. */
|
|
53
|
+
read(path) {
|
|
54
|
+
checkPath(path)
|
|
55
|
+
return this.overlay.has(path) ? this.overlay.get(path) : this.disk(path)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Text on disk, ignoring staged edits. BOM removed. */
|
|
59
|
+
disk(path) {
|
|
60
|
+
const stat = this.stat(path)
|
|
61
|
+
if (!stat?.isFile()) return null
|
|
62
|
+
const text = readFileSync(this.abs(path), 'utf8')
|
|
63
|
+
return text.startsWith(BOM) ? text.slice(1) : text
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
hasBom(path) {
|
|
67
|
+
if (!this.stat(path)?.isFile()) return false
|
|
68
|
+
const fd = openSync(this.abs(path), 'r')
|
|
69
|
+
try {
|
|
70
|
+
const head = Buffer.alloc(3)
|
|
71
|
+
return readSync(fd, head, 0, 3, 0) === 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf
|
|
72
|
+
} finally { closeSync(fd) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Names in a folder (files and folders, links included), plus staged new files. Sorted. */
|
|
76
|
+
list(dir = '.') {
|
|
77
|
+
const names = new Set()
|
|
78
|
+
try { for (const name of readdirSync(this.abs(dir))) names.add(name) } catch { /* missing */ }
|
|
79
|
+
const prefix = dir === '.' ? '' : `${dir}/`
|
|
80
|
+
for (const path of this.overlay.keys()) {
|
|
81
|
+
if (path.startsWith(prefix) && !path.slice(prefix.length).includes('/')) names.add(path.slice(prefix.length))
|
|
82
|
+
}
|
|
83
|
+
return [...names].sort(byCodePoint)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every regular file under dir, recursive, sorted. Skips links, SKIP_DIRS
|
|
88
|
+
* and dot folders except .github. Includes staged new files.
|
|
89
|
+
*/
|
|
90
|
+
files(dir = '.') {
|
|
91
|
+
const out = new Set()
|
|
92
|
+
const walk = (rel) => {
|
|
93
|
+
let entries
|
|
94
|
+
try { entries = readdirSync(this.abs(rel), { withFileTypes: true }) } catch { return }
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (out.size >= LIMIT) { this.truncated = true; return }
|
|
97
|
+
const path = rel === '.' ? entry.name : `${rel}/${entry.name}`
|
|
98
|
+
if (entry.isSymbolicLink()) continue
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
if (SKIP_DIRS.has(entry.name) || (entry.name.startsWith('.') && entry.name !== '.github')) continue
|
|
101
|
+
walk(path)
|
|
102
|
+
} else if (entry.isFile()) out.add(path)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(dir)
|
|
106
|
+
const prefix = dir === '.' ? '' : `${dir}/`
|
|
107
|
+
for (const path of this.overlay.keys()) if (path.startsWith(prefix)) out.add(path)
|
|
108
|
+
return [...out].sort(byCodePoint)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Stage an edit. Throws when edit.before is not the current text, which
|
|
113
|
+
* means 2 producers raced on the same file.
|
|
114
|
+
* @param {import('./types.mjs').Edit} edit
|
|
115
|
+
*/
|
|
116
|
+
stage(edit) {
|
|
117
|
+
const current = this.read(edit.file)
|
|
118
|
+
const stale = edit.before === null ? current !== null || this.exists(edit.file) : current !== edit.before
|
|
119
|
+
if (stale) throw new Error(`Edit for ${edit.file} is out of date`)
|
|
120
|
+
if (edit.after === current) return
|
|
121
|
+
if (!this.original.has(edit.file)) this.original.set(edit.file, current)
|
|
122
|
+
this.overlay.set(edit.file, edit.after)
|
|
123
|
+
this.whys.set(edit.file, [...(this.whys.get(edit.file) ?? []), edit.why])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Net changes, in the order files were first staged. Files that ended up unchanged are left out. */
|
|
127
|
+
changes() {
|
|
128
|
+
const out = []
|
|
129
|
+
for (const [file, before] of this.original) {
|
|
130
|
+
const after = this.overlay.get(file)
|
|
131
|
+
if (after === before) continue
|
|
132
|
+
out.push({ file, before, after, bom: before !== null && this.hasBom(file), why: this.whys.get(file) ?? [] })
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {string} root */
|
|
139
|
+
export const createWorkspace = (root) => new Workspace(root)
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @typedef {Object} RepoView A read view of the whole git repo around the root, for --path runs in a monorepo.
|
|
143
|
+
* @property {() => string[]} files
|
|
144
|
+
* @property {(path: string) => string|null} read
|
|
145
|
+
* @property {(path: string) => boolean} exists
|
|
146
|
+
* @property {(path: string) => import('node:fs').Stats|null} stat
|
|
147
|
+
* @property {string} top The repo top, relative to the root: '.', '..' or '../..'.
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The files of the whole git repo when the root is a folder inside it. Paths
|
|
152
|
+
* stay relative to the root: a file above it reads as '../../k8s/api.yaml'.
|
|
153
|
+
* Inside the root, reads go through ws, so staged edits count. Only folders
|
|
154
|
+
* up to 5 levels above the root count, and never the home folder, which can
|
|
155
|
+
* be a dotfiles repo. Nothing here writes.
|
|
156
|
+
* @param {Workspace} ws
|
|
157
|
+
* @returns {RepoView}
|
|
158
|
+
*/
|
|
159
|
+
export function repoView(ws) {
|
|
160
|
+
let top = null
|
|
161
|
+
for (let dir = ws.root, n = 0; n <= 5 && dir !== homedir(); dir = dirname(dir), n++) {
|
|
162
|
+
if (existsSync(join(dir, '.git'))) { top = dir; break }
|
|
163
|
+
if (dirname(dir) === dir) break
|
|
164
|
+
}
|
|
165
|
+
const view = { files: () => ws.files(), read: (path) => ws.read(path), exists: (path) => ws.exists(path), stat: (path) => ws.stat(path), top: '.' }
|
|
166
|
+
if (!top || top === ws.root) return view
|
|
167
|
+
const outer = createWorkspace(top)
|
|
168
|
+
const prefix = relative(top, ws.root).split(sep).join('/')
|
|
169
|
+
const above = (path) => path.startsWith('../')
|
|
170
|
+
const toTop = (path) => posix.normalize(`${prefix}/${path}`)
|
|
171
|
+
let files = null
|
|
172
|
+
return {
|
|
173
|
+
files: () => (files ??= [...new Set([...ws.files(), ...outer.files().map((p) => posix.relative(prefix, p))])].sort(byCodePoint)),
|
|
174
|
+
read: (path) => (above(path) ? outer.read(toTop(path)) : ws.read(path)),
|
|
175
|
+
exists: (path) => (above(path) ? outer.exists(toTop(path)) : ws.exists(path)),
|
|
176
|
+
stat: (path) => (above(path) ? outer.stat(toTop(path)) : ws.stat(path)),
|
|
177
|
+
top: prefix.split('/').map(() => '..').join('/'),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Folders from dir up to top, nearest first: upFrom('a/b', '..') is ['a/b', 'a', '.', '..']. */
|
|
182
|
+
export function upFrom(dir, top = '.') {
|
|
183
|
+
const out = []
|
|
184
|
+
for (let d = dir; out.length < 64; d = d === '.' ? '..' : /^\.\.(\/\.\.)*$/.test(d) ? `${d}/..` : d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '.') {
|
|
185
|
+
out.push(d)
|
|
186
|
+
if (d === top) break
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function checkPath(path) {
|
|
192
|
+
if (typeof path !== 'string' || !path || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)
|
|
193
|
+
|| (path !== '.' && path.split('/').some((part) => !part || part === '.' || part === '..'))) {
|
|
194
|
+
throw new Error(`Not a repo-relative path: ${path}`)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function byCodePoint(a, b) { return a < b ? -1 : a > b ? 1 : 0 }
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uknows/setup",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Add standard OpenTelemetry to your app in 1 command, so it sends traces to UKNOWS. Shows the change, asks once, can undo.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"uknows",
|
|
7
|
+
"opentelemetry",
|
|
8
|
+
"otel",
|
|
9
|
+
"apm",
|
|
10
|
+
"tracing",
|
|
11
|
+
"observability",
|
|
12
|
+
"setup",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://uknows.defacloud.my/docs",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/MohammediYunus/uknows.git",
|
|
19
|
+
"directory": "packages/uknows-setup"
|
|
20
|
+
},
|
|
21
|
+
"license": "Apache-2.0",
|
|
22
|
+
"author": "UKNOWS",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"uknows-setup": "bin/uknows-setup.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/",
|
|
29
|
+
"lib/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "node --test test/*.test.mjs",
|
|
42
|
+
"prepublishOnly": "node tools/prepublish.mjs"
|
|
43
|
+
}
|
|
44
|
+
}
|