@uknows/setup 0.1.0 → 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 (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
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 CHANGED
@@ -9,6 +9,11 @@
9
9
 
10
10
  /** @typedef {'node'|'python'|'dotnet'|'java'|'go'|'php'|'ruby'} Lang */
11
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
+ */
12
17
  /** @typedef {'azure-container-apps'|'kubernetes'|'alibaba-sae'|'vm-docker'|'github-actions'} HostId Same ids as contract HOSTS. */
13
18
 
14
19
  /**
@@ -43,7 +48,7 @@
43
48
  * @property {Lang|null} lang null = nothing recognized. The CLI then shows the language menu.
44
49
  * @property {string[]} frameworks Canonical ids, sorted:
45
50
  * node: express fastify nestjs koa hapi nitro nuxt next | python: fastapi django flask starlette falcon tornado
46
- * dotnet: aspnetcore | java: spring-boot quarkus micronaut | go: echo gin chi | php: laravel symfony slim | ruby: rails sinatra
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
47
52
  * @property {string[]} manifests Manifest paths in this folder, sorted.
48
53
  * @property {string} name Proposed OTEL_SERVICE_NAME: [a-z0-9-], 1 to 63 chars, no leading or trailing '-'.
49
54
  * @property {string} nameFrom 'existing' (an OTEL_SERVICE_NAME already in the repo), a manifest basename, or 'folder'.
@@ -55,6 +60,7 @@
55
60
  * @property {K8sTarget[]} k8s Plain k8s containers that run this service. Files with '{{' (Helm) are never listed.
56
61
  * @property {Problem[]} problems codes: start-ambiguous, start-hooks, start-unknown, dockerfile-ambiguous, frontend
57
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).
58
64
  */
59
65
 
60
66
  /**
@@ -74,9 +80,32 @@
74
80
  /** @typedef {{vendor: string, evidence: Evidence[]}} Apm vendor: Datadog, New Relic, Elastic APM, Azure Monitor, Dynatrace, Instana or Sentry. */
75
81
 
76
82
  /**
77
- * @typedef {Object} Existing What findExisting() returns for 1 service.
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.
78
90
  * @property {Evidence[]} otel OpenTelemetry SDK-level packages or agents already in use.
79
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]
80
109
  */
81
110
 
82
111
  /**
@@ -90,7 +119,15 @@
90
119
  /**
91
120
  * @typedef {Object} Command A package manager run. The engine runs it after it writes the edits, only on apply.
92
121
  * @property {string} cwd Repo-relative folder to run in.
93
- * @property {string} cmd 'npm' | 'pnpm' | 'yarn'
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).
94
131
  * @property {string[]} args Exact argv, e.g. ['install', '--save', '@opentelemetry/api', '@opentelemetry/auto-instrumentations-node'].
95
132
  * @property {string[]} touches Repo-relative files it changes. The engine restores them when any step fails.
96
133
  * @property {string[]} added Packages it adds that were not there before. The manifest records them.
@@ -106,6 +143,9 @@
106
143
  * @property {string} title Sentence case, no final period, e.g. 'Set your key once on Kubernetes'.
107
144
  * @property {string[]} lines Plain-English sentences. The CLI prints them as a numbered list.
108
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.
109
149
  */
110
150
 
111
151
  /** @typedef {{name: string, value: string}} Setting */
@@ -117,6 +157,12 @@
117
157
  * @property {Detection} detection
118
158
  * @property {import('./workspace.mjs').RepoView} [repo] Reads files above the root too ('../x'), for --path runs. Never written.
119
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.
120
166
  */
121
167
 
122
168
  /**
@@ -125,7 +171,7 @@
125
171
  * @property {Edit[]} edits
126
172
  * @property {Command[]} commands
127
173
  * @property {Setting[]} extraSettings Settings to place with the 5, e.g. DJANGO_SETTINGS_MODULE or OTEL_PHP_AUTOLOAD_ENABLED.
128
- * @property {Block[]} manual Steps the user must do. Any entry makes the run exit 3.
174
+ * @property {Block[]} manual Steps for the user. Any entry without required: false makes the run exit 3.
129
175
  * @property {string[]} notes Warnings and tips, 1 sentence each.
130
176
  * @property {boolean} [stop] Change nothing for this service, settings included: the user must run this tool elsewhere.
131
177
  */
@@ -143,7 +189,7 @@
143
189
  * @property {{kind: 'dockerfile'|'k8s'|'env-file', file: string}} target
144
190
  * @property {Edit[]} edits Empty when every setting is already there with the right value.
145
191
  * @property {string[]} summary
146
- * @property {Block[]} manual e.g. a key committed in the repo, or an endpoint that points at another collector. Any entry: exit 3.
192
+ * @property {Block[]} manual e.g. a key committed in the repo, or an endpoint that points at another collector. Required ones: exit 3.
147
193
  * @property {string[]} notes
148
194
  */
149
195
 
@@ -155,6 +201,7 @@
155
201
  * @property {string|null} environment
156
202
  * @property {string|null} [keyState] From the site: active, retiring, or revoked. null when the site was not asked.
157
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).
158
205
  * @property {0|1|2} exit 0 reached, 1 rejected or network error, 2 OTEL_EXPORTER_OTLP_HEADERS is not set.
159
206
  */
160
207
 
package/lib/ui.mjs CHANGED
@@ -1,7 +1,7 @@
1
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.
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
5
  */
6
6
  import { createInterface } from 'node:readline'
7
7
  import { HOSTS, STACKS } from './contract.mjs'
@@ -12,41 +12,107 @@ export class UsageError extends Error {}
12
12
  const FRAMEWORKS = { express: 'Express', fastify: 'Fastify', nestjs: 'NestJS', koa: 'Koa', hapi: 'Hapi', nitro: 'Nitro',
13
13
  nuxt: 'Nuxt', next: 'Next.js', fastapi: 'FastAPI', django: 'Django', flask: 'Flask', starlette: 'Starlette',
14
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' }
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
17
 
18
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
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)
20
21
  export const frameworkLabel = (id) => FRAMEWORKS[id] ?? id
21
22
  export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
22
23
  export const unique = (list) => [...new Set(list)]
23
24
  /** 'a', 'a and b', 'a, b and c'. */
24
25
  export const list = (names) => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''))
25
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
+
26
56
  /**
27
- * @param {{stdin?: any, stdout?: any, stderr?: any, json?: boolean, interactive?: boolean}} [io]
57
+ * @param {{stdin?: any, stdout?: any, stderr?: any, json?: boolean, interactive?: boolean, terminal?: boolean,
58
+ * env?: Record<string, string|undefined>}} [io]
28
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.
29
61
  */
30
- export function createUI({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, json = false, interactive } = {}) {
31
- let rl = null, closed = false
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
32
64
  const queue = [], waiting = []
33
65
  const nextLine = () => {
34
66
  if (!rl) {
35
67
  rl = createInterface({ input: stdin, terminal: false })
36
68
  rl.on('line', (line) => (waiting.length ? waiting.shift()(line) : queue.push(line)))
37
- rl.on('close', () => { closed = true; while (waiting.length) waiting.shift()(null) })
69
+ rl.on('close', () => { if (detaching) return; closed = true; while (waiting.length) waiting.shift()(null) })
38
70
  }
39
71
  if (queue.length) return Promise.resolve(queue.shift())
40
72
  return closed ? Promise.resolve(null) : new Promise((resolve) => waiting.push(resolve))
41
73
  }
42
- const print = (text = '') => { if (!json) stdout.write(`${text}\n`) }
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`) } }
43
86
  /** null when input ended. */
44
- const prompt = (question) => { stdout.write(`${question} `); return nextLine() }
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' }
45
97
 
46
98
  return {
47
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'),
48
110
  print,
49
- error: (text) => stderr.write(`${text}\n`),
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
+ },
50
116
  /** @param {import('./types.mjs').Block} block */
51
117
  block(block) {
52
118
  print(block.title)
@@ -57,16 +123,68 @@ export function createUI({ stdin = process.stdin, stdout = process.stdout, stder
57
123
  }
58
124
  print()
59
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
+ },
60
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() },
61
179
  /**
62
180
  * Read 1 line without showing it, for a key. In a terminal, raw mode stops the echo. Backspace works,
63
181
  * Ctrl-C and Ctrl-D skip. Without a terminal (tests, pipes) it reads a plain line. Never stores the answer.
64
182
  */
65
183
  async secret(question) {
66
- const tty = stdin.isTTY && typeof stdin.setRawMode === 'function'
67
- if (tty) stdin.setRawMode(true)
184
+ const raw = stdin.isTTY && typeof stdin.setRawMode === 'function'
185
+ if (raw) stdin.setRawMode(true)
68
186
  let line
69
- try { line = await prompt(question) } finally { if (tty) { stdin.setRawMode(false); stdout.write('\n') } }
187
+ try { line = await prompt(question) } finally { if (raw) { stdin.setRawMode(false); stdout.write('\n') } }
70
188
  let out = ''
71
189
  for (const ch of line ?? '') {
72
190
  if (ch === '\u0003' || ch === '\u0004') return ''
@@ -76,13 +194,14 @@ export function createUI({ stdin = process.stdin, stdout = process.stdout, stder
76
194
  }
77
195
  return out
78
196
  },
79
- /** Y/n. Empty means yes. Ended input means no. */
80
- async confirm(question) {
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) {
81
199
  for (let tries = 0; tries < 3; tries++) {
82
200
  const answer = await prompt(question)
83
201
  if (answer === null) return false
84
202
  const a = answer.trim().toLowerCase()
85
- if (a === '' || a === 'y' || a === 'yes') return true
203
+ if (a === '') return yes
204
+ if (a === 'y' || a === 'yes') return true
86
205
  if (a === 'n' || a === 'no') return false
87
206
  print('Type y or n.')
88
207
  }
@@ -101,6 +220,6 @@ export function createUI({ stdin = process.stdin, stdout = process.stdout, stder
101
220
  }
102
221
  throw new UsageError('No choice was made.')
103
222
  },
104
- close() { rl?.close() },
223
+ close() { if (live) { clearLive(); live = null } rl?.close() },
105
224
  }
106
225
  }