@analyticscli/growth-engineer 0.1.1-preview.19 → 0.1.1-preview.21

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.
@@ -2126,6 +2126,9 @@ function getPassingConnectorKeys(payload, failedConnectors = new Set()) {
2126
2126
  }
2127
2127
  function summarizeFailureReason(detail) {
2128
2128
  const text = String(detail || '').replace(/\s+/g, ' ').trim();
2129
+ if (/ASC .*\.p8 private key is invalid|invalid or truncated|sequence truncated|malformed/i.test(text)) {
2130
+ return 'ASC .p8 file is invalid or truncated';
2131
+ }
2129
2132
  if (/token has been revoked/i.test(text))
2130
2133
  return 'token has been revoked';
2131
2134
  if (/unauthorized|UNAUTHORIZED/i.test(text))
@@ -2168,6 +2171,9 @@ function summarizeFailureFix(connector, blockers) {
2168
2171
  return 'Paste a Coolify base URL and read-only API token from Keys & Tokens / API tokens, then rerun setup.';
2169
2172
  }
2170
2173
  if (connector === 'asc') {
2174
+ if (/invalid|truncated|malformed|private key/i.test(combined)) {
2175
+ return 'Choose the original valid AuthKey_<KEY_ID>.p8 file, or paste the full key content from BEGIN PRIVATE KEY to END PRIVATE KEY.';
2176
+ }
2171
2177
  return 'Rerun ASC setup and verify ASC credentials, key role access, and `asc apps list --output json`.';
2172
2178
  }
2173
2179
  if (isAccountSignalConnector(connector)) {
@@ -2216,21 +2222,15 @@ function printConciseSetupBlockers(payload, command, options = {}) {
2216
2222
  process.stdout.write(`Live checks passed: ${passingConnectors.map(connectorTitle).join(', ')}.\n`);
2217
2223
  }
2218
2224
  if (groups.size > 0) {
2219
- process.stdout.write('\nNeeds fix:\n');
2225
+ process.stdout.write('\nNeeds attention:\n');
2220
2226
  for (const [connector, connectorBlockers] of groups.entries()) {
2221
2227
  const primary = connectorBlockers[0] || {};
2222
- const reasons = [
2223
- ...new Set(connectorBlockers
2224
- .map((blocker) => summarizeFailureReason(blocker.detail || blocker.check))
2225
- .filter(Boolean)),
2226
- ];
2227
2228
  process.stdout.write(`- ${connectorTitle(connector)}: ${summarizeFailureReason(primary.detail || primary.check)}\n`);
2228
- process.stdout.write(` Why: ${reasons.join('; ')}.\n`);
2229
2229
  process.stdout.write(` Fix: ${summarizeFailureFix(connector, connectorBlockers)}\n`);
2230
2230
  }
2231
2231
  }
2232
2232
  printDeferredSetupNotes(blockers, focusConnectors);
2233
- if (groups.size > 0 || !options.hideRerunWhenClean) {
2233
+ if (!options.hideRerun && (groups.size > 0 || !options.hideRerunWhenClean)) {
2234
2234
  process.stdout.write(`\nRerun: ${command}\n`);
2235
2235
  }
2236
2236
  }
@@ -2375,9 +2375,9 @@ function isDeferredGitHubFailure(failure) {
2375
2375
  return (name === 'project:github-repo' ||
2376
2376
  (name === 'connection:github' && /project\.githubRepo|repo is missing|repo is not configured/i.test(detail)));
2377
2377
  }
2378
- function healthStatusLabel(status) {
2378
+ function healthStatusLabel(status, spinner = '') {
2379
2379
  if (status === 'running')
2380
- return 'running';
2380
+ return spinner ? `running ${spinner}` : 'running';
2381
2381
  if (status === 'pass')
2382
2382
  return 'done';
2383
2383
  if (status === 'warn')
@@ -2386,18 +2386,27 @@ function healthStatusLabel(status) {
2386
2386
  return 'needs attention';
2387
2387
  if (status === 'deferred')
2388
2388
  return 'deferred';
2389
- return 'pending';
2389
+ return spinner ? `pending ${spinner}` : 'pending';
2390
2390
  }
2391
- function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check') {
2391
+ function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check', options = {}) {
2392
2392
  if (process.stdout.isTTY)
2393
2393
  clearTerminal();
2394
- const finished = items.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
2394
+ const final = Boolean(options.final);
2395
+ const visibleItems = final
2396
+ ? items.filter((item) => !['pending', 'running'].includes(String(item.status || '')) && item.key !== 'finalize')
2397
+ : items;
2398
+ const finished = visibleItems.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
2395
2399
  process.stdout.write(`${title}\n`);
2396
2400
  process.stdout.write('------------\n');
2397
2401
  process.stdout.write(`${message}\n\n`);
2398
- process.stdout.write(`${finished}/${items.length} checks finished.\n\n`);
2399
- for (const item of items) {
2400
- process.stdout.write(`[${healthStatusLabel(item.status)}] ${item.label}: ${item.detail}\n`);
2402
+ if (final) {
2403
+ process.stdout.write('Checks complete.\n\n');
2404
+ }
2405
+ else {
2406
+ process.stdout.write(`${finished}/${visibleItems.length} checks finished.\n\n`);
2407
+ }
2408
+ for (const item of visibleItems) {
2409
+ process.stdout.write(`[${healthStatusLabel(item.status, options.spinner || '')}] ${item.label}: ${item.detail}\n`);
2401
2410
  }
2402
2411
  }
2403
2412
  function updateHealthProgress(items, event) {
@@ -2508,22 +2517,35 @@ function updateProgressItem(items, key, status, detail) {
2508
2517
  }
2509
2518
  async function runSetupCommandWithProgress(command, env, selected, message) {
2510
2519
  const plan = buildSetupTestProgressPlan(selected);
2511
- renderHealthProgress(plan, `${message}\nDo not close this terminal yet.`, 'Connector setup test');
2520
+ const spinnerFrames = ['-', '\\', '|', '/'];
2521
+ let spinnerIndex = 0;
2522
+ let currentMessage = message;
2523
+ const render = (nextMessage = currentMessage, options = {}) => {
2524
+ currentMessage = nextMessage;
2525
+ renderHealthProgress(plan, currentMessage, 'Connector setup test', {
2526
+ ...options,
2527
+ spinner: spinnerFrames[spinnerIndex++ % spinnerFrames.length],
2528
+ });
2529
+ };
2530
+ render(message);
2531
+ const spinnerInterval = process.stdout.isTTY
2532
+ ? setInterval(() => render(currentMessage), 800)
2533
+ : null;
2512
2534
  const progressCommand = command.includes('--progress-json') ? command : `${command} --progress-json`;
2513
2535
  const result = await runCommandCaptureWithProgress(progressCommand, (event) => {
2514
- if (updateHealthProgress(plan, event)) {
2515
- const primaryFinished = primaryProgressItemsFinished(plan);
2516
- if (primaryFinished) {
2517
- updateProgressItem(plan, 'finalize', 'running', 'command still running; parsing final output and follow-up work');
2518
- }
2519
- const message = primaryFinished
2520
- ? 'Checks finished. Finalizing result; do not close this terminal yet.'
2521
- : 'Connector setup test is still running. Do not close this terminal yet.';
2522
- renderHealthProgress(plan, message, 'Connector setup test');
2536
+ if (!updateHealthProgress(plan, event))
2537
+ return;
2538
+ const primaryFinished = primaryProgressItemsFinished(plan);
2539
+ if (primaryFinished) {
2540
+ updateProgressItem(plan, 'finalize', 'running', 'finishing');
2523
2541
  }
2524
- }, { env, timeoutMs: 180_000 });
2542
+ render(primaryFinished ? 'Finishing setup test...' : 'Testing connector setup...');
2543
+ }, { env, timeoutMs: 180_000 }).finally(() => {
2544
+ if (spinnerInterval)
2545
+ clearInterval(spinnerInterval);
2546
+ });
2525
2547
  updateProgressItem(plan, 'finalize', 'pass', 'result received');
2526
- renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test');
2548
+ renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test', { final: true });
2527
2549
  return result;
2528
2550
  }
2529
2551
  async function saveSecretsImmediately(secrets) {
@@ -2556,6 +2578,7 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
2556
2578
  printConciseSetupBlockers(payload, command, {
2557
2579
  focusConnectors: [connector],
2558
2580
  hideRerunWhenClean: true,
2581
+ hideRerun: true,
2559
2582
  });
2560
2583
  const retry = await askYesNo(rl, `Re-enter ${connectorLabel(connector)} configuration now?`, true);
2561
2584
  return { ok: false, retry, result, payload };
@@ -3529,11 +3552,9 @@ function bold(text) {
3529
3552
  }
3530
3553
  async function guideGitHubConnector(rl, secrets) {
3531
3554
  printSection('GitHub code access', [
3532
- 'Use this when OpenClaw should read repo context or create GitHub delivery artifacts.',
3533
- ]);
3534
- printBullets([
3535
- 'Open the token page, select the scopes you want, then paste the token here.',
3536
- 'You can rerun this wizard later to change GitHub permissions.',
3555
+ `${bold('Create token')} here: https://github.com/settings/tokens/new`,
3556
+ `${bold('Scopes')}: public repos = public_repo, private repos/issues/PRs = repo.`,
3557
+ `${bold('Only add workflow')} if OpenClaw should edit GitHub Actions files.`,
3537
3558
  ]);
3538
3559
  let hasGh = await commandExists('gh');
3539
3560
  if (!hasGh) {
@@ -3542,15 +3563,6 @@ async function guideGitHubConnector(rl, secrets) {
3542
3563
  if (hasGh) {
3543
3564
  process.stdout.write('GitHub CLI is available for helper commands.\n\n');
3544
3565
  }
3545
- process.stdout.write('Token URL: https://github.com/settings/tokens/new\n\n');
3546
- process.stdout.write(`${ANSI.bold}Suggested scopes${ANSI.reset}\n`);
3547
- printBullets([
3548
- 'Public repo only: select `public_repo`.',
3549
- 'Private repo access: select `repo` (classic GitHub tokens make private repo access broad).',
3550
- 'Create issues / draft PRs in private repos: `repo` is the relevant classic-token scope.',
3551
- 'Edit GitHub Actions workflow files: add `workflow` only if you explicitly want this.',
3552
- 'Usually do not select: packages, admin:org, hooks, gist, user, delete_repo, enterprise, codespace, copilot.',
3553
- ]);
3554
3566
  const token = await maybePromptSecret(rl, 'Paste GITHUB_TOKEN into this local terminal', 'GITHUB_TOKEN');
3555
3567
  if (token)
3556
3568
  secrets.GITHUB_TOKEN = token;
@@ -3571,12 +3583,10 @@ function shouldForceFreshAnalyticsToken(healthByConnector = {}) {
3571
3583
  return ['blocked', 'partial'].includes(String(health?.status || '')) || /revoked|unauthorized|invalid token/i.test(detail);
3572
3584
  }
3573
3585
  async function guideAnalyticsConnector(rl, secrets, options = {}) {
3574
- printSection('AnalyticsCLI');
3575
- process.stdout.write('Create a readonly CLI token:\n');
3576
- process.stdout.write('1. Open https://dash.analyticscli.com/\n');
3577
- process.stdout.write('2. Account -> API Keys\n');
3578
- process.stdout.write('3. Create Access Token\n');
3579
- process.stdout.write('4. Copy the Readonly CLI Token and paste it below\n\n');
3586
+ printSection('AnalyticsCLI', [
3587
+ `${bold('Create readonly CLI token')}: https://dash.analyticscli.com/`,
3588
+ `${bold('Path')}: Account -> API Keys -> Create Access Token.`,
3589
+ ]);
3580
3590
  const forceFresh = Boolean(options.forceFresh);
3581
3591
  if (forceFresh && process.env.ANALYTICSCLI_ACCESS_TOKEN) {
3582
3592
  process.stdout.write('Stored token failed. Paste a new token.\n\n');
@@ -3593,17 +3603,9 @@ async function guideAnalyticsConnector(rl, secrets, options = {}) {
3593
3603
  }
3594
3604
  async function guideRevenueCatConnector(rl, secrets) {
3595
3605
  printSection('RevenueCat monetization data', [
3596
- 'Use this when OpenClaw should read subscription, product, entitlement, and revenue context.',
3597
- ]);
3598
- process.stdout.write('\nCreate a RevenueCat secret API key here:\n https://app.revenuecat.com/\n\n');
3599
- printBullets([
3600
- 'Select your app.',
3601
- 'In the sidebar, choose "Apps & providers".',
3602
- 'Click "API keys" and generate a new secret API key.',
3603
- 'Name it "analyticscli" and choose API version 2.',
3604
- 'Set Charts metrics permissions to read.',
3605
- 'Set Customer information permissions to read.',
3606
- 'Set Project configuration permissions to read.',
3606
+ `${bold('Create secret API key')}: https://app.revenuecat.com/`,
3607
+ `${bold('Path')}: Apps & providers -> API keys -> New secret key.`,
3608
+ `${bold('Permissions')}: API v2, read for Charts metrics, Customer information, Project configuration.`,
3607
3609
  ]);
3608
3610
  const apiKey = await maybePromptSecret(rl, 'Paste REVENUECAT_API_KEY into this local terminal', 'REVENUECAT_API_KEY');
3609
3611
  if (apiKey)
@@ -3626,17 +3628,9 @@ function paddleTokenEnvForAccount(index, label) {
3626
3628
  }
3627
3629
  async function guidePaddleConnector(rl, secrets) {
3628
3630
  printSection('Paddle Billing metrics', [
3629
- 'Use this when OpenClaw should read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
3630
- ]);
3631
- process.stdout.write('\nCreate or update a scoped Paddle API key here:\n https://vendors.paddle.com/authentication-v2\n\n');
3632
- printBullets([
3633
- 'Open Paddle > Developer Tools > Authentication.',
3634
- 'Use the API keys tab and create a new live API key.',
3635
- 'Minimum: grant `metrics.read` so account-level revenue, MRR, refunds, chargebacks, subscribers, and checkout conversion work.',
3636
- 'Recommended for better Growth Engineer analysis: grant all available read-only permissions (`*.read`), including products, prices, discounts, customers, transactions, subscriptions, adjustments, reports, and notifications.',
3637
- 'Do not grant any write permissions (`*.write`) unless another workflow explicitly needs them.',
3638
- 'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3639
- 'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3631
+ `${bold('Create live API key')}: https://vendors.paddle.com/authentication-v2`,
3632
+ `${bold('Minimum')}: metrics.read. Better: all read-only *.read scopes.`,
3633
+ `${bold('Do not grant write scopes')} unless you explicitly need them elsewhere.`,
3640
3634
  ]);
3641
3635
  const accounts = [];
3642
3636
  let index = 0;
@@ -3663,17 +3657,10 @@ async function guidePaddleConnector(rl, secrets) {
3663
3657
  }
3664
3658
  async function guideSeoConnector(rl, secrets) {
3665
3659
  printSection('SEO / Google Search Console / DataForSEO', [
3666
- 'Use this when OpenClaw should read organic search demand, GSC clicks/impressions/CTR/position, and optional paid keyword ideas.',
3667
- ]);
3668
- process.stdout.write('\nGoogle Search Console:\n https://search.google.com/search-console\nGoogle Cloud service accounts:\n https://console.cloud.google.com/iam-admin/serviceaccounts\nDataForSEO API dashboard:\n https://app.dataforseo.com/api-dashboard\n\n');
3669
- printBullets([
3670
- 'Preferred: give the token/service account access to all Search Console properties you want analyzed.',
3671
- 'Leave the property URL empty to let the exporter list and query all verified GSC properties in the account.',
3672
- 'Enter a property URL only when you intentionally want to restrict analysis to one site.',
3673
- 'For OAuth token mode, paste a read-only Search Console token with `webmasters.readonly` scope.',
3674
- 'For service-account mode, add the service account email as a restricted/full user in Search Console, then set GOOGLE_APPLICATION_CREDENTIALS or GSC_SERVICE_ACCOUNT_JSON outside this wizard.',
3675
- 'DataForSEO is optional and paid. The exporter refuses paid calls unless the source command includes --confirm-paid and a small --max-paid-requests cap.',
3676
- 'CSV-only mode is also supported with --gsc-csv or --csv in sources.seo.command.',
3660
+ `${bold('GSC')}: https://search.google.com/search-console`,
3661
+ `${bold('Service account')}: https://console.cloud.google.com/iam-admin/serviceaccounts`,
3662
+ `${bold('Optional paid keyword data')}: https://app.dataforseo.com/api-dashboard`,
3663
+ `${bold('Default')}: leave property URL empty to use all verified GSC properties.`,
3677
3664
  ]);
3678
3665
  const siteUrl = await ask(rl, 'Optional GSC property URL (empty = all verified properties)', process.env.GSC_SITE_URL || '');
3679
3666
  if (siteUrl.trim())
@@ -3772,11 +3759,10 @@ async function guideAccountSignalConnector(rl, secrets, key) {
3772
3759
  if (!definition)
3773
3760
  return [];
3774
3761
  printSection(definition.label, [
3775
- definition.summary,
3776
- 'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3762
+ `${bold('Docs')}: ${definition.docsUrl}`,
3763
+ `${bold('Setup is account-wide')}. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.`,
3764
+ `${bold('Paste only credentials')} below. The agent discovers accounts/apps/projects later.`,
3777
3765
  ]);
3778
- process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3779
- printBullets(definition.steps);
3780
3766
  const accounts = [];
3781
3767
  let index = 0;
3782
3768
  while (true) {
@@ -3816,8 +3802,8 @@ async function guideAccountSignalConnector(rl, secrets, key) {
3816
3802
  }
3817
3803
  async function guideSentryConnector(rl, secrets) {
3818
3804
  printSection('Sentry / GlitchTip', [
3819
- 'Paste token, org, and base URL. Projects are discovered automatically.',
3820
- 'Use `https://sentry.io` for Sentry Cloud or your GlitchTip/self-hosted base URL.',
3805
+ `${bold('Base URL')}: https://sentry.io for Sentry Cloud, otherwise your GlitchTip/self-hosted URL.`,
3806
+ `${bold('Token + org')} are needed. Project scope remains unpinned.`,
3821
3807
  ]);
3822
3808
  const accounts = [];
3823
3809
  let index = 0;
@@ -3869,7 +3855,7 @@ async function guideSentryConnector(rl, secrets) {
3869
3855
  let verifiedVisibleProjects = false;
3870
3856
  if (discovery.ok && discovery.projects.length > 0) {
3871
3857
  verifiedVisibleProjects = true;
3872
- process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned so OpenClaw/Hermes can decide per run.\n`);
3858
+ process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned.\n`);
3873
3859
  }
3874
3860
  else {
3875
3861
  const fallbackOrgs = discoveredOrganizations
@@ -3926,19 +3912,12 @@ function normalizeCoolifyBaseUrl(value) {
3926
3912
  }
3927
3913
  async function guideCoolifyConnector(rl, secrets) {
3928
3914
  printSection('Coolify deployment monitoring', [
3929
- 'Use this when OpenClaw should read deployment, resource, server, and health-check signals from Coolify.',
3930
- 'The token should be read-only. Do not use "*" or sensitive-token permissions for normal monitoring.',
3915
+ `${bold('Create read-only API token')} in Coolify.`,
3916
+ `${bold('Do not use * or sensitive-token permissions')} for normal monitoring.`,
3931
3917
  ]);
3932
3918
  const baseUrl = normalizeCoolifyBaseUrl(await ask(rl, 'Coolify base URL', process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com'));
3933
3919
  const tokenUrl = baseUrl ? `${baseUrl}/security/api-tokens` : 'https://<your-coolify-host>/security/api-tokens';
3934
- process.stdout.write(`\nToken page: ${tokenUrl}\n\n`);
3935
- printBullets([
3936
- 'Open the Coolify dashboard.',
3937
- 'In the sidebar, go to "Keys & Tokens".',
3938
- 'Open "API tokens".',
3939
- 'Create a new API key/token with read-only permissions.',
3940
- 'Copy the token once and paste it into this local terminal.',
3941
- ]);
3920
+ process.stdout.write(`${bold('Token page')}: ${tokenUrl}\n\n`);
3942
3921
  const token = await maybePromptSecret(rl, 'Paste COOLIFY_API_TOKEN into this local terminal', 'COOLIFY_API_TOKEN');
3943
3922
  if (baseUrl)
3944
3923
  secrets.COOLIFY_BASE_URL = baseUrl;
@@ -3955,7 +3934,7 @@ async function guideAscConnector(rl, secrets) {
3955
3934
  process.stdout.write(`${bold('Enter the Reports key now:')}\n`);
3956
3935
  printBullets([
3957
3936
  `${bold('.p8 path')} to Apple\'s original ${bold('AuthKey_<KEY_ID>.p8')} file. ${bold('Do not rename it')}; KEY_ID is read from the filename.`,
3958
- `${bold('Issuer ID')} from the API keys page.`,
3937
+ `${bold('Issuer ID')} from the API keys page. Same value for both keys.`,
3959
3938
  `${bold('Vendor Number')} from Sales and Trends > Reports.`,
3960
3939
  ]);
3961
3940
  const normalKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
@@ -3969,7 +3948,7 @@ async function guideAscConnector(rl, secrets) {
3969
3948
  secrets.ASC_KEY_ID = keyId;
3970
3949
  process.stdout.write(`Inferred ASC_KEY_ID=${keyId} from ${path.basename(normalKeyPath.privateKeyPath)}.\n`);
3971
3950
  }
3972
- const issuerId = await ask(rl, 'ASC_ISSUER_ID (reports key, empty = skip)', process.env.ASC_ISSUER_ID || '');
3951
+ const issuerId = await ask(rl, 'ASC_ISSUER_ID (same for both keys, empty = skip)', process.env.ASC_ISSUER_ID || '');
3973
3952
  if (issuerId.trim())
3974
3953
  secrets.ASC_ISSUER_ID = issuerId.trim();
3975
3954
  if (!normalKeyPath.privateKeyPath) {
@@ -4007,7 +3986,10 @@ async function guideAscBootstrapAdminKey(rl, issuerIdDefault = '') {
4007
3986
  keyLabel: 'the temporary Admin key',
4008
3987
  });
4009
3988
  let bootstrapKeyId = bootstrapKeyPath.keyId;
4010
- const bootstrapIssuerId = await ask(rl, 'ASC_BOOTSTRAP_ISSUER_ID', issuerIdDefault);
3989
+ let bootstrapIssuerId = String(issuerIdDefault || '').trim();
3990
+ if (!bootstrapIssuerId) {
3991
+ bootstrapIssuerId = await ask(rl, 'ASC_ISSUER_ID (same API keys page)', process.env.ASC_ISSUER_ID || '');
3992
+ }
4011
3993
  if (bootstrapKeyPath.privateKeyPath) {
4012
3994
  bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = bootstrapKeyPath.privateKeyPath;
4013
3995
  process.stdout.write(`Inferred ASC_BOOTSTRAP_KEY_ID=${bootstrapKeyId} from ${path.basename(bootstrapKeyPath.privateKeyPath)}.\n`);