kasy-cli 1.27.0 → 1.29.0

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.
@@ -154,6 +154,11 @@ async function confirmIdentities(backend, gcloudAccount, tr, cancel) {
154
154
  const doLogin = await ui.confirm({ message: tr('new.accounts.loginPrompt', { name }), initialValue: true, onCancel: cancel });
155
155
  if (!doLogin) return;
156
156
  ui.log.message(tr('new.accounts.loggingIn', { name }));
157
+ // Firebase's first run interrupts the login with two unrelated yes/no
158
+ // prompts (Gemini in Firebase, anonymous usage data). For a non-technical
159
+ // user mid-setup that's just noise that can derail them. Opt out silently so
160
+ // `firebase login` goes straight to the browser.
161
+ if (name === 'Firebase') silenceFirebaseFirstRunPrompts();
157
162
  const { spawnSync } = require('node:child_process');
158
163
  spawnSync(cmd, { stdio: 'inherit', shell: true });
159
164
  await revalidate();
@@ -195,6 +200,47 @@ async function confirmIdentities(backend, gcloudAccount, tr, cancel) {
195
200
  }
196
201
  }
197
202
 
203
+ /**
204
+ * Opt out of Firebase CLI's first-run questions before we shell into
205
+ * `firebase login`. On a fresh machine the CLI otherwise stops to ask, one at a
206
+ * time, whether to enable Gemini in Firebase and whether to share anonymous
207
+ * usage data — both irrelevant to creating a project and confusing for a
208
+ * first-time user who just wants to log in. firebase-tools reads these from a
209
+ * configstore JSON (~/.config/configstore/firebase-tools.json on every OS); we
210
+ * only write keys that aren't already set, so a user who answered them before
211
+ * keeps their choice. Best-effort: any failure is ignored, the prompts just
212
+ * reappear. Never throws.
213
+ */
214
+ function silenceFirebaseFirstRunPrompts() {
215
+ try {
216
+ const fs = require('node:fs');
217
+ const os = require('node:os');
218
+ const path = require('node:path');
219
+ // firebase-tools honors XDG_CONFIG_HOME, falling back to ~/.config.
220
+ const cfgHome =
221
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
222
+ const dir = path.join(cfgHome, 'configstore');
223
+ const file = path.join(dir, 'firebase-tools.json');
224
+ let data = {};
225
+ if (fs.existsSync(file)) {
226
+ try {
227
+ data = JSON.parse(fs.readFileSync(file, 'utf8')) || {};
228
+ } catch {
229
+ data = {};
230
+ }
231
+ }
232
+ let changed = false;
233
+ if (!('usage' in data)) { data.usage = false; changed = true; }
234
+ if (!('gemini' in data)) { data.gemini = false; changed = true; }
235
+ if (changed) {
236
+ fs.mkdirSync(dir, { recursive: true });
237
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
238
+ }
239
+ } catch {
240
+ // Best-effort: if we can't write the config, the prompts just show up.
241
+ }
242
+ }
243
+
198
244
  /**
199
245
  * Try to get gcloud ready (installed + logged in) without making the user leave
200
246
  * the flow. Mirrors the machine-prep we do for Node/Flutter/Firebase: offer to
@@ -211,37 +257,34 @@ async function ensureGcloudReady(tr) {
211
257
  let check = await checkGcloudAuth();
212
258
  if (check.ok) return check;
213
259
 
214
- // 1) Not installed → tell the user it's missing, then offer to install it.
260
+ // 1) Not installed → install it automatically. gcloud is mandatory to create a
261
+ // Firebase project from scratch, so a yes/no here just adds a dead end the
262
+ // user can fall into; we install it the same way we do Node/Flutter/Firebase
263
+ // (auto, with visible progress) and only tell them what's happening.
215
264
  if (check.missing === 'gcloud') {
216
265
  const guide = getGcloudInstallInstructions();
217
266
  if (guide.install) {
218
- ui.log.warn(tr('new.firebase.create.gcloudMissing'));
219
- const doInstall = await ui.confirm({
220
- message: tr('new.firebase.create.gcloudInstallConfirm'),
221
- initialValue: true,
267
+ ui.log.info(tr('new.firebase.create.gcloudMissing'));
268
+ const spinner = ui.timedSpinner();
269
+ spinner.start(tr('new.firebase.create.gcloudInstalling'));
270
+ // Run async (NOT spawnSync): a synchronous child blocks the event loop,
271
+ // which freezes the spinner — the user stares at a dead screen for
272
+ // minutes. exec keeps the clock ticking so it's clear it's working.
273
+ await new Promise((resolve) => {
274
+ exec(
275
+ guide.install,
276
+ { env: augmentedEnv(), timeout: 600_000, windowsHide: true, maxBuffer: 50 * 1024 * 1024 },
277
+ () => resolve()
278
+ );
222
279
  });
223
- if (doInstall) {
224
- const spinner = ui.timedSpinner();
225
- spinner.start(tr('new.firebase.create.gcloudInstalling'));
226
- // Run async (NOT spawnSync): a synchronous child blocks the event loop,
227
- // which freezes the spinner — the user stares at a dead screen for
228
- // minutes. exec keeps the clock ticking so it's clear it's working.
229
- await new Promise((resolve) => {
230
- exec(
231
- guide.install,
232
- { env: augmentedEnv(), timeout: 600_000, windowsHide: true, maxBuffer: 50 * 1024 * 1024 },
233
- () => resolve()
234
- );
235
- });
236
- spinner.stop(tr('new.firebase.create.gcloudInstalling'));
237
- // checkGcloudAuth and the later billing/project calls use the plain
238
- // process PATH, which doesn't see a tool winget just put on the machine
239
- // PATH. Inject the known gcloud dir so the rest of the flow works.
240
- const env = augmentedEnv();
241
- if (env.PATH) process.env.PATH = env.PATH;
242
- if (process.platform === 'win32' && env.Path) process.env.Path = env.Path;
243
- check = await checkGcloudAuth();
244
- }
280
+ spinner.stop(tr('new.firebase.create.gcloudInstalling'));
281
+ // checkGcloudAuth and the later billing/project calls use the plain
282
+ // process PATH, which doesn't see a tool winget just put on the machine
283
+ // PATH. Inject the known gcloud dir so the rest of the flow works.
284
+ const env = augmentedEnv();
285
+ if (env.PATH) process.env.PATH = env.PATH;
286
+ if (process.platform === 'win32' && env.Path) process.env.Path = env.Path;
287
+ check = await checkGcloudAuth();
245
288
  }
246
289
  }
247
290
 
@@ -563,20 +606,26 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
563
606
  const tr = createTranslator(language);
564
607
  const cancel = () => onCancel(tr);
565
608
 
609
+ // Show the brand right away so the first thing the user sees is the logo, not
610
+ // a couple of silent seconds while we probe the toolchain below.
611
+ printBanner(tr);
612
+
566
613
  // ── 1b. Version check — warn if outdated, let user decide ───────────────
567
614
  if (!yes) {
568
615
  await warnIfOutdatedBeforeNew(tr);
569
616
  }
570
617
 
571
618
  // ── 2. Environment checks (non-blocking — only warnings) ────────────────
619
+ // quiet:true keeps it to a single short "Checking environment…" line (no
620
+ // per-tool detail). It only gets noisy if a tool is actually missing and has
621
+ // to be installed — exactly when the user wants to know.
572
622
  const envChecks = [...getBaseChecks(), ...getPlatformChecks()];
573
- await runChecks(envChecks, tr('new.checks.environment'), {
623
+ await runChecks(envChecks, tr('new.checks.environment.checking'), {
574
624
  t: tr,
575
625
  quiet: true,
626
+ doneLabel: tr('new.checks.environment.done'),
576
627
  });
577
628
 
578
- printBanner(tr);
579
-
580
629
  ui.intro(kleur.bold(tr('new.subtitle2')));
581
630
 
582
631
  // Whether an explicit target directory was provided by the user
@@ -14,6 +14,7 @@ const { promisify } = require('node:util');
14
14
  const path = require('node:path');
15
15
  const os = require('node:os');
16
16
  const fs = require('fs-extra');
17
+ const { augmentedEnv } = require('../../../utils/env-tools');
17
18
 
18
19
  const execAsync = promisify(exec);
19
20
 
@@ -160,6 +161,10 @@ async function run(cmd, cwd) {
160
161
  const { stdout, stderr } = await execAsync(cmd, {
161
162
  cwd,
162
163
  maxBuffer: 50 * 1024 * 1024,
164
+ // Same reason as setup-from-scratch: find tools (gcloud, firebase, npm)
165
+ // that may have been installed earlier in this run but aren't on the
166
+ // default Windows process PATH yet.
167
+ env: augmentedEnv(),
163
168
  });
164
169
  return { ok: true, stdout, stderr };
165
170
  } catch (err) {
@@ -15,12 +15,18 @@ const path = require('node:path');
15
15
  const { promisify } = require('node:util');
16
16
  const execAsync = promisify(require('node:child_process').exec);
17
17
  const fs = require('fs-extra');
18
+ const { augmentedEnv } = require('../../../utils/env-tools');
18
19
 
19
20
  const DEPLOY_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — auth deploys are fast but allow slack
20
21
 
21
22
  async function getGcloudAccountEmail() {
23
+ // No shell-specific redirect here: `2>/dev/null` is a Unix-ism that cmd.exe
24
+ // does NOT understand, so on Windows it made the whole command fail and we
25
+ // wrongly reported "no gcloud account" even when the user was logged in.
26
+ // gcloud prints the account to stdout; any "(unset)" note goes to stderr,
27
+ // which exec already keeps separate, so we don't need to silence it.
22
28
  try {
23
- const { stdout } = await execAsync('gcloud config get-value account 2>/dev/null');
29
+ const { stdout } = await execAsync('gcloud config get-value account', { env: augmentedEnv() });
24
30
  const email = (stdout || '').trim();
25
31
  return email && email !== '(unset)' ? email : null;
26
32
  } catch {
@@ -98,7 +104,7 @@ async function enableAuthViaFirebaseCli({ projectDir, projectId, appName, suppor
98
104
  // 3. Deploy. --non-interactive prevents the CLI from prompting on edge cases.
99
105
  const cmd = `firebase deploy --only auth --project ${projectId} --non-interactive`;
100
106
  try {
101
- await execAsync(cmd, { cwd: projectDir, timeout: DEPLOY_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
107
+ await execAsync(cmd, { cwd: projectDir, timeout: DEPLOY_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024, env: augmentedEnv() });
102
108
  return { ok: true, supportEmail: email };
103
109
  } catch (err) {
104
110
  const stderr = (err.stderr || '').toString();
@@ -19,7 +19,8 @@ const path = require('node:path');
19
19
  const fs = require('fs-extra');
20
20
  const os = require('node:os');
21
21
  const { getInstallGuide } = require('../../../utils/checks');
22
- const { keytoolBin } = require('../../../utils/env-tools');
22
+ const { keytoolBin, augmentedEnv } = require('../../../utils/env-tools');
23
+ const { ensureJdkWindows } = require('../../../utils/flutter-install');
23
24
 
24
25
  const execAsync = promisify(exec);
25
26
 
@@ -66,6 +67,11 @@ async function run(cmd, cwd = process.cwd()) {
66
67
  cwd,
67
68
  maxBuffer: 10 * 1024 * 1024,
68
69
  encoding: 'utf8',
70
+ // augmentedEnv() injects the install dirs of tools we may have just put on
71
+ // the machine this run (gcloud, Flutter/Dart, Git, the JDK for keytool).
72
+ // On Windows those aren't on the default process PATH yet, so without this
73
+ // every gcloud/keytool call here would fail with "not recognized".
74
+ env: augmentedEnv(),
69
75
  });
70
76
  return { ok: true, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
71
77
  } catch (err) {
@@ -533,6 +539,14 @@ async function createWebApp(projectId, appName) {
533
539
  * Ensure debug.keystore exists. Creates it if missing (standard Android debug key).
534
540
  */
535
541
  async function ensureDebugKeystore() {
542
+ // On Windows a fresh machine has no JDK, so keytool is missing. If we can't
543
+ // resolve a real keytool path (keytoolBin falls back to the bare name), try
544
+ // installing Microsoft OpenJDK once so the steps below actually find it. This
545
+ // covers the case where Flutter was already present (so its installer, which
546
+ // also installs the JDK, never ran this session). Best-effort.
547
+ if (process.platform === 'win32' && keytoolBin() === 'keytool') {
548
+ await ensureJdkWindows();
549
+ }
536
550
  const androidDir = path.dirname(DEBUG_KEYSTORE);
537
551
  if (!(await fs.pathExists(DEBUG_KEYSTORE))) {
538
552
  await fs.ensureDir(androidDir);
@@ -275,34 +275,33 @@ async function recoverCheckInteractively(check, t) {
275
275
  // The step list already flagged this tool as missing; go straight to fixing it.
276
276
  const guide = typeof check.installGuide === 'function' ? check.installGuide() : null;
277
277
 
278
- // 1) Offer auto-install for heavy tools. Most use a package-manager command
278
+ // 1) Auto-install heavy tools. Most use a package-manager command
279
279
  // (brew/winget); Flutter on Windows uses a custom installer (check.installFn)
280
- // because winget ships no stable Flutter package.
280
+ // because winget ships no stable Flutter package. We don't ask a yes/no here:
281
+ // for a Flutter project these tools are required to go any further, so a
282
+ // confirm just adds a dead end the user can fall into. We install
283
+ // automatically and show what's happening (incl. the size note for big
284
+ // downloads), matching how the Firebase/FlutterFire CLIs already behave.
281
285
  if (check.confirmInstall && (check.installFn || (guide && guide.cmd))) {
282
286
  const cmdLabel = check.installFn
283
287
  ? (check.installFnDescKey ? t(check.installFnDescKey) : check.name)
284
288
  : guide.cmd;
285
- const doInstall = await ui.confirm({
286
- message: t('checks.install.confirm', { name: check.name, cmd: cmdLabel }),
287
- initialValue: true,
288
- });
289
- if (doInstall) {
290
- // Heads-up for the custom installer: it's a big download and may pop a UAC.
291
- if (check.installFn && check.installFnNoteKey) ui.log.info(t(check.installFnNoteKey));
292
- const spinner = ui.timedSpinner();
293
- spinner.start(t('checks.install.running', { name: check.name }));
294
- const installed = check.installFn
295
- ? await check.installFn({})
296
- : await execTool(guide.cmd, INSTALL_TIMEOUT);
297
- spinner.stop(t('checks.install.running', { name: check.name }));
298
- if (installed.ok && (await revalidate(check, t))) return true;
299
- ui.log.warn(t('checks.install.failedManual', { name: check.name }));
300
- // Surface the installer's own last line — it usually says WHY (needs admin,
301
- // agreement not accepted, package not found), which beats a generic failure.
302
- const reason = (installed.stderr || installed.error || '')
303
- .split('\n').map((l) => l.trim()).filter(Boolean).pop();
304
- if (reason) ui.log.message(kleur.dim(reason.slice(0, 200)));
305
- }
289
+ // Tell the user we're installing it (and why it can take a while / is big).
290
+ ui.log.info(t('checks.install.autoInstalling', { name: check.name, cmd: cmdLabel }));
291
+ if (check.installFn && check.installFnNoteKey) ui.log.info(t(check.installFnNoteKey));
292
+ const spinner = ui.timedSpinner();
293
+ spinner.start(t('checks.install.running', { name: check.name }));
294
+ const installed = check.installFn
295
+ ? await check.installFn({})
296
+ : await execTool(guide.cmd, INSTALL_TIMEOUT);
297
+ spinner.stop(t('checks.install.running', { name: check.name }));
298
+ if (installed.ok && (await revalidate(check, t))) return true;
299
+ ui.log.warn(t('checks.install.failedManual', { name: check.name }));
300
+ // Surface the installer's own last line — it usually says WHY (needs admin,
301
+ // agreement not accepted, package not found), which beats a generic failure.
302
+ const reason = (installed.stderr || installed.error || '')
303
+ .split('\n').map((l) => l.trim()).filter(Boolean).pop();
304
+ if (reason) ui.log.message(kleur.dim(reason.slice(0, 200)));
306
305
  }
307
306
 
308
307
  // 2) Manual guidance — the exact command for this OS + an Enter-to-open link.
@@ -379,6 +378,15 @@ async function runChecks(checks, title, options = {}) {
379
378
  // (doctor) shows every step. Either way, failures are handled below.
380
379
  const quiet = options.quiet === true;
381
380
  const stepper = quiet ? null : ui.makeTimedStepper();
381
+ // In quiet mode we'd otherwise probe the toolchain in total silence, leaving
382
+ // the user staring at a couple of dead seconds. Show ONE short spinner with
383
+ // the phase title (no per-tool detail). It hands off to an install spinner if
384
+ // a tool is missing, or clears with `doneLabel` once everything passes.
385
+ let phaseSpinner = null;
386
+ if (quiet && title && process.stdout.isTTY) {
387
+ phaseSpinner = ui.spinner();
388
+ phaseSpinner.start(title);
389
+ }
382
390
  const results = [];
383
391
 
384
392
  for (const check of checks) {
@@ -391,6 +399,8 @@ async function runChecks(checks, title, options = {}) {
391
399
  onInstalling: (c) => {
392
400
  const msg = c.tryInstallMessageKey ? t(c.tryInstallMessageKey) : t('setup.installingNamed', { name: c.name });
393
401
  if (stepper) { stepper.update(msg); return; }
402
+ // Hand the line off from the phase spinner to this install's own spinner.
403
+ if (phaseSpinner) { phaseSpinner.stop(title); phaseSpinner = null; }
394
404
  installSpinner = ui.timedSpinner();
395
405
  installSpinner.start(msg);
396
406
  },
@@ -411,7 +421,17 @@ async function runChecks(checks, title, options = {}) {
411
421
  if (result.ok) installSpinner.stop(result.version ? `${result.name} — ${result.version}` : result.name);
412
422
  else installSpinner.error(t('checks.missing', { name: result.name }));
413
423
  }
414
- // quiet + passed without installing → completely silent (the desired behavior).
424
+ // quiet + passed without installing → the phase spinner stays running.
425
+ }
426
+
427
+ // Close the phase line. If everything passed, show the friendly done label;
428
+ // if something failed, stop neutrally and let the recovery/failure output
429
+ // below explain it. Stopping here (before the prompts) avoids a spinner
430
+ // animating underneath an interactive question.
431
+ if (phaseSpinner) {
432
+ const allOk = results.every((r) => r.ok);
433
+ phaseSpinner.stop(allOk ? (options.doneLabel || title) : title);
434
+ phaseSpinner = null;
415
435
  }
416
436
 
417
437
  const failures = results.filter((r) => !r.ok);
@@ -69,16 +69,52 @@ function pubCacheBin(name) {
69
69
  return `"${file}"`;
70
70
  }
71
71
 
72
+ /**
73
+ * Find the `bin` dir of a JDK installed on Windows under a versioned folder
74
+ * (e.g. `C:\Program Files\Microsoft\jdk-17.0.13\bin`). winget's Microsoft
75
+ * OpenJDK / Eclipse Adoptium packages install there but the exact version is in
76
+ * the path, so we can't hardcode it — we scan the known vendor roots and pick a
77
+ * folder that actually contains keytool. Returns null on non-Windows or if none
78
+ * is found.
79
+ */
80
+ function findWindowsJdkBin() {
81
+ if (!isWindows) return null;
82
+ const pf = process.env.ProgramFiles;
83
+ if (!pf) return null;
84
+ const roots = [
85
+ path.win32.join(pf, 'Microsoft'), // Microsoft.OpenJDK.*
86
+ path.win32.join(pf, 'Eclipse Adoptium'), // EclipseAdoptium.Temurin.*
87
+ path.win32.join(pf, 'Java'), // Oracle / other
88
+ ];
89
+ for (const root of roots) {
90
+ try {
91
+ if (!fs.existsSync(root)) continue;
92
+ // Highest version first (lexical desc is close enough for jdk-NN.x names).
93
+ const dirs = fs.readdirSync(root).filter((d) => /jdk|jre/i.test(d)).sort().reverse();
94
+ for (const d of dirs) {
95
+ const bin = path.win32.join(root, d, 'bin');
96
+ if (fs.existsSync(path.win32.join(bin, 'keytool.exe'))) return bin;
97
+ }
98
+ } catch {
99
+ // Unreadable root — skip it.
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
72
105
  /**
73
106
  * Quoted path to `keytool` (used for the Android debug SHA-1). It ships with
74
- * the JDK and is almost never on PATH on Windows, so we look in JAVA_HOME and
75
- * Android Studio's bundled JBR before falling back to bare `keytool`.
107
+ * the JDK and is almost never on PATH on Windows, so we look in JAVA_HOME, a
108
+ * winget-installed JDK, and Android Studio's bundled JBR before falling back to
109
+ * bare `keytool`.
76
110
  */
77
111
  function keytoolBin() {
78
112
  const exe = isWindows ? 'keytool.exe' : 'keytool';
79
113
  const candidates = [];
80
114
  if (process.env.JAVA_HOME) candidates.push(path.join(process.env.JAVA_HOME, 'bin', exe));
81
115
  if (isWindows) {
116
+ const jdkBin = findWindowsJdkBin();
117
+ if (jdkBin) candidates.push(path.win32.join(jdkBin, exe));
82
118
  const pf = process.env.ProgramFiles;
83
119
  const local = process.env.LOCALAPPDATA;
84
120
  if (pf) candidates.push(path.win32.join(pf, 'Android', 'Android Studio', 'jbr', 'bin', exe));
@@ -112,6 +148,9 @@ function extraToolDirs() {
112
148
  // Git (a Flutter prerequisite) installed via winget Git.Git — default locations.
113
149
  process.env.ProgramFiles && path.win32.join(process.env.ProgramFiles, 'Git', 'cmd'),
114
150
  process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'cmd'),
151
+ // JDK auto-installed for keytool (Android SHA-1) and Android builds — its bin
152
+ // lives under a versioned folder, so resolve it dynamically.
153
+ findWindowsJdkBin(),
115
154
  ].filter(Boolean);
116
155
  return candidates.filter((dir) => fs.existsSync(dir));
117
156
  }
@@ -5,6 +5,8 @@
5
5
  * but do it for the user so `kasy new` can prepare a bare machine end to end:
6
6
  *
7
7
  * 1. Install Git via winget if missing (Flutter refuses to run without it).
8
+ * 1b. Install Microsoft OpenJDK if `keytool` is missing — needed for the
9
+ * Android debug SHA-1 (Google Sign-In) and for Gradle Android builds.
8
10
  * 2. Download the current stable Flutter SDK zip and unzip it into
9
11
  * %LOCALAPPDATA%\flutter (a per-user dir that never needs admin rights).
10
12
  * 3. Persist %LOCALAPPDATA%\flutter\bin on the User PATH for future terminals.
@@ -62,6 +64,17 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
62
64
  throw 'Git is required for Flutter but is not available. Install Git and run this again.'
63
65
  }
64
66
 
67
+ # 1b. JDK — provides 'keytool' (used to register the Android debug SHA-1 for
68
+ # Google Sign-In) and is required by Gradle to build the Android app. A bare
69
+ # Windows machine has neither, so install Microsoft's OpenJDK 17 (free, no
70
+ # account). Non-fatal: if it fails, SHA-1 just falls back to a manual step.
71
+ if (-not (Get-Command keytool -ErrorAction SilentlyContinue)) {
72
+ if (Get-Command winget -ErrorAction SilentlyContinue) {
73
+ winget install --id Microsoft.OpenJDK.17 -e --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Null
74
+ Sync-Path
75
+ }
76
+ }
77
+
65
78
  # 2. Flutter SDK — download + unzip, unless it's already there.
66
79
  if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
67
80
  if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
@@ -136,4 +149,49 @@ function installFlutterWindows({ timeout = 1_800_000 } = {}) {
136
149
  });
137
150
  }
138
151
 
139
- module.exports = { installFlutterWindows, flutterWinHome };
152
+ // Standalone JDK installer, for the case where Flutter is ALREADY present (so the
153
+ // installer above never runs) but there's still no JDK — e.g. a machine set up
154
+ // before we started shipping the JDK, hitting the Android SHA-1 step. keytool
155
+ // comes with the JDK; without it Google Sign-In on Android can't be wired up.
156
+ const JDK_PS_SCRIPT = `
157
+ $ErrorActionPreference = 'Stop'
158
+ $ProgressPreference = 'SilentlyContinue'
159
+ if (Get-Command keytool -ErrorAction SilentlyContinue) { Write-Output 'JDK_PRESENT'; exit 0 }
160
+ if (Get-Command winget -ErrorAction SilentlyContinue) {
161
+ winget install --id Microsoft.OpenJDK.17 -e --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Null
162
+ }
163
+ Write-Output 'JDK_DONE'
164
+ `;
165
+
166
+ /**
167
+ * Install Microsoft OpenJDK on Windows if `keytool` isn't available. Best-effort
168
+ * and never throws — the caller (SHA-1 registration) degrades to a manual step
169
+ * if it fails. Resolves to the same shape as installFlutterWindows.
170
+ *
171
+ * @param {{ timeout?: number }} [opts]
172
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, error: string|null }>}
173
+ */
174
+ function ensureJdkWindows({ timeout = 600_000 } = {}) {
175
+ return new Promise((resolve) => {
176
+ if (!isWindows) {
177
+ resolve({ ok: false, stdout: '', stderr: '', error: 'not windows' });
178
+ return;
179
+ }
180
+ const encoded = Buffer.from(JDK_PS_SCRIPT, 'utf16le').toString('base64');
181
+ const cmd = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
182
+ exec(
183
+ cmd,
184
+ { timeout, maxBuffer: 50 * 1024 * 1024, windowsHide: true },
185
+ (error, stdout, stderr) => {
186
+ resolve({
187
+ ok: !error,
188
+ stdout: stdout || '',
189
+ stderr: stderr || '',
190
+ error: error ? error.message : null,
191
+ });
192
+ }
193
+ );
194
+ });
195
+ }
196
+
197
+ module.exports = { installFlutterWindows, flutterWinHome, ensureJdkWindows };
@@ -134,6 +134,7 @@ module.exports = {
134
134
  'checks.thenRun': 'Then run',
135
135
  'checks.recheck': 'After installing {name}, press Enter to check again',
136
136
  'checks.install.confirm': 'Install {name} now? ({cmd})',
137
+ 'checks.install.autoInstalling': '{name} is required and is not installed yet. Installing it automatically now ({cmd}).',
137
138
  'checks.install.flutterWinDesc': 'downloads the official Flutter SDK + Git, ~1.8 GB',
138
139
  'checks.install.flutterWinNote': 'This can take a few minutes (large download). If a Windows approval popup appears, click Yes.',
139
140
  'checks.install.running': 'Installing {name}…',
@@ -238,7 +239,7 @@ module.exports = {
238
239
  'new.firebase.create.installAfter': 'Then log in',
239
240
  'new.firebase.create.installUrl': 'Or download from',
240
241
  'new.firebase.create.authCommand': 'Run: gcloud auth login',
241
- 'new.firebase.create.gcloudMissing': 'The Google Cloud CLI (gcloud) is required to create the Firebase project from scratch, and it is not installed yet.',
242
+ 'new.firebase.create.gcloudMissing': 'The Google Cloud CLI (gcloud) is required to create the Firebase project from scratch, and it is not installed yet. Installing it automatically now.',
242
243
  'new.firebase.create.gcloudInstallConfirm': 'Install the Google Cloud CLI (gcloud) automatically now?',
243
244
  'new.firebase.create.gcloudInstalling': 'Installing the Google Cloud CLI…',
244
245
  'new.firebase.create.gcloudAuthOpening': 'Opening the Google sign-in in your browser — log in with your account…',
@@ -134,6 +134,7 @@ module.exports = {
134
134
  'checks.thenRun': 'Luego ejecuta',
135
135
  'checks.recheck': 'Tras instalar {name}, presiona Enter para verificar de nuevo',
136
136
  'checks.install.confirm': '¿Instalar {name} ahora? ({cmd})',
137
+ 'checks.install.autoInstalling': '{name} es necesario y aún no está instalado. Lo instalaré automáticamente ahora ({cmd}).',
137
138
  'checks.install.flutterWinDesc': 'descarga el SDK oficial de Flutter + Git, ~1,8 GB',
138
139
  'checks.install.flutterWinNote': 'Esto puede tardar unos minutos (descarga grande). Si aparece una ventana de permiso de Windows, haz clic en Sí.',
139
140
  'checks.install.running': 'Instalando {name}…',
@@ -238,7 +239,7 @@ module.exports = {
238
239
  'new.firebase.create.installAfter': 'Luego inicia sesión',
239
240
  'new.firebase.create.installUrl': 'O descarga en',
240
241
  'new.firebase.create.authCommand': 'Ejecuta: gcloud auth login',
241
- 'new.firebase.create.gcloudMissing': 'El Google Cloud CLI (gcloud) es necesario para crear el proyecto Firebase desde cero, y aún no está instalado.',
242
+ 'new.firebase.create.gcloudMissing': 'El Google Cloud CLI (gcloud) es necesario para crear el proyecto Firebase desde cero, y aún no está instalado. Lo instalaré automáticamente ahora.',
242
243
  'new.firebase.create.gcloudInstallConfirm': '¿Instalar el Google Cloud CLI (gcloud) automáticamente ahora?',
243
244
  'new.firebase.create.gcloudInstalling': 'Instalando el Google Cloud CLI…',
244
245
  'new.firebase.create.gcloudAuthOpening': 'Abriendo el inicio de sesión de Google en el navegador — entra con tu cuenta…',
@@ -134,6 +134,7 @@ module.exports = {
134
134
  'checks.thenRun': 'Depois rode',
135
135
  'checks.recheck': 'Após instalar {name}, pressione Enter para verificar novamente',
136
136
  'checks.install.confirm': 'Instalar {name} agora? ({cmd})',
137
+ 'checks.install.autoInstalling': '{name} é necessário e ainda não está instalado. Vou instalar automaticamente agora ({cmd}).',
137
138
  'checks.install.flutterWinDesc': 'baixa o SDK oficial do Flutter + Git, ~1,8 GB',
138
139
  'checks.install.flutterWinNote': 'Pode levar alguns minutos (download grande). Se aparecer um popup do Windows pedindo permissão, clique em Sim.',
139
140
  'checks.install.running': 'Instalando {name}…',
@@ -238,7 +239,7 @@ module.exports = {
238
239
  'new.firebase.create.installAfter': 'Depois faca login',
239
240
  'new.firebase.create.installUrl': 'Ou baixe em',
240
241
  'new.firebase.create.authCommand': 'Execute: gcloud auth login',
241
- 'new.firebase.create.gcloudMissing': 'O Google Cloud CLI (gcloud) é necessário para criar o projeto Firebase do zero, e ainda não está instalado.',
242
+ 'new.firebase.create.gcloudMissing': 'O Google Cloud CLI (gcloud) é necessário para criar o projeto Firebase do zero, e ainda não está instalado. Vou instalar automaticamente agora.',
242
243
  'new.firebase.create.gcloudInstallConfirm': 'Instalar o Google Cloud CLI (gcloud) automaticamente agora?',
243
244
  'new.firebase.create.gcloudInstalling': 'Instalando o Google Cloud CLI…',
244
245
  'new.firebase.create.gcloudAuthOpening': 'Abrindo o login do Google no navegador — entre com a sua conta…',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -104,6 +104,14 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
104
104
  late final ValueNotifier<AppLocale> _localeNotifier;
105
105
  final _screenshotKey = GlobalKey();
106
106
 
107
+ // Keeps the wrapped app's Element alive across enable/disable. Toggling the
108
+ // preview moves MaterialApp.router between tree positions (in/out of the
109
+ // device frame); without a stable GlobalKey the Router would be rebuilt from
110
+ // scratch and navigation would snap back to the initial route ('/'). The
111
+ // GlobalKey makes Flutter reparent the same Element, preserving the current
112
+ // screen.
113
+ final _appKey = GlobalKey();
114
+
107
115
  static const _textScaleSteps = [1.0, 1.3, 1.5];
108
116
 
109
117
  List<DeviceInfo> get _devices => switch (_platform) {
@@ -410,7 +418,7 @@ class _WebDevicePreviewState extends State<WebDevicePreview>
410
418
  deviceNotifier: _deviceNotifier,
411
419
  frameVisibleNotifier: _frameVisibleNotifier,
412
420
  landscapeNotifier: _landscapeNotifier,
413
- child: widget.child,
421
+ child: KeyedSubtree(key: _appKey, child: widget.child),
414
422
  ),
415
423
  ),
416
424
  ),