@uknows/setup 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }