@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
package/lib/toml.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A small TOML reader for the files this tool reads: pyproject.toml, uv.lock and poetry.lock. It reads tables,
|
|
2
|
+
// strings, string arrays, keys and 1-line inline tables. It never writes TOML.
|
|
3
|
+
|
|
4
|
+
/** The lines of 1 table, without its header. Array tables ([[x]]) end a table. */
|
|
5
|
+
export function tomlTable(body, header) {
|
|
6
|
+
const out = []
|
|
7
|
+
let on = false
|
|
8
|
+
for (const line of (body ?? '').split(/\r?\n/)) {
|
|
9
|
+
const h = /^\s*\[([^\][]+)\]\s*(?:#.*)?$/.exec(line)
|
|
10
|
+
if (h) { on = h[1].trim() === header; continue }
|
|
11
|
+
if (/^\s*\[\[/.test(line)) { on = false; continue }
|
|
12
|
+
if (on) out.push(line)
|
|
13
|
+
}
|
|
14
|
+
return out.join('\n')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const tomlString = (table, key) => new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^"'\\n]*)\\1`, 'm').exec(table ?? '')?.[2] ?? null
|
|
18
|
+
export const tomlKeys = (table) => (table ?? '').split('\n').map((l) => /^\s*"?([A-Za-z0-9_.-]+)"?\s*=/.exec(l)?.[1]).filter(Boolean)
|
|
19
|
+
|
|
20
|
+
/** The strings of a string array, which can span lines. */
|
|
21
|
+
export function tomlArray(table, key) {
|
|
22
|
+
const m = new RegExp(`^\\s*${key}\\s*=\\s*\\[`, 'm').exec(table ?? '')
|
|
23
|
+
if (!m) return []
|
|
24
|
+
const out = []
|
|
25
|
+
let quote = '', value = ''
|
|
26
|
+
for (const c of table.slice(m.index + m[0].length)) {
|
|
27
|
+
if (quote) { if (c === quote) { out.push(value); quote = ''; value = '' } else value += c }
|
|
28
|
+
else if (c === '"' || c === "'") quote = c
|
|
29
|
+
else if (c === ']') break
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A 1-line inline table, { path = "../shared", editable = true }, as an object of strings and booleans.
|
|
36
|
+
* null when the text is not an inline table.
|
|
37
|
+
*/
|
|
38
|
+
export function tomlInline(text) {
|
|
39
|
+
const m = /^\s*\{(.*)\}\s*(?:#.*)?$/.exec(text ?? '')
|
|
40
|
+
if (!m) return null
|
|
41
|
+
const out = {}
|
|
42
|
+
for (const pair of m[1].matchAll(/([A-Za-z0-9_-]+)\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*'|true|false|[^,}\s]+)/g)) {
|
|
43
|
+
const raw = pair[2]
|
|
44
|
+
out[pair[1]] = raw === 'true' ? true : raw === 'false' ? false : /^["']/.test(raw) ? raw.slice(1, -1) : raw
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The key = value lines of 1 table, with 1-based line numbers in the whole file. */
|
|
50
|
+
export function tomlEntries(body, header) {
|
|
51
|
+
const out = []
|
|
52
|
+
let on = false
|
|
53
|
+
;(body ?? '').split(/\r?\n/).forEach((line, i) => {
|
|
54
|
+
const h = /^\s*\[([^\][]+)\]\s*(?:#.*)?$/.exec(line)
|
|
55
|
+
if (h) { on = h[1].trim() === header; return }
|
|
56
|
+
if (/^\s*\[\[/.test(line)) { on = false; return }
|
|
57
|
+
const kv = on && /^\s*["']?([A-Za-z0-9_.-]+)["']?\s*=\s*(.*?)\s*$/.exec(line)
|
|
58
|
+
if (kv) out.push({ key: kv[1], value: kv[2].replace(/\s+#.*$/, ''), line: i + 1 })
|
|
59
|
+
})
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** PEP 503 name: lower case, runs of - _ . become -. */
|
|
64
|
+
export const pyName = (name) => String(name).toLowerCase().replace(/[-_.]+/g, '-')
|
package/lib/types.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared types for @uknows/setup. JSDoc only: this module has no runtime code.
|
|
3
|
+
* Import types with: @typedef {import('./types.mjs').Service} Service
|
|
4
|
+
*
|
|
5
|
+
* Path rule: every path in these types, in the manifest and in the --json
|
|
6
|
+
* report is relative to the repo root (the --path folder, default the current
|
|
7
|
+
* folder) and uses '/' on every OS. The root folder itself is '.'.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** @typedef {'node'|'python'|'dotnet'|'java'|'go'|'php'|'ruby'} Lang */
|
|
11
|
+
/** @typedef {Lang|'otel'} StackId Same ids as contract STACKS. 'otel' = the app already uses OpenTelemetry: settings only. */
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {'standard'|'otel'|'azure-monitor'|'stop'} Mode How the tool sets up 1 service.
|
|
14
|
+
* standard: no OpenTelemetry yet, the 5 settings. otel: OpenTelemetry already, 3 settings. azure-monitor: keep
|
|
15
|
+
* Azure Monitor and add 1 exporter beside it, 3 settings. stop: another APM agent or an unknown wrapper, change nothing (exit 1).
|
|
16
|
+
*/
|
|
17
|
+
/** @typedef {'azure-container-apps'|'kubernetes'|'alibaba-sae'|'vm-docker'|'github-actions'} HostId Same ids as contract HOSTS. */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {{file: string, line?: number, what: string}} Evidence `what` is plain English, e.g. 'express in dependencies'.
|
|
21
|
+
* Host evidence can name a file above the --path folder in the same git repo, as '../../k8s/api.yaml'. It is shown, never written.
|
|
22
|
+
*/
|
|
23
|
+
/** @typedef {{code: string, message: string}} Problem `message` is 1 plain-English sentence. */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} Start How the service starts, as the repo declares it.
|
|
27
|
+
* @property {'dockerfile'|'package.json'|'procfile'|'answer'} kind 'answer' = the user typed it at the prompt (file '', line 0).
|
|
28
|
+
* @property {string} file
|
|
29
|
+
* @property {number} line 1-based line of the ENTRYPOINT/CMD (ENTRYPOINT when both), the "start" key, or the web: line.
|
|
30
|
+
* @property {'exec'|'shell'} form exec = Dockerfile JSON array. Everything else is shell.
|
|
31
|
+
* @property {string} raw The command text. Dockerfile: ENTRYPOINT then CMD args, as written.
|
|
32
|
+
* @property {string[]|null} argv Literal argv when the text has no shell syntax, else null.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} K8sTarget A container in a plain Kubernetes manifest that runs this service.
|
|
37
|
+
* @property {string} file e.g. 'k8s/api.yaml'
|
|
38
|
+
* @property {number} doc 0-based index of the YAML document in the file ('---' separated)
|
|
39
|
+
* @property {string} container the container name
|
|
40
|
+
* @property {Record<string, string|null>} env Its env list: name -> value, null for valueFrom. It replaces the image ENV.
|
|
41
|
+
* @property {boolean} command It sets command:, which replaces the image ENTRYPOINT and CMD.
|
|
42
|
+
* @property {boolean} args It sets args:, which replaces the image CMD.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} Service One deployable app folder.
|
|
47
|
+
* @property {string} dir '.' or 'services/api'
|
|
48
|
+
* @property {Lang|null} lang null = nothing recognized. The CLI then shows the language menu.
|
|
49
|
+
* @property {string[]} frameworks Canonical ids, sorted:
|
|
50
|
+
* node: express fastify nestjs koa hapi nitro nuxt next | python: fastapi django flask starlette falcon tornado
|
|
51
|
+
* dotnet: aspnetcore | java: spring-boot quarkus micronaut keycloak (a Keycloak image with no manifest) | go: echo gin chi | php: laravel symfony slim | ruby: rails sinatra
|
|
52
|
+
* @property {string[]} manifests Manifest paths in this folder, sorted.
|
|
53
|
+
* @property {string} name Proposed OTEL_SERVICE_NAME: [a-z0-9-], 1 to 63 chars, no leading or trailing '-'.
|
|
54
|
+
* @property {string} nameFrom 'existing' (an OTEL_SERVICE_NAME already in the repo), a manifest basename, or 'folder'.
|
|
55
|
+
* @property {string|null} dockerfile The Dockerfile that builds this service.
|
|
56
|
+
* @property {Start|null} start The single start declaration. null when there is none or it is ambiguous (see problems).
|
|
57
|
+
* @property {string|null} runtimeVersion Version from the final FROM image tag ('3.12', '8.0', '22'), else from an exact declaration
|
|
58
|
+
* (.python-version, TargetFramework 'net8.0' -> '8.0', go directive). Ranges such as
|
|
59
|
+
* requires-python do not count. null when unknown.
|
|
60
|
+
* @property {K8sTarget[]} k8s Plain k8s containers that run this service. Files with '{{' (Helm) are never listed.
|
|
61
|
+
* @property {Problem[]} problems codes: start-ambiguous, start-hooks, start-unknown, dockerfile-ambiguous, frontend
|
|
62
|
+
* @property {Evidence[]} evidence Why this folder is a service.
|
|
63
|
+
* @property {Mode} [mode] Set by detection when it knows. Without it, the engine classifies the service (existing.mjs classify).
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} Host
|
|
68
|
+
* @property {HostId} id
|
|
69
|
+
* @property {Evidence[]} evidence Empty for the vm-docker fallback.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} Detection What detect() returns. Static reading only: no commands, no network.
|
|
74
|
+
* @property {string} root Absolute native path of the repo root.
|
|
75
|
+
* @property {Service[]} services Sorted by dir. When nothing is recognized: 1 service {dir: '.', lang: null}.
|
|
76
|
+
* @property {Host[]} hosts Runtime hosts in HOSTS order, then 'github-actions'. Never empty ('vm-docker' fallback).
|
|
77
|
+
* @property {Problem[]} problems Repo-level notes, such as skipped large files.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/** @typedef {{vendor: string, evidence: Evidence[]}} Apm vendor: Datadog, New Relic, Elastic APM, Azure Monitor, Dynatrace, Instana or Sentry. */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {Evidence & {kind: string, value?: string}} Anchor A line in source code or a deploy file that matters.
|
|
84
|
+
* kind: azure-call, appinsights-call, phoenix, provider, otlp-signal, sampling, service-name, connection-string,
|
|
85
|
+
* auto-attach, sampler-env. value: what the line sets, when the rule reads it.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {Object} Existing What findExisting() returns for 1 service. It reads the service and its first-party members.
|
|
90
|
+
* @property {Evidence[]} otel OpenTelemetry SDK-level packages or agents already in use.
|
|
91
|
+
* @property {Apm[]} apm Other APM agents. Any entry stops the run (exit 1).
|
|
92
|
+
* @property {(Evidence & {version?: string|null})[]} [azure] Azure Monitor OpenTelemetry packages, and App Insights 3.x: azure-monitor mode.
|
|
93
|
+
* @property {(Evidence & {version?: string|null})[]} [classic] App Insights that UKNOWS cannot join (.NET 2.x, Node 2.x, OpenCensus, Java): stop.
|
|
94
|
+
* @property {Evidence[]} [browser] The browser App Insights SDK: a note only.
|
|
95
|
+
* @property {Evidence[]} [tracers] Phoenix, openinference, langfuse, traceloop, logfire: allowed, with advice about the key.
|
|
96
|
+
* @property {Anchor[]} [anchors]
|
|
97
|
+
* @property {import('./members.mjs').LocalPackage[]} [local] Wheels, sdists, .tgz, .nupkg files and local NuGet sources.
|
|
98
|
+
* @property {import('./members.mjs').Member[]} [members] uv workspace members, path sources, file: packages, project references.
|
|
99
|
+
* @property {import('./locks.mjs').InstalledSet} [installed] The lock closure.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} Classification What classify() returns.
|
|
104
|
+
* @property {Mode} mode
|
|
105
|
+
* @property {'apm'|'classic'|'wrapper'|'local'|null} reason Why it stops.
|
|
106
|
+
* @property {Evidence[]} [classic]
|
|
107
|
+
* @property {{package: import('./locks.mjs').Installed, sdk: import('./locks.mjs').Installed, limit: {name: string, spec: string|null, line?: number}|null, need: string|null}} [wrapper]
|
|
108
|
+
* @property {import('./members.mjs').LocalPackage} [local]
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} Edit One change to one text file.
|
|
113
|
+
* @property {string} file Repo-relative path.
|
|
114
|
+
* @property {string|null} before Exactly ctx.ws.read(file) when the producer read it (BOM removed). null = new file.
|
|
115
|
+
* @property {string} after The full new text, without BOM. The engine puts the original BOM back.
|
|
116
|
+
* @property {string} why 1 plain-English sentence, e.g. 'Load OpenTelemetry when node starts.'
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {Object} Command A package manager run. The engine runs it after it writes the edits, only on apply.
|
|
121
|
+
* @property {string} cwd Repo-relative folder to run in.
|
|
122
|
+
* @property {string} cmd 'npm' | 'pnpm' | 'yarn', or for kind 'lock' 'uv' | 'poetry' | 'dotnet'
|
|
123
|
+
* @property {'lock'} [kind] A lock refresh (uv lock, poetry lock, dotnet restore --force-evaluate). The manifest records
|
|
124
|
+
* the files in touches as text changes, not as packages, so remove restores them byte for
|
|
125
|
+
* byte. undo is null. When it fails, the engine prints the resolver text and puts every file back.
|
|
126
|
+
* @property {{file: string, pattern: string}[]} [keep] kind 'lock': a line (regex, m flag) that must not change, such as
|
|
127
|
+
* '^version = \\d+$' in uv.lock. When it changes, the run is rolled back.
|
|
128
|
+
* @property {{assets: string, library: string, range: string[], package: string, version: string}} [verify] dotnet restore: after
|
|
129
|
+
* it, obj/project.assets.json must not show `library` in `range` (the build check would then
|
|
130
|
+
* need `package`). When it does, the run is rolled back (dotnet.mjs verifyRestore).
|
|
131
|
+
* @property {string[]} args Exact argv, e.g. ['install', '--save', '@opentelemetry/api', '@opentelemetry/auto-instrumentations-node'].
|
|
132
|
+
* @property {string[]} touches Repo-relative files it changes. The engine restores them when any step fails.
|
|
133
|
+
* @property {string[]} added Packages it adds that were not there before. The manifest records them.
|
|
134
|
+
* @property {boolean} [lockOnly] It writes only the files in touches (no node_modules).
|
|
135
|
+
* @property {{cmd: string, args: string[]}} undo Removes `added`. The remove command runs it.
|
|
136
|
+
* @property {string} why Plan sentence, e.g. 'Add @opentelemetry/api and @opentelemetry/auto-instrumentations-node with npm.'
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/** @typedef {{caption: string, language: string, code: string}} Snippet Same shape as contract snippets. */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @typedef {Object} Block A printable section: a manual step, a key step or a stop reason.
|
|
143
|
+
* @property {string} title Sentence case, no final period, e.g. 'Set your key once on Kubernetes'.
|
|
144
|
+
* @property {string[]} lines Plain-English sentences. The CLI prints them as a numbered list.
|
|
145
|
+
* @property {Snippet[]} [snippets]
|
|
146
|
+
* @property {boolean} [required] Manual steps only. false = advice (sampling, role name, Phoenix, Alpine): printed under
|
|
147
|
+
* "Advice", never counts toward exit 3. Missing or true = a step traces need. The engine
|
|
148
|
+
* sets it on every manual block of the --json report.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/** @typedef {{name: string, value: string}} Setting */
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {Object} Ctx What appliers and settingsPlan() get.
|
|
155
|
+
* @property {import('./workspace.mjs').Workspace} ws Reads include the edits staged earlier in this run.
|
|
156
|
+
* @property {string} name The final OTEL_SERVICE_NAME of this service.
|
|
157
|
+
* @property {Detection} detection
|
|
158
|
+
* @property {import('./workspace.mjs').RepoView} [repo] Reads files above the root too ('../x'), for --path runs. Never written.
|
|
159
|
+
* @property {string} [endpoint] The ingest address for OTEL_EXPORTER_OTLP_ENDPOINT. Default: INGEST_ENDPOINT.
|
|
160
|
+
* @property {Mode} [mode] The mode of this service. settingsPlan() and the azure applier read it. Default: standard.
|
|
161
|
+
* @property {Existing} [existing] What findExisting() found for this service.
|
|
162
|
+
* @property {import('./locks.mjs').InstalledSet} [installed] The lock closure (existing.installed).
|
|
163
|
+
* @property {Record<string, string|null>} [tools] Local tools probed before planning: name -> version, null when missing.
|
|
164
|
+
* uv and poetry: their version. dotnet: the newest SDK of dotnet --list-sdks.
|
|
165
|
+
* Missing key: not probed (unit tests), so the applier assumes the tool is there.
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @typedef {Object} StackPlan What Applier.plan() returns.
|
|
170
|
+
* @property {string[]} summary Plan sentences, e.g. 'Load OpenTelemetry when node starts (NODE_OPTIONS in Dockerfile).'
|
|
171
|
+
* @property {Edit[]} edits
|
|
172
|
+
* @property {Command[]} commands
|
|
173
|
+
* @property {Setting[]} extraSettings Settings to place with the 5, e.g. DJANGO_SETTINGS_MODULE or OTEL_PHP_AUTOLOAD_ENABLED.
|
|
174
|
+
* @property {Block[]} manual Steps for the user. Any entry without required: false makes the run exit 3.
|
|
175
|
+
* @property {string[]} notes Warnings and tips, 1 sentence each.
|
|
176
|
+
* @property {boolean} [stop] Change nothing for this service, settings included: the user must run this tool elsewhere.
|
|
177
|
+
*/
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @typedef {Object} Applier
|
|
181
|
+
* @property {StackId} id
|
|
182
|
+
* @property {(service: Service, ctx: Ctx) => StackPlan} plan
|
|
183
|
+
* Pure: reads ctx.ws only. Never writes, runs, prompts or reads process.env.
|
|
184
|
+
* Idempotent: planning its own output returns no edits and no commands.
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @typedef {Object} SettingsPlan What settingsPlan() returns.
|
|
189
|
+
* @property {{kind: 'dockerfile'|'k8s'|'env-file', file: string}} target
|
|
190
|
+
* @property {Edit[]} edits Empty when every setting is already there with the right value.
|
|
191
|
+
* @property {string[]} summary
|
|
192
|
+
* @property {Block[]} manual e.g. a key committed in the repo, or an endpoint that points at another collector. Required ones: exit 3.
|
|
193
|
+
* @property {string[]} notes
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @typedef {Object} CheckResult What runCheck() returns.
|
|
198
|
+
* @property {boolean} ok
|
|
199
|
+
* @property {number|null} status HTTP status. null on a network error.
|
|
200
|
+
* @property {string|null} workspace
|
|
201
|
+
* @property {string|null} environment
|
|
202
|
+
* @property {string|null} [keyState] From the site: active, retiring, or revoked. null when the site was not asked.
|
|
203
|
+
* @property {string[]} lines What to print. Never contains the key.
|
|
204
|
+
* @property {boolean} [notReady] A new key that UKNOWS did not get ready within 2 minutes (check.mjs NEW_KEY).
|
|
205
|
+
* @property {0|1|2} exit 0 reached, 1 rejected or network error, 2 OTEL_EXPORTER_OTLP_HEADERS is not set.
|
|
206
|
+
*/
|
|
207
|
+
|
|
208
|
+
export {}
|
package/lib/ui.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Terminal input and output. Normal text goes to stdout and errors to stderr. With --json, normal text is left out,
|
|
3
|
+
* so stdout holds only the JSON report, and nothing ever prompts. Color, the spinner and single key presses are only
|
|
4
|
+
* for a real terminal: in a pipe, a log or a test, the text is plain and every line ends.
|
|
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', keycloak: 'Keycloak', echo: 'Echo', gin: 'Gin', chi: 'chi', laravel: 'Laravel', symfony: 'Symfony', slim: 'Slim',
|
|
16
|
+
rails: 'Rails', sinatra: 'Sinatra', 'azure-functions': 'Azure Functions' }
|
|
17
|
+
|
|
18
|
+
export const stackLabel = (id) => STACKS.find((s) => s.id === id)?.label ?? 'an unknown stack'
|
|
19
|
+
/** Azure Functions is a host of the setup tool before it is one of the docs page (FUNCTIONS in the contract). */
|
|
20
|
+
export const hostLabel = (id) => HOSTS.find((h) => h.id === id)?.label ?? (id === 'azure-functions' ? 'Azure Functions' : id)
|
|
21
|
+
export const frameworkLabel = (id) => FRAMEWORKS[id] ?? id
|
|
22
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
23
|
+
export const unique = (list) => [...new Set(list)]
|
|
24
|
+
/** 'a', 'a and b', 'a, b and c'. */
|
|
25
|
+
export const list = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''))
|
|
26
|
+
|
|
27
|
+
/** Color only on a terminal that shows it: not with NO_COLOR, not on TERM=dumb. FORCE_COLOR turns it on. */
|
|
28
|
+
export function colorOn(stream, env = process.env) {
|
|
29
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true
|
|
30
|
+
if ('NO_COLOR' in env && env.NO_COLOR !== '') return false
|
|
31
|
+
return Boolean(stream?.isTTY) && env.TERM !== 'dumb'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The old Windows console shows no braille or check marks. Windows Terminal (WT_SESSION) does. */
|
|
35
|
+
const unicode = (env = process.env, platform = process.platform) => platform !== 'win32' || Boolean(env.WT_SESSION || env.TERM_PROGRAM)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap text to width, by words. Each line after the first starts with indent. A word longer than the line (a link)
|
|
39
|
+
* stays whole. Color codes do not count toward the width.
|
|
40
|
+
*/
|
|
41
|
+
export function wrap(text, width = 80, indent = '') {
|
|
42
|
+
const visible = (s) => s.replace(/\u001b\[[0-9;]*m/g, '').length
|
|
43
|
+
// Spaces at the start of the text stay, and the lines after the first keep them too.
|
|
44
|
+
const lead = /^ */.exec(String(text))[0]
|
|
45
|
+
indent += lead
|
|
46
|
+
const lines = []
|
|
47
|
+
let line = lead
|
|
48
|
+
for (const word of String(text).slice(lead.length).split(/ +/)) {
|
|
49
|
+
if (line === lead) { line += word; continue }
|
|
50
|
+
if (visible(line) + 1 + visible(word) > width) { lines.push(line); line = `${indent}${word}` } else line += ` ${word}`
|
|
51
|
+
}
|
|
52
|
+
lines.push(line)
|
|
53
|
+
return lines
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{stdin?: any, stdout?: any, stderr?: any, json?: boolean, interactive?: boolean, terminal?: boolean,
|
|
58
|
+
* env?: Record<string, string|undefined>}} [io]
|
|
59
|
+
* interactive defaults to: stdin and stdout are terminals and json is off.
|
|
60
|
+
* terminal defaults to: stdout is a terminal and json is off. Then --yes still checks a key set in this terminal.
|
|
61
|
+
*/
|
|
62
|
+
export function createUI({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, json = false, interactive, terminal, env = process.env } = {}) {
|
|
63
|
+
let rl = null, closed = false, detaching = false
|
|
64
|
+
const queue = [], waiting = []
|
|
65
|
+
const nextLine = () => {
|
|
66
|
+
if (!rl) {
|
|
67
|
+
rl = createInterface({ input: stdin, terminal: false })
|
|
68
|
+
rl.on('line', (line) => (waiting.length ? waiting.shift()(line) : queue.push(line)))
|
|
69
|
+
rl.on('close', () => { if (detaching) return; closed = true; while (waiting.length) waiting.shift()(null) })
|
|
70
|
+
}
|
|
71
|
+
if (queue.length) return Promise.resolve(queue.shift())
|
|
72
|
+
return closed ? Promise.resolve(null) : new Promise((resolve) => waiting.push(resolve))
|
|
73
|
+
}
|
|
74
|
+
/** Stop reading lines for a while, so single key presses do not end up as answers. The next question reads again. */
|
|
75
|
+
const detach = () => {
|
|
76
|
+
if (!rl || waiting.length) return
|
|
77
|
+
detaching = true
|
|
78
|
+
rl.close()
|
|
79
|
+
rl = null
|
|
80
|
+
detaching = false
|
|
81
|
+
}
|
|
82
|
+
// A line that the spinner owns: it is cleared before any other text, so text never runs into it.
|
|
83
|
+
let live = null
|
|
84
|
+
const clearLive = () => { if (live) { stdout.write('\r\u001b[2K'); live.drawn = false } }
|
|
85
|
+
const print = (text = '') => { if (!json) { clearLive(); stdout.write(`${text}\n`) } }
|
|
86
|
+
/** null when input ended. */
|
|
87
|
+
const prompt = (question) => { clearLive(); stdout.write(`${question} `); return nextLine() }
|
|
88
|
+
const color = !json && colorOn(stdout, env)
|
|
89
|
+
const paint = (code) => (text) => (color ? `\u001b[${code}m${text}\u001b[0m` : String(text))
|
|
90
|
+
const cyan = paint('36')
|
|
91
|
+
const fancy = unicode(env)
|
|
92
|
+
const tty = Boolean(stdout.isTTY) && !json
|
|
93
|
+
// Only a terminal gets text wrapped to its width. A pipe or a log keeps each line whole.
|
|
94
|
+
const columns = () => (tty ? Math.max(40, Math.min(Number(stdout.columns) || 80, 100)) : 1000)
|
|
95
|
+
const FRAMES = fancy ? ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'] : ['-', '\\', '|', '/']
|
|
96
|
+
const marks = { done: fancy ? '\u2713' : '+', failed: fancy ? '\u2717' : 'x' }
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
interactive: interactive ?? Boolean(stdin.isTTY && stdout.isTTY && !json),
|
|
100
|
+
terminal: terminal ?? Boolean(stdout.isTTY && !json),
|
|
101
|
+
/** True when the screen is a real terminal: the spinner and the redrawn line work there. */
|
|
102
|
+
tty,
|
|
103
|
+
color,
|
|
104
|
+
columns,
|
|
105
|
+
marks,
|
|
106
|
+
bold: paint('1'),
|
|
107
|
+
dim: paint('2'),
|
|
108
|
+
cyan,
|
|
109
|
+
red: paint('31'),
|
|
110
|
+
print,
|
|
111
|
+
error: (text) => { clearLive(); stderr.write(`${text}\n`) },
|
|
112
|
+
/** Print text wrapped to the terminal, with indent before every line and hang after the first. */
|
|
113
|
+
say(text = '', indent = '', hang = '') {
|
|
114
|
+
for (const line of wrap(text, columns() - indent.length, hang)) print(`${indent}${line}`)
|
|
115
|
+
},
|
|
116
|
+
/** @param {import('./types.mjs').Block} block */
|
|
117
|
+
block(block) {
|
|
118
|
+
print(block.title)
|
|
119
|
+
block.lines.forEach((line, i) => print(` ${i + 1}. ${line}`))
|
|
120
|
+
for (const s of block.snippets ?? []) {
|
|
121
|
+
print(` ${s.caption}:`)
|
|
122
|
+
for (const line of s.code.split('\n')) print(` ${line}`)
|
|
123
|
+
}
|
|
124
|
+
print()
|
|
125
|
+
},
|
|
126
|
+
/**
|
|
127
|
+
* A line that shows work in progress: a spinner and text() on a terminal, redrawn 10 times a second, else nothing.
|
|
128
|
+
* end(line) puts the last line in its place. Only 1 at a time.
|
|
129
|
+
*/
|
|
130
|
+
progress(text, indent = '') {
|
|
131
|
+
if (!tty) return { end: (line) => { if (line !== null) print(line) }, stop() {} }
|
|
132
|
+
let frame = 0
|
|
133
|
+
const self = { drawn: false }
|
|
134
|
+
const draw = () => {
|
|
135
|
+
const body = typeof text === 'function' ? text() : text
|
|
136
|
+
const line = `${indent}${cyan(FRAMES[frame++ % FRAMES.length])} ${body}`
|
|
137
|
+
stdout.write(`\r\u001b[2K${line.length > columns() + 10 ? line.slice(0, columns() + 10) : line}`)
|
|
138
|
+
self.drawn = true
|
|
139
|
+
}
|
|
140
|
+
live = self
|
|
141
|
+
draw()
|
|
142
|
+
const timer = setInterval(() => { if (live === self) draw() }, 100)
|
|
143
|
+
const stop = () => { clearInterval(timer); if (live === self) { clearLive(); live = null } }
|
|
144
|
+
return { end: (line) => { stop(); if (line !== null) print(line) }, stop }
|
|
145
|
+
},
|
|
146
|
+
/**
|
|
147
|
+
* Single key presses on a terminal: next() gives 'enter', 'ctrl-c' or the key, as a person presses it. null when
|
|
148
|
+
* this is no terminal (a pipe or a test): then nothing reads keys. close() gives the input back to the questions.
|
|
149
|
+
*/
|
|
150
|
+
keys() {
|
|
151
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
152
|
+
detach()
|
|
153
|
+
const pending = [], readers = []
|
|
154
|
+
const onData = (chunk) => {
|
|
155
|
+
for (const ch of chunk.toString('utf8')) {
|
|
156
|
+
const key = ch === '\r' || ch === '\n' ? 'enter' : ch === '\u0003' ? 'ctrl-c' : ch === '\u0004' ? 'ctrl-d' : ch.toLowerCase()
|
|
157
|
+
if (readers.length) readers.shift()(key); else pending.push(key)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
stdin.setRawMode(true)
|
|
161
|
+
stdin.on('data', onData)
|
|
162
|
+
stdin.resume()
|
|
163
|
+
let open = true
|
|
164
|
+
return {
|
|
165
|
+
next: () => (pending.length ? Promise.resolve(pending.shift()) : new Promise((resolve) => readers.push(resolve))),
|
|
166
|
+
close() {
|
|
167
|
+
if (!open) return
|
|
168
|
+
open = false
|
|
169
|
+
stdin.off('data', onData)
|
|
170
|
+
stdin.setRawMode(false)
|
|
171
|
+
stdin.pause()
|
|
172
|
+
while (readers.length) readers.shift()(null)
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
async ask(question) { return ((await prompt(question)) ?? '').trim() },
|
|
177
|
+
/** Like ask, but null when input ended, so an empty answer and no answer differ. */
|
|
178
|
+
async line(question) { const answer = await prompt(question); return answer === null ? null : answer.trim() },
|
|
179
|
+
/**
|
|
180
|
+
* Read 1 line without showing it, for a key. In a terminal, raw mode stops the echo. Backspace works,
|
|
181
|
+
* Ctrl-C and Ctrl-D skip. Without a terminal (tests, pipes) it reads a plain line. Never stores the answer.
|
|
182
|
+
*/
|
|
183
|
+
async secret(question) {
|
|
184
|
+
const raw = stdin.isTTY && typeof stdin.setRawMode === 'function'
|
|
185
|
+
if (raw) stdin.setRawMode(true)
|
|
186
|
+
let line
|
|
187
|
+
try { line = await prompt(question) } finally { if (raw) { stdin.setRawMode(false); stdout.write('\n') } }
|
|
188
|
+
let out = ''
|
|
189
|
+
for (const ch of line ?? '') {
|
|
190
|
+
if (ch === '\u0003' || ch === '\u0004') return ''
|
|
191
|
+
if (ch === '\u007f' || ch === '\b') out = out.slice(0, -1)
|
|
192
|
+
else if (ch === '\u0015') out = ''
|
|
193
|
+
else if (ch >= ' ') out += ch
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
},
|
|
197
|
+
/** Y/n. Empty means yes, or no when yes is false (a y/N question). Ended input means no. */
|
|
198
|
+
async confirm(question, yes = true) {
|
|
199
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
200
|
+
const answer = await prompt(question)
|
|
201
|
+
if (answer === null) return false
|
|
202
|
+
const a = answer.trim().toLowerCase()
|
|
203
|
+
if (a === '') return yes
|
|
204
|
+
if (a === 'y' || a === 'yes') return true
|
|
205
|
+
if (a === 'n' || a === 'no') return false
|
|
206
|
+
print('Type y or n.')
|
|
207
|
+
}
|
|
208
|
+
return false
|
|
209
|
+
},
|
|
210
|
+
/** A numbered menu. Returns the 0-based index. 3 bad answers: usage error. */
|
|
211
|
+
async choose(title, options) {
|
|
212
|
+
print(title)
|
|
213
|
+
options.forEach((option, i) => print(` ${i + 1}. ${option}`))
|
|
214
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
215
|
+
const answer = await prompt(`Type a number from 1 to ${options.length}:`)
|
|
216
|
+
if (answer === null) break
|
|
217
|
+
const n = Number(answer.trim())
|
|
218
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return n - 1
|
|
219
|
+
print(`Type a number from 1 to ${options.length}.`)
|
|
220
|
+
}
|
|
221
|
+
throw new UsageError('No choice was made.')
|
|
222
|
+
},
|
|
223
|
+
close() { if (live) { clearLive(); live = null } rl?.close() },
|
|
224
|
+
}
|
|
225
|
+
}
|