codebase-onboarder 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/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- package/shared/search/query.js +518 -0
package/cli/commands.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// The verbs of the `onboarder` command. `main.js` parses argv into flags and a
|
|
2
|
+
// command name; each function here is one command, taking `{ flags, out, err,
|
|
3
|
+
// io }` so the tests can drive them with strings instead of a terminal.
|
|
4
|
+
//
|
|
5
|
+
// Every command returns an exit code instead of calling process.exit — the
|
|
6
|
+
// bin wrapper is the only place that touches the process.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import fsp from 'node:fs/promises';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_SETTINGS, configPath, configExists, readSettings, writeSettings,
|
|
15
|
+
publicSettings, serverUrls, maskAccessKey,
|
|
16
|
+
} from '../server/config.js';
|
|
17
|
+
import { startServer } from '../server/index.js';
|
|
18
|
+
import { tunnelStatus, cloudflareCommand, tailscaleCommand, installHint, findOnPath } from '../server/tunnel.js';
|
|
19
|
+
import { bold, cyan, dim, ok, warn, bad, kv, tick, cross, dash, welcomeBanner } from './ui.js';
|
|
20
|
+
import { buildSteps, pendingSteps, defaultOf, applyFlags, answersToSettings, summaryLines } from './wizard.js';
|
|
21
|
+
import { runSteps, WizardCancelled } from './prompt.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------- setup ---
|
|
24
|
+
|
|
25
|
+
export async function runSetup({ flags = {}, out = console.log, err = console.error, version = '' } = {}) {
|
|
26
|
+
const file = flags.config || configPath();
|
|
27
|
+
const existed = await configExists(file);
|
|
28
|
+
const current = existed ? await readSettings(file) : { ...DEFAULT_SETTINGS };
|
|
29
|
+
|
|
30
|
+
// Non-interactive (or a piped stdout): flags are the answers, defaults fill
|
|
31
|
+
// the rest, and the summary is printed instead of asked about.
|
|
32
|
+
if (flags.nonInteractive || !process.stdout.isTTY) {
|
|
33
|
+
const { settings } = applyFlags(current, flags);
|
|
34
|
+
await writeSettings(settings, file);
|
|
35
|
+
if (flags.json) {
|
|
36
|
+
out(JSON.stringify({ configFile: file, created: !existed, settings: publicSettings(settings) }, null, 2));
|
|
37
|
+
} else {
|
|
38
|
+
out(tick + `Wrote ${file}`);
|
|
39
|
+
printSummary(out, settings, { revealKey: Boolean(patchHasNewKey(flags)) });
|
|
40
|
+
}
|
|
41
|
+
await printTunnelFollowup(out, settings);
|
|
42
|
+
if (flags.start) return runStart({ flags, out, err });
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
out(welcomeBanner(version, file));
|
|
47
|
+
if (existed) out(dim(' A config already exists — answers start from what it says.\n'));
|
|
48
|
+
|
|
49
|
+
// Flag answers go in first; the wizard only asks what is left.
|
|
50
|
+
const seed = {};
|
|
51
|
+
for (const id of ['name', 'email', 'mode', 'host', 'port', 'domain', 'autoOpen']) {
|
|
52
|
+
if (flags[id] !== undefined) seed[id] = flags[id];
|
|
53
|
+
}
|
|
54
|
+
if (flags.provider !== undefined) seed.provider = flags.provider;
|
|
55
|
+
if (flags.baseUrl !== undefined) seed.baseUrl = flags.baseUrl;
|
|
56
|
+
if (flags.model !== undefined) seed.model = flags.model;
|
|
57
|
+
|
|
58
|
+
let answers;
|
|
59
|
+
try {
|
|
60
|
+
answers = await runSteps(pendingSteps(buildSteps(current), seed), seed);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e instanceof WizardCancelled) {
|
|
63
|
+
err('\n Setup cancelled — nothing was written.');
|
|
64
|
+
return 130;
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let settings;
|
|
70
|
+
try {
|
|
71
|
+
settings = answersToSettings(current, answers);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
err(bad(' Those answers do not make a valid config: ' + e.message));
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
out('');
|
|
78
|
+
printSummary(out, settings, { revealKey: false });
|
|
79
|
+
out('');
|
|
80
|
+
const confirmed = await confirm(out, 'Write this config?');
|
|
81
|
+
if (!confirmed) {
|
|
82
|
+
err(' Not written.');
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
await writeSettings(settings, file);
|
|
86
|
+
out(tick + 'Wrote ' + file);
|
|
87
|
+
|
|
88
|
+
// The generated key exists exactly once in a readable form: right now.
|
|
89
|
+
if (answers.keyChoice === 'generate' && settings.accessKey) {
|
|
90
|
+
out('');
|
|
91
|
+
out(warn(' Your access key — shown this once, store it somewhere safe:'));
|
|
92
|
+
out(' ' + bold(settings.accessKey));
|
|
93
|
+
}
|
|
94
|
+
await printTunnelFollowup(out, settings);
|
|
95
|
+
|
|
96
|
+
if (flags.start || await confirm(out, 'Start Onboarder now?', true)) {
|
|
97
|
+
out('');
|
|
98
|
+
return runStart({ flags, out, err });
|
|
99
|
+
}
|
|
100
|
+
out(dim(' Later: `onboarder start`'));
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function patchHasNewKey(flags) {
|
|
105
|
+
return flags.accessKey === 'generate' || (flags.accessKey === undefined && flags.mode === 'self-hosted');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function printSummary(out, settings, { revealKey = false } = {}) {
|
|
109
|
+
out(bold(' About to write:'));
|
|
110
|
+
for (const [label, value] of summaryLines(settings, { revealKey })) out(kv(label, String(value)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function confirm(out, question, fallback = false) {
|
|
114
|
+
const { default: readline } = await import('node:readline/promises');
|
|
115
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
try {
|
|
117
|
+
const typed = await rl.question(` ${bold(question)} ${dim(fallback ? '(Y/n)' : '(y/N)')} `);
|
|
118
|
+
const v = typed.trim().toLowerCase();
|
|
119
|
+
return v ? v === 'y' || v === 'yes' : fallback;
|
|
120
|
+
} finally {
|
|
121
|
+
rl.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function printTunnelFollowup(out, settings) {
|
|
126
|
+
const status = tunnelStatus(settings);
|
|
127
|
+
for (const name of ['cloudflare', 'tailscale']) {
|
|
128
|
+
const t = status[name];
|
|
129
|
+
if (!t.enabled) continue;
|
|
130
|
+
out('');
|
|
131
|
+
if (t.installed) {
|
|
132
|
+
out(` ${name} — start the tunnel with:`);
|
|
133
|
+
out(' ' + cyan(t.command));
|
|
134
|
+
out(dim(` or just: onboarder tunnel ${name}`));
|
|
135
|
+
} else {
|
|
136
|
+
out(warn(` ${name} is enabled but its CLI is not installed.`));
|
|
137
|
+
out(dim(' Install: ' + t.install));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------- start ---
|
|
143
|
+
|
|
144
|
+
export async function runStart({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
145
|
+
const file = flags.config || configPath();
|
|
146
|
+
if (!await configExists(file)) {
|
|
147
|
+
// No config yet: on a terminal the kind thing is to run setup first; in a
|
|
148
|
+
// script the predictable thing is to boot the defaults (which are the
|
|
149
|
+
// pre-settings behavior) and say so.
|
|
150
|
+
if (process.stdout.isTTY && !flags.nonInteractive) {
|
|
151
|
+
out(dim(' No settings yet — running setup first.'));
|
|
152
|
+
return runSetup({ flags: { ...flags, start: true }, out, err });
|
|
153
|
+
}
|
|
154
|
+
out(dim(` No config at ${file} — starting with defaults (local mode).`));
|
|
155
|
+
}
|
|
156
|
+
const { server, host, port } = await startServer({ configFile: file, log: out });
|
|
157
|
+
if (flags.json) out(JSON.stringify({ host, port, url: `http://127.0.0.1:${port}/` }));
|
|
158
|
+
// The listening server holds the event loop; resolve so callers/tests know
|
|
159
|
+
// we are up, but leave the process running.
|
|
160
|
+
return { server, host, port, code: 0 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --------------------------------------------------------------- config ---
|
|
164
|
+
|
|
165
|
+
// Keys `config set` may touch — the same allow-list the HTTP API enforces, so
|
|
166
|
+
// neither surface can smuggle in a key the schema does not know.
|
|
167
|
+
const SETTABLE = {
|
|
168
|
+
mode: (v) => { if (!['local', 'self-hosted'].includes(v)) throw new Error('mode must be "local" or "self-hosted".'); return v; },
|
|
169
|
+
host: (v) => v,
|
|
170
|
+
port: (v) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > 65535) throw new Error('port must be 1-65535.'); return n; },
|
|
171
|
+
domain: (v) => v,
|
|
172
|
+
autoOpen: (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
|
|
173
|
+
'account.name': (v) => v,
|
|
174
|
+
'account.email': (v) => v,
|
|
175
|
+
'account.provider': (v) => v,
|
|
176
|
+
'account.baseUrl': (v) => v,
|
|
177
|
+
'account.model': (v) => v,
|
|
178
|
+
'tunnel.cloudflare': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
|
|
179
|
+
'tunnel.tailscale': (v) => ['true', 'yes', '1', 'on'].includes(String(v).toLowerCase()),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function dig(obj, dotted) {
|
|
183
|
+
return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function bury(obj, dotted, value) {
|
|
187
|
+
const keys = dotted.split('.');
|
|
188
|
+
let o = obj;
|
|
189
|
+
for (const k of keys.slice(0, -1)) o = o[k] = o[k] && typeof o[k] === 'object' ? o[k] : {};
|
|
190
|
+
o[keys.at(-1)] = value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
export async function runConfig(sub, args, { flags = {}, out = console.log } = {}) {
|
|
195
|
+
const file = flags.config || configPath();
|
|
196
|
+
switch (sub) {
|
|
197
|
+
case 'path':
|
|
198
|
+
out(file);
|
|
199
|
+
return 0;
|
|
200
|
+
case 'show': {
|
|
201
|
+
const settings = await readSettings(file);
|
|
202
|
+
if (flags.json) {
|
|
203
|
+
const pub = publicSettings(settings);
|
|
204
|
+
if (flags.reveal) pub.accessKey = settings.accessKey || null;
|
|
205
|
+
out(JSON.stringify({ configFile: file, ...pub }, null, 2));
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
const urls = serverUrls(settings);
|
|
209
|
+
out(bold(' ' + file));
|
|
210
|
+
out(kv('Mode', settings.mode));
|
|
211
|
+
out(kv('Bind', `${settings.host}:${settings.port}`));
|
|
212
|
+
if (settings.domain) out(kv('Domain', settings.domain));
|
|
213
|
+
out(kv('Local', urls.local));
|
|
214
|
+
if (urls.network) out(kv('Network', urls.network));
|
|
215
|
+
if (urls.domain) out(kv('Public', urls.domain));
|
|
216
|
+
if (settings.mode === 'self-hosted') {
|
|
217
|
+
out(kv('Access key', flags.reveal ? (settings.accessKey || '(none)') : maskAccessKey(settings.accessKey)));
|
|
218
|
+
if (!flags.reveal) out(dim(' `onboarder config show --reveal` prints it — you are on this machine, after all.'));
|
|
219
|
+
}
|
|
220
|
+
const who = [settings.account.name, settings.account.email].filter(Boolean).join(' · ');
|
|
221
|
+
if (who) out(kv('Profile', who));
|
|
222
|
+
if (settings.account.baseUrl) {
|
|
223
|
+
out(kv('AI', `${settings.account.provider} — ${settings.account.baseUrl}${settings.account.model ? ' — ' + settings.account.model : ''}`));
|
|
224
|
+
}
|
|
225
|
+
const status = tunnelStatus(settings);
|
|
226
|
+
for (const name of ['cloudflare', 'tailscale']) {
|
|
227
|
+
if (status[name].enabled) {
|
|
228
|
+
out(kv(name, status[name].installed ? status[name].command : 'enabled, CLI missing — ' + status[name].install));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
out(kv('Auto-open', settings.autoOpen ? 'yes' : 'no'));
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
case 'get': {
|
|
235
|
+
const [key] = args;
|
|
236
|
+
if (!key || (!(key in SETTABLE) && key !== 'accessKey')) throw new Error('Unknown key. Known: ' + [...Object.keys(SETTABLE), 'accessKey'].join(', '));
|
|
237
|
+
const settings = await readSettings(file);
|
|
238
|
+
const value = key === 'accessKey' ? maskAccessKey(settings.accessKey) : dig(settings, key);
|
|
239
|
+
out(String(value ?? ''));
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
case 'set': {
|
|
243
|
+
const [key, ...rest] = args;
|
|
244
|
+
const raw = rest.join(' ');
|
|
245
|
+
if (!key || !(key in SETTABLE)) throw new Error('Unknown or unsettable key "' + key + '". Known: ' + Object.keys(SETTABLE).join(', ') + '\nThe access key is managed by `onboarder config key …`.');
|
|
246
|
+
if (!raw) throw new Error('Missing value: onboarder config set ' + key + ' <value>');
|
|
247
|
+
const value = SETTABLE[key](raw);
|
|
248
|
+
const current = await readSettings(file);
|
|
249
|
+
const next = { ...current, account: { ...current.account }, tunnel: { ...current.tunnel } };
|
|
250
|
+
bury(next, key, value);
|
|
251
|
+
await writeSettings(next, file); // normalizeSettings validates the result
|
|
252
|
+
out(tick + key + ' = ' + (typeof value === 'boolean' ? String(value) : value));
|
|
253
|
+
if (['host', 'port'].includes(key)) out(dim(' Restart the server for this to take effect.'));
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
throw new Error('Usage: onboarder config <show|get|set|path|reset|key>');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
// Config surgery that did not fit in `set`: full reset, and the access key,
|
|
263
|
+
// which gets its own verb because it is the one value that is shown once and
|
|
264
|
+
// never round-trips through an API.
|
|
265
|
+
export async function runConfigKey(action, args, { flags = {}, out = console.log, err = console.error } = {}) {
|
|
266
|
+
const file = flags.config || configPath();
|
|
267
|
+
const current = await readSettings(file);
|
|
268
|
+
if (action === 'rotate') {
|
|
269
|
+
const { generateAccessKey } = await import('../server/config.js');
|
|
270
|
+
const key = generateAccessKey();
|
|
271
|
+
await writeSettings({ ...current, accessKey: key }, file);
|
|
272
|
+
out(tick + 'Rotated. The old key is dead this second — settings are re-read per request.');
|
|
273
|
+
out(' ' + bold(key));
|
|
274
|
+
out(dim(' Shown once. Distribute it to your devices.'));
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (action === 'show') {
|
|
278
|
+
out(current.accessKey || '(no key set)');
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
if (action === 'set') {
|
|
282
|
+
const value = args.join(' ').trim();
|
|
283
|
+
if (value.length < 16) throw new Error('Access keys are at least 16 characters.');
|
|
284
|
+
await writeSettings({ ...current, accessKey: value }, file);
|
|
285
|
+
out(tick + 'Access key updated.');
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
throw new Error('Usage: onboarder config key <rotate|show|set <value>>');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function runConfigReset({ flags = {}, out = console.log, err = console.error } = {}) {
|
|
292
|
+
const file = flags.config || configPath();
|
|
293
|
+
if (!flags.yes) {
|
|
294
|
+
err(' This rewrites ' + file + ' with factory defaults (local mode, port 4310, no key).');
|
|
295
|
+
err(' Re-run with --yes to confirm.');
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
await writeSettings({ ...DEFAULT_SETTINGS }, file);
|
|
299
|
+
out(tick + 'Reset to defaults: ' + file);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
// --------------------------------------------------------------- doctor ---
|
|
305
|
+
|
|
306
|
+
// Diagnose without mutating. Every check reports { id, ok, required, detail }
|
|
307
|
+
// and the command exits non-zero if any *required* check failed — the same
|
|
308
|
+
// shape `doctor --json` hands to scripts.
|
|
309
|
+
export async function runDoctor({ flags = {}, out = console.log } = {}) {
|
|
310
|
+
const file = flags.config || configPath();
|
|
311
|
+
const checks = [];
|
|
312
|
+
|
|
313
|
+
checks.push({
|
|
314
|
+
id: 'node', ok: Number(process.versions.node.split('.')[0]) >= 20, required: true,
|
|
315
|
+
detail: `v${process.versions.node} (needs ≥ 20)`,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
let settings = null;
|
|
319
|
+
try {
|
|
320
|
+
settings = await readSettings(file);
|
|
321
|
+
checks.push({ id: 'config', ok: true, required: true, detail: file });
|
|
322
|
+
} catch (e) {
|
|
323
|
+
checks.push({ id: 'config', ok: false, required: true, detail: `${file} — ${e.message}` });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const dir = file.slice(0, file.lastIndexOf('/'));
|
|
327
|
+
let dirOk = true;
|
|
328
|
+
try { await fsp.access(dir, fs.constants.W_OK); } catch { dirOk = false; }
|
|
329
|
+
checks.push({ id: 'config-dir', ok: dirOk, required: true, detail: dirOk ? dir + ' is writable' : dir + ' is NOT writable' });
|
|
330
|
+
|
|
331
|
+
if (settings) {
|
|
332
|
+
const free = await portIsFree(settings.host, settings.port);
|
|
333
|
+
checks.push({
|
|
334
|
+
id: 'port', ok: free, required: false,
|
|
335
|
+
detail: free ? `${settings.host}:${settings.port} is free` : `${settings.host}:${settings.port} is already in use (running already?)`,
|
|
336
|
+
});
|
|
337
|
+
if (settings.mode === 'self-hosted') {
|
|
338
|
+
checks.push({
|
|
339
|
+
id: 'access-key', ok: Boolean(settings.accessKey), required: true,
|
|
340
|
+
detail: settings.accessKey ? 'set — ' + maskAccessKey(settings.accessKey) : 'MISSING — the API refuses every call until one is set',
|
|
341
|
+
});
|
|
342
|
+
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(settings.host);
|
|
343
|
+
checks.push({
|
|
344
|
+
id: 'domain', ok: loopback || Boolean(settings.domain), required: !loopback,
|
|
345
|
+
detail: settings.domain || (loopback ? '(none — a tunnel provides the name)' : 'MISSING — a LAN bind needs a domain'),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (const name of ['git', 'cloudflared', 'tailscale']) {
|
|
351
|
+
const wanted = name === 'git' || (settings && settings.tunnel?.[name === 'cloudflared' ? 'cloudflare' : 'tailscale']);
|
|
352
|
+
const found = Boolean(findOnPath(name));
|
|
353
|
+
checks.push({
|
|
354
|
+
id: name, ok: found || !wanted, required: name === 'git',
|
|
355
|
+
detail: found ? 'installed' : wanted ? 'not installed — ' + installHint(name) : 'not installed (not needed for your settings)',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (flags.json) {
|
|
360
|
+
out(JSON.stringify({ ok: checks.every((c) => c.ok || !c.required), checks }, null, 2));
|
|
361
|
+
} else {
|
|
362
|
+
out('');
|
|
363
|
+
out(bold(' Onboarder doctor'));
|
|
364
|
+
for (const c of checks) {
|
|
365
|
+
const mark = c.ok ? tick : c.required ? cross : dash;
|
|
366
|
+
out(`${mark}${c.id} — ${c.detail}`);
|
|
367
|
+
}
|
|
368
|
+
out('');
|
|
369
|
+
}
|
|
370
|
+
return checks.every((c) => c.ok || !c.required) ? 0 : 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function portIsFree(host, port) {
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
const probe = net.createServer();
|
|
376
|
+
probe.once('error', () => resolve(false));
|
|
377
|
+
probe.listen(port, host, () => probe.close(() => resolve(true)));
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --------------------------------------------------------------- tunnel ---
|
|
382
|
+
|
|
383
|
+
export async function runTunnel(kind, { flags = {}, out = console.log, err = console.error } = {}) {
|
|
384
|
+
const settings = await readSettings(flags.config || configPath());
|
|
385
|
+
const target = `http://127.0.0.1:${settings.port}`;
|
|
386
|
+
if (kind === 'tailscale') {
|
|
387
|
+
if (!findOnPath('tailscale')) {
|
|
388
|
+
err(' tailscale is not installed — ' + installHint('tailscale'));
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
const cmd = tailscaleCommand(settings);
|
|
392
|
+
out(' ' + cmd);
|
|
393
|
+
const { spawnSync } = await import('node:child_process');
|
|
394
|
+
const r = spawnSync(cmd, { shell: true, stdio: 'inherit' });
|
|
395
|
+
if (r.status === 0) {
|
|
396
|
+
out(tick + 'Tailscale is serving ' + target + ' over your tailnet (https://<machine>.<tailnet>.ts.net).');
|
|
397
|
+
out(dim(' `tailscale serve status` to verify, `onboarder doctor` to re-check.'));
|
|
398
|
+
}
|
|
399
|
+
return r.status ?? 1;
|
|
400
|
+
}
|
|
401
|
+
if (kind === 'cloudflare') {
|
|
402
|
+
if (!findOnPath('cloudflared')) {
|
|
403
|
+
err(' cloudflared is not installed — ' + installHint('cloudflared'));
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
const cmd = cloudflareCommand(settings);
|
|
407
|
+
out(dim(' $ ' + cmd));
|
|
408
|
+
out(dim(' Ctrl-C stops the tunnel; the server keeps running.\n'));
|
|
409
|
+
// Quick tunnels print their random name on stderr; surface just that line
|
|
410
|
+
// plus everything when --verbose, so the default output is one useful URL.
|
|
411
|
+
const child = spawn('cloudflared', ['tunnel', '--url', target], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
412
|
+
let announced = false;
|
|
413
|
+
child.stderr.on('data', (chunk) => {
|
|
414
|
+
const text = String(chunk);
|
|
415
|
+
const m = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
416
|
+
if (m && !announced) {
|
|
417
|
+
announced = true;
|
|
418
|
+
out(tick + 'Public URL: ' + bold(m[0]));
|
|
419
|
+
out(dim(' Anyone with the URL still needs the access key: ' + m[0] + '?key=<your-key>'));
|
|
420
|
+
}
|
|
421
|
+
if (flags.verbose) process.stderr.write(text);
|
|
422
|
+
});
|
|
423
|
+
child.stdout.on('data', (d) => { if (flags.verbose) process.stdout.write(d); });
|
|
424
|
+
const stop = () => child.kill('SIGINT');
|
|
425
|
+
process.once('SIGINT', stop);
|
|
426
|
+
return new Promise((resolve) => {
|
|
427
|
+
child.on('close', (code) => {
|
|
428
|
+
process.removeListener('SIGINT', stop);
|
|
429
|
+
if (!announced && code !== 0) err(' cloudflared exited without printing a URL — re-run with --verbose to see why.');
|
|
430
|
+
resolve(code ?? 0);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
throw new Error('Usage: onboarder tunnel <cloudflare|tailscale>');
|
|
435
|
+
}
|
|
436
|
+
|
package/cli/main.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// argv → command. `bin/onboarder.js` is a one-line trampoline into `main`; all
|
|
2
|
+
// parsing and dispatch lives here so the tests can call it directly.
|
|
3
|
+
//
|
|
4
|
+
// Flags use node's util.parseArgs — still zero dependencies, still strict about
|
|
5
|
+
// unknown flags, which is what a tool that writes a config file should be.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
|
|
10
|
+
import { runSetup, runStart, runConfig, runConfigKey, runConfigReset, runDoctor, runTunnel } from './commands.js';
|
|
11
|
+
|
|
12
|
+
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
|
+
|
|
14
|
+
const HELP = `
|
|
15
|
+
🧭 Onboarder — drop a path, get a map.
|
|
16
|
+
|
|
17
|
+
Usage
|
|
18
|
+
onboarder Start the server (runs setup first if needed)
|
|
19
|
+
onboarder setup | onboard Configure interactively (the wizard)
|
|
20
|
+
onboarder start Start the server
|
|
21
|
+
onboarder config <…> show | get <key> | set <key> <value> | path | reset | key <rotate|show|set>
|
|
22
|
+
onboarder tunnel <name> cloudflare | tailscale
|
|
23
|
+
onboarder doctor Check the machine and the config
|
|
24
|
+
|
|
25
|
+
Setup flags (interactive wizard skips what they answer)
|
|
26
|
+
--mode local|self-hosted --host <addr> --port <n> --domain <name>
|
|
27
|
+
--access-key generate|<k> --name <n> --email <e>
|
|
28
|
+
--provider none|openai-compatible|ollama|openrouter|custom
|
|
29
|
+
--base-url <url> --model <m> --cloudflare --tailscale
|
|
30
|
+
|
|
31
|
+
General flags
|
|
32
|
+
--config <file> Use this config file (or ONBOARDER_CONFIG)
|
|
33
|
+
--non-interactive Never prompt; flags + defaults are the answers
|
|
34
|
+
-y, --yes Answer yes to confirmations
|
|
35
|
+
--json Machine-readable output
|
|
36
|
+
--reveal config show prints the full access key
|
|
37
|
+
--no-color Plain output (NO_COLOR works too)
|
|
38
|
+
-h, --help This text -v, --version Print the version
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
onboarder setup
|
|
42
|
+
onboarder setup --non-interactive --mode local --port 4310
|
|
43
|
+
onboarder setup --non-interactive --mode self-hosted --domain map.example.com --access-key generate
|
|
44
|
+
onboarder config set tunnel.cloudflare true && onboarder tunnel cloudflare
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const OPTIONS = {
|
|
48
|
+
help: { type: 'boolean', short: 'h' },
|
|
49
|
+
version: { type: 'boolean', short: 'v' },
|
|
50
|
+
json: { type: 'boolean' },
|
|
51
|
+
yes: { type: 'boolean', short: 'y' },
|
|
52
|
+
'non-interactive': { type: 'boolean' },
|
|
53
|
+
reveal: { type: 'boolean' },
|
|
54
|
+
verbose: { type: 'boolean' },
|
|
55
|
+
start: { type: 'boolean' },
|
|
56
|
+
color: { type: 'boolean' }, // --no-color falls out of parseArgs for free
|
|
57
|
+
config: { type: 'string' },
|
|
58
|
+
mode: { type: 'string' },
|
|
59
|
+
host: { type: 'string' },
|
|
60
|
+
port: { type: 'string' },
|
|
61
|
+
domain: { type: 'string' },
|
|
62
|
+
'access-key': { type: 'string' },
|
|
63
|
+
name: { type: 'string' },
|
|
64
|
+
email: { type: 'string' },
|
|
65
|
+
provider: { type: 'string' },
|
|
66
|
+
'base-url': { type: 'string' },
|
|
67
|
+
model: { type: 'string' },
|
|
68
|
+
cloudflare: { type: 'boolean' },
|
|
69
|
+
tailscale: { type: 'boolean' },
|
|
70
|
+
'auto-open': { type: 'boolean' },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// parseArgs speaks kebab-case; the wizard's flags speak camelCase.
|
|
74
|
+
function normalizeFlags(values) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const [k, v] of Object.entries(values)) {
|
|
77
|
+
const key = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
78
|
+
out[key] = v;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.error(' ' + e.message + '\n' + HELP);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
const { values, positionals } = parsed;
|
|
92
|
+
const flags = normalizeFlags(values);
|
|
93
|
+
|
|
94
|
+
if (flags.help) { console.log(HELP); return 0; }
|
|
95
|
+
if (flags.version) { console.log(PACKAGE.version); return 0; }
|
|
96
|
+
|
|
97
|
+
const [cmd, sub, ...rest] = positionals;
|
|
98
|
+
try {
|
|
99
|
+
switch (cmd) {
|
|
100
|
+
case undefined:
|
|
101
|
+
case 'start':
|
|
102
|
+
return codeOf(await runStart({ flags }));
|
|
103
|
+
case 'setup':
|
|
104
|
+
case 'onboard':
|
|
105
|
+
case 'init':
|
|
106
|
+
return codeOf(await runSetup({ flags, version: PACKAGE.version }));
|
|
107
|
+
case 'config':
|
|
108
|
+
if (sub === 'key') return codeOf(await runConfigKey(rest[0], rest.slice(1), { flags }));
|
|
109
|
+
if (sub === 'reset') return codeOf(await runConfigReset({ flags }));
|
|
110
|
+
return codeOf(await runConfig(sub, rest, { flags }));
|
|
111
|
+
case 'tunnel':
|
|
112
|
+
return codeOf(await runTunnel(sub, { flags }));
|
|
113
|
+
case 'doctor':
|
|
114
|
+
return codeOf(await runDoctor({ flags }));
|
|
115
|
+
default:
|
|
116
|
+
console.error(' Unknown command: ' + cmd + '\n' + HELP);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(' ' + (e.message || e));
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Commands return an exit code or, for `start`, an object carrying the live
|
|
126
|
+
// server. Both collapse to "what code should the process eventually use".
|
|
127
|
+
function codeOf(result) {
|
|
128
|
+
return typeof result === 'number' ? result : result?.code ?? 0;
|
|
129
|
+
}
|
package/cli/prompt.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// The wizard's face: one readline interface that renders the pure steps from
|
|
2
|
+
// `wizard.js`. Everything display-shaped lives here — numbered choices, hints
|
|
3
|
+
// in gray, validation loops that re-ask with the reason — and nothing
|
|
4
|
+
// decision-shaped does. A step asks, validates, returns; the engine decides
|
|
5
|
+
// what the answer means.
|
|
6
|
+
//
|
|
7
|
+
// Ctrl-C is a first-class answer ("cancelled"), not a stack trace: the wizard
|
|
8
|
+
// has written nothing yet at any point it can be pressed, and it says so.
|
|
9
|
+
|
|
10
|
+
import readline from 'node:readline/promises';
|
|
11
|
+
|
|
12
|
+
import { bold, cyan, dim, bad, section } from './ui.js';
|
|
13
|
+
import { defaultOf } from './wizard.js';
|
|
14
|
+
|
|
15
|
+
export class WizardCancelled extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('cancelled');
|
|
18
|
+
this.code = 'CANCELLED';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Run every pending step in order, collecting answers. `answers` is seeded
|
|
23
|
+
// with flag answers by the caller, which is how --flags skip questions.
|
|
24
|
+
export async function runSteps(steps, answers = {}, io = {}) {
|
|
25
|
+
const input = io.input || process.stdin;
|
|
26
|
+
const output = io.output || process.stdout;
|
|
27
|
+
const rl = readline.createInterface({ input, output });
|
|
28
|
+
// readline eats SIGINT when the terminal is in its raw-ish mode; route it to
|
|
29
|
+
// the same cancellation path as anything else that would kill us mid-ask.
|
|
30
|
+
const onSigint = () => rl.close();
|
|
31
|
+
process.once('SIGINT', onSigint);
|
|
32
|
+
try {
|
|
33
|
+
let lastSection = null;
|
|
34
|
+
for (const step of steps) {
|
|
35
|
+
if (step.section && step.section !== lastSection) {
|
|
36
|
+
output.write(section(step.section) + '\n');
|
|
37
|
+
lastSection = step.section;
|
|
38
|
+
}
|
|
39
|
+
answers[step.id] = await askOne(rl, output, step, answers);
|
|
40
|
+
}
|
|
41
|
+
return answers;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new WizardCancelled();
|
|
44
|
+
} finally {
|
|
45
|
+
process.removeListener('SIGINT', onSigint);
|
|
46
|
+
rl.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function askOne(rl, output, step, answers) {
|
|
51
|
+
// Re-ask until the validator is happy; each failure prints the reason so the
|
|
52
|
+
// next attempt is informed rather than punished.
|
|
53
|
+
for (;;) {
|
|
54
|
+
const value = step.type === 'choice'
|
|
55
|
+
? await askChoice(rl, output, step, answers)
|
|
56
|
+
: step.type === 'confirm'
|
|
57
|
+
? await askConfirm(rl, step, answers)
|
|
58
|
+
: await askText(rl, step, answers);
|
|
59
|
+
const complaint = step.validate ? step.validate(value) : null;
|
|
60
|
+
if (!complaint) return value;
|
|
61
|
+
output.write(bad(' ' + complaint) + '\n');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function askText(rl, step, answers) {
|
|
66
|
+
const fallback = defaultOf(step, answers);
|
|
67
|
+
if (step.hint) rl.output.write(dim(' ' + step.hint) + '\n');
|
|
68
|
+
const suffix = fallback ? dim(` (${fallback})`) : '';
|
|
69
|
+
const typed = await rl.question(` ${bold(step.question)}${suffix}: `);
|
|
70
|
+
const value = typed.trim() || String(fallback ?? '');
|
|
71
|
+
return step.type === 'text' && /port/i.test(step.id) ? value : value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function askConfirm(rl, step, answers) {
|
|
75
|
+
const fallback = Boolean(defaultOf(step, answers));
|
|
76
|
+
const typed = await rl.question(` ${bold(step.question)} ${dim(fallback ? '(Y/n)' : '(y/N)')} `);
|
|
77
|
+
const v = typed.trim().toLowerCase();
|
|
78
|
+
if (!v) return fallback;
|
|
79
|
+
return v === 'y' || v === 'yes';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function askChoice(rl, output, step, answers) {
|
|
83
|
+
const fallback = defaultOf(step, answers);
|
|
84
|
+
const choices = step.choices.filter((c) => !c.when || c.when(answers));
|
|
85
|
+
output.write(` ${bold(step.question)}\n`);
|
|
86
|
+
choices.forEach((c, i) => {
|
|
87
|
+
const marker = c.value === fallback ? cyan('›') : ' ';
|
|
88
|
+
const hint = c.hint ? dim(' ' + c.hint) : '';
|
|
89
|
+
output.write(` ${marker} ${i + 1}. ${c.label}${hint}\n`);
|
|
90
|
+
});
|
|
91
|
+
if (step.hint) output.write(dim(' ' + step.hint) + '\n');
|
|
92
|
+
const typed = await rl.question(dim(' Choose [1-' + choices.length + '] ') + '');
|
|
93
|
+
const v = typed.trim();
|
|
94
|
+
if (!v) return fallback;
|
|
95
|
+
const byNumber = choices[Number(v) - 1];
|
|
96
|
+
if (byNumber) return byNumber.value;
|
|
97
|
+
const byValue = choices.find((c) => c.value === v.toLowerCase());
|
|
98
|
+
if (byValue) return byValue.value;
|
|
99
|
+
output.write(bad(' Pick a number from the list.') + '\n');
|
|
100
|
+
return askChoice(rl, output, step, answers);
|
|
101
|
+
}
|