feat: guided setup — persona re-audit fixes (grade-1 round)

- Regulated path finishes without a terminal: the signed-source
  state offers 'Add a signed source…' (stores dialog with pin-a-key)
  plus the per-module install buttons and a plain-language hint why
  pinning the publisher's key matters — instead of a hint with no
  affordance.
- Apply warnings (e.g. the empty-trusted-publishers caveat) surface
  selectable in the done state instead of being swallowed.
- Truthful preview: new lines state which machine is being set up
  (server/container targets configure THIS machine), that regulated
  profiles always get the hash-chained audit log (even with WORM
  off), and that the curated reading list is stored with the setup
  record.
- Language pass: onboarding checklist in Sie-form + 'System-KI'
  (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced,
  answers file moved to a private per-dialog temp dir.
- Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity
  proof runs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-13 01:20:59 +02:00
parent cf4024a4e2
commit ddac84ce8e
9 changed files with 308 additions and 33 deletions

View file

@ -21,6 +21,7 @@ import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/tokens.dart';
import 'chain_stores_dialog.dart';
/// Allowed wire values per answer an AI suggestion is validated
/// against these; anything else is rejected as a parse failure so a
@ -238,6 +239,11 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
@override
void dispose() {
_goalCtl.dispose();
try {
_answersDir?.deleteSync(recursive: true);
} on FileSystemException {
// Best-effort cleanup; the OS temp reaper covers the rest.
}
super.dispose();
}
@ -265,8 +271,16 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
'data_must_stay_local: $_dataLocal\n'
'allow_unsigned_modules: $_allowUnsigned\n';
/// Per-dialog private temp dir for the answers file a fixed
/// name in the shared system temp would be world-readable and
/// racy on multi-user machines. Cleaned up in [dispose].
Directory? _answersDir;
Future<String> _writeAnswers() async {
final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml');
_answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
final f = File(
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
);
await f.writeAsString(_answersYaml());
return f.path;
}
@ -304,6 +318,11 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
}
}
/// Warning lines the apply emitted on success (e.g. the empty
/// trusted_publishers caveat). Swallowing them made the wizard
/// claim more than the config delivers show them instead.
String _applyWarnings = '';
Future<void> _apply() async {
setState(() => _busy = true);
final path = await _writeAnswers();
@ -313,7 +332,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
if (!mounted) return;
setState(() => _busy = false);
if (r.ok) {
setState(() => _applied = true);
final warnings = r.stderr
.split('\n')
.where((line) => line.toLowerCase().contains('warn'))
.join('\n')
.trim();
setState(() {
_applied = true;
_applyWarnings = warnings;
});
unawaited(_probeHub());
} else {
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
@ -736,13 +763,24 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
label: Text(l.setupNextHubStart),
),
),
// 2 the plan's modules.
// 2 the plan's modules. On a signature-strict plan the path
// stays clickable: add a signed source (the stores dialog with
// its pin-a-key field), then install no terminal, no dead end.
if (modules.isNotEmpty) ...[
if (airGapped)
_hintRow(l.setupModulesOfflineHint)
else if (sigGate)
_hintRow(l.setupModulesSignedHint)
else ...[
if (sigGate) ...[
_hintRow(l.setupModulesSignedHint),
_hintRow(l.setupTrustedPublishersHint),
_actionRow(
OutlinedButton.icon(
onPressed: () => ChainStoresDialog.show(context),
icon: const Icon(Icons.add_moderator_outlined, size: 18),
label: Text(l.setupAddSignedSource),
),
),
],
if (!_hubUp) _hintRow(l.setupStartHubFirst),
for (final m in modules)
_installed.contains(m)
@ -776,6 +814,20 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
label: Text(l.setupActionOpenFlow(flow)),
),
),
// Honesty: warnings the apply emitted (e.g. the empty
// trusted-publishers caveat), selectable so they can go
// verbatim into a compliance note.
if (_applyWarnings.isNotEmpty) ...[
const SizedBox(height: ChainSpace.sm),
Text(l.setupApplyNotes, style: theme.textTheme.labelLarge),
const SizedBox(height: 4),
SelectableText(
_applyWarnings,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
];
}
@ -823,8 +875,9 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
/// Build the localized plain-language plan lines from the structured
/// SetupPlan (never the CLI prose).
List<String> _planLines(AppLocalizations l, Map<String, dynamic> plan) {
final profile = plan['profile'] as String? ?? '';
final lines = <String>[];
switch (plan['profile'] as String? ?? '') {
switch (profile) {
case 'air-gapped':
lines.add(l.setupPlanIntroAirgapped);
case 'enterprise':
@ -832,13 +885,34 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
default:
lines.add(l.setupPlanIntroDev);
}
// Where this actually lands: the wizard always configures the
// machine Studio runs on say so, especially when the operator
// picked a server/container target.
switch (plan['runbook'] as String? ?? '') {
case 'service':
lines.add(l.setupPlanRunbookService);
case 'air-gap-transfer':
lines.add(l.setupPlanRunbookAirgap);
case 'container':
lines.add(l.setupPlanRunbookContainer);
default:
lines.add(l.setupPlanRunbookLocal);
}
if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures);
// The audit chain is always on for regulated profiles the
// scenario card promises a tamper-evident log, so the preview
// states what is delivered (and the WORM line covers the rest).
if (profile == 'enterprise' || profile == 'air-gapped') {
lines.add(l.setupPlanAuditChain);
}
if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm);
if (plan['approval_step'] == true) lines.add(l.setupPlanApproval);
final modules = (plan['modules'] as List?)?.cast<String>() ?? const [];
if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', ')));
final flow = plan['starter_flow'] as String? ?? '';
if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow));
final docs = (plan['curated_docs'] as List?)?.length ?? 0;
if (docs > 0) lines.add(l.setupPlanDocs(docs));
lines.add(l.setupPlanFileChanged('~/.chain/config.yaml'));
return lines;
}