@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.
Files changed (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. 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
+ }