@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,685 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config/secret-refs';
|
|
4
|
+
import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
|
|
5
|
+
import {
|
|
6
|
+
clearOnboardingCompletionMarker,
|
|
7
|
+
getOnboardingCompletionMarkerPath,
|
|
8
|
+
readOnboardingCompletionMarker,
|
|
9
|
+
writeOnboardingCompletionMarker,
|
|
10
|
+
} from './markers.ts';
|
|
11
|
+
import {
|
|
12
|
+
getOnboardingRuntimeStatePath,
|
|
13
|
+
readOnboardingRuntimeState,
|
|
14
|
+
writeOnboardingAcknowledgementState,
|
|
15
|
+
} from './state.ts';
|
|
16
|
+
import { verifyOnboardingRequest } from './verify.ts';
|
|
17
|
+
import type {
|
|
18
|
+
OnboardingApplyDependencies,
|
|
19
|
+
OnboardingAppliedOperation,
|
|
20
|
+
OnboardingApplyError,
|
|
21
|
+
OnboardingApplyOperation,
|
|
22
|
+
OnboardingApplyRequest,
|
|
23
|
+
OnboardingApplyResult,
|
|
24
|
+
} from './types.ts';
|
|
25
|
+
|
|
26
|
+
function getNow(deps: Pick<OnboardingApplyDependencies, 'clock'>): number {
|
|
27
|
+
return deps.clock?.() ?? Date.now();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeCompletionSource(source: string): 'wizard' | 'command' | 'import' | 'unknown' {
|
|
31
|
+
if (source === 'wizard' || source === 'command' || source === 'import') return source;
|
|
32
|
+
return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonObject(path: string): Record<string, unknown> {
|
|
40
|
+
if (!existsSync(path)) return {};
|
|
41
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
42
|
+
if (!isPlainObject(parsed)) throw new Error(`Expected an object JSON payload at ${path}.`);
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeJsonObject(path: string, payload: Record<string, unknown>): void {
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setNestedValue(root: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
|
|
52
|
+
const parts = key.split('.');
|
|
53
|
+
const next = structuredClone(root);
|
|
54
|
+
let cursor: Record<string, unknown> = next;
|
|
55
|
+
|
|
56
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
57
|
+
const part = parts[index]!;
|
|
58
|
+
const existing = cursor[part];
|
|
59
|
+
if (!isPlainObject(existing)) cursor[part] = {};
|
|
60
|
+
cursor = cursor[part] as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
cursor[parts[parts.length - 1]!] = structuredClone(value);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type RollbackAction = () => Promise<void> | void;
|
|
68
|
+
|
|
69
|
+
interface BootstrapCredential {
|
|
70
|
+
readonly username: string;
|
|
71
|
+
readonly password: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PersistedAuthUser {
|
|
75
|
+
readonly username: string;
|
|
76
|
+
readonly passwordHash: string;
|
|
77
|
+
readonly roles?: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface MutableAuthManager {
|
|
81
|
+
readonly users?: Map<string, PersistedAuthUser>;
|
|
82
|
+
readonly sessions?: Map<string, { readonly token: string; readonly username: string; readonly expiresAt: number }>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function restoreFile(path: string, previous: string | null, reload?: () => void): void {
|
|
86
|
+
if (previous === null) {
|
|
87
|
+
if (existsSync(path)) unlinkSync(path);
|
|
88
|
+
} else {
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
+
writeFileSync(path, previous, 'utf-8');
|
|
91
|
+
}
|
|
92
|
+
reload?.();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseBootstrapCredential(content: string | null): BootstrapCredential | null {
|
|
96
|
+
if (content === null) return null;
|
|
97
|
+
let username = '';
|
|
98
|
+
let password = '';
|
|
99
|
+
for (const rawLine of content.split('\n')) {
|
|
100
|
+
const line = rawLine.trim();
|
|
101
|
+
if (line.startsWith('username=')) username = line.slice('username='.length);
|
|
102
|
+
if (line.startsWith('password=')) password = line.slice('password='.length);
|
|
103
|
+
}
|
|
104
|
+
return username.length > 0 && password.length > 0 ? { username, password } : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parsePersistedAuthUsers(content: string): readonly PersistedAuthUser[] {
|
|
108
|
+
const parsed = JSON.parse(content) as unknown;
|
|
109
|
+
if (!isPlainObject(parsed) || parsed.version !== 1 || !Array.isArray(parsed.users)) {
|
|
110
|
+
throw new Error('Expected a version 1 local auth user store.');
|
|
111
|
+
}
|
|
112
|
+
return parsed.users.filter((user): user is PersistedAuthUser => (
|
|
113
|
+
isPlainObject(user)
|
|
114
|
+
&& typeof user.username === 'string'
|
|
115
|
+
&& typeof user.passwordHash === 'string'
|
|
116
|
+
&& (user.roles === undefined || (Array.isArray(user.roles) && user.roles.every((role) => typeof role === 'string')))
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function snapshotFileRollback(path: string, reload?: () => void): RollbackAction {
|
|
121
|
+
const previous = existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
122
|
+
return () => restoreFile(path, previous, reload);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runRollbacks(rollbacks: readonly RollbackAction[]): Promise<readonly string[]> {
|
|
126
|
+
const errors: string[] = [];
|
|
127
|
+
for (const rollback of [...rollbacks].reverse()) {
|
|
128
|
+
try {
|
|
129
|
+
await rollback();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isGoodVibesSecretReferenceValue(value: string): boolean {
|
|
138
|
+
const normalized = value.trim();
|
|
139
|
+
return normalized.startsWith('goodvibes://secrets/') && isSecretRefInput(normalized);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
|
|
143
|
+
const normalized = value.trim();
|
|
144
|
+
return normalized.startsWith('goodvibes://') && !isGoodVibesSecretReferenceValue(normalized);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): void {
|
|
148
|
+
if (typeof operation.value === 'string' && isMalformedGoodVibesSecretReferenceValue(operation.value)) {
|
|
149
|
+
throw new Error(`Config key ${operation.key} only accepts goodvibes://secrets/... secret references.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const schema = CONFIG_SCHEMA.find((entry) => entry.key === operation.key);
|
|
153
|
+
if (!schema) {
|
|
154
|
+
const defaultValue = operation.key.split('.').reduce<unknown>((cursor, part) => (
|
|
155
|
+
isPlainObject(cursor) ? cursor[part] : undefined
|
|
156
|
+
), DEFAULT_CONFIG);
|
|
157
|
+
if (defaultValue === undefined) throw new Error(`Unknown config key: ${operation.key}`);
|
|
158
|
+
if (typeof defaultValue === 'boolean' && typeof operation.value !== 'boolean') {
|
|
159
|
+
throw new Error(`Config key ${operation.key} expects a boolean value.`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof defaultValue === 'number' && typeof operation.value !== 'number') {
|
|
162
|
+
throw new Error(`Config key ${operation.key} expects a numeric value.`);
|
|
163
|
+
}
|
|
164
|
+
if (typeof defaultValue === 'string' && typeof operation.value !== 'string') {
|
|
165
|
+
throw new Error(`Config key ${operation.key} expects a string value.`);
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const stringValue = typeof operation.value === 'string' ? operation.value : null;
|
|
170
|
+
|
|
171
|
+
if (schema.type === 'boolean' && typeof operation.value !== 'boolean') {
|
|
172
|
+
throw new Error(`Config key ${operation.key} expects a boolean value.`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (schema.type === 'number' && typeof operation.value !== 'number') {
|
|
176
|
+
throw new Error(`Config key ${operation.key} expects a numeric value.`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if ((schema.type === 'string' || schema.type === 'enum') && stringValue === null) {
|
|
180
|
+
throw new Error(`Config key ${operation.key} expects a string value.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (schema.type === 'enum' && schema.enumValues && stringValue !== null && !schema.enumValues.includes(stringValue)) {
|
|
184
|
+
throw new Error(`Invalid value for ${operation.key}: ${String(operation.value)}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (schema.validate && !schema.validate(operation.value)) {
|
|
188
|
+
throw new Error(`Invalid value for ${operation.key}: ${String(operation.value)}.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateSecretOperation(
|
|
193
|
+
deps: OnboardingApplyDependencies,
|
|
194
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-secret' }>,
|
|
195
|
+
): void {
|
|
196
|
+
if (!deps.secrets) throw new Error('Secret persistence is unavailable.');
|
|
197
|
+
if (operation.key.trim().length === 0) throw new Error('Secret key is required.');
|
|
198
|
+
if (operation.value.length === 0) throw new Error(`Secret value for ${operation.key} is required.`);
|
|
199
|
+
if (!operation.medium) throw new Error(`Secret storage medium for ${operation.key} is required.`);
|
|
200
|
+
if (isMalformedGoodVibesSecretReferenceValue(operation.value)) {
|
|
201
|
+
throw new Error(`Secret value for ${operation.key} only accepts goodvibes://secrets/... secret references.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function validateAuthOperation(
|
|
206
|
+
deps: OnboardingApplyDependencies,
|
|
207
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'ensure-auth-user' }>,
|
|
208
|
+
): void {
|
|
209
|
+
if (!deps.auth) throw new Error('Local auth management is unavailable.');
|
|
210
|
+
if (operation.username.trim().length === 0) throw new Error('Local auth username is required.');
|
|
211
|
+
if (operation.password.length === 0) throw new Error(`Local auth password for ${operation.username} is required.`);
|
|
212
|
+
const username = operation.username.trim();
|
|
213
|
+
const existing = deps.auth.inspect().users.find((user) => user.username === username);
|
|
214
|
+
const requiredRoles = operation.roles ?? ['admin'];
|
|
215
|
+
if (existing && !requiredRoles.every((role) => existing.roles.includes(role))) {
|
|
216
|
+
throw new Error(`Existing local auth user ${username} is missing required role(s): ${requiredRoles.join(', ')}.`);
|
|
217
|
+
}
|
|
218
|
+
if (existing && operation.retireBootstrapCredential) {
|
|
219
|
+
throw new Error('Replacing a bootstrap credential requires a new local admin username.');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function validateAcknowledgementOperation(
|
|
224
|
+
deps: OnboardingApplyDependencies,
|
|
225
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'acknowledge' }>,
|
|
226
|
+
): void {
|
|
227
|
+
if (typeof operation.acknowledged !== 'boolean') {
|
|
228
|
+
throw new Error(`${operation.target} acknowledgement must be boolean.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const state = readOnboardingRuntimeState(deps.shellPaths, deps.acknowledgementScope ?? 'project');
|
|
232
|
+
if (state.parseError) {
|
|
233
|
+
throw new Error(`Existing onboarding acknowledgement state could not be parsed: ${state.parseError}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function validateCompletionMarkerOperation(
|
|
238
|
+
deps: OnboardingApplyDependencies,
|
|
239
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-completion-marker' }>,
|
|
240
|
+
): void {
|
|
241
|
+
const marker = readOnboardingCompletionMarker(deps.shellPaths, operation.scope);
|
|
242
|
+
if (marker.parseError && !operation.completed) return;
|
|
243
|
+
if (marker.parseError) {
|
|
244
|
+
throw new Error(`Existing ${operation.scope} onboarding marker could not be parsed: ${marker.parseError}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function applyConfigOperation(
|
|
249
|
+
deps: OnboardingApplyDependencies,
|
|
250
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>,
|
|
251
|
+
): OnboardingAppliedOperation {
|
|
252
|
+
validateConfigValue(operation);
|
|
253
|
+
|
|
254
|
+
if ((operation.scope ?? 'global') === 'project') {
|
|
255
|
+
const path = deps.shellPaths.resolveProjectPath('tui', 'settings.json');
|
|
256
|
+
const existing = readJsonObject(path);
|
|
257
|
+
const updated = setNestedValue(existing, operation.key, operation.value);
|
|
258
|
+
writeJsonObject(path, updated);
|
|
259
|
+
deps.config.load();
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
kind: operation.kind,
|
|
263
|
+
summary: `Persisted ${operation.key} in project onboarding settings.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
deps.config.setDynamic(operation.key, operation.value);
|
|
268
|
+
return {
|
|
269
|
+
kind: operation.kind,
|
|
270
|
+
summary: `Updated ${operation.key} in global onboarding settings.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function applySecretOperation(
|
|
275
|
+
deps: OnboardingApplyDependencies,
|
|
276
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-secret' }>,
|
|
277
|
+
): Promise<OnboardingAppliedOperation> {
|
|
278
|
+
validateSecretOperation(deps, operation);
|
|
279
|
+
await deps.secrets!.set(operation.key, operation.value, {
|
|
280
|
+
scope: operation.scope ?? 'project',
|
|
281
|
+
...(operation.medium ? { medium: operation.medium } : {}),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
kind: operation.kind,
|
|
286
|
+
summary: `Stored ${operation.key} through the configured secret manager.`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function applyAuthOperation(
|
|
291
|
+
deps: OnboardingApplyDependencies,
|
|
292
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'ensure-auth-user' }>,
|
|
293
|
+
): OnboardingAppliedOperation {
|
|
294
|
+
validateAuthOperation(deps, operation);
|
|
295
|
+
const auth = deps.auth!;
|
|
296
|
+
const username = operation.username.trim();
|
|
297
|
+
const before = auth.inspect();
|
|
298
|
+
const existing = before.users.find((user) => user.username === username);
|
|
299
|
+
const bootstrapCredential = before.bootstrapCredentialPresent
|
|
300
|
+
? parseBootstrapCredential(readFileSync(before.bootstrapCredentialPath, 'utf-8'))
|
|
301
|
+
: null;
|
|
302
|
+
|
|
303
|
+
if (!existing) {
|
|
304
|
+
auth.addUser(username, operation.password, operation.roles ?? ['admin']);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (operation.retireBootstrapCredential) {
|
|
308
|
+
if (bootstrapCredential && bootstrapCredential.username !== username && auth.getUser(bootstrapCredential.username)) {
|
|
309
|
+
auth.deleteUser(bootstrapCredential.username);
|
|
310
|
+
}
|
|
311
|
+
auth.clearBootstrapCredentialFile();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (operation.createSession ?? true) {
|
|
315
|
+
auth.createSession(username);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
kind: operation.kind,
|
|
320
|
+
summary: existing
|
|
321
|
+
? `Verified local auth user ${username}.`
|
|
322
|
+
: `Created local auth user ${username}.`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function buildSecretRollbackAction(
|
|
327
|
+
deps: OnboardingApplyDependencies,
|
|
328
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-secret' }>,
|
|
329
|
+
): Promise<RollbackAction> {
|
|
330
|
+
validateSecretOperation(deps, operation);
|
|
331
|
+
const scope = operation.scope ?? 'project';
|
|
332
|
+
const review = await deps.secrets!.inspect();
|
|
333
|
+
const locations = review.locations.filter((entry) => entry.source.startsWith(`${scope}-`));
|
|
334
|
+
if (locations.length === 0) throw new Error(`Secret storage locations for ${scope} scope are unavailable.`);
|
|
335
|
+
const snapshots = locations.map((location) => ({
|
|
336
|
+
path: location.path,
|
|
337
|
+
previous: existsSync(location.path) ? readFileSync(location.path, 'utf-8') : null,
|
|
338
|
+
}));
|
|
339
|
+
return () => {
|
|
340
|
+
for (const snapshot of snapshots) restoreFile(snapshot.path, snapshot.previous);
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildAuthRollbackAction(
|
|
345
|
+
deps: OnboardingApplyDependencies,
|
|
346
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'ensure-auth-user' }>,
|
|
347
|
+
): RollbackAction {
|
|
348
|
+
validateAuthOperation(deps, operation);
|
|
349
|
+
const auth = deps.auth!;
|
|
350
|
+
const username = operation.username.trim();
|
|
351
|
+
const before = auth.inspect();
|
|
352
|
+
const existingUser = before.users.find((user) => user.username === username);
|
|
353
|
+
const existingSessionTokens = new Set(before.sessions
|
|
354
|
+
.filter((session) => session.username === username)
|
|
355
|
+
.map((session) => session.token));
|
|
356
|
+
const userStoreSnapshot = existsSync(before.userStorePath) ? readFileSync(before.userStorePath, 'utf-8') : null;
|
|
357
|
+
const bootstrapCredentialSnapshot = existsSync(before.bootstrapCredentialPath)
|
|
358
|
+
? readFileSync(before.bootstrapCredentialPath, 'utf-8')
|
|
359
|
+
: null;
|
|
360
|
+
const bootstrapCredential = parseBootstrapCredential(bootstrapCredentialSnapshot);
|
|
361
|
+
const beforeSessions = before.sessions.map((session) => ({ ...session }));
|
|
362
|
+
|
|
363
|
+
return () => {
|
|
364
|
+
const mutable = auth as unknown as MutableAuthManager;
|
|
365
|
+
|
|
366
|
+
for (const session of auth.inspect().sessions) {
|
|
367
|
+
if (session.username === username && !existingSessionTokens.has(session.token)) {
|
|
368
|
+
auth.revokeSession(session.token);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (bootstrapCredential && !auth.getUser(bootstrapCredential.username)) {
|
|
373
|
+
auth.addUser(bootstrapCredential.username, bootstrapCredential.password, ['admin']);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!existingUser && auth.getUser(username)) {
|
|
377
|
+
try {
|
|
378
|
+
auth.deleteUser(username);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (mutable.users instanceof Map) mutable.users.delete(username);
|
|
381
|
+
else throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
restoreFile(before.bootstrapCredentialPath, bootstrapCredentialSnapshot);
|
|
386
|
+
restoreFile(before.userStorePath, userStoreSnapshot);
|
|
387
|
+
|
|
388
|
+
if (mutable.users instanceof Map) {
|
|
389
|
+
if (userStoreSnapshot === null) {
|
|
390
|
+
if (before.users.length === 0) mutable.users.clear();
|
|
391
|
+
} else {
|
|
392
|
+
mutable.users.clear();
|
|
393
|
+
for (const user of parsePersistedAuthUsers(userStoreSnapshot)) mutable.users.set(user.username, user);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (mutable.sessions instanceof Map) {
|
|
398
|
+
mutable.sessions.clear();
|
|
399
|
+
for (const session of beforeSessions) mutable.sessions.set(session.token, session);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function buildRollbackAction(
|
|
405
|
+
deps: OnboardingApplyDependencies,
|
|
406
|
+
operation: OnboardingApplyOperation,
|
|
407
|
+
): Promise<RollbackAction> {
|
|
408
|
+
if (operation.kind === 'set-config') {
|
|
409
|
+
if ((operation.scope ?? 'global') === 'project') {
|
|
410
|
+
return snapshotFileRollback(
|
|
411
|
+
deps.shellPaths.resolveProjectPath('tui', 'settings.json'),
|
|
412
|
+
() => deps.config.load(),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const previous = deps.config.get(operation.key);
|
|
417
|
+
return () => {
|
|
418
|
+
deps.config.setDynamic(operation.key, previous);
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (operation.kind === 'set-secret') {
|
|
423
|
+
return buildSecretRollbackAction(deps, operation);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (operation.kind === 'ensure-auth-user') {
|
|
427
|
+
return buildAuthRollbackAction(deps, operation);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (operation.kind === 'acknowledge') {
|
|
431
|
+
return snapshotFileRollback(
|
|
432
|
+
getOnboardingRuntimeStatePath(deps.shellPaths, deps.acknowledgementScope ?? 'project'),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return snapshotFileRollback(
|
|
437
|
+
getOnboardingCompletionMarkerPath(deps.shellPaths, operation.scope),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function applyAcknowledgementOperation(
|
|
442
|
+
deps: OnboardingApplyDependencies,
|
|
443
|
+
request: OnboardingApplyRequest,
|
|
444
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'acknowledge' }>,
|
|
445
|
+
): OnboardingAppliedOperation {
|
|
446
|
+
writeOnboardingAcknowledgementState(deps.shellPaths, {
|
|
447
|
+
scope: deps.acknowledgementScope ?? 'project',
|
|
448
|
+
target: operation.target,
|
|
449
|
+
acknowledged: operation.acknowledged,
|
|
450
|
+
updatedAt: getNow(deps),
|
|
451
|
+
source: request.source,
|
|
452
|
+
mode: request.mode,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
kind: operation.kind,
|
|
457
|
+
summary: `${operation.target} acknowledgement set to ${operation.acknowledged ? 'accepted' : 'pending'}.`,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function applyCompletionMarkerOperation(
|
|
462
|
+
deps: OnboardingApplyDependencies,
|
|
463
|
+
request: OnboardingApplyRequest,
|
|
464
|
+
operation: Extract<OnboardingApplyOperation, { kind: 'set-completion-marker' }>,
|
|
465
|
+
): OnboardingAppliedOperation {
|
|
466
|
+
if (!operation.completed) {
|
|
467
|
+
clearOnboardingCompletionMarker(deps.shellPaths, operation.scope);
|
|
468
|
+
return {
|
|
469
|
+
kind: operation.kind,
|
|
470
|
+
summary: `Cleared ${operation.scope} onboarding completion marker.`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const updatedAt = getNow(deps);
|
|
475
|
+
writeOnboardingCompletionMarker(deps.shellPaths, {
|
|
476
|
+
scope: operation.scope,
|
|
477
|
+
completedAt: operation.payload?.completedAt,
|
|
478
|
+
updatedAt: operation.payload?.updatedAt ?? updatedAt,
|
|
479
|
+
source: operation.payload?.source ?? normalizeCompletionSource(request.source),
|
|
480
|
+
mode: operation.payload?.mode ?? request.mode,
|
|
481
|
+
workspaceRoot: operation.payload?.workspaceRoot,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
kind: operation.kind,
|
|
486
|
+
summary: `Wrote ${operation.scope} onboarding completion marker.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function orderApplyOperations(
|
|
491
|
+
operations: readonly OnboardingApplyOperation[],
|
|
492
|
+
): readonly OnboardingApplyOperation[] {
|
|
493
|
+
const secretPolicyOperations = operations.filter((operation) => (
|
|
494
|
+
operation.kind === 'set-config' && operation.key === 'storage.secretPolicy'
|
|
495
|
+
));
|
|
496
|
+
const authOperations = operations.filter((operation) => operation.kind === 'ensure-auth-user');
|
|
497
|
+
const secretOperations = operations.filter((operation) => operation.kind === 'set-secret');
|
|
498
|
+
const configOperations = operations.filter((operation) => (
|
|
499
|
+
operation.kind === 'set-config' && operation.key !== 'storage.secretPolicy'
|
|
500
|
+
));
|
|
501
|
+
const finalOperations = operations.filter((operation) => (
|
|
502
|
+
operation.kind === 'acknowledge' || operation.kind === 'set-completion-marker'
|
|
503
|
+
));
|
|
504
|
+
|
|
505
|
+
return [
|
|
506
|
+
...secretPolicyOperations,
|
|
507
|
+
...authOperations,
|
|
508
|
+
...secretOperations,
|
|
509
|
+
...configOperations,
|
|
510
|
+
...finalOperations,
|
|
511
|
+
];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function prevalidateApplyRequest(
|
|
515
|
+
deps: OnboardingApplyDependencies,
|
|
516
|
+
request: OnboardingApplyRequest,
|
|
517
|
+
): OnboardingApplyError[] {
|
|
518
|
+
const errors: OnboardingApplyError[] = [];
|
|
519
|
+
const orderedOperations = orderApplyOperations(request.operations);
|
|
520
|
+
|
|
521
|
+
for (const operation of orderedOperations) {
|
|
522
|
+
try {
|
|
523
|
+
if (operation.kind === 'set-config') {
|
|
524
|
+
validateConfigValue(operation);
|
|
525
|
+
if ((operation.scope ?? 'global') === 'project') {
|
|
526
|
+
readJsonObject(deps.shellPaths.resolveProjectPath('tui', 'settings.json'));
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (operation.kind === 'set-secret') {
|
|
532
|
+
validateSecretOperation(deps, operation);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (operation.kind === 'ensure-auth-user') {
|
|
537
|
+
validateAuthOperation(deps, operation);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (operation.kind === 'acknowledge') {
|
|
542
|
+
validateAcknowledgementOperation(deps, operation);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
validateCompletionMarkerOperation(deps, operation);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
errors.push({
|
|
549
|
+
kind: operation.kind,
|
|
550
|
+
message: error instanceof Error ? error.message : String(error),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return errors;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function getVerificationFailureKind(itemId: string): OnboardingApplyOperation['kind'] {
|
|
559
|
+
if (itemId.startsWith('config:')) return 'set-config';
|
|
560
|
+
if (itemId.startsWith('secret:')) return 'set-secret';
|
|
561
|
+
if (itemId.startsWith('auth:')) return 'ensure-auth-user';
|
|
562
|
+
if (itemId.startsWith('acknowledge:')) return 'acknowledge';
|
|
563
|
+
if (itemId.startsWith('marker:')) return 'set-completion-marker';
|
|
564
|
+
return 'set-config';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function splitCompletionMarkerOperations(
|
|
568
|
+
operations: readonly OnboardingApplyOperation[],
|
|
569
|
+
): {
|
|
570
|
+
readonly preMarkerOperations: readonly OnboardingApplyOperation[];
|
|
571
|
+
readonly markerOperations: readonly OnboardingApplyOperation[];
|
|
572
|
+
} {
|
|
573
|
+
return {
|
|
574
|
+
preMarkerOperations: operations.filter((operation) => operation.kind !== 'set-completion-marker'),
|
|
575
|
+
markerOperations: operations.filter((operation) => operation.kind === 'set-completion-marker'),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export async function applyOnboardingRequest(
|
|
580
|
+
deps: OnboardingApplyDependencies,
|
|
581
|
+
request: OnboardingApplyRequest,
|
|
582
|
+
): Promise<OnboardingApplyResult> {
|
|
583
|
+
const applied: OnboardingAppliedOperation[] = [];
|
|
584
|
+
const skipped: never[] = [];
|
|
585
|
+
const errors: OnboardingApplyError[] = prevalidateApplyRequest(deps, request);
|
|
586
|
+
if (errors.length > 0) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
applied,
|
|
590
|
+
skipped,
|
|
591
|
+
errors,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const orderedOperations = orderApplyOperations(request.operations);
|
|
596
|
+
const { preMarkerOperations, markerOperations } = splitCompletionMarkerOperations(orderedOperations);
|
|
597
|
+
const rollbacks: RollbackAction[] = [];
|
|
598
|
+
|
|
599
|
+
const applyOperations = async (operations: readonly OnboardingApplyOperation[]): Promise<boolean> => {
|
|
600
|
+
for (const operation of operations) {
|
|
601
|
+
let rollback: RollbackAction = () => {};
|
|
602
|
+
try {
|
|
603
|
+
rollback = await buildRollbackAction(deps, operation);
|
|
604
|
+
if (operation.kind === 'set-config') {
|
|
605
|
+
applied.push(applyConfigOperation(deps, operation));
|
|
606
|
+
rollbacks.push(rollback);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (operation.kind === 'set-secret') {
|
|
611
|
+
applied.push(await applySecretOperation(deps, operation));
|
|
612
|
+
rollbacks.push(rollback);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (operation.kind === 'ensure-auth-user') {
|
|
617
|
+
applied.push(applyAuthOperation(deps, operation));
|
|
618
|
+
rollbacks.push(rollback);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (operation.kind === 'acknowledge') {
|
|
623
|
+
applied.push(applyAcknowledgementOperation(deps, request, operation));
|
|
624
|
+
rollbacks.push(rollback);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
applied.push(applyCompletionMarkerOperation(deps, request, operation));
|
|
629
|
+
rollbacks.push(rollback);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
const rollbackErrors = await runRollbacks([...rollbacks, rollback]);
|
|
632
|
+
applied.length = 0;
|
|
633
|
+
errors.push({
|
|
634
|
+
kind: operation.kind,
|
|
635
|
+
message: [
|
|
636
|
+
error instanceof Error ? error.message : String(error),
|
|
637
|
+
...rollbackErrors.map((rollbackError) => `rollback: ${rollbackError}`),
|
|
638
|
+
].join('; '),
|
|
639
|
+
});
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const verifyOrRollback = async (operations: readonly OnboardingApplyOperation[]): Promise<boolean> => {
|
|
647
|
+
const verification = await verifyOnboardingRequest(deps, { ...request, operations });
|
|
648
|
+
const failures = verification.items.filter((item) => item.status !== 'pass');
|
|
649
|
+
if (failures.length === 0) return true;
|
|
650
|
+
|
|
651
|
+
const rollbackErrors = await runRollbacks(rollbacks);
|
|
652
|
+
applied.length = 0;
|
|
653
|
+
errors.push(...failures.map((item, index) => ({
|
|
654
|
+
kind: getVerificationFailureKind(item.id),
|
|
655
|
+
message: [
|
|
656
|
+
`verify ${item.id}: ${item.message}`,
|
|
657
|
+
...(index === 0 ? rollbackErrors.map((rollbackError) => `rollback: ${rollbackError}`) : []),
|
|
658
|
+
].join('; '),
|
|
659
|
+
})));
|
|
660
|
+
return false;
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
if (!await applyOperations(preMarkerOperations)) {
|
|
664
|
+
return { ok: false, applied, skipped, errors };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (!await verifyOrRollback(preMarkerOperations)) {
|
|
668
|
+
return { ok: false, applied, skipped, errors };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (!await applyOperations(markerOperations)) {
|
|
672
|
+
return { ok: false, applied, skipped, errors };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (!await verifyOrRollback(request.operations)) {
|
|
676
|
+
return { ok: false, applied, skipped, errors };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
ok: errors.length === 0,
|
|
681
|
+
applied,
|
|
682
|
+
skipped,
|
|
683
|
+
errors,
|
|
684
|
+
};
|
|
685
|
+
}
|