scoutline 0.10.2 → 0.12.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/README.md +99 -44
- package/dist/capabilities/diagnostics.d.ts +75 -0
- package/dist/capabilities/diagnostics.d.ts.map +1 -1
- package/dist/capabilities/diagnostics.js.map +1 -1
- package/dist/capabilities/quota.d.ts +64 -1
- package/dist/capabilities/quota.d.ts.map +1 -1
- package/dist/capabilities/quota.js.map +1 -1
- package/dist/capabilities/vision.d.ts +4 -3
- package/dist/capabilities/vision.d.ts.map +1 -1
- package/dist/capabilities/vision.js +7 -5
- package/dist/capabilities/vision.js.map +1 -1
- package/dist/commands/code.d.ts +9 -1
- package/dist/commands/code.d.ts.map +1 -1
- package/dist/commands/code.js +4 -4
- package/dist/commands/code.js.map +1 -1
- package/dist/commands/crawl.d.ts.map +1 -1
- package/dist/commands/crawl.js +14 -3
- package/dist/commands/crawl.js.map +1 -1
- package/dist/commands/doctor.d.ts +71 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +96 -15
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +186 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +1221 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/map.d.ts.map +1 -1
- package/dist/commands/map.js +16 -3
- package/dist/commands/map.js.map +1 -1
- package/dist/commands/quota.d.ts +32 -0
- package/dist/commands/quota.d.ts.map +1 -1
- package/dist/commands/quota.js +165 -24
- package/dist/commands/quota.js.map +1 -1
- package/dist/commands/read.d.ts.map +1 -1
- package/dist/commands/read.js +9 -3
- package/dist/commands/read.js.map +1 -1
- package/dist/commands/repo.d.ts.map +1 -1
- package/dist/commands/repo.js +5 -3
- package/dist/commands/repo.js.map +1 -1
- package/dist/commands/research.d.ts +35 -7
- package/dist/commands/research.d.ts.map +1 -1
- package/dist/commands/research.js +93 -15
- package/dist/commands/research.js.map +1 -1
- package/dist/commands/tools.d.ts +8 -0
- package/dist/commands/tools.d.ts.map +1 -1
- package/dist/commands/tools.js +4 -4
- package/dist/commands/tools.js.map +1 -1
- package/dist/commands/vision.d.ts +10 -0
- package/dist/commands/vision.d.ts.map +1 -1
- package/dist/commands/vision.js +25 -2
- package/dist/commands/vision.js.map +1 -1
- package/dist/index.d.ts +136 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +894 -298
- package/dist/index.js.map +1 -1
- package/dist/lib/api-client.d.ts +1 -1
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +6 -2
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/cache.d.ts +9 -2
- package/dist/lib/cache.d.ts.map +1 -1
- package/dist/lib/cache.js +9 -2
- package/dist/lib/cache.js.map +1 -1
- package/dist/lib/code-mode.d.ts +8 -0
- package/dist/lib/code-mode.d.ts.map +1 -1
- package/dist/lib/code-mode.js +3 -1
- package/dist/lib/code-mode.js.map +1 -1
- package/dist/lib/config-store.d.ts +165 -0
- package/dist/lib/config-store.d.ts.map +1 -0
- package/dist/lib/config-store.js +332 -0
- package/dist/lib/config-store.js.map +1 -0
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +19 -11
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/consumption.d.ts +144 -0
- package/dist/lib/consumption.d.ts.map +1 -0
- package/dist/lib/consumption.js +161 -0
- package/dist/lib/consumption.js.map +1 -0
- package/dist/lib/errors.d.ts +11 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +14 -0
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/execution.d.ts +32 -4
- package/dist/lib/execution.d.ts.map +1 -1
- package/dist/lib/execution.js +156 -13
- package/dist/lib/execution.js.map +1 -1
- package/dist/lib/mcp-client.d.ts +12 -0
- package/dist/lib/mcp-client.d.ts.map +1 -1
- package/dist/lib/mcp-client.js +16 -7
- package/dist/lib/mcp-client.js.map +1 -1
- package/dist/lib/mcp-config.d.ts +10 -0
- package/dist/lib/mcp-config.d.ts.map +1 -1
- package/dist/lib/mcp-config.js +13 -7
- package/dist/lib/mcp-config.js.map +1 -1
- package/dist/lib/monitor-client.d.ts +19 -3
- package/dist/lib/monitor-client.d.ts.map +1 -1
- package/dist/lib/monitor-client.js +21 -5
- package/dist/lib/monitor-client.js.map +1 -1
- package/dist/lib/provider-fallback.d.ts +150 -0
- package/dist/lib/provider-fallback.d.ts.map +1 -0
- package/dist/lib/provider-fallback.js +510 -0
- package/dist/lib/provider-fallback.js.map +1 -0
- package/dist/lib/quota-mapping.d.ts +402 -0
- package/dist/lib/quota-mapping.d.ts.map +1 -0
- package/dist/lib/quota-mapping.js +539 -0
- package/dist/lib/quota-mapping.js.map +1 -0
- package/dist/lib/quota-store.d.ts +271 -0
- package/dist/lib/quota-store.d.ts.map +1 -0
- package/dist/lib/quota-store.js +540 -0
- package/dist/lib/quota-store.js.map +1 -0
- package/dist/lib/trigger-detection.d.ts +149 -0
- package/dist/lib/trigger-detection.d.ts.map +1 -0
- package/dist/lib/trigger-detection.js +149 -0
- package/dist/lib/trigger-detection.js.map +1 -0
- package/dist/lib/tty.d.ts +8 -1
- package/dist/lib/tty.d.ts.map +1 -1
- package/dist/lib/tty.js +53 -1
- package/dist/lib/tty.js.map +1 -1
- package/dist/providers/brave/adapter.d.ts +26 -0
- package/dist/providers/brave/adapter.d.ts.map +1 -1
- package/dist/providers/brave/adapter.js +66 -4
- package/dist/providers/brave/adapter.js.map +1 -1
- package/dist/providers/brave/client.d.ts +46 -2
- package/dist/providers/brave/client.d.ts.map +1 -1
- package/dist/providers/brave/client.js +55 -3
- package/dist/providers/brave/client.js.map +1 -1
- package/dist/providers/exa/adapter.d.ts.map +1 -1
- package/dist/providers/exa/adapter.js +10 -1
- package/dist/providers/exa/adapter.js.map +1 -1
- package/dist/providers/firecrawl/adapter.d.ts.map +1 -1
- package/dist/providers/firecrawl/adapter.js +13 -3
- package/dist/providers/firecrawl/adapter.js.map +1 -1
- package/dist/providers/minimax/adapter.d.ts.map +1 -1
- package/dist/providers/minimax/adapter.js +4 -0
- package/dist/providers/minimax/adapter.js.map +1 -1
- package/dist/providers/selection.d.ts +114 -1
- package/dist/providers/selection.d.ts.map +1 -1
- package/dist/providers/selection.js +99 -3
- package/dist/providers/selection.js.map +1 -1
- package/dist/providers/tavily/adapter.d.ts.map +1 -1
- package/dist/providers/tavily/adapter.js +2 -0
- package/dist/providers/tavily/adapter.js.map +1 -1
- package/dist/providers/types.d.ts +33 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +8 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/providers/zai/adapter.d.ts.map +1 -1
- package/dist/providers/zai/adapter.js +18 -5
- package/dist/providers/zai/adapter.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Init command — interactive onboarding wizard (T3a + T3b — Plan A).
|
|
3
|
+
*
|
|
4
|
+
* This module owns the interactive `scoutline init` flow:
|
|
5
|
+
* - State detection (absent / corrupt / already-onboarded / fresh).
|
|
6
|
+
* - Provider checklist (registry-derived; none pre-checked — equal weight).
|
|
7
|
+
* - Per-provider ask-key-first → hidden input → inline single-attempt
|
|
8
|
+
* validation through `DiagnosticsCapability.invoke({probe:true})` against
|
|
9
|
+
* an EPHEMERAL resolved env (never persists, never mutates `process.env`).
|
|
10
|
+
* - Honest broad classification of probe failures: key-problems
|
|
11
|
+
* (`AuthError`/`ApiError`) reject + re-prompt; `NetworkError` offers
|
|
12
|
+
* save-unverified. No false-precise "wrong/disabled/mismatch" subtypes
|
|
13
|
+
* are inferred from message text — the taxonomy only distinguishes
|
|
14
|
+
* `AuthError`/`ApiError`/`NetworkError`.
|
|
15
|
+
* - Credit-cost disclosure before any paid-provider probe.
|
|
16
|
+
* - Fallback preference question.
|
|
17
|
+
* - Atomic config write (T1 `writeConfig` primitive) + redacted summary.
|
|
18
|
+
*
|
|
19
|
+
* T3b additions:
|
|
20
|
+
* - **Re-config menu** when an already-valid config exists: edit a
|
|
21
|
+
* Provider key, add a Provider, remove a Provider, change the
|
|
22
|
+
* fallback preference, re-run the full wizard, or cancel. Editing
|
|
23
|
+
* a Provider key resets `verification.status` to `unverified`
|
|
24
|
+
* (Doctor re-promotes after a successful probe).
|
|
25
|
+
* - **Corrupt-config repair**: tolerant `inspectConfig` distinguishes
|
|
26
|
+
* `absent` / `valid` / `corrupt`. On `corrupt` the wizard offers to
|
|
27
|
+
* back up the live file and rewrite a fresh config — `init` is the
|
|
28
|
+
* recovery path, not a victim of corruption.
|
|
29
|
+
* - **Formal non-TTY refuse**: without an interactive terminal the
|
|
30
|
+
* wizard refuses before any prompt, prints env instructions and
|
|
31
|
+
* the `init` hint, and exits.
|
|
32
|
+
* - **Stale-env-after-import warning**: when the user imports a key
|
|
33
|
+
* from env, the wizard notes that the env value will continue to
|
|
34
|
+
* take precedence (env > file in the runtime precedence rule) —
|
|
35
|
+
* the wizard cannot turn off the env value for the user.
|
|
36
|
+
* - **`hintShown` reset on re-init**: a re-config or fresh write
|
|
37
|
+
* resets the env-only hint marker so a user who later switches to
|
|
38
|
+
* env-only usage sees the hint once again.
|
|
39
|
+
*
|
|
40
|
+
* Boundary rules:
|
|
41
|
+
* - No Provider transport is constructed outside the per-provider probe;
|
|
42
|
+
* the candidate credential lives only in the ephemeral env until the
|
|
43
|
+
* final atomic write.
|
|
44
|
+
* - All prompt IO flows through the {@link InitPrompts} seam; no direct
|
|
45
|
+
* `process.stdin` / `process.stdout` reads in this module. Production
|
|
46
|
+
* wires `@inquirer/prompts`; tests inject scripted doubles.
|
|
47
|
+
* - Registration links render BOTH a terminal hyperlink AND the literal
|
|
48
|
+
* URL text so captured / non-hyperlink output stays usable.
|
|
49
|
+
* - No selection work (Plan B); the wizard writes at most one
|
|
50
|
+
* `fallbackEnabled` flag and per-Provider records.
|
|
51
|
+
*/
|
|
52
|
+
import { inspectConfig, writeConfig } from "../lib/config-store.js";
|
|
53
|
+
import { AuthError, ApiError, NetworkError } from "../lib/errors.js";
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Public constants
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Help text for `scoutline init --help`. T3b completes the wizard's
|
|
59
|
+
* lifecycle (re-config menu, corrupt-config repair, formal non-TTY
|
|
60
|
+
* refuse), so the T3a PREVIEW caveat is dropped — the public claim of
|
|
61
|
+
* a complete `init` is now accurate.
|
|
62
|
+
*/
|
|
63
|
+
export const INIT_HELP = `
|
|
64
|
+
init - Interactive onboarding wizard
|
|
65
|
+
|
|
66
|
+
Usage: scoutline init [options]
|
|
67
|
+
|
|
68
|
+
The wizard walks you through recording API keys in
|
|
69
|
+
~/.scoutline/config.json (mode 0600). It supports four states:
|
|
70
|
+
|
|
71
|
+
- ABSENT (no config yet): the fresh-onboarding flow runs.
|
|
72
|
+
- VALID + ALREADY-ONBOARDED: a re-config menu runs (edit a key,
|
|
73
|
+
add a Provider, remove a Provider, change the fallback
|
|
74
|
+
preference, re-run the full wizard, or cancel). Editing a key
|
|
75
|
+
resets that Provider's verification to "unverified".
|
|
76
|
+
- VALID + EMPTY: the fresh-onboarding flow runs.
|
|
77
|
+
- CORRUPT: the wizard offers to back up the live file and rewrite
|
|
78
|
+
a fresh config. init is the recovery path for a corrupt config.
|
|
79
|
+
|
|
80
|
+
The fresh flow:
|
|
81
|
+
- offers to import a provider key already present in env (the
|
|
82
|
+
wizard notes that env precedence means the env value keeps
|
|
83
|
+
winning at runtime)
|
|
84
|
+
- shows a provider checklist (Z.AI, MiniMax, Tavily, Exa, Brave,
|
|
85
|
+
Firecrawl) with NO pre-checked defaults — every provider has
|
|
86
|
+
equal weight
|
|
87
|
+
- for each selected provider, asks whether you have a key, takes a
|
|
88
|
+
hidden input, and performs a single inline validation probe
|
|
89
|
+
against an ephemeral in-memory environment (the candidate
|
|
90
|
+
credential is never persisted or written to process.env until the
|
|
91
|
+
final atomic write)
|
|
92
|
+
- honestly classifies probe failures as auth/api (reject + re-prompt)
|
|
93
|
+
or network (offer save-unverified). No false-precise subtypes.
|
|
94
|
+
- asks the fallback preference (route automatically when the
|
|
95
|
+
selected provider is unavailable)
|
|
96
|
+
- writes ~/.scoutline/config.json atomically with mode 0600
|
|
97
|
+
|
|
98
|
+
Non-interactive terminals: init refuses before any prompt and exits.
|
|
99
|
+
Run it inside a real TTY, or set up credentials via the documented
|
|
100
|
+
environment variables instead.
|
|
101
|
+
|
|
102
|
+
Options:
|
|
103
|
+
--help Show this help
|
|
104
|
+
|
|
105
|
+
Exit codes:
|
|
106
|
+
0 Onboarding completed, re-config applied, or already-onboarded
|
|
107
|
+
with the user choosing Cancel.
|
|
108
|
+
1 User cancelled (Ctrl+C / EOF), the wizard was invoked without a
|
|
109
|
+
terminal, a write failed, or the user declined corrupt-config
|
|
110
|
+
repair.
|
|
111
|
+
`.trim();
|
|
112
|
+
const PROVIDER_PROMPT_META = {
|
|
113
|
+
zai: {
|
|
114
|
+
label: "Z.AI",
|
|
115
|
+
envVar: "Z_AI_API_KEY",
|
|
116
|
+
registrationUrl: "https://z.ai/manage-apikey",
|
|
117
|
+
probeCostsCredit: false,
|
|
118
|
+
},
|
|
119
|
+
minimax: {
|
|
120
|
+
label: "MiniMax",
|
|
121
|
+
envVar: "MINIMAX_API_KEY",
|
|
122
|
+
registrationUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key",
|
|
123
|
+
probeCostsCredit: false,
|
|
124
|
+
},
|
|
125
|
+
tavily: {
|
|
126
|
+
label: "Tavily",
|
|
127
|
+
envVar: "TAVILY_API_KEY",
|
|
128
|
+
registrationUrl: "https://app.tavily.com/settings",
|
|
129
|
+
probeCostsCredit: true,
|
|
130
|
+
},
|
|
131
|
+
exa: {
|
|
132
|
+
label: "Exa",
|
|
133
|
+
envVar: "EXA_API_KEY",
|
|
134
|
+
registrationUrl: "https://dashboard.exa.ai/api-keys",
|
|
135
|
+
probeCostsCredit: true,
|
|
136
|
+
},
|
|
137
|
+
brave: {
|
|
138
|
+
label: "Brave Search",
|
|
139
|
+
envVar: "BRAVE_SEARCH_API_KEY",
|
|
140
|
+
registrationUrl: "https://api.search.brave.com/app/subscriptions",
|
|
141
|
+
probeCostsCredit: true,
|
|
142
|
+
},
|
|
143
|
+
firecrawl: {
|
|
144
|
+
label: "Firecrawl",
|
|
145
|
+
envVar: "FIRECRAWL_API_KEY",
|
|
146
|
+
registrationUrl: "https://www.firecrawl.dev/signin",
|
|
147
|
+
probeCostsCredit: true,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Lookup helper that fails closed when a registry provider lacks prompt
|
|
152
|
+
* metadata. Guards against a future Provider landing in the registry
|
|
153
|
+
* without a matching entry above.
|
|
154
|
+
*/
|
|
155
|
+
function providerMeta(id) {
|
|
156
|
+
const meta = PROVIDER_PROMPT_META[id];
|
|
157
|
+
if (!meta) {
|
|
158
|
+
throw new Error(`Provider "${id}" has no init-wizard metadata. ` +
|
|
159
|
+
`Add it to PROVIDER_PROMPT_META in src/commands/init.ts.`);
|
|
160
|
+
}
|
|
161
|
+
return meta;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Hyperlink rendering
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Terminal hyperlink escape (OSC 8). Modern terminals render this as a
|
|
168
|
+
* clickable link; non-hyperlink terminals show the literal text. The
|
|
169
|
+
* `printRegistrationLink` helper ALWAYS emits the literal URL too, so
|
|
170
|
+
* captured/non-hyperlink output remains usable.
|
|
171
|
+
*/
|
|
172
|
+
function hyperlink(text, url) {
|
|
173
|
+
return `\x1B]8;;${url}\x1B\\${text}\x1B]8;;\x1B\\`;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Render a registration link with BOTH a terminal hyperlink and the
|
|
177
|
+
* literal URL text on a separate line. The literal URL is unconditional:
|
|
178
|
+
* captured output (CI logs, pipe redirection) and non-hyperlink terminals
|
|
179
|
+
* still see a copy-pasteable URL.
|
|
180
|
+
*/
|
|
181
|
+
function renderRegistrationLine(id) {
|
|
182
|
+
const meta = providerMeta(id);
|
|
183
|
+
return `${meta.label}: ${hyperlink("Get an API key", meta.registrationUrl)}\n ${meta.registrationUrl}`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Wrap the real `inspectConfig` / `writeConfig` pair as an
|
|
187
|
+
* {@link InitConfigStore}. The default options object is captured per-call
|
|
188
|
+
* so the wizard can pass a temp `filePath` / atomic options through
|
|
189
|
+
* unchanged in tests.
|
|
190
|
+
*/
|
|
191
|
+
export function createDefaultConfigStore(options = {}) {
|
|
192
|
+
return {
|
|
193
|
+
async inspect() {
|
|
194
|
+
return inspectConfig(options);
|
|
195
|
+
},
|
|
196
|
+
async write(config, writeOptions) {
|
|
197
|
+
// The wizard prefers its own atomic options when supplied; otherwise
|
|
198
|
+
// fall back to the store's. This keeps test-injected temp `filePath`
|
|
199
|
+
// honored through both paths.
|
|
200
|
+
const merged = writeOptions
|
|
201
|
+
? { ...options, ...writeOptions, atomic: { ...options.atomic, ...writeOptions.atomic } }
|
|
202
|
+
: options;
|
|
203
|
+
await writeConfig(config, merged);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Probe classifier. `AuthError`/`ApiError` (and HTTP-status-4xx-typed
|
|
209
|
+
* variants) are key-problems → reject + re-prompt. `NetworkError` is
|
|
210
|
+
* transient/connectivity → offer save-unverified. Everything else is
|
|
211
|
+
* surfaced honestly as "unknown" rather than mis-typed.
|
|
212
|
+
*/
|
|
213
|
+
function classifyProbeError(error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
if (error instanceof AuthError || error instanceof ApiError) {
|
|
216
|
+
return { status: "auth-error", message };
|
|
217
|
+
}
|
|
218
|
+
if (error instanceof NetworkError) {
|
|
219
|
+
return { status: "network-error", message };
|
|
220
|
+
}
|
|
221
|
+
return { status: "unknown-error", message };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Build the ephemeral resolved-env for a single-provider probe: a fresh
|
|
225
|
+
* shallow copy of the injected env with the candidate key written into
|
|
226
|
+
* the provider's canonical env-var slot. `process.env` is never mutated.
|
|
227
|
+
*
|
|
228
|
+
* The candidate lives only in this ephemeral view until the final atomic
|
|
229
|
+
* write. If the same env-var is already set in `env` (env precedence),
|
|
230
|
+
* the candidate overrides it for the probe only — we are testing the
|
|
231
|
+
* CANDIDATE, not the ambient value.
|
|
232
|
+
*/
|
|
233
|
+
function buildEphemeralProbeEnv(env, canonicalVar, candidateKey) {
|
|
234
|
+
const ephemeral = { ...env };
|
|
235
|
+
ephemeral[canonicalVar] = candidateKey;
|
|
236
|
+
return ephemeral;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Run a single probe against the descriptor. Constructs the adapter from
|
|
240
|
+
* the EPHEMERAL env (never `deps.env` directly), calls
|
|
241
|
+
* `diagnostics.invoke({probe:true})` EXACTLY once, and classifies the
|
|
242
|
+
* outcome honestly. Doctor's retry wrapper is intentionally NOT used —
|
|
243
|
+
* the wizard honours the "one attempt" contract from the ticket.
|
|
244
|
+
*/
|
|
245
|
+
async function probeProviderOnce(descriptor, ephemeralEnv) {
|
|
246
|
+
const options = { probe: true };
|
|
247
|
+
try {
|
|
248
|
+
const adapter = descriptor.create({ env: ephemeralEnv });
|
|
249
|
+
const capability = adapter.diagnostics;
|
|
250
|
+
if (!capability) {
|
|
251
|
+
// The descriptor advertises diagnostics but the adapter did not
|
|
252
|
+
// supply the handle. Treat as unknown-error so the user can
|
|
253
|
+
// save-unverified rather than hard-fail the wizard.
|
|
254
|
+
return {
|
|
255
|
+
status: "unknown-error",
|
|
256
|
+
message: `Provider "${descriptor.id}" did not supply a diagnostics capability`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
await capability.invoke(options);
|
|
260
|
+
return { status: "verified" };
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return classifyProbeError(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* `true` when the existing config indicates the user has already
|
|
268
|
+
* completed onboarding. The wizard treats this state as
|
|
269
|
+
* "already-onboarded — re-config is a T3b follow-up" rather than entering
|
|
270
|
+
* the fresh flow.
|
|
271
|
+
*/
|
|
272
|
+
function isAlreadyOnboarded(config) {
|
|
273
|
+
for (const providerConfig of Object.values(config.providers)) {
|
|
274
|
+
const pc = providerConfig;
|
|
275
|
+
if (pc?.onboarded === true)
|
|
276
|
+
return true;
|
|
277
|
+
if (typeof pc?.apiKey === "string" && pc.apiKey.trim().length > 0)
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Return the providers from `descriptors` whose canonical env-var holds a
|
|
284
|
+
* non-blank key in `env`. Used to offer env-import at the start of the
|
|
285
|
+
* fresh flow.
|
|
286
|
+
*/
|
|
287
|
+
function detectEnvKeyProviders(descriptors, env) {
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const descriptor of descriptors) {
|
|
290
|
+
const canonical = providerMeta(descriptor.id).envVar;
|
|
291
|
+
const value = env[canonical];
|
|
292
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
293
|
+
out.push(descriptor.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Wizard orchestrator
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
/**
|
|
302
|
+
* Non-TTY refuse message. The wizard is interactive end-to-end; without
|
|
303
|
+
* a terminal it cannot receive keypresses, and we do not want a partial
|
|
304
|
+
* (config-only) flow that misleads users into thinking they configured
|
|
305
|
+
* something. The refuse message points users at the environment-variable
|
|
306
|
+
* path AND at `init` so they have a non-interactive alternative.
|
|
307
|
+
*/
|
|
308
|
+
function formatNonTTYRefuse(env, descriptors) {
|
|
309
|
+
const example = descriptors[0];
|
|
310
|
+
const exampleVar = example ? providerMeta(example.id).envVar : "Z_AI_API_KEY";
|
|
311
|
+
const detected = descriptors.filter((d) => {
|
|
312
|
+
const v = env[providerMeta(d.id).envVar];
|
|
313
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
314
|
+
});
|
|
315
|
+
const detectedLine = detected.length > 0
|
|
316
|
+
? `\nDetected env keys: ${detected.map((d) => `$${providerMeta(d.id).envVar}`).join(", ")}. The CLI will use them at runtime.`
|
|
317
|
+
: "";
|
|
318
|
+
return ("scoutline init requires an interactive terminal.\n" +
|
|
319
|
+
"Re-run inside a TTY, or set up credentials via environment variables:\n" +
|
|
320
|
+
` export ${exampleVar}="your-api-key"\n` +
|
|
321
|
+
"Then run any command directly (for example `scoutline doctor`)." +
|
|
322
|
+
detectedLine +
|
|
323
|
+
"\n");
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Run the interactive init wizard. Performs the T3b state dispatch:
|
|
327
|
+
* - non-TTY → refuse before any prompt + exit 1.
|
|
328
|
+
* - corrupt config → offer backup + rewrite (init is the recovery path).
|
|
329
|
+
* - valid + already-onboarded → re-config menu.
|
|
330
|
+
* - valid + empty / absent → fresh-onboarding flow.
|
|
331
|
+
*
|
|
332
|
+
* Returns the exit code:
|
|
333
|
+
* - 0 on success, re-config applied, or already-onboarded + Cancel.
|
|
334
|
+
* - 1 on user cancel, non-TTY refuse, write failure, or declined repair.
|
|
335
|
+
*/
|
|
336
|
+
export async function runFreshOnboarding(deps) {
|
|
337
|
+
// Formal non-TTY refuse (T3b). Without a TTY the wizard cannot run;
|
|
338
|
+
// we refuse before any prompt and surface the env-only alternative.
|
|
339
|
+
if (!deps.stdinIsTTY) {
|
|
340
|
+
deps.writeStderr(formatNonTTYRefuse(deps.env, deps.descriptors));
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
// Tolerant state detection. `inspect` returns absent / valid / corrupt;
|
|
344
|
+
// the wizard dispatches on the status rather than entering a single
|
|
345
|
+
// flow. This is the recovery path for a corrupt config.
|
|
346
|
+
const inspection = await deps.configStore.inspect();
|
|
347
|
+
if (inspection.status === "corrupt") {
|
|
348
|
+
return repairCorruptConfig(deps, inspection.filePath, inspection.error);
|
|
349
|
+
}
|
|
350
|
+
if (inspection.status === "valid" && isAlreadyOnboarded(inspection.config)) {
|
|
351
|
+
return runReconfigMenu(deps, inspection.config, inspection.filePath);
|
|
352
|
+
}
|
|
353
|
+
// Absent OR valid+empty → fresh-onboarding flow. The fresh flow writes
|
|
354
|
+
// a complete config (replacing any empty valid file) and resets the
|
|
355
|
+
// env-only hint marker so a later switch to env-only usage re-hints.
|
|
356
|
+
return runFreshFlow(deps);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The fresh-onboarding flow (T3a). Splash + env-key detection + provider
|
|
360
|
+
* checklist + per-provider probe + fallback preference + atomic write.
|
|
361
|
+
* Resets `hintShown` on the written config (the T3b release contract:
|
|
362
|
+
* a fresh write clears the marker so the trigger-detection hint can
|
|
363
|
+
* fire again if the user later switches to env-only usage).
|
|
364
|
+
*/
|
|
365
|
+
async function runFreshFlow(deps) {
|
|
366
|
+
// Splash (init-only). Sent to stderr so stdout stays data-only for the
|
|
367
|
+
// final summary.
|
|
368
|
+
deps.writeStderr([
|
|
369
|
+
"",
|
|
370
|
+
"Welcome to scoutline onboarding.",
|
|
371
|
+
"This wizard writes ~/.scoutline/config.json with mode 0600.",
|
|
372
|
+
"You can cancel at any time with Ctrl+C — nothing is written until the end.",
|
|
373
|
+
"",
|
|
374
|
+
].join("\n"));
|
|
375
|
+
// Detect ambient env keys for the import offer.
|
|
376
|
+
const envKeyProviders = detectEnvKeyProviders(deps.descriptors, deps.env);
|
|
377
|
+
if (envKeyProviders.length > 0) {
|
|
378
|
+
const labels = envKeyProviders.map((id) => `${providerMeta(id).label} ($${providerMeta(id).envVar})`);
|
|
379
|
+
deps.writeStderr(`Detected env key${envKeyProviders.length === 1 ? "" : "s"}: ${labels.join(", ")}.\n` +
|
|
380
|
+
"Each will be offered as an import candidate in the per-provider flow.\n");
|
|
381
|
+
}
|
|
382
|
+
// Step 1 — provider checklist. Choices come from the registry (equal
|
|
383
|
+
// weight; none pre-checked). Env-key providers surface that hint in
|
|
384
|
+
// their description column; credit-cost disclosure lands there too so
|
|
385
|
+
// the user can opt out before any paid probe.
|
|
386
|
+
const onboardings = await collectProviderOnboardings(deps, envKeyProviders);
|
|
387
|
+
if (onboardings === null) {
|
|
388
|
+
// User cancelled mid-flow.
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
// Step 2 — fallback preference. The wizard writes `fallbackEnabled`;
|
|
392
|
+
// T2a consumes it at runtime.
|
|
393
|
+
let fallbackEnabled = true;
|
|
394
|
+
try {
|
|
395
|
+
fallbackEnabled = await deps.prompts.confirm("Route automatically if the selected provider is unavailable? [Y/n]", true);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// Cancel on the fallback prompt is still a cancel.
|
|
399
|
+
return 1;
|
|
400
|
+
}
|
|
401
|
+
// Step 3 — atomic write (T1 primitive). Build the final config and
|
|
402
|
+
// commit. No partial writes ever reach disk: `writeConfig` either
|
|
403
|
+
// replaces the live file atomically or leaves it untouched. The
|
|
404
|
+
// `hintShown` field is deliberately OMITTED from buildConfig so the
|
|
405
|
+
// written file does not carry the marker — a fresh write clears it.
|
|
406
|
+
const config = buildConfig(onboardings, fallbackEnabled);
|
|
407
|
+
try {
|
|
408
|
+
await deps.configStore.write(config);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
412
|
+
deps.writeStderr(`Failed to write config: ${message}\n`);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
// Step 4 — redacted summary on stdout (data-only contract). The
|
|
416
|
+
// summary line is the ONLY write to stdout in the wizard; it lists
|
|
417
|
+
// provider ids and their verification status, never the keys.
|
|
418
|
+
deps.writeStdout(`${formatSummary(onboardings, fallbackEnabled)}\n`);
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Corrupt-config repair (T3b). init is the documented recovery path for
|
|
423
|
+
// a corrupt config.json; the wizard offers a backup + rewrite rather
|
|
424
|
+
// than refusing or silently clobbering the live file.
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
/**
|
|
427
|
+
* Offer corrupt-config repair. Surfaces the underlying error, asks for
|
|
428
|
+
* confirmation, and on Yes:
|
|
429
|
+
* 1. Renames the live file to `<filePath>.corrupt-<timestamp>.bak`
|
|
430
|
+
* (best-effort; a rename failure does NOT block the rewrite —
|
|
431
|
+
* the user already confirmed).
|
|
432
|
+
* 2. Writes a fresh config via the normal atomic primitive (the
|
|
433
|
+
* backup line is logged to stderr; the live file is not unlinked
|
|
434
|
+
* without a backup unless rename fails, in which case we ask
|
|
435
|
+
* again).
|
|
436
|
+
* 3. Falls through to the fresh-onboarding flow so the user can
|
|
437
|
+
* rebuild their config interactively.
|
|
438
|
+
*
|
|
439
|
+
* On No (decline), the wizard exits 1 without modifying anything. This
|
|
440
|
+
* keeps init safe: a user who lands here by accident can back out.
|
|
441
|
+
*/
|
|
442
|
+
async function repairCorruptConfig(deps, filePath, error) {
|
|
443
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
444
|
+
deps.writeStderr([
|
|
445
|
+
"",
|
|
446
|
+
`scoutline: config.json at ${filePath} is corrupt or unreadable.`,
|
|
447
|
+
`Reason: ${errorMessage}`,
|
|
448
|
+
"",
|
|
449
|
+
"init can back up the live file and write a fresh config.",
|
|
450
|
+
"The backup is named <config.json>.corrupt-<timestamp>.bak and is",
|
|
451
|
+
"never deleted by scoutline.",
|
|
452
|
+
"",
|
|
453
|
+
].join("\n"));
|
|
454
|
+
let proceed;
|
|
455
|
+
try {
|
|
456
|
+
proceed = await deps.prompts.confirm("Back up the corrupt config and run the fresh-onboarding flow? [y/N]", false);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// Cancel on the repair confirm is the same as declining.
|
|
460
|
+
return 1;
|
|
461
|
+
}
|
|
462
|
+
if (!proceed) {
|
|
463
|
+
deps.writeStderr("Declined repair. Run `scoutline init` again to retry, or fix/remove the file manually.\n");
|
|
464
|
+
return 1;
|
|
465
|
+
}
|
|
466
|
+
// Best-effort backup. The ConfigStore interface owns the file path
|
|
467
|
+
// (real or temp-dir-injected); we delegate the rename to a typed
|
|
468
|
+
// helper if the store exposes one, otherwise we use the same write
|
|
469
|
+
// primitive to land the fresh file (the live corrupt bytes are
|
|
470
|
+
// replaced atomically). The store seam is intentionally narrow so
|
|
471
|
+
// tests do not need a real fs.rename injection.
|
|
472
|
+
const backupPath = `${filePath}.corrupt-${deps.now()}.bak`;
|
|
473
|
+
const backedUp = await backupCorruptFile(deps, filePath, backupPath);
|
|
474
|
+
if (backedUp) {
|
|
475
|
+
deps.writeStderr(`Backed up corrupt config to ${backupPath}.\n`);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
// Rename failed — we did not create a backup. Re-ask the user so
|
|
479
|
+
// the live file is never clobbered without explicit consent.
|
|
480
|
+
deps.writeStderr("Could not create a backup (rename failed or unsupported by the store).");
|
|
481
|
+
try {
|
|
482
|
+
const clobber = await deps.prompts.confirm("Rewrite the live config WITHOUT a backup? [y/N]", false);
|
|
483
|
+
if (!clobber) {
|
|
484
|
+
deps.writeStderr("Declined. Repair aborted; the live file is untouched.\n");
|
|
485
|
+
return 1;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return 1;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Fall through to the fresh flow. The first write replaces the live
|
|
493
|
+
// (corrupt) bytes atomically; nothing partial reaches disk.
|
|
494
|
+
deps.writeStderr("Running the fresh-onboarding flow. The corrupt file has been set aside.\n");
|
|
495
|
+
return runFreshFlow(deps);
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Try to rename the corrupt file to the backup path. Returns `true` on
|
|
499
|
+
* success. The implementation prefers a store-supplied rename hook
|
|
500
|
+
* (tests can inject a fake); in production the real store delegates to
|
|
501
|
+
* `fs.rename`. A thrown rename is swallowed and reported as `false` so
|
|
502
|
+
* the caller can re-ask the user.
|
|
503
|
+
*/
|
|
504
|
+
async function backupCorruptFile(deps, filePath, backupPath) {
|
|
505
|
+
const store = deps.configStore;
|
|
506
|
+
if (typeof store.backupCorrupt === "function") {
|
|
507
|
+
try {
|
|
508
|
+
return await store.backupCorrupt(filePath, backupPath);
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Offer the re-config menu when an already-valid config exists. Each
|
|
518
|
+
* menu action re-uses the T3a ask-key → input → validate loop where
|
|
519
|
+
* applicable; editing a Provider key resets that Provider's
|
|
520
|
+
* `verification.status` to `unverified` (Doctor re-promotes after a
|
|
521
|
+
* successful probe).
|
|
522
|
+
*
|
|
523
|
+
* The menu loops until the user picks `cancel` or `rerun-full` (which
|
|
524
|
+
* exits the loop and either returns 0 or delegates to the fresh flow).
|
|
525
|
+
*/
|
|
526
|
+
async function runReconfigMenu(deps, config, filePath) {
|
|
527
|
+
const configuredIds = Object.keys(config.providers).filter((id) => {
|
|
528
|
+
const provider = config.providers[id];
|
|
529
|
+
return provider && typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
530
|
+
});
|
|
531
|
+
const fallbackLine = config.fallbackEnabled === undefined
|
|
532
|
+
? "fallback: default (true)"
|
|
533
|
+
: `fallback: ${config.fallbackEnabled ? "enabled" : "disabled"}`;
|
|
534
|
+
deps.writeStderr([
|
|
535
|
+
"",
|
|
536
|
+
`scoutline is already set up at ${filePath}.`,
|
|
537
|
+
`Providers configured: ${configuredIds.length === 0 ? "none" : configuredIds.join(", ")}.`,
|
|
538
|
+
fallbackLine,
|
|
539
|
+
"",
|
|
540
|
+
].join("\n"));
|
|
541
|
+
for (;;) {
|
|
542
|
+
const action = await promptReconfigAction(deps, configuredIds);
|
|
543
|
+
if (action === null) {
|
|
544
|
+
// Cancel on the menu itself.
|
|
545
|
+
return 1;
|
|
546
|
+
}
|
|
547
|
+
if (action === "cancel") {
|
|
548
|
+
deps.writeStderr("No changes made.\n");
|
|
549
|
+
return 0;
|
|
550
|
+
}
|
|
551
|
+
if (action === "rerun-full") {
|
|
552
|
+
deps.writeStderr("Re-running the full onboarding flow. The live config will be replaced atomically.\n");
|
|
553
|
+
return runFreshFlow(deps);
|
|
554
|
+
}
|
|
555
|
+
// Mutating actions: each returns the next config (or null on cancel).
|
|
556
|
+
const next = await applyReconfigAction(deps, action, config, configuredIds);
|
|
557
|
+
if (next === "write-error") {
|
|
558
|
+
return 1;
|
|
559
|
+
}
|
|
560
|
+
if (next === "loop") {
|
|
561
|
+
// The action was a no-op (e.g. user backed out of a sub-prompt);
|
|
562
|
+
// re-render the menu.
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (next === "cancel") {
|
|
566
|
+
deps.writeStderr("No changes made.\n");
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
// next === "written": the action mutated and persisted the config.
|
|
570
|
+
// Re-render the menu so the user can take another action.
|
|
571
|
+
configuredIds.length = 0;
|
|
572
|
+
const fresh = await deps.configStore.inspect();
|
|
573
|
+
if (fresh.status === "valid") {
|
|
574
|
+
for (const id of Object.keys(fresh.config.providers)) {
|
|
575
|
+
const provider = fresh.config.providers[id];
|
|
576
|
+
if (provider && typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0) {
|
|
577
|
+
configuredIds.push(id);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Mirror mutations into the local `config` reference so the next
|
|
581
|
+
// iteration sees the latest state.
|
|
582
|
+
Object.assign(config, fresh.config);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Render the re-config menu and return the chosen action. Returns
|
|
588
|
+
* `null` on cancel (Ctrl+C / EOF).
|
|
589
|
+
*/
|
|
590
|
+
async function promptReconfigAction(deps, configuredIds) {
|
|
591
|
+
const choices = [];
|
|
592
|
+
if (configuredIds.length > 0) {
|
|
593
|
+
choices.push({
|
|
594
|
+
value: "edit-key",
|
|
595
|
+
name: "Edit a provider key",
|
|
596
|
+
description: "Replace an existing API key (resets verification to unverified)",
|
|
597
|
+
});
|
|
598
|
+
choices.push({
|
|
599
|
+
value: "remove-provider",
|
|
600
|
+
name: "Remove a provider",
|
|
601
|
+
description: "Drop a provider entry from the config",
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
choices.push({
|
|
605
|
+
value: "add-provider",
|
|
606
|
+
name: "Add a provider",
|
|
607
|
+
description: "Run the per-provider flow for a provider not yet configured",
|
|
608
|
+
});
|
|
609
|
+
choices.push({
|
|
610
|
+
value: "change-fallback",
|
|
611
|
+
name: "Change fallback preference",
|
|
612
|
+
description: "Toggle the Provider-fallback flag (currently consulted at runtime)",
|
|
613
|
+
});
|
|
614
|
+
choices.push({
|
|
615
|
+
value: "rerun-full",
|
|
616
|
+
name: "Re-run full onboarding",
|
|
617
|
+
description: "Discard the current config and start the wizard from scratch",
|
|
618
|
+
});
|
|
619
|
+
choices.push({ value: "cancel", name: "Cancel", description: "Exit without changes" });
|
|
620
|
+
try {
|
|
621
|
+
return await deps.prompts.select("What would you like to do?", choices);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Apply a single mutating re-config action. Persists the result via the
|
|
629
|
+
* store and returns a status the caller uses to decide whether to
|
|
630
|
+
* re-render the menu, exit, or loop.
|
|
631
|
+
*
|
|
632
|
+
* - "written": the config was mutated + persisted successfully.
|
|
633
|
+
* - "loop": the user backed out of a sub-prompt (no mutation); re-render.
|
|
634
|
+
* - "cancel": the user explicitly cancelled; exit 0.
|
|
635
|
+
* - "write-error": the atomic write failed; exit 1.
|
|
636
|
+
*/
|
|
637
|
+
async function applyReconfigAction(deps, action, config, configuredIds) {
|
|
638
|
+
if (action === "change-fallback") {
|
|
639
|
+
return changeFallback(deps, config);
|
|
640
|
+
}
|
|
641
|
+
if (action === "add-provider") {
|
|
642
|
+
return addProvider(deps, config, configuredIds);
|
|
643
|
+
}
|
|
644
|
+
if (action === "remove-provider") {
|
|
645
|
+
return removeProvider(deps, config, configuredIds);
|
|
646
|
+
}
|
|
647
|
+
// edit-key
|
|
648
|
+
return editProviderKey(deps, config, configuredIds);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Toggle the fallback flag. Reads the current value, asks the new
|
|
652
|
+
* preference, and persists.
|
|
653
|
+
*/
|
|
654
|
+
async function changeFallback(deps, config) {
|
|
655
|
+
const current = config.fallbackEnabled ?? true;
|
|
656
|
+
try {
|
|
657
|
+
const next = await deps.prompts.confirm(`Route automatically if the selected provider is unavailable? [${current ? "Y/n" : "y/N"}]`, current);
|
|
658
|
+
const updated = {
|
|
659
|
+
...config,
|
|
660
|
+
providers: { ...config.providers },
|
|
661
|
+
fallbackEnabled: next,
|
|
662
|
+
// hintShown is intentionally preserved (re-config does not reset it;
|
|
663
|
+
// only a fresh-write or re-init does).
|
|
664
|
+
...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
|
|
665
|
+
};
|
|
666
|
+
return persistConfig(deps, updated);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return "loop";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Add a provider not yet configured. Reuses the T3a per-provider flow
|
|
674
|
+
* (ask-key → input → validate). The new record joins the existing
|
|
675
|
+
* `providers` map without disturbing the others.
|
|
676
|
+
*/
|
|
677
|
+
async function addProvider(deps, config, configuredIds) {
|
|
678
|
+
const available = deps.descriptors.map((d) => d.id).filter((id) => !configuredIds.includes(id));
|
|
679
|
+
if (available.length === 0) {
|
|
680
|
+
deps.writeStderr("Every built-in provider is already configured.\n");
|
|
681
|
+
return "loop";
|
|
682
|
+
}
|
|
683
|
+
const choices = available.map((id) => ({
|
|
684
|
+
value: id,
|
|
685
|
+
name: providerMeta(id).label,
|
|
686
|
+
description: providerMeta(id).probeCostsCredit
|
|
687
|
+
? "validation probe costs ~1 credit"
|
|
688
|
+
: "validation probe is free",
|
|
689
|
+
}));
|
|
690
|
+
let providerId;
|
|
691
|
+
try {
|
|
692
|
+
providerId = await deps.prompts.select("Add which provider?", choices);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return "loop";
|
|
696
|
+
}
|
|
697
|
+
const descriptor = deps.descriptors.find((d) => d.id === providerId);
|
|
698
|
+
if (!descriptor) {
|
|
699
|
+
deps.writeStderr(`Provider "${providerId}" is not in the registry.\n`);
|
|
700
|
+
return "loop";
|
|
701
|
+
}
|
|
702
|
+
// Reuse the T3a per-provider flow against an empty envKeyProviders so
|
|
703
|
+
// the import offer is skipped (the user is ADDING; we do not auto-pull
|
|
704
|
+
// from env here). The probe runs against the ephemeral candidate.
|
|
705
|
+
const onboarding = await onboardSingleProvider(deps, providerId, []);
|
|
706
|
+
if (onboarding === null) {
|
|
707
|
+
return "loop";
|
|
708
|
+
}
|
|
709
|
+
if (onboarding === "skip") {
|
|
710
|
+
return "loop";
|
|
711
|
+
}
|
|
712
|
+
const updated = {
|
|
713
|
+
...config,
|
|
714
|
+
providers: {
|
|
715
|
+
...config.providers,
|
|
716
|
+
[providerId]: {
|
|
717
|
+
apiKey: onboarding.apiKey,
|
|
718
|
+
onboarded: true,
|
|
719
|
+
verification: onboarding.verification,
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
|
|
723
|
+
};
|
|
724
|
+
const status = persistConfig(deps, updated);
|
|
725
|
+
if ((await status) === "written") {
|
|
726
|
+
deps.writeStdout(`${providerMeta(providerId).label}: added (verification: ${onboarding.verification.status}).\n`);
|
|
727
|
+
}
|
|
728
|
+
return status;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Remove a configured provider. The user picks from the currently
|
|
732
|
+
* configured set; the chosen entry is dropped from `providers`.
|
|
733
|
+
*/
|
|
734
|
+
async function removeProvider(deps, config, configuredIds) {
|
|
735
|
+
if (configuredIds.length === 0) {
|
|
736
|
+
deps.writeStderr("No providers are configured.\n");
|
|
737
|
+
return "loop";
|
|
738
|
+
}
|
|
739
|
+
const choices = configuredIds.map((id) => ({
|
|
740
|
+
value: id,
|
|
741
|
+
name: providerMeta(id).label,
|
|
742
|
+
}));
|
|
743
|
+
choices.push({ value: undefined, name: "Back" });
|
|
744
|
+
let providerId;
|
|
745
|
+
try {
|
|
746
|
+
const picked = await deps.prompts.select("Remove which provider?", choices);
|
|
747
|
+
providerId = picked;
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return "loop";
|
|
751
|
+
}
|
|
752
|
+
if (providerId === undefined) {
|
|
753
|
+
return "loop";
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const confirmed = await deps.prompts.confirm(`Remove ${providerMeta(providerId).label} from the config? [y/N]`, false);
|
|
757
|
+
if (!confirmed) {
|
|
758
|
+
return "loop";
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return "loop";
|
|
763
|
+
}
|
|
764
|
+
const nextProviders = {
|
|
765
|
+
...config.providers,
|
|
766
|
+
};
|
|
767
|
+
delete nextProviders[providerId];
|
|
768
|
+
const updated = {
|
|
769
|
+
...config,
|
|
770
|
+
providers: nextProviders,
|
|
771
|
+
...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
|
|
772
|
+
};
|
|
773
|
+
const status = persistConfig(deps, updated);
|
|
774
|
+
if ((await status) === "written") {
|
|
775
|
+
deps.writeStdout(`${providerMeta(providerId).label}: removed.\n`);
|
|
776
|
+
}
|
|
777
|
+
return status;
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Edit (replace) a configured provider's key. Reuses the T3a per-provider
|
|
781
|
+
* flow's validate step against the ephemeral candidate. Editing a key
|
|
782
|
+
* RESETS that Provider's `verification.status` to `unverified` —
|
|
783
|
+
* Doctor re-promotes after a successful probe (the probe the wizard
|
|
784
|
+
* runs here does NOT promote because the wizard's probe is a
|
|
785
|
+
* connectivity check, not a Doctor invocation).
|
|
786
|
+
*/
|
|
787
|
+
async function editProviderKey(deps, config, configuredIds) {
|
|
788
|
+
if (configuredIds.length === 0) {
|
|
789
|
+
deps.writeStderr("No providers are configured.\n");
|
|
790
|
+
return "loop";
|
|
791
|
+
}
|
|
792
|
+
const choices = configuredIds.map((id) => ({
|
|
793
|
+
value: id,
|
|
794
|
+
name: providerMeta(id).label,
|
|
795
|
+
}));
|
|
796
|
+
choices.push({ value: undefined, name: "Back" });
|
|
797
|
+
let providerId;
|
|
798
|
+
try {
|
|
799
|
+
providerId = await deps.prompts.select("Edit which provider's key?", choices);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return "loop";
|
|
803
|
+
}
|
|
804
|
+
if (providerId === undefined) {
|
|
805
|
+
return "loop";
|
|
806
|
+
}
|
|
807
|
+
const descriptor = deps.descriptors.find((d) => d.id === providerId);
|
|
808
|
+
if (!descriptor) {
|
|
809
|
+
deps.writeStderr(`Provider "${providerId}" is not in the registry.\n`);
|
|
810
|
+
return "loop";
|
|
811
|
+
}
|
|
812
|
+
const meta = providerMeta(providerId);
|
|
813
|
+
if (meta.probeCostsCredit) {
|
|
814
|
+
deps.writeStderr(`${meta.label}: validating the new key costs ~1 credit against your account.\n`);
|
|
815
|
+
}
|
|
816
|
+
let candidate;
|
|
817
|
+
try {
|
|
818
|
+
candidate = (await deps.prompts.password(`Paste the new ${meta.label} API key (input hidden):`)).trim();
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return "loop";
|
|
822
|
+
}
|
|
823
|
+
// Reuse the T3a validate loop. The candidate is probed; on verified
|
|
824
|
+
// we persist a verified record; on save-unverified we persist an
|
|
825
|
+
// unverified record; on skip/cancel we re-render the menu.
|
|
826
|
+
const result = await validateAndCollect(deps, descriptor, candidate);
|
|
827
|
+
if (result === null) {
|
|
828
|
+
return "loop";
|
|
829
|
+
}
|
|
830
|
+
if (result === "skip") {
|
|
831
|
+
return "loop";
|
|
832
|
+
}
|
|
833
|
+
const updated = {
|
|
834
|
+
...config,
|
|
835
|
+
providers: {
|
|
836
|
+
...config.providers,
|
|
837
|
+
[providerId]: {
|
|
838
|
+
apiKey: result.apiKey,
|
|
839
|
+
onboarded: true,
|
|
840
|
+
verification: result.verification,
|
|
841
|
+
},
|
|
842
|
+
},
|
|
843
|
+
...(config.hintShown !== undefined ? { hintShown: config.hintShown } : {}),
|
|
844
|
+
};
|
|
845
|
+
const status = persistConfig(deps, updated);
|
|
846
|
+
if ((await status) === "written") {
|
|
847
|
+
deps.writeStdout(`${meta.label}: key updated (verification: ${result.verification.status}). ` +
|
|
848
|
+
`Run \`scoutline doctor\` to re-verify.\n`);
|
|
849
|
+
}
|
|
850
|
+
return status;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Persist a mutated config through the store. Returns `"written"` on
|
|
854
|
+
* success or `"write-error"` on failure (the caller surfaces the exit
|
|
855
|
+
* code). The atomic primitive guarantees no partial write reaches disk.
|
|
856
|
+
*/
|
|
857
|
+
async function persistConfig(deps, config) {
|
|
858
|
+
try {
|
|
859
|
+
await deps.configStore.write(config);
|
|
860
|
+
return "written";
|
|
861
|
+
}
|
|
862
|
+
catch (error) {
|
|
863
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
864
|
+
deps.writeStderr(`Failed to write config: ${message}\n`);
|
|
865
|
+
return "write-error";
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Step 1 of the wizard: collect per-provider onboarding state via the
|
|
870
|
+
* checklist + ask-key-first + hidden input + single probe. Returns
|
|
871
|
+
* `null` when the user cancelled mid-flow (so the caller treats it as a
|
|
872
|
+
* no-write exit).
|
|
873
|
+
*/
|
|
874
|
+
async function collectProviderOnboardings(deps, envKeyProviders) {
|
|
875
|
+
// Loop until the user either selects at least one provider OR confirms
|
|
876
|
+
// the zero-provider continue. The "back to checklist" path on a
|
|
877
|
+
// zero-select-No branch is the documented fresh-flow edge case.
|
|
878
|
+
for (;;) {
|
|
879
|
+
const choices = deps.descriptors.map((descriptor) => {
|
|
880
|
+
const meta = providerMeta(descriptor.id);
|
|
881
|
+
const hints = [];
|
|
882
|
+
if (envKeyProviders.includes(descriptor.id)) {
|
|
883
|
+
hints.push(`env $${meta.envVar} present (importable)`);
|
|
884
|
+
}
|
|
885
|
+
if (meta.probeCostsCredit) {
|
|
886
|
+
hints.push("validation probe costs ~1 credit");
|
|
887
|
+
}
|
|
888
|
+
else {
|
|
889
|
+
hints.push("validation probe is free");
|
|
890
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
value: descriptor.id,
|
|
893
|
+
name: meta.label,
|
|
894
|
+
description: hints.join("; "),
|
|
895
|
+
checked: false,
|
|
896
|
+
};
|
|
897
|
+
});
|
|
898
|
+
let selected;
|
|
899
|
+
try {
|
|
900
|
+
selected = await deps.prompts.checkbox("Select providers to configure (space to toggle, enter to confirm)", choices);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
if (selected.length === 0) {
|
|
906
|
+
// Zero-provider confirmation. Default No returns to the checklist
|
|
907
|
+
// so the user does not accidentally exit on a stray enter.
|
|
908
|
+
let continueWithNone = false;
|
|
909
|
+
try {
|
|
910
|
+
continueWithNone = await deps.prompts.confirm("Continue with no providers? [y/N]", false);
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
if (!continueWithNone) {
|
|
916
|
+
// Loop back to the checklist.
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
// Empty-onboarding + fallback-default writes a minimal config and
|
|
920
|
+
// leaves a pointer that the user can re-run `init` later.
|
|
921
|
+
return [];
|
|
922
|
+
}
|
|
923
|
+
// Step 1b — per-provider ask-key-first → hidden input → single probe.
|
|
924
|
+
const onboardings = [];
|
|
925
|
+
for (const providerId of selected) {
|
|
926
|
+
const onboarding = await onboardSingleProvider(deps, providerId, envKeyProviders);
|
|
927
|
+
if (onboarding === null) {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
if (onboarding === "skip") {
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
onboardings.push(onboarding);
|
|
934
|
+
}
|
|
935
|
+
return onboardings;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Per-provider flow: ask-key-first → hidden input → single probe. The
|
|
940
|
+
* candidate credential lives only in the ephemeral probe env. Returns
|
|
941
|
+
* `null` on cancel, `"skip"` if the user declined to provide a key (no
|
|
942
|
+
* registration link visit, no probe), or the resulting onboarding state.
|
|
943
|
+
*/
|
|
944
|
+
async function onboardSingleProvider(deps, providerId, envKeyProviders) {
|
|
945
|
+
const meta = providerMeta(providerId);
|
|
946
|
+
const descriptor = deps.descriptors.find((d) => d.id === providerId);
|
|
947
|
+
if (!descriptor) {
|
|
948
|
+
// The checklist is registry-derived, so this is unreachable unless
|
|
949
|
+
// the caller passed a divergent `descriptors` list.
|
|
950
|
+
deps.writeStderr(`Provider "${providerId}" is not in the registry; skipping.\n`);
|
|
951
|
+
return "skip";
|
|
952
|
+
}
|
|
953
|
+
// Env-import offer takes precedence over ask-key-first. The wizard
|
|
954
|
+
// does NOT auto-import; it asks once per env-key provider.
|
|
955
|
+
if (envKeyProviders.includes(providerId)) {
|
|
956
|
+
try {
|
|
957
|
+
const importFromEnv = await deps.prompts.confirm(`Import ${meta.label} key from $${meta.envVar}? [Y/n]`, true);
|
|
958
|
+
if (importFromEnv) {
|
|
959
|
+
const candidate = deps.env[meta.envVar];
|
|
960
|
+
if (typeof candidate === "string" && candidate.trim().length > 0) {
|
|
961
|
+
// Stale-env-after-import warning (T3b edge case). The runtime
|
|
962
|
+
// precedence rule is env > file: an env value still set at
|
|
963
|
+
// runtime will keep winning over this imported file key, so
|
|
964
|
+
// the saved key is effectively dormant until the env value is
|
|
965
|
+
// removed. The wizard notes this so the user is not surprised
|
|
966
|
+
// later. The env value still wins at runtime by design; the
|
|
967
|
+
// wizard cannot unset it for the user.
|
|
968
|
+
deps.writeStderr(`Note: env precedence means $${meta.envVar} will keep overriding the saved key at runtime. ` +
|
|
969
|
+
`Unset it (or remove it from your shell profile) to make the saved key authoritative.\n`);
|
|
970
|
+
return validateAndCollect(deps, descriptor, candidate);
|
|
971
|
+
}
|
|
972
|
+
// The env value was blank/removed between detection and read;
|
|
973
|
+
// fall through to ask-key-first so the user can still supply one.
|
|
974
|
+
deps.writeStderr(`Env value for $${meta.envVar} is blank or missing; falling back to manual input.\n`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// Ask-key-first. Default Yes — most users who reach this prompt have
|
|
982
|
+
// a key. Declining (No) shows the registration link and skips the
|
|
983
|
+
// provider; it does not advance on a key-problem, it just defers.
|
|
984
|
+
let hasKey;
|
|
985
|
+
try {
|
|
986
|
+
hasKey = await deps.prompts.confirm(`Do you have a ${meta.label} API key? [Y/n]`, true);
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
if (!hasKey) {
|
|
992
|
+
deps.writeStderr(`${renderRegistrationLine(providerId)}\n`);
|
|
993
|
+
return "skip";
|
|
994
|
+
}
|
|
995
|
+
// Credit-cost disclosure BEFORE any probe. Z.AI / MiniMax probes are
|
|
996
|
+
// free (tool discovery / raw quota); the other four charge ~1 credit.
|
|
997
|
+
if (meta.probeCostsCredit) {
|
|
998
|
+
deps.writeStderr(`${meta.label}: validating the key costs ~1 credit against your account.\n`);
|
|
999
|
+
}
|
|
1000
|
+
// Hidden password input. The value flows only into the ephemeral env.
|
|
1001
|
+
let candidate;
|
|
1002
|
+
try {
|
|
1003
|
+
candidate = await deps.prompts.password(`Paste your ${meta.label} API key (input hidden):`);
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
return validateAndCollect(deps, descriptor, candidate);
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Build the ephemeral probe env for `descriptor` + `candidate`, run a
|
|
1012
|
+
* single probe, and classify. On verified → return the onboarding state.
|
|
1013
|
+
* On auth-error → re-prompt once for a fresh candidate. On
|
|
1014
|
+
* network-error → offer save-unverified. On unknown-error → surface
|
|
1015
|
+
* honestly and offer save-unverified (the wizard never claims an unknown
|
|
1016
|
+
* failure was verified). Returns `null` on cancel, `"skip"` if the user
|
|
1017
|
+
* declines all recovery options.
|
|
1018
|
+
*/
|
|
1019
|
+
async function validateAndCollect(deps, descriptor, initialCandidate) {
|
|
1020
|
+
const meta = providerMeta(descriptor.id);
|
|
1021
|
+
let candidate = initialCandidate.trim();
|
|
1022
|
+
// Re-prompt loop. We allow one re-entry per auth/unknown error so the
|
|
1023
|
+
// user can fix a typo without restarting the wizard. The probe itself
|
|
1024
|
+
// is exactly one attempt per loop iteration (the ticket's "one
|
|
1025
|
+
// attempt" contract).
|
|
1026
|
+
for (;;) {
|
|
1027
|
+
if (candidate.length === 0) {
|
|
1028
|
+
deps.writeStderr(`${meta.label}: blank key — skipping this provider.\n`);
|
|
1029
|
+
return "skip";
|
|
1030
|
+
}
|
|
1031
|
+
const ephemeralEnv = buildEphemeralProbeEnv(deps.env, meta.envVar, candidate);
|
|
1032
|
+
const outcome = await probeProviderOnce(descriptor, ephemeralEnv);
|
|
1033
|
+
if (outcome.status === "verified") {
|
|
1034
|
+
return {
|
|
1035
|
+
providerId: descriptor.id,
|
|
1036
|
+
apiKey: candidate,
|
|
1037
|
+
verification: { status: "verified", checkedAt: deps.now() },
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (outcome.status === "network-error") {
|
|
1041
|
+
// Offer save-unverified. The candidate is preserved; the
|
|
1042
|
+
// verification record marks the deferred state with the reason.
|
|
1043
|
+
try {
|
|
1044
|
+
const save = await deps.prompts.confirm(`${meta.label}: connectivity check failed (${outcome.message}). ` +
|
|
1045
|
+
"Save the key as UNVERIFIED? [Y/n]", true);
|
|
1046
|
+
if (save) {
|
|
1047
|
+
return {
|
|
1048
|
+
providerId: descriptor.id,
|
|
1049
|
+
apiKey: candidate,
|
|
1050
|
+
verification: {
|
|
1051
|
+
status: "unverified",
|
|
1052
|
+
checkedAt: deps.now(),
|
|
1053
|
+
reason: "network-deferred",
|
|
1054
|
+
},
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
return "skip";
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
if (outcome.status === "auth-error") {
|
|
1064
|
+
// Key-problem → reject + re-prompt. Never advance on an
|
|
1065
|
+
// auth/api error.
|
|
1066
|
+
deps.writeStderr(`${meta.label}: key rejected (${outcome.message}).\n`);
|
|
1067
|
+
try {
|
|
1068
|
+
candidate = (await deps.prompts.password(`Re-enter your ${meta.label} API key (or press Ctrl+C to skip):`)).trim();
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
// Unknown-error — surface honestly. Offer save-unverified as a
|
|
1076
|
+
// graceful recovery rather than masking as verified.
|
|
1077
|
+
deps.writeStderr(`${meta.label}: probe failed — ${outcome.message}\n`);
|
|
1078
|
+
try {
|
|
1079
|
+
const save = await deps.prompts.confirm(`${meta.label}: save the key as UNVERIFIED anyway? [y/N]`, false);
|
|
1080
|
+
if (save) {
|
|
1081
|
+
return {
|
|
1082
|
+
providerId: descriptor.id,
|
|
1083
|
+
apiKey: candidate,
|
|
1084
|
+
verification: {
|
|
1085
|
+
status: "unverified",
|
|
1086
|
+
checkedAt: deps.now(),
|
|
1087
|
+
reason: "unknown-probe-failure",
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
return "skip";
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// Config assembly + summary
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
/**
|
|
1102
|
+
* Build the final {@link ScoutlineConfig} from the wizard's collected
|
|
1103
|
+
* state. Each onboarding entry becomes a `providers[id]` row carrying
|
|
1104
|
+
* the api key, the onboarding-completed flag, and the verification
|
|
1105
|
+
* record. `fallbackEnabled` is the Step-2 answer.
|
|
1106
|
+
*/
|
|
1107
|
+
function buildConfig(onboardings, fallbackEnabled) {
|
|
1108
|
+
const providers = {};
|
|
1109
|
+
for (const onboarding of onboardings) {
|
|
1110
|
+
providers[onboarding.providerId] = {
|
|
1111
|
+
apiKey: onboarding.apiKey,
|
|
1112
|
+
onboarded: true,
|
|
1113
|
+
verification: onboarding.verification,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
version: 1,
|
|
1118
|
+
fallbackEnabled,
|
|
1119
|
+
providers,
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Format the redacted stdout summary line. Provider ids and verification
|
|
1124
|
+
* status are surfaced; keys never are. One line per provider + a
|
|
1125
|
+
* fallback footer.
|
|
1126
|
+
*/
|
|
1127
|
+
function formatSummary(onboardings, fallbackEnabled) {
|
|
1128
|
+
if (onboardings.length === 0) {
|
|
1129
|
+
return ("scoutline onboarding complete with no providers configured. " +
|
|
1130
|
+
"Re-run `scoutline init` to add one.");
|
|
1131
|
+
}
|
|
1132
|
+
const lines = onboardings.map((onboarding) => {
|
|
1133
|
+
const meta = providerMeta(onboarding.providerId);
|
|
1134
|
+
const status = onboarding.verification.status;
|
|
1135
|
+
return `${meta.label} (${onboarding.providerId}): ${status}`;
|
|
1136
|
+
});
|
|
1137
|
+
lines.push(`fallbackEnabled=${fallbackEnabled ? "true" : "false"}`);
|
|
1138
|
+
lines.push("Wrote ~/.scoutline/config.json (mode 0600).");
|
|
1139
|
+
return lines.join("\n");
|
|
1140
|
+
}
|
|
1141
|
+
let inquirerPromise;
|
|
1142
|
+
async function loadInquirer() {
|
|
1143
|
+
if (!inquirerPromise) {
|
|
1144
|
+
inquirerPromise = import("@inquirer/prompts").then((mod) => {
|
|
1145
|
+
return mod;
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
return inquirerPromise;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Build the production {@link InitPrompts} from `@inquirer/prompts`. The
|
|
1152
|
+
* adapter:
|
|
1153
|
+
* - Rewires the inquirer output stream to `process.stderr` so the
|
|
1154
|
+
* wizard keeps the codebase's data-only-stdout contract (the final
|
|
1155
|
+
* summary line is the only stdout write).
|
|
1156
|
+
* - Maps the four wizard prompt shapes onto the inquirer calls.
|
|
1157
|
+
*
|
|
1158
|
+
* Tests never call this — they inject a scripted {@link InitPrompts}
|
|
1159
|
+
* double instead, so test runs are fully hermetic and do not need a TTY.
|
|
1160
|
+
*/
|
|
1161
|
+
export function createInquirerPrompts() {
|
|
1162
|
+
const context = { output: process.stderr };
|
|
1163
|
+
return {
|
|
1164
|
+
async checkbox(message, choices) {
|
|
1165
|
+
const inquirer = await loadInquirer();
|
|
1166
|
+
return inquirer.checkbox({
|
|
1167
|
+
message,
|
|
1168
|
+
// inquirer mutates the choice array; pass a defensive shallow
|
|
1169
|
+
// copy so the wizard's `readonly` source is untouched.
|
|
1170
|
+
choices: choices.map((c) => ({ ...c })),
|
|
1171
|
+
}, context);
|
|
1172
|
+
},
|
|
1173
|
+
async select(message, choices) {
|
|
1174
|
+
const inquirer = await loadInquirer();
|
|
1175
|
+
return inquirer.select({
|
|
1176
|
+
message,
|
|
1177
|
+
choices: choices.map((c) => ({ ...c })),
|
|
1178
|
+
}, context);
|
|
1179
|
+
},
|
|
1180
|
+
async confirm(message, defaultYes) {
|
|
1181
|
+
const inquirer = await loadInquirer();
|
|
1182
|
+
return inquirer.confirm({ message, default: defaultYes }, context);
|
|
1183
|
+
},
|
|
1184
|
+
async password(message) {
|
|
1185
|
+
const inquirer = await loadInquirer();
|
|
1186
|
+
const value = await inquirer.password({ message, mask: "" }, context);
|
|
1187
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1188
|
+
},
|
|
1189
|
+
async input(message) {
|
|
1190
|
+
const inquirer = await loadInquirer();
|
|
1191
|
+
return inquirer.input({ message }, context);
|
|
1192
|
+
},
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
// ---------------------------------------------------------------------------
|
|
1196
|
+
// Top-level handler
|
|
1197
|
+
// ---------------------------------------------------------------------------
|
|
1198
|
+
/**
|
|
1199
|
+
* Top-level init handler. Wired into the dispatch switch in `index.ts`.
|
|
1200
|
+
*
|
|
1201
|
+
* - `--help` / `-h` prints {@link INIT_HELP} to stdout and returns 0.
|
|
1202
|
+
* - Otherwise dispatches to {@link runFreshOnboarding}.
|
|
1203
|
+
*
|
|
1204
|
+
* The CLI arg list is the same shape every other handler receives; the
|
|
1205
|
+
* handler only inspects it for the help flag.
|
|
1206
|
+
*/
|
|
1207
|
+
export async function handleInitWithHelp(args, deps) {
|
|
1208
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
1209
|
+
deps.writeStdout(`${INIT_HELP}\n`);
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
return runFreshOnboarding(deps);
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Backwards-compatible alias used by the dispatch switch. Tests that drive
|
|
1216
|
+
* the wizard directly pass an empty arg list.
|
|
1217
|
+
*/
|
|
1218
|
+
export async function handleInit(deps) {
|
|
1219
|
+
return handleInitWithHelp([], deps);
|
|
1220
|
+
}
|
|
1221
|
+
//# sourceMappingURL=init.js.map
|