@pellux/goodvibes-tui 0.19.23 → 0.19.25
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/CHANGELOG.md +21 -0
- package/README.md +5 -5
- package/bin/goodvibes +5 -0
- package/bin/goodvibes-daemon +5 -0
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/completion.ts +89 -0
- package/src/cli/config-overrides.ts +159 -0
- package/src/cli/endpoints.ts +63 -0
- package/src/cli/entrypoint.ts +155 -0
- package/src/cli/help.ts +122 -0
- package/src/cli/index.ts +8 -0
- package/src/cli/management-commands.ts +576 -0
- package/src/cli/management.ts +693 -0
- package/src/cli/parser.ts +367 -0
- package/src/cli/status.ts +112 -0
- package/src/cli/tui-startup.ts +32 -0
- package/src/cli/types.ts +63 -0
- package/src/cli-flags.ts +17 -55
- package/src/config/index.ts +1 -1
- package/src/config/secrets.ts +44 -0
- package/src/core/conversation.ts +36 -13
- package/src/daemon/cli.ts +62 -11
- package/src/input/command-registry.ts +3 -0
- package/src/input/commands/guidance-runtime.ts +9 -4
- package/src/input/commands/local-runtime.ts +21 -7
- package/src/input/commands/local-setup.ts +31 -38
- package/src/input/commands/onboarding-runtime.ts +14 -0
- package/src/input/commands/runtime-services.ts +9 -0
- package/src/input/commands.ts +2 -0
- package/src/input/feed-context-factory.ts +8 -1
- package/src/input/handler-feed.ts +13 -8
- package/src/input/handler-interactions.ts +266 -0
- package/src/input/handler-modal-stack.ts +23 -3
- package/src/input/handler-modal-token-routes.ts +23 -1
- package/src/input/handler-onboarding.ts +696 -0
- package/src/input/handler-picker-routes.ts +15 -7
- package/src/input/handler-ui-state.ts +58 -0
- package/src/input/handler.ts +120 -246
- package/src/input/onboarding/handler-onboarding-routes.ts +105 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +211 -0
- package/src/input/onboarding/onboarding-wizard-constants.ts +148 -0
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +712 -0
- package/src/input/onboarding/onboarding-wizard-helpers.ts +218 -0
- package/src/input/onboarding/onboarding-wizard-rules.ts +224 -0
- package/src/input/onboarding/onboarding-wizard-state.ts +354 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +642 -0
- package/src/input/onboarding/onboarding-wizard-types.ts +170 -0
- package/src/input/onboarding/onboarding-wizard.ts +594 -0
- package/src/main.ts +32 -39
- package/src/panels/builtin/operations.ts +0 -10
- package/src/panels/index.ts +0 -1
- package/src/panels/panel-manager.ts +6 -2
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/help-overlay.ts +1 -1
- package/src/renderer/onboarding/onboarding-wizard.ts +533 -0
- package/src/renderer/panel-composite.ts +42 -5
- package/src/renderer/panel-workspace-bar.ts +5 -1
- package/src/runtime/bootstrap-core.ts +1 -0
- package/src/runtime/bootstrap.ts +123 -0
- package/src/runtime/onboarding/apply.ts +685 -0
- package/src/runtime/onboarding/derivation.ts +495 -0
- package/src/runtime/onboarding/index.ts +7 -0
- package/src/runtime/onboarding/markers.ts +161 -0
- package/src/runtime/onboarding/snapshot.ts +400 -0
- package/src/runtime/onboarding/state.ts +140 -0
- package/src/runtime/onboarding/types.ts +402 -0
- package/src/runtime/onboarding/verify.ts +233 -0
- package/src/runtime/ui-services.ts +16 -0
- package/src/shell/ui-openers.ts +12 -2
- package/src/version.ts +1 -1
- package/src/panels/welcome-panel.ts +0 -64
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config/oauth-local-listener';
|
|
4
|
+
import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config/openai-codex-auth';
|
|
5
|
+
import { openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils/open-external';
|
|
6
|
+
import { buildProviderAccountSnapshot } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
|
|
7
|
+
import { OnboardingWizardController, type OnboardingWizardAction } from './onboarding/onboarding-wizard.ts';
|
|
8
|
+
import { applyOnboardingRequest, collectOnboardingSnapshot, getOnboardingCompletionMarkerPath, verifyOnboardingRequest } from '../runtime/onboarding/index.ts';
|
|
9
|
+
import type { OnboardingApplyOperation, OnboardingApplyRequest, OnboardingShellPaths, OnboardingVerificationItem } from '../runtime/onboarding/index.ts';
|
|
10
|
+
import type { ModelPickerTarget } from './model-picker.ts';
|
|
11
|
+
import { captureOnboardingWizardSnapshot, restoreOnboardingWizardSnapshot } from './handler-ui-state.ts';
|
|
12
|
+
import type { InputHandler } from './handler.ts';
|
|
13
|
+
|
|
14
|
+
interface CompletionMarkerSnapshot {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly previous: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OnboardingRuntimePosture {
|
|
20
|
+
readonly serviceEnabled: boolean;
|
|
21
|
+
readonly serviceAutostart: boolean;
|
|
22
|
+
readonly restartOnFailure: boolean;
|
|
23
|
+
readonly expectedDaemon: boolean;
|
|
24
|
+
readonly expectedHttpListener: boolean;
|
|
25
|
+
readonly serverBacked: boolean;
|
|
26
|
+
readonly remoteExposure: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractAuthorizationCode(input: string): string | null {
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (!trimmed) return null;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(trimmed);
|
|
35
|
+
return url.searchParams.get('code');
|
|
36
|
+
} catch {
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function splitCompletionMarkerOperations(request: OnboardingApplyRequest): {
|
|
42
|
+
readonly settingsRequest: OnboardingApplyRequest;
|
|
43
|
+
readonly markerRequest: OnboardingApplyRequest;
|
|
44
|
+
} {
|
|
45
|
+
const markerOperations = request.operations.filter((operation) => operation.kind === 'set-completion-marker');
|
|
46
|
+
const settingsOperations = request.operations.filter((operation) => operation.kind !== 'set-completion-marker');
|
|
47
|
+
return {
|
|
48
|
+
settingsRequest: { ...request, operations: settingsOperations },
|
|
49
|
+
markerRequest: { ...request, operations: markerOperations },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function snapshotCompletionMarkers(
|
|
54
|
+
shellPaths: OnboardingShellPaths,
|
|
55
|
+
operations: readonly OnboardingApplyOperation[],
|
|
56
|
+
): readonly CompletionMarkerSnapshot[] {
|
|
57
|
+
return operations
|
|
58
|
+
.filter((operation) => operation.kind === 'set-completion-marker')
|
|
59
|
+
.map((operation) => {
|
|
60
|
+
const path = getOnboardingCompletionMarkerPath(shellPaths, operation.scope);
|
|
61
|
+
return {
|
|
62
|
+
path,
|
|
63
|
+
previous: existsSync(path) ? readFileSync(path, 'utf-8') : null,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function restoreCompletionMarkers(snapshots: readonly CompletionMarkerSnapshot[]): void {
|
|
69
|
+
for (const snapshot of snapshots) {
|
|
70
|
+
if (snapshot.previous === null) {
|
|
71
|
+
rmSync(snapshot.path, { force: true });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
mkdirSync(dirname(snapshot.path), { recursive: true });
|
|
75
|
+
writeFileSync(snapshot.path, snapshot.previous, 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isLoopbackHostValue(value: string | null | undefined): boolean {
|
|
80
|
+
const normalized = (value ?? '').trim().toLowerCase();
|
|
81
|
+
if (normalized.length === 0) return false;
|
|
82
|
+
return normalized === 'localhost'
|
|
83
|
+
|| normalized === '::1'
|
|
84
|
+
|| normalized === '[::1]'
|
|
85
|
+
|| normalized === '0:0:0:0:0:0:0:1'
|
|
86
|
+
|| /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearOnboardingPendingModelPickerTargetForHandler(handler: InputHandler): void {
|
|
90
|
+
handler.onboardingWizard.clearPendingModelPickerTarget();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearOnboardingModelPickerCancelStateForHandler(handler: InputHandler): void {
|
|
94
|
+
handler.onboardingModelPickerCancelSnapshot = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function restoreOnboardingModelPickerCancelStateForHandler(handler: InputHandler): void {
|
|
98
|
+
if (!handler.onboardingModelPickerCancelSnapshot) return;
|
|
99
|
+
restoreOnboardingWizardSnapshot(handler.onboardingWizard, handler.onboardingModelPickerCancelSnapshot, {
|
|
100
|
+
active: true,
|
|
101
|
+
});
|
|
102
|
+
handler.onboardingModelPickerCancelSnapshot = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function openModelPickerWithTargetForHandler(
|
|
106
|
+
handler: InputHandler,
|
|
107
|
+
target: ModelPickerTarget,
|
|
108
|
+
source: 'settings' | 'onboarding' = 'settings',
|
|
109
|
+
): boolean {
|
|
110
|
+
const openModelPicker = handler.commandContext?.openModelPicker;
|
|
111
|
+
if (!openModelPicker) return false;
|
|
112
|
+
if (source === 'onboarding' && handler.onboardingWizard.active) {
|
|
113
|
+
handler.onboardingModelPickerCancelSnapshot = captureOnboardingWizardSnapshot(handler.onboardingWizard);
|
|
114
|
+
} else {
|
|
115
|
+
handler.clearOnboardingModelPickerCancelState();
|
|
116
|
+
}
|
|
117
|
+
handler.clearOnboardingPendingModelPickerTarget();
|
|
118
|
+
handler.modelPicker.target = target;
|
|
119
|
+
openModelPicker();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function handleModelPickerCommitForHandler(handler: InputHandler): boolean {
|
|
124
|
+
if (handler.onboardingModelPickerCancelSnapshot && handler.onboardingWizard.active) {
|
|
125
|
+
const selected = handler.modelPicker.mode === 'effort'
|
|
126
|
+
? handler.modelPicker.pendingModel
|
|
127
|
+
: handler.modelPicker.mode === 'contextCap'
|
|
128
|
+
? handler.modelPicker.contextCapPendingModel
|
|
129
|
+
: handler.modelPicker.getSelected();
|
|
130
|
+
if (selected) {
|
|
131
|
+
handler.onboardingWizard.applyModelSelection(handler.modelPicker.target, {
|
|
132
|
+
providerId: selected.provider,
|
|
133
|
+
modelId: selected.registryKey ?? selected.id,
|
|
134
|
+
enabled: true,
|
|
135
|
+
});
|
|
136
|
+
if (handler.modelPicker.target === 'main' && handler.modelPicker.mode === 'effort') {
|
|
137
|
+
const effort = handler.modelPicker.effortLevels[handler.modelPicker.selectedIndex];
|
|
138
|
+
if (effort) handler.onboardingWizard.setFieldValue('default-model.reasoning', effort);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
handler.clearOnboardingPendingModelPickerTarget();
|
|
142
|
+
handler.clearOnboardingModelPickerCancelState();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
handler.clearOnboardingPendingModelPickerTarget();
|
|
146
|
+
handler.clearOnboardingModelPickerCancelState();
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function handleOnboardingActionForHandler(handler: InputHandler, action: OnboardingWizardAction): Promise<void> {
|
|
151
|
+
if (action === 'start-openai-subscription') {
|
|
152
|
+
await handler.handleOpenAiSubscriptionStart();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (action === 'finish-openai-subscription') {
|
|
156
|
+
await handler.handleOpenAiSubscriptionFinish();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (action !== 'apply') return;
|
|
160
|
+
if (handler.onboardingApplyPending) return;
|
|
161
|
+
const blockers = handler.onboardingWizard.getBlockingFieldLabels();
|
|
162
|
+
if (blockers.length > 0) {
|
|
163
|
+
handler.commandContext?.print?.([
|
|
164
|
+
'Onboarding needs required confirmations before applying.',
|
|
165
|
+
...blockers.map((label) => ` ${label}`),
|
|
166
|
+
].join('\n'));
|
|
167
|
+
handler.requestRender();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const request = handler.onboardingWizard.buildApplyRequest();
|
|
172
|
+
const { settingsRequest, markerRequest } = splitCompletionMarkerOperations(request);
|
|
173
|
+
const deps = {
|
|
174
|
+
config: handler.uiServices.platform.configManager,
|
|
175
|
+
secrets: handler.uiServices.platform.secretsManager,
|
|
176
|
+
auth: handler.uiServices.platform.localUserAuthManager,
|
|
177
|
+
shellPaths: handler.uiServices.environment.shellPaths,
|
|
178
|
+
acknowledgementScope: 'project' as const,
|
|
179
|
+
};
|
|
180
|
+
let appliedErrors: string[] = [];
|
|
181
|
+
let verificationItems: readonly OnboardingVerificationItem[] = [];
|
|
182
|
+
handler.onboardingApplyPending = true;
|
|
183
|
+
try {
|
|
184
|
+
const settingsApplied = await applyOnboardingRequest(deps, settingsRequest);
|
|
185
|
+
const settingsVerification = await verifyOnboardingRequest(deps, settingsRequest);
|
|
186
|
+
verificationItems = settingsVerification.items;
|
|
187
|
+
appliedErrors = [
|
|
188
|
+
...settingsApplied.errors.map((error) => `apply ${error.kind}: ${error.message}`),
|
|
189
|
+
...settingsVerification.items
|
|
190
|
+
.filter((item) => item.status !== 'pass')
|
|
191
|
+
.map((item) => `verify ${item.id}: ${item.message}`),
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
if (appliedErrors.length === 0) {
|
|
195
|
+
const activationVerification = await handler.restartOnboardingExternalServicesIfNeeded(request);
|
|
196
|
+
const runtimeVerification = [...activationVerification, ...handler.verifyOnboardingRuntimePosture(request)];
|
|
197
|
+
verificationItems = [...settingsVerification.items, ...runtimeVerification];
|
|
198
|
+
appliedErrors = runtimeVerification
|
|
199
|
+
.filter((item) => item.status === 'fail')
|
|
200
|
+
.map((item) => `verify ${item.id}: ${item.message}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (appliedErrors.length === 0 && markerRequest.operations.length > 0) {
|
|
204
|
+
const markerSnapshots = snapshotCompletionMarkers(deps.shellPaths, markerRequest.operations);
|
|
205
|
+
const markerApplied = await applyOnboardingRequest(deps, markerRequest);
|
|
206
|
+
const finalVerification = await verifyOnboardingRequest(deps, request);
|
|
207
|
+
const runtimeVerification = handler.verifyOnboardingRuntimePosture(request);
|
|
208
|
+
verificationItems = [...finalVerification.items, ...runtimeVerification];
|
|
209
|
+
appliedErrors = [
|
|
210
|
+
...markerApplied.errors.map((error) => `apply ${error.kind}: ${error.message}`),
|
|
211
|
+
...finalVerification.items
|
|
212
|
+
.filter((item) => item.status !== 'pass')
|
|
213
|
+
.map((item) => `verify ${item.id}: ${item.message}`),
|
|
214
|
+
...runtimeVerification
|
|
215
|
+
.filter((item) => item.status === 'fail')
|
|
216
|
+
.map((item) => `verify ${item.id}: ${item.message}`),
|
|
217
|
+
];
|
|
218
|
+
if (appliedErrors.length > 0) restoreCompletionMarkers(markerSnapshots);
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
handler.commandContext?.print?.([
|
|
222
|
+
'Onboarding apply did not complete.',
|
|
223
|
+
` ${error instanceof Error ? error.message : String(error)}`,
|
|
224
|
+
].join('\n'));
|
|
225
|
+
handler.requestRender();
|
|
226
|
+
return;
|
|
227
|
+
} finally {
|
|
228
|
+
handler.onboardingApplyPending = false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (appliedErrors.length > 0) {
|
|
232
|
+
handler.commandContext?.print?.([
|
|
233
|
+
'Onboarding apply did not complete.',
|
|
234
|
+
...appliedErrors.map((error) => ` ${error}`),
|
|
235
|
+
].join('\n'));
|
|
236
|
+
handler.requestRender();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handler.syncRuntimeFromOnboardingRequest(request);
|
|
241
|
+
handler.onboardingWizard.markApplied();
|
|
242
|
+
handler.onboardingWizard.close();
|
|
243
|
+
for (let index = handler.modalStack.length - 1; index >= 0; index -= 1) {
|
|
244
|
+
if (handler.modalStack[index] === 'onboarding') handler.modalStack.splice(index, 1);
|
|
245
|
+
}
|
|
246
|
+
if (handler.modalStack.length === 0) {
|
|
247
|
+
const returnFocus = handler.modalReturnFocus;
|
|
248
|
+
handler.panelFocused = returnFocus === 'panel';
|
|
249
|
+
handler.indicatorFocused = returnFocus === 'indicator';
|
|
250
|
+
handler.modalReturnFocus = 'prompt';
|
|
251
|
+
}
|
|
252
|
+
const warnings = verificationItems.filter((item) => item.status === 'warn');
|
|
253
|
+
handler.commandContext?.print?.([
|
|
254
|
+
`Onboarding applied and verified ${verificationItems.length} item(s).`,
|
|
255
|
+
...warnings.map((warning) => ` warning ${warning.id}: ${warning.message}`),
|
|
256
|
+
].join('\n'));
|
|
257
|
+
handler.requestRender();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function refreshOnboardingHydrationForHandler(handler: InputHandler, options: {
|
|
261
|
+
readonly preserveValues?: boolean;
|
|
262
|
+
readonly targetStepId?: string;
|
|
263
|
+
} = {}): Promise<void> {
|
|
264
|
+
const hydrationSerial = ++handler.onboardingHydrationSerial;
|
|
265
|
+
handler.onboardingWizard.beginRuntimeHydration();
|
|
266
|
+
handler.requestRender();
|
|
267
|
+
try {
|
|
268
|
+
const snapshot = await collectOnboardingSnapshot({
|
|
269
|
+
config: handler.uiServices.platform.configManager,
|
|
270
|
+
shellPaths: handler.uiServices.environment.shellPaths,
|
|
271
|
+
acknowledgementScope: 'project',
|
|
272
|
+
subscriptions: handler.uiServices.platform.subscriptionManager,
|
|
273
|
+
secrets: handler.uiServices.platform.secretsManager,
|
|
274
|
+
auth: handler.uiServices.platform.localUserAuthManager,
|
|
275
|
+
services: handler.uiServices.platform.serviceRegistry,
|
|
276
|
+
surfaces: {
|
|
277
|
+
list: () => handler.uiServices.platform.surfaceRegistry.syncConfiguredSurfaces(),
|
|
278
|
+
},
|
|
279
|
+
providerAccounts: {
|
|
280
|
+
loadSnapshot: () => buildProviderAccountSnapshot({
|
|
281
|
+
providerRegistry: handler.uiServices.providers.providerRegistry,
|
|
282
|
+
serviceRegistry: handler.uiServices.platform.serviceRegistry,
|
|
283
|
+
subscriptionManager: handler.uiServices.platform.subscriptionManager,
|
|
284
|
+
secretsManager: handler.uiServices.platform.secretsManager,
|
|
285
|
+
}),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
if (!handler.onboardingWizard.active || hydrationSerial !== handler.onboardingHydrationSerial) return;
|
|
289
|
+
handler.onboardingWizard.hydrateRuntimeState({ snapshot }, { resetValues: !(options.preserveValues ?? false) });
|
|
290
|
+
if (options.targetStepId) {
|
|
291
|
+
const targetIndex = handler.onboardingWizard.steps.findIndex((step) => step.id === options.targetStepId);
|
|
292
|
+
if (targetIndex >= 0) handler.onboardingWizard.setStep(targetIndex);
|
|
293
|
+
}
|
|
294
|
+
handler.requestRender();
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!handler.onboardingWizard.active || hydrationSerial !== handler.onboardingHydrationSerial) return;
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
handler.onboardingWizard.failRuntimeHydration(message);
|
|
299
|
+
handler.requestRender();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function handleOpenAiSubscriptionStartForHandler(handler: InputHandler): Promise<void> {
|
|
304
|
+
if (handler.onboardingApplyPending) return;
|
|
305
|
+
handler.onboardingApplyPending = true;
|
|
306
|
+
let listener: Awaited<ReturnType<typeof createOAuthLocalListener>> | null = null;
|
|
307
|
+
try {
|
|
308
|
+
const started = await beginOpenAICodexLogin();
|
|
309
|
+
handler.uiServices.platform.subscriptionManager.savePending({
|
|
310
|
+
provider: 'openai',
|
|
311
|
+
state: started.state,
|
|
312
|
+
verifier: started.verifier,
|
|
313
|
+
redirectUri: started.redirectUri,
|
|
314
|
+
createdAt: Date.now(),
|
|
315
|
+
});
|
|
316
|
+
listener = await createOAuthLocalListener({
|
|
317
|
+
expectedState: started.state,
|
|
318
|
+
host: '127.0.0.1',
|
|
319
|
+
port: 1455,
|
|
320
|
+
path: '/auth/callback',
|
|
321
|
+
}).catch(() => null);
|
|
322
|
+
const browserOpened = await openExternalUrl(started.authorizationUrl);
|
|
323
|
+
await handler.refreshOnboardingHydration({ preserveValues: true, targetStepId: 'provider-access' });
|
|
324
|
+
handler.onboardingWizard.setFieldValue('providers.openai-authorization-url', started.authorizationUrl);
|
|
325
|
+
const providerIndex = handler.onboardingWizard.steps.findIndex((step) => step.id === 'provider-access');
|
|
326
|
+
if (providerIndex >= 0) handler.onboardingWizard.setStep(providerIndex);
|
|
327
|
+
handler.requestRender();
|
|
328
|
+
|
|
329
|
+
if (listener && browserOpened) {
|
|
330
|
+
const serial = ++handler.onboardingOpenAiListenerSerial;
|
|
331
|
+
handler.commandContext?.print?.([
|
|
332
|
+
'OpenAI subscription sign-in started from onboarding.',
|
|
333
|
+
' callback listener: waiting on 127.0.0.1:1455',
|
|
334
|
+
' authorizationUrl: shown in the wizard provider step',
|
|
335
|
+
'You can also paste the callback code or URL into the OpenAI callback field.',
|
|
336
|
+
].join('\n'));
|
|
337
|
+
void handler.completeOpenAiSubscriptionFromListener(listener, started.verifier, serial);
|
|
338
|
+
listener = null;
|
|
339
|
+
handler.requestRender();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
listener?.close();
|
|
344
|
+
handler.commandContext?.print?.([
|
|
345
|
+
'OpenAI subscription sign-in started from onboarding.',
|
|
346
|
+
` browser: ${browserOpened ? 'opened' : 'open failed'}`,
|
|
347
|
+
` callback listener: ${listener ? 'ready' : 'unavailable'}`,
|
|
348
|
+
' authorizationUrl: shown in the wizard provider step',
|
|
349
|
+
'Paste the callback code or URL into the OpenAI callback field after sign-in.',
|
|
350
|
+
].join('\n'));
|
|
351
|
+
handler.requestRender();
|
|
352
|
+
} catch (error) {
|
|
353
|
+
listener?.close();
|
|
354
|
+
handler.commandContext?.print?.([
|
|
355
|
+
'OpenAI subscription sign-in could not start.',
|
|
356
|
+
` ${error instanceof Error ? error.message : String(error)}`,
|
|
357
|
+
].join('\n'));
|
|
358
|
+
handler.requestRender();
|
|
359
|
+
} finally {
|
|
360
|
+
handler.onboardingApplyPending = false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function completeOpenAiSubscriptionFromListenerForHandler(
|
|
365
|
+
handler: InputHandler,
|
|
366
|
+
listener: Awaited<ReturnType<typeof createOAuthLocalListener>>,
|
|
367
|
+
verifier: string,
|
|
368
|
+
serial: number,
|
|
369
|
+
): Promise<void> {
|
|
370
|
+
try {
|
|
371
|
+
const callback = await listener.waitForCode();
|
|
372
|
+
const pending = handler.uiServices.platform.subscriptionManager.getPending('openai');
|
|
373
|
+
if (!pending || pending.verifier !== verifier || serial !== handler.onboardingOpenAiListenerSerial) return;
|
|
374
|
+
const token = await exchangeOpenAICodexCode(callback.code, verifier);
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
const existing = handler.uiServices.platform.subscriptionManager.get('openai');
|
|
377
|
+
handler.uiServices.platform.subscriptionManager.saveSubscription({
|
|
378
|
+
provider: 'openai',
|
|
379
|
+
accessToken: token.accessToken,
|
|
380
|
+
refreshToken: token.refreshToken,
|
|
381
|
+
tokenType: token.tokenType,
|
|
382
|
+
expiresAt: token.expiresAt,
|
|
383
|
+
...(token.scopes ? { scopes: token.scopes } : {}),
|
|
384
|
+
authMode: 'oauth',
|
|
385
|
+
overrideAmbientApiKeys: false,
|
|
386
|
+
createdAt: existing?.createdAt ?? now,
|
|
387
|
+
updatedAt: now,
|
|
388
|
+
});
|
|
389
|
+
handler.uiServices.platform.subscriptionManager.clearPending('openai');
|
|
390
|
+
handler.onboardingOpenAiListenerSerial += 1;
|
|
391
|
+
handler.commandContext?.print?.([
|
|
392
|
+
'OpenAI subscription sign-in completed from onboarding.',
|
|
393
|
+
` tokenType: ${token.tokenType}`,
|
|
394
|
+
` expiresAt: ${token.expiresAt ? new Date(token.expiresAt).toISOString() : 'n/a'}`,
|
|
395
|
+
].join('\n'));
|
|
396
|
+
await handler.refreshOnboardingHydration({ preserveValues: true, targetStepId: 'provider-access' });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
handler.commandContext?.print?.([
|
|
399
|
+
'OpenAI subscription listener could not complete automatically.',
|
|
400
|
+
` listener: ${error instanceof Error ? error.message : String(error)}`,
|
|
401
|
+
'Paste the callback code or URL into the OpenAI callback field to finish in onboarding.',
|
|
402
|
+
].join('\n'));
|
|
403
|
+
handler.requestRender();
|
|
404
|
+
} finally {
|
|
405
|
+
listener.close();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function handleOpenAiSubscriptionFinishForHandler(handler: InputHandler): Promise<void> {
|
|
410
|
+
if (handler.onboardingApplyPending) return;
|
|
411
|
+
const code = extractAuthorizationCode(handler.onboardingWizard.getTextFieldValue('providers.openai-callback-code'));
|
|
412
|
+
if (!code) {
|
|
413
|
+
handler.commandContext?.print?.('OpenAI subscription sign-in needs a callback code or URL.');
|
|
414
|
+
handler.requestRender();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
handler.onboardingApplyPending = true;
|
|
419
|
+
try {
|
|
420
|
+
const pending = handler.uiServices.platform.subscriptionManager.getPending('openai');
|
|
421
|
+
if (!pending) {
|
|
422
|
+
handler.commandContext?.print?.('No pending OpenAI subscription sign-in exists in onboarding.');
|
|
423
|
+
handler.requestRender();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const token = await exchangeOpenAICodexCode(code, pending.verifier);
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const existing = handler.uiServices.platform.subscriptionManager.get('openai');
|
|
430
|
+
handler.uiServices.platform.subscriptionManager.saveSubscription({
|
|
431
|
+
provider: 'openai',
|
|
432
|
+
accessToken: token.accessToken,
|
|
433
|
+
refreshToken: token.refreshToken,
|
|
434
|
+
tokenType: token.tokenType,
|
|
435
|
+
expiresAt: token.expiresAt,
|
|
436
|
+
...(token.scopes ? { scopes: token.scopes } : {}),
|
|
437
|
+
authMode: 'oauth',
|
|
438
|
+
overrideAmbientApiKeys: false,
|
|
439
|
+
createdAt: existing?.createdAt ?? now,
|
|
440
|
+
updatedAt: now,
|
|
441
|
+
});
|
|
442
|
+
handler.uiServices.platform.subscriptionManager.clearPending('openai');
|
|
443
|
+
handler.commandContext?.print?.([
|
|
444
|
+
'OpenAI subscription sign-in completed from onboarding.',
|
|
445
|
+
` tokenType: ${token.tokenType}`,
|
|
446
|
+
` expiresAt: ${token.expiresAt ? new Date(token.expiresAt).toISOString() : 'n/a'}`,
|
|
447
|
+
].join('\n'));
|
|
448
|
+
await handler.refreshOnboardingHydration({ preserveValues: true, targetStepId: 'provider-access' });
|
|
449
|
+
} catch (error) {
|
|
450
|
+
handler.commandContext?.print?.([
|
|
451
|
+
'OpenAI subscription sign-in could not finish.',
|
|
452
|
+
` ${error instanceof Error ? error.message : String(error)}`,
|
|
453
|
+
].join('\n'));
|
|
454
|
+
handler.requestRender();
|
|
455
|
+
} finally {
|
|
456
|
+
handler.onboardingApplyPending = false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function syncRuntimeFromOnboardingRequestForHandler(handler: InputHandler, request: ReturnType<OnboardingWizardController['buildApplyRequest']>): void {
|
|
461
|
+
const runtime = handler.commandContext?.session.runtime;
|
|
462
|
+
if (!runtime) return;
|
|
463
|
+
|
|
464
|
+
for (const operation of request.operations) {
|
|
465
|
+
if (operation.kind !== 'set-config') continue;
|
|
466
|
+
if (operation.key === 'provider.model' && typeof operation.value === 'string') runtime.model = operation.value;
|
|
467
|
+
if (operation.key === 'provider.provider' && typeof operation.value === 'string') runtime.provider = operation.value;
|
|
468
|
+
if (operation.key === 'provider.reasoningEffort' && typeof operation.value === 'string') runtime.reasoningEffort = operation.value;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function getOnboardingConfigValueForHandler(handler: InputHandler, request: OnboardingApplyRequest, key: string): unknown {
|
|
473
|
+
const config = handler.uiServices.platform.configManager;
|
|
474
|
+
for (let index = request.operations.length - 1; index >= 0; index -= 1) {
|
|
475
|
+
const operation = request.operations[index];
|
|
476
|
+
if (operation?.kind === 'set-config' && operation.key === key) return operation.value;
|
|
477
|
+
}
|
|
478
|
+
return config.get(key as never);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function getOnboardingRuntimePostureForHandler(handler: InputHandler, request: OnboardingApplyRequest): OnboardingRuntimePosture {
|
|
482
|
+
const getConfigValue = (key: string): unknown => handler.getOnboardingConfigValue(request, key);
|
|
483
|
+
const serviceEnabled = getConfigValue('service.enabled') === true;
|
|
484
|
+
const serviceAutostart = getConfigValue('service.autostart') === true;
|
|
485
|
+
const restartOnFailure = getConfigValue('service.restartOnFailure') === true;
|
|
486
|
+
const daemonEnabled = getConfigValue('danger.daemon') === true || getConfigValue('controlPlane.enabled') === true;
|
|
487
|
+
const listenerEnabled = getConfigValue('danger.httpListener') === true;
|
|
488
|
+
const webEnabled = getConfigValue('web.enabled') === true;
|
|
489
|
+
const controlPlaneRemote = getConfigValue('controlPlane.hostMode') === 'network'
|
|
490
|
+
|| (getConfigValue('controlPlane.hostMode') === 'custom'
|
|
491
|
+
&& !isLoopbackHostValue(String(getConfigValue('controlPlane.host') ?? '')))
|
|
492
|
+
|| getConfigValue('controlPlane.allowRemote') === true;
|
|
493
|
+
const listenerRemote = getConfigValue('httpListener.hostMode') === 'network'
|
|
494
|
+
|| (getConfigValue('httpListener.hostMode') === 'custom'
|
|
495
|
+
&& !isLoopbackHostValue(String(getConfigValue('httpListener.host') ?? '')));
|
|
496
|
+
const webRemote = getConfigValue('web.hostMode') === 'network'
|
|
497
|
+
|| (getConfigValue('web.hostMode') === 'custom'
|
|
498
|
+
&& !isLoopbackHostValue(String(getConfigValue('web.host') ?? '')));
|
|
499
|
+
const remoteExposure = controlPlaneRemote || listenerRemote || webRemote;
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
serviceEnabled,
|
|
503
|
+
serviceAutostart,
|
|
504
|
+
restartOnFailure,
|
|
505
|
+
expectedDaemon: daemonEnabled || webEnabled,
|
|
506
|
+
expectedHttpListener: listenerEnabled,
|
|
507
|
+
serverBacked: serviceEnabled || daemonEnabled || listenerEnabled || webEnabled,
|
|
508
|
+
remoteExposure,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function restartOnboardingExternalServicesIfNeededForHandler(handler: InputHandler, request: OnboardingApplyRequest): Promise<OnboardingVerificationItem[]> {
|
|
513
|
+
const posture = handler.getOnboardingRuntimePosture(request);
|
|
514
|
+
const externalServices = handler.uiServices.platform.externalServices;
|
|
515
|
+
|
|
516
|
+
if (!externalServices) {
|
|
517
|
+
return [{
|
|
518
|
+
id: 'runtime:activation-restart',
|
|
519
|
+
status: 'fail',
|
|
520
|
+
message: 'Background service controller is unavailable, so onboarding cannot verify active daemon/listener state.',
|
|
521
|
+
target: 'service',
|
|
522
|
+
}];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const currentState = externalServices.inspect();
|
|
526
|
+
const hasLiveExternalServices = currentState.daemonRunning === true
|
|
527
|
+
|| currentState.daemonPortInUse === true
|
|
528
|
+
|| currentState.httpListenerRunning === true
|
|
529
|
+
|| currentState.httpListenerPortInUse === true;
|
|
530
|
+
if (!posture.serverBacked && !hasLiveExternalServices) return [];
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const state = await externalServices.restart();
|
|
534
|
+
const failures: OnboardingVerificationItem[] = [];
|
|
535
|
+
if (posture.expectedDaemon && !state.daemonRunning) {
|
|
536
|
+
failures.push({
|
|
537
|
+
id: 'runtime:daemon-active',
|
|
538
|
+
status: 'fail',
|
|
539
|
+
message: 'The GoodVibes daemon did not start after applying onboarding settings.',
|
|
540
|
+
target: 'service',
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (!posture.expectedDaemon && (state.daemonRunning || state.daemonPortInUse)) {
|
|
544
|
+
failures.push({
|
|
545
|
+
id: 'runtime:daemon-stopped',
|
|
546
|
+
status: 'fail',
|
|
547
|
+
message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
|
|
548
|
+
target: 'service',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (posture.expectedHttpListener && !state.httpListenerRunning) {
|
|
552
|
+
failures.push({
|
|
553
|
+
id: 'runtime:http-listener-active',
|
|
554
|
+
status: 'fail',
|
|
555
|
+
message: 'The HTTP listener did not start after applying onboarding settings.',
|
|
556
|
+
target: 'service',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (!posture.expectedHttpListener && (state.httpListenerRunning || state.httpListenerPortInUse)) {
|
|
560
|
+
failures.push({
|
|
561
|
+
id: 'runtime:http-listener-stopped',
|
|
562
|
+
status: 'fail',
|
|
563
|
+
message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
|
|
564
|
+
target: 'service',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (failures.length > 0) return failures;
|
|
568
|
+
|
|
569
|
+
return [{
|
|
570
|
+
id: 'runtime:activation-restart',
|
|
571
|
+
status: 'pass',
|
|
572
|
+
message: 'Background services restarted with the applied onboarding settings.',
|
|
573
|
+
target: 'service',
|
|
574
|
+
}];
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return [{
|
|
577
|
+
id: 'runtime:activation-restart',
|
|
578
|
+
status: 'fail',
|
|
579
|
+
message: `Background services could not restart: ${error instanceof Error ? error.message : String(error)}`,
|
|
580
|
+
target: 'service',
|
|
581
|
+
}];
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler, request: OnboardingApplyRequest): OnboardingVerificationItem[] {
|
|
586
|
+
const posture = handler.getOnboardingRuntimePosture(request);
|
|
587
|
+
const externalServices = handler.uiServices.platform.externalServices;
|
|
588
|
+
const externalState = externalServices?.inspect();
|
|
589
|
+
if (!posture.serverBacked) {
|
|
590
|
+
if (!externalServices) {
|
|
591
|
+
return [{
|
|
592
|
+
id: 'runtime:external-services-controller',
|
|
593
|
+
status: 'fail',
|
|
594
|
+
message: 'Background service controller is unavailable, so onboarding cannot verify daemon/listener shutdown state.',
|
|
595
|
+
target: 'service',
|
|
596
|
+
}];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const stoppedItems: OnboardingVerificationItem[] = [];
|
|
600
|
+
if (externalState?.daemonRunning || externalState?.daemonPortInUse) {
|
|
601
|
+
stoppedItems.push({
|
|
602
|
+
id: 'runtime:daemon-stopped',
|
|
603
|
+
status: 'fail',
|
|
604
|
+
message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
|
|
605
|
+
target: 'service',
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if (externalState?.httpListenerRunning || externalState?.httpListenerPortInUse) {
|
|
609
|
+
stoppedItems.push({
|
|
610
|
+
id: 'runtime:http-listener-stopped',
|
|
611
|
+
status: 'fail',
|
|
612
|
+
message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
|
|
613
|
+
target: 'service',
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (externalState && stoppedItems.length === 0) {
|
|
617
|
+
stoppedItems.push({
|
|
618
|
+
id: 'runtime:external-services-stopped',
|
|
619
|
+
status: 'pass',
|
|
620
|
+
message: 'Background daemon and HTTP listener are stopped for Local TUI Only.',
|
|
621
|
+
target: 'service',
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return stoppedItems;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const auth = handler.uiServices.platform.localUserAuthManager.inspect();
|
|
628
|
+
const hasAdmin = auth.users.some((user) => user.roles.includes('admin'));
|
|
629
|
+
const items: OnboardingVerificationItem[] = [];
|
|
630
|
+
|
|
631
|
+
items.push({
|
|
632
|
+
id: 'runtime:service-mode',
|
|
633
|
+
status: posture.serviceEnabled && posture.serviceAutostart && posture.restartOnFailure ? 'pass' : 'fail',
|
|
634
|
+
message: posture.serviceEnabled && posture.serviceAutostart && posture.restartOnFailure
|
|
635
|
+
? 'Service mode, autostart, and restart-on-failure are enabled for server-backed onboarding.'
|
|
636
|
+
: 'Server-backed onboarding requires service mode, autostart, and restart-on-failure.',
|
|
637
|
+
target: 'service',
|
|
638
|
+
});
|
|
639
|
+
items.push({
|
|
640
|
+
id: 'runtime:auth-posture',
|
|
641
|
+
status: hasAdmin && !auth.bootstrapCredentialPresent ? 'pass' : 'fail',
|
|
642
|
+
message: hasAdmin && !auth.bootstrapCredentialPresent
|
|
643
|
+
? 'Local admin auth is configured and bootstrap credentials are not present.'
|
|
644
|
+
: 'Network-capable surfaces require local admin auth with no bootstrap credential file.',
|
|
645
|
+
target: 'auth',
|
|
646
|
+
});
|
|
647
|
+
if (posture.remoteExposure) {
|
|
648
|
+
items.push({
|
|
649
|
+
id: 'runtime:remote-auth-gate',
|
|
650
|
+
status: hasAdmin ? 'pass' : 'fail',
|
|
651
|
+
message: hasAdmin
|
|
652
|
+
? 'Remote-capable bind settings have local admin auth available.'
|
|
653
|
+
: 'Remote-capable bind settings cannot be applied without local admin auth.',
|
|
654
|
+
target: 'auth',
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (posture.expectedDaemon) {
|
|
659
|
+
items.push({
|
|
660
|
+
id: 'runtime:daemon-active',
|
|
661
|
+
status: externalState?.daemonRunning ? 'pass' : 'fail',
|
|
662
|
+
message: externalState?.daemonRunning
|
|
663
|
+
? 'The GoodVibes daemon is running with the applied onboarding settings.'
|
|
664
|
+
: 'The GoodVibes daemon is not running after onboarding apply.',
|
|
665
|
+
target: 'service',
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
if (!posture.expectedDaemon && (externalState?.daemonRunning || externalState?.daemonPortInUse)) {
|
|
669
|
+
items.push({
|
|
670
|
+
id: 'runtime:daemon-stopped',
|
|
671
|
+
status: 'fail',
|
|
672
|
+
message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
|
|
673
|
+
target: 'service',
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
if (posture.expectedHttpListener) {
|
|
677
|
+
items.push({
|
|
678
|
+
id: 'runtime:http-listener-active',
|
|
679
|
+
status: externalState?.httpListenerRunning ? 'pass' : 'fail',
|
|
680
|
+
message: externalState?.httpListenerRunning
|
|
681
|
+
? 'The HTTP listener is running with the applied onboarding settings.'
|
|
682
|
+
: 'The HTTP listener is not running after onboarding apply.',
|
|
683
|
+
target: 'service',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (!posture.expectedHttpListener && (externalState?.httpListenerRunning || externalState?.httpListenerPortInUse)) {
|
|
687
|
+
items.push({
|
|
688
|
+
id: 'runtime:http-listener-stopped',
|
|
689
|
+
status: 'fail',
|
|
690
|
+
message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
|
|
691
|
+
target: 'service',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return items;
|
|
696
|
+
}
|