@pellux/goodvibes-tui 0.19.24 → 0.19.26
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 +13 -0
- package/README.md +5 -5
- package/bin/goodvibes +10 -0
- package/bin/goodvibes-daemon +10 -0
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -2
- package/src/cli/bundle-command.ts +225 -0
- package/src/cli/completion.ts +90 -0
- package/src/cli/config-overrides.ts +159 -0
- package/src/cli/endpoints.ts +63 -0
- package/src/cli/entrypoint.ts +169 -0
- package/src/cli/help.ts +301 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/management-commands.ts +426 -0
- package/src/cli/management.ts +719 -0
- package/src/cli/network-posture.ts +46 -0
- package/src/cli/package-verification.ts +119 -0
- package/src/cli/parser.ts +369 -0
- package/src/cli/provider-classification.ts +107 -0
- package/src/cli/redaction.ts +105 -0
- package/src/cli/service-command.ts +45 -0
- package/src/cli/service-posture.ts +247 -0
- package/src/cli/status.ts +382 -0
- package/src/cli/surface-command.ts +248 -0
- package/src/cli/tui-startup.ts +32 -0
- package/src/cli/types.ts +69 -0
- package/src/cli-flags.ts +18 -55
- package/src/config/index.ts +1 -1
- package/src/config/secrets.ts +44 -0
- 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/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/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,495 @@
|
|
|
1
|
+
import { DEFAULT_CONFIG } from '../../config/index.ts';
|
|
2
|
+
import type {
|
|
3
|
+
OnboardingAcknowledgementState,
|
|
4
|
+
OnboardingAcknowledgementTarget,
|
|
5
|
+
OnboardingNetworkMode,
|
|
6
|
+
OnboardingReopenEditAcknowledgementState,
|
|
7
|
+
OnboardingSnapshotState,
|
|
8
|
+
OnboardingStep1CapabilityItem,
|
|
9
|
+
OnboardingStepDerivationState,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
const PROVIDER_SECRET_ENV_ALIASES = {
|
|
13
|
+
openai: ['OPENAI_API_KEY', 'OPENAI_KEY'],
|
|
14
|
+
anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'],
|
|
15
|
+
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GEMINI_API_KEY'],
|
|
16
|
+
inceptionlabs: ['INCEPTION_API_KEY'],
|
|
17
|
+
openrouter: ['OPENROUTER_API_KEY'],
|
|
18
|
+
aihubmix: ['AIHUBMIX_API_KEY'],
|
|
19
|
+
groq: ['GROQ_API_KEY'],
|
|
20
|
+
cerebras: ['CEREBRAS_API_KEY'],
|
|
21
|
+
mistral: ['MISTRAL_API_KEY'],
|
|
22
|
+
'ollama-cloud': ['OLLAMA_CLOUD_API_KEY', 'OLLAMA_API_KEY'],
|
|
23
|
+
huggingface: ['HF_API_KEY', 'HUGGINGFACE_API_KEY', 'HF_TOKEN'],
|
|
24
|
+
nvidia: ['NVIDIA_API_KEY'],
|
|
25
|
+
llm7: ['LLM7_API_KEY'],
|
|
26
|
+
deepseek: ['DEEPSEEK_API_KEY'],
|
|
27
|
+
fireworks: ['FIREWORKS_API_KEY'],
|
|
28
|
+
'github-copilot': ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'],
|
|
29
|
+
'microsoft-foundry': ['AZURE_OPENAI_API_KEY'],
|
|
30
|
+
minimax: ['MINIMAX_API_KEY'],
|
|
31
|
+
moonshot: ['MOONSHOT_API_KEY'],
|
|
32
|
+
qianfan: ['QIANFAN_API_KEY'],
|
|
33
|
+
qwen: ['QWEN_API_KEY', 'DASHSCOPE_API_KEY', 'MODELSTUDIO_API_KEY'],
|
|
34
|
+
sglang: ['SGLANG_API_KEY'],
|
|
35
|
+
stepfun: ['STEPFUN_API_KEY'],
|
|
36
|
+
together: ['TOGETHER_API_KEY'],
|
|
37
|
+
venice: ['VENICE_API_KEY'],
|
|
38
|
+
volcengine: ['VOLCANO_ENGINE_API_KEY'],
|
|
39
|
+
xai: ['XAI_API_KEY'],
|
|
40
|
+
xiaomi: ['XIAOMI_API_KEY'],
|
|
41
|
+
zai: ['ZAI_API_KEY', 'Z_AI_API_KEY'],
|
|
42
|
+
'cloudflare-ai-gateway': ['CLOUDFLARE_AI_GATEWAY_API_KEY'],
|
|
43
|
+
'vercel-ai-gateway': ['AI_GATEWAY_API_KEY'],
|
|
44
|
+
litellm: ['LITELLM_API_KEY'],
|
|
45
|
+
'copilot-proxy': ['COPILOT_PROXY_API_KEY'],
|
|
46
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
47
|
+
|
|
48
|
+
const SECRET_KEY_TO_PROVIDER_IDS = new Map<string, readonly string[]>(
|
|
49
|
+
Object.entries(PROVIDER_SECRET_ENV_ALIASES).flatMap(([providerId, aliases]) => aliases.map((alias) => [alias, [providerId] as const])),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const INBOUND_EVENT_SURFACE_KINDS = new Set<string>([
|
|
53
|
+
'bluebubbles',
|
|
54
|
+
'discord',
|
|
55
|
+
'google-chat',
|
|
56
|
+
'googleChat',
|
|
57
|
+
'imessage',
|
|
58
|
+
'mattermost',
|
|
59
|
+
'matrix',
|
|
60
|
+
'msteams',
|
|
61
|
+
'ntfy',
|
|
62
|
+
'signal',
|
|
63
|
+
'slack',
|
|
64
|
+
'telegram',
|
|
65
|
+
'webhook',
|
|
66
|
+
'whatsapp',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
function isDeepEqual(left: unknown, right: unknown): boolean {
|
|
70
|
+
if (Object.is(left, right)) return true;
|
|
71
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
72
|
+
return left.length === right.length && left.every((value, index) => isDeepEqual(value, right[index]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
typeof left === 'object' && left !== null
|
|
77
|
+
&& typeof right === 'object' && right !== null
|
|
78
|
+
&& !Array.isArray(left)
|
|
79
|
+
&& !Array.isArray(right)
|
|
80
|
+
) {
|
|
81
|
+
const leftEntries = Object.entries(left);
|
|
82
|
+
const rightEntries = Object.entries(right);
|
|
83
|
+
if (leftEntries.length !== rightEntries.length) return false;
|
|
84
|
+
|
|
85
|
+
return leftEntries.every(([key, value]) => isDeepEqual(value, (right as Record<string, unknown>)[key]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function countPermissionToolOverrides(snapshot: OnboardingSnapshotState): number {
|
|
92
|
+
return Object.entries(snapshot.config.permissions.tools).filter(([key, value]) => {
|
|
93
|
+
if (value === undefined) return false;
|
|
94
|
+
return value !== DEFAULT_CONFIG.permissions.tools[key as keyof typeof DEFAULT_CONFIG.permissions.tools];
|
|
95
|
+
}).length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasCustomizedProviderRouting(snapshot: OnboardingSnapshotState): boolean {
|
|
99
|
+
return snapshot.providerRouting.primaryProviderId !== DEFAULT_CONFIG.provider.provider
|
|
100
|
+
|| snapshot.providerRouting.primaryModelId !== DEFAULT_CONFIG.provider.model
|
|
101
|
+
|| snapshot.providerRouting.primaryReasoningEffort !== DEFAULT_CONFIG.provider.reasoningEffort
|
|
102
|
+
|| snapshot.providerRouting.embeddingProviderId !== DEFAULT_CONFIG.provider.embeddingProvider
|
|
103
|
+
|| snapshot.providerRouting.systemPromptFile.trim() !== DEFAULT_CONFIG.provider.systemPromptFile.trim()
|
|
104
|
+
|| snapshot.providerRouting.helperEnabled !== DEFAULT_CONFIG.helper.enabled
|
|
105
|
+
|| snapshot.providerRouting.helperProviderId !== DEFAULT_CONFIG.helper.globalProvider
|
|
106
|
+
|| snapshot.providerRouting.helperModelId !== DEFAULT_CONFIG.helper.globalModel
|
|
107
|
+
|| snapshot.providerRouting.toolLlmEnabled !== DEFAULT_CONFIG.tools.llmEnabled
|
|
108
|
+
|| snapshot.providerRouting.toolProviderId !== DEFAULT_CONFIG.tools.llmProvider
|
|
109
|
+
|| snapshot.providerRouting.toolModelId !== DEFAULT_CONFIG.tools.llmModel;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getProviderAccountSignalIds(snapshot: OnboardingSnapshotState): string[] {
|
|
113
|
+
return (snapshot.providerAccounts?.providers ?? [])
|
|
114
|
+
.filter((provider) => provider.activeRoute !== 'unconfigured' || provider.pendingLogin || provider.oauthReady)
|
|
115
|
+
.map((provider) => provider.providerId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getServiceCredentialProviderIds(snapshot: OnboardingSnapshotState): string[] {
|
|
119
|
+
return snapshot.services.services
|
|
120
|
+
.filter((service) => service.hasPrimaryCredential || service.hasPasswordCredential)
|
|
121
|
+
.map((service) => service.providerId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getSecretBackedProviderIds(snapshot: OnboardingSnapshotState): string[] {
|
|
125
|
+
const providerIds = new Set<string>();
|
|
126
|
+
|
|
127
|
+
for (const record of snapshot.secrets.records) {
|
|
128
|
+
const matches = SECRET_KEY_TO_PROVIDER_IDS.get(record.key);
|
|
129
|
+
if (!matches) continue;
|
|
130
|
+
for (const providerId of matches) providerIds.add(providerId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [...providerIds].sort((left, right) => left.localeCompare(right));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getConfiguredProviderSignalIds(snapshot: OnboardingSnapshotState): string[] {
|
|
137
|
+
return [...new Set<string>([
|
|
138
|
+
...getProviderAccountSignalIds(snapshot),
|
|
139
|
+
...snapshot.services.oauthProviderIds,
|
|
140
|
+
...getServiceCredentialProviderIds(snapshot),
|
|
141
|
+
...snapshot.subscriptions.activeProviderIds,
|
|
142
|
+
...snapshot.subscriptions.pendingProviderIds,
|
|
143
|
+
...getSecretBackedProviderIds(snapshot),
|
|
144
|
+
])].sort((left, right) => left.localeCompare(right));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasConfiguredProviderState(snapshot: OnboardingSnapshotState): boolean {
|
|
148
|
+
return getConfiguredProviderSignalIds(snapshot).length > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function countConfiguredSurfaceKinds(snapshot: OnboardingSnapshotState): number {
|
|
152
|
+
return new Set<string>([
|
|
153
|
+
...snapshot.surfaces.configuredEnabledKinds,
|
|
154
|
+
...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
|
|
155
|
+
]).size;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasInboundEventSurface(snapshot: OnboardingSnapshotState): boolean {
|
|
159
|
+
return snapshot.surfaces.configuredEnabledKinds.some((kind) => INBOUND_EVENT_SURFACE_KINDS.has(kind))
|
|
160
|
+
|| snapshot.surfaces.records.some((surface) => surface.enabled && INBOUND_EVENT_SURFACE_KINDS.has(surface.kind));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hasCustomizedWorkspaceDefaults(snapshot: OnboardingSnapshotState): boolean {
|
|
164
|
+
return !isDeepEqual(snapshot.config.behavior, DEFAULT_CONFIG.behavior)
|
|
165
|
+
|| !isDeepEqual(snapshot.config.display, DEFAULT_CONFIG.display);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function hasAnyServerEnabled(snapshot: OnboardingSnapshotState): boolean {
|
|
169
|
+
return snapshot.bindSettings.daemonEnabled
|
|
170
|
+
|| snapshot.bindSettings.controlPlane.enabled
|
|
171
|
+
|| snapshot.bindSettings.httpListenerEnabled
|
|
172
|
+
|| snapshot.bindSettings.web.enabled;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasBrowserAccess(snapshot: OnboardingSnapshotState): boolean {
|
|
176
|
+
return snapshot.bindSettings.web.enabled;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isLoopbackHost(host: string | null | undefined): boolean {
|
|
180
|
+
const normalized = (host ?? '').trim().toLowerCase();
|
|
181
|
+
if (normalized.length === 0) return false;
|
|
182
|
+
return normalized === 'localhost'
|
|
183
|
+
|| normalized === '::1'
|
|
184
|
+
|| normalized === '[::1]'
|
|
185
|
+
|| normalized === '0:0:0:0:0:0:0:1'
|
|
186
|
+
|| /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isRemoteBind(hostMode: string, host: string | null | undefined, allowRemote = false): boolean {
|
|
190
|
+
if (hostMode === 'network') return true;
|
|
191
|
+
if (hostMode === 'local') return allowRemote;
|
|
192
|
+
if (hostMode === 'custom') return !isLoopbackHost(host);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hasRemoteDeviceAccess(snapshot: OnboardingSnapshotState): boolean {
|
|
197
|
+
return (
|
|
198
|
+
((snapshot.bindSettings.daemonEnabled || snapshot.bindSettings.controlPlane.enabled)
|
|
199
|
+
&& isRemoteBind(
|
|
200
|
+
snapshot.bindSettings.controlPlane.hostMode,
|
|
201
|
+
snapshot.bindSettings.controlPlane.host,
|
|
202
|
+
snapshot.bindSettings.controlPlane.allowRemote,
|
|
203
|
+
))
|
|
204
|
+
|| (snapshot.bindSettings.web.enabled && isRemoteBind(
|
|
205
|
+
snapshot.bindSettings.web.hostMode,
|
|
206
|
+
snapshot.bindSettings.web.host,
|
|
207
|
+
))
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasWebhookOrEventIngress(snapshot: OnboardingSnapshotState): boolean {
|
|
212
|
+
return snapshot.bindSettings.httpListenerEnabled
|
|
213
|
+
|| hasInboundEventSurface(snapshot)
|
|
214
|
+
|| snapshot.services.services.some((service) => service.hasWebhookUrl || service.hasSigningSecret || service.hasPublicKey);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getProviderIdentityIds(snapshot: OnboardingSnapshotState): Set<string> {
|
|
218
|
+
return new Set<string>([
|
|
219
|
+
...Object.keys(PROVIDER_SECRET_ENV_ALIASES),
|
|
220
|
+
...getConfiguredProviderSignalIds(snapshot),
|
|
221
|
+
snapshot.providerRouting.primaryProviderId,
|
|
222
|
+
snapshot.providerRouting.embeddingProviderId,
|
|
223
|
+
snapshot.providerRouting.helperProviderId,
|
|
224
|
+
snapshot.providerRouting.toolProviderId,
|
|
225
|
+
].filter((value) => value.trim().length > 0));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getExternalIntegrationServiceIds(snapshot: OnboardingSnapshotState): string[] {
|
|
229
|
+
const providerIdentityIds = getProviderIdentityIds(snapshot);
|
|
230
|
+
|
|
231
|
+
return snapshot.services.services
|
|
232
|
+
.filter((service) => !providerIdentityIds.has(service.providerId) && !providerIdentityIds.has(service.name))
|
|
233
|
+
.map((service) => service.name);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function hasExternalIntegrations(snapshot: OnboardingSnapshotState): boolean {
|
|
237
|
+
return getExternalIntegrationServiceIds(snapshot).length > 0
|
|
238
|
+
|| countConfiguredSurfaceKinds(snapshot) > 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function describeLocalTuiOnly(snapshot: OnboardingSnapshotState): string {
|
|
242
|
+
if (!hasAnyServerEnabled(snapshot)) {
|
|
243
|
+
return 'Keep GoodVibes in this terminal and disable browser access, background services, network listeners, and external surfaces.';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return 'Switching to this disables browser access, background services, network listeners, and external surfaces.';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function describeBrowserAccess(snapshot: OnboardingSnapshotState): string {
|
|
250
|
+
return snapshot.bindSettings.web.enabled
|
|
251
|
+
? 'Keep the background service and web UI enabled, reachable according to the network step.'
|
|
252
|
+
: 'Enable the background service and web UI, reachable on the local network by default unless customized.';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function describeRemoteDeviceAccess(snapshot: OnboardingSnapshotState): string {
|
|
256
|
+
return hasRemoteDeviceAccess(snapshot)
|
|
257
|
+
? 'Keep enabled GoodVibes services reachable from other devices on your LAN. Local auth is required.'
|
|
258
|
+
: 'Expose enabled GoodVibes services on your LAN so other devices can reach them. Local auth is required.';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function describeWebhookIngress(snapshot: OnboardingSnapshotState): string {
|
|
262
|
+
return hasWebhookOrEventIngress(snapshot)
|
|
263
|
+
? 'Keep the HTTP listener available for incoming webhooks, callbacks, and automation events.'
|
|
264
|
+
: 'Turn on the HTTP listener for incoming webhooks, callbacks, and automation events.';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function describeExternalIntegrations(snapshot: OnboardingSnapshotState): string {
|
|
268
|
+
const integrationCount = new Set<string>([
|
|
269
|
+
...getExternalIntegrationServiceIds(snapshot),
|
|
270
|
+
...snapshot.surfaces.configuredEnabledKinds,
|
|
271
|
+
...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
|
|
272
|
+
]).size;
|
|
273
|
+
|
|
274
|
+
if (integrationCount === 0) {
|
|
275
|
+
return 'Show Slack, Discord, Telegram, Teams, Matrix, and other app surfaces so they can be enabled and configured here.';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return `Review and configure ${integrationCount} detected external app, service, or surface integration signal(s).`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getAcknowledgementAccepted(
|
|
282
|
+
snapshot: OnboardingSnapshotState,
|
|
283
|
+
target: OnboardingAcknowledgementTarget,
|
|
284
|
+
): boolean {
|
|
285
|
+
return snapshot.acknowledgements.accepted[target] === true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildNotNeededAcknowledgement(
|
|
289
|
+
snapshot: OnboardingSnapshotState,
|
|
290
|
+
target: OnboardingAcknowledgementTarget,
|
|
291
|
+
detail: string,
|
|
292
|
+
): OnboardingAcknowledgementState {
|
|
293
|
+
return {
|
|
294
|
+
required: false,
|
|
295
|
+
accepted: getAcknowledgementAccepted(snapshot, target),
|
|
296
|
+
reason: 'not-needed',
|
|
297
|
+
detail,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildRequiredAcknowledgement(
|
|
302
|
+
snapshot: OnboardingSnapshotState,
|
|
303
|
+
target: OnboardingAcknowledgementTarget,
|
|
304
|
+
reason: Exclude<OnboardingAcknowledgementState['reason'], 'not-needed'>,
|
|
305
|
+
detail: string,
|
|
306
|
+
): OnboardingAcknowledgementState {
|
|
307
|
+
return {
|
|
308
|
+
required: true,
|
|
309
|
+
accepted: getAcknowledgementAccepted(snapshot, target),
|
|
310
|
+
reason,
|
|
311
|
+
detail,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function deriveStep1Capabilities(
|
|
316
|
+
snapshot: OnboardingSnapshotState,
|
|
317
|
+
): readonly OnboardingStep1CapabilityItem[] {
|
|
318
|
+
return [
|
|
319
|
+
{
|
|
320
|
+
id: 'local-tui-only',
|
|
321
|
+
label: 'Local TUI Only (No Servers)',
|
|
322
|
+
selected: !hasAnyServerEnabled(snapshot),
|
|
323
|
+
detail: describeLocalTuiOnly(snapshot),
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: 'browser-access',
|
|
327
|
+
label: 'Open GoodVibes in a Browser',
|
|
328
|
+
selected: hasBrowserAccess(snapshot),
|
|
329
|
+
detail: describeBrowserAccess(snapshot),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: 'network-access',
|
|
333
|
+
label: 'Let other devices use GoodVibes',
|
|
334
|
+
selected: hasRemoteDeviceAccess(snapshot),
|
|
335
|
+
detail: describeRemoteDeviceAccess(snapshot),
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: 'webhook-events',
|
|
339
|
+
label: 'Receive webhooks or events from other tools',
|
|
340
|
+
selected: hasWebhookOrEventIngress(snapshot),
|
|
341
|
+
detail: describeWebhookIngress(snapshot),
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'external-integrations',
|
|
345
|
+
label: 'Connect GoodVibes to external apps and services',
|
|
346
|
+
selected: hasExternalIntegrations(snapshot),
|
|
347
|
+
detail: describeExternalIntegrations(snapshot),
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function deriveStep1CapabilityFlags(
|
|
353
|
+
snapshot: OnboardingSnapshotState,
|
|
354
|
+
): {
|
|
355
|
+
readonly providers: boolean;
|
|
356
|
+
readonly services: boolean;
|
|
357
|
+
readonly subscriptions: boolean;
|
|
358
|
+
readonly auth: boolean;
|
|
359
|
+
readonly controlPlane: boolean;
|
|
360
|
+
readonly httpListener: boolean;
|
|
361
|
+
readonly web: boolean;
|
|
362
|
+
readonly surfaces: boolean;
|
|
363
|
+
} {
|
|
364
|
+
return {
|
|
365
|
+
providers: hasConfiguredProviderState(snapshot) || hasCustomizedProviderRouting(snapshot),
|
|
366
|
+
services: snapshot.services.total > 0,
|
|
367
|
+
subscriptions: snapshot.subscriptions.active.length > 0 || snapshot.subscriptions.pending.length > 0,
|
|
368
|
+
auth: snapshot.auth.snapshot.userCount > 0
|
|
369
|
+
|| snapshot.auth.snapshot.sessionCount > 0
|
|
370
|
+
|| snapshot.auth.snapshot.bootstrapCredentialPresent,
|
|
371
|
+
controlPlane: snapshot.bindSettings.daemonEnabled || snapshot.bindSettings.controlPlane.enabled,
|
|
372
|
+
httpListener: snapshot.bindSettings.httpListenerEnabled,
|
|
373
|
+
web: snapshot.bindSettings.web.enabled,
|
|
374
|
+
surfaces: countConfiguredSurfaceKinds(snapshot) > 0,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function deriveStep1_5NetworkMode(
|
|
379
|
+
bindSettings: Pick<OnboardingSnapshotState, 'bindSettings'>['bindSettings'],
|
|
380
|
+
): OnboardingNetworkMode {
|
|
381
|
+
const activeModes: string[] = [];
|
|
382
|
+
const hasNetworkFacingSurface = bindSettings.httpListenerEnabled || bindSettings.web.enabled;
|
|
383
|
+
|
|
384
|
+
if (
|
|
385
|
+
(bindSettings.daemonEnabled || bindSettings.controlPlane.enabled)
|
|
386
|
+
&& (!hasNetworkFacingSurface || bindSettings.controlPlane.hostMode !== 'local')
|
|
387
|
+
) {
|
|
388
|
+
activeModes.push(bindSettings.controlPlane.hostMode);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (bindSettings.httpListenerEnabled) {
|
|
392
|
+
activeModes.push(bindSettings.httpListener.hostMode);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (bindSettings.web.enabled) {
|
|
396
|
+
activeModes.push(bindSettings.web.hostMode);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return activeModes.some((mode) => mode !== 'network') ? 'custom' : 'local-network-default';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function deriveReopenEditAcknowledgementState(
|
|
403
|
+
snapshot: OnboardingSnapshotState,
|
|
404
|
+
): OnboardingReopenEditAcknowledgementState {
|
|
405
|
+
const providerAccounts = snapshot.providerAccounts?.providers ?? [];
|
|
406
|
+
const providerPendingCount = providerAccounts.filter((provider) => provider.pendingLogin).length;
|
|
407
|
+
const providerConfiguredCount = providerAccounts.filter((provider) => provider.activeRoute !== 'unconfigured' || provider.oauthReady).length;
|
|
408
|
+
const providerRoutingCustomized = hasCustomizedProviderRouting(snapshot);
|
|
409
|
+
const providerSignalCount = getConfiguredProviderSignalIds(snapshot).length;
|
|
410
|
+
|
|
411
|
+
const subscriptionsPendingCount = snapshot.subscriptions.pending.length;
|
|
412
|
+
const subscriptionsActiveCount = snapshot.subscriptions.active.length;
|
|
413
|
+
|
|
414
|
+
const authUserCount = snapshot.auth.snapshot.userCount;
|
|
415
|
+
const authSessionCount = snapshot.auth.snapshot.sessionCount;
|
|
416
|
+
const bootstrapCredentialPresent = snapshot.auth.snapshot.bootstrapCredentialPresent;
|
|
417
|
+
|
|
418
|
+
const providers = providerPendingCount > 0
|
|
419
|
+
? buildRequiredAcknowledgement(
|
|
420
|
+
snapshot,
|
|
421
|
+
'providers',
|
|
422
|
+
'pending-login',
|
|
423
|
+
`${providerPendingCount} provider login(s) are still pending completion.`,
|
|
424
|
+
)
|
|
425
|
+
: providerConfiguredCount > 0 || providerSignalCount > 0
|
|
426
|
+
? buildRequiredAcknowledgement(
|
|
427
|
+
snapshot,
|
|
428
|
+
'providers',
|
|
429
|
+
'configured-routing',
|
|
430
|
+
`${Math.max(providerConfiguredCount, providerSignalCount, 1)} provider auth path(s) are already configured.`,
|
|
431
|
+
)
|
|
432
|
+
: providerRoutingCustomized
|
|
433
|
+
? buildRequiredAcknowledgement(
|
|
434
|
+
snapshot,
|
|
435
|
+
'providers',
|
|
436
|
+
'customized-config',
|
|
437
|
+
'Provider routing already differs from the default shell configuration.',
|
|
438
|
+
)
|
|
439
|
+
: buildNotNeededAcknowledgement(snapshot, 'providers', 'No existing provider routing needs confirmation.');
|
|
440
|
+
|
|
441
|
+
const subscriptions = subscriptionsPendingCount > 0
|
|
442
|
+
? buildRequiredAcknowledgement(
|
|
443
|
+
snapshot,
|
|
444
|
+
'subscriptions',
|
|
445
|
+
'pending-login',
|
|
446
|
+
`${subscriptionsPendingCount} subscription login(s) are pending completion.`,
|
|
447
|
+
)
|
|
448
|
+
: subscriptionsActiveCount > 0
|
|
449
|
+
? buildRequiredAcknowledgement(
|
|
450
|
+
snapshot,
|
|
451
|
+
'subscriptions',
|
|
452
|
+
'subscription-state',
|
|
453
|
+
`${subscriptionsActiveCount} stored subscription session(s) already exist.`,
|
|
454
|
+
)
|
|
455
|
+
: buildNotNeededAcknowledgement(snapshot, 'subscriptions', 'No stored subscription sessions need confirmation.');
|
|
456
|
+
|
|
457
|
+
const auth = bootstrapCredentialPresent
|
|
458
|
+
? buildRequiredAcknowledgement(
|
|
459
|
+
snapshot,
|
|
460
|
+
'auth',
|
|
461
|
+
'bootstrap-credential',
|
|
462
|
+
'The local auth bootstrap credential file is still present.',
|
|
463
|
+
)
|
|
464
|
+
: authSessionCount > 0
|
|
465
|
+
? buildRequiredAcknowledgement(
|
|
466
|
+
snapshot,
|
|
467
|
+
'auth',
|
|
468
|
+
'active-sessions',
|
|
469
|
+
`${authSessionCount} local auth session(s) are currently active.`,
|
|
470
|
+
)
|
|
471
|
+
: authUserCount > 0
|
|
472
|
+
? buildRequiredAcknowledgement(
|
|
473
|
+
snapshot,
|
|
474
|
+
'auth',
|
|
475
|
+
'auth-state',
|
|
476
|
+
`${authUserCount} local auth user(s) are already configured.`,
|
|
477
|
+
)
|
|
478
|
+
: buildNotNeededAcknowledgement(snapshot, 'auth', 'No local auth state needs confirmation.');
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
providers,
|
|
482
|
+
subscriptions,
|
|
483
|
+
auth,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function deriveOnboardingStepState(
|
|
488
|
+
snapshot: OnboardingSnapshotState,
|
|
489
|
+
): OnboardingStepDerivationState {
|
|
490
|
+
return {
|
|
491
|
+
step1Capabilities: deriveStep1Capabilities(snapshot),
|
|
492
|
+
step1_5NetworkMode: deriveStep1_5NetworkMode(snapshot.bindSettings),
|
|
493
|
+
reopenEditAcknowledgements: deriveReopenEditAcknowledgementState(snapshot),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import type { ShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
|
|
4
|
+
import type {
|
|
5
|
+
OnboardingCompletionMarkerPayload,
|
|
6
|
+
OnboardingCompletionMarkerScope,
|
|
7
|
+
OnboardingCompletionMarkerState,
|
|
8
|
+
OnboardingCompletionMarkersState,
|
|
9
|
+
WriteOnboardingCompletionMarkerOptions,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
const ONBOARDING_COMPLETION_MARKER_FILE = 'onboarding-complete.json';
|
|
13
|
+
|
|
14
|
+
type OnboardingShellPaths = Pick<
|
|
15
|
+
ShellPathService,
|
|
16
|
+
'workingDirectory' | 'resolveProjectPath' | 'resolveUserPath'
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
function resolveMarkerPath(
|
|
20
|
+
shellPaths: OnboardingShellPaths,
|
|
21
|
+
scope: OnboardingCompletionMarkerScope,
|
|
22
|
+
): string {
|
|
23
|
+
return scope === 'project'
|
|
24
|
+
? shellPaths.resolveProjectPath('tui', ONBOARDING_COMPLETION_MARKER_FILE)
|
|
25
|
+
: shellPaths.resolveUserPath('tui', ONBOARDING_COMPLETION_MARKER_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === 'object' && value !== null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isOnboardingMode(value: unknown): value is OnboardingCompletionMarkerPayload['mode'] {
|
|
33
|
+
return value === 'new' || value === 'edit' || value === 'reopen';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCompletionMarkerPayload(value: unknown): value is OnboardingCompletionMarkerPayload {
|
|
37
|
+
return isObject(value)
|
|
38
|
+
&& value.version === 1
|
|
39
|
+
&& typeof value.completedAt === 'number'
|
|
40
|
+
&& Number.isFinite(value.completedAt)
|
|
41
|
+
&& typeof value.updatedAt === 'number'
|
|
42
|
+
&& Number.isFinite(value.updatedAt)
|
|
43
|
+
&& typeof value.source === 'string'
|
|
44
|
+
&& (value.mode === undefined || isOnboardingMode(value.mode))
|
|
45
|
+
&& (value.workspaceRoot === undefined || typeof value.workspaceRoot === 'string');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildMissingMarkerState(
|
|
49
|
+
scope: OnboardingCompletionMarkerScope,
|
|
50
|
+
path: string,
|
|
51
|
+
): OnboardingCompletionMarkerState {
|
|
52
|
+
return {
|
|
53
|
+
scope,
|
|
54
|
+
path,
|
|
55
|
+
exists: false,
|
|
56
|
+
payload: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildParseErrorState(
|
|
61
|
+
scope: OnboardingCompletionMarkerScope,
|
|
62
|
+
path: string,
|
|
63
|
+
parseError: string,
|
|
64
|
+
): OnboardingCompletionMarkerState {
|
|
65
|
+
return {
|
|
66
|
+
scope,
|
|
67
|
+
path,
|
|
68
|
+
exists: true,
|
|
69
|
+
payload: null,
|
|
70
|
+
parseError,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickEffectiveMarker(
|
|
75
|
+
project: OnboardingCompletionMarkerState,
|
|
76
|
+
user: OnboardingCompletionMarkerState,
|
|
77
|
+
): OnboardingCompletionMarkerState | null {
|
|
78
|
+
if (project.payload) return project;
|
|
79
|
+
if (user.payload) return user;
|
|
80
|
+
if (project.exists) return project;
|
|
81
|
+
if (user.exists) return user;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getOnboardingCompletionMarkerPath(
|
|
86
|
+
shellPaths: OnboardingShellPaths,
|
|
87
|
+
scope: OnboardingCompletionMarkerScope = 'user',
|
|
88
|
+
): string {
|
|
89
|
+
return resolveMarkerPath(shellPaths, scope);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readOnboardingCompletionMarker(
|
|
93
|
+
shellPaths: OnboardingShellPaths,
|
|
94
|
+
scope: OnboardingCompletionMarkerScope = 'user',
|
|
95
|
+
): OnboardingCompletionMarkerState {
|
|
96
|
+
const path = resolveMarkerPath(shellPaths, scope);
|
|
97
|
+
if (!existsSync(path)) return buildMissingMarkerState(scope, path);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
101
|
+
if (!isCompletionMarkerPayload(parsed)) {
|
|
102
|
+
return buildParseErrorState(scope, path, 'Invalid onboarding completion marker payload.');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
scope,
|
|
107
|
+
path,
|
|
108
|
+
exists: true,
|
|
109
|
+
payload: parsed,
|
|
110
|
+
};
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const parseError = error instanceof Error ? error.message : String(error);
|
|
113
|
+
return buildParseErrorState(scope, path, parseError);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readOnboardingCompletionMarkers(
|
|
118
|
+
shellPaths: OnboardingShellPaths,
|
|
119
|
+
): OnboardingCompletionMarkersState {
|
|
120
|
+
const user = readOnboardingCompletionMarker(shellPaths, 'user');
|
|
121
|
+
const project = readOnboardingCompletionMarker(shellPaths, 'project');
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
user,
|
|
125
|
+
project,
|
|
126
|
+
effective: pickEffectiveMarker(project, user),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function writeOnboardingCompletionMarker(
|
|
131
|
+
shellPaths: OnboardingShellPaths,
|
|
132
|
+
options: WriteOnboardingCompletionMarkerOptions = {},
|
|
133
|
+
): OnboardingCompletionMarkerState {
|
|
134
|
+
const scope = options.scope ?? 'user';
|
|
135
|
+
const path = resolveMarkerPath(shellPaths, scope);
|
|
136
|
+
const completedAt = options.completedAt ?? Date.now();
|
|
137
|
+
const payload: OnboardingCompletionMarkerPayload = {
|
|
138
|
+
version: 1,
|
|
139
|
+
completedAt,
|
|
140
|
+
updatedAt: options.updatedAt ?? completedAt,
|
|
141
|
+
source: options.source ?? 'wizard',
|
|
142
|
+
...(options.mode ? { mode: options.mode } : {}),
|
|
143
|
+
...(options.workspaceRoot ?? shellPaths.workingDirectory
|
|
144
|
+
? { workspaceRoot: options.workspaceRoot ?? shellPaths.workingDirectory }
|
|
145
|
+
: {}),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
149
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
150
|
+
|
|
151
|
+
return readOnboardingCompletionMarker(shellPaths, scope);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function clearOnboardingCompletionMarker(
|
|
155
|
+
shellPaths: OnboardingShellPaths,
|
|
156
|
+
scope: OnboardingCompletionMarkerScope = 'user',
|
|
157
|
+
): OnboardingCompletionMarkerState {
|
|
158
|
+
const path = resolveMarkerPath(shellPaths, scope);
|
|
159
|
+
if (existsSync(path)) unlinkSync(path);
|
|
160
|
+
return buildMissingMarkerState(scope, path);
|
|
161
|
+
}
|