@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/cli.mjs ADDED
@@ -0,0 +1,166 @@
1
+ /*
2
+ * Command line: parse the arguments, then run setup, remove or check.
3
+ * A hand-written parser, because util.parseArgs is experimental on Node 18.
4
+ */
5
+ import { statSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+ import { runCheck } from './check.mjs'
8
+ import { CLI, DOCS_URL, INGEST_ENDPOINT, SECRET, SITE_URL } from './contract.mjs'
9
+ import { toServiceName } from './detect.mjs'
10
+ import { ingestEndpoint, siteUrl } from './endpoint.mjs'
11
+ import { runRemove, runSetup } from './engine.mjs'
12
+ import { TOOL, VERSION } from './manifest.mjs'
13
+ import { UsageError, createUI } from './ui.mjs'
14
+
15
+ const FLAGS = { '--dry-run': 'dryRun', '--yes': 'yes', '-y': 'yes', '--all': 'all', '--json': 'json', '--force': 'force',
16
+ '--check': 'check', '--help': 'help', '-h': 'help', '--version': 'version' }
17
+ const VALUES = { '--path': 'path', '--service-name': 'serviceName' }
18
+ const COMMANDS = { setup: ['dryRun', 'yes', 'path', 'all', 'json', 'serviceName', 'force'], remove: ['yes', 'path', 'json', 'force'], check: ['json'] }
19
+
20
+ export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces to UKNOWS.
21
+
22
+ Usage:
23
+ npx @uknows/setup [setup] [options] Find your app, show the plan, then apply it.
24
+ npx @uknows/setup remove [options] Undo what setup changed.
25
+ npx @uknows/setup check Send 1 test span with the key in ${SECRET.name}.
26
+
27
+ Options:
28
+ --dry-run Show the plan. Change nothing.
29
+ --yes, -y Apply without asking. Never asks any question.
30
+ --path <folder> The folder to set up. The default is this folder.
31
+ --all Set up every service folder. Each one gets its own OTEL_SERVICE_NAME.
32
+ --service-name <name> The name of the service in UKNOWS.
33
+ --json Print a JSON report. Without --yes, setup only plans.
34
+ --force Run even when the repo has changes that are not committed.
35
+ --check The same as the check command.
36
+ --help, -h Show this help.
37
+ --version Show the version.
38
+
39
+ Exit codes:
40
+ 0 Done, or nothing to do.
41
+ 1 Stopped or failed. Nothing changed.
42
+ 2 Usage error, or you need to pick a service.
43
+ 3 You need to do 1 or more steps by hand.
44
+
45
+ Environment:
46
+ ${SECRET.name}
47
+ Your key, for check, as ${SECRET.value}. Set it only in your terminal.
48
+ ${CLI.siteEnv}
49
+ A test UKNOWS site, for check and the docs links. The default is ${SITE_URL}.
50
+ ${CLI.endpointEnv}
51
+ A test ingest address, for setup and check. The default is ${INGEST_ENDPOINT}.
52
+
53
+ After setup, the tool asks for your key to check it. The key does not show on the screen.
54
+ This tool never writes your key to a file.
55
+ Docs: ${DOCS_URL}
56
+ `
57
+
58
+ /** @returns {{command: 'setup'|'remove'|'check', dryRun: boolean, yes: boolean, all: boolean, json: boolean, force: boolean, help: boolean, version: boolean, path: string|null, serviceName: string|null}} */
59
+ export function parseArgs(argv) {
60
+ const opts = { command: null, dryRun: false, yes: false, all: false, json: false, force: false, help: false, version: false, path: null, serviceName: null }
61
+ const given = []
62
+ let check = false
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const arg = argv[i]
65
+ if (!arg.startsWith('-') || arg === '-') {
66
+ if (opts.command) throw new UsageError(`Unexpected argument: ${arg}.`)
67
+ if (!(arg in COMMANDS)) throw new UsageError(`Unknown command: ${arg}.`)
68
+ opts.command = arg
69
+ continue
70
+ }
71
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1
72
+ const flag = eq > 0 ? arg.slice(0, eq) : arg
73
+ if (flag in VALUES) {
74
+ let value = eq > 0 ? arg.slice(eq + 1) : argv[i + 1]
75
+ if (eq < 0) {
76
+ if (value === undefined || value.startsWith('-')) throw new UsageError(`${flag} needs a value.`)
77
+ i++
78
+ }
79
+ if (!value) throw new UsageError(`${flag} needs a value.`)
80
+ opts[VALUES[flag]] = value
81
+ given.push([VALUES[flag], flag])
82
+ } else if (flag in FLAGS) {
83
+ if (eq > 0) throw new UsageError(`${flag} does not take a value.`)
84
+ if (FLAGS[flag] === 'check') check = true
85
+ else opts[FLAGS[flag]] = true
86
+ if (!['help', 'version', 'check'].includes(FLAGS[flag])) given.push([FLAGS[flag], flag])
87
+ } else throw new UsageError(`Unknown option: ${flag}.`)
88
+ }
89
+ if (check && opts.command && opts.command !== 'check') throw new UsageError(`--check does not work with ${opts.command}.`)
90
+ opts.command = check ? 'check' : opts.command ?? 'setup'
91
+ for (const [key, flag] of given) {
92
+ if (!COMMANDS[opts.command].includes(key)) throw new UsageError(`${flag} does not work with ${opts.command}.`)
93
+ }
94
+ return opts
95
+ }
96
+
97
+ /**
98
+ * Run the CLI. Returns the exit code.
99
+ * @param {string[]} argv
100
+ * @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, deps?: object}} [io]
101
+ */
102
+ export async function main(argv, io = {}) {
103
+ const { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, deps = {} } = io
104
+ const json = argv.includes('--json')
105
+ const usage = (message) => {
106
+ stderr.write(`${message}\nRun uknows-setup --help.\n`)
107
+ if (json) stdout.write(`${JSON.stringify({ schema: 1, tool: TOOL, version: VERSION, command: null, status: 'usage-error', exitCode: 2, problems: [{ code: 'usage', message }] }, null, 2)}\n`)
108
+ return 2
109
+ }
110
+ let args
111
+ try {
112
+ args = parseArgs(argv)
113
+ } catch (error) {
114
+ if (error instanceof UsageError) return usage(error.message)
115
+ throw error
116
+ }
117
+ if (args.help) { stdout.write(HELP); return 0 }
118
+ if (args.version) { stdout.write(`${VERSION}\n`); return 0 }
119
+ if (args.command === 'check') return check(args, { stdout, stderr, env }, deps.runCheck ?? runCheck)
120
+
121
+ // setup writes the same ingest address that check sends to.
122
+ const endpoint = ingestEndpoint(env)
123
+ if (!endpoint) return usage(`${CLI.endpointEnv} must be an https address.`)
124
+ if (!siteUrl(env)) return usage(`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`)
125
+ const root = resolve(cwd, args.path ?? '.')
126
+ let folder = false
127
+ try { folder = statSync(root).isDirectory() } catch { /* missing */ }
128
+ if (!folder) return usage(`${args.path ?? root} is not a folder.`)
129
+ if (args.serviceName !== null && (deps.toServiceName ?? toServiceName)(args.serviceName) !== args.serviceName) {
130
+ return usage(`${args.serviceName} is not a valid service name. Use lowercase letters, digits and -, up to 63 characters.`)
131
+ }
132
+ const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive })
133
+ try {
134
+ const { code, report } = await (args.command === 'remove' ? runRemove : runSetup)({ ...args, root, endpoint, env }, { ...deps, ui })
135
+ if (args.json) stdout.write(`${JSON.stringify(report, null, 2)}\n`)
136
+ return code
137
+ } finally {
138
+ ui.close()
139
+ }
140
+ }
141
+
142
+ async function check(args, { stdout, stderr, env }, run) {
143
+ const result = await run({ env })
144
+ const status = result.exit === 2 ? 'no-key' : result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
145
+ const lines = redact(result.lines, env[SECRET.name])
146
+ if (args.json) {
147
+ stdout.write(`${JSON.stringify({ schema: 1, tool: TOOL, version: VERSION, command: 'check', status, exitCode: result.exit,
148
+ httpStatus: result.status, workspace: result.workspace, environment: result.environment, lines }, null, 2)}\n`)
149
+ } else {
150
+ (result.exit === 1 ? stderr : stdout).write(lines.map((line) => `${line}\n`).join(''))
151
+ }
152
+ return result.exit
153
+ }
154
+
155
+ /** Belt and braces: take every header value out of the lines, even if the server echoed it. */
156
+ function redact(lines, headers) {
157
+ const secrets = []
158
+ for (const pair of String(headers ?? '').split(',')) {
159
+ const value = pair.slice(pair.indexOf('=') + 1).trim()
160
+ if (pair.includes('=') && value.length >= 4) {
161
+ secrets.push(value)
162
+ try { secrets.push(decodeURIComponent(value)) } catch { /* not encoded */ }
163
+ }
164
+ }
165
+ return lines.map((line) => secrets.reduce((out, secret) => out.split(secret).join('***'), String(line)))
166
+ }