@whisperr/wizard 0.3.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/index.js +371 -39
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { fileURLToPath } from "url";
8
8
  // src/cli.ts
9
9
  import { resolve as resolve2 } from "path";
10
10
  import * as p from "@clack/prompts";
11
+ import open2 from "open";
11
12
 
12
13
  // src/core/config.ts
13
14
  var DEFAULT_API_BASE = "https://api.whisperr.net";
@@ -1016,42 +1017,67 @@ var divider = theme.dim("\u2500".repeat(48));
1016
1017
  // src/core/auth.ts
1017
1018
  async function authenticate(config) {
1018
1019
  if (config.offline) return offlineSession();
1020
+ const auth = await startDeviceAuth(config);
1021
+ const url = auth.verificationUrlComplete ?? auth.verificationUrl;
1022
+ try {
1023
+ await open(url);
1024
+ } catch {
1025
+ }
1026
+ return auth.poll();
1027
+ }
1028
+ async function startDeviceAuth(config) {
1019
1029
  const authorize = await fetchJson(
1020
1030
  `${config.apiBaseUrl}/wizard/device/authorize`,
1021
1031
  { method: "POST", body: JSON.stringify({ client: "whisperr-wizard" }) }
1022
1032
  );
1023
- const url = authorize.verification_uri_complete ?? authorize.verification_uri;
1024
1033
  const intervalMs = (authorize.interval ?? 5) * 1e3;
1025
1034
  const deadline = Date.now() + (authorize.expires_in ?? 600) * 1e3;
1026
- try {
1027
- await open(url);
1028
- } catch {
1029
- }
1030
- let wait = intervalMs;
1031
- while (Date.now() < deadline) {
1032
- await sleep(wait);
1033
- const res = await fetch(
1034
- `${config.apiBaseUrl}/wizard/device/token`,
1035
- {
1036
- method: "POST",
1037
- headers: { "content-type": "application/json" },
1038
- body: JSON.stringify({ device_code: authorize.device_code })
1035
+ return {
1036
+ verificationUrl: authorize.verification_uri,
1037
+ verificationUrlComplete: authorize.verification_uri_complete,
1038
+ userCode: authorize.user_code,
1039
+ async poll() {
1040
+ let wait = intervalMs;
1041
+ while (Date.now() < deadline) {
1042
+ await sleep(wait);
1043
+ const res = await fetch(
1044
+ `${config.apiBaseUrl}/wizard/device/token`,
1045
+ {
1046
+ method: "POST",
1047
+ headers: { "content-type": "application/json" },
1048
+ body: JSON.stringify({ device_code: authorize.device_code })
1049
+ }
1050
+ );
1051
+ if (res.ok) {
1052
+ const tok = await res.json();
1053
+ return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
1054
+ }
1055
+ if (res.status === 429) {
1056
+ wait += 2e3;
1057
+ continue;
1058
+ }
1059
+ if (res.status === 428) continue;
1060
+ throw new Error(
1061
+ `Authorization failed (${res.status}). Re-run the wizard and use the printed link and code to try again.`
1062
+ );
1039
1063
  }
1040
- );
1041
- if (res.ok) {
1042
- const tok = await res.json();
1043
- return { token: tok.token, appId: tok.app_id, expiresAt: tok.expires_at };
1044
- }
1045
- if (res.status === 429) {
1046
- wait += 2e3;
1047
- continue;
1064
+ throw new Error(
1065
+ "Authorization timed out. Re-run the wizard and use the printed link and code to try again."
1066
+ );
1048
1067
  }
1049
- if (res.status === 428) continue;
1050
- throw new Error(
1051
- `Authorization failed (${res.status}). Run the wizard again to retry.`
1052
- );
1053
- }
1054
- throw new Error("Authorization timed out. Run the wizard again.");
1068
+ };
1069
+ }
1070
+ function startSessionKeepalive(config, session, intervalMs = 5 * 6e4) {
1071
+ if (config.offline) return () => {
1072
+ };
1073
+ const timer = setInterval(() => {
1074
+ fetch(`${config.apiBaseUrl}/wizard/first-event`, {
1075
+ headers: { authorization: `Bearer ${session.token}` }
1076
+ }).catch(() => {
1077
+ });
1078
+ }, intervalMs);
1079
+ timer.unref?.();
1080
+ return () => clearInterval(timer);
1055
1081
  }
1056
1082
  function offlineSession() {
1057
1083
  return {
@@ -1405,6 +1431,71 @@ async function submitAdditions(config, session, target9, repoFingerprint2, oppor
1405
1431
  }
1406
1432
  return await res.json();
1407
1433
  }
1434
+ async function submitSuggestions(config, session, target9, repoFingerprint2, opportunities) {
1435
+ const res = await fetch(`${config.apiBaseUrl}/wizard/universe/suggestions`, {
1436
+ method: "POST",
1437
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1438
+ headers: {
1439
+ "Content-Type": "application/json",
1440
+ Authorization: `Bearer ${session.token}`
1441
+ },
1442
+ body: JSON.stringify({
1443
+ target: target9,
1444
+ repoFingerprint: repoFingerprint2,
1445
+ events: opportunities.events,
1446
+ interventions: opportunities.interventions
1447
+ })
1448
+ });
1449
+ if (res.status === 404) return null;
1450
+ if (!res.ok) {
1451
+ const body = await res.text().catch(() => "");
1452
+ throw new Error(
1453
+ `Submitting universe suggestions failed (${res.status})${body ? `: ${body.slice(0, 300)}` : ""}`
1454
+ );
1455
+ }
1456
+ return await res.json();
1457
+ }
1458
+ async function fetchSuggestions(config, session, statuses) {
1459
+ if (config.offline) return [];
1460
+ try {
1461
+ const statusQuery = statuses.map(encodeURIComponent).join(",");
1462
+ const url = `${config.apiBaseUrl}/wizard/universe/suggestions${statusQuery ? `?status=${statusQuery}` : ""}`;
1463
+ const res = await fetch(url, {
1464
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1465
+ headers: { Authorization: `Bearer ${session.token}` }
1466
+ });
1467
+ if (!res.ok) return [];
1468
+ const body = await res.json();
1469
+ return Array.isArray(body) && body.every(isSuggestionRecord) ? body : [];
1470
+ } catch {
1471
+ return [];
1472
+ }
1473
+ }
1474
+ function isSuggestionRecord(value) {
1475
+ if (typeof value !== "object" || value === null) return false;
1476
+ const record = value;
1477
+ return typeof record.id === "string" && (record.kind === "event" || record.kind === "intervention") && typeof record.code === "string" && (record.status === "proposed" || record.status === "approved" || record.status === "rejected" || record.status === "integrated") && typeof record.payload === "object" && record.payload !== null;
1478
+ }
1479
+ async function markSuggestionIntegrated(config, session, id, target9, repoFingerprint2) {
1480
+ if (config.offline) return false;
1481
+ try {
1482
+ const res = await fetch(
1483
+ `${config.apiBaseUrl}/wizard/universe/suggestions/${encodeURIComponent(id)}/integrated`,
1484
+ {
1485
+ method: "POST",
1486
+ signal: AbortSignal.timeout(SUBMIT_TIMEOUT_MS),
1487
+ headers: {
1488
+ "Content-Type": "application/json",
1489
+ Authorization: `Bearer ${session.token}`
1490
+ },
1491
+ body: JSON.stringify({ target: target9, repoFingerprint: repoFingerprint2 })
1492
+ }
1493
+ );
1494
+ return res.status === 200;
1495
+ } catch {
1496
+ return false;
1497
+ }
1498
+ }
1408
1499
  function asArray(value) {
1409
1500
  return Array.isArray(value) ? value : [];
1410
1501
  }
@@ -1460,6 +1551,7 @@ function coerceEvent(entry) {
1460
1551
  description: asString(e.description),
1461
1552
  ...side ? { side } : {},
1462
1553
  rationale: asString(e.rationale),
1554
+ expectedEffect: asString(e.expectedEffect),
1463
1555
  confidence: asConfidence(e.confidence),
1464
1556
  ...properties.length ? { properties } : {},
1465
1557
  ...links.length ? { links } : {}
@@ -1484,6 +1576,7 @@ function coerceIntervention(entry) {
1484
1576
  label: asString(i.label),
1485
1577
  description: asString(i.description),
1486
1578
  rationale: asString(i.rationale),
1579
+ expectedEffect: asString(i.expectedEffect),
1487
1580
  confidence: asConfidence(i.confidence),
1488
1581
  ...links.length ? { links } : {}
1489
1582
  };
@@ -1684,16 +1777,20 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1684
1777
  ];
1685
1778
  return [
1686
1779
  "UNIVERSE OPPORTUNITIES (do this LAST, after your corrections):",
1687
- "While auditing you read this app's real lifecycle. If you found churn-",
1688
- "relevant moments the plan does NOT cover \u2014 e.g. a recurring-payment or",
1689
- "cancellation path with no event, a support/refund flow worth a retention",
1690
- "play \u2014 record them as PROPOSALS. Do NOT add track() calls for them.",
1780
+ "While auditing you read this app's real lifecycle. Now sweep it",
1781
+ "deliberately: walk each surface the plan should care about \u2014 signup and",
1782
+ "auth, billing/payments (renewals, failures, upgrades, downgrades,",
1783
+ "cancellation), the core engagement loop, support/feedback/refund flows,",
1784
+ "sharing/invites, and any expiry or dormancy mechanics you saw \u2014 and for",
1785
+ "each one ask: does a churn-relevant moment happen here that the plan does",
1786
+ "NOT cover? Record those as PROPOSALS. Do NOT add track() calls for them.",
1691
1787
  `Write a single JSON file at the repo root named ${opportunitiesFile}:`,
1692
1788
  "",
1693
1789
  "{",
1694
1790
  ' "events": [{',
1695
1791
  ' "code": "snake_case_event", "label": "...", "description": "...",',
1696
1792
  ' "side": "frontend|backend|either", "rationale": "what in the code shows this",',
1793
+ ' "expectedEffect": "one sentence: what improves if this is adopted",',
1697
1794
  ' "confidence": 0.0-1.0,',
1698
1795
  ' "properties": [{"name": "...", "description": "...", "required": false}],',
1699
1796
  ' "links": [{"interventionCode": "existing_or_proposed", "weight": 0.0-1.0}]',
@@ -1701,6 +1798,7 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1701
1798
  ' "interventions": [{',
1702
1799
  ' "code": "snake_case_strategy", "label": "...", "description": "...",',
1703
1800
  ' "rationale": "...", "confidence": 0.0-1.0,',
1801
+ ' "expectedEffect": "one sentence: what improves if this is adopted",',
1704
1802
  ' "links": [{"eventCode": "existing_or_proposed", "weight": 0.0-1.0}]',
1705
1803
  " }]",
1706
1804
  "}",
@@ -1710,8 +1808,14 @@ function renderOpportunitiesBrief(m, opportunitiesFile) {
1710
1808
  ` And these interventions: ${interventionCodes.join(", ") || "(none)"}.`,
1711
1809
  " Propose ONLY what is genuinely missing \u2014 never re-propose or rename these.",
1712
1810
  "- Every proposal needs concrete code evidence in its rationale (file/flow),",
1713
- " not speculation. 0-3 events and 0-2 interventions is the normal range;",
1714
- " an empty file or no file at all is a perfectly good outcome.",
1811
+ " not speculation. VERIFY before you write: re-open the file you are citing",
1812
+ " and confirm the flow exists as described \u2014 a false suggestion costs more",
1813
+ " trust than a missed one, so drop anything you cannot point at real code.",
1814
+ "- Be thorough, not shy: a feature-rich product typically yields 3-6 solid",
1815
+ " event proposals and 1-3 interventions. An empty file is only the right",
1816
+ " answer when the plan genuinely already covers the product's lifecycle \u2014",
1817
+ " never because you stopped looking early.",
1818
+ "- Every proposal should include expectedEffect: one sentence explaining what adoption should improve.",
1715
1819
  "- Link each proposed event to the intervention(s) it should feed (existing",
1716
1820
  " codes or ones you propose in the same file).",
1717
1821
  "- This file is metadata for the wizard, not app code \u2014 write it and move on."
@@ -2949,6 +3053,118 @@ function scrubTerminalControls(value) {
2949
3053
  return value.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/ {2,}/g, " ").trim();
2950
3054
  }
2951
3055
 
3056
+ // src/core/gapreport.ts
3057
+ function buildGapReport(input) {
3058
+ const outcomes = new Map(
3059
+ input.events.map((event) => [
3060
+ event.eventType,
3061
+ {
3062
+ status: event.status,
3063
+ reason: trimmedReason(event.reason)
3064
+ }
3065
+ ])
3066
+ );
3067
+ const interventions = /* @__PURE__ */ new Map();
3068
+ for (const event of input.manifest.events) {
3069
+ for (const intervention of event.interventions ?? []) {
3070
+ let collected = interventions.get(intervention.code);
3071
+ if (!collected) {
3072
+ collected = {
3073
+ code: intervention.code,
3074
+ label: intervention.label,
3075
+ drivingEvents: [],
3076
+ drivingEventSet: /* @__PURE__ */ new Set()
3077
+ };
3078
+ interventions.set(intervention.code, collected);
3079
+ } else if (!collected.label && intervention.label) {
3080
+ collected.label = intervention.label;
3081
+ }
3082
+ if (!collected.drivingEventSet.has(event.eventType)) {
3083
+ collected.drivingEventSet.add(event.eventType);
3084
+ collected.drivingEvents.push(event.eventType);
3085
+ }
3086
+ }
3087
+ }
3088
+ const interventionGaps = [];
3089
+ for (const intervention of interventions.values()) {
3090
+ const plannedEvents = intervention.drivingEvents.filter((code) => outcomes.has(code));
3091
+ const missingEvents = plannedEvents.flatMap((code) => {
3092
+ const outcome = outcomes.get(code);
3093
+ if (outcome?.status !== "skipped") return [];
3094
+ return [{ code, ...outcome.reason ? { reason: outcome.reason } : {} }];
3095
+ });
3096
+ if (missingEvents.length === 0) continue;
3097
+ interventionGaps.push({
3098
+ code: intervention.code,
3099
+ ...intervention.label ? { label: intervention.label } : {},
3100
+ blocked: missingEvents.length === plannedEvents.length ? "fully" : "partially",
3101
+ missingEvents
3102
+ });
3103
+ }
3104
+ interventionGaps.sort((a, b) => {
3105
+ if (a.blocked !== b.blocked) return a.blocked === "fully" ? -1 : 1;
3106
+ return b.missingEvents.length - a.missingEvents.length;
3107
+ });
3108
+ const otherSurface = input.manifest.universeSummary?.otherSurface ?? 0;
3109
+ const derived = input.manifest.universeSummary?.derived ?? 0;
3110
+ return {
3111
+ interventionGaps,
3112
+ otherSurface,
3113
+ derived,
3114
+ hasContent: interventionGaps.length > 0 || otherSurface > 0
3115
+ };
3116
+ }
3117
+ function renderGapReport(report, theme2) {
3118
+ const lines = [];
3119
+ for (const gap of report.interventionGaps) {
3120
+ const status = gap.blocked === "fully" ? "on hold" : "running below potential";
3121
+ lines.push(`\u25A0 ${theme2.bright(gap.label ?? gap.code)}${theme2.muted(` \u2014 ${status}`)}`);
3122
+ for (const event of gap.missingEvents) {
3123
+ const reason = event.reason ? ` \u2014 ${lowercaseFirst(event.reason)}` : "";
3124
+ lines.push(
3125
+ theme2.muted(" \xB7 ") + theme2.bright(event.code) + theme2.muted(` isn't flowing yet${reason}`)
3126
+ );
3127
+ }
3128
+ lines.push(
3129
+ theme2.muted(
3130
+ gap.blocked === "fully" ? " Ship the missing flow and this play switches on by itself \u2014 the strategy is already built for it." : " It works today, but with only part of its signal \u2014 every event above sharpens it."
3131
+ )
3132
+ );
3133
+ }
3134
+ if (report.otherSurface > 0) {
3135
+ const suffix = report.otherSurface === 1 ? "" : "s";
3136
+ lines.push(
3137
+ `\u25A0 ${report.otherSurface} more event${suffix} live on another surface (your backend or another app)`
3138
+ );
3139
+ lines.push(
3140
+ theme2.muted(
3141
+ " Run the wizard there and they join the same strategy \u2014 nothing to reconfigure."
3142
+ )
3143
+ );
3144
+ }
3145
+ if (report.derived > 0) {
3146
+ const suffix = report.derived === 1 ? "" : "s";
3147
+ lines.push(
3148
+ theme2.muted(
3149
+ `${report.derived} derived signal${suffix} (dormancy, anniversaries, expiries) are computed by Whisperr automatically once your raw events flow \u2014 nothing to wire.`
3150
+ )
3151
+ );
3152
+ }
3153
+ lines.push(
3154
+ theme2.muted(
3155
+ "All of this is capacity your strategy already includes. Each item you unlock compounds the rest."
3156
+ )
3157
+ );
3158
+ return lines.join("\n");
3159
+ }
3160
+ function trimmedReason(reason) {
3161
+ const trimmed = reason?.trim();
3162
+ return trimmed || void 0;
3163
+ }
3164
+ function lowercaseFirst(value) {
3165
+ return value.slice(0, 1).toLowerCase() + value.slice(1);
3166
+ }
3167
+
2952
3168
  // src/ui/banner.ts
2953
3169
  function banner() {
2954
3170
  const wave = theme.signal("\u223F\u223F\u223F");
@@ -3012,11 +3228,13 @@ Flutter is live today; ${theme.bright(
3012
3228
  p.cancel(theme.alert(err.message));
3013
3229
  return 1;
3014
3230
  }
3231
+ startSessionKeepalive(config, session);
3015
3232
  const fingerprint = await repoFingerprint(repoPath);
3016
3233
  const manifest = await withSpinner(
3017
3234
  "Loading your onboarding context",
3018
3235
  () => fetchManifest(config, session, chosen.playbook.target.id, fingerprint)
3019
3236
  );
3237
+ const approvedSuggestionsPromise = config.offline ? null : fetchSuggestions(config, session, ["approved"]);
3020
3238
  p.note(
3021
3239
  summarizeManifest(manifest),
3022
3240
  theme.signal(`Plan for ${manifest.appName ?? manifest.appId}`)
@@ -3320,10 +3538,62 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3320
3538
  )
3321
3539
  );
3322
3540
  }
3541
+ try {
3542
+ const approvedSuggestions = await approvedSuggestionsPromise;
3543
+ if (approvedSuggestions?.length) {
3544
+ const integratedEventCodes = new Set(
3545
+ reportEvents.filter((event) => event.status === "wired").map((event) => normalizeCode(event.event_type))
3546
+ );
3547
+ for (const event of manifest.events) {
3548
+ if (event.coverage?.some((entry) => entry.sameSurface && entry.status === "wired")) {
3549
+ integratedEventCodes.add(normalizeCode(event.eventType));
3550
+ }
3551
+ }
3552
+ const toMark = approvedSuggestions.filter(
3553
+ (suggestion) => suggestion.status === "approved" && (suggestion.kind === "intervention" || integratedEventCodes.has(normalizeCode(suggestion.code)))
3554
+ );
3555
+ const marked = (await Promise.all(
3556
+ toMark.map(
3557
+ (suggestion) => markSuggestionIntegrated(
3558
+ config,
3559
+ session,
3560
+ suggestion.id,
3561
+ chosen.playbook.target.id,
3562
+ fingerprint
3563
+ )
3564
+ )
3565
+ )).filter(Boolean).length;
3566
+ if (marked > 0) {
3567
+ p.log.info(
3568
+ theme.muted(
3569
+ `${marked} approved suggestion${marked === 1 ? "" : "s"} marked integrated.`
3570
+ )
3571
+ );
3572
+ }
3573
+ }
3574
+ } catch {
3575
+ }
3323
3576
  const eventLines = renderEventOutcomeLines(outcome.eventOutcomes, wiredMap);
3324
3577
  if (eventLines) {
3325
3578
  p.note(eventLines, "Events");
3326
3579
  }
3580
+ const eventOutcomeByType = new Map(
3581
+ outcome.eventOutcomes.map((event) => [event.event, event])
3582
+ );
3583
+ const gapReport = buildGapReport({
3584
+ manifest,
3585
+ events: reportEvents.map((event) => ({
3586
+ eventType: event.event_type,
3587
+ status: event.status,
3588
+ reason: eventOutcomeByType.get(event.event_type)?.reason
3589
+ }))
3590
+ });
3591
+ if (gapReport.hasContent) {
3592
+ p.note(
3593
+ renderGapReport(gapReport, theme),
3594
+ theme.signal("What you could be doing")
3595
+ );
3596
+ }
3327
3597
  const eventDenominator = manifest.universeSummary?.wireableHere ?? eventTypesToScan.length;
3328
3598
  const eventStatsLabel = manifest.universeSummary ? "events wired here" : "events wired";
3329
3599
  const timingDetails = outcome.phaseTimings.map(({ phase, ms }) => `${shortPhaseLabel(phase)} ${formatDuration(ms)}`).join(" \xB7 ");
@@ -3366,7 +3636,21 @@ ${repair.summary}`].filter(Boolean).join("\n\n");
3366
3636
  }
3367
3637
  var NO_ADDITIONS = { acceptedEvents: [], instrumentationSnapshot: null };
3368
3638
  async function offerOpportunities(opts) {
3369
- const { opportunities, manifest, config, session } = opts;
3639
+ const { manifest, config, session } = opts;
3640
+ const openSuggestions = await fetchSuggestions(config, session, ["proposed", "approved"]);
3641
+ const openSuggestionKeys = new Set(
3642
+ openSuggestions.map(
3643
+ (suggestion) => `${suggestion.kind}:${normalizeCode(suggestion.code)}`
3644
+ )
3645
+ );
3646
+ const opportunities = {
3647
+ events: opts.opportunities.events.filter(
3648
+ (event) => !openSuggestionKeys.has(`event:${normalizeCode(event.code)}`)
3649
+ ),
3650
+ interventions: opts.opportunities.interventions.filter(
3651
+ (intervention) => !openSuggestionKeys.has(`intervention:${normalizeCode(intervention.code)}`)
3652
+ )
3653
+ };
3370
3654
  const total = opportunities.events.length + opportunities.interventions.length;
3371
3655
  if (total === 0) return NO_ADDITIONS;
3372
3656
  const describe = (kind, code, why) => `${theme.muted(`${kind}: `)}${theme.bright(code)}${why ? theme.muted(` \u2014 ${why}`) : ""}`;
@@ -3390,7 +3674,7 @@ async function offerOpportunities(opts) {
3390
3674
  return NO_ADDITIONS;
3391
3675
  }
3392
3676
  const selection = await p.multiselect({
3393
- message: "Add these to your Whisperr universe? (new interventions start paused)",
3677
+ message: "Send these to your dashboard for approval?",
3394
3678
  options: [
3395
3679
  ...opportunities.events.map((e) => ({
3396
3680
  value: `e:${e.code}`,
@@ -3403,8 +3687,8 @@ async function offerOpportunities(opts) {
3403
3687
  hint: i.description ?? i.rationale
3404
3688
  }))
3405
3689
  ],
3406
- // Opt-in per item: nothing is pre-selected, so adding to the universe is an
3407
- // explicit choice rather than an opt-out the user has to notice and undo.
3690
+ // Opt-in per item: nothing is pre-selected, so submitting a suggestion is
3691
+ // an explicit choice rather than an opt-out the user has to notice and undo.
3408
3692
  initialValues: [],
3409
3693
  required: false
3410
3694
  });
@@ -3437,6 +3721,39 @@ async function offerOpportunities(opts) {
3437
3721
  )
3438
3722
  }))
3439
3723
  };
3724
+ let stageResult;
3725
+ try {
3726
+ stageResult = await withSpinner(
3727
+ "Sending suggestions for approval",
3728
+ () => submitSuggestions(config, session, opts.playbook.target.id, opts.fingerprint, submitted)
3729
+ );
3730
+ } catch (err) {
3731
+ p.log.warn(
3732
+ theme.warn("Couldn't send the proposals") + theme.muted(` \u2014 ${err.message}. Nothing was submitted.`)
3733
+ );
3734
+ return NO_ADDITIONS;
3735
+ }
3736
+ if (stageResult) {
3737
+ const lines2 = stageResult.outcomes.map((outcome) => {
3738
+ const mark = outcome.status === "staged" ? theme.success("\u2713") : outcome.status === "duplicate" ? theme.muted("=") : theme.warn("\u2717");
3739
+ const reason = outcome.reason ?? (outcome.duplicateOf && outcome.duplicateOf !== outcome.code ? `already queued as ${outcome.duplicateOf}` : void 0);
3740
+ const detail = reason ? theme.muted(` (${reason})`) : "";
3741
+ return `${mark} ${outcome.kind} ${theme.bright(outcome.code)}${detail}`;
3742
+ });
3743
+ lines2.push(
3744
+ theme.muted(
3745
+ `${stageResult.staged} staged \xB7 ${stageResult.duplicates} already queued \xB7 ${stageResult.invalid} rejected`
3746
+ )
3747
+ );
3748
+ p.note(lines2.join("\n"), theme.signal("Suggestions submitted"));
3749
+ const where = stageResult.approvalsUrl ? `approve at ${theme.bright(stageResult.approvalsUrl)}` : "approve them in your Whisperr dashboard";
3750
+ p.log.info(
3751
+ theme.muted(
3752
+ `${stageResult.staged} proposal${stageResult.staged === 1 ? "" : "s"} sent \u2014 ${where}. The next wizard run wires whatever you approve.`
3753
+ )
3754
+ );
3755
+ return NO_ADDITIONS;
3756
+ }
3440
3757
  let result;
3441
3758
  try {
3442
3759
  result = await withSpinner(
@@ -3582,10 +3899,25 @@ async function withBrowserAuth(config) {
3582
3899
  p.log.step(
3583
3900
  theme.bright("Authenticate") + theme.muted(" \u2014 opening your browser to approve this device\u2026")
3584
3901
  );
3902
+ const auth = await startDeviceAuth(config);
3903
+ p.note(
3904
+ [
3905
+ theme.bright(`Your code: ${auth.userCode}`),
3906
+ `${theme.muted("Approval URL:")} ${auth.verificationUrl}`,
3907
+ theme.muted(
3908
+ "We tried to open your browser. If it didn't open, visit that link on any device and enter the code."
3909
+ )
3910
+ ].join("\n"),
3911
+ theme.signal("Approve this device")
3912
+ );
3913
+ try {
3914
+ await open2(auth.verificationUrlComplete ?? auth.verificationUrl);
3915
+ } catch {
3916
+ }
3585
3917
  const spin = p.spinner();
3586
3918
  spin.start("Waiting for you to approve in the browser");
3587
3919
  try {
3588
- const session = await authenticate(config);
3920
+ const session = await auth.poll();
3589
3921
  spin.stop(theme.success("Authenticated \u2713"));
3590
3922
  return session;
3591
3923
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whisperr/wizard",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Whisperr Wizard — one command to integrate the Whisperr SDK into your app. Authenticates with your onboarded account and uses an AI coding agent to wire up identify() and your business events automatically.",
5
5
  "repository": {
6
6
  "type": "git",