feat(setup): first-run gate, hub-first preview, honest wording — setup before the app
Some checks are pending
Security / Security check (push) Waiting to run

Stefan's live findings, all four addressed at the root:

- 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly
  'Einrichtung starten' / 'Start setup'.
- The setup button sat permanently on the Welcome page of a running
  app ('setup after the app runs is backwards' — reported twice). A
  fresh install now starts INSIDE the setup: SetupGateScreen hosts
  the wizard embedded as the page (new embedded/onFinished modes on
  GuidedSetupDialog), with an explicit 'Später einrichten' skip.
  Welcome loses the setup button entirely and stays a calm intro.
- Re-running the setup later lives in Settings → General ('Run setup
  again…'), the single post-first-run home.
- 'You must grant access first and only then see what will be done':
  the preview used to spawn the chain CLI, whose first run could pop
  the macOS permission prompt BEFORE the plan was ever shown. The
  preview now calls the new PlanSetup RPC over the live hub
  connection (no subprocess, nothing granted); the CLI remains only
  a fallback when no hub is reachable — and applying stays the
  explicit, separate step.

Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path
tests drive the fallback through the new hub-preview test seam.
Suite 76 green, analyze clean.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-17 00:59:22 +02:00
parent 06f023aada
commit 065939be74
13 changed files with 377 additions and 62 deletions

View file

@ -259,6 +259,41 @@ class HubService {
/// hub is up and needs a token fix, not an endpoint fix.
Future<HubProbeResult> probeHealth() => _client.probe();
/// Assemble the guided-setup plan over the live hub connection
/// a pure computation, nothing applied. Returned in exactly the
/// `chain init --plan-json` map shape so the wizard renders the
/// same preview from either source. Using the hub avoids spawning
/// the CLI for the preview: on macOS that subprocess can be the
/// app's first file-system touch and pop a permission prompt
/// BEFORE the operator saw what would be set up.
Future<Map<String, dynamic>> planSetup({
required String scenario,
required String intent,
required String target,
bool requireApproval = false,
bool dataMustStayLocal = false,
bool allowUnsignedModules = false,
}) async {
final r = await _client.planSetup(
scenario: scenario,
intent: intent,
target: target,
requireApproval: requireApproval,
dataMustStayLocal: dataMustStayLocal,
allowUnsignedModules: allowUnsignedModules,
);
return {
'profile': r.profile,
'modules': List<String>.from(r.modules),
'starter_flow': r.starterFlow,
'runbook': r.runbook,
'curated_docs': List<String>.from(r.curatedDocs),
'require_signatures': r.requireSignatures,
'worm_audit': r.wormAudit,
'approval_step': r.approvalStep,
};
}
/// Configured module stores (+ the bundled seed) for the store manager.
Future<List<StoreSource>> listStores() => _client.listStores();

View file

@ -1791,7 +1791,11 @@
"setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.",
"setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben",
"setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.",
"setupStartCta": "In 3 Fragen loslegen",
"setupStartCta": "Einrichtung starten",
"setupGateIntro": "Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.",
"setupGateSkip": "Später einrichten",
"settingsRunSetup": "Einrichtung erneut ausführen…",
"settingsRunSetupHint": "Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.",
"setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.",
"setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.",
"setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.",

View file

@ -1830,7 +1830,11 @@
"setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.",
"setupAllowUnsigned": "Allow installing from the public store",
"setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.",
"setupStartCta": "Get started in 3 questions",
"setupStartCta": "Start setup",
"setupGateIntro": "Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.",
"setupGateSkip": "Set up later",
"settingsRunSetup": "Run setup again…",
"settingsRunSetupHint": "Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.",
"setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.",
"setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.",
"setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.",

View file

@ -5524,9 +5524,33 @@ abstract class AppLocalizations {
/// No description provided for @setupStartCta.
///
/// In en, this message translates to:
/// **'Get started in 3 questions'**
/// **'Start setup'**
String get setupStartCta;
/// No description provided for @setupGateIntro.
///
/// In en, this message translates to:
/// **'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.'**
String get setupGateIntro;
/// No description provided for @setupGateSkip.
///
/// In en, this message translates to:
/// **'Set up later'**
String get setupGateSkip;
/// No description provided for @settingsRunSetup.
///
/// In en, this message translates to:
/// **'Run setup again…'**
String get settingsRunSetup;
/// No description provided for @settingsRunSetupHint.
///
/// In en, this message translates to:
/// **'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.'**
String get settingsRunSetupHint;
/// No description provided for @setupPlanRunbookLocal.
///
/// In en, this message translates to:

View file

@ -3270,7 +3270,21 @@ class AppLocalizationsDe extends AppLocalizations {
'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.';
@override
String get setupStartCta => 'In 3 Fragen loslegen';
String get setupStartCta => 'Einrichtung starten';
@override
String get setupGateIntro =>
'Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.';
@override
String get setupGateSkip => 'Später einrichten';
@override
String get settingsRunSetup => 'Einrichtung erneut ausführen…';
@override
String get settingsRunSetupHint =>
'Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.';
@override
String get setupPlanRunbookLocal =>

View file

@ -3265,7 +3265,21 @@ class AppLocalizationsEn extends AppLocalizations {
'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.';
@override
String get setupStartCta => 'Get started in 3 questions';
String get setupStartCta => 'Start setup';
@override
String get setupGateIntro =>
'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.';
@override
String get setupGateSkip => 'Set up later';
@override
String get settingsRunSetup => 'Run setup again…';
@override
String get settingsRunSetupHint =>
'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.';
@override
String get setupPlanRunbookLocal =>

View file

@ -17,6 +17,8 @@ import 'data/system_actions.dart';
import 'data/theme_plugin.dart';
import 'l10n/app_localizations.dart';
import 'pages/approvals.dart';
import 'pages/setup_gate.dart';
import 'widgets/guided_setup_dialog.dart';
import 'pages/audit.dart';
import 'pages/doctor.dart';
import 'pages/federation.dart';
@ -114,6 +116,13 @@ class StudioApp extends StatefulWidget {
}
class StudioAppState extends State<StudioApp> {
/// First-run gate: on a fresh install (no config, no recorded
/// setup plan) the app starts INSIDE the setup instead of layering
/// a dialog over a shell that isn't set up yet. Flips to true when
/// the operator finishes or skips; also true from the start on any
/// already-configured machine.
bool _setupGateDone = !GuidedSetupDialog.isFreshInstall();
/// Exposed so any descendant can listen via
/// `ValueListenableBuilder` and rebuild instantly when the
/// theme flips. setState alone wouldn't propagate through
@ -263,9 +272,14 @@ class StudioAppState extends State<StudioApp> {
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: StudioShell(
startSidebarExpanded: widget.startSidebarExpanded,
),
home: _setupGateDone
? StudioShell(
startSidebarExpanded: widget.startSidebarExpanded,
)
: SetupGateScreen(
onDone: () =>
setState(() => _setupGateDone = true),
),
);
},
),

81
lib/pages/setup_gate.dart Normal file
View file

@ -0,0 +1,81 @@
// First-run gate. On a fresh install (no ~/.chain/config.yaml, no
// setup-plan.yaml) the app opens INTO the setup instead of showing
// the full shell with a wizard dialog on top setup before the
// app, not after. The guided-setup wizard renders embedded as the
// page content; "Später einrichten" skips into the app for this
// run (the gate returns on the next launch as long as nothing is
// configured the wizard IS the onboarding until then).
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
import '../widgets/guided_setup_dialog.dart';
class SetupGateScreen extends StatelessWidget {
/// Called when the operator finishes the setup or skips the
/// app swaps this screen for the normal shell.
final VoidCallback onDone;
const SetupGateScreen({super.key, required this.onDone});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(ChainSpace.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ch∆In Studio',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: ChainSpace.xs),
Text(
l.setupGateIntro,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: ChainSpace.lg),
Container(
padding: const EdgeInsets.all(ChainSpace.xl),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(ChainRadius.md),
border: Border.all(
color: theme.colorScheme.outlineVariant,
),
),
child: GuidedSetupDialog(
embedded: true,
onFinished: onDone,
),
),
const SizedBox(height: ChainSpace.sm),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: onDone,
child: Text(l.setupGateSkip),
),
),
],
),
),
),
),
);
}
}

View file

@ -30,35 +30,10 @@ class WelcomePage extends StatefulWidget {
}
class _WelcomePageState extends State<WelcomePage> {
/// Bumped when the guided-setup dialog closes so the onboarding
/// checklist remounts and re-probes the assistant may have just
/// installed the modules the checklist looks for.
int _checklistEpoch = 0;
/// One auto-open per app run: a fresh install that dismisses the
/// wizard should not have it spring back on every rebuild.
static bool _autoOpenedThisRun = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen());
}
/// On a fresh hub (no config, no recorded setup plan) the wizard
/// IS the onboarding open it rather than hoping the operator
/// finds the button. Once a setup exists it steps back for good.
Future<void> _maybeAutoOpen() async {
if (_autoOpenedThisRun || !mounted) return;
if (!GuidedSetupDialog.isFreshInstall()) return;
_autoOpenedThisRun = true;
await _openSetup();
}
Future<void> _openSetup() async {
await GuidedSetupDialog.show(context);
if (mounted) setState(() => _checklistEpoch++);
}
// Setup no longer lives on this page. A fresh install starts in
// the SetupGateScreen (setup BEFORE the app, not a dialog over
// it), and re-running the setup later is a Settings action
// Welcome stays a calm introduction, not a second setup surface.
@override
Widget build(BuildContext context) {
@ -95,19 +70,6 @@ class _WelcomePageState extends State<WelcomePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Hero(),
const SizedBox(height: ChainSpace.md),
Align(
alignment: Alignment.centerLeft,
// High-emphasis: the guided setup is the fastest path
// to a working first run, so lead with a filled button
// rather than a low-key outlined one. Honest framing:
// "get started in 3 questions", not tool-speak.
child: FilledButton.icon(
onPressed: _openSetup,
icon: const Icon(Icons.auto_fix_high),
label: Text(l.setupStartCta),
),
),
const SizedBox(height: ChainSpace.xxl),
// When the hub is down the onboarding checklist
// would render dead (all-unchecked, nothing to
@ -117,7 +79,7 @@ class _WelcomePageState extends State<WelcomePage> {
const _HubDownHero(),
const SizedBox(height: ChainSpace.xxl),
] else
_OnboardingChecklist(key: ValueKey(_checklistEpoch)),
const _OnboardingChecklist(),
const _PillarRow(),
const SizedBox(height: ChainSpace.xxl),
const _SectionLabel(textKey: _SectionLabelKey.trust),
@ -551,7 +513,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed';
/// parallel; failures stay false and the checklist still
/// renders (with the "Refresh" button for retry).
class _OnboardingChecklist extends StatefulWidget {
const _OnboardingChecklist({super.key});
const _OnboardingChecklist();
@override
State<_OnboardingChecklist> createState() => _OnboardingChecklistState();

View file

@ -15,6 +15,7 @@ import '../pages/welcome.dart' show showFaiDoc;
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'chain_error_box.dart';
import 'guided_setup_dialog.dart';
import 'chain_pill.dart';
import 'chain_system_ai_editor.dart';
import 'hub_auth_policy_panel.dart';
@ -572,6 +573,26 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
],
const SizedBox(height: ChainSpace.xl),
const _DefaultScopePanel(),
const SizedBox(height: ChainSpace.xl),
// Re-run the guided setup. This is the ONLY place it lives
// after first run the first-run gate owns the fresh-install
// case, and Welcome stays a calm introduction instead of a
// permanent setup surface.
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: () => GuidedSetupDialog.show(context),
icon: const Icon(Icons.auto_fix_high, size: 18),
label: Text(l.settingsRunSetup),
),
),
const SizedBox(height: 4),
Text(
l.settingsRunSetupHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
];
}

View file

@ -109,12 +109,29 @@ class GuidedSetupDialog extends StatefulWidget {
@visibleForTesting
final bool debugSkipAiProbe;
/// Test seam: replaces the hub-first plan preview (a live gRPC
/// call) so widget tests can drive the CLI fallback or return a
/// canned plan without a hub.
@visibleForTesting
static Future<Map<String, dynamic>> Function()? debugPlanViaHubOverride;
/// Render as page content instead of an [AlertDialog] used by
/// the first-run gate, where the wizard IS the screen.
final bool embedded;
/// Called instead of popping a dialog route when the wizard is
/// [embedded] (the gate owns what comes next). Also invoked after
/// a completed setup so the gate can enter the app.
final VoidCallback? onFinished;
const GuidedSetupDialog({
super.key,
this.shell,
this.debugPlan,
this.debugApplied = false,
this.debugSkipAiProbe = false,
this.embedded = false,
this.onFinished,
});
static Future<void> show(BuildContext context) {
@ -309,6 +326,33 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_busy = true;
_plan = null;
});
// Preview order matters for trust: try the LIVE HUB first a
// pure PlanSetup RPC over the existing connection, no subprocess.
// The CLI fallback (hub down, first run without a daemon) spawns
// `chain`, which on macOS can be the app's very first file-system
// touch and pop a permission prompt; that must never happen
// BEFORE the operator has seen the plan when a hub is available.
try {
final plan = GuidedSetupDialog.debugPlanViaHubOverride != null
? await GuidedSetupDialog.debugPlanViaHubOverride!()
: await HubService.instance.planSetup(
scenario: _scenario,
intent: _intent,
target: _target,
requireApproval: _requireApproval,
dataMustStayLocal: _dataLocal,
allowUnsignedModules: _allowUnsigned,
);
if (!mounted) return;
setState(() {
_busy = false;
_plan = plan;
_step = _totalSteps; // review
});
return;
} catch (_) {
// Hub unreachable / RPC unavailable fall through to the CLI.
}
final path = _writeAnswers();
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
if (!mounted) return;
@ -536,6 +580,18 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
}
}
/// Leave the wizard: pop the dialog route, or embedded full
/// screen in the first-run gate, where there is no route to pop
/// hand control back to the gate.
void _close() {
final onFinished = widget.onFinished;
if (onFinished != null) {
onFinished();
} else {
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
@ -545,17 +601,43 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
: _showReflection
? l.setupReflectionTitle
: _stepTitle(l);
final body = SingleChildScrollView(
child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(l)
: _answerStep(l),
);
if (widget.embedded) {
// First-run gate: same content as the dialog, hosted as a
// full page instead of a modal over an app that isn't set
// up yet ("setup after the app runs" reads backwards).
final theme = Theme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.headlineSmall),
const SizedBox(height: ChainSpace.md),
Flexible(child: body),
const SizedBox(height: ChainSpace.md),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (final a in _actions(l)) ...[
const SizedBox(width: ChainSpace.sm),
a,
],
],
),
],
);
}
return AlertDialog(
title: Text(title),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: reviewing
? _reviewStep(l)
: _showReflection
? _reflectionView(l)
: _answerStep(l),
),
child: body,
),
actions: _actions(l),
);
@ -901,7 +983,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_actionRow(
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_close();
widget.shell?.navigateTo('flows');
},
icon: const Icon(Icons.account_tree_outlined, size: 18),
@ -1027,7 +1109,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
if (_applied) {
return [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: _close,
child: Text(l.guidedSetupClose),
),
];
@ -1053,7 +1135,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
return [
TextButton(
onPressed: _step == 0
? () => Navigator.of(context).pop()
? _close
: () => setState(() => _step -= 1),
child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack),
),

View file

@ -259,12 +259,17 @@ void main() {
setUp(() {
tmp = Directory.systemTemp.createTempSync('wizard-test-');
ChainLog.testPathOverride = '${tmp.path}/studio-errors.log';
// These tests exercise the CLI fallback make the hub-first
// preview fail immediately instead of attempting a live RPC.
GuidedSetupDialog.debugPlanViaHubOverride = () async =>
throw Exception('hub unavailable (test)');
});
tearDown(() {
ChainLog.testPathOverride = null;
SystemActions.debugRunFaiOverride = null;
SystemActions.debugResolveOverride = null;
GuidedSetupDialog.debugPlanViaHubOverride = null;
tmp.deleteSync(recursive: true);
});

55
test/setup_gate_test.dart Normal file
View file

@ -0,0 +1,55 @@
// First-run gate: on a fresh install the app opens INTO the setup
// (embedded wizard as the page), with an explicit skip. Setup
// before the app not a dialog over a shell that isn't set up.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/pages/setup_gate.dart';
void main() {
testWidgets('gate hosts the embedded wizard + a skip action', (
tester,
) async {
var done = false;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SetupGateScreen(onDone: () => done = true),
),
);
await tester.pumpAndSettle();
// The wizard renders as PAGE content (step 1 visible, no dialog).
expect(find.text('Worum geht es?'), findsOneWidget);
expect(find.text('Erst mal ausprobieren'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
// Honest framing + a way past the gate.
expect(find.textContaining('Nichts wird ohne Ihre'), findsOneWidget);
await tester.ensureVisible(find.text('Später einrichten'));
await tester.tap(find.text('Später einrichten'));
await tester.pumpAndSettle();
expect(done, isTrue, reason: 'skip must hand control to the app');
});
testWidgets('cancel on step 1 also leaves the gate', (tester) async {
var done = false;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SetupGateScreen(onDone: () => done = true),
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Abbrechen'));
await tester.tap(find.text('Abbrechen'));
await tester.pumpAndSettle();
expect(done, isTrue);
});
}